diff --git a/.github/workflows/master.yml b/.github/workflows/master.yml index 4ac46c2929c3..c2eb5c010bf1 100644 --- a/.github/workflows/master.yml +++ b/.github/workflows/master.yml @@ -2860,6 +2860,516 @@ jobs: . ./ci/tmp/praktika_setup_env.sh PYTHONUNBUFFERED=1 python3 -m praktika run 'Stateless tests (arm_binary, sequential)' --workflow "MasterCI" --ci --timestamp + stateless_tests_amd_binary_cas_s3_storage_parallel: + runs-on: [self-hosted, altinity-on-demand, altinity-builder, 16c] + needs: [build_amd_binary, config_workflow, dockers_build_amd, dockers_build_arm, dockers_build_multiplatform_manifest] + if: ${{ !cancelled() && !contains(needs.*.outputs.pipeline_status, 'failure') && !contains(needs.*.outputs.pipeline_status, 'undefined') && !contains(fromJson(needs.config_workflow.outputs.data).workflow_config.cache_success_base64, 'U3RhdGVsZXNzIHRlc3RzIChhbWRfYmluYXJ5LCBjYXMgczMgc3RvcmFnZSwgcGFyYWxsZWwp') }} + name: "Stateless tests (amd_binary, cas s3 storage, parallel)" + outputs: + data: ${{ steps.run.outputs.DATA }} + pipeline_status: ${{ steps.run.outputs.pipeline_status || 'undefined' }} + steps: + - name: Checkout code + uses: actions/checkout@v6 + with: + ref: ${{ env.CHECKOUT_REF }} + + - name: Setup + uses: ./.github/actions/runner_setup + - name: Docker setup + uses: ./.github/actions/docker_setup + with: + test_name: "Stateless tests (amd_binary, cas s3 storage, parallel)" + + - name: Prepare env script + run: | + rm -rf ./ci/tmp + mkdir -p ./ci/tmp + cat > ./ci/tmp/praktika_setup_env.sh << 'ENV_SETUP_SCRIPT_EOF' + export PYTHONPATH=./ci:.: + cat > ./ci/tmp/workflow_inputs.json << 'EOF' + ${{ toJson(github.event.inputs) }} + EOF + cat > ./ci/tmp/workflow_job.json << 'EOF' + ${{ toJson(job) }} + EOF + cat > ./ci/tmp/workflow_status.json << 'EOF' + ${{ toJson(needs) }} + EOF + ENV_SETUP_SCRIPT_EOF + + - name: Download artifact CH_AMD_BINARY_GH + uses: actions/download-artifact@v8 + continue-on-error: true + with: + name: CH_AMD_BINARY_GH + path: ./ci/tmp + + - name: Run + id: run + run: | + . ./ci/tmp/praktika_setup_env.sh + PYTHONUNBUFFERED=1 python3 -m praktika run 'Stateless tests (amd_binary, cas s3 storage, parallel)' --workflow "MasterCI" --ci --timestamp + + stateless_tests_amd_asan_ubsan_cas_s3_storage_parallel_1_2: + runs-on: [self-hosted, altinity-on-demand, altinity-builder, 16c] + needs: [build_amd_asan_ubsan, config_workflow, dockers_build_amd, dockers_build_arm, dockers_build_multiplatform_manifest] + if: ${{ !cancelled() && !contains(needs.*.outputs.pipeline_status, 'failure') && !contains(needs.*.outputs.pipeline_status, 'undefined') && !contains(fromJson(needs.config_workflow.outputs.data).workflow_config.cache_success_base64, 'U3RhdGVsZXNzIHRlc3RzIChhbWRfYXNhbl91YnNhbiwgY2FzIHMzIHN0b3JhZ2UsIHBhcmFsbGVsLCAxLzIp') }} + name: "Stateless tests (amd_asan_ubsan, cas s3 storage, parallel, 1/2)" + outputs: + data: ${{ steps.run.outputs.DATA }} + pipeline_status: ${{ steps.run.outputs.pipeline_status || 'undefined' }} + steps: + - name: Checkout code + uses: actions/checkout@v6 + with: + ref: ${{ env.CHECKOUT_REF }} + + - name: Setup + uses: ./.github/actions/runner_setup + - name: Docker setup + uses: ./.github/actions/docker_setup + with: + test_name: "Stateless tests (amd_asan_ubsan, cas s3 storage, parallel, 1/2)" + + - name: Prepare env script + run: | + rm -rf ./ci/tmp + mkdir -p ./ci/tmp + cat > ./ci/tmp/praktika_setup_env.sh << 'ENV_SETUP_SCRIPT_EOF' + export PYTHONPATH=./ci:.: + cat > ./ci/tmp/workflow_inputs.json << 'EOF' + ${{ toJson(github.event.inputs) }} + EOF + cat > ./ci/tmp/workflow_job.json << 'EOF' + ${{ toJson(job) }} + EOF + cat > ./ci/tmp/workflow_status.json << 'EOF' + ${{ toJson(needs) }} + EOF + ENV_SETUP_SCRIPT_EOF + + - name: Download artifact CH_AMD_ASAN_UBSAN_GH + uses: actions/download-artifact@v8 + continue-on-error: true + with: + name: CH_AMD_ASAN_UBSAN_GH + path: ./ci/tmp + + - name: Run + id: run + run: | + . ./ci/tmp/praktika_setup_env.sh + PYTHONUNBUFFERED=1 python3 -m praktika run 'Stateless tests (amd_asan_ubsan, cas s3 storage, parallel, 1/2)' --workflow "MasterCI" --ci --timestamp + + stateless_tests_amd_asan_ubsan_cas_s3_storage_parallel_2_2: + runs-on: [self-hosted, altinity-on-demand, altinity-builder, 16c] + needs: [build_amd_asan_ubsan, config_workflow, dockers_build_amd, dockers_build_arm, dockers_build_multiplatform_manifest] + if: ${{ !cancelled() && !contains(needs.*.outputs.pipeline_status, 'failure') && !contains(needs.*.outputs.pipeline_status, 'undefined') && !contains(fromJson(needs.config_workflow.outputs.data).workflow_config.cache_success_base64, 'U3RhdGVsZXNzIHRlc3RzIChhbWRfYXNhbl91YnNhbiwgY2FzIHMzIHN0b3JhZ2UsIHBhcmFsbGVsLCAyLzIp') }} + name: "Stateless tests (amd_asan_ubsan, cas s3 storage, parallel, 2/2)" + outputs: + data: ${{ steps.run.outputs.DATA }} + pipeline_status: ${{ steps.run.outputs.pipeline_status || 'undefined' }} + steps: + - name: Checkout code + uses: actions/checkout@v6 + with: + ref: ${{ env.CHECKOUT_REF }} + + - name: Setup + uses: ./.github/actions/runner_setup + - name: Docker setup + uses: ./.github/actions/docker_setup + with: + test_name: "Stateless tests (amd_asan_ubsan, cas s3 storage, parallel, 2/2)" + + - name: Prepare env script + run: | + rm -rf ./ci/tmp + mkdir -p ./ci/tmp + cat > ./ci/tmp/praktika_setup_env.sh << 'ENV_SETUP_SCRIPT_EOF' + export PYTHONPATH=./ci:.: + cat > ./ci/tmp/workflow_inputs.json << 'EOF' + ${{ toJson(github.event.inputs) }} + EOF + cat > ./ci/tmp/workflow_job.json << 'EOF' + ${{ toJson(job) }} + EOF + cat > ./ci/tmp/workflow_status.json << 'EOF' + ${{ toJson(needs) }} + EOF + ENV_SETUP_SCRIPT_EOF + + - name: Download artifact CH_AMD_ASAN_UBSAN_GH + uses: actions/download-artifact@v8 + continue-on-error: true + with: + name: CH_AMD_ASAN_UBSAN_GH + path: ./ci/tmp + + - name: Run + id: run + run: | + . ./ci/tmp/praktika_setup_env.sh + PYTHONUNBUFFERED=1 python3 -m praktika run 'Stateless tests (amd_asan_ubsan, cas s3 storage, parallel, 2/2)' --workflow "MasterCI" --ci --timestamp + + stateless_tests_amd_tsan_cas_s3_storage_parallel_1_2: + runs-on: [self-hosted, altinity-on-demand, altinity-func-tester, 16c] + needs: [build_amd_tsan, config_workflow, dockers_build_amd, dockers_build_arm, dockers_build_multiplatform_manifest] + if: ${{ !cancelled() && !contains(needs.*.outputs.pipeline_status, 'failure') && !contains(needs.*.outputs.pipeline_status, 'undefined') && !contains(fromJson(needs.config_workflow.outputs.data).workflow_config.cache_success_base64, 'U3RhdGVsZXNzIHRlc3RzIChhbWRfdHNhbiwgY2FzIHMzIHN0b3JhZ2UsIHBhcmFsbGVsLCAxLzIp') }} + name: "Stateless tests (amd_tsan, cas s3 storage, parallel, 1/2)" + outputs: + data: ${{ steps.run.outputs.DATA }} + pipeline_status: ${{ steps.run.outputs.pipeline_status || 'undefined' }} + steps: + - name: Checkout code + uses: actions/checkout@v6 + with: + ref: ${{ env.CHECKOUT_REF }} + + - name: Setup + uses: ./.github/actions/runner_setup + - name: Docker setup + uses: ./.github/actions/docker_setup + with: + test_name: "Stateless tests (amd_tsan, cas s3 storage, parallel, 1/2)" + + - name: Prepare env script + run: | + rm -rf ./ci/tmp + mkdir -p ./ci/tmp + cat > ./ci/tmp/praktika_setup_env.sh << 'ENV_SETUP_SCRIPT_EOF' + export PYTHONPATH=./ci:.: + cat > ./ci/tmp/workflow_inputs.json << 'EOF' + ${{ toJson(github.event.inputs) }} + EOF + cat > ./ci/tmp/workflow_job.json << 'EOF' + ${{ toJson(job) }} + EOF + cat > ./ci/tmp/workflow_status.json << 'EOF' + ${{ toJson(needs) }} + EOF + ENV_SETUP_SCRIPT_EOF + + - name: Download artifact CH_AMD_TSAN_GH + uses: actions/download-artifact@v8 + continue-on-error: true + with: + name: CH_AMD_TSAN_GH + path: ./ci/tmp + + - name: Run + id: run + run: | + . ./ci/tmp/praktika_setup_env.sh + PYTHONUNBUFFERED=1 python3 -m praktika run 'Stateless tests (amd_tsan, cas s3 storage, parallel, 1/2)' --workflow "MasterCI" --ci --timestamp + + stateless_tests_amd_tsan_cas_s3_storage_parallel_2_2: + runs-on: [self-hosted, altinity-on-demand, altinity-func-tester, 16c] + needs: [build_amd_tsan, config_workflow, dockers_build_amd, dockers_build_arm, dockers_build_multiplatform_manifest] + if: ${{ !cancelled() && !contains(needs.*.outputs.pipeline_status, 'failure') && !contains(needs.*.outputs.pipeline_status, 'undefined') && !contains(fromJson(needs.config_workflow.outputs.data).workflow_config.cache_success_base64, 'U3RhdGVsZXNzIHRlc3RzIChhbWRfdHNhbiwgY2FzIHMzIHN0b3JhZ2UsIHBhcmFsbGVsLCAyLzIp') }} + name: "Stateless tests (amd_tsan, cas s3 storage, parallel, 2/2)" + outputs: + data: ${{ steps.run.outputs.DATA }} + pipeline_status: ${{ steps.run.outputs.pipeline_status || 'undefined' }} + steps: + - name: Checkout code + uses: actions/checkout@v6 + with: + ref: ${{ env.CHECKOUT_REF }} + + - name: Setup + uses: ./.github/actions/runner_setup + - name: Docker setup + uses: ./.github/actions/docker_setup + with: + test_name: "Stateless tests (amd_tsan, cas s3 storage, parallel, 2/2)" + + - name: Prepare env script + run: | + rm -rf ./ci/tmp + mkdir -p ./ci/tmp + cat > ./ci/tmp/praktika_setup_env.sh << 'ENV_SETUP_SCRIPT_EOF' + export PYTHONPATH=./ci:.: + cat > ./ci/tmp/workflow_inputs.json << 'EOF' + ${{ toJson(github.event.inputs) }} + EOF + cat > ./ci/tmp/workflow_job.json << 'EOF' + ${{ toJson(job) }} + EOF + cat > ./ci/tmp/workflow_status.json << 'EOF' + ${{ toJson(needs) }} + EOF + ENV_SETUP_SCRIPT_EOF + + - name: Download artifact CH_AMD_TSAN_GH + uses: actions/download-artifact@v8 + continue-on-error: true + with: + name: CH_AMD_TSAN_GH + path: ./ci/tmp + + - name: Run + id: run + run: | + . ./ci/tmp/praktika_setup_env.sh + PYTHONUNBUFFERED=1 python3 -m praktika run 'Stateless tests (amd_tsan, cas s3 storage, parallel, 2/2)' --workflow "MasterCI" --ci --timestamp + + stateless_tests_amd_msan_cas_s3_storage_parallel_1_3: + runs-on: [self-hosted, altinity-on-demand, altinity-func-tester] + needs: [build_amd_msan, config_workflow, dockers_build_amd, dockers_build_arm, dockers_build_multiplatform_manifest] + if: ${{ !cancelled() && !contains(needs.*.outputs.pipeline_status, 'failure') && !contains(needs.*.outputs.pipeline_status, 'undefined') && !contains(fromJson(needs.config_workflow.outputs.data).workflow_config.cache_success_base64, 'U3RhdGVsZXNzIHRlc3RzIChhbWRfbXNhbiwgY2FzIHMzIHN0b3JhZ2UsIHBhcmFsbGVsLCAxLzMp') }} + name: "Stateless tests (amd_msan, cas s3 storage, parallel, 1/3)" + outputs: + data: ${{ steps.run.outputs.DATA }} + pipeline_status: ${{ steps.run.outputs.pipeline_status || 'undefined' }} + steps: + - name: Checkout code + uses: actions/checkout@v6 + with: + ref: ${{ env.CHECKOUT_REF }} + + - name: Setup + uses: ./.github/actions/runner_setup + - name: Docker setup + uses: ./.github/actions/docker_setup + with: + test_name: "Stateless tests (amd_msan, cas s3 storage, parallel, 1/3)" + + - name: Prepare env script + run: | + rm -rf ./ci/tmp + mkdir -p ./ci/tmp + cat > ./ci/tmp/praktika_setup_env.sh << 'ENV_SETUP_SCRIPT_EOF' + export PYTHONPATH=./ci:.: + cat > ./ci/tmp/workflow_inputs.json << 'EOF' + ${{ toJson(github.event.inputs) }} + EOF + cat > ./ci/tmp/workflow_job.json << 'EOF' + ${{ toJson(job) }} + EOF + cat > ./ci/tmp/workflow_status.json << 'EOF' + ${{ toJson(needs) }} + EOF + ENV_SETUP_SCRIPT_EOF + + - name: Download artifact CH_AMD_MSAN_GH + uses: actions/download-artifact@v8 + continue-on-error: true + with: + name: CH_AMD_MSAN_GH + path: ./ci/tmp + + - name: Run + id: run + run: | + . ./ci/tmp/praktika_setup_env.sh + PYTHONUNBUFFERED=1 python3 -m praktika run 'Stateless tests (amd_msan, cas s3 storage, parallel, 1/3)' --workflow "MasterCI" --ci --timestamp + + stateless_tests_amd_msan_cas_s3_storage_parallel_2_3: + runs-on: [self-hosted, altinity-on-demand, altinity-func-tester] + needs: [build_amd_msan, config_workflow, dockers_build_amd, dockers_build_arm, dockers_build_multiplatform_manifest] + if: ${{ !cancelled() && !contains(needs.*.outputs.pipeline_status, 'failure') && !contains(needs.*.outputs.pipeline_status, 'undefined') && !contains(fromJson(needs.config_workflow.outputs.data).workflow_config.cache_success_base64, 'U3RhdGVsZXNzIHRlc3RzIChhbWRfbXNhbiwgY2FzIHMzIHN0b3JhZ2UsIHBhcmFsbGVsLCAyLzMp') }} + name: "Stateless tests (amd_msan, cas s3 storage, parallel, 2/3)" + outputs: + data: ${{ steps.run.outputs.DATA }} + pipeline_status: ${{ steps.run.outputs.pipeline_status || 'undefined' }} + steps: + - name: Checkout code + uses: actions/checkout@v6 + with: + ref: ${{ env.CHECKOUT_REF }} + + - name: Setup + uses: ./.github/actions/runner_setup + - name: Docker setup + uses: ./.github/actions/docker_setup + with: + test_name: "Stateless tests (amd_msan, cas s3 storage, parallel, 2/3)" + + - name: Prepare env script + run: | + rm -rf ./ci/tmp + mkdir -p ./ci/tmp + cat > ./ci/tmp/praktika_setup_env.sh << 'ENV_SETUP_SCRIPT_EOF' + export PYTHONPATH=./ci:.: + cat > ./ci/tmp/workflow_inputs.json << 'EOF' + ${{ toJson(github.event.inputs) }} + EOF + cat > ./ci/tmp/workflow_job.json << 'EOF' + ${{ toJson(job) }} + EOF + cat > ./ci/tmp/workflow_status.json << 'EOF' + ${{ toJson(needs) }} + EOF + ENV_SETUP_SCRIPT_EOF + + - name: Download artifact CH_AMD_MSAN_GH + uses: actions/download-artifact@v8 + continue-on-error: true + with: + name: CH_AMD_MSAN_GH + path: ./ci/tmp + + - name: Run + id: run + run: | + . ./ci/tmp/praktika_setup_env.sh + PYTHONUNBUFFERED=1 python3 -m praktika run 'Stateless tests (amd_msan, cas s3 storage, parallel, 2/3)' --workflow "MasterCI" --ci --timestamp + + stateless_tests_amd_msan_cas_s3_storage_parallel_3_3: + runs-on: [self-hosted, altinity-on-demand, altinity-func-tester] + needs: [build_amd_msan, config_workflow, dockers_build_amd, dockers_build_arm, dockers_build_multiplatform_manifest] + if: ${{ !cancelled() && !contains(needs.*.outputs.pipeline_status, 'failure') && !contains(needs.*.outputs.pipeline_status, 'undefined') && !contains(fromJson(needs.config_workflow.outputs.data).workflow_config.cache_success_base64, 'U3RhdGVsZXNzIHRlc3RzIChhbWRfbXNhbiwgY2FzIHMzIHN0b3JhZ2UsIHBhcmFsbGVsLCAzLzMp') }} + name: "Stateless tests (amd_msan, cas s3 storage, parallel, 3/3)" + outputs: + data: ${{ steps.run.outputs.DATA }} + pipeline_status: ${{ steps.run.outputs.pipeline_status || 'undefined' }} + steps: + - name: Checkout code + uses: actions/checkout@v6 + with: + ref: ${{ env.CHECKOUT_REF }} + + - name: Setup + uses: ./.github/actions/runner_setup + - name: Docker setup + uses: ./.github/actions/docker_setup + with: + test_name: "Stateless tests (amd_msan, cas s3 storage, parallel, 3/3)" + + - name: Prepare env script + run: | + rm -rf ./ci/tmp + mkdir -p ./ci/tmp + cat > ./ci/tmp/praktika_setup_env.sh << 'ENV_SETUP_SCRIPT_EOF' + export PYTHONPATH=./ci:.: + cat > ./ci/tmp/workflow_inputs.json << 'EOF' + ${{ toJson(github.event.inputs) }} + EOF + cat > ./ci/tmp/workflow_job.json << 'EOF' + ${{ toJson(job) }} + EOF + cat > ./ci/tmp/workflow_status.json << 'EOF' + ${{ toJson(needs) }} + EOF + ENV_SETUP_SCRIPT_EOF + + - name: Download artifact CH_AMD_MSAN_GH + uses: actions/download-artifact@v8 + continue-on-error: true + with: + name: CH_AMD_MSAN_GH + path: ./ci/tmp + + - name: Run + id: run + run: | + . ./ci/tmp/praktika_setup_env.sh + PYTHONUNBUFFERED=1 python3 -m praktika run 'Stateless tests (amd_msan, cas s3 storage, parallel, 3/3)' --workflow "MasterCI" --ci --timestamp + + stateless_tests_arm_binary_cas_s3_storage_parallel: + runs-on: [self-hosted, altinity-on-demand, altinity-func-tester-aarch64, 16c] + needs: [build_arm_binary, config_workflow, dockers_build_amd, dockers_build_arm, dockers_build_multiplatform_manifest] + if: ${{ !cancelled() && !contains(needs.*.outputs.pipeline_status, 'failure') && !contains(needs.*.outputs.pipeline_status, 'undefined') && !contains(fromJson(needs.config_workflow.outputs.data).workflow_config.cache_success_base64, 'U3RhdGVsZXNzIHRlc3RzIChhcm1fYmluYXJ5LCBjYXMgczMgc3RvcmFnZSwgcGFyYWxsZWwp') }} + name: "Stateless tests (arm_binary, cas s3 storage, parallel)" + outputs: + data: ${{ steps.run.outputs.DATA }} + pipeline_status: ${{ steps.run.outputs.pipeline_status || 'undefined' }} + steps: + - name: Checkout code + uses: actions/checkout@v6 + with: + ref: ${{ env.CHECKOUT_REF }} + + - name: Setup + uses: ./.github/actions/runner_setup + - name: Docker setup + uses: ./.github/actions/docker_setup + with: + test_name: "Stateless tests (arm_binary, cas s3 storage, parallel)" + + - name: Prepare env script + run: | + rm -rf ./ci/tmp + mkdir -p ./ci/tmp + cat > ./ci/tmp/praktika_setup_env.sh << 'ENV_SETUP_SCRIPT_EOF' + export PYTHONPATH=./ci:.: + cat > ./ci/tmp/workflow_inputs.json << 'EOF' + ${{ toJson(github.event.inputs) }} + EOF + cat > ./ci/tmp/workflow_job.json << 'EOF' + ${{ toJson(job) }} + EOF + cat > ./ci/tmp/workflow_status.json << 'EOF' + ${{ toJson(needs) }} + EOF + ENV_SETUP_SCRIPT_EOF + + - name: Download artifact CH_ARM_BIN_GH + uses: actions/download-artifact@v8 + continue-on-error: true + with: + name: CH_ARM_BIN_GH + path: ./ci/tmp + + - name: Run + id: run + run: | + . ./ci/tmp/praktika_setup_env.sh + PYTHONUNBUFFERED=1 python3 -m praktika run 'Stateless tests (arm_binary, cas s3 storage, parallel)' --workflow "MasterCI" --ci --timestamp + + stateless_tests_amd_binary_cas_storage_parallel: + runs-on: [self-hosted, altinity-on-demand, altinity-builder, 16c] + needs: [build_amd_binary, config_workflow, dockers_build_amd, dockers_build_arm, dockers_build_multiplatform_manifest] + if: ${{ !cancelled() && !contains(needs.*.outputs.pipeline_status, 'failure') && !contains(needs.*.outputs.pipeline_status, 'undefined') && !contains(fromJson(needs.config_workflow.outputs.data).workflow_config.cache_success_base64, 'U3RhdGVsZXNzIHRlc3RzIChhbWRfYmluYXJ5LCBjYXMgc3RvcmFnZSwgcGFyYWxsZWwp') }} + name: "Stateless tests (amd_binary, cas storage, parallel)" + outputs: + data: ${{ steps.run.outputs.DATA }} + pipeline_status: ${{ steps.run.outputs.pipeline_status || 'undefined' }} + steps: + - name: Checkout code + uses: actions/checkout@v6 + with: + ref: ${{ env.CHECKOUT_REF }} + + - name: Setup + uses: ./.github/actions/runner_setup + - name: Docker setup + uses: ./.github/actions/docker_setup + with: + test_name: "Stateless tests (amd_binary, cas storage, parallel)" + + - name: Prepare env script + run: | + rm -rf ./ci/tmp + mkdir -p ./ci/tmp + cat > ./ci/tmp/praktika_setup_env.sh << 'ENV_SETUP_SCRIPT_EOF' + export PYTHONPATH=./ci:.: + cat > ./ci/tmp/workflow_inputs.json << 'EOF' + ${{ toJson(github.event.inputs) }} + EOF + cat > ./ci/tmp/workflow_job.json << 'EOF' + ${{ toJson(job) }} + EOF + cat > ./ci/tmp/workflow_status.json << 'EOF' + ${{ toJson(needs) }} + EOF + ENV_SETUP_SCRIPT_EOF + + - name: Download artifact CH_AMD_BINARY_GH + uses: actions/download-artifact@v8 + continue-on-error: true + with: + name: CH_AMD_BINARY_GH + path: ./ci/tmp + + - name: Run + id: run + run: | + . ./ci/tmp/praktika_setup_env.sh + PYTHONUNBUFFERED=1 python3 -m praktika run 'Stateless tests (amd_binary, cas storage, parallel)' --workflow "MasterCI" --ci --timestamp + stateless_tests_amd_llvm_coverage_per_test_per_test_coverage_1_8: runs-on: [self-hosted, altinity-on-demand, altinity-func-tester] needs: [build_amd_llvm_coverage_per_test, config_workflow, dockers_build_amd, dockers_build_arm, dockers_build_multiplatform_manifest] @@ -5995,7 +6505,7 @@ jobs: finish_workflow: runs-on: [self-hosted, altinity-on-demand, altinity-style-checker] - needs: [ast_fuzzer_amd_debug, ast_fuzzer_amd_msan, ast_fuzzer_amd_tsan, ast_fuzzer_arm_asan_ubsan, build_amd_asan_ubsan, build_amd_binary, build_amd_debug, build_amd_llvm_coverage_per_test, build_amd_msan, build_amd_release, build_amd_tsan, build_arm_asan_ubsan, build_arm_binary, build_arm_debug, build_arm_msan, build_arm_release, build_arm_tsan, build_arm_ubsan, buzzhouse_amd_debug, buzzhouse_amd_msan, buzzhouse_amd_tsan, buzzhouse_arm_asan_ubsan, clickbench_amd_release, clickbench_arm_release, compatibility_check_amd_release, compatibility_check_arm_release, config_workflow, docker_keeper_image, docker_server_image, dockers_build_amd, dockers_build_arm, dockers_build_multiplatform_manifest, install_packages_amd_release, install_packages_arm_release, integration_tests_amd_asan_ubsan_db_disk_old_analyzer_1_8, integration_tests_amd_asan_ubsan_db_disk_old_analyzer_2_8, integration_tests_amd_asan_ubsan_db_disk_old_analyzer_3_8, integration_tests_amd_asan_ubsan_db_disk_old_analyzer_4_8, integration_tests_amd_asan_ubsan_db_disk_old_analyzer_5_8, integration_tests_amd_asan_ubsan_db_disk_old_analyzer_6_8, integration_tests_amd_asan_ubsan_db_disk_old_analyzer_7_8, integration_tests_amd_asan_ubsan_db_disk_old_analyzer_8_8, integration_tests_amd_msan_10_10, integration_tests_amd_msan_1_10, integration_tests_amd_msan_2_10, integration_tests_amd_msan_3_10, integration_tests_amd_msan_4_10, integration_tests_amd_msan_5_10, integration_tests_amd_msan_6_10, integration_tests_amd_msan_7_10, integration_tests_amd_msan_8_10, integration_tests_amd_msan_9_10, integration_tests_amd_tsan_1_6, integration_tests_amd_tsan_2_6, integration_tests_amd_tsan_3_6, integration_tests_amd_tsan_4_6, integration_tests_amd_tsan_5_6, integration_tests_amd_tsan_6_6, integration_tests_arm_binary_distributed_plan_1_4, integration_tests_arm_binary_distributed_plan_2_4, integration_tests_arm_binary_distributed_plan_3_4, integration_tests_arm_binary_distributed_plan_4_4, sign_release_amd_release, sign_release_arm_release, source_upload, sqllogic_test, sqlstorm_test, sqltest, stateless_tests_amd_asan_ubsan_db_disk_distributed_plan_sequential_1_2, stateless_tests_amd_asan_ubsan_db_disk_distributed_plan_sequential_2_2, stateless_tests_amd_asan_ubsan_distributed_plan_parallel_1_2, stateless_tests_amd_asan_ubsan_distributed_plan_parallel_2_2, stateless_tests_amd_debug_distributed_plan_s3_storage_parallel, stateless_tests_amd_debug_distributed_plan_s3_storage_sequential, stateless_tests_amd_debug_parallel, stateless_tests_amd_debug_sequential, stateless_tests_amd_llvm_coverage_per_test_per_test_coverage_1_8, stateless_tests_amd_llvm_coverage_per_test_per_test_coverage_2_8, stateless_tests_amd_llvm_coverage_per_test_per_test_coverage_3_8, stateless_tests_amd_llvm_coverage_per_test_per_test_coverage_4_8, stateless_tests_amd_llvm_coverage_per_test_per_test_coverage_5_8, stateless_tests_amd_llvm_coverage_per_test_per_test_coverage_6_8, stateless_tests_amd_llvm_coverage_per_test_per_test_coverage_7_8, stateless_tests_amd_llvm_coverage_per_test_per_test_coverage_8_8, stateless_tests_amd_msan_wasmedge_parallel_1_4, stateless_tests_amd_msan_wasmedge_parallel_2_4, stateless_tests_amd_msan_wasmedge_parallel_3_4, stateless_tests_amd_msan_wasmedge_parallel_4_4, stateless_tests_amd_msan_wasmedge_sequential_1_2, stateless_tests_amd_msan_wasmedge_sequential_2_2, stateless_tests_amd_tsan_parallel_1_2, stateless_tests_amd_tsan_parallel_2_2, stateless_tests_amd_tsan_s3_storage_parallel_1_2, stateless_tests_amd_tsan_s3_storage_parallel_2_2, stateless_tests_amd_tsan_s3_storage_sequential_1_2, stateless_tests_amd_tsan_s3_storage_sequential_2_2, stateless_tests_amd_tsan_sequential_1_2, stateless_tests_amd_tsan_sequential_2_2, stateless_tests_arm_asan_ubsan_azure_parallel, stateless_tests_arm_asan_ubsan_azure_sequential_1_2, stateless_tests_arm_asan_ubsan_azure_sequential_2_2, stateless_tests_arm_binary_parallel, stateless_tests_arm_binary_sequential, stress_test_amd_asan_ubsan, stress_test_amd_debug, stress_test_amd_msan, stress_test_amd_tsan, stress_test_arm_asan_ubsan, stress_test_arm_asan_ubsan_s3, stress_test_arm_debug, stress_test_arm_msan, stress_test_arm_release, stress_test_arm_tsan, stress_test_arm_ubsan, stress_test_azure_amd_msan, stress_test_azure_amd_tsan, unit_tests_asan_ubsan, unit_tests_asan_ubsan_function_prop_fuzzer, unit_tests_msan, unit_tests_msan_function_prop_fuzzer, unit_tests_tsan, unit_tests_tsan_function_prop_fuzzer] + needs: [ast_fuzzer_amd_debug, ast_fuzzer_amd_msan, ast_fuzzer_amd_tsan, ast_fuzzer_arm_asan_ubsan, build_amd_asan_ubsan, build_amd_binary, build_amd_debug, build_amd_llvm_coverage_per_test, build_amd_msan, build_amd_release, build_amd_tsan, build_arm_asan_ubsan, build_arm_binary, build_arm_debug, build_arm_msan, build_arm_release, build_arm_tsan, build_arm_ubsan, buzzhouse_amd_debug, buzzhouse_amd_msan, buzzhouse_amd_tsan, buzzhouse_arm_asan_ubsan, clickbench_amd_release, clickbench_arm_release, compatibility_check_amd_release, compatibility_check_arm_release, config_workflow, docker_keeper_image, docker_server_image, dockers_build_amd, dockers_build_arm, dockers_build_multiplatform_manifest, install_packages_amd_release, install_packages_arm_release, integration_tests_amd_asan_ubsan_db_disk_old_analyzer_1_8, integration_tests_amd_asan_ubsan_db_disk_old_analyzer_2_8, integration_tests_amd_asan_ubsan_db_disk_old_analyzer_3_8, integration_tests_amd_asan_ubsan_db_disk_old_analyzer_4_8, integration_tests_amd_asan_ubsan_db_disk_old_analyzer_5_8, integration_tests_amd_asan_ubsan_db_disk_old_analyzer_6_8, integration_tests_amd_asan_ubsan_db_disk_old_analyzer_7_8, integration_tests_amd_asan_ubsan_db_disk_old_analyzer_8_8, integration_tests_amd_msan_10_10, integration_tests_amd_msan_1_10, integration_tests_amd_msan_2_10, integration_tests_amd_msan_3_10, integration_tests_amd_msan_4_10, integration_tests_amd_msan_5_10, integration_tests_amd_msan_6_10, integration_tests_amd_msan_7_10, integration_tests_amd_msan_8_10, integration_tests_amd_msan_9_10, integration_tests_amd_tsan_1_6, integration_tests_amd_tsan_2_6, integration_tests_amd_tsan_3_6, integration_tests_amd_tsan_4_6, integration_tests_amd_tsan_5_6, integration_tests_amd_tsan_6_6, integration_tests_arm_binary_distributed_plan_1_4, integration_tests_arm_binary_distributed_plan_2_4, integration_tests_arm_binary_distributed_plan_3_4, integration_tests_arm_binary_distributed_plan_4_4, sign_release_amd_release, sign_release_arm_release, source_upload, sqllogic_test, sqlstorm_test, sqltest, stateless_tests_amd_asan_ubsan_cas_s3_storage_parallel_1_2, stateless_tests_amd_asan_ubsan_cas_s3_storage_parallel_2_2, stateless_tests_amd_asan_ubsan_db_disk_distributed_plan_sequential_1_2, stateless_tests_amd_asan_ubsan_db_disk_distributed_plan_sequential_2_2, stateless_tests_amd_asan_ubsan_distributed_plan_parallel_1_2, stateless_tests_amd_asan_ubsan_distributed_plan_parallel_2_2, stateless_tests_amd_binary_cas_s3_storage_parallel, stateless_tests_amd_binary_cas_storage_parallel, stateless_tests_amd_debug_distributed_plan_s3_storage_parallel, stateless_tests_amd_debug_distributed_plan_s3_storage_sequential, stateless_tests_amd_debug_parallel, stateless_tests_amd_debug_sequential, stateless_tests_amd_llvm_coverage_per_test_per_test_coverage_1_8, stateless_tests_amd_llvm_coverage_per_test_per_test_coverage_2_8, stateless_tests_amd_llvm_coverage_per_test_per_test_coverage_3_8, stateless_tests_amd_llvm_coverage_per_test_per_test_coverage_4_8, stateless_tests_amd_llvm_coverage_per_test_per_test_coverage_5_8, stateless_tests_amd_llvm_coverage_per_test_per_test_coverage_6_8, stateless_tests_amd_llvm_coverage_per_test_per_test_coverage_7_8, stateless_tests_amd_llvm_coverage_per_test_per_test_coverage_8_8, stateless_tests_amd_msan_cas_s3_storage_parallel_1_3, stateless_tests_amd_msan_cas_s3_storage_parallel_2_3, stateless_tests_amd_msan_cas_s3_storage_parallel_3_3, stateless_tests_amd_msan_wasmedge_parallel_1_4, stateless_tests_amd_msan_wasmedge_parallel_2_4, stateless_tests_amd_msan_wasmedge_parallel_3_4, stateless_tests_amd_msan_wasmedge_parallel_4_4, stateless_tests_amd_msan_wasmedge_sequential_1_2, stateless_tests_amd_msan_wasmedge_sequential_2_2, stateless_tests_amd_tsan_cas_s3_storage_parallel_1_2, stateless_tests_amd_tsan_cas_s3_storage_parallel_2_2, stateless_tests_amd_tsan_parallel_1_2, stateless_tests_amd_tsan_parallel_2_2, stateless_tests_amd_tsan_s3_storage_parallel_1_2, stateless_tests_amd_tsan_s3_storage_parallel_2_2, stateless_tests_amd_tsan_s3_storage_sequential_1_2, stateless_tests_amd_tsan_s3_storage_sequential_2_2, stateless_tests_amd_tsan_sequential_1_2, stateless_tests_amd_tsan_sequential_2_2, stateless_tests_arm_asan_ubsan_azure_parallel, stateless_tests_arm_asan_ubsan_azure_sequential_1_2, stateless_tests_arm_asan_ubsan_azure_sequential_2_2, stateless_tests_arm_binary_cas_s3_storage_parallel, stateless_tests_arm_binary_parallel, stateless_tests_arm_binary_sequential, stress_test_amd_asan_ubsan, stress_test_amd_debug, stress_test_amd_msan, stress_test_amd_tsan, stress_test_arm_asan_ubsan, stress_test_arm_asan_ubsan_s3, stress_test_arm_debug, stress_test_arm_msan, stress_test_arm_release, stress_test_arm_tsan, stress_test_arm_ubsan, stress_test_azure_amd_msan, stress_test_azure_amd_tsan, unit_tests_asan_ubsan, unit_tests_asan_ubsan_function_prop_fuzzer, unit_tests_msan, unit_tests_msan_function_prop_fuzzer, unit_tests_tsan, unit_tests_tsan_function_prop_fuzzer] if: ${{ !cancelled() && needs.config_workflow.outputs.pipeline_status != '' }} name: "Finish Workflow" outputs: @@ -6148,6 +6658,16 @@ jobs: - stateless_tests_amd_tsan_s3_storage_sequential_2_2 - stateless_tests_arm_binary_parallel - stateless_tests_arm_binary_sequential + - stateless_tests_amd_binary_cas_s3_storage_parallel + - stateless_tests_amd_asan_ubsan_cas_s3_storage_parallel_1_2 + - stateless_tests_amd_asan_ubsan_cas_s3_storage_parallel_2_2 + - stateless_tests_amd_tsan_cas_s3_storage_parallel_1_2 + - stateless_tests_amd_tsan_cas_s3_storage_parallel_2_2 + - stateless_tests_amd_msan_cas_s3_storage_parallel_1_3 + - stateless_tests_amd_msan_cas_s3_storage_parallel_2_3 + - stateless_tests_amd_msan_cas_s3_storage_parallel_3_3 + - stateless_tests_arm_binary_cas_s3_storage_parallel + - stateless_tests_amd_binary_cas_storage_parallel - stateless_tests_amd_llvm_coverage_per_test_per_test_coverage_1_8 - stateless_tests_amd_llvm_coverage_per_test_per_test_coverage_2_8 - stateless_tests_amd_llvm_coverage_per_test_per_test_coverage_3_8 diff --git a/.github/workflows/pull_request.yml b/.github/workflows/pull_request.yml index 07f1a6856aa6..f3ac34c197d7 100644 --- a/.github/workflows/pull_request.yml +++ b/.github/workflows/pull_request.yml @@ -2405,6 +2405,496 @@ jobs: . ./ci/tmp/praktika_setup_env.sh PYTHONUNBUFFERED=1 python3 -m praktika run 'Stateless tests (arm_binary, sequential)' --workflow "PR" --ci --timestamp + stateless_tests_amd_binary_cas_s3_storage_parallel: + runs-on: [self-hosted, altinity-on-demand, altinity-builder, 16c] + needs: [build_amd_asan_ubsan, build_amd_binary, build_amd_debug, build_amd_tsan, build_arm_binary, ci_tests, config_workflow, dockers_build_amd, dockers_build_arm, dockers_build_multiplatform_manifest, fast_test, stateless_tests_amd_asan_ubsan_distributed_plan_parallel_1_2, stateless_tests_amd_asan_ubsan_distributed_plan_parallel_2_2, stateless_tests_amd_debug_parallel, stateless_tests_amd_tsan_parallel_1_2, stateless_tests_amd_tsan_parallel_2_2, stateless_tests_arm_binary_parallel] + if: ${{ !cancelled() && !contains(needs.*.outputs.pipeline_status, 'failure') && !contains(needs.*.outputs.pipeline_status, 'undefined') && !contains(fromJson(needs.config_workflow.outputs.data).workflow_config.cache_success_base64, 'U3RhdGVsZXNzIHRlc3RzIChhbWRfYmluYXJ5LCBjYXMgczMgc3RvcmFnZSwgcGFyYWxsZWwp') }} + name: "Stateless tests (amd_binary, cas s3 storage, parallel)" + outputs: + data: ${{ steps.run.outputs.DATA }} + pipeline_status: ${{ steps.run.outputs.pipeline_status || 'undefined' }} + steps: + - name: Checkout code + uses: actions/checkout@v6 + with: + ref: ${{ env.CHECKOUT_REF }} + + - name: Setup + uses: ./.github/actions/runner_setup + - name: Docker setup + uses: ./.github/actions/docker_setup + with: + test_name: "Stateless tests (amd_binary, cas s3 storage, parallel)" + + - name: Prepare env script + run: | + rm -rf ./ci/tmp + mkdir -p ./ci/tmp + cat > ./ci/tmp/praktika_setup_env.sh << 'ENV_SETUP_SCRIPT_EOF' + export PYTHONPATH=./ci:.: + + cat > ./ci/tmp/workflow_job.json << 'EOF' + ${{ toJson(job) }} + EOF + cat > ./ci/tmp/workflow_status.json << 'EOF' + ${{ toJson(needs) }} + EOF + ENV_SETUP_SCRIPT_EOF + + - name: Download artifact CH_AMD_BINARY_GH + uses: actions/download-artifact@v8 + continue-on-error: true + with: + name: CH_AMD_BINARY_GH + path: ./ci/tmp + + - name: Run + id: run + run: | + . ./ci/tmp/praktika_setup_env.sh + PYTHONUNBUFFERED=1 python3 -m praktika run 'Stateless tests (amd_binary, cas s3 storage, parallel)' --workflow "PR" --ci --timestamp + + stateless_tests_amd_asan_ubsan_cas_s3_storage_parallel_1_2: + runs-on: [self-hosted, altinity-on-demand, altinity-builder, 16c] + needs: [build_amd_asan_ubsan, build_amd_debug, build_amd_tsan, build_arm_binary, ci_tests, config_workflow, dockers_build_amd, dockers_build_arm, dockers_build_multiplatform_manifest, fast_test, stateless_tests_amd_asan_ubsan_distributed_plan_parallel_1_2, stateless_tests_amd_asan_ubsan_distributed_plan_parallel_2_2, stateless_tests_amd_debug_parallel, stateless_tests_amd_tsan_parallel_1_2, stateless_tests_amd_tsan_parallel_2_2, stateless_tests_arm_binary_parallel] + if: ${{ !cancelled() && !contains(needs.*.outputs.pipeline_status, 'failure') && !contains(needs.*.outputs.pipeline_status, 'undefined') && !contains(fromJson(needs.config_workflow.outputs.data).workflow_config.cache_success_base64, 'U3RhdGVsZXNzIHRlc3RzIChhbWRfYXNhbl91YnNhbiwgY2FzIHMzIHN0b3JhZ2UsIHBhcmFsbGVsLCAxLzIp') }} + name: "Stateless tests (amd_asan_ubsan, cas s3 storage, parallel, 1/2)" + outputs: + data: ${{ steps.run.outputs.DATA }} + pipeline_status: ${{ steps.run.outputs.pipeline_status || 'undefined' }} + steps: + - name: Checkout code + uses: actions/checkout@v6 + with: + ref: ${{ env.CHECKOUT_REF }} + + - name: Setup + uses: ./.github/actions/runner_setup + - name: Docker setup + uses: ./.github/actions/docker_setup + with: + test_name: "Stateless tests (amd_asan_ubsan, cas s3 storage, parallel, 1/2)" + + - name: Prepare env script + run: | + rm -rf ./ci/tmp + mkdir -p ./ci/tmp + cat > ./ci/tmp/praktika_setup_env.sh << 'ENV_SETUP_SCRIPT_EOF' + export PYTHONPATH=./ci:.: + + cat > ./ci/tmp/workflow_job.json << 'EOF' + ${{ toJson(job) }} + EOF + cat > ./ci/tmp/workflow_status.json << 'EOF' + ${{ toJson(needs) }} + EOF + ENV_SETUP_SCRIPT_EOF + + - name: Download artifact CH_AMD_ASAN_UBSAN_GH + uses: actions/download-artifact@v8 + continue-on-error: true + with: + name: CH_AMD_ASAN_UBSAN_GH + path: ./ci/tmp + + - name: Run + id: run + run: | + . ./ci/tmp/praktika_setup_env.sh + PYTHONUNBUFFERED=1 python3 -m praktika run 'Stateless tests (amd_asan_ubsan, cas s3 storage, parallel, 1/2)' --workflow "PR" --ci --timestamp + + stateless_tests_amd_asan_ubsan_cas_s3_storage_parallel_2_2: + runs-on: [self-hosted, altinity-on-demand, altinity-builder, 16c] + needs: [build_amd_asan_ubsan, build_amd_debug, build_amd_tsan, build_arm_binary, ci_tests, config_workflow, dockers_build_amd, dockers_build_arm, dockers_build_multiplatform_manifest, fast_test, stateless_tests_amd_asan_ubsan_distributed_plan_parallel_1_2, stateless_tests_amd_asan_ubsan_distributed_plan_parallel_2_2, stateless_tests_amd_debug_parallel, stateless_tests_amd_tsan_parallel_1_2, stateless_tests_amd_tsan_parallel_2_2, stateless_tests_arm_binary_parallel] + if: ${{ !cancelled() && !contains(needs.*.outputs.pipeline_status, 'failure') && !contains(needs.*.outputs.pipeline_status, 'undefined') && !contains(fromJson(needs.config_workflow.outputs.data).workflow_config.cache_success_base64, 'U3RhdGVsZXNzIHRlc3RzIChhbWRfYXNhbl91YnNhbiwgY2FzIHMzIHN0b3JhZ2UsIHBhcmFsbGVsLCAyLzIp') }} + name: "Stateless tests (amd_asan_ubsan, cas s3 storage, parallel, 2/2)" + outputs: + data: ${{ steps.run.outputs.DATA }} + pipeline_status: ${{ steps.run.outputs.pipeline_status || 'undefined' }} + steps: + - name: Checkout code + uses: actions/checkout@v6 + with: + ref: ${{ env.CHECKOUT_REF }} + + - name: Setup + uses: ./.github/actions/runner_setup + - name: Docker setup + uses: ./.github/actions/docker_setup + with: + test_name: "Stateless tests (amd_asan_ubsan, cas s3 storage, parallel, 2/2)" + + - name: Prepare env script + run: | + rm -rf ./ci/tmp + mkdir -p ./ci/tmp + cat > ./ci/tmp/praktika_setup_env.sh << 'ENV_SETUP_SCRIPT_EOF' + export PYTHONPATH=./ci:.: + + cat > ./ci/tmp/workflow_job.json << 'EOF' + ${{ toJson(job) }} + EOF + cat > ./ci/tmp/workflow_status.json << 'EOF' + ${{ toJson(needs) }} + EOF + ENV_SETUP_SCRIPT_EOF + + - name: Download artifact CH_AMD_ASAN_UBSAN_GH + uses: actions/download-artifact@v8 + continue-on-error: true + with: + name: CH_AMD_ASAN_UBSAN_GH + path: ./ci/tmp + + - name: Run + id: run + run: | + . ./ci/tmp/praktika_setup_env.sh + PYTHONUNBUFFERED=1 python3 -m praktika run 'Stateless tests (amd_asan_ubsan, cas s3 storage, parallel, 2/2)' --workflow "PR" --ci --timestamp + + stateless_tests_amd_tsan_cas_s3_storage_parallel_1_2: + runs-on: [self-hosted, altinity-on-demand, altinity-func-tester, 16c] + needs: [build_amd_asan_ubsan, build_amd_debug, build_amd_tsan, build_arm_binary, ci_tests, config_workflow, dockers_build_amd, dockers_build_arm, dockers_build_multiplatform_manifest, fast_test, stateless_tests_amd_asan_ubsan_distributed_plan_parallel_1_2, stateless_tests_amd_asan_ubsan_distributed_plan_parallel_2_2, stateless_tests_amd_debug_parallel, stateless_tests_amd_tsan_parallel_1_2, stateless_tests_amd_tsan_parallel_2_2, stateless_tests_arm_binary_parallel] + if: ${{ !cancelled() && !contains(needs.*.outputs.pipeline_status, 'failure') && !contains(needs.*.outputs.pipeline_status, 'undefined') && !contains(fromJson(needs.config_workflow.outputs.data).workflow_config.cache_success_base64, 'U3RhdGVsZXNzIHRlc3RzIChhbWRfdHNhbiwgY2FzIHMzIHN0b3JhZ2UsIHBhcmFsbGVsLCAxLzIp') }} + name: "Stateless tests (amd_tsan, cas s3 storage, parallel, 1/2)" + outputs: + data: ${{ steps.run.outputs.DATA }} + pipeline_status: ${{ steps.run.outputs.pipeline_status || 'undefined' }} + steps: + - name: Checkout code + uses: actions/checkout@v6 + with: + ref: ${{ env.CHECKOUT_REF }} + + - name: Setup + uses: ./.github/actions/runner_setup + - name: Docker setup + uses: ./.github/actions/docker_setup + with: + test_name: "Stateless tests (amd_tsan, cas s3 storage, parallel, 1/2)" + + - name: Prepare env script + run: | + rm -rf ./ci/tmp + mkdir -p ./ci/tmp + cat > ./ci/tmp/praktika_setup_env.sh << 'ENV_SETUP_SCRIPT_EOF' + export PYTHONPATH=./ci:.: + + cat > ./ci/tmp/workflow_job.json << 'EOF' + ${{ toJson(job) }} + EOF + cat > ./ci/tmp/workflow_status.json << 'EOF' + ${{ toJson(needs) }} + EOF + ENV_SETUP_SCRIPT_EOF + + - name: Download artifact CH_AMD_TSAN_GH + uses: actions/download-artifact@v8 + continue-on-error: true + with: + name: CH_AMD_TSAN_GH + path: ./ci/tmp + + - name: Run + id: run + run: | + . ./ci/tmp/praktika_setup_env.sh + PYTHONUNBUFFERED=1 python3 -m praktika run 'Stateless tests (amd_tsan, cas s3 storage, parallel, 1/2)' --workflow "PR" --ci --timestamp + + stateless_tests_amd_tsan_cas_s3_storage_parallel_2_2: + runs-on: [self-hosted, altinity-on-demand, altinity-func-tester, 16c] + needs: [build_amd_asan_ubsan, build_amd_debug, build_amd_tsan, build_arm_binary, ci_tests, config_workflow, dockers_build_amd, dockers_build_arm, dockers_build_multiplatform_manifest, fast_test, stateless_tests_amd_asan_ubsan_distributed_plan_parallel_1_2, stateless_tests_amd_asan_ubsan_distributed_plan_parallel_2_2, stateless_tests_amd_debug_parallel, stateless_tests_amd_tsan_parallel_1_2, stateless_tests_amd_tsan_parallel_2_2, stateless_tests_arm_binary_parallel] + if: ${{ !cancelled() && !contains(needs.*.outputs.pipeline_status, 'failure') && !contains(needs.*.outputs.pipeline_status, 'undefined') && !contains(fromJson(needs.config_workflow.outputs.data).workflow_config.cache_success_base64, 'U3RhdGVsZXNzIHRlc3RzIChhbWRfdHNhbiwgY2FzIHMzIHN0b3JhZ2UsIHBhcmFsbGVsLCAyLzIp') }} + name: "Stateless tests (amd_tsan, cas s3 storage, parallel, 2/2)" + outputs: + data: ${{ steps.run.outputs.DATA }} + pipeline_status: ${{ steps.run.outputs.pipeline_status || 'undefined' }} + steps: + - name: Checkout code + uses: actions/checkout@v6 + with: + ref: ${{ env.CHECKOUT_REF }} + + - name: Setup + uses: ./.github/actions/runner_setup + - name: Docker setup + uses: ./.github/actions/docker_setup + with: + test_name: "Stateless tests (amd_tsan, cas s3 storage, parallel, 2/2)" + + - name: Prepare env script + run: | + rm -rf ./ci/tmp + mkdir -p ./ci/tmp + cat > ./ci/tmp/praktika_setup_env.sh << 'ENV_SETUP_SCRIPT_EOF' + export PYTHONPATH=./ci:.: + + cat > ./ci/tmp/workflow_job.json << 'EOF' + ${{ toJson(job) }} + EOF + cat > ./ci/tmp/workflow_status.json << 'EOF' + ${{ toJson(needs) }} + EOF + ENV_SETUP_SCRIPT_EOF + + - name: Download artifact CH_AMD_TSAN_GH + uses: actions/download-artifact@v8 + continue-on-error: true + with: + name: CH_AMD_TSAN_GH + path: ./ci/tmp + + - name: Run + id: run + run: | + . ./ci/tmp/praktika_setup_env.sh + PYTHONUNBUFFERED=1 python3 -m praktika run 'Stateless tests (amd_tsan, cas s3 storage, parallel, 2/2)' --workflow "PR" --ci --timestamp + + stateless_tests_amd_msan_cas_s3_storage_parallel_1_3: + runs-on: [self-hosted, altinity-on-demand, altinity-func-tester] + needs: [build_amd_asan_ubsan, build_amd_debug, build_amd_msan, build_amd_tsan, build_arm_binary, ci_tests, config_workflow, dockers_build_amd, dockers_build_arm, dockers_build_multiplatform_manifest, fast_test, stateless_tests_amd_asan_ubsan_distributed_plan_parallel_1_2, stateless_tests_amd_asan_ubsan_distributed_plan_parallel_2_2, stateless_tests_amd_debug_parallel, stateless_tests_amd_tsan_parallel_1_2, stateless_tests_amd_tsan_parallel_2_2, stateless_tests_arm_binary_parallel] + if: ${{ !cancelled() && !contains(needs.*.outputs.pipeline_status, 'failure') && !contains(needs.*.outputs.pipeline_status, 'undefined') && !contains(fromJson(needs.config_workflow.outputs.data).workflow_config.cache_success_base64, 'U3RhdGVsZXNzIHRlc3RzIChhbWRfbXNhbiwgY2FzIHMzIHN0b3JhZ2UsIHBhcmFsbGVsLCAxLzMp') }} + name: "Stateless tests (amd_msan, cas s3 storage, parallel, 1/3)" + outputs: + data: ${{ steps.run.outputs.DATA }} + pipeline_status: ${{ steps.run.outputs.pipeline_status || 'undefined' }} + steps: + - name: Checkout code + uses: actions/checkout@v6 + with: + ref: ${{ env.CHECKOUT_REF }} + + - name: Setup + uses: ./.github/actions/runner_setup + - name: Docker setup + uses: ./.github/actions/docker_setup + with: + test_name: "Stateless tests (amd_msan, cas s3 storage, parallel, 1/3)" + + - name: Prepare env script + run: | + rm -rf ./ci/tmp + mkdir -p ./ci/tmp + cat > ./ci/tmp/praktika_setup_env.sh << 'ENV_SETUP_SCRIPT_EOF' + export PYTHONPATH=./ci:.: + + cat > ./ci/tmp/workflow_job.json << 'EOF' + ${{ toJson(job) }} + EOF + cat > ./ci/tmp/workflow_status.json << 'EOF' + ${{ toJson(needs) }} + EOF + ENV_SETUP_SCRIPT_EOF + + - name: Download artifact CH_AMD_MSAN_GH + uses: actions/download-artifact@v8 + continue-on-error: true + with: + name: CH_AMD_MSAN_GH + path: ./ci/tmp + + - name: Run + id: run + run: | + . ./ci/tmp/praktika_setup_env.sh + PYTHONUNBUFFERED=1 python3 -m praktika run 'Stateless tests (amd_msan, cas s3 storage, parallel, 1/3)' --workflow "PR" --ci --timestamp + + stateless_tests_amd_msan_cas_s3_storage_parallel_2_3: + runs-on: [self-hosted, altinity-on-demand, altinity-func-tester] + needs: [build_amd_asan_ubsan, build_amd_debug, build_amd_msan, build_amd_tsan, build_arm_binary, ci_tests, config_workflow, dockers_build_amd, dockers_build_arm, dockers_build_multiplatform_manifest, fast_test, stateless_tests_amd_asan_ubsan_distributed_plan_parallel_1_2, stateless_tests_amd_asan_ubsan_distributed_plan_parallel_2_2, stateless_tests_amd_debug_parallel, stateless_tests_amd_tsan_parallel_1_2, stateless_tests_amd_tsan_parallel_2_2, stateless_tests_arm_binary_parallel] + if: ${{ !cancelled() && !contains(needs.*.outputs.pipeline_status, 'failure') && !contains(needs.*.outputs.pipeline_status, 'undefined') && !contains(fromJson(needs.config_workflow.outputs.data).workflow_config.cache_success_base64, 'U3RhdGVsZXNzIHRlc3RzIChhbWRfbXNhbiwgY2FzIHMzIHN0b3JhZ2UsIHBhcmFsbGVsLCAyLzMp') }} + name: "Stateless tests (amd_msan, cas s3 storage, parallel, 2/3)" + outputs: + data: ${{ steps.run.outputs.DATA }} + pipeline_status: ${{ steps.run.outputs.pipeline_status || 'undefined' }} + steps: + - name: Checkout code + uses: actions/checkout@v6 + with: + ref: ${{ env.CHECKOUT_REF }} + + - name: Setup + uses: ./.github/actions/runner_setup + - name: Docker setup + uses: ./.github/actions/docker_setup + with: + test_name: "Stateless tests (amd_msan, cas s3 storage, parallel, 2/3)" + + - name: Prepare env script + run: | + rm -rf ./ci/tmp + mkdir -p ./ci/tmp + cat > ./ci/tmp/praktika_setup_env.sh << 'ENV_SETUP_SCRIPT_EOF' + export PYTHONPATH=./ci:.: + + cat > ./ci/tmp/workflow_job.json << 'EOF' + ${{ toJson(job) }} + EOF + cat > ./ci/tmp/workflow_status.json << 'EOF' + ${{ toJson(needs) }} + EOF + ENV_SETUP_SCRIPT_EOF + + - name: Download artifact CH_AMD_MSAN_GH + uses: actions/download-artifact@v8 + continue-on-error: true + with: + name: CH_AMD_MSAN_GH + path: ./ci/tmp + + - name: Run + id: run + run: | + . ./ci/tmp/praktika_setup_env.sh + PYTHONUNBUFFERED=1 python3 -m praktika run 'Stateless tests (amd_msan, cas s3 storage, parallel, 2/3)' --workflow "PR" --ci --timestamp + + stateless_tests_amd_msan_cas_s3_storage_parallel_3_3: + runs-on: [self-hosted, altinity-on-demand, altinity-func-tester] + needs: [build_amd_asan_ubsan, build_amd_debug, build_amd_msan, build_amd_tsan, build_arm_binary, ci_tests, config_workflow, dockers_build_amd, dockers_build_arm, dockers_build_multiplatform_manifest, fast_test, stateless_tests_amd_asan_ubsan_distributed_plan_parallel_1_2, stateless_tests_amd_asan_ubsan_distributed_plan_parallel_2_2, stateless_tests_amd_debug_parallel, stateless_tests_amd_tsan_parallel_1_2, stateless_tests_amd_tsan_parallel_2_2, stateless_tests_arm_binary_parallel] + if: ${{ !cancelled() && !contains(needs.*.outputs.pipeline_status, 'failure') && !contains(needs.*.outputs.pipeline_status, 'undefined') && !contains(fromJson(needs.config_workflow.outputs.data).workflow_config.cache_success_base64, 'U3RhdGVsZXNzIHRlc3RzIChhbWRfbXNhbiwgY2FzIHMzIHN0b3JhZ2UsIHBhcmFsbGVsLCAzLzMp') }} + name: "Stateless tests (amd_msan, cas s3 storage, parallel, 3/3)" + outputs: + data: ${{ steps.run.outputs.DATA }} + pipeline_status: ${{ steps.run.outputs.pipeline_status || 'undefined' }} + steps: + - name: Checkout code + uses: actions/checkout@v6 + with: + ref: ${{ env.CHECKOUT_REF }} + + - name: Setup + uses: ./.github/actions/runner_setup + - name: Docker setup + uses: ./.github/actions/docker_setup + with: + test_name: "Stateless tests (amd_msan, cas s3 storage, parallel, 3/3)" + + - name: Prepare env script + run: | + rm -rf ./ci/tmp + mkdir -p ./ci/tmp + cat > ./ci/tmp/praktika_setup_env.sh << 'ENV_SETUP_SCRIPT_EOF' + export PYTHONPATH=./ci:.: + + cat > ./ci/tmp/workflow_job.json << 'EOF' + ${{ toJson(job) }} + EOF + cat > ./ci/tmp/workflow_status.json << 'EOF' + ${{ toJson(needs) }} + EOF + ENV_SETUP_SCRIPT_EOF + + - name: Download artifact CH_AMD_MSAN_GH + uses: actions/download-artifact@v8 + continue-on-error: true + with: + name: CH_AMD_MSAN_GH + path: ./ci/tmp + + - name: Run + id: run + run: | + . ./ci/tmp/praktika_setup_env.sh + PYTHONUNBUFFERED=1 python3 -m praktika run 'Stateless tests (amd_msan, cas s3 storage, parallel, 3/3)' --workflow "PR" --ci --timestamp + + stateless_tests_arm_binary_cas_s3_storage_parallel: + runs-on: [self-hosted, altinity-on-demand, altinity-func-tester-aarch64, 16c] + needs: [build_amd_asan_ubsan, build_amd_debug, build_amd_tsan, build_arm_binary, ci_tests, config_workflow, dockers_build_amd, dockers_build_arm, dockers_build_multiplatform_manifest, fast_test, stateless_tests_amd_asan_ubsan_distributed_plan_parallel_1_2, stateless_tests_amd_asan_ubsan_distributed_plan_parallel_2_2, stateless_tests_amd_debug_parallel, stateless_tests_amd_tsan_parallel_1_2, stateless_tests_amd_tsan_parallel_2_2, stateless_tests_arm_binary_parallel] + if: ${{ !cancelled() && !contains(needs.*.outputs.pipeline_status, 'failure') && !contains(needs.*.outputs.pipeline_status, 'undefined') && !contains(fromJson(needs.config_workflow.outputs.data).workflow_config.cache_success_base64, 'U3RhdGVsZXNzIHRlc3RzIChhcm1fYmluYXJ5LCBjYXMgczMgc3RvcmFnZSwgcGFyYWxsZWwp') }} + name: "Stateless tests (arm_binary, cas s3 storage, parallel)" + outputs: + data: ${{ steps.run.outputs.DATA }} + pipeline_status: ${{ steps.run.outputs.pipeline_status || 'undefined' }} + steps: + - name: Checkout code + uses: actions/checkout@v6 + with: + ref: ${{ env.CHECKOUT_REF }} + + - name: Setup + uses: ./.github/actions/runner_setup + - name: Docker setup + uses: ./.github/actions/docker_setup + with: + test_name: "Stateless tests (arm_binary, cas s3 storage, parallel)" + + - name: Prepare env script + run: | + rm -rf ./ci/tmp + mkdir -p ./ci/tmp + cat > ./ci/tmp/praktika_setup_env.sh << 'ENV_SETUP_SCRIPT_EOF' + export PYTHONPATH=./ci:.: + + cat > ./ci/tmp/workflow_job.json << 'EOF' + ${{ toJson(job) }} + EOF + cat > ./ci/tmp/workflow_status.json << 'EOF' + ${{ toJson(needs) }} + EOF + ENV_SETUP_SCRIPT_EOF + + - name: Download artifact CH_ARM_BIN_GH + uses: actions/download-artifact@v8 + continue-on-error: true + with: + name: CH_ARM_BIN_GH + path: ./ci/tmp + + - name: Run + id: run + run: | + . ./ci/tmp/praktika_setup_env.sh + PYTHONUNBUFFERED=1 python3 -m praktika run 'Stateless tests (arm_binary, cas s3 storage, parallel)' --workflow "PR" --ci --timestamp + + stateless_tests_amd_binary_cas_storage_parallel: + runs-on: [self-hosted, altinity-on-demand, altinity-builder, 16c] + needs: [build_amd_asan_ubsan, build_amd_binary, build_amd_debug, build_amd_tsan, build_arm_binary, ci_tests, config_workflow, dockers_build_amd, dockers_build_arm, dockers_build_multiplatform_manifest, fast_test, stateless_tests_amd_asan_ubsan_distributed_plan_parallel_1_2, stateless_tests_amd_asan_ubsan_distributed_plan_parallel_2_2, stateless_tests_amd_debug_parallel, stateless_tests_amd_tsan_parallel_1_2, stateless_tests_amd_tsan_parallel_2_2, stateless_tests_arm_binary_parallel] + if: ${{ !cancelled() && !contains(needs.*.outputs.pipeline_status, 'failure') && !contains(needs.*.outputs.pipeline_status, 'undefined') && !contains(fromJson(needs.config_workflow.outputs.data).workflow_config.cache_success_base64, 'U3RhdGVsZXNzIHRlc3RzIChhbWRfYmluYXJ5LCBjYXMgc3RvcmFnZSwgcGFyYWxsZWwp') }} + name: "Stateless tests (amd_binary, cas storage, parallel)" + outputs: + data: ${{ steps.run.outputs.DATA }} + pipeline_status: ${{ steps.run.outputs.pipeline_status || 'undefined' }} + steps: + - name: Checkout code + uses: actions/checkout@v6 + with: + ref: ${{ env.CHECKOUT_REF }} + + - name: Setup + uses: ./.github/actions/runner_setup + - name: Docker setup + uses: ./.github/actions/docker_setup + with: + test_name: "Stateless tests (amd_binary, cas storage, parallel)" + + - name: Prepare env script + run: | + rm -rf ./ci/tmp + mkdir -p ./ci/tmp + cat > ./ci/tmp/praktika_setup_env.sh << 'ENV_SETUP_SCRIPT_EOF' + export PYTHONPATH=./ci:.: + + cat > ./ci/tmp/workflow_job.json << 'EOF' + ${{ toJson(job) }} + EOF + cat > ./ci/tmp/workflow_status.json << 'EOF' + ${{ toJson(needs) }} + EOF + ENV_SETUP_SCRIPT_EOF + + - name: Download artifact CH_AMD_BINARY_GH + uses: actions/download-artifact@v8 + continue-on-error: true + with: + name: CH_AMD_BINARY_GH + path: ./ci/tmp + + - name: Run + id: run + run: | + . ./ci/tmp/praktika_setup_env.sh + PYTHONUNBUFFERED=1 python3 -m praktika run 'Stateless tests (amd_binary, cas storage, parallel)' --workflow "PR" --ci --timestamp + stateless_tests_arm_asan_ubsan_azure_parallel: runs-on: [self-hosted, altinity-on-demand, altinity-func-tester-aarch64, 16c] needs: [build_amd_asan_ubsan, build_amd_debug, build_amd_tsan, build_arm_asan_ubsan, build_arm_binary, ci_tests, config_workflow, dockers_build_amd, dockers_build_arm, dockers_build_multiplatform_manifest, fast_test, stateless_tests_amd_asan_ubsan_distributed_plan_parallel_1_2, stateless_tests_amd_asan_ubsan_distributed_plan_parallel_2_2, stateless_tests_amd_debug_parallel, stateless_tests_amd_tsan_parallel_1_2, stateless_tests_amd_tsan_parallel_2_2, stateless_tests_arm_binary_parallel] @@ -5545,7 +6035,7 @@ jobs: finish_workflow: runs-on: [self-hosted, altinity-on-demand, altinity-style-checker] - needs: [ast_fuzzer_amd_debug, ast_fuzzer_amd_debug_targeted, ast_fuzzer_amd_debug_targeted_old_compatibility, ast_fuzzer_amd_msan, ast_fuzzer_amd_tsan, ast_fuzzer_arm_asan_ubsan, build_amd_asan_ubsan, build_amd_binary, build_amd_debug, build_amd_msan, build_amd_release, build_amd_tsan, build_arm_asan_ubsan, build_arm_binary, build_arm_debug, build_arm_msan, build_arm_release, build_arm_tsan, build_arm_ubsan, build_toolchain_pgo_bolt_aarch64, build_toolchain_pgo_bolt_amd64, buzzhouse_amd_debug, buzzhouse_amd_msan, buzzhouse_amd_tsan, buzzhouse_arm_asan_ubsan, ci_tests, compatibility_check_amd_release, compatibility_check_arm_release, config_workflow, docker_keeper_image, docker_server_image, dockers_build_amd, dockers_build_arm, dockers_build_multiplatform_manifest, fast_test, install_packages_amd_release, install_packages_arm_release, integration_tests_amd_asan_ubsan_db_disk_old_analyzer_1_8, integration_tests_amd_asan_ubsan_db_disk_old_analyzer_2_8, integration_tests_amd_asan_ubsan_db_disk_old_analyzer_3_8, integration_tests_amd_asan_ubsan_db_disk_old_analyzer_4_8, integration_tests_amd_asan_ubsan_db_disk_old_analyzer_5_8, integration_tests_amd_asan_ubsan_db_disk_old_analyzer_6_8, integration_tests_amd_asan_ubsan_db_disk_old_analyzer_7_8, integration_tests_amd_asan_ubsan_db_disk_old_analyzer_8_8, integration_tests_amd_asan_ubsan_targeted, integration_tests_amd_msan_10_10, integration_tests_amd_msan_1_10, integration_tests_amd_msan_2_10, integration_tests_amd_msan_3_10, integration_tests_amd_msan_4_10, integration_tests_amd_msan_5_10, integration_tests_amd_msan_6_10, integration_tests_amd_msan_7_10, integration_tests_amd_msan_8_10, integration_tests_amd_msan_9_10, integration_tests_amd_tsan_1_6, integration_tests_amd_tsan_2_6, integration_tests_amd_tsan_3_6, integration_tests_amd_tsan_4_6, integration_tests_amd_tsan_5_6, integration_tests_amd_tsan_6_6, integration_tests_arm_binary_distributed_plan_1_4, integration_tests_arm_binary_distributed_plan_2_4, integration_tests_arm_binary_distributed_plan_3_4, integration_tests_arm_binary_distributed_plan_4_4, keeper_stress_tests_pr, quick_functional_tests, source_upload, sqllogic_test, sqlstorm_test, stateless_tests_amd_asan_ubsan_db_disk_distributed_plan_sequential_1_2, stateless_tests_amd_asan_ubsan_db_disk_distributed_plan_sequential_2_2, stateless_tests_amd_asan_ubsan_distributed_plan_parallel_1_2, stateless_tests_amd_asan_ubsan_distributed_plan_parallel_2_2, stateless_tests_amd_debug_distributed_plan_s3_storage_parallel, stateless_tests_amd_debug_distributed_plan_s3_storage_sequential, stateless_tests_amd_debug_parallel, stateless_tests_amd_debug_sequential, stateless_tests_amd_msan_wasmedge_parallel_1_4, stateless_tests_amd_msan_wasmedge_parallel_2_4, stateless_tests_amd_msan_wasmedge_parallel_3_4, stateless_tests_amd_msan_wasmedge_parallel_4_4, stateless_tests_amd_msan_wasmedge_sequential_1_2, stateless_tests_amd_msan_wasmedge_sequential_2_2, stateless_tests_amd_tsan_parallel_1_2, stateless_tests_amd_tsan_parallel_2_2, stateless_tests_amd_tsan_s3_storage_parallel_1_2, stateless_tests_amd_tsan_s3_storage_parallel_2_2, stateless_tests_amd_tsan_s3_storage_sequential_1_2, stateless_tests_amd_tsan_s3_storage_sequential_2_2, stateless_tests_amd_tsan_sequential_1_2, stateless_tests_amd_tsan_sequential_2_2, stateless_tests_arm_asan_ubsan_azure_parallel, stateless_tests_arm_asan_ubsan_azure_sequential_1_2, stateless_tests_arm_asan_ubsan_azure_sequential_2_2, stateless_tests_arm_asan_ubsan_targeted, stateless_tests_arm_binary_parallel, stateless_tests_arm_binary_sequential, stress_test_amd_asan_ubsan, stress_test_amd_debug, stress_test_amd_msan, stress_test_amd_tsan, stress_test_arm_asan_ubsan, stress_test_arm_asan_ubsan_s3, stress_test_arm_debug, stress_test_arm_msan, stress_test_arm_release, stress_test_arm_tsan, stress_test_arm_ubsan, unit_tests_asan_ubsan, unit_tests_asan_ubsan_function_prop_fuzzer, unit_tests_msan, unit_tests_msan_function_prop_fuzzer, unit_tests_tsan, unit_tests_tsan_function_prop_fuzzer] + needs: [ast_fuzzer_amd_debug, ast_fuzzer_amd_debug_targeted, ast_fuzzer_amd_debug_targeted_old_compatibility, ast_fuzzer_amd_msan, ast_fuzzer_amd_tsan, ast_fuzzer_arm_asan_ubsan, build_amd_asan_ubsan, build_amd_binary, build_amd_debug, build_amd_msan, build_amd_release, build_amd_tsan, build_arm_asan_ubsan, build_arm_binary, build_arm_debug, build_arm_msan, build_arm_release, build_arm_tsan, build_arm_ubsan, build_toolchain_pgo_bolt_aarch64, build_toolchain_pgo_bolt_amd64, buzzhouse_amd_debug, buzzhouse_amd_msan, buzzhouse_amd_tsan, buzzhouse_arm_asan_ubsan, ci_tests, compatibility_check_amd_release, compatibility_check_arm_release, config_workflow, docker_keeper_image, docker_server_image, dockers_build_amd, dockers_build_arm, dockers_build_multiplatform_manifest, fast_test, install_packages_amd_release, install_packages_arm_release, integration_tests_amd_asan_ubsan_db_disk_old_analyzer_1_8, integration_tests_amd_asan_ubsan_db_disk_old_analyzer_2_8, integration_tests_amd_asan_ubsan_db_disk_old_analyzer_3_8, integration_tests_amd_asan_ubsan_db_disk_old_analyzer_4_8, integration_tests_amd_asan_ubsan_db_disk_old_analyzer_5_8, integration_tests_amd_asan_ubsan_db_disk_old_analyzer_6_8, integration_tests_amd_asan_ubsan_db_disk_old_analyzer_7_8, integration_tests_amd_asan_ubsan_db_disk_old_analyzer_8_8, integration_tests_amd_asan_ubsan_targeted, integration_tests_amd_msan_10_10, integration_tests_amd_msan_1_10, integration_tests_amd_msan_2_10, integration_tests_amd_msan_3_10, integration_tests_amd_msan_4_10, integration_tests_amd_msan_5_10, integration_tests_amd_msan_6_10, integration_tests_amd_msan_7_10, integration_tests_amd_msan_8_10, integration_tests_amd_msan_9_10, integration_tests_amd_tsan_1_6, integration_tests_amd_tsan_2_6, integration_tests_amd_tsan_3_6, integration_tests_amd_tsan_4_6, integration_tests_amd_tsan_5_6, integration_tests_amd_tsan_6_6, integration_tests_arm_binary_distributed_plan_1_4, integration_tests_arm_binary_distributed_plan_2_4, integration_tests_arm_binary_distributed_plan_3_4, integration_tests_arm_binary_distributed_plan_4_4, keeper_stress_tests_pr, quick_functional_tests, source_upload, sqllogic_test, sqlstorm_test, stateless_tests_amd_asan_ubsan_cas_s3_storage_parallel_1_2, stateless_tests_amd_asan_ubsan_cas_s3_storage_parallel_2_2, stateless_tests_amd_asan_ubsan_db_disk_distributed_plan_sequential_1_2, stateless_tests_amd_asan_ubsan_db_disk_distributed_plan_sequential_2_2, stateless_tests_amd_asan_ubsan_distributed_plan_parallel_1_2, stateless_tests_amd_asan_ubsan_distributed_plan_parallel_2_2, stateless_tests_amd_binary_cas_s3_storage_parallel, stateless_tests_amd_binary_cas_storage_parallel, stateless_tests_amd_debug_distributed_plan_s3_storage_parallel, stateless_tests_amd_debug_distributed_plan_s3_storage_sequential, stateless_tests_amd_debug_parallel, stateless_tests_amd_debug_sequential, stateless_tests_amd_msan_cas_s3_storage_parallel_1_3, stateless_tests_amd_msan_cas_s3_storage_parallel_2_3, stateless_tests_amd_msan_cas_s3_storage_parallel_3_3, stateless_tests_amd_msan_wasmedge_parallel_1_4, stateless_tests_amd_msan_wasmedge_parallel_2_4, stateless_tests_amd_msan_wasmedge_parallel_3_4, stateless_tests_amd_msan_wasmedge_parallel_4_4, stateless_tests_amd_msan_wasmedge_sequential_1_2, stateless_tests_amd_msan_wasmedge_sequential_2_2, stateless_tests_amd_tsan_cas_s3_storage_parallel_1_2, stateless_tests_amd_tsan_cas_s3_storage_parallel_2_2, stateless_tests_amd_tsan_parallel_1_2, stateless_tests_amd_tsan_parallel_2_2, stateless_tests_amd_tsan_s3_storage_parallel_1_2, stateless_tests_amd_tsan_s3_storage_parallel_2_2, stateless_tests_amd_tsan_s3_storage_sequential_1_2, stateless_tests_amd_tsan_s3_storage_sequential_2_2, stateless_tests_amd_tsan_sequential_1_2, stateless_tests_amd_tsan_sequential_2_2, stateless_tests_arm_asan_ubsan_azure_parallel, stateless_tests_arm_asan_ubsan_azure_sequential_1_2, stateless_tests_arm_asan_ubsan_azure_sequential_2_2, stateless_tests_arm_asan_ubsan_targeted, stateless_tests_arm_binary_cas_s3_storage_parallel, stateless_tests_arm_binary_parallel, stateless_tests_arm_binary_sequential, stress_test_amd_asan_ubsan, stress_test_amd_debug, stress_test_amd_msan, stress_test_amd_tsan, stress_test_arm_asan_ubsan, stress_test_arm_asan_ubsan_s3, stress_test_arm_debug, stress_test_arm_msan, stress_test_arm_release, stress_test_arm_tsan, stress_test_arm_ubsan, unit_tests_asan_ubsan, unit_tests_asan_ubsan_function_prop_fuzzer, unit_tests_msan, unit_tests_msan_function_prop_fuzzer, unit_tests_tsan, unit_tests_tsan_function_prop_fuzzer] if: ${{ !cancelled() && needs.config_workflow.outputs.pipeline_status != '' }} name: "Finish Workflow" outputs: @@ -5687,6 +6177,16 @@ jobs: - stateless_tests_amd_tsan_s3_storage_sequential_2_2 - stateless_tests_arm_binary_parallel - stateless_tests_arm_binary_sequential + - stateless_tests_amd_binary_cas_s3_storage_parallel + - stateless_tests_amd_asan_ubsan_cas_s3_storage_parallel_1_2 + - stateless_tests_amd_asan_ubsan_cas_s3_storage_parallel_2_2 + - stateless_tests_amd_tsan_cas_s3_storage_parallel_1_2 + - stateless_tests_amd_tsan_cas_s3_storage_parallel_2_2 + - stateless_tests_amd_msan_cas_s3_storage_parallel_1_3 + - stateless_tests_amd_msan_cas_s3_storage_parallel_2_3 + - stateless_tests_amd_msan_cas_s3_storage_parallel_3_3 + - stateless_tests_arm_binary_cas_s3_storage_parallel + - stateless_tests_amd_binary_cas_storage_parallel - stateless_tests_arm_asan_ubsan_azure_parallel - stateless_tests_arm_asan_ubsan_azure_sequential_1_2 - stateless_tests_arm_asan_ubsan_azure_sequential_2_2 diff --git a/.github/workflows/pull_request_community.yml b/.github/workflows/pull_request_community.yml index 6f81498b69bd..29eec6d222e5 100644 --- a/.github/workflows/pull_request_community.yml +++ b/.github/workflows/pull_request_community.yml @@ -2073,6 +2073,486 @@ jobs: . ./ci/tmp/praktika_setup_env.sh PYTHONUNBUFFERED=1 python3 -m praktika run 'Stateless tests (arm_binary, sequential)' --workflow "Community PR" --ci --timestamp + stateless_tests_amd_binary_cas_s3_storage_parallel: + runs-on: [self-hosted, altinity-on-demand, altinity-builder, 16c] + needs: [build_amd_binary, build_amd_debug, build_arm_binary, config_workflow, fast_test, stateless_tests_amd_debug_parallel, stateless_tests_arm_binary_parallel] + if: ${{ !cancelled() && !contains(needs.*.outputs.pipeline_status, 'failure') && !contains(needs.*.outputs.pipeline_status, 'undefined') && !contains(fromJson(needs.config_workflow.outputs.data).workflow_config.cache_success_base64, 'U3RhdGVsZXNzIHRlc3RzIChhbWRfYmluYXJ5LCBjYXMgczMgc3RvcmFnZSwgcGFyYWxsZWwp') }} + name: "Stateless tests (amd_binary, cas s3 storage, parallel)" + outputs: + data: ${{ steps.run.outputs.DATA }} + pipeline_status: ${{ steps.run.outputs.pipeline_status || 'undefined' }} + steps: + - name: Checkout code + uses: actions/checkout@v6 + with: + ref: ${{ env.CHECKOUT_REF }} + + - name: Setup + uses: ./.github/actions/runner_setup + - name: Docker setup + uses: ./.github/actions/docker_setup + with: + test_name: "Stateless tests (amd_binary, cas s3 storage, parallel)" + + - name: Prepare env script + run: | + rm -rf ./ci/tmp + mkdir -p ./ci/tmp + cat > ./ci/tmp/praktika_setup_env.sh << 'ENV_SETUP_SCRIPT_EOF' + export PYTHONPATH=./ci:.: + + cat > ./ci/tmp/workflow_job.json << 'EOF' + ${{ toJson(job) }} + EOF + cat > ./ci/tmp/workflow_status.json << 'EOF' + ${{ toJson(needs) }} + EOF + ENV_SETUP_SCRIPT_EOF + + - name: Download artifact CH_AMD_BINARY + uses: actions/download-artifact@v8 + with: + name: CH_AMD_BINARY + path: ./ci/tmp + + - name: Run + id: run + run: | + . ./ci/tmp/praktika_setup_env.sh + PYTHONUNBUFFERED=1 python3 -m praktika run 'Stateless tests (amd_binary, cas s3 storage, parallel)' --workflow "Community PR" --ci --timestamp + + stateless_tests_amd_asan_ubsan_cas_s3_storage_parallel_1_2: + runs-on: [self-hosted, altinity-on-demand, altinity-builder, 16c] + needs: [build_amd_asan_ubsan, build_amd_debug, build_arm_binary, config_workflow, fast_test, stateless_tests_amd_debug_parallel, stateless_tests_arm_binary_parallel] + if: ${{ !cancelled() && !contains(needs.*.outputs.pipeline_status, 'failure') && !contains(needs.*.outputs.pipeline_status, 'undefined') && !contains(fromJson(needs.config_workflow.outputs.data).workflow_config.cache_success_base64, 'U3RhdGVsZXNzIHRlc3RzIChhbWRfYXNhbl91YnNhbiwgY2FzIHMzIHN0b3JhZ2UsIHBhcmFsbGVsLCAxLzIp') }} + name: "Stateless tests (amd_asan_ubsan, cas s3 storage, parallel, 1/2)" + outputs: + data: ${{ steps.run.outputs.DATA }} + pipeline_status: ${{ steps.run.outputs.pipeline_status || 'undefined' }} + steps: + - name: Checkout code + uses: actions/checkout@v6 + with: + ref: ${{ env.CHECKOUT_REF }} + + - name: Setup + uses: ./.github/actions/runner_setup + - name: Docker setup + uses: ./.github/actions/docker_setup + with: + test_name: "Stateless tests (amd_asan_ubsan, cas s3 storage, parallel, 1/2)" + + - name: Prepare env script + run: | + rm -rf ./ci/tmp + mkdir -p ./ci/tmp + cat > ./ci/tmp/praktika_setup_env.sh << 'ENV_SETUP_SCRIPT_EOF' + export PYTHONPATH=./ci:.: + + cat > ./ci/tmp/workflow_job.json << 'EOF' + ${{ toJson(job) }} + EOF + cat > ./ci/tmp/workflow_status.json << 'EOF' + ${{ toJson(needs) }} + EOF + ENV_SETUP_SCRIPT_EOF + + - name: Download artifact CH_AMD_ASAN_UBSAN + uses: actions/download-artifact@v8 + with: + name: CH_AMD_ASAN_UBSAN + path: ./ci/tmp + + - name: Run + id: run + run: | + . ./ci/tmp/praktika_setup_env.sh + PYTHONUNBUFFERED=1 python3 -m praktika run 'Stateless tests (amd_asan_ubsan, cas s3 storage, parallel, 1/2)' --workflow "Community PR" --ci --timestamp + + stateless_tests_amd_asan_ubsan_cas_s3_storage_parallel_2_2: + runs-on: [self-hosted, altinity-on-demand, altinity-builder, 16c] + needs: [build_amd_asan_ubsan, build_amd_debug, build_arm_binary, config_workflow, fast_test, stateless_tests_amd_debug_parallel, stateless_tests_arm_binary_parallel] + if: ${{ !cancelled() && !contains(needs.*.outputs.pipeline_status, 'failure') && !contains(needs.*.outputs.pipeline_status, 'undefined') && !contains(fromJson(needs.config_workflow.outputs.data).workflow_config.cache_success_base64, 'U3RhdGVsZXNzIHRlc3RzIChhbWRfYXNhbl91YnNhbiwgY2FzIHMzIHN0b3JhZ2UsIHBhcmFsbGVsLCAyLzIp') }} + name: "Stateless tests (amd_asan_ubsan, cas s3 storage, parallel, 2/2)" + outputs: + data: ${{ steps.run.outputs.DATA }} + pipeline_status: ${{ steps.run.outputs.pipeline_status || 'undefined' }} + steps: + - name: Checkout code + uses: actions/checkout@v6 + with: + ref: ${{ env.CHECKOUT_REF }} + + - name: Setup + uses: ./.github/actions/runner_setup + - name: Docker setup + uses: ./.github/actions/docker_setup + with: + test_name: "Stateless tests (amd_asan_ubsan, cas s3 storage, parallel, 2/2)" + + - name: Prepare env script + run: | + rm -rf ./ci/tmp + mkdir -p ./ci/tmp + cat > ./ci/tmp/praktika_setup_env.sh << 'ENV_SETUP_SCRIPT_EOF' + export PYTHONPATH=./ci:.: + + cat > ./ci/tmp/workflow_job.json << 'EOF' + ${{ toJson(job) }} + EOF + cat > ./ci/tmp/workflow_status.json << 'EOF' + ${{ toJson(needs) }} + EOF + ENV_SETUP_SCRIPT_EOF + + - name: Download artifact CH_AMD_ASAN_UBSAN + uses: actions/download-artifact@v8 + with: + name: CH_AMD_ASAN_UBSAN + path: ./ci/tmp + + - name: Run + id: run + run: | + . ./ci/tmp/praktika_setup_env.sh + PYTHONUNBUFFERED=1 python3 -m praktika run 'Stateless tests (amd_asan_ubsan, cas s3 storage, parallel, 2/2)' --workflow "Community PR" --ci --timestamp + + stateless_tests_amd_tsan_cas_s3_storage_parallel_1_2: + runs-on: [self-hosted, altinity-on-demand, altinity-func-tester, 16c] + needs: [build_amd_debug, build_amd_tsan, build_arm_binary, config_workflow, fast_test, stateless_tests_amd_debug_parallel, stateless_tests_arm_binary_parallel] + if: ${{ !cancelled() && !contains(needs.*.outputs.pipeline_status, 'failure') && !contains(needs.*.outputs.pipeline_status, 'undefined') && !contains(fromJson(needs.config_workflow.outputs.data).workflow_config.cache_success_base64, 'U3RhdGVsZXNzIHRlc3RzIChhbWRfdHNhbiwgY2FzIHMzIHN0b3JhZ2UsIHBhcmFsbGVsLCAxLzIp') }} + name: "Stateless tests (amd_tsan, cas s3 storage, parallel, 1/2)" + outputs: + data: ${{ steps.run.outputs.DATA }} + pipeline_status: ${{ steps.run.outputs.pipeline_status || 'undefined' }} + steps: + - name: Checkout code + uses: actions/checkout@v6 + with: + ref: ${{ env.CHECKOUT_REF }} + + - name: Setup + uses: ./.github/actions/runner_setup + - name: Docker setup + uses: ./.github/actions/docker_setup + with: + test_name: "Stateless tests (amd_tsan, cas s3 storage, parallel, 1/2)" + + - name: Prepare env script + run: | + rm -rf ./ci/tmp + mkdir -p ./ci/tmp + cat > ./ci/tmp/praktika_setup_env.sh << 'ENV_SETUP_SCRIPT_EOF' + export PYTHONPATH=./ci:.: + + cat > ./ci/tmp/workflow_job.json << 'EOF' + ${{ toJson(job) }} + EOF + cat > ./ci/tmp/workflow_status.json << 'EOF' + ${{ toJson(needs) }} + EOF + ENV_SETUP_SCRIPT_EOF + + - name: Download artifact CH_AMD_TSAN + uses: actions/download-artifact@v8 + with: + name: CH_AMD_TSAN + path: ./ci/tmp + + - name: Run + id: run + run: | + . ./ci/tmp/praktika_setup_env.sh + PYTHONUNBUFFERED=1 python3 -m praktika run 'Stateless tests (amd_tsan, cas s3 storage, parallel, 1/2)' --workflow "Community PR" --ci --timestamp + + stateless_tests_amd_tsan_cas_s3_storage_parallel_2_2: + runs-on: [self-hosted, altinity-on-demand, altinity-func-tester, 16c] + needs: [build_amd_debug, build_amd_tsan, build_arm_binary, config_workflow, fast_test, stateless_tests_amd_debug_parallel, stateless_tests_arm_binary_parallel] + if: ${{ !cancelled() && !contains(needs.*.outputs.pipeline_status, 'failure') && !contains(needs.*.outputs.pipeline_status, 'undefined') && !contains(fromJson(needs.config_workflow.outputs.data).workflow_config.cache_success_base64, 'U3RhdGVsZXNzIHRlc3RzIChhbWRfdHNhbiwgY2FzIHMzIHN0b3JhZ2UsIHBhcmFsbGVsLCAyLzIp') }} + name: "Stateless tests (amd_tsan, cas s3 storage, parallel, 2/2)" + outputs: + data: ${{ steps.run.outputs.DATA }} + pipeline_status: ${{ steps.run.outputs.pipeline_status || 'undefined' }} + steps: + - name: Checkout code + uses: actions/checkout@v6 + with: + ref: ${{ env.CHECKOUT_REF }} + + - name: Setup + uses: ./.github/actions/runner_setup + - name: Docker setup + uses: ./.github/actions/docker_setup + with: + test_name: "Stateless tests (amd_tsan, cas s3 storage, parallel, 2/2)" + + - name: Prepare env script + run: | + rm -rf ./ci/tmp + mkdir -p ./ci/tmp + cat > ./ci/tmp/praktika_setup_env.sh << 'ENV_SETUP_SCRIPT_EOF' + export PYTHONPATH=./ci:.: + + cat > ./ci/tmp/workflow_job.json << 'EOF' + ${{ toJson(job) }} + EOF + cat > ./ci/tmp/workflow_status.json << 'EOF' + ${{ toJson(needs) }} + EOF + ENV_SETUP_SCRIPT_EOF + + - name: Download artifact CH_AMD_TSAN + uses: actions/download-artifact@v8 + with: + name: CH_AMD_TSAN + path: ./ci/tmp + + - name: Run + id: run + run: | + . ./ci/tmp/praktika_setup_env.sh + PYTHONUNBUFFERED=1 python3 -m praktika run 'Stateless tests (amd_tsan, cas s3 storage, parallel, 2/2)' --workflow "Community PR" --ci --timestamp + + stateless_tests_amd_msan_cas_s3_storage_parallel_1_3: + runs-on: [self-hosted, altinity-on-demand, altinity-func-tester] + needs: [build_amd_debug, build_amd_msan, build_arm_binary, config_workflow, fast_test, stateless_tests_amd_debug_parallel, stateless_tests_arm_binary_parallel] + if: ${{ !cancelled() && !contains(needs.*.outputs.pipeline_status, 'failure') && !contains(needs.*.outputs.pipeline_status, 'undefined') && !contains(fromJson(needs.config_workflow.outputs.data).workflow_config.cache_success_base64, 'U3RhdGVsZXNzIHRlc3RzIChhbWRfbXNhbiwgY2FzIHMzIHN0b3JhZ2UsIHBhcmFsbGVsLCAxLzMp') }} + name: "Stateless tests (amd_msan, cas s3 storage, parallel, 1/3)" + outputs: + data: ${{ steps.run.outputs.DATA }} + pipeline_status: ${{ steps.run.outputs.pipeline_status || 'undefined' }} + steps: + - name: Checkout code + uses: actions/checkout@v6 + with: + ref: ${{ env.CHECKOUT_REF }} + + - name: Setup + uses: ./.github/actions/runner_setup + - name: Docker setup + uses: ./.github/actions/docker_setup + with: + test_name: "Stateless tests (amd_msan, cas s3 storage, parallel, 1/3)" + + - name: Prepare env script + run: | + rm -rf ./ci/tmp + mkdir -p ./ci/tmp + cat > ./ci/tmp/praktika_setup_env.sh << 'ENV_SETUP_SCRIPT_EOF' + export PYTHONPATH=./ci:.: + + cat > ./ci/tmp/workflow_job.json << 'EOF' + ${{ toJson(job) }} + EOF + cat > ./ci/tmp/workflow_status.json << 'EOF' + ${{ toJson(needs) }} + EOF + ENV_SETUP_SCRIPT_EOF + + - name: Download artifact CH_AMD_MSAN + uses: actions/download-artifact@v8 + with: + name: CH_AMD_MSAN + path: ./ci/tmp + + - name: Run + id: run + run: | + . ./ci/tmp/praktika_setup_env.sh + PYTHONUNBUFFERED=1 python3 -m praktika run 'Stateless tests (amd_msan, cas s3 storage, parallel, 1/3)' --workflow "Community PR" --ci --timestamp + + stateless_tests_amd_msan_cas_s3_storage_parallel_2_3: + runs-on: [self-hosted, altinity-on-demand, altinity-func-tester] + needs: [build_amd_debug, build_amd_msan, build_arm_binary, config_workflow, fast_test, stateless_tests_amd_debug_parallel, stateless_tests_arm_binary_parallel] + if: ${{ !cancelled() && !contains(needs.*.outputs.pipeline_status, 'failure') && !contains(needs.*.outputs.pipeline_status, 'undefined') && !contains(fromJson(needs.config_workflow.outputs.data).workflow_config.cache_success_base64, 'U3RhdGVsZXNzIHRlc3RzIChhbWRfbXNhbiwgY2FzIHMzIHN0b3JhZ2UsIHBhcmFsbGVsLCAyLzMp') }} + name: "Stateless tests (amd_msan, cas s3 storage, parallel, 2/3)" + outputs: + data: ${{ steps.run.outputs.DATA }} + pipeline_status: ${{ steps.run.outputs.pipeline_status || 'undefined' }} + steps: + - name: Checkout code + uses: actions/checkout@v6 + with: + ref: ${{ env.CHECKOUT_REF }} + + - name: Setup + uses: ./.github/actions/runner_setup + - name: Docker setup + uses: ./.github/actions/docker_setup + with: + test_name: "Stateless tests (amd_msan, cas s3 storage, parallel, 2/3)" + + - name: Prepare env script + run: | + rm -rf ./ci/tmp + mkdir -p ./ci/tmp + cat > ./ci/tmp/praktika_setup_env.sh << 'ENV_SETUP_SCRIPT_EOF' + export PYTHONPATH=./ci:.: + + cat > ./ci/tmp/workflow_job.json << 'EOF' + ${{ toJson(job) }} + EOF + cat > ./ci/tmp/workflow_status.json << 'EOF' + ${{ toJson(needs) }} + EOF + ENV_SETUP_SCRIPT_EOF + + - name: Download artifact CH_AMD_MSAN + uses: actions/download-artifact@v8 + with: + name: CH_AMD_MSAN + path: ./ci/tmp + + - name: Run + id: run + run: | + . ./ci/tmp/praktika_setup_env.sh + PYTHONUNBUFFERED=1 python3 -m praktika run 'Stateless tests (amd_msan, cas s3 storage, parallel, 2/3)' --workflow "Community PR" --ci --timestamp + + stateless_tests_amd_msan_cas_s3_storage_parallel_3_3: + runs-on: [self-hosted, altinity-on-demand, altinity-func-tester] + needs: [build_amd_debug, build_amd_msan, build_arm_binary, config_workflow, fast_test, stateless_tests_amd_debug_parallel, stateless_tests_arm_binary_parallel] + if: ${{ !cancelled() && !contains(needs.*.outputs.pipeline_status, 'failure') && !contains(needs.*.outputs.pipeline_status, 'undefined') && !contains(fromJson(needs.config_workflow.outputs.data).workflow_config.cache_success_base64, 'U3RhdGVsZXNzIHRlc3RzIChhbWRfbXNhbiwgY2FzIHMzIHN0b3JhZ2UsIHBhcmFsbGVsLCAzLzMp') }} + name: "Stateless tests (amd_msan, cas s3 storage, parallel, 3/3)" + outputs: + data: ${{ steps.run.outputs.DATA }} + pipeline_status: ${{ steps.run.outputs.pipeline_status || 'undefined' }} + steps: + - name: Checkout code + uses: actions/checkout@v6 + with: + ref: ${{ env.CHECKOUT_REF }} + + - name: Setup + uses: ./.github/actions/runner_setup + - name: Docker setup + uses: ./.github/actions/docker_setup + with: + test_name: "Stateless tests (amd_msan, cas s3 storage, parallel, 3/3)" + + - name: Prepare env script + run: | + rm -rf ./ci/tmp + mkdir -p ./ci/tmp + cat > ./ci/tmp/praktika_setup_env.sh << 'ENV_SETUP_SCRIPT_EOF' + export PYTHONPATH=./ci:.: + + cat > ./ci/tmp/workflow_job.json << 'EOF' + ${{ toJson(job) }} + EOF + cat > ./ci/tmp/workflow_status.json << 'EOF' + ${{ toJson(needs) }} + EOF + ENV_SETUP_SCRIPT_EOF + + - name: Download artifact CH_AMD_MSAN + uses: actions/download-artifact@v8 + with: + name: CH_AMD_MSAN + path: ./ci/tmp + + - name: Run + id: run + run: | + . ./ci/tmp/praktika_setup_env.sh + PYTHONUNBUFFERED=1 python3 -m praktika run 'Stateless tests (amd_msan, cas s3 storage, parallel, 3/3)' --workflow "Community PR" --ci --timestamp + + stateless_tests_arm_binary_cas_s3_storage_parallel: + runs-on: [self-hosted, altinity-on-demand, altinity-func-tester-aarch64, 16c] + needs: [build_amd_debug, build_arm_binary, config_workflow, fast_test, stateless_tests_amd_debug_parallel, stateless_tests_arm_binary_parallel] + if: ${{ !cancelled() && !contains(needs.*.outputs.pipeline_status, 'failure') && !contains(needs.*.outputs.pipeline_status, 'undefined') && !contains(fromJson(needs.config_workflow.outputs.data).workflow_config.cache_success_base64, 'U3RhdGVsZXNzIHRlc3RzIChhcm1fYmluYXJ5LCBjYXMgczMgc3RvcmFnZSwgcGFyYWxsZWwp') }} + name: "Stateless tests (arm_binary, cas s3 storage, parallel)" + outputs: + data: ${{ steps.run.outputs.DATA }} + pipeline_status: ${{ steps.run.outputs.pipeline_status || 'undefined' }} + steps: + - name: Checkout code + uses: actions/checkout@v6 + with: + ref: ${{ env.CHECKOUT_REF }} + + - name: Setup + uses: ./.github/actions/runner_setup + - name: Docker setup + uses: ./.github/actions/docker_setup + with: + test_name: "Stateless tests (arm_binary, cas s3 storage, parallel)" + + - name: Prepare env script + run: | + rm -rf ./ci/tmp + mkdir -p ./ci/tmp + cat > ./ci/tmp/praktika_setup_env.sh << 'ENV_SETUP_SCRIPT_EOF' + export PYTHONPATH=./ci:.: + + cat > ./ci/tmp/workflow_job.json << 'EOF' + ${{ toJson(job) }} + EOF + cat > ./ci/tmp/workflow_status.json << 'EOF' + ${{ toJson(needs) }} + EOF + ENV_SETUP_SCRIPT_EOF + + - name: Download artifact CH_ARM_BIN + uses: actions/download-artifact@v8 + with: + name: CH_ARM_BIN + path: ./ci/tmp + + - name: Run + id: run + run: | + . ./ci/tmp/praktika_setup_env.sh + PYTHONUNBUFFERED=1 python3 -m praktika run 'Stateless tests (arm_binary, cas s3 storage, parallel)' --workflow "Community PR" --ci --timestamp + + stateless_tests_amd_binary_cas_storage_parallel: + runs-on: [self-hosted, altinity-on-demand, altinity-builder, 16c] + needs: [build_amd_binary, build_amd_debug, build_arm_binary, config_workflow, fast_test, stateless_tests_amd_debug_parallel, stateless_tests_arm_binary_parallel] + if: ${{ !cancelled() && !contains(needs.*.outputs.pipeline_status, 'failure') && !contains(needs.*.outputs.pipeline_status, 'undefined') && !contains(fromJson(needs.config_workflow.outputs.data).workflow_config.cache_success_base64, 'U3RhdGVsZXNzIHRlc3RzIChhbWRfYmluYXJ5LCBjYXMgc3RvcmFnZSwgcGFyYWxsZWwp') }} + name: "Stateless tests (amd_binary, cas storage, parallel)" + outputs: + data: ${{ steps.run.outputs.DATA }} + pipeline_status: ${{ steps.run.outputs.pipeline_status || 'undefined' }} + steps: + - name: Checkout code + uses: actions/checkout@v6 + with: + ref: ${{ env.CHECKOUT_REF }} + + - name: Setup + uses: ./.github/actions/runner_setup + - name: Docker setup + uses: ./.github/actions/docker_setup + with: + test_name: "Stateless tests (amd_binary, cas storage, parallel)" + + - name: Prepare env script + run: | + rm -rf ./ci/tmp + mkdir -p ./ci/tmp + cat > ./ci/tmp/praktika_setup_env.sh << 'ENV_SETUP_SCRIPT_EOF' + export PYTHONPATH=./ci:.: + + cat > ./ci/tmp/workflow_job.json << 'EOF' + ${{ toJson(job) }} + EOF + cat > ./ci/tmp/workflow_status.json << 'EOF' + ${{ toJson(needs) }} + EOF + ENV_SETUP_SCRIPT_EOF + + - name: Download artifact CH_AMD_BINARY + uses: actions/download-artifact@v8 + with: + name: CH_AMD_BINARY + path: ./ci/tmp + + - name: Run + id: run + run: | + . ./ci/tmp/praktika_setup_env.sh + PYTHONUNBUFFERED=1 python3 -m praktika run 'Stateless tests (amd_binary, cas storage, parallel)' --workflow "Community PR" --ci --timestamp + integration_tests_amd_asan_ubsan_db_disk_old_analyzer_1_8: runs-on: [self-hosted, altinity-on-demand, altinity-func-tester, 16c] needs: [build_amd_asan_ubsan, build_amd_debug, build_arm_binary, config_workflow, fast_test, stateless_tests_amd_debug_parallel, stateless_tests_arm_binary_parallel] diff --git a/.github/workflows/release_builds.yml b/.github/workflows/release_builds.yml index 8f6f6f22525d..5e943c0cd19e 100644 --- a/.github/workflows/release_builds.yml +++ b/.github/workflows/release_builds.yml @@ -1334,9 +1334,162 @@ jobs: . ./ci/tmp/praktika_setup_env.sh PYTHONUNBUFFERED=1 python3 -m praktika run 'Stateless tests (arm_binary, sequential)' --workflow "Release Builds" --ci --timestamp + stateless_tests_amd_binary_cas_s3_storage_parallel: + runs-on: [self-hosted, altinity-on-demand, altinity-builder, 16c] + needs: [build_amd_binary, config_workflow, dockers_build_amd, dockers_build_arm, dockers_build_multiplatform_manifest] + if: ${{ !cancelled() && !contains(needs.*.outputs.pipeline_status, 'failure') && !contains(needs.*.outputs.pipeline_status, 'undefined') && !contains(fromJson(needs.config_workflow.outputs.data).workflow_config.cache_success_base64, 'U3RhdGVsZXNzIHRlc3RzIChhbWRfYmluYXJ5LCBjYXMgczMgc3RvcmFnZSwgcGFyYWxsZWwp') }} + name: "Stateless tests (amd_binary, cas s3 storage, parallel)" + outputs: + data: ${{ steps.run.outputs.DATA }} + pipeline_status: ${{ steps.run.outputs.pipeline_status || 'undefined' }} + steps: + - name: Checkout code + uses: actions/checkout@v6 + with: + ref: ${{ env.CHECKOUT_REF }} + + - name: Setup + uses: ./.github/actions/runner_setup + - name: Docker setup + uses: ./.github/actions/docker_setup + with: + test_name: "Stateless tests (amd_binary, cas s3 storage, parallel)" + + - name: Prepare env script + run: | + rm -rf ./ci/tmp + mkdir -p ./ci/tmp + cat > ./ci/tmp/praktika_setup_env.sh << 'ENV_SETUP_SCRIPT_EOF' + export PYTHONPATH=./ci:.: + cat > ./ci/tmp/workflow_inputs.json << 'EOF' + ${{ toJson(github.event.inputs) }} + EOF + cat > ./ci/tmp/workflow_job.json << 'EOF' + ${{ toJson(job) }} + EOF + cat > ./ci/tmp/workflow_status.json << 'EOF' + ${{ toJson(needs) }} + EOF + ENV_SETUP_SCRIPT_EOF + + - name: Download artifact CH_AMD_BINARY_GH + uses: actions/download-artifact@v8 + continue-on-error: true + with: + name: CH_AMD_BINARY_GH + path: ./ci/tmp + + - name: Run + id: run + run: | + . ./ci/tmp/praktika_setup_env.sh + PYTHONUNBUFFERED=1 python3 -m praktika run 'Stateless tests (amd_binary, cas s3 storage, parallel)' --workflow "Release Builds" --ci --timestamp + + stateless_tests_arm_binary_cas_s3_storage_parallel: + runs-on: [self-hosted, altinity-on-demand, altinity-func-tester-aarch64, 16c] + needs: [build_arm_binary, config_workflow, dockers_build_amd, dockers_build_arm, dockers_build_multiplatform_manifest] + if: ${{ !cancelled() && !contains(needs.*.outputs.pipeline_status, 'failure') && !contains(needs.*.outputs.pipeline_status, 'undefined') && !contains(fromJson(needs.config_workflow.outputs.data).workflow_config.cache_success_base64, 'U3RhdGVsZXNzIHRlc3RzIChhcm1fYmluYXJ5LCBjYXMgczMgc3RvcmFnZSwgcGFyYWxsZWwp') }} + name: "Stateless tests (arm_binary, cas s3 storage, parallel)" + outputs: + data: ${{ steps.run.outputs.DATA }} + pipeline_status: ${{ steps.run.outputs.pipeline_status || 'undefined' }} + steps: + - name: Checkout code + uses: actions/checkout@v6 + with: + ref: ${{ env.CHECKOUT_REF }} + + - name: Setup + uses: ./.github/actions/runner_setup + - name: Docker setup + uses: ./.github/actions/docker_setup + with: + test_name: "Stateless tests (arm_binary, cas s3 storage, parallel)" + + - name: Prepare env script + run: | + rm -rf ./ci/tmp + mkdir -p ./ci/tmp + cat > ./ci/tmp/praktika_setup_env.sh << 'ENV_SETUP_SCRIPT_EOF' + export PYTHONPATH=./ci:.: + cat > ./ci/tmp/workflow_inputs.json << 'EOF' + ${{ toJson(github.event.inputs) }} + EOF + cat > ./ci/tmp/workflow_job.json << 'EOF' + ${{ toJson(job) }} + EOF + cat > ./ci/tmp/workflow_status.json << 'EOF' + ${{ toJson(needs) }} + EOF + ENV_SETUP_SCRIPT_EOF + + - name: Download artifact CH_ARM_BIN_GH + uses: actions/download-artifact@v8 + continue-on-error: true + with: + name: CH_ARM_BIN_GH + path: ./ci/tmp + + - name: Run + id: run + run: | + . ./ci/tmp/praktika_setup_env.sh + PYTHONUNBUFFERED=1 python3 -m praktika run 'Stateless tests (arm_binary, cas s3 storage, parallel)' --workflow "Release Builds" --ci --timestamp + + stateless_tests_amd_binary_cas_storage_parallel: + runs-on: [self-hosted, altinity-on-demand, altinity-builder, 16c] + needs: [build_amd_binary, config_workflow, dockers_build_amd, dockers_build_arm, dockers_build_multiplatform_manifest] + if: ${{ !cancelled() && !contains(needs.*.outputs.pipeline_status, 'failure') && !contains(needs.*.outputs.pipeline_status, 'undefined') && !contains(fromJson(needs.config_workflow.outputs.data).workflow_config.cache_success_base64, 'U3RhdGVsZXNzIHRlc3RzIChhbWRfYmluYXJ5LCBjYXMgc3RvcmFnZSwgcGFyYWxsZWwp') }} + name: "Stateless tests (amd_binary, cas storage, parallel)" + outputs: + data: ${{ steps.run.outputs.DATA }} + pipeline_status: ${{ steps.run.outputs.pipeline_status || 'undefined' }} + steps: + - name: Checkout code + uses: actions/checkout@v6 + with: + ref: ${{ env.CHECKOUT_REF }} + + - name: Setup + uses: ./.github/actions/runner_setup + - name: Docker setup + uses: ./.github/actions/docker_setup + with: + test_name: "Stateless tests (amd_binary, cas storage, parallel)" + + - name: Prepare env script + run: | + rm -rf ./ci/tmp + mkdir -p ./ci/tmp + cat > ./ci/tmp/praktika_setup_env.sh << 'ENV_SETUP_SCRIPT_EOF' + export PYTHONPATH=./ci:.: + cat > ./ci/tmp/workflow_inputs.json << 'EOF' + ${{ toJson(github.event.inputs) }} + EOF + cat > ./ci/tmp/workflow_job.json << 'EOF' + ${{ toJson(job) }} + EOF + cat > ./ci/tmp/workflow_status.json << 'EOF' + ${{ toJson(needs) }} + EOF + ENV_SETUP_SCRIPT_EOF + + - name: Download artifact CH_AMD_BINARY_GH + uses: actions/download-artifact@v8 + continue-on-error: true + with: + name: CH_AMD_BINARY_GH + path: ./ci/tmp + + - name: Run + id: run + run: | + . ./ci/tmp/praktika_setup_env.sh + PYTHONUNBUFFERED=1 python3 -m praktika run 'Stateless tests (amd_binary, cas storage, parallel)' --workflow "Release Builds" --ci --timestamp + finish_workflow: runs-on: [self-hosted, altinity-on-demand, altinity-style-checker] - needs: [build_amd_asan_ubsan, build_amd_binary, build_amd_debug, build_amd_msan, build_amd_release, build_amd_tsan, build_arm_asan_ubsan, build_arm_binary, build_arm_debug, build_arm_msan, build_arm_release, build_arm_tsan, build_arm_ubsan, config_workflow, docker_keeper_image, docker_server_image, dockers_build_amd, dockers_build_arm, dockers_build_multiplatform_manifest, install_packages_amd_release, install_packages_arm_release, sign_release_amd_release, sign_release_arm_release, source_upload, stateless_tests_arm_binary_parallel, stateless_tests_arm_binary_sequential] + needs: [build_amd_asan_ubsan, build_amd_binary, build_amd_debug, build_amd_msan, build_amd_release, build_amd_tsan, build_arm_asan_ubsan, build_arm_binary, build_arm_debug, build_arm_msan, build_arm_release, build_arm_tsan, build_arm_ubsan, config_workflow, docker_keeper_image, docker_server_image, dockers_build_amd, dockers_build_arm, dockers_build_multiplatform_manifest, install_packages_amd_release, install_packages_arm_release, sign_release_amd_release, sign_release_arm_release, source_upload, stateless_tests_amd_binary_cas_s3_storage_parallel, stateless_tests_amd_binary_cas_storage_parallel, stateless_tests_arm_binary_cas_s3_storage_parallel, stateless_tests_arm_binary_parallel, stateless_tests_arm_binary_sequential] if: ${{ !cancelled() && needs.config_workflow.outputs.pipeline_status != '' }} name: "Finish Workflow" outputs: @@ -1433,6 +1586,9 @@ jobs: - source_upload - stateless_tests_arm_binary_parallel - stateless_tests_arm_binary_sequential + - stateless_tests_amd_binary_cas_s3_storage_parallel + - stateless_tests_arm_binary_cas_s3_storage_parallel + - stateless_tests_amd_binary_cas_storage_parallel - finish_workflow - GrypeScanServer - GrypeScanKeeper diff --git a/ci/defs/altinity_jobs.py b/ci/defs/altinity_jobs.py index 1d736abaa17e..ab7db9c881be 100644 --- a/ci/defs/altinity_jobs.py +++ b/ci/defs/altinity_jobs.py @@ -1,6 +1,7 @@ from praktika import Artifact, Job from ci.defs.defs import TEMP_DIR, ArtifactNames, RunnerLabels +from ci.defs.job_configs import common_ft_job_config class AltinityArtifactNames: @@ -69,3 +70,53 @@ class AltinityJobConfigs: command="python3 ./ci/jobs/source_upload.py", timeout=3600, ) + # Stateless tests with a content-addressed disk as the default MergeTree storage. + cas_functional_tests_jobs = common_ft_job_config.parametrize( + # CAS over S3: RustFS, not MinIO OSS, because the incarnation pool needs + # enforced conditional deletes. + Job.ParamSet( + parameter="amd_binary, cas s3 storage, parallel", + runs_on=RunnerLabels.AMD_MEDIUM_CPU, + requires=[ArtifactNames.CH_AMD_BINARY_GH], + ), + # The sanitizer lanes are sharded because an unsharded one exceeds the 6h + # GitHub job timeout and is killed before it uploads any results. + *[ + Job.ParamSet( + parameter=f"amd_asan_ubsan, cas s3 storage, parallel, {batch}/{total_batches}", + runs_on=RunnerLabels.AMD_MEDIUM_CPU, + requires=[ArtifactNames.CH_AMD_ASAN_UBSAN_GH], + ) + for total_batches in (2,) + for batch in range(1, total_batches + 1) + ], + *[ + Job.ParamSet( + parameter=f"amd_tsan, cas s3 storage, parallel, {batch}/{total_batches}", + runs_on=RunnerLabels.AMD_MEDIUM, + requires=[ArtifactNames.CH_AMD_TSAN_GH], + ) + for total_batches in (2,) + for batch in range(1, total_batches + 1) + ], + *[ + Job.ParamSet( + parameter=f"amd_msan, cas s3 storage, parallel, {batch}/{total_batches}", + runs_on=RunnerLabels.FUNC_TESTER_AMD, + requires=[ArtifactNames.CH_AMD_MSAN_GH], + ) + for total_batches in (3,) + for batch in range(1, total_batches + 1) + ], + Job.ParamSet( + parameter="arm_binary, cas s3 storage, parallel", + runs_on=RunnerLabels.ARM_MEDIUM_CPU, + requires=[ArtifactNames.CH_ARM_BINARY_GH], + ), + # CAS over local object storage. + Job.ParamSet( + parameter="amd_binary, cas storage, parallel", + runs_on=RunnerLabels.AMD_MEDIUM_CPU, + requires=[ArtifactNames.CH_AMD_BINARY_GH], + ), + ) diff --git a/ci/jobs/functional_tests.py b/ci/jobs/functional_tests.py index e4aaa09c4348..590ffa7ce0c9 100644 --- a/ci/jobs/functional_tests.py +++ b/ci/jobs/functional_tests.py @@ -142,6 +142,8 @@ def run_tests( "old analyzer": "--analyzer", "WasmEdge": "--wasm-engine wasmedge", "s3 storage": "--s3-storage", + "cas storage": "--cas-storage", + "cas s3 storage": "--cas-s3-storage", "DatabaseReplicated": "--db-replicated", "DatabaseOrdinary": "--db-ordinary", "wide parts enabled": "--wide-parts", @@ -155,6 +157,8 @@ def run_tests( OPTIONS_TO_TEST_RUNNER_ARGUMENTS = { "s3 storage": "--s3-storage --no-stateful", + "cas storage": "--cas-storage", + "cas s3 storage": "--cas-s3-storage", "ParallelReplicas": "--no-zookeeper --no-shard --no-parallel-replicas", "AsyncInsert": " --no-async-insert", "DatabaseReplicated": " --no-stateful --replicated-database", @@ -241,6 +245,7 @@ def main(): is_targeted_check = False is_bugfix_validation = False is_s3_storage = False + is_cas_s3 = False is_azure_storage = False is_database_replicated = False is_shared_catalog = False @@ -294,8 +299,13 @@ def main(): is_excluded_from_llvm = True if "per_test_coverage" in to: is_per_test_coverage = True - if "s3 storage" in to: + if "s3 storage" in to and "cas" not in to: + # The CAS-over-s3 variant ("cas s3 storage") installs + # only its own default policy and must not pull in the s3 stateful-data / encrypted + # storage machinery, so it is deliberately excluded from is_s3_storage. is_s3_storage = True + if "cas s3 storage" in to: + is_cas_s3 = True if "azure" in to: is_azure_storage = True if "DatabaseReplicated" in to: @@ -639,6 +649,14 @@ def main(): def start(): res = CH.start_minio(test_type="stateless") and CH.start_azurite() + if res and is_cas_s3: + # The CA-over-S3 pool lives on RustFS (M-W D-W8): the incarnation pool + # needs ENFORCED conditional deletes, which MinIO OSS lacks (the + # fail-closed capability probe rejects it). start_rustfs wipes its data + # dir per run, so no pool state bleeds between runs (the local-CA + # analogue is the per-run server-store wipe). MinIO keeps the non-CA + # s3 disks. + res = CH.start_rustfs() res = res and CH.start() res = res and CH.wait_ready() if res: diff --git a/ci/jobs/scripts/check_style/various_checks.sh b/ci/jobs/scripts/check_style/various_checks.sh index 3660b8acd193..1d208fff563d 100755 --- a/ci/jobs/scripts/check_style/various_checks.sh +++ b/ci/jobs/scripts/check_style/various_checks.sh @@ -230,6 +230,32 @@ done # CLICKHOUSE_URL already includes "?" git grep -P 'CLICKHOUSE_URL(|_HTTPS)(}|}/|/|)\?' $ROOT_PATH/tests/queries/0_stateless/*.sh && echo "CLICKHOUSE_URL already includes '?', use '&' to append query parameters" +# A bare double quote inside a `-q """ ... """` block ends the string early. +# Bash treats """ as an empty string followed by an open quote, so everything up to the next quote is +# one argument. A quote anywhere inside -- including in a SQL comment -- closes it there, and the rest +# of the block becomes shell words. The script stays syntactically valid, so `bash -n` and shellcheck +# both pass; the only symptom is that the client receives a truncated query and reports a syntax error +# pointing at whatever followed the quote. Escape it as \" or use single quotes. +python3 - "$ROOT_PATH" <<'PYEOF' +import glob, os, re, sys + +for path in sorted(glob.glob(os.path.join(sys.argv[1], "tests/queries/0_stateless/*.sh"))): + inside = False + with open(path, encoding="utf-8", errors="replace") as handle: + for number, line in enumerate(handle, 1): + if not inside: + if re.search(r'(-q|--query)\s+"""\s*$', line.strip()): + inside = True + continue + # The block can close mid-line (`... LIMIT 10;"""`), so only what precedes the + # closing delimiter is still inside it. + body, closed, _ = line.partition('"""') + if '"' in re.sub(r'\\"', "", body): + print(f"{path}:{number}: bare double quote inside a -q \"\"\" block ends the SQL early") + if closed: + inside = False +PYEOF + # Large files checked into git. # Every byte committed is cloned by every contributor forever and cannot be removed without history rewriting. # Binary blobs (JARs, archives, .so, datasets) should be downloaded at test time or built from source. diff --git a/ci/jobs/scripts/clickhouse_proc.py b/ci/jobs/scripts/clickhouse_proc.py index 88a4e56c97ba..cbc8ad5984b4 100644 --- a/ci/jobs/scripts/clickhouse_proc.py +++ b/ci/jobs/scripts/clickhouse_proc.py @@ -9,6 +9,7 @@ import threading import traceback import uuid +import zipfile from collections import defaultdict from pathlib import Path from typing import List @@ -43,6 +44,7 @@ class ClickHouseProc: MINIO_LOG = f"{temp_dir}/minio.log" AZURITE_LOG = f"{temp_dir}/azurite.log" KAFKA_LOG = f"{temp_dir}/kafka.log" + RUSTFS_LOG = f"{temp_dir}/rustfs.log" LOGS_SAVER_CLIENT_OPTIONS = "--max_memory_usage 10G --max_threads 1 --max_rows_to_read=0 --max_result_rows 0 --max_result_bytes 0 --max_bytes_to_read 0 --max_execution_time 0 --max_execution_time_leaf 0 --max_estimated_execution_time 0" DMESG_LOG = f"{temp_dir}/dmesg.log" # TODO: run servers in dedicated wds to keep trash localised @@ -163,6 +165,77 @@ def start_minio(self, test_type): print("Failed to start minio") return False + RUSTFS_VERSION = "1.0.0-beta.12" + + def download_rustfs(self, rustfs_bin): + machine = platform.machine() + if machine not in ("x86_64", "aarch64", "arm64"): + print(f"unsupported architecture for rustfs [{machine}]") + return False + arch = "aarch64" if machine in ("aarch64", "arm64") else "x86_64" + url = ( + f"https://github.com/rustfs/rustfs/releases/download/{self.RUSTFS_VERSION}" + f"/rustfs-linux-{arch}-musl-v{self.RUSTFS_VERSION}.zip" + ) + zip_path = f"{temp_dir}/rustfs.zip" + if not Shell.check( + f"curl -sSfL --retry 3 --retry-delay 5 -o {zip_path} {url}", verbose=True + ): + print(f"failed to download rustfs from {url}") + return False + # The release zip contains the single `rustfs` binary at its root. + with zipfile.ZipFile(zip_path) as archive: + archive.extract("rustfs", temp_dir) + os.remove(zip_path) + os.chmod(rustfs_bin, 0o755) + return True + + def start_rustfs(self): + # RustFS backs the CAS-over-S3 pool because the incarnation pool needs enforced + # conditional operations (a wrong-token DELETE must fail with 412) that MinIO OSS lacks; + # MinIO keeps serving the non-CAS s3 disks on its own port. Binary and data dir live + # under ci/tmp, which CI wipes per run, so no pool state bleeds between runs. + rustfs_bin = f"{temp_dir}/rustfs" + if not Path(rustfs_bin).is_file() and not self.download_rustfs(rustfs_bin): + print(f"rustfs binary not found at {rustfs_bin} and download failed") + return False + data_dir = f"{temp_dir}/rustfs_data" + Shell.check(f"rm -rf {data_dir} && mkdir -p {data_dir}", verbose=True) + # The background data-scanner and auto-heal manager do no useful work on a single-disk + # ephemeral pool, but their namespace locks produced multi-minute bursts of 503 + # ServiceUnavailable that stalled client I/O. Client GET/PUT/LIST/DELETE do not depend on + # either. The RUSTFS_ENABLE_* spellings are deprecated since 1.0.0-beta.8. + # Raise the open-files limit for the same reason start_azurite does: under parallel load + # the server holds thousands of S3 connections, and at the default soft limit (1024) + # rustfs runs out of fds and refuses new TCP connections in bursts. + command = ( + "(ulimit -n 1048576 2>/dev/null || ulimit -n $(ulimit -Hn)) && " + f"RUSTFS_SCANNER_ENABLED=false RUSTFS_HEAL_ENABLED=false " + f"{rustfs_bin} server --address 0.0.0.0:11121 " + f"--access-key clickhouse --secret-key clickhouse {data_dir}" + ) + with open(self.RUSTFS_LOG, "w") as log_file: + self.rustfs_proc = subprocess.Popen( + command, stdout=log_file, stderr=subprocess.STDOUT, shell=True + ) + print(f"Started rustfs asynchronously with PID {self.rustfs_proc.pid}") + + if not Shell.check( + "curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1:11121/ | grep -qE '403|200'", + verbose=False, + retries=6, + ): + print("Failed to start rustfs") + return False + # The `test` bucket the storage policy expects. + res = Shell.check( + "/mc alias set carustfs http://localhost:11121 clickhouse clickhouse && /mc mb --ignore-existing carustfs/test", + verbose=True, + ) + if not res: + print("Failed to create rustfs test bucket") + return res + def start_azurite(self): # Raise the open files limit before launching azurite-rs. # Each concurrent test query opens a TCP connection plus an in-memory @@ -938,6 +1011,8 @@ def prepare_logs(self, info, all=False): res.append(self.AZURITE_LOG) if Path(self.KAFKA_LOG).exists(): res.append(self.KAFKA_LOG) + if Path(self.RUSTFS_LOG).exists(): + res.append(self.RUSTFS_LOG) if Path(self.DMESG_LOG).exists(): res.append(self.DMESG_LOG) if Path(self.CH_LOCAL_ERR_LOG).exists(): @@ -1211,6 +1286,29 @@ def dump_system_tables(self): Shell.check( f"sed -i 's|.*|{self.CH_LOCAL_ERR_LOG}|' /etc/clickhouse-server/config.xml" ) + # Open any CAS disk read-only: a writable open claims server-root ownership and fails + # closed against the real server's persisted owner uuid, while a read-only open skips the + # claim and is all a dump needs. Keyed on the `cas` marker + # rather than on disk names, so it covers every CAS disk however this job names it. + # `grep -R` and `sed --follow-symlinks` are required: `tests/config/install.sh` symlinks + # these configs into `config.d`, and `-r`/plain `sed` would silently match nothing. + Shell.check( + "grep -Rl 'cas' /etc/clickhouse-server/ 2>/dev/null " + "| xargs -r sed -i --follow-symlinks 's|cas|castrue|g'" + ) + # Report loudly if the substitution stops matching: a declared but not read-only CAS disk + # means this scrape is about to die on ownership. Reports; does not abort the dump. + if Shell.check( + "grep -Rlq 'cas' /etc/clickhouse-server/", + verbose=False, + ) and not Shell.check( + "grep -Rlq 'castrue' /etc/clickhouse-server/", + verbose=False, + ): + print( + "WARNING: a CAS disk is declared but the read-only marker was not inserted " + "-- `clickhouse local` will claim server-root ownership and this scrape will fail" + ) # FIXME: Hack for s3_with_keeper (note, that we don't need the disk, # the problem is that whenever we need disks all disks will be # initialized [1]) @@ -1226,8 +1324,12 @@ def dump_system_tables(self): self.restore_system_metadata_files_from_remote_database_disk() + # `**`, not `*`: dynamic cache disks created by tests nest their path, e.g. + # `filesystem_caches/disks/cache_03517/status` — a one-level glob missed exactly that file, + # and the scrape died on its flock (`StatusFile.cpp` "Another server instance ... is already + # running") when the server had not released it. cache_status_files = glob.glob( - f"{self.ch_var_lib_dir}/filesystem_caches/*/status" + f"{self.ch_var_lib_dir}/filesystem_caches/**/status", recursive=True ) if cache_status_files: print( diff --git a/ci/workflows/backport_branches.py b/ci/workflows/backport_branches.py index 528a47cbe32e..5f891943d5c9 100644 --- a/ci/workflows/backport_branches.py +++ b/ci/workflows/backport_branches.py @@ -1,9 +1,15 @@ from praktika import Workflow from ci.defs.defs import DOCKERS, SECRETS, ArtifactConfigs +from ci.defs.altinity_jobs import AltinityJobConfigs from ci.defs.job_configs import JobConfigs from ci.jobs.scripts.workflow_hooks.filter_job import should_skip_job +FUNCTIONAL_TESTS_JOBS = [ + *JobConfigs.functional_tests_jobs, + *AltinityJobConfigs.cas_functional_tests_jobs, +] + workflow = Workflow.Config( name="BackportPR", event=Workflow.Event.PULL_REQUEST, @@ -24,7 +30,7 @@ JobConfigs.docker_keeper, *JobConfigs.install_check_jobs, *JobConfigs.compatibility_test_jobs, - *[job for job in JobConfigs.functional_tests_jobs if "amd_asan_ubsan" in job.name], + *[job for job in FUNCTIONAL_TESTS_JOBS if "amd_asan_ubsan" in job.name], *[ job for job in JobConfigs.unittest_jobs diff --git a/ci/workflows/master.py b/ci/workflows/master.py index b3ffe686f331..b98ffb96fa66 100644 --- a/ci/workflows/master.py +++ b/ci/workflows/master.py @@ -13,6 +13,11 @@ from ci.jobs.scripts.workflow_hooks.filter_job import should_skip_job from ci.workflows.pull_request import REGULAR_BUILD_NAMES +FUNCTIONAL_TESTS_JOBS = [ + *JobConfigs.functional_tests_jobs, + *AltinityJobConfigs.cas_functional_tests_jobs, +] + # Add long retention tags to subset of artifacts clickhouse_binaries_with_tags = [] for artifact in ArtifactConfigs.clickhouse_binaries + ArtifactConfigs.clickhouse_stripped_binaries: @@ -55,7 +60,7 @@ *JobConfigs.compatibility_test_jobs, *[ j - for j in JobConfigs.functional_tests_jobs + for j in FUNCTIONAL_TESTS_JOBS if "coverage" not in j.name ], # *JobConfigs.functional_test_llvm_coverage_jobs, diff --git a/ci/workflows/pull_request.py b/ci/workflows/pull_request.py index ae88a3b3a244..a3d063400716 100644 --- a/ci/workflows/pull_request.py +++ b/ci/workflows/pull_request.py @@ -14,11 +14,16 @@ from ci.jobs.scripts.workflow_hooks.filter_job import should_skip_job from ci.jobs.scripts.workflow_hooks.trusted import can_be_tested -ALL_FUNCTIONAL_TESTS = [job.name for job in JobConfigs.functional_tests_jobs] +FUNCTIONAL_TESTS_JOBS = [ + *JobConfigs.functional_tests_jobs, + *AltinityJobConfigs.cas_functional_tests_jobs, +] + +ALL_FUNCTIONAL_TESTS = [job.name for job in FUNCTIONAL_TESTS_JOBS] FUNCTIONAL_TESTS_PARALLEL_BLOCKING_JOB_NAMES = [ job.name - for job in JobConfigs.functional_tests_jobs + for job in FUNCTIONAL_TESTS_JOBS if any( substr in job.name for substr in ( @@ -40,7 +45,7 @@ REGULAR_BUILD_NAMES = [job.name for job in JobConfigs.build_jobs] PLAIN_FUNCTIONAL_TEST_JOB = [ - j for j in JobConfigs.functional_tests_jobs if "amd_debug, parallel" in j.name + j for j in FUNCTIONAL_TESTS_JOBS if "amd_debug, parallel" in j.name ][0] workflow = Workflow.Config( @@ -91,7 +96,7 @@ if j.name not in FUNCTIONAL_TESTS_PARALLEL_BLOCKING_JOB_NAMES else [] ) - for j in JobConfigs.functional_tests_jobs + for j in FUNCTIONAL_TESTS_JOBS if "coverage" not in j.name ], *[ diff --git a/ci/workflows/pull_request_community.py b/ci/workflows/pull_request_community.py index af76e6ac9c4b..71fe9bbca8ed 100644 --- a/ci/workflows/pull_request_community.py +++ b/ci/workflows/pull_request_community.py @@ -2,12 +2,18 @@ from praktika import Workflow, Artifact from ci.defs.defs import BASE_BRANCH, DOCKERS, ArtifactConfigs, JobNames +from ci.defs.altinity_jobs import AltinityJobConfigs from ci.defs.job_configs import JobConfigs from ci.jobs.scripts.workflow_hooks.filter_job import should_skip_job +FUNCTIONAL_TESTS_JOBS = [ + *JobConfigs.functional_tests_jobs, + *AltinityJobConfigs.cas_functional_tests_jobs, +] + FUNCTIONAL_TESTS_PARALLEL_BLOCKING_JOB_NAMES = [ job.name - for job in JobConfigs.functional_tests_jobs + for job in FUNCTIONAL_TESTS_JOBS if any( substr in job.name for substr in ( @@ -24,7 +30,7 @@ ] PLAIN_FUNCTIONAL_TEST_JOB = [ - j for j in JobConfigs.functional_tests_jobs if "amd_debug, parallel" in j.name + j for j in FUNCTIONAL_TESTS_JOBS if "amd_debug, parallel" in j.name ][0] def _normalize_gh_aliases(items): @@ -74,7 +80,7 @@ def _normalize_gh_aliases(items): if j.name not in FUNCTIONAL_TESTS_PARALLEL_BLOCKING_JOB_NAMES else [] ) - for j in JobConfigs.functional_tests_jobs if 'coverage' not in j.name + for j in FUNCTIONAL_TESTS_JOBS if 'coverage' not in j.name ], *[ job.set_run_after(FUNCTIONAL_TESTS_PARALLEL_BLOCKING_JOB_NAMES) diff --git a/ci/workflows/release_branches.py b/ci/workflows/release_branches.py index 2969c8f10b30..78f43c15d600 100644 --- a/ci/workflows/release_branches.py +++ b/ci/workflows/release_branches.py @@ -1,9 +1,15 @@ from praktika import Workflow from ci.defs.defs import BINARIES_WITH_LONG_RETENTION, DOCKERS, SECRETS, ArtifactConfigs +from ci.defs.altinity_jobs import AltinityJobConfigs from ci.defs.job_configs import JobConfigs from ci.jobs.scripts.workflow_hooks.filter_job import should_skip_job +FUNCTIONAL_TESTS_JOBS = [ + *JobConfigs.functional_tests_jobs, + *AltinityJobConfigs.cas_functional_tests_jobs, +] + builds_for_release_branch = [ job for job in JobConfigs.build_jobs @@ -31,7 +37,7 @@ JobConfigs.docker_server, JobConfigs.docker_keeper, *JobConfigs.install_check_master_jobs, - *[job for job in JobConfigs.functional_tests_jobs if "asan" in job.name], + *[job for job in FUNCTIONAL_TESTS_JOBS if "asan" in job.name], *[job for job in JobConfigs.unittest_jobs if "fuzzer" not in job.name], *[ job diff --git a/ci/workflows/release_builds.py b/ci/workflows/release_builds.py index 5e6165f9a744..d11b1324968e 100644 --- a/ci/workflows/release_builds.py +++ b/ci/workflows/release_builds.py @@ -5,6 +5,11 @@ from ci.defs.job_configs import JobConfigs from ci.jobs.scripts.workflow_hooks.filter_job import should_skip_job +FUNCTIONAL_TESTS_JOBS = [ + *JobConfigs.functional_tests_jobs, + *AltinityJobConfigs.cas_functional_tests_jobs, +] + # Add long retention tags to subset of artifacts clickhouse_binaries_with_tags = [] for artifact in ArtifactConfigs.clickhouse_binaries + ArtifactConfigs.clickhouse_stripped_binaries: @@ -45,7 +50,7 @@ AltinityJobConfigs.source_upload_job, *[ job - for job in JobConfigs.functional_tests_jobs + for job in FUNCTIONAL_TESTS_JOBS if any(t in job.name for t in ("release", "binary")) ], ], diff --git a/docs/en/antalya/cas/architecture/backend.md b/docs/en/antalya/cas/architecture/backend.md new file mode 100644 index 000000000000..fab4d23e323b --- /dev/null +++ b/docs/en/antalya/cas/architecture/backend.md @@ -0,0 +1,100 @@ +--- +description: 'The Cas::Backend storage seam, its token contract, the per-provider conditional-write dialects, and the mount-time capability probe.' +sidebar_label: 'Backend abstraction' +sidebar_position: 11 +slug: /antalya/cas/architecture/backend +title: 'CAS Architecture — Backend Abstraction' +doc_type: 'reference' +--- + +# Backend abstraction {#backend-abstraction} + +Every protocol described elsewhere in this set — blobs, manifests, refs, mounts, GC — is written +against one interface, `Cas::Backend` (`Backend/CasBackend.h`). It is a token-aware storage seam: +every present key has exactly one current incarnation identified by an opaque `Token`, and +`putOverwrite`/`casPut` succeed only against the expected current token (or expected absence). + +## The interface {#interface} + +| Method | Contract | +|---|---| +| `get` / `getStream` | Read bytes (or a forward-only stream, for write-once objects) plus the token of the incarnation read | +| `head` | Existence, size, token, and metadata without reading the body | +| `putIfAbsent` / `putIfAbsentStream` | Create-if-absent (`If-None-Match: *`); `PreconditionFailed` is a returned outcome, never an exception | +| `putOverwrite` | Replace the current object only when its token equals `expected`; a mismatch is a returned outcome | +| `casPut` | `expected == nullopt` ⇒ create-if-absent CAS (used for the first write of a root object); a set `expected` conditionally replaces that exact incarnation | +| `deleteExact` | Delete only the incarnation named by `token`; a token mismatch (`TokenMismatch`) leaves the object untouched and is distinguished from `NotFound` | +| `list` | One page of keys under a prefix, resumed by the backend's own cursor | +| `supportsListTokens` | Whether `list` can surface a per-key incarnation token, letting GC discovery skip an unchanged root shard without a `GET` | +| `promoteStaged` / `resurrect` | `promoteStaged`: write-once server-side copy from S3 staging (optional, defaults to `NOT_IMPLEMENTED`). `resurrect`: unconditional re-upload displacing a condemned incarnation from a caller-supplied reader (streamed on remote object storage, materialized one-at-a-time on the local emulated mode); size-checked before publication, and fresh-tagged so pending deletes of the old incarnation cannot remove it | + +`deleteExact`, `putIfAbsent`/`putIfAbsentStream`, and `putOverwrite`/`casPut` are safety-critical: +they are what makes exact-token deletes, write-once creation, and mutual exclusion hold. Every +other method is protocol hygiene. + +**`TOKEN ⟹ CONTENT`** is the one contract item the capability probe cannot check: a token must +uniquely identify the byte content of the incarnation it labels, so that a repeated token never +means different bytes. The read-path decode cache skips a re-read on a token match, so a backend +that recycled tokens across different content would serve stale manifests — a wrong-result bug, not +merely inefficiency. `S3` `ETag`s are content-derived; the in-memory and emulated backends mint a +strictly monotonic sequence that is never reused. This remains a standing requirement of every +backend implementation, not a property the probe verifies. + +## Provider dialects {#dialects} + +`ObjectStorageBackend` (`Backend/CasObjectStorageBackend.cpp`) wraps one `IObjectStorage` and picks +its token dialect from `IObjectStorage::conditionalOpsUseGenerationTokens()`: + +| Dialect | Token type | How a conditional write is expressed | +|---|---|---| +| `AWS` (default) | `ETag` | `If-None-Match: *` / `If-Match: ` sent as-is | +| `GCS` | `Generation` | The backend rewrites conditional headers before the request goes out: `If-None-Match: *` becomes `x-goog-if-generation-match: 0`, and `If-Match: ` becomes `x-goog-if-generation-match: ` (`applyGcsConditionalDialectToRequest`, `IO/S3/GCSConditionalDialect.cpp`) | + +The GCS dialect is opted into by client configuration (`http_client = gcs_hmac` or `gcp_oauth`), not +auto-detected from the endpoint host. It also rejects one shape outright: a **conditional +`CompleteMultipartUpload`** throws rather than silently dropping the precondition, because GCS +ignores preconditions on that call — a measured, documented gap, not a hypothetical one. `CAS`'s +conditional writes therefore always take the single-`PUT` path on a generation-dialect backend. + +This bounds conditional writes only: the write-once create is therefore single-part and limited by +`gcs_max_conditional_put_bytes` on a generation dialect, while the unconditional resurrect takes the +ordinary multipart path and has no size limit on any backend. + +Every request carrying a rewritten header also has its AWS auth headers stripped and every +remaining `x-amz-*` header renamed to `x-goog-*`, since GCS rejects a mixed header set. + +Azure Blob Storage's REST API documents equivalent conditional headers (`If-None-Match`, +`If-Match`), but no third dialect exists in this backend yet — `IObjectStorage`'s Azure +implementation does not currently wire up a `CAS` conditional path, so Azure is untested by the +capability probe below, not merely a slower-verified third case. + +## Exact-token delete, per provider {#exact-token-delete} + +`deleteExact(key, token)` is realized as a conditional `DELETE` naming the token as a precondition: +an `If-Match`-style delete on `AWS` (`ETag`), a generation-match delete on `GCS`. A precondition +failure — `S3::isPreconditionFailedError` — is reported as `DeleteOutcome::TokenMismatch`, never as +an exception; the object is left untouched. `DeleteOutcome::created_delete_marker` reports whether +the backend created a delete marker instead of actually removing the object, which the capability +probe rejects: a bucket with versioning enabled would let `CAS` "delete" a blob without freeing any +storage, and GC would silently stop reclaiming. + +## The capability probe {#capability-probe} + +`runCapabilityProbe` (`Backend/CasProbe.cpp`) runs a throwaway-key battery against every writable +mount, described in full on the [bucket requirements](/antalya/cas/bucket-requirements) page. It is +fail-closed: any check that does not pass throws `NOT_IMPLEMENTED` naming the specific failure, and +the mount refuses to become writable. Two mount-time gates sit alongside it: + +- `checkPoolPreconditions` — on the `GCS`-dialect combination only, verifies bucket versioning is + off (a confirmed `Enabled` throws; an inconclusive check proceeds under an assumption, logged at + `WARNING`, that versioning is off). +- `checkConditionalWriteSingleAttemptSupport` — refuses to mount writable unless the underlying + object storage supports a single-HTTP-attempt retry profile for conditional writes. A hidden SDK + retry can outlive the writer's mount lease and obscure whether a conditional operation actually + committed, so retries on the conditional path must be explicit CAS state-machine transitions, not + transparent client behavior. + +A third, optional probe (`probeConditionalCopy`) checks whether the backend enforces a write-once +conditional server-side copy. It only matters for `staging_backend = s3`: when the probe reports +`false`, S3-native staging silently falls back to local staging rather than refusing to mount, since +enforcement here is an optimization, not a correctness requirement of the disk itself. diff --git a/docs/en/antalya/cas/architecture/blob-protocol.md b/docs/en/antalya/cas/architecture/blob-protocol.md new file mode 100644 index 000000000000..64a62511afba --- /dev/null +++ b/docs/en/antalya/cas/architecture/blob-protocol.md @@ -0,0 +1,232 @@ +--- +description: 'How CAS writes, deduplicates, and reclaims a blob: conditional-write sequencing, the writer-versus-GC race, and the deterministic-artifact adoption pin.' +sidebar_label: 'Blob protocol' +sidebar_position: 3 +slug: /antalya/cas/architecture/blob-protocol +title: 'CAS Architecture — Blob Protocol' +doc_type: 'reference' +--- + +# CAS architecture — blob protocol {#blob-protocol} + +A blob is the unit of content-addressed storage: one part file's bytes, keyed by a hash of its +own content. This page covers how a blob gets written exactly once, how a duplicate write is +turned into a no-op, and how a writer and a `GC` round racing over the same blob are kept safe +without ever comparing multi-gigabyte bodies. Object layout and the four durable object kinds +are covered on the [overview page](/antalya/cas/architecture/); `GC`'s fold and round structure +is covered on the GC page. + +## Conditional-write sequence {#conditional-write-sequence} + +Every blob body lives at a key derived purely from its content hash +(`blobs///`, `CasLayout::blobKey`), with a sidecar `.meta` object at the +same key plus `.meta`. Because the key already encodes the digest, the backend never needs a +compare-and-swap on content — only on *presence* (`PUT` with `If-None-Match: *`) or on a specific +prior incarnation (`PUT`/`DELETE` with `If-Match: `). + +```mermaid +sequenceDiagram + autonumber + participant Writer + participant S3 as Object store + + Writer->>Writer: hash source, derive key from digest + alt dedup cache hit OR size >= deduplication_head_first_min_bytes + Writer->>S3: HEAD blobs/algo/hex + alt body present + Writer->>S3: GET .meta (point read, body never streamed) + Writer->>Writer: adopt current token if Clean or absent + else body absent + Writer->>S3: putIfAbsentStream (If-None-Match: star) + end + else small, no cache hit + Writer->>S3: putIfAbsentStream (If-None-Match: star) directly + end + S3-->>Writer: Done -- fresh upload, write Clean meta + S3-->>Writer: PreconditionFailed -- someone occupies the key + opt on PreconditionFailed + Writer->>S3: HEAD blobs/algo/hex + Writer->>S3: GET .meta -- adopt the occupant's token as a dependency + end +``` + +Ordered steps (`Pool/CasPartWriteTxn.cpp:160-245` and `:427-779`): + +1. `requireAlive()` — the build is not abandoned, the namespace not dropped, the writer epoch + still live. +2. **Adaptive dedup gate.** `HEAD` first if the dedup cache reports the content present, or the + object is at least `deduplication_head_first_min_bytes` (default 1 MiB). Below that threshold + a speculative conditional `PUT` is cheaper than a `HEAD` plus a `PUT`. +3. On a `HEAD` hit, `observeAndAdmit` point-reads the `.meta` sidecar and adopts the live + incarnation — the body is never streamed for a dedup hit. +4. Otherwise a bounded retry loop (up to 8 attempts) around `uploadFromSource`, which mints a + **fresh `incarnation_tag` per attempt** and does either a conditional server-side `COPY` from + S3 staging or a streaming `putIfAbsentStream`. The byte count is verified against the declared + source size. +5. A 412 means someone occupies the key. Because the key embeds the content digest, **any + occupant is by definition the intended content** — ambiguity is resolved by one `HEAD` + (occupancy), never by comparing bodies. +6. `Unresolved` (timeout, 5xx, connection loss) never acks. It throws retry-later — nothing was + published, so a body that lands late is inert debris for the orphan sweep. + +**Two writers uploading identical content** both derive the same key and both send +`If-None-Match: *`. The object store serializes them: one gets `Done`, the other gets 412, +`HEAD`s, point-reads `.meta`, and adopts the winner's token as its own dependency. The loser never +published anything — a failed or cancelled sink publishes nothing — and its adopt is protected by +its own durable precommit edge (see [the writer-versus-GC race](#writer-gc-race)). Both writers +are safe; the only cost is one wasted upload attempt. + +## Dedup and the identity primitive {#dedup-identity} + +Two blobs are the same object if and only if they hash to the same digest under the pool's +configured algorithm. Nothing else — not size, not `LIST` order, not a cheap prefix compare — +is allowed to stand in for that check. This follows the same rule everywhere in CAS: identity +is *proven* by hash equality, never *inferred* by a cheap signal, and re-hashing on read is the +identity primitive wherever the correctness of a decision depends on it. + +The blob content hash is pluggable per pool, fixed at pool creation: `blob_hash` selects +`cityhash128` (default), `xxh3-128`, or `sha256` (`parseBlobHashAlgo`, +`Primitives/CasBlobDigest.h`). A blob is identified by the pair `BlobRef = (BlobHashAlgo, digest)`, +never by a bare digest — a bare digest is ambiguous once more than one algorithm can appear in a +pool. `blob_hash_allow_new` gates admitting a second algorithm into an already-populated pool's +`algos_used` set; it defaults to off. + +`cityhash128` is not cryptographically collision-resistant. A pool shared across mutually +untrusted writers should run `sha256` — CAS enforces no policy choice here; the operator picks +the threat model via `blob_hash`. This is why the dedup admission gate is a `HEAD` (occupancy) +rather than a body compare: it tells the writer *something* already claims this key, and the +digest is the only claim CAS trusts. + +## The writer-versus-GC race {#writer-gc-race} + +This is the interleaving that gets the most reviewer attention, because a writer and a `GC` round +can legitimately disagree about whether a blob is still needed. + +```mermaid +sequenceDiagram + autonumber + participant W as Writer + participant S3 as Object store + participant GC as GC leader + + Note over GC: round n -- fold finds in-degree 0 + GC->>S3: HEAD blob -- capture exact token t1 + GC->>S3: write .meta = Condemned round n + + rect rgba(120,160,255,0.12) + Note over W,S3: a writer arrives wanting this content + W->>S3: append ref-log PRECOMMIT (durable +1 edge) + W->>S3: HEAD blob (present, token t1) + W->>S3: GET .meta + alt meta is Clean + W->>W: adopt t1 as dependency + Note over GC: next fold sees in-degree >= 1 -- spared + else meta is Condemned + W->>S3: PUT blob unconditional re-upload of writer own source, fresh incarnation tag -- token t2 not t1 + W->>S3: CAS .meta back to Clean + end + end + + Note over GC: round n+1 -- graduation, only if still zero + GC->>S3: re-verify in-degree, requires confirmed durable Condemned evidence for hash+token t1 + Note over GC: publishes delete_pending + + Note over GC: round n+2 -- the single content-delete site + GC->>S3: deleteExact(blob, t1) + alt writer resurrected + S3-->>GC: TokenMismatch -- nothing deleted, blob is live at t2 + else genuinely dead + S3-->>GC: Deleted -- then drop the .meta + end +``` + +The invariant that makes every interleaving safe: **revival is re-upload only — never `GET` a +condemned object to revive it.** A writer that finds `Condemned` metadata does not resurrect the +existing body; it re-uploads its own source bytes under a fresh `incarnation_tag`, producing a +new token that no prior `deleteExact` call can name. `GC` never streams a body it might delete, +and a writer never trusts a body it did not itself just write. + +Why this closes the race in both directions: + +- A writer that **adopts** a token must have read a non-`Condemned` marker, and its precommit + edge was durable *before* that read. The next fold therefore sees in-degree ≥ 1 and spares the + blob. +- A writer that **resurrects** changes the token. A stale `deleteExact(t1)` then returns + `TokenMismatch` and reclaims nothing — the delete names an exact incarnation, never "the object + at this key". +- The delete lags condemnation by at least two full rounds, and publishing the one edge that + authorizes an irreversible delete requires confirmed durable `Condemned` evidence for that + exact `(hash, token)` pair. Without it `GC` never throws — it carries the entry and retries the + marker write on the next round. + +Both directions degrade to a spurious re-upload or a no-op delete. Neither can lose data or leave +a dangling manifest entry. + +**One asymmetry worth flagging:** on a local (emulated) disk the resurrect path materializes the +full `[header][payload]` in memory; resurrections are serialized, so at most one body is held whole +in RAM at a time. On remote object storage the resurrect streams and holds nothing. + +### The `.meta` sidecar {#meta-sidecar} + +`.meta` has exactly two states: `Clean` (body present, may be referenced) and `Condemned` +(`GC` observed zero in-degree; the body is still present and a writer may resurrect it). An +*absent* `.meta` reads exactly like `Clean` — there is no third "unaccounted" state in the +stored format; `unaccounted` is an `ca-fsck` classification, not something `GC` ever writes. + +The record carries `state`, `condemn_round`, and `size`, and deliberately carries **no token**: +it is a per-hash hint, not a per-incarnation fact. All safety comes from the body's in-envelope +`incarnation_tag` plus exact-token deletes; a stale marker costs at worst one spurious re-upload, +never a lost delete or a false revival. + +## Deterministic artifacts and the adoption pin {#deterministic-artifacts} + +Some CAS objects are a pure function of their inputs: the `GC` source-edge run files (`cas_run`) +and fold seals (`cas_fold_seal`). For these, `putDeterministicArtifact` +(`Gc/CasBlobInDegree.cpp:341-352`) is the write-once helper: + +```cpp +if (backend.putIfAbsent(key, bytes).outcome == PutOutcome::PreconditionFailed) +{ + const auto existing = backend.get(key); + if (!existing || existing->bytes != bytes) + throw Exception(ErrorCodes::CORRUPTED_DATA, ...); + /// byte-equal => our own deterministic replay; adopt (no-op). +} +``` + +The idempotency argument: identical inputs produce byte-identical output, so a replayed round — +leader deposed mid-round, round `CAS` aborted, crash-restart — re-derives exactly the same bytes. +A 412 therefore means "already occupied by our own replay", verified by comparing the fetched +bytes, not inferred from occupancy alone as blob uploads do. Divergent bytes are impossible under +correct operation and fail closed as `CORRUPTED_DATA`. + +This is the format-evolution **adoption pin**, documented in the persisted-format registry +(`Formats/README.md`): on a `putDeterministicArtifact` conflict, the writer re-encodes at the `v` +of the *existing* object rather than at its own current build's version, so two writers on +different builds replaying the same deterministic round still land on byte-identical output. + +The helper is explicitly **not** for observation-bearing artifacts — `GC` outcome logs carry +`HEAD`-observed tokens on which two observers may legitimately disagree, so those use +first-durable-write-wins byte-adopt semantics instead. And a blob body can never use this path: +the fresh-tag rule means two attempts at the same logical create are allowed to legitimately +differ, which is exactly what `putDeterministicArtifact`'s divergence check would reject. + +## Settings {#settings} + +All names below are unprefixed keys inside the disk's `cas` config block +(`ContentAddressedSettings.cpp`, `LIST_OF_CONTENT_ADDRESSED_SETTINGS`); none carry a `cas_`/`ca_` +prefix. + +| Setting | Controls | Default | +|---|---|---| +| `blob_hash` | Pool blob content-hash function (`cityhash128` \| `xxh3-128` \| `sha256`); fixed at pool creation | `cityhash128` | +| `blob_hash_allow_new` | Explicit opt-in to admit a new hash algorithm into an existing pool's `algos_used` | `false` | +| `deduplication_cache_bytes` | Byte budget of the blob-presence cache that feeds the dedup `HEAD`-first decision (`0` disables) | 64 MiB | +| `deduplication_head_first_min_bytes` | Minimum blob size to try a `HEAD` before uploading the body | 1 MiB | +| `staging_backend` | Blob staging backend (`local` \| `s3`); `s3` is opt-in | `local` | +| `scratch_path` | Server-local scratch directory for the local-staging write-buffer spill; a relative value is anchored to the server data path | `""` | +| `gcs_max_conditional_put_bytes` | Largest conditional write on a generation-token store (GCS forces those single-part); does not bound the unconditional resurrect | 1 GiB | + +`GC`-round budgets that gate condemnation and reclaim of these same blobs (graduation, redelete, +sweep budgets) live on the GC architecture page, not here — they govern the `GC` side of the race +in [Writer-versus-GC race](#writer-gc-race), not the write path. diff --git a/docs/en/antalya/cas/architecture/correctness.md b/docs/en/antalya/cas/architecture/correctness.md new file mode 100644 index 000000000000..1beb28b0039f --- /dev/null +++ b/docs/en/antalya/cas/architecture/correctness.md @@ -0,0 +1,91 @@ +--- +description: 'What the TLA+ model corpus proves about CAS safety, the counterexamples that shaped the design, and how model-checking and soak/chaos testing complement each other.' +sidebar_label: 'Correctness' +sidebar_position: 12 +slug: /antalya/cas/architecture/correctness +title: 'CAS Architecture — Correctness' +doc_type: 'reference' +--- + +# CAS architecture — correctness {#correctness} + +`CAS` treats formal modelling as a pre-implementation gate, not after-the-fact documentation. No +task that changes safety-relevant behavior starts until the relevant `TLA+` model is green, and +"green" means every safety and liveness stage holds **and** every deliberately sabotaged variant +(`sab_*`) violates the specific rule it targets. A sabotage that fails to reproduce its named +counterexample is treated as seriously as a real violation — it means the model was not actually +covering the case it claimed to cover. This is why every safety rule below ships with the +counterexample that appears when you remove it. + +The full model index (source `.tla` files and proof-run records) lives at +`docs/superpowers/models/`; this page is the reader-facing summary. + +## Model → invariant → counterexample {#model-invariant-counterexample} + +| Model (`docs/superpowers/models/`) | Invariant it proves | Counterexample it caught | +|---|---|---| +| `CaIncarnationCore.tla` | `INV_NO_DANGLE`, `INV_NO_LOSS`, `INV_NO_RETURN` — the safety spine for the whole GC core | `sab_unconddelete`: replacing the exact-token delete with an unconditional one lets a stale delete kill the live incarnation a resurrect just wrote | +| `CaBuildRootPrecommit.tla` | `INV_NO_DANGLE_COMMITTED` — a committed manifest never references an absent blob | Reproduces the dangling-manifest hazard exactly: `WriteBlob → AdoptBlob → BuildDie → GcDelete → Commit` with no presence re-check publishes a manifest over a deleted blob | +| `CaGcLeaseCore.tla` | `NoFalseSteal` — no leader steals leadership from a live, mid-round incumbent | Without the advisory heartbeat, a frozen `seq` during a round looks identical to a dead leader, and a second leader steals from the alive one | +| `CaCasMountCore.tla` | Reclaim exclusivity for an expired mount | `sab_wallclockreclaim`: trusting the foreign mount body's wall-clock timestamp (instead of observing a stable token on the reclaimer's own monotonic clock) breaks exclusivity | +| `CaB140DangleMerge.tla` | `INV_NO_LOSS` across a GC lease handoff | Trim-before-durable: a fold cursor trimmed from in-memory state (not the durable snapshot) skips a live edge across a lease handoff, and the referenced blob is deleted while still live | +| `CaGcRootLocalPartManifestCore.tla` | `INV_NO_DANGLE` over the root-local part-manifest fold | `sab_lazyfenceunsafe`: reusing a stale parent fence position instead of a fresh all-shard fence dangles a live object | +| `CaGcShardIncarnationCore.tla` | `INV_NO_DANGLING` — safety of registry-free namespace discovery | `sab_pathkeyedcursor`: dropping the per-shard incarnation from the fold cursor reintroduces an ABA hazard on delete-then-recreate at the same path | +| `CaGcAckFloorZombie.tla` | `INV_NO_DANGLE` under two fully-interleaved GC leaders | `sab_eagerdelete`: a leader deleting its own fresh (not-yet-pending) graduations — the pre-amendment single-phase behavior — dangles when a deposed leader's pass overlaps a live one | +| `CaGcRoundDeferCore.tla` | `NoOverDelete` — a deferred round may skip a rebuild only when nothing destructive is pending | `sab_graduate_on_stale`: dropping the "an unfolded delta covers this blob" guard lets a deferred round delete a blob its own unread history still protects | +| `CaEdgeBeforeObserve.tla` | The writer/`GC` publish order is safe to simplify | `sab_late_edge`: allowing adoption before the precommit closure is durable (the pre-fix order) dangles | +| `CaGcCondemnMarkerGate.tla` | Graduation requires confirmed durable `Condemned` evidence | Swallowing a failed asynchronous condemn-marker write let a writer adopt a token a later graduation was about to delete | +| `CaRefTableSnapshotLogCore.tla` | Dense, per-life ref ids with an in-band `_ckpt` recovery frontier | `sab_scanistruth`: trusting a listing as the source of truth for "acked" reproduces the real production incident where a `LIST` omitted two already-durable, already-acknowledged ref entries | + +## Soak and chaos: the empirical oracle {#soak-and-chaos} + +Model-checking and the soak/chaos harness (`utils/ca-soak/`) catch different classes of error, and +the design leans on both rather than either alone. `TLA+` proves a protocol's constraints before a +line of `C++` exists — the two-coordinate namespace-incarnation proof and the build-root necessity +proof were both design decisions made this way. The soak, running two `ReplicatedMergeTree` +replicas against one shared pool under a seeded workload and a seeded fault injector, finds what an +idealized model necessarily abstracts away: the dangling-manifest hazard and the resurrect-reupload orphan were +both first observed live in `system.cas_log` during soak runs, before either got a focused model. Each +quiesced soak checkpoint cross-checks `SQL` results against a model oracle and runs +`clickhouse-disks ca-fsck` plus `ca-gc-dryrun`, asserting `dangling=0`. + +The relationship runs in both directions: the resurrect-reupload orphan (`utils/ca-soak` scenario +S30, root-caused via `system.cas_log`) got a focused `TLA+` reproduction that proved the fix and +was then retired once a deterministic `gtest` (`CASGCLeak.ResurrectReplacedIncarnationReclaimed`) +covered the same scenario for less ongoing cost — the model did its job as a pre-implementation +gate and the regression coverage moved to the cheaper, faster tool. A model's proven-safe shape +also becomes the thing a later soak scenario is written to stress. The `0x1430c` +incident — a `LIST` that omitted two already-durable ref entries, caught live by an instrumented +probe rather than reproduced by brute-force enumeration — is the clearest example: it is what made +`sab_scanistruth` a permanent, named counterexample rather than a one-off incident report. + +## What this buys a reader {#what-this-buys} + +None of this proves the shipped `C++` is bug-free — a model proves its own abstraction, and several +entries in the index are annotated `MIXED` or `DRIFTED` where the concrete mechanism has moved on +from what a model checks, with the audit trail kept precisely so that gap is visible rather than +implied. What it does buy: every safety rule in the GC core has an explicit counterexample on +record for the world where that rule is missing, and the corpus is itself periodically re-audited +for faithfulness to the code — a model whose guarantee the code no longer needs is deleted rather +than kept as false comfort. + +## Test coverage {#test-coverage} + +The implementation was built test-first (TDD), and the coverage is correspondingly dense: + +| Layer | Volume | +|---|---| +| Unit tests (`gtest`, `CAS*` suites) | ~1,900 test cases across ~130 files, covering formats, the write and read paths, the ref machinery, `GC`, recovery, and the backend contract | +| Integration tests | 10 dedicated `test_cas_*` suites (shared pools, `GC` on S3, sharded `GC`, relink replication, fault-injected `INSERT` recovery, member decommission, and more) | +| Stateless tests | dozens of dedicated `CAS` tests (pool integrity, leftovers, fsck, GC), in addition to the whole standard suite running on a `CAS`-default server (below) | + +## The whole test suite, on CAS by default {#stateless-suite-on-cas} + +Beyond the model corpus and the soak harness, the standard ClickHouse **stateless test suite runs +green with `CAS` as the default `MergeTree` storage**: dedicated CI lanes +(the `cas storage` and `cas s3 storage` job families — the latter covering ASan/TSan/MSan/UBSan +and ARM against a real S3-compatible store) run every stateless test against a server whose default disk is +a `CAS` pool. A small set of tests carries the `no-cas-storage` tag and is skipped in those lanes — +tests that exercise a mechanism a content-addressed disk deliberately does not have (for example, +`s3_plain` layouts or deliberately corrupted on-disk part chains). Everything else — the thousands +of tests that define what `MergeTree` is supposed to do — passes unchanged on top of `CAS`. diff --git a/docs/en/antalya/cas/architecture/design-history.md b/docs/en/antalya/cas/architecture/design-history.md new file mode 100644 index 000000000000..1587b2fdd58a --- /dev/null +++ b/docs/en/antalya/cas/architecture/design-history.md @@ -0,0 +1,61 @@ +--- +description: 'A condensed record of the paths CAS explored and rejected, and the major design pivots that produced the current architecture.' +sidebar_label: 'Design history' +sidebar_position: 13 +slug: /antalya/cas/architecture/design-history +title: 'CAS Architecture — Design History' +doc_type: 'reference' +--- + +# CAS architecture — design history {#design-history} + +This page is a condensed record of the roads not taken: what was tried, why it was abandoned, and +the sequence of pivots that produced the architecture described elsewhere in this section. + +## Rejected paths {#rejected-paths} + +| What it was | Why it was abandoned | What replaced it | +|---|---|---| +| **Generation-in-the-key** (Epoch-Based Reclamation core; blob keys carried a generation, `blobs//`) | Required `O(files)` persistent `Keeper` writes per commit and colliding intent keys across writers building identical content; a stuck writer stalled reclamation pool-wide | The incarnation-token design: identity moved into the object body and delete precision into the backend token, removing the generation from every key | +| **Merkle tree layer** (a `Tree` object kind, `trees/` prefix, `child_gen` carried inside a tree's own identity) | Depended on the generation-in-the-key core: a reclaim at any child propagated a new generation up the entire tree chain, and the tree layer was itself an extra surface for the same class of bug | Removed entirely; trees became manifest-internal, and `Blob` is the sole durable object kind besides the manifest and the ref | +| **Integer in-degree refcount** (a mutable counter, incremented per reference, decremented per release) | The decide-to-reference-then-not-yet-durable window let the fold observe in-degree 0 for a still-live blob; a mutable counter also costs a `CAS` round-trip proportional to write volume | A derived count: `GC` folds a multiset of `+`/`-` source-edge deltas, so losing or duplicating a record can only delay reclamation, never accelerate one | +| **Extending zero-copy replication instead of a new mechanism** | Zero-copy's structural costs (a commit spanning local disk, S3, and `Keeper`; a mutable refcount) are inherent to its design, not a bug to patch | `CAS` is an alternative to zero-copy, not a replacement: both remain available, `metadata_type = cas` is opt-in per disk, and no existing deployment needs to migrate | +| **Per-incarnation body keys** (`blobs/xx/.`, an alternative to the in-body incarnation tag) | A resurrect reusing the condemned incarnation instead of minting a fresh one reintroduced the shared-key race; structurally this was generation-in-the-key again | The in-body `incarnation_tag` plus exact-token body delete, which keeps the generation out of every object key | +| **Meta as the lifecycle linearizer** (a per-hash `.meta` object whose presence/absence *was* the authority for a blob's lifetime) | The marker is a point-read hint only, never consulted by reads; treating it as the linearizer would assert a guarantee the design does not make | The meta stays advisory: the in-body incarnation tag and exact-token delete are the real authority, and an absent meta reads identically to `Clean` | +| **Raw immutable bodies with a three-state tombstone meta** | A resurrect displacing the body forced a terminal-tombstone handshake — a writer↔`GC` liveness coupling that could re-enable data loss | The settled one-key-per-hash design with an in-body incarnation tag | +| **A persistent, append-only namespace registry for `GC` discovery** | Never deregistered on drop, so it grew monotonically forever; its fence cost scaled with namespaces ever created, not namespaces live | Discovery from the ref data itself, made safe by two independent coordinates: a durable per-shard incarnation plus a pool-global round | +| **A separate all-shard fence-and-recheck phase per `GC` round** | Both phases cost `O(pool size)` GET+CAS every round regardless of churn — roughly 2.4 million requests at 100k tables | A causal ack-floor: one streaming merge per round, with no separate fence or recheck phase, cutting the request count by roughly three orders of magnitude | +| **A pool-wide sparse ref-id allocator with completeness certificates bolted on** | Successive additive fixes kept growing without closing the root cause: absence is undecidable in a sparse id space | The invariants were changed instead of patched: dense per-life ids derived from applied state, an in-band epoch seal, and a `_ckpt` head object carrying the exact acknowledged frontier | + +## Turns at a glance {#turns-at-a-glance} + +| Date | Turn | +|---|---| +| 2026-06-01 | Starting point: "content-addressed storage for `MergeTree`" thesis and a working proof of concept | +| 2026-06-07 – 10 | The generation-in-the-key core is abandoned; the incarnation-token design replaces it | +| 2026-06-11 | The incarnation model passes exhaustive model checking with zero violations | +| 2026-06-18 | A dangling manifest reference — a committed manifest naming an already-deleted blob — leads to replacing per-blob protection hints with structural build-root reachability | +| 2026-06-24 – 26 | Formats begin converging on a single self-describing envelope (completed in July as the all-text, JSON-based codec set) | +| 2026-06-26 | Root-local full-tree manifests collapse a forest of small `GC` objects into one hot/cold split | +| 2026-07-01 | The namespace registry is deleted; discovery moves to the two-coordinate incarnation-and-round scheme | +| 2026-07-02 | Fence-and-recheck `GC` rounds are replaced by the one-pass, causal ack-floor round | +| 2026-07-06 – 10 | Writer/`GC` simplification: promote-time revalidation of tokened dependencies is proved redundant | +| 2026-07-13 | Mount-lease handover becomes boundary-exclusive, closing the cross-epoch grace window without a timeout | +| 2026-07-15 | All part files become content-addressed: the mutable file set drops to empty, and disk-transaction dispatch collapses to one precommit contract | +| 2026-07-17 | An acknowledged `INSERT` that could be lost is traced to a removed durability guard and fixed | +| 2026-07-26 | A `LIST` omitting two already-durable ref entries is caught during a soak run — the incident that settles the trust model for listings | +| 2026-07-27 – 29 | The sparse-id certificate stack is abandoned; the invariants change instead — dense ids, an in-band epoch seal, and a `_ckpt` recovery frontier | +| 2026-08-01 – 03 | Recovery stops reading listings entirely and works from authoritative objects; the listing trust model is finalized | + +## The pattern underneath {#the-pattern} + +A few reflexes recur across these pivots and still apply to new design work: + +- **Re-derive the invariant, don't patch the mechanism.** Every durable fix came from asking what + property must hold, not from patching the specific failure observed. +- **Delay is acceptable, authorization is not.** A stale-but-honest observation can only ever + postpone a decision; the design consistently rejects any mechanism that could *accelerate* a + destructive action past its safety gates — a delay is a latency cost, a wrongful authorization is + data loss. +- **A model that no longer matches the code is worse than no model.** Superseded models are + removed rather than kept: an unfaithful proof is false comfort, not documentation. diff --git a/docs/en/antalya/cas/architecture/garbage-collection.md b/docs/en/antalya/cas/architecture/garbage-collection.md new file mode 100644 index 000000000000..48be9c4e6b24 --- /dev/null +++ b/docs/en/antalya/cas/architecture/garbage-collection.md @@ -0,0 +1,263 @@ +--- +description: 'The CAS garbage collector: leadership as work de-duplication, the 18-phase round pipeline, condemnation and exact-token deletion, sharding, and observability.' +sidebar_label: 'Garbage collection' +sidebar_position: 8 +slug: /antalya/cas/architecture/garbage-collection +title: 'CAS Architecture — Garbage Collection' +doc_type: 'reference' +--- + +# CAS architecture — garbage collection {#garbage-collection} + +`GC` is the only place in `CAS` that ever deletes a blob body or a manifest body. It runs as a +background, lease-paced loop per mount (`Gc::runRegularRound`, `Gc/CasGc.cpp`), folding ref-log +history into blob in-degree, condemning what reaches zero, and deleting only after that +condemnation has survived a full extra round. This page covers leadership, the round's 18 phases, +condemnation and deletion, sharding, pruning, round cost, and observability. Manifest and ref +mechanics that `GC` folds are covered on the +[manifests-and-refs page](/antalya/cas/architecture/manifests-and-refs); the writer-versus-`GC` +race over one blob is covered on the +[blob-protocol page](/antalya/cas/architecture/blob-protocol#writer-gc-race). + +## Leadership {#leadership} + +There is **no separate `GC` lease object**. The lease lives inside `gc/state` itself as +`{owner, seq}`. + +```mermaid +stateDiagram-v2 + [*] --> Reading: GET gc/state + Reading --> Creating: object absent, never observed before + Creating --> Leader: casPut create-if-absent, gc_shards fixed here, once + Reading --> Renewing: lease owner is me + Renewing --> Leader: casPut seq+1, guarded by the observed token + Reading --> Evaluating: foreign owner + Evaluating --> NotLeader: incumbent lease moved, or heartbeat moved, or steal not allowed + Evaluating --> Stealing: both frozen across a full observation window + Stealing --> Leader: casPut owner=me seq+1, on the observed token + Stealing --> NotLeader: lost the CAS, re-read and re-arm + Leader --> [*]: run the round +``` + +Two independent liveness signals are consulted before a steal: whether `(owner, seq)` moved since +the last tick, and whether the separate `gc/hb` heartbeat moved. The heartbeat is compared only +under the same remembered heartbeat owner, deliberately not against `lease.owner` — a deposed +leader's heartbeat thread keeps pulsing, and that must not cause a live new leader's lease to be +stolen. The paced background loop may steal; a manual `SYSTEM CAS GC RUN` may not, because the +safety argument needs two observations separated by real wall time. Because every renew or steal +bumps `seq`, `seq` doubles as the round's attempt id. + +**A deposed leader that keeps running cannot corrupt anything**, and the argument does not rely on +exclusivity at all: + +1. `gc/state` is published by exactly **one** `CAS` per round; a deposed leader's `CAS` fails and + its entire round evaporates. +2. Every fold artifact is written under that leader's own attempt number, invisible to every + reader, and reclaimed later by wholesale generation pruning. +3. Destructive pre-`CAS` actions are justified only by previously published durable state, so they + are replay-idempotent. +4. Deletes are exact-token, so a stale leader can never delete a newer incarnation. + +The lease is therefore **work de-duplication, not mutual exclusion**. + +## The round {#the-round} + +A round is one pass of 18 named phases ending in exactly one `gc/state` `CAS` +(`Gc::runRegularRound`, `Gc/CasGc.cpp`). + +| # | Phase (`GcPhaseTimer` name) | What it does | +|---|---|---| +| 1 | `lease` | Acquire, renew or steal the lease inside `gc/state`. The only phase a not-a-leader round emits | +| 2 | `pre_fold_ref_drain` | Resolve catalog `Removing` rows whose cleanup evidence the adopted parent already sealed; exact-CAS-delete the completed ones before anything else can act | +| 3 | `heartbeat_floor` | One `LIST` of `gc/server-roots/`, one `GET` per mount slot, fence-out `PUT` for any mount whose write-token has held stable past the threshold | +| 4 | `defer_decision` | One full `LIST` of `cas/ns/stream/`, build the catalog-keyed ref walk plan; decide `DEFER` (nothing changed, no graduation due) or continue to a full fold. A `DEFER` verdict still runs one namespace-janitor page — the same work phase 16 does on a folding round — with its deletes suppressed | +| 5 | `parent_seal_read` | Capture the parent fold seal's run references before the fold mutates the in-memory generation/attempt, to detect a ref that moved off an already-pruned generation | +| 6 | `fold_ref_group` | Regroup the one `LIST` from phase 4 into per-table listings — no I/O, the keys are already in hand | +| 7 | `fold_seal_read` | `GET` and decode the adopted fold seal that anchors this fold's coverage | +| 8 | `fold_ref_intake` | `GET` every new ref-log record and every referenced manifest, extracting blob source edges | +| 9 | `fold_reduce` | The three-cursor merge over prior edges, new deltas and the parent's condemned rows: spare, condemn, graduate or redelete each candidate | +| 10 | `fold_seal_write` | Write the new fold seal once, write-once deterministic, adopting a byte-identical replay instead of rewriting it | +| 11 | `pending_deletes` | The single content-delete site: exact-token `deleteExact` of every entry the *previous* round marked `delete_pending`, plus the forensic outcome-log writes | +| 12 | `meta_pool_wait` | Drain the bounded pool of async `.meta` condemn-marker writes queued during the fold | +| 13 | `round_commit` | Retention-prune old generations, then publish the single `gc/state` `CAS` that adopts the whole round | +| 14 | `handoff_reclaim` | Post-`CAS`: reclaim any generation that a ref moved off during this very round, before the ordinary wholesale prune would reach it | +| 15 | `manifest_deletes` | Delete manifest bodies whose owner-removal minus-one edge the `CAS` in phase 13 just adopted | +| 16 | `namespace_cleanup` | One bounded page of the perpetual namespace janitor, reclaiming dead-life debris | +| 17 | `ref_object_cleanup` | Prune ref logs and snapshots once both fold coverage and a live snapshot make them safe to delete | +| 18 | `orphan_sweep` | One cursor-paced page of the [orphan-manifest sweep](/antalya/cas/architecture/manifests-and-refs#orphan-sweep); wrapped so it can never fail the round | + +Phases 5 through 18 run only when phase 4 decides to fold. A `DEFER` verdict is not a bare no-op: +it still runs one bounded namespace-janitor page with `suppress_destructive = true` — cursor +progress and diagnostics only, no deletes — and then returns, publishing no fold artifact and no +`gc/state` `CAS` at all: + +```mermaid +flowchart LR + D4{"4 defer_decision"} -->|"nothing changed, no graduation due"| DEF["DEFER: one suppressed
namespace-janitor page, then return"] + D4 -->|"changed shards, or graduation due"| FOLD["phases 5 through 18: full fold and round commit"] +``` + +Orderings that are load-bearing: + +- **2 before 4** — a row proved complete by the adopted parent is resolved before `DEFER` or any + successor plan can publish. +- **15 after 13** — manifest bodies are deleted only after the `CAS` adopted their decrements. +- **13's prune before the `CAS`** — a pre-`CAS` destructive action may rely only on already- + published state. + +**Clamp suppression.** `suppress_destructive = !anomalies.empty() || !carried_holds.empty() || +!frontier_complete` is computed once and threaded into the merge, current-life ref cleanup and the +perpetual namespace janitor, so they cannot desynchronize. Under suppression there is no +graduation, no redelete, and no ref or namespace deletion; condemnation and sparing continue, +because both are non-destructive. + +**Fail-closed aborts.** A throw before the `CAS` means nothing is adopted: unapplied transactions, +a cursor/apply mismatch, a missing adopted seal, a table with a snapshot but no surviving log and +no cursor, a non-total condemned summary, and an observed delete marker (bucket versioning is on). + +## The one-pass commit {#gc-state} + +`gc/state` is the durable safety and round-adoption state: `round`, `gc_shards`, +`snap_generation`, `snap_pruned_through`, `snap_attempt`, `manifest_sweep_cursor`, and the lease. +Exactly one `CAS` per round publishes it; the fold itself performs no `CAS` of its own. + +**The fold seal *is* the coverage record**: generation, parent generation, one `ref_lives` row per +catalog-admitted opaque life (coverage plus optional cleanup evidence), references to the +source-edge run segments, and a per-shard condemned summary. It is encoded deterministically, so a +replayed round produces byte-identical bytes and adopts its own output through the +`putDeterministicArtifact` adoption pin (see the [blob-protocol page](/antalya/cas/architecture/blob-protocol#deterministic-artifacts)). +There is **no separate retired-list object** — condemned entries ride the source-edge run as +sentinel rows at `source_id = 0` — and **no run-file list outside the seal**; runs are resolved +*through* the seal's references, never by key construction. + +## Finding orphans {#finding-orphans} + +In-degree is a set of source edges, not a refcount. A blob becomes a candidate when its edge set +becomes empty and it was touched this pass: one `HEAD` captures the exact incarnation token and +size that a future delete will name. A blob merely carried from the parent run pays no `HEAD`. + +**The grace period is measured in rounds, not acks:** an entry graduates once it has survived one +full round (`condemn_round < current_round`). The heartbeat floor is liveness only and **never** +gates graduation. + +**The 404 rule.** A body that is present but invalid is `CORRUPTED_DATA`, hard. A body that is +missing is **never** a throw — the fold records and continues, and the caller decides by position: +a precommit activation clamps as a barrier; a committed or removal fold clamps only that table. +Prunes are likewise fail-open on 404. + +## Condemnation and deletion {#condemn-delete} + +```mermaid +flowchart LR + A["round n: in-degree hits zero
HEAD -- exact token t"] --> B["write .meta = Condemned round n
async, bounded pool, drained pre-CAS"] + B --> C["retired with condemn_round = n+1"] + C --> D{"round n+1: re-verify"} + D -->|"in-degree recovered"| S["SPARED -- recovery wins, even past the floor"] + D -->|"still zero, confirmed durable Condemned evidence for hash and t"| G["GRADUATED -- delete_pending"] + D -->|"still zero, evidence unconfirmed"| C2["carried unchanged, retry the marker, never throw"] + D -->|"current token not equal to t"| SUP["SUPERSEDED -- a writer resurrected, re-condemn the CURRENT token"] + G --> E["round n+2, pre-CAS: deleteExact blob, t"] + E -->|"Deleted or Absent"| F["then drop the .meta"] + E -->|TokenMismatch| H["nothing deleted -- live at a newer token, leave the .meta alone"] +``` + +The `.meta` sidecar carries **no token** — it is a per-hash hint. The exact incarnation token lives +in the condemned sentinel row inside the run, together with the condemn round and two flags, +`delete_pending` and `marker_confirmed`. `GC`'s marker is add-only: `Clean → Condemned` yes, the +reverse never, not even when sparing — only a writer that has already displaced the body may clear +it. Minimum two full rounds separate condemnation from deletion, and `delete_pending` is terminal — +an entry is never un-pended. + +## Sharding {#sharding} + +`gc_shards` is fixed at first lease acquire and immutable; decoders reject `0`. A blob routes by +the **high** 64 bits of its digest, read big-endian. + +The role split is worth internalizing: the **coordinator** — the lease holder — owns discovery, +round visibility, the single global fence, and the generation advance, because a publish into +*one* namespace can protect a blob owned by *any* shard, so these span the whole universe and must +not be sharded. **Reducers** own only their disjoint shard; their run-key namespaces never +collide, so two servers could reduce different shards concurrently and reducer work needs no +lease. + +A shard with an empty delta bucket and no condemned entries in the parent summary copies the +parent's run references verbatim — zero run I/O, a "pure carry". A missing parent summary entry on +a non-fresh pool is `CORRUPTED_DATA`, never silently treated as zero. + +## Pruning old objects {#pruning} + +- **Current-life ref logs and snapshots** (phase 17) — a log is deletable only when covered by + both durable fold coverage and a durable live snapshot; snapshots strictly older than the newest + observed one are deletable. There is no batch delete; it is `HEAD` plus `deleteExact` per key. +- **Generations** (phase 13) — keep the last `gc_snapshot_generations_to_keep` (default 3; `0` + means keep everything, for forensics). Pruning is wholesale: `LIST` the generation prefix and + delete everything under it, including deposed-leader debris and attempt-scoped outcome sets. A + generation still referenced by the live seal is skipped, but the cursor still advances past it — + leak-freedom then rests on the post-`CAS` hand-off reclaim in phase 14. +- **Manifests** — owner-removed bodies delete in phase 15; never-precommitted bodies go through the + [orphan-manifest sweep](/antalya/cas/architecture/manifests-and-refs#orphan-sweep) in phase 18. + +## What a round costs {#round-cost} + +Per **folding** round, with `N` live mounts, `S` ref tables and `S_changed` tables carrying new +logs: + +| Operation | Count | +|---|---| +| `LIST cas/ns/stream/` | 1 full enumeration | +| `LIST gc/server-roots/` | 1, plus 1 `GET` per mount | +| `GET` the adopted fold seal | 5, explicitly instrumented | +| `GET` ref logs | 1 per new log | +| `GET` manifests | 1 per emitted edge — no manifest-body cache within a round | +| `PUT` run segments | 1 per non-pure-carry shard, plus 1 fold seal | +| `HEAD` blobs | 1 per newly condemned | +| `DELETE` | 1 per graduate | +| `CAS gc/state` | 1 | + +The measured `GET` formula is exact: total `GET`s equal ref-log body `GET`s plus manifest body +`GET`s, i.e. `1 + edges_per_log`. An idle round is one `LIST` sweep, `N` heartbeat `GET`s, and one +`CAS`. A deferred round is cheaper still: one `LIST`, three seal `GET`s, the lease `GET`/`PUT` and +the heartbeat floor — no `gc/state` `CAS` at all. + +The round's work is internally self-regulated: anything a pass cannot finish is carried and retried +by the next round's cursors, never dropped. The internal pacing knobs are deliberately not part of +the user-facing configuration surface. + +| Setting | Default | Bounds | +|---|---|---| +| `gc_meta_pool_size` | 16 | bounded pool for condemn-marker writes | + +## Observability {#observability} + +`system.cas_gc_log` emits `Start`, `Finish` and per-`Phase` rows, correlated by `round_id` — not +`round`, which is `0` on `Start` and does not exist at all on a not-a-leader round. Phase rows +carry no verb columns by design: per-phase operation counts ride the row's own `ProfileEvents` +delta, so grouping by phase over an S3 event attributes the LIST/GET/PUT/DELETE budget without +inventing schema. `phase_metrics` carries the semantic counts no counter can supply (clamped +tables, dead precommits skipped, pure-carry shards, generations visited). `Deferred` is kept +distinct from `Success` precisely so "folded and found nothing" is distinguishable from "never +folded". Every `GC`-related `ProfileEvent` carries the uppercase `CAS`/`CASGC` prefix — for example +`CASGCRetiredCondemned`, `CASGCRetiredGraduated`, `CASGCRetiredRedeleted`, +`CASGCClampSuppressedPasses`, `CASGCHeartbeatFenceOuts`. + +Alongside it, `system.cas_log` carries the audit trail: the condemn chain, fence-outs, anomalies +(capped per round, each carrying the true total), and manifest deletes. + +`ca-fsck` distinguishes two classes that are easy to conflate: `dangling` — referenced but missing, +data loss — versus `unreachable`/`awaiting-gc` — present, unreferenced, and +simply waiting for graduation. + +## Operational surface {#operational-surface} + +| Command | Effect | +|---|---| +| `SYSTEM CAS GC RUN ''` | One synchronous round on the contacted node; only the lease holder makes progress | +| `SYSTEM CAS GC STOP` / `SYSTEM CAS GC START` | Stop or resume future rounds on the same scheduler, preserving its identity | +| `SYSTEM CAS GC REBUILD` (`clickhouse-disks ca-gc-rebuild`) | Fail-closed disaster-recovery path that every "GC refuses to run" error points at; deliberately over-protects — it prefers bounded leaks over risking an under-count. It cannot delete live data directly: deletions it produces still flow through the normal round's condemn, graduate, exact-token path | +| `clickhouse-disks ca-gc-dryrun` | Opens the disk read-only, constructs a non-leader `GC`, and prints what would be deleted with a reason per entry. Write-free, resolves runs through the seal's references. Documented caveat: it does not fold new owner events, so away from quiescence it can **over-report** — the subset guarantee holds only at quiescence, and its output must never feed a real delete | + +`SYSTEM CAS DROP POOL MEMBER '' FROM DISK ''` — permanent removal of a dead +replica, distinct from ordinary `GC` — is covered on the +[mounts-and-leases page](/antalya/cas/architecture/mounts-and-leases#mount-lifecycle). `SYSTEM CAS +FSCK` and its `dangling`/`unreachable` vocabulary are a read-only diagnostic pass, not part of the +`GC` protocol itself. diff --git a/docs/en/antalya/cas/architecture/index.md b/docs/en/antalya/cas/architecture/index.md new file mode 100644 index 000000000000..15a9516c9ae7 --- /dev/null +++ b/docs/en/antalya/cas/architecture/index.md @@ -0,0 +1,114 @@ +--- +description: 'What CAS is, the Git-analogy mental model, the object model, and the safety invariants a reviewer should hold every CAS protocol against.' +sidebar_label: 'Architecture overview' +sidebar_position: 1 +slug: /antalya/cas/architecture/ +title: 'CAS Architecture — Overview' +doc_type: 'reference' +--- + +# CAS architecture — overview {#overview} + +`CAS` ("content-addressed storage") is a `MetadataStorage` back-end for object-storage disks +(`metadata_type = cas`) that stores every `MergeTree` part file once, addressed by the hash of +its content. Many servers share one object-storage pool with no byte duplication, no zero-copy +bookkeeping in `Keeper`, no per-replica local-disk reference state that grows with data volume, +and no mutable per-blob refcount. + +It is still experimental — that is deliberate, not a caveat to apologize for. Pre-release means +the format can still change cheaply, with zero compatibility scaffolding, and the design can +still be iterated on invariants rather than migrations. The bet: all you need underneath is a +good S3 bucket. No external coordinator, no metadata service, no Keeper state proportional to +data — the pool is self-describing, and everything CAS needs to agree on (refs, leases, GC +leadership, fencing tokens) is an object in the bucket. + +This page is the entry point of a 4-page set: it gives the mental model. Deeper detail on +storage layout, the write/read protocols, and GC lives in the other three pages. + +## The Git analogy {#git-analogy} + +The fastest way to load the model is Git, which most readers already carry: + +| Git | CAS | +|---|---| +| blob (file content by hash) | **blob** — one part file's bytes, keyed by content hash | +| tree (directory listing) | **part manifest** — the immutable file list of one part | +| ref (`refs/heads/main`) | **ref** — `part name → manifest id`, the only mutable state | +| `gc` / reachability | **GC round** — an in-degree fold over refs → manifests → blobs | + +Where the analogy breaks: Git's objects are locally addressed and GC runs against a single +repository with no concurrent writers; CAS objects are addressed inside a shared, multi-writer +object-storage pool, and its GC round has to reason about ambiguity (crashed writers, +in-flight precommits, eventually-consistent `LIST`) that a local Git repository never faces. +Git also has no equivalent of a CAS ref's precommit state — a CAS ref transition is durable +before the blob it names is guaranteed reachable, never the other way round. + +## The object model {#object-model} + +Four durable object kinds exist in a pool: one mutable (the ref), three immutable +(part manifest, blob, and a blob's condemnation-marker sidecar). + +```mermaid +graph TD + R["Ref: part name maps to manifest id"] + M["Part manifest: file list of one part"] + B["Blob: one part file's bytes, keyed by content hash"] + BM["Blob meta: condemnation marker sidecar"] + + R -->|names| M + M -->|entry references| B + B -.->|sidecar| BM +``` + +**The reachability rule, stated once:** a blob is live if and only if some live manifest names +it, and a manifest is live if and only if some ref — committed or precommitted — names it. `GC` +computes exactly this and nothing else. + +## Safety invariants {#safety-invariants} + +The full numbered list lives in the CAS agent guide; this is the reader-facing summary of the +substance: + +| Invariant | What it means | +|---|---| +| No silent data loss | No path may delete an object a committed reference still names | +| Revival is re-upload only | A condemned blob is never revived by copying it — only by re-uploading the original bytes under a fresh identity | +| Exact-token deletes | Every delete names the exact object incarnation it removes, never "the object at this key" | +| `TOKEN ⟹ CONTENT` | A repeated write token implies unchanged bytes — the backend must never let a token be reused over different content | +| Fail closed on ambiguity | An operation that may have landed is never treated as one that did not | +| One content-delete site | Exactly one place in the whole codebase ever deletes a blob body, gated on a previously published `GC` round | +| `GC` never invents history | Cleaning up an abandoned write is the writer's job, not `GC`'s | +| Over-count only | A lost or duplicated `GC` fold can only delay a reclaim, never bring one forward | +| No dangle / no loss / no return | A live ref always resolves through present objects; a delete requires proven unreachability at an exact token; a retired object identity is never valid again (though the same logical key can return under a new token) | + +## Positioning: shared-nothing, not shared-state {#positioning} + +Each server owns the catalog rows under its own identity and writes only its own state objects +— that part is shared-nothing, same as `ReplicatedMergeTree` today. What CAS adds is a single +**shared** resource: the blob content space, addressed purely by content hash, which is +write-once and conflict-free by construction — two servers writing the same content write the +same key with the same bytes, so there is nothing to reconcile. The only mutual exclusion CAS +needs anywhere is a conditional write (create-if-absent, or compare-and-swap on a token) against +a single object. + +That is deliberately not a coordinator or a serializable metadata service: there is no external +coordinator, and no `ZooKeeper`/`Keeper` usage inside the pool protocol itself. `Keeper` stays +exactly where `ReplicatedMergeTree` already used it — replication log and part-set consensus — +and its load does not grow with pool size, because the pool's own bookkeeping never touches it. + +## The subsystem pages {#subsystem-pages} + +| Page | Covers | +|---|---| +| [Storage layout](/antalya/cas/architecture/storage-layout) | Every S3 key shape, the object envelope, codecs, a worked example tree | +| [Namespaces](/antalya/cas/architecture/namespaces) | Namespaces, `life_id`, the catalog, and their lifetime | +| [Blob protocol](/antalya/cas/architecture/blob-protocol) | Conditional writes, deduplication, the writer-vs-GC race | +| [Part lifecycle](/antalya/cas/architecture/part-lifecycle) | Build, precommit, upload, promote; crash points and their cleaners | +| [Manifests and refs](/antalya/cas/architecture/manifests-and-refs) | Part manifests and the ref machinery: publish, fold, recovery | +| [Mounts and leases](/antalya/cas/architecture/mounts-and-leases) | Server identity, the owner claim, the mount lease, fencing | +| [Replication](/antalya/cas/architecture/replication) | Fetch-by-relink between replicas sharing one pool | +| [Read path](/antalya/cas/architecture/read-path) | Ref resolution, manifest reads, ranged blob reads, the caches | +| [Garbage collection](/antalya/cas/architecture/garbage-collection) | Leadership, the round, sharding, cost, observability | +| [Backend abstraction](/antalya/cas/architecture/backend) | Provider dialects for conditional writes, the capability probe | +| [Correctness](/antalya/cas/architecture/correctness) | TLA+ models, counterexamples, soak methodology, test coverage | +| [Design history](/antalya/cas/architecture/design-history) | The rejected designs and the major pivots | diff --git a/docs/en/antalya/cas/architecture/manifests-and-refs.md b/docs/en/antalya/cas/architecture/manifests-and-refs.md new file mode 100644 index 000000000000..bd23b51e9a5f --- /dev/null +++ b/docs/en/antalya/cas/architecture/manifests-and-refs.md @@ -0,0 +1,260 @@ +--- +description: 'Part manifest structure and lifecycle, the ref table as the only mutable state in a CAS pool, the publish protocol, and the orphan-manifest sweep.' +sidebar_label: 'Manifests and refs' +sidebar_position: 5 +slug: /antalya/cas/architecture/manifests-and-refs +title: 'CAS Architecture — Manifests and Refs' +doc_type: 'reference' +--- + +# CAS architecture — manifests and refs {#manifests-and-refs} + +A part manifest is the immutable file list of one `MergeTree` part; a ref is the mutable pointer +from a part name to the manifest that currently backs it. Together they are the two object kinds +that make a CAS pool's state machine: manifests never change, refs are the only place anything +moves. This page covers what a manifest contains, how a manifest becomes reachable or becomes an +orphan, how a ref mutation is published durably, and how a mounted server recovers a ref table +after a crash or a fresh mount. The write/promote sequence that drives these primitives is on the +[part-lifecycle page](/antalya/cas/architecture/part-lifecycle); how `GC` folds ref history into +blob liveness is on the [garbage-collection page](/antalya/cas/architecture/garbage-collection). + +## Part manifests {#part-manifests} + +A manifest (`cas_part_manifest`, `Formats/CasPartManifestFormat.h`) has four top-level fields: +`ref` (its own id, repeated in the body for fail-closed validation), `root_namespace_id` (the +owning namespace, likewise repeated), `payload_digest` (integrity/debug only — never a key, never +a dedup input, never a `GC` edge), and `entries` — strictly ascending by path after decode. Each +entry is `{path, placement, BlobRef, blob_size, inline_bytes}`; the hash algorithm travels **per +entry**, so one manifest may legitimately mix algorithms if the pool has more than one enabled. + +A manifest deliberately holds **no** offsets, no packed-file support, no projections field, no +codec info, no parent-manifest link, no source edges, and no incarnation token. One blob is one +file's bytes; a read window is `{blobKey, blob_header_len, blob_size}`. A projection is an +ordinary entry whose path has a `.proj` component. The incarnation token is the backend `ETag` +observed by a `HEAD`, never stored in the manifest. + +**The manifest id is neither a content hash nor random.** It is +`ManifestRef = {writer_epoch, build_sequence, manifest_ordinal}` — durable writer epoch times +monotone per-incarnation build sequence times monotone per-build ordinal — which gives "no +manifest id reuse" by construction with no randomness needed. The `GC`-level identity is the pair +`ManifestId = (RootNamespace, ManifestRef)`; two namespaces may legally carry the same +`ManifestRef`. + +Backpressure caps are enforced before the body is written (`Pool/CasPartWriteTxn.cpp`): + +| Cap | Limit | +|---|---| +| Entries per manifest | 1 048 576 | +| Encoded manifest text | 256 MiB | +| Total inline bytes | 16 MiB | +| Largest single inline entry | 1 MiB | + +A manifest is written once with a conditional create (`putIfAbsentStream`) and **never rewritten**. +A different object at that key would be an id collision and is `CORRUPTED_DATA`, fail-closed, +before any owner transition names it. Rewriting a part therefore writes a **new** manifest over +the **same** blobs and moves the ref in one ref-log record — a repoint, covered in full on the +[part-lifecycle page](/antalya/cas/architecture/part-lifecycle#repoint). + +## Manifest lifecycle and the orphan sweep {#manifest-lifecycle} + +```mermaid +stateDiagram-v2 + [*] --> Staged: stageManifest, body PUT write-once + Staged --> PrecommitOwned: precommitAdd, ref-log OwnerTransition, plus-one edges on fold + PrecommitOwned --> Committed: promote, Precommit to Committed, no edge, net zero + Committed --> OwnerRemoved: drop or repoint or namespace removal, minus-one edges + OwnerRemoved --> [*]: GC deletes the body after the decrements are sealed + + Staged --> OrphanA: writer died before precommitAdd + OrphanA --> [*]: writer best-effort delete, else the orphan sweep + + PrecommitOwned --> DanglingPrecommit: writer died before promote + DanglingPrecommit --> OwnerRemoved: binding removed by abandon or a successor stale-precommit sweep +``` + +Two disjoint failure classes matter here: + +- **Pre-precommit orphan.** The body exists but no ref-log record ever named it. It contributes no + edges and nobody protects it — this is exactly what the orphan sweep below reclaims. +- **Dangling precommit.** The transaction died between `precommitAdd` and `promote`. Nothing wakes + it up on its own: a `PartWriteTxn` is never persisted. The binding must be removed by a ref-log + transaction — either the live writer's own `abandon`, or a fenced successor's stale-precommit + sweep, which removes precommits whose `manifest_ref.writer_epoch < live_epoch` + (`Pool/CasRefLedger.cpp`). Only after that minus-one folds does `GC` delete the body, on the + ordinary owner-removal path. + +The writer's own best-effort cleanup deliberately **skips** the precommit target once a precommit +was even attempted — including an uncertain outcome — because deleting a body that turns out to be +a live precommit would clamp `GC`'s fold barrier forever. + +### The orphan-manifest sweep {#orphan-sweep} + +Runs as the last phase of the `GC` round, cursor-paced and budgeted, wrapped so it can never fail +a round (`Gc/CasOrphanManifestSweep.cpp`). Eligibility comes **exclusively** from the durable +watermark in the mount lease — there is no age threshold and no time-based grace period anywhere +in this protocol. No mount lease for the `server_root_id` means no deletion authority means +nothing is swept for that root. + +```mermaid +flowchart TD + A["LIST one page of cas/manifests/
budget: manifest_sweep_list_budget_keys"] --> B{"build-prefix eligible?
durable watermark fact only"} + B -->|"epoch less than lease epoch"| ELIG["eligible, old-epoch debris"] + B -->|"same epoch, min_active clears build_seq"| ELIG + B -->|"no lease, or epoch ahead, or build may be live"| SKIP["skip"] + ELIG --> C["protection view: committed manifests
plus live precommits
plus manifests with an unfolded minus-one"] + C -->|"key protected"| SKIP2["skip"] + C -->|"not protected"| D["deleteExact key, token"] + D -->|Deleted| E["emit ManifestDelete audit event"] + D -->|"NotFound or TokenMismatch"| SKIP3["spared, a fresh owner reclaimed the key"] + E --> F["CAS gc/state with the advanced cursor"] +``` + +The protection view is built from the **same complete replay** that writer recovery uses, and a +namespace whose view fails to build is added to an errored set with **all** of its deletions +skipped — an empty owner set is never substituted for a failed one. The sweep deletes only +manifest bodies and emits no blob deltas, correct precisely because a pre-precommit body never +contributed a `+1`. Contrast with the owner-removal path, which is ordered the other way: fold the +`-1` edges, adopt the decrements in the round `CAS`, *then* delete the body — a crash there leaks +a body to this sweep, never a dangle. + +## Source edges: how a manifest makes blobs live {#source-edges} + +Blob liveness is a **set of source edges**, not a counter (`Gc/CasBlobInDegree.h`) — which is what +makes `GC`'s fold idempotent. An edge id is `sourceEdgeId(ManifestId, path)`, a deterministic hash +over the namespace, epoch, build sequence, ordinal and path — an edge *identity*, deliberately not +a content hash and not reconstructable. + +Edges are never written at manifest-write time. They materialize only when `GC` folds a ref-log +transaction that changes ownership: add-precommit means `+1` per blob entry; either removal means +`-1`; **promote means no edge at all**, because the manifest never loses an owner, so it is net +zero. Inline entries produce no edges — they have no separate object to reclaim. + +## The ref table {#ref-table} + +A ref is the only mutable state in the whole system, so this is where the concurrency design is +concentrated. + +- **Name** — a canonical clean relative path, in practice the part directory name with an optional + `detached/` or `moving/` prefix. +- **Value** — `{ref_name, ManifestRef, published_at_ms}`. There is **no** token/`ETag` in a ref + row; the cross-server "confirm token" is the text form `epoch:build:ordinal`. +- **Scope** — one ref table per `RootNamespace`, i.e. per table per server root. +- **Ownership slots** — a `ManifestRef` has at most one owner across the table, in one of two + slots: `Committed` or `Precommit`. Precommits are keyed by the pair `(ref_name, manifest_ref)`, + so several in-flight builds may legitimately contend for one ref name. + +In memory, `RefTableState` holds a copy-on-write map of committed rows, a set of precommits, an +ownership index enforcing the one-owner rule, a lifecycle (`Live`/`Removed`), the greatest applied +transaction id, and byte-size counters used for admission. Copying a state is a refcount bump, so +a flush's trial and candidate copies cost proportional to touched rows, not the whole table. +Network I/O is never performed while holding the state lock, so a reader sees either a whole +transaction or none of it. + +Two immutable object kinds carry the durable form under `cas/ns/stream//` (see the +[storage-layout key table](/antalya/cas/architecture/storage-layout#key-table)): a log object +holds exactly one transaction, `{namespace, txn_id, ops[]}`; a snapshot object holds one live table image +— sorted committed rows plus precommits. Mutable/path-addressed state lives separately under +`cas/ns/state//`: the per-life `_ckpt` checkpoint and any namespace-owned `_files/`. + +`RefTxnId = {writer_epoch, ref_sequence}` renders as two fixed-width hex fields, so lexical key +order equals tuple order. Ids are per-namespace and contiguous: within one `(namespace, +writer_epoch)` they run `1, 2, 3, …` with no holes, and a new mount epoch restarts the sequence at +`1`. A hole is therefore corruption, not an allocation artifact, and a non-successor id is rejected +as `CORRUPTED_DATA`. + +The op vocabulary is deliberately tiny: `NamespaceBirth`, `OwnerTransition{old?, new?}`, +`SetPublishedAt`, `RemoveNamespace`. There are exactly four legal `OwnerTransition` shapes — add +precommit, remove precommit, remove committed, and promote — enumerated identically by the state +machine and by `GC`'s edge extractor, so the two readers of the format cannot drift. + +Logs are pure conditional creates on write-once keys. There is no append-to-object and no +`CAS`-swapped mutable pointer anywhere in the ref lane. The writer never deletes ref objects; only +`GC` does, once coverage and a live snapshot both make a log safe to remove. + +Snapshots publish in the background, best-effort, one in flight per table, when the tail exceeds a +log-count or log-byte threshold. + +## Publishing a ref mutation {#publish-protocol} + +All mutations funnel through one flat-combining lane, `CasRefLedger::appendRefOps`. A single flush +carves a batch out of the queue and commits it as one or more transactions. + +```mermaid +flowchart TD + Q["appendRefOps enqueues ops"] --> REC["ensure the table is recovered"] + REC --> FEN{"mount fence still live?"} + FEN -->|no| FAIL0["fail the whole carved queue, retry error"] + FEN -->|yes| W{"outstanding wedge?"} + W -->|yes| WR["resolve the wedge by its exact key first"] + WR -->|resolved durable| INST0["install candidate, clear wedge"] + WR -->|still unresolved| FAIL1["fail the queue, stay wedged, never allocate a new id"] + W -->|no| CARVE["two-phase carve: plan may throw, publish never throws"] + CARVE --> VAL["per-item validation: caps, shape, byte budget
a failing item fails alone"] + VAL --> PREP["build candidate state and the complete wedge before the PUT"] + PREP --> PUT["putIfAbsent the ref-log key"] + PUT -->|Committed| OK["allocation-free install: swap state, bump counters, complete waiters"] + PUT -->|DefiniteFailure| GAP["fail survivors, id not consumed"] + PUT -->|"Unresolved, provably nothing sent"| NOSEND["do not wedge"] + PUT -->|"Unresolved, otherwise"| WEDGE["install the prepared wedge, survivors fail Uncertain"] + OK --> SNAP["maybe schedule a snapshot publish"] +``` + +The **wedge** is the mechanism that makes fail-closed ambiguity concrete: at most one per table, +recording the single conditional `PUT` whose outcome is unknown, complete with the key and the +sealed bytes. The next flush must resolve *that exact key* before it may allocate a new transaction +id — an unresolved write can never silently become a gap, and the ledger never double-publishes. + +Crash points: between the `PUT` and the install, the object is durable and unapplied — the next +mount's recovery replays it. Between a precommit and its promote, a dangling precommit is reclaimed +by the successor's stale-precommit sweep, described above. + +## Recovery {#recovery} + +Recovery is lazy per table, on first touch (`Pool/CasRefLedger.cpp`), and reads only named, +authoritative objects — there is no `LIST` anywhere in this path: + +1. **Exact `GET` of `_ckpt`.** The durable checkpoint is the sole source of the recovery grounding: + `chooseRecoveryGrounding` derives the base (a snapshot id, or genesis if there is none) and the + exact transaction to walk from purely from the checkpoint's own fields + (`committed_through`/`checkpoint_snapshot_id`/`life_epoch`) — recovery never enumerates its own + stream to find them. +2. If the grounding names a snapshot, `GET` and decode it as the replay base. +3. Walk forward by exact key from there, one transaction resident at a time: `GET` + `cas/ns/stream//-`, decode, apply, discard, advance to the next + arithmetic id. Every key this walk touches is a dense, deterministic successor of the last — + never a listed or guessed one. +4. **Absence is a decision point, not an error.** Finding a slot empty is either the live epoch's + stream legitimately ending there, or — for a dead predecessor epoch — the exact slot where its + closing `EpochSeal` must be written before the table may be trusted; the two cases are + distinguished by whether the epoch being walked is still live, not by retrying a listing. +5. Recovery may itself advance `_ckpt` as it replays, each time via a conditional write against the + checkpoint it last read; the write is re-verified with a fresh exact `GET` afterward, and a + concurrent winner's farther frontier is honored by restarting from that newer checkpoint rather + than trusting the write blindly. +6. Transient network errors retry the whole attempt with capped backoff; corruption and logic + errors fail fast. + +For a mounted writer the recovered in-memory table is authoritative for reads of its own +namespaces — there is no other writer of that namespace. S3 is authoritative for durability: +in-memory state advances only after a durable `PUT`, and a caller's `appendRefOps` returns only +after the durable install. In-flight precommits are visible only through the precommit set, never +through an ordinary ref resolve. + +Two cross-process readers see a different, colder view, but only at the discovery boundary: `GC` +and `ca-fsck` `LIST` once to discover which namespaces exist, staleness-bounded by whatever was +durable at `LIST` time, so a namespace born after that `LIST` is invisible to this pass. Within +each discovered namespace, the replay itself is not `LIST`-driven — it is the same exact-`GET`, +`_ckpt`-grounded arithmetic walk described above, just called from a caller-supplied catalog entry +instead of a live mount. The relink-confirm handshake (see the +[replication page](/antalya/cas/architecture/replication#relink-gates)) does zero object-store I/O +and answers `Yes` only against the resident, warm, fence-live in-memory table — `No` is not proof +of the negative, only `Yes` is fence-gated. + +## Namespace removal {#namespace-removal} + +Namespace removal has no physical-empty handshake. The writer changes the catalog row from `Live` +to `Removing`, appends the exact removals plus `RemoveNamespace`, and deletes nothing itself. The +`GC` fold attaches cleanup evidence to that life row; a later invocation's pre-fold drain exact-CAS +-deletes the matching `Removing` catalog row before any successor plan publishes. A perpetual +namespace janitor and the orphan-manifest sweep reclaim physical debris independently — a same-name +birth waits only for the catalog row to disappear, never for physical emptiness. diff --git a/docs/en/antalya/cas/architecture/mounts-and-leases.md b/docs/en/antalya/cas/architecture/mounts-and-leases.md new file mode 100644 index 000000000000..468a783297a6 --- /dev/null +++ b/docs/en/antalya/cas/architecture/mounts-and-leases.md @@ -0,0 +1,223 @@ +--- +description: 'How a CAS server establishes identity, claims its mount slot, and holds a renewable lease that fences stale writers out of the pool.' +sidebar_label: 'Mounts and leases' +sidebar_position: 4 +slug: /antalya/cas/architecture/mounts-and-leases +title: 'CAS Architecture — Mounts and Leases' +doc_type: 'reference' +--- + +Page 4 of 4 in the CAS architecture set. Covers server identity, the mount lease that fences +writers, and the server-scoped control-plane objects. No external coordinator is involved: there +is no ZooKeeper/Keeper client anywhere in this protocol — `MountLeaseKeeper` is a local lease +*renewer*, not a Keeper client. + +## `server_root_id` — the identity {#server-root-id} + +Every content-addressed disk must be configured with an explicit `server_root_id`. It is +validated and immutable, and deliberately **not** derived from `ServerUUID` — two replicas can +otherwise regenerate the same `ServerUUID` from a wiped local state directory, which must not +silently steal an existing identity. + +Validation (`validateServerRootId`, `Pool/CasServerRoot.h`) is fail-closed `BAD_ARGUMENTS`, no +sanitizing fallback: non-empty, at most 255 bytes, no empty/`.`/`..` path segment, no `_files` or +`_manifests` segment. + +It roots four subtrees and owns catalog names at or below ``: + +| Subtree | Contents | +|---|---| +| `gc/server-roots//` | owner, epoch, mount — the three control-plane objects below | +| `roots//` | loose mountpoint objects, no namespace/catalog association | +| `cas/manifests//` | part manifests | +| `staging//` | S3-staging debris, outside every GC `LIST`, reclaimed only by this server's next mount | + +`blobs/` is **not** under the `server_root_id` — content is pool-global, which is what makes cross-server +dedup work. Ref/namespace keys are also deliberately opaque and do not embed the `server_root_id`. + +Each replica sharing a backend endpoint must use a distinct `server_root_id`; omitting the setting +is a startup error. + +## The owner claim {#owner-claim} + +`claimOwnerOrThrow` binds `server_root_id` ↔ `server_uuid` **permanently**. The owner object is never deleted +and never reassigned — decommission only tombstones it in place. + +| Observed at `gc/server-roots//owner` | Action | +|---|---| +| present, same `server_uuid`, not tombstoned | proceed | +| present, `retired_at_ms` set | `CORRUPTED_DATA` — explicitly decommissioned, refuses to resume | +| present, different `server_uuid` | `CORRUPTED_DATA` — names the regenerated-uuid-file cause | +| absent, subtree provably empty | `putIfAbsent` the owner (claim) | +| absent, subtree non-empty | `CORRUPTED_DATA` — identity lost over existing data | +| lost the `putIfAbsent` race | re-read; equal uuid proceeds, else `CORRUPTED_DATA` | + +"Provably empty" requires both an authoritative decoded catalog naming no life owned by `server_root_id` and +a 1-key `LIST` probe finding nothing under `cas/manifests//` or `roots//`. + +Two failure modes this closes: + +- A **second server with a different `server_uuid`** is refused at this gate and can never take + over, regardless of lease expiry. +- A **same-uuid live twin** (two processes sharing one uuid file and `server_root_id`) is caught separately, by + the mount claim's token-stability observation, and aborts with an operator-facing message rather + than corrupting the pool. + +## The mount lease {#mount-lease} + +One object, `gc/server-roots//mount`, carries **both** the liveness lease and the build +watermark — there is no separate watermark object. `MountLease` fields: `server_uuid`, +`writer_epoch`, `hostname`, `pid`, `started_at_ms`, renewal `seq`, `expires_at_ms`, `min_active` +(the build-watermark floor), and `gc_fenced`. + +- **Cadence.** Renew every `mount_renew_period` (default 10 s), TTL `mount_lease_ttl_ms` (default + 30 s, TTL/3 renewal ratio). Each beat is a token-guarded `putOverwrite` bumping `seq + 1` — + `MountLeaseKeeper` never re-mints the object. +- **Local fence clock.** `CLOCK_BOOTTIME`, not `CLOCK_MONOTONIC`, so a VM resumed from suspend + correctly observes itself expired. The deadline anchors at attempt-*start*, never at response + time. +- **Per-write recheck.** Every durable write or delete captures the fence generation at admission + and rechecks it immediately before the object-store call and on every conditional retry. Reads + are not gated. +- **Request-budget admission.** `refAppendFenceOk` refuses to *start* a ref-log attempt unless + `attempt_timeout + safety_margin` fits inside the remaining lease, rejecting with + `BAD_ARGUMENTS` at request-admission time rather than mid-flight. + +**Losing the lease is neither read-only mode nor an abort.** It trips the local fence (latches +`lost`, bumps the fence generation, moves the in-process runtime to `TransientNotLive`) and +schedules a self-remount with exponential backoff from 1 s to 30 s. Only a *foreign* `server_uuid` +observed on the mount body is `LOGICAL_ERROR` — the owner anchor makes a foreign claim +protocol-unreachable, so seeing one is an invariant violation, not a recoverable race. A +`putOverwrite` that threw *before* observing any outcome does not fence while the confirmed +deadline is still comfortably ahead — only a **confirmed** mismatch is immediately terminal. A real +fence still costs only an epoch: recovery re-claims with a fresh one, bounded at 3 attempts. This +is the fail-closed posture from the general CAS invariant: doubt about the source aborts, doubt +about the mechanism may retry. + +GC's own view of a dead server is symmetric and clock-skew-immune: a slot becomes fence-eligible +only after the leader observes the *same* renewal token hold stable, on its own monotonic clock, +for `TTL + TTL/20 + cadence` — the identical formula a re-mounting server uses to wait out a +predecessor. The stamped `expires_at_ms` never participates in that decision; wall-clock `now` is +audit-only. + +## The two monotone counters {#counters} + +| Counter | Storage | Scope | Protects against | +|---|---|---|---| +| `writer_epoch` | durable, `gc/server-roots//epoch` (`ServerEpoch::next_writer_epoch`, CAS-bumped by `allocateWriterEpoch`) | across crashes and restarts | a same-`(uuid, epoch)` twin: a present mount under a normal claim attempt is `CORRUPTED_DATA` | +| `build_seq` | in-memory only, `CasMountRuntime::next_build_seq`, reset to 1 on every process start | one process incarnation | orders builds *within* an epoch; combined with `writer_epoch` it gives GC a total order | + +The absent-epoch branch of `allocateWriterEpoch` is deliberately paranoid: absent with a +non-empty subtree is `CORRUPTED_DATA` (reset hazard); absent with an empty subtree decides by an +authoritative probe, never by plain-`get` absence, because a transport fault must not be flattened +into "not found". + +Global build ordering is the **pair** `(writer_epoch, build_seq)` compared lexicographically — the +exact comparison GC uses for eligibility. The durable authority for both is the mount object +itself: no mount means no deletion authority means nothing is swept. `min_active`, the oldest +in-flight `build_seq`, rides in the same mount object as the watermark floor; `UINT64_MAX` in +`min_active` is the farewell/retired sentinel, not a real build. + +## Mount claim outcomes {#claim-outcomes} + +The implementation does not expose a single named durable-slot enum; `claimMount` instead returns +a `MountClaimResult::Kind` together with a `MountPriorState` describing which certificate of death +(if any) justified a reclaim: + +| `Kind` | Meaning | +|---|---| +| `Claimed` | fresh claim (absent slot), same-`(uuid, epoch)` refresh, or a certified reclaim | +| `LiveDoubleStart` | same `server_uuid`, different `writer_epoch`, and no certificate of death yet — a live twin, wait it out | +| `ForeignOwner` | different `server_uuid` — refused unconditionally | +| `FencedSelf` | same `(uuid, epoch)`, but `gc_fenced` — terminal for *this* epoch; the caller must mint a fresh one | + +| `MountPriorState` | Certificate that justified the reclaim | +|---|---| +| `None` | no reclaim needed (fresh claim or same-epoch refresh) | +| `Clean` | the predecessor's own graceful farewell (`min_active == UINT64_MAX`) | +| `Fenced` | GC's own threshold-gated fence-out (`gc_fenced`) | +| `UncleanObserved` | this claimant's own token-stability observation held for the full `TTL + drift` window | + +## Behavioral mount-slot model {#mount-state-machines} + +Two coupled state pictures. Neither is a literal source enum — the durable slot is derived from +the claim outcomes above and is shown here as behavior, not as a type in the code: + +```mermaid +stateDiagram-v2 + [*] --> Absent + Absent --> Live: claimMount putIfAbsent, seq=1 + Live --> Live: keeper beat, putOverwrite seq+1 + Live --> Fenced: GC observes a stable token past threshold, gc_fenced=1, body preserved + Live --> Terminated: certified drain, terminal farewell (expires_at=now, min_active=MAX) + Fenced --> Live: same-uuid claim with a fresh writer_epoch, instant reclaim + Terminated --> Live: same-uuid claim with a fresh writer_epoch, instant reclaim + Live --> Live: same-uuid claim, proven-dead token via UncleanObserved + Fenced --> Fenced: same uuid and epoch claim, FencedSelf, no write + Live --> Absent: decommission tail, mount then epoch then owner tombstone + Terminated --> [*] +``` + +The in-process `PoolLifecycle` runtime, by contrast, is a literal enum (`CasMountRuntime.h`): + +```mermaid +stateDiagram-v2 + [*] --> Live: Pool constructed, fence unarmed + Live --> Live: mountWritable arms the fence + Live --> TransientNotLive: renewal failure, tripMountLost, lost=true + TransientNotLive --> Live: self-remount succeeds with a fresh epoch + TransientNotLive --> TransientNotLive: probe inconclusive, retry with backoff + TransientNotLive --> IdentityLost: pool meta and owner both authoritatively absent + TransientNotLive --> VanishedReplaced: foreign pool_id observed + Live --> VanishedForgotten: SYSTEM CAS FORGET + IdentityLost --> [*] + VanishedReplaced --> [*] + VanishedForgotten --> [*] +``` + +`IdentityLost`, `VanishedReplaced` and `VanishedForgotten` are terminal and absorbing: the remount +and GC threads self-exit, and there is deliberately no auto-revive — an identity disappearing +under a live mount is an operator-level event. + +## Mount, unmount, crash {#mount-lifecycle} + +**Writable open** runs in a strict order: bootstrap-residual proof, capability probe under a +random per-mount prefix, pool-meta create-or-validate, `validateServerRootId`, owner claim, +`allocateWriterEpoch`, mount claim and keeper adopt, materialization grace if the predecessor was +unclean (default 30 s), arm the fence, start background renewal. If the grace period consumed the +TTL, one fresh renewal re-anchors the deadline before the fence is armed. + +**Clean unmount:** stop and join the remount thread, drain the ref lanes, and only if the drain +*certified* quiescence write the terminal farewell (`expires_at_ms` already-expired, +`min_active = UINT64_MAX`). That sentinel is what lets a successor reclaim instantly. If the drain +did not certify, the keeper stops renewing and writes no farewell — an unearned farewell would let +a successor start mutating while a stale conditional write from the predecessor is still in +flight. + +**Crash:** no farewell; the renewal token freezes. Recovery is either the same server restarting +and waiting out the token-stability observation, or the GC leader fencing the slot first, after +which any reclaim is instant. + +**Permanent removal** of a dead replica (`Cas::decommissionPoolMember`, driven by +`SYSTEM CAS DROP POOL MEMBER '' FROM DISK ''`) claims the victim's mount slot +as an administrative writer with a no-wait policy (refuses immediately if the member is alive), +drops every ref-bearing namespace, sweeps manifest debris before the slot (deleting the mount +removes the watermark authority), drains staging and roots, then — only with zero warnings — +retires in order: mount, epoch, a final liveness re-check, owner tombstone. + +## `system.cas_mounts` {#mounts-table} + +A read-only view of the same heartbeat-floor computation GC uses: one `LIST` of +`gc/server-roots/` plus one `GET` per slot, zero writes, per-row fail-open (an undecodable body +becomes `state = 'corrupt'`, never an exception). Shows every `server_root_id` in the pool, including peers. + +| Column | Notes | +|---|---| +| `disk`, `server_root_id`, `server_uuid`, `hostname`, `process_id` | identity | +| `writer_epoch`, `renewal_sequence`, `started_at`, `expires_at`, `min_active_build_sequence`, `gc_fenced` | lease state (`DateTime64(3)` columns; the millisecond-integer field names live only in the internal `MountLease` struct and the on-disk body) | +| `state` | one of `live`, `expired`, `terminated`, `fenced`, `corrupt` | +| `is_leader`, `pending_reclaim`, `last_success_age_seconds`, `wedged_namespace_count` | GC health, process-local; **`NULL` on every peer row** — a process-local fact must never be stamped onto another server's row | +| `lifecycle`, `lifecycle_reason`, `lifecycle_detail`, `lifecycle_since` | the SQL surface for the in-process `PoolLifecycle` runtime above: `lifecycle` is one of `live`, `not_live`, `identity_lost`, `vanished`, `constructing`, `shutdown`; `lifecycle_reason` distinguishes `replaced` from `forgotten` for a `vanished` disk; `lifecycle_detail` carries the full diagnosis text; `lifecycle_since` is when the current non-live state began (`NULL` while live) | + +The lifecycle snapshot is I/O-free and ungated, so a not-live, never-started, or vanished disk +still produces a row instead of silently disappearing from the table. diff --git a/docs/en/antalya/cas/architecture/namespaces.md b/docs/en/antalya/cas/architecture/namespaces.md new file mode 100644 index 000000000000..2f622ff0e329 --- /dev/null +++ b/docs/en/antalya/cas/architecture/namespaces.md @@ -0,0 +1,172 @@ +--- +description: 'What a namespace is, the opaque life_id that qualifies every object it owns, the pool-wide namespace catalog, and a namespace lifetime end to end from first write to catalog-row deletion.' +sidebar_label: 'Namespaces' +sidebar_position: 10 +slug: /antalya/cas/architecture/namespaces +title: 'CAS Architecture — Namespaces' +doc_type: 'reference' +--- + +# CAS architecture — namespaces {#namespaces} + +A namespace (`Cas::RootNamespace`) is the opaque, per-table, per-server-root string under which one +table's part manifests and one ref table live — in practice something the wiring layer composes, +such as `srv1/` for an ordinary table or `shadow//` for a `FREEZE` +shadow. `CAS` never interprets its contents beyond a shape check (non-empty, no empty or reserved +path segment, at most 512 bytes). The [manifests-and-refs page](/antalya/cas/architecture/manifests-and-refs#ref-table) +covers the ref table one namespace owns; this page covers the namespace itself — its physical +identity, the catalog that is the sole authority for whether it exists, and its full lifetime from +first write to the catalog row's deletion. + +## `life_id`: the physical identity {#life-id} + +A namespace **name** can be reused — a table dropped and recreated keeps the same name. What must +never be reused is the **physical identity** any durable object under that name is keyed by, so +that a stale reader of the old incarnation can never be handed bytes belonging to the new one. That +identity is `life_id`: an opaque, pool-wide, randomly minted 128-bit value (two `thread_local_rng` +draws; retried on the astronomically unlikely zero draw, since `0` is reserved as "never a valid +life"). Internally it is the catalog's `incarnation` field, aliased as `NamespaceLifePhysicalId`; +paired with the namespace name it forms `NamespaceLifeId{ns, incarnation}` +(`Primitives/CasNamespaceLifeId.h`). + +`NamespaceLifeId` deliberately has no default construction and no conversion from a bare namespace +name: code holding only the name cannot address a ref object or a namespace file at all, so +forgetting the life qualifier is a compile error, not a runtime aliasing bug. The only legitimate +source of a `NamespaceLifeId` is `fromCatalogEntry` — reading it off one immutable catalog cut — +which is what makes "this life belongs to this name" a catalog fact rather than something a caller +could reconstruct incorrectly. + +`life_id` renders as 32 fixed-width lowercase hex digits and appears in exactly the two subtrees +that are life-owned (see the [storage-layout key table](/antalya/cas/architecture/storage-layout#key-table)): + +| Subtree | Contents | +|---|---| +| `cas/ns/stream//` | The immutable `_log`/`_snap` ref-transaction history | +| `cas/ns/state//` | The mutable `_ckpt` checkpoint and any namespace-owned `_files/` | + +Part manifests deliberately do **not** carry `life_id` — a manifest already has its own globally +unique identity (`{writer_epoch, build_sequence, manifest_ordinal}` under the server root, see the +[manifests-and-refs page](/antalya/cas/architecture/manifests-and-refs#part-manifests)) and needs no +further qualification. Loose mountpoint objects under `roots/` are outside namespace ownership +altogether and carry no `life_id` either. + +## The namespace catalog {#catalog} + +One pool-wide object, `cas/ref_catalog` (`Layout::refCatalogKey`), is the sole authority for which +namespaces exist. It is read on every fold round and every ref-table recovery, and mutated by one +token-`CAS` write per lifecycle transition. Its entries are canonically ordered by namespace bytes, +strictly ascending, with no duplicate name — both the encoder and the decoder enforce this, so an +out-of-order or duplicate-keyed catalog can never become durable. + +Each row (`CatalogEntry`) carries: + +| Field | Meaning | +|---|---| +| `ns` | The namespace name | +| `state` | `Creating`, `Live`, or `Removing` — see below | +| `incarnation` | The `life_id` for this row, nonzero, never reused | +| `creator` | The mounted writer's fence identity (server root, writer epoch, admission fence generation) that is creating this row — **required** iff `state == Creating`, **forbidden** otherwise | +| `removal_started_round` | The `GC` round observed when removal began — **required** iff `state == Removing`, absent otherwise | + +`NsState`'s three wire values (`Creating = 1`, `Live = 2`, `Removing = 3`) are append-only, exactly +like every other persisted enum in `CAS`: a catalog object written by one build is read by another, +so a value is never renumbered or repurposed. + +```mermaid +stateDiagram-v2 + [*] --> Creating: casAdmitEntry -- fresh random life_id, creator fence stamped + Creating --> Live: completeCreation -- publish genesis _ckpt, then flip, clear creator + Creating --> Creating: a live foreign creator fence -- retry later, no steal + Creating --> Live: reconcileStaleCreator finds the creator fence provably dead,
a fresh opener steals and completes it + Live --> Removing: beginRemoving -- table drop, stamps removal_started_round + Removing --> [*]: GC drains the row once a fold sealed positive cleanup evidence + [*] --> Creating: a fresh createNamespace call, only once the old row is fully absent -- brand new life_id +``` + +A row's own state machine is linear per row (`Creating → Live → Removing → gone`); what makes the +catalog non-linear as a whole is that a stalled `Creating` row can resolve two different ways +depending on whether its creator fence is still alive, and that a name only becomes creatable again +once its prior row is completely gone — both shown above. + +## Lifetime end to end {#lifetime} + +### Creation, on first write {#creation} + +There is no explicit "create namespace" statement; a namespace is born the first time anything +resolves its ref table (`CasRefLedger::resolveNamespaceLife`, bounded at 32 loop attempts). If the +catalog has no row for the name at all, the resolving mount admits a `Creating` entry stamped with +its own creator fence and a freshly minted `life_id` +(`CasRefCatalog::createNamespace` → `casAdmitEntry`). Two more steps make it usable: + +1. **Publish the genesis checkpoint.** The first `_ckpt` ever written for this `life_id` carries + `life_epoch = creator.writer_epoch` — the only writer that will ever know this namespace's + genesis epoch. +2. **Flip to `Live`.** One token-`CAS` moves the row from `Creating` to `Live` and clears `creator`. + +Both steps re-check the resolving mount's own fence before writing, so a mount that lost its lease +mid-creation reports `FencedOut` rather than silently completing. Several openers racing the same +brand-new name all observe "no entry", but only one wins the admit; the rest see `Superseded` and +simply re-read the catalog, landing on the winner's `Creating` row. + +A `Creating` row under a **different** mount's creator fence is not this opener's problem to force: +if that fence is still provably alive, the opener retries later; only once the fence is provably +dead (the same mount-lease terminality check `GC`'s heartbeat floor uses) does +`reconcileStaleCreator` let a fresh opener steal the row onto its own fence and finish the two steps +above itself. + +### Removal {#removal} + +Dropping a table (`DROP TABLE`, and every operation that reduces to it) calls +`CasRefLedger::dropNamespace`. It closes the namespace's local positive-mutation lane first — new +positive writers are refused while the in-flight ones drain — then transitions the catalog row from +`Live` to `Removing` in one token-`CAS` (`beginRemoving`, stamping `removal_started_round` from the +currently observed `GC` round), then appends **one** ref-log transaction that removes every current +committed and precommit binding and ends with a terminal `RemoveNamespace` op. Removal is never +refused by an admission check — Constraint 13 in the catalog's own spec — it always succeeds once +the fence holds. + +Nothing is deleted by the writer at this point. No blob, no manifest, no ref-log object physically +disappears here — only pointers move, exactly like an ordinary [`DROP TABLE`](/antalya/cas/architecture/part-lifecycle#operation-mapping) +on any other ref. + +### What `GC` does with a `Removing` namespace {#gc-and-removal} + +The terminal `RemoveNamespace` transaction is folded like any other ref-log record, during the +[round's fold phases](/antalya/cas/architecture/garbage-collection#the-round). Folding it stamps +positive **cleanup evidence** directly onto that `life_id`'s row in the new fold seal — there is no +physical listing and no `Pending`/`Completed` handshake; the evidence is a pure fact about which +ref-log transaction folded. + +The **next** round's `pre_fold_ref_drain` phase is what actually removes the catalog row: it reads +the just-adopted parent fold seal, and for every `Removing` row whose life carries durable cleanup +evidence, it exact-`CAS`-deletes the catalog entry before that round does anything else. This +two-round shape — evidence sealed in round *n*, catalog row deleted in round *n+1* — is why removal +needs no separate physical-emptiness proof: by the time the row is deleted, a fold has already +proven its ref history is fully drained. + +### What disappears, and when {#what-disappears} + +| Object class | Reclaimed by | When | +|---|---|---| +| Catalog row (`cas/ref_catalog` entry) | `GC` phase 2, `pre_fold_ref_drain` | The round after the fold that sealed cleanup evidence for this life | +| Part manifest bodies | Ordinary owner-removal ([phase 15](/antalya/cas/architecture/garbage-collection#the-round)) for anything that had a committed or precommit binding, the [orphan-manifest sweep](/antalya/cas/architecture/manifests-and-refs#orphan-sweep) for anything that never got that far | As each owning ref is dropped by the removal transaction itself, independent of the catalog row | +| Blob bodies | The ordinary condemn/graduate/delete pipeline | Whenever the manifests that named them stop being live, same as any other blob | +| Ref stream/state objects (`_log`, `_snap`, `_ckpt`, `_files`) under the dead `life_id` | The perpetual namespace janitor ([phase 16](/antalya/cas/architecture/garbage-collection#the-round)) | Best-effort, one bounded `LIST` page at a time, whenever it next lists a key whose `life_id` a fresh catalog cut no longer names — independent of, and not gated on, catalog-row deletion | + +The janitor is leak-only: it never fails a round, never blocks progress on an unreadable key, and a +crash mid-page simply leaves debris for its next page. + +### Recreate while removing {#recreate-while-removing} + +A fresh `createNamespace` call for a name whose catalog row is still `Live` or `Removing` is +refused outright — internally this is a misuse `LOGICAL_ERROR`, because the higher-level open loop +(`resolveNamespaceLife`) filters that case out first and reports a typed retry-later error instead: +"creation waits for its terminal fold and catalog removal to complete". A caller that keeps +resolving the same name simply keeps retrying until the row is gone. + +Once `pre_fold_ref_drain` has deleted the row, the name is free again, and the very next opener mints +a **brand new**, independently random `life_id` — never the retired one. That is the whole answer to +"what happens on recreate": the old physical identity is never revived, so every key ever written +under it — its `_log`, its `_snap`, its `_ckpt`, its `_files` — stays permanently addressed by a +value nothing will ever mint again, and a reader still holding the old `NamespaceLifeId` observes +only stale-or-absent data, never a byte that belongs to the new incarnation. diff --git a/docs/en/antalya/cas/architecture/part-lifecycle.md b/docs/en/antalya/cas/architecture/part-lifecycle.md new file mode 100644 index 000000000000..5a61d0490dc1 --- /dev/null +++ b/docs/en/antalya/cas/architecture/part-lifecycle.md @@ -0,0 +1,148 @@ +--- +description: 'The part-add protocol from local build through blob upload to promote, its nine crash points and their cleaners, and how each MergeTree operation maps onto it.' +sidebar_label: 'Part lifecycle' +sidebar_position: 6 +slug: /antalya/cas/architecture/part-lifecycle +title: 'CAS Architecture — Part Lifecycle' +doc_type: 'reference' +--- + +# CAS architecture — part lifecycle {#part-lifecycle} + +Publishing a `MergeTree` part on a `CAS` disk is one durable protocol, +`stageManifest → precommitAdd → putBlob → promote`, driven by `Cas::PartWriteTxn` +(`Pool/CasPartWriteTxn.cpp`). This page walks that protocol end to end: local build, the durable +order and why each step is where it is, every crash window and who cleans it up, and how each +`MergeTree`-level operation (insert, merge, mutation, detach, …) maps onto it. Manifest structure +and the ref table it writes into are covered on the +[manifests-and-refs page](/antalya/cas/architecture/manifests-and-refs); the fetch-side protocol +for replicated parts is on the [replication page](/antalya/cas/architecture/replication). + +## The protocol {#protocol} + +```mermaid +sequenceDiagram + autonumber + participant MT as MergeTree + participant TX as CA transaction overlay + participant PW as PartWriteTxn + participant S3 as Object store + + rect rgba(140,190,140,0.12) + Note over MT,S3: Phase A -- local build, nothing durable, nothing visible + MT->>TX: writeFile data.bin + TX->>TX: classify: blob class spills and hashes to scratch or S3 staging + MT->>TX: writeFile count.txt, columns.txt, ... + TX->>TX: buffer small files in memory as inline candidates + MT->>TX: moveDirectory tmp_insert to final name + Note over TX: pure overlay re-key, not a publish + end + + rect rgba(120,160,255,0.12) + Note over MT,S3: Phase B -- publish, per part, serially + MT->>TX: commit + TX->>PW: stageManifest entries + PW->>S3: PUT manifest, write-once, no preliminary HEAD + PW->>S3: append ref-log PRECOMMIT, plus NamespaceBirth if needed + Note over PW: precommit durable, the observe gate opens + TX->>PW: fan out blob uploads, one task per unique BlobRef + par blob 1 + PW->>S3: HEAD / conditional PUT / adopt + and blob 2 + PW->>S3: ... + end + PW->>PW: merge upload results on the owning thread, one no-throw swap + TX->>PW: promote + PW->>S3: GET and validate the precommit manifest body + PW->>S3: append ref-log txn: retire old committed, Precommit to Committed, SetPublishedAt + Note over PW: commit durable, then retire the build sequence + end +``` + +**Phase A — staging.** The transaction is an eager overlay, not a queue: `writeFile` immediately +classifies the path and either spills bytes to a hashing buffer or holds them in memory as an +inline candidate. Blob-class files stage to local scratch by default, or — when `staging_backend` +is `s3` and the mount-time conditional-copy probe passed — to an S3 staging object written as +`[header][payload]`, so that the later promote is a verbatim server-side copy. The `tmp_ → final` +rename is a pure overlay re-key; the durable publish happens only in `commit`. + +**Step 1 — `stageManifest`.** Caps (see the +[manifests-and-refs page](/antalya/cas/architecture/manifests-and-refs#part-manifests)) are +checked before the write; the id is minted as `{epoch, build_seq, ordinal++}`; the body goes out +with a conditional create and no preliminary `HEAD`. Both a definite failure and an unresolved +outcome throw retry-later. + +**Step 2 — `precommitAdd`.** The intent — target namespace, final ref name, manifest — is recorded +before the append, because an unresolved append may have landed anyway. One ref-log transaction +adds the precommit binding. A same-name birth is refused with retry-later while the catalog still +says `Removing`; once the predecessor row is absent, creation receives a new opaque life id and +starts its own stream. On return the precommit is durable, and only now may the writer adopt +existing blobs. + +**Step 3 — blob upload fan-out.** One task per unique `BlobRef`, deterministic dispatch order, one +pre-sized result slot per ref (see the write-path sequence on the +[blob-protocol page](/antalya/cas/architecture/blob-protocol#conditional-write-sequence)). The +calling thread only submits and joins, never occupies a pool slot, so a pool of size one degenerates +to a correct serial run and can never deadlock. The contract is merge-nothing: if any task threw, +nothing is merged and the first error in dispatch order is rethrown. Results are folded into the +dependency set on the owning thread, into a copy, committed by one no-throw swap. Pool size is the +server setting `cas_blob_upload_pool_size` (default 16). + +**Step 4 — `promote`.** Reads and revalidates the precommit manifest body once; sets the commit +state to Uncertain before the append — past that point, failure is no longer proof of the negative +— then checks that the precommit is still the live owner and revalidates leaves. Tokened leaves +are skipped because they are edge-protected; tokenless leaves must be evidence adopts, trusted +through the durable manifest edge with no per-file `HEAD`; anything else is a `LOGICAL_ERROR`. The +whole thing lands as one ref-log record: optional retirement of the old committed binding, the pure +Precommit-to-Committed owner move, and `SetPublishedAt`. Promotion emits no blob deltas — the +manifest never loses an owner, so it is net zero. + +## Crash points and their cleaners {#crash-points} + +This table is the single best summary of the design's crash-safety story: every row leaks +something recoverable; no row loses data or leaves a dangling reference. + +| # | Crash window | Left behind | Who cleans it | +|---|---|---|---| +| C1 | During staging | Local temp files, or S3 staging objects | Local: unconditional cleanup plus buffer destructor. S3: the mount's own staging sweep at next mount — never deleted on abort | +| C2 | After `stageManifest`, before `precommitAdd` | An unreferenced manifest body | Writer's best-effort exact-token delete; durable backstop is the orphan-manifest sweep | +| C3 | `precommitAdd` returned Unresolved | A possibly-live precommit binding | Intent recorded pre-append; `abandon` appends the exact removal, tolerating absence. The body is never writer-deleted | +| C4 | Between `precommitAdd` and `promote` | A live precommit plus uploaded blobs | No resume path exists. Removed by `abandon`, else by a fenced successor's stale-precommit sweep | +| C5 | Mid blob fan-out | Already-uploaded blobs | Nothing merged; blobs become `GC`-reclaimable debris; the part is not published | +| C6 | `promote` append Unresolved | The ref may or may not be committed | Commit state Uncertain — the relink layer maps this to "retry the whole fetch", never to a byte fetch | +| C7 | A later part throws after earlier parts published | A partial multi-part commit | Precise rollback: drop only the refs this call created, matching the exact manifest — never clobbers a concurrent writer's repoint | +| C8 | Transaction destroyed uncommitted | Open builds | Destructor abandons every build | +| C9 | Namespace dropped mid-build | — | One atomic flag; every further op fails closed at the alive check | + +## The repoint {#repoint} + +Writing into an already-committed part — an `ALTER`-style metadata rewrite, or any standalone +write against a committed source — never mutates the existing manifest. It writes a **new** +manifest over the (possibly partly reused) blob set and moves the ref to it in one ref-log record. +Unchanged columns are adopted by hash through a tokenless evidence dependency with no `HEAD` and no +`GET`; changed columns are fresh uploads. A repoint therefore costs zero bytes moved for the +carry-forward portion of the file set — only the changed content re-uploads. + +## How each MergeTree operation maps {#operation-mapping} + +| Operation | CAS mechanics | +|---|---| +| `INSERT` | The canonical path above. Projections ride the parent part's transaction | +| Merge | Identical for the output part. `.tmp_proj → .proj` is an entry-prefix re-key inside the staged manifest, not a rename | +| Mutation | `createHardLink` per unchanged file: a source staged in *this* transaction copies the entry and its pending-blob record; a **committed** source records a tokenless evidence dependency with no `HEAD` and no `GET`. A mutation is a manifest rewrite where zero bytes move for the carry-forward | +| `ALTER` / metadata rewrites | Standalone writes into a committed part, i.e. a repoint | +| `DROP PART` | `removeDirectory` drops the ref and clears any per-file removal marks — one ref-drop, zero repoints | +| `DROP TABLE` / `DETACHED` / `UNFREEZE` | A namespace or prefixed-ref drop. Blobs are never deleted here — removal is pointer-unlink plus deferred `GC` | +| `RENAME TABLE` | Republishes every ref and verbatim file into the new namespace, then drops the old one. Not atomic across namespaces, but idempotent and re-drivable — true atomicity would need a move journal and is out of scope | +| `FREEZE` / `BACKUP` / `RESTORE` / cross-disk `MOVE` | Each wraps the whole clone in one disk transaction, because a CAS part is one atomic unit | + +`FREEZE` is the one operation that materializes real bytes into a genuinely separate shadow +namespace rather than reusing a table's own ref names — that shadow namespace is a `GC` +reachability root, and `UNFREEZE` releases its refs. + +## Reads while a part is in flight {#in-flight-reads} + +Read-your-writes for a part still inside an open transaction is served by an explicit overlay +rather than by any durable object — `tryGetInFlightStorageObjects`, `tryReadFileInFlight`, +`listInFlightDirectory`. One deliberate subtlety: the bare part directory reports as absent in the +overlay, so cleanup of a deduplication-rejected temporary part does not mistake it for a real part. diff --git a/docs/en/antalya/cas/architecture/read-path.md b/docs/en/antalya/cas/architecture/read-path.md new file mode 100644 index 000000000000..c92634dd9e74 --- /dev/null +++ b/docs/en/antalya/cas/architecture/read-path.md @@ -0,0 +1,84 @@ +--- +description: 'How a CAS read resolves a ref to a manifest and then to ranged blob reads, and the two caches — manifest decode and part-folder view — that sit on that path.' +sidebar_label: 'Read path' +sidebar_position: 9 +slug: /antalya/cas/architecture/read-path +title: 'CAS Architecture — Read Path' +doc_type: 'reference' +--- + +# CAS architecture — read path {#read-path} + +A `CAS` read never touches a classical local-metadata path: there is no local directory listing to +consult, only a ref resolve followed by object-store reads. This page covers the three ways a file +access is served, the full chain for the common case, the two caches that sit on that chain, and +how a part still open inside a write transaction serves its own reads. + +## How a file access is served {#access-kinds} + +| Access kind | How it is served | S3 cost | +|---|---|---| +| Inline entry — small files such as `count.txt`, `columns.txt` | Decoded straight out of the manifest body | Zero additional operations | +| Blob-backed file — `.bin`, marks, large `primary.idx` | Ranged `GET` bounded by `[header_len, header_len + blob_size)` | One `GET` per column file per part open | +| Verbatim file — `roots/…` objects | Plain object read, no `CAS` indirection | One `GET` | + +The full chain for a blob-backed file is: resolve the ref, read the manifest, look up the path, +build a blob view plan, ranged `GET`, then `ReadBufferFromFileView`. Because the payload always +starts at a pool-constant offset (the manifest's `blob_header_len`), no header parse is needed to +locate content — see the [envelope format](/antalya/cas/architecture/storage-layout#envelope-format) +on the storage-layout page. + +Part manifests themselves are read whole after opening the object: there is no on-disk random +access, `seek`, or streaming requirement for their entry records — a manifest is small enough that +decoding the whole body is cheaper than any partial-read machinery would be. + +## The two caches {#caches} + +| Cache | Keyed by | Setting | Default | What still hits the network | +|---|---|---|---|---| +| Manifest decode cache | `(ManifestId, Token)` | `manifest_decode_cache_bytes` | 128 MiB | A mandatory `HEAD` on **every** access, cache hit or miss | +| Part-folder view cache (`Cas::CachedPartFolderAccess`, `Parts/PartFolderAccess.h`) | Part ref key | `part_folder_cache_bytes`, `part_folder_cache_max_entries`, `part_folder_cache_max_entry_bytes` | 64 MiB / 10 000 entries / 16 MiB | Its `ForceFresh` policy re-proves the manifest body via that same mandatory `HEAD`, paced by `part_folder_validate` (`always` \| `never` \| `age `) | + +**The `HEAD` is mandatory even on a cache hit** — the page's most counter-intuitive fact, because it +means a cache hit still costs one object-store round trip: + +```mermaid +flowchart TD + A["readManifestShared(ManifestId)"] --> B["HEAD the manifest key"] + B -->|"absent"| C["throw FILE_DOESNT_EXIST --
a live ref must never name a missing object"] + B -->|"present, token t"| D{"cache lookup (ManifestId, t)"} + D -->|hit| E["return the cached decode -- no GET"] + D -->|miss| F["GET the body"] + F --> G{"body's own ref and namespace
match the key?"} + G -->|no| H["throw CORRUPTED_DATA"] + G -->|yes| I["decode, insert into cache keyed by (ManifestId, t), return"] +``` + +The `HEAD` is what proves the live ref still names an existing object — the no-dangle invariant — +and it supplies the token that keys the cache; only then is the decode cache consulted. On a miss, +the `GET` is followed by the two identity checks in the diagram, each `CORRUPTED_DATA` on failure. +Only a fully validated decode enters the cache. Setting either cache's byte budget to `0` disables +retention while leaving the `HEAD`-and-validate sequence intact — a cache is purely an +optimization, never a trust boundary. + +The part-folder view cache is invalidated on every promote and repoint, and is single-flight on a +cold build: concurrent readers of the same not-yet-cached view coalesce into one build rather than +racing independent `GET`s. + +## Reads while a part is still being written {#in-flight-reads} + +An in-flight part inside an open write transaction is not yet visible through the ordinary ref +resolve — reading it goes through the same explicit overlay used for read-your-writes, covered on +the [part-lifecycle page](/antalya/cas/architecture/part-lifecycle#in-flight-reads). The bare part +directory itself reports as absent in that overlay, precisely so that cleanup of a rejected +temporary part is never mistaken for a real, resolvable part. + +## Diagnostic and read-only access {#read-only-access} + +A read-only or diagnostic opener of a `CAS` disk (`ca-fsck`, `ca-gc-dryrun`, and similar tools) +must not claim mount ownership, schedule `GC`, or mint writer state — read-only enforcement sits +below the ordinary facade checks, at the backend layer itself. A mounted `Pool` caches its ref +table and does not re-recover it on every read; a diagnostic tool that deliberately performs a +fresh cold recovery on each pass can therefore observe a **less** stale ref table than a live +mounted read, which is intentional for tools whose entire purpose is catching drift a live mount +would not notice. diff --git a/docs/en/antalya/cas/architecture/replication.md b/docs/en/antalya/cas/architecture/replication.md new file mode 100644 index 000000000000..cc767f7fe115 --- /dev/null +++ b/docs/en/antalya/cas/architecture/replication.md @@ -0,0 +1,118 @@ +--- +description: 'Fetch by relink between two replicas sharing a pool: the gates in order, what actually seals commit-before-release, and detach/attach/drop.' +sidebar_label: 'Replication' +sidebar_position: 7 +slug: /antalya/cas/architecture/replication +title: 'CAS Architecture — Replication' +doc_type: 'reference' +--- + +# CAS architecture — replication {#replication} + +When two `ReplicatedMergeTree` replicas share a `CAS` pool, a fetch should move **no bytes** — the +receiver already has access to the same blobs the sender does. The mechanism is a three-phase +handshake, fetch by relink, layered directly on the ordinary interserver part-fetch protocol. This +page covers the handshake, the gates that decide whether it fires, what actually makes it safe +against a concurrent `GC` round, and how detach/attach/drop reduce to the same primitives. The +writer owns table semantics and part publication (see the +[part-lifecycle page](/antalya/cas/architecture/part-lifecycle)); `GC` owns ref-log folding and +physical cleanup (see the [garbage-collection page](/antalya/cas/architecture/garbage-collection)) +— ordinary replication traffic never reads `gc/state` or waits on a `GC` round. + +## The handshake {#handshake} + +Only two of the three phases are round trips to the sender — the offer and the confirm. The +publish and the promote are the receiver's own writes to the pool. + +```mermaid +sequenceDiagram + autonumber + participant R as Receiver + participant Snd as Sender + participant S3 as Shared pool + + R->>Snd: GET part, cas_pool_uuid = R's pool uuid, client_protocol_version = 11 + Note over R: advertising 11 is a promise to confirm before promoting + Snd->>Snd: same disk pool uuid? identity, never endpoint plus prefix + Snd->>S3: resolve the offer once -- manifest bytes and confirm token from the SAME view + Snd-->>R: cookie cas_relink = part_manifest_v2, cookie cas_source_token = ..., body = manifest bytes + Note over Snd: sender is fire-and-forget -- it releases the part here + + rect rgba(120,160,255,0.12) + Note over R,S3: T1 -- publish, the plus-one lands first + R->>S3: adopt entries by evidence, no HEAD, no bytes, stageManifest fresh receiver-local id, precommitAdd + Note over R: the sender's ManifestRef, namespace and digest are ignored -- only entries are used + end + + rect rgba(255,190,120,0.15) + Note over R,Snd: T2 -- confirm + R->>Snd: POST cas_confirm = token + Snd->>Snd: confirmExactRef, zero object-store I/O, never throws + Snd-->>R: cookie cas_confirm_answer = yes or unproven + end + + alt answer is yes + R->>S3: T3 -- promote, ref published + else anything else -- unproven, missing cookie, timeout, transport error + R->>R: throw a locally generated NETWORK_ERROR, retry later + Note over R: never a byte re-request -- that would go back to the very source whose state is in doubt + end +``` + +## The gates, in order {#relink-gates} + +| # | Gate | What it enforces | +|---|---|---| +| 1 | Pool identity | The receiver advertises `cas_pool_uuid`; the sender offers relink only if its own disk's pool uuid is **equal**. Matching by endpoint and prefix was tried and rejected — a minted pool uuid is the identity | +| 2 | Protocol version 11 | On the receiver side, advertising it is a promise to run the confirm round trip before promoting | +| 3 | One resolution for two outputs | The manifest bytes and the confirm token come from the **same** view. Two separate calls would allow a repoint in between and hand the receiver a token naming a manifest whose entries it never adopted | +| 4 | The receiver trusts nothing from the wire but the entry list | The sender's manifest id, namespace and payload digest are ignored; the target namespace and ref come from the receiver's own router, and manifest path hygiene is validated at decode | +| 5 | The confirm is I/O-free and fail-closed | A cold, evicted, unfenced or terminal mount answers `Unknown`. `No` and `Unknown` both go on the wire as `unproven`, because the fence check is evaluated last, so a `No` cannot be distinguished from "cannot prove it right now" | +| 6 | Only the literal `yes` authorizes promotion | Everything else — including a timeout — is one outcome: throw and retry later | +| 7 | Promote outcomes are three-way | `Committed` proceeds; a **proven** not-committed state (body-absent precommit, precommit no longer live owner, ref conflict) falls back to a byte fetch; `Unresolved` **throws**, because returning "fall back" there would publish the part twice | + +The byte-fetch fallback is bounded: it re-invokes the fetch with relink disabled, which stops the +receiver advertising its pool uuid, which stops the sender offering relink — so the relink path +cannot be entered twice for one fetch. Byte-fetched files content-address and dedup on arrival +anyway, so falling back never loses the dedup property, only the zero-byte-move property for that +one fetch. + +## What actually seals "commit before release" {#relink-seal} + +The receiver's `+1` — its precommit binding — is durable **before** the sender is asked anything, +and any removal of the sender's own binding is appended strictly after that `+1` is in the ref +log. That ordering, steps T1 then T2 then T3, is the whole seal. + +This does **not** establish that every subsequent `GC` fold *sees* that `+1` under every listing +behavior: a configuration with one incomplete listing page can, in principle, let a fold miss a +freshly published edge. A confirmed relink therefore proves only "the source still holds exactly +this manifest right now", not "no future fold can ever miss this edge" — `ca-fsck`'s +reachable-but-absent scan is the backstop for that gap, not the relink protocol itself. Relink +also races `GC` in the ordinary sense any writer does: between the sender encoding its offer and +the receiver's promote, `GC` on the shared pool may condemn a blob that was live only through the +sender's own ref. The [writer-versus-GC race](/antalya/cas/architecture/blob-protocol#writer-gc-race) +on the blob-protocol page is what makes that interleaving safe — revival is re-upload only, and the +receiver's evidence-adopt is protected by its own durable precommit edge exactly like any other +writer's adopt. + +A fetch whose source part is still a live, held `DataPartPtr` on the sender's own replica — the +common case for a local, same-process relink — keeps the source pinned through the destination's +commit by ordinary part-lifetime rules, independent of the ref-log seal above. + +## Detach, attach, drop {#detach-attach-drop} + +A detached part is **not** a separate namespace — it is a ref in the table's own namespace with a +`detached/` prefix (the same is true of `moving/`). Only `FREEZE` uses a genuinely separate shadow +namespace, which the ownership check deliberately refuses to claim, so a frozen part can never be +relink-confirmed. + +`DETACH`, `ATTACH`, `delete_tmp_` cleanup, and merge-result renames all reduce to the same two +moves: re-key any *staged* source into the destination, then `republishRef(src → dst)` for any +*committed* source. `republishRef` re-reads the source manifest freshly, publishes an +equivalent-entry manifest under the destination ref — a **new** manifest id, with blobs untouched +and adopted by evidence — then drops the source ref. A destination that already exists with +identical entries just drops the source, an idempotent re-drive; one with different entries +throws. + +Manifests are therefore per-ref and never moved: a detach creates a new manifest for +`detached/` and retires the old one, and the blobs' net in-degree is unchanged. diff --git a/docs/en/antalya/cas/architecture/storage-layout.md b/docs/en/antalya/cas/architecture/storage-layout.md new file mode 100644 index 000000000000..9b4336a6bf2f --- /dev/null +++ b/docs/en/antalya/cas/architecture/storage-layout.md @@ -0,0 +1,160 @@ +--- +description: 'S3 key layout and on-disk text-object formats used by the content-addressed storage (CAS) MergeTree disk backend.' +sidebar_label: 'Storage layout' +sidebar_position: 2 +slug: /antalya/cas/architecture/storage-layout +title: 'CAS Architecture — Storage Layout' +doc_type: 'reference' +--- + +# CAS architecture — storage layout {#storage-layout} + +Every key in a pool is built by one class, `Cas::Layout` (`Formats/CasLayout.h`), which owns +exactly the pool prefix. Every persisted object opens with a one-line JSON envelope header, and +control-plane bodies are JSON Lines — one JSON object per line, sorted where the object is a log +or a set of entries (`Formats/README.md`; see [Envelope format](#envelope-format) below for which +parts are a single JSON object versus JSON Lines versus raw payload bytes). The format is +deliberately this plain: any object can be fetched and read with ordinary line-oriented tools +while debugging, and a new field is additive — a tolerant reader skips it — so the format evolves +without a migration. + +## Key table {#key-table} + +All key patterns are shown under the pool prefix. A **namespace** is the opaque per-table string +under which one `MergeTree` table's part manifests and ref history live: for a live table it is +the table's canonical disk path (`store//`, `@cas@`-marked) prefixed by the owning +server's `server_root_id`, and a backup gets its own `shadow/…` namespace instead; `Cas::Layout` +only validates a namespace's shape and never interprets its contents. + +| Key pattern | Object | Codec | Writer | +|---|---|---|---| +| `_pool_meta` | pool identity + floors | `cas_pool_meta` | pool create/admit | +| `blobs///` | blob envelope + payload | `cas_blob` | uploads | +| `blobs///.meta` | blob freshness sidecar | `cas_blob_meta` | dedup/GC | +| `cas/ns/stream//_log/-.zst` | ref transaction log | `cas_ref_log` | writer commit path | +| `cas/ns/stream//_snap/-.zst` | complete ref table snapshot | `cas_ref_snap` | writer/GC fold | +| `cas/ns/state//_ckpt` | mutable per-life checkpoint | `cas_ref_ckpt` | writer/GC fold | +| `cas/ns/state//_files/` | namespace-owned verbatim file | — (raw passthrough) | upper layers | +| `cas/manifests//-/.zst` | part manifest | `cas_part_manifest` | part build | +| `gc/state` | GC state (incl. GC lease) | `cas_gc_state` | GC | +| `gc/hb` | GC leader heartbeat | `cas_gc_hb` | GC | +| `gc/maintenance_state` | leak-only namespace-janitor cursor | `cas_gc_maintenance_state` | future janitor | +| `gc/gen//attempt//fold_seal` | fold seal (deterministic) | `cas_fold_seal` | GC | +| `gc/gen//attempt//blob_target//` | GC source-edge run segment | `cas_run` | GC | +| `gc/gen//attempt//outcomes//.zst` | GC outcome log | `cas_gc_outcomes` | GC | +| `gc/server-roots//owner` | server-root owner singleton | `cas_owner` | mount | +| `gc/server-roots//epoch` | server-root epoch singleton | `cas_epoch` | mount | +| `gc/server-roots//mount` | mount lease (incl. `min_active` watermark) | `cas_mount_lease` | mount | +| `roots/` | loose mountpoint object, verbatim | — (never interpreted) | upper layers | +| `staging//…` | S3-native upload staging scratch | — | writer, own mount only | + +`` is `ch128`, `xxh3`, or `sha256` — the hash algorithm is a path segment because one pool may +legally hold blobs under several algorithms at once. `` is a flat two-character S3 key +shard for request-fan-out, unrelated to the separate `gc_shards` GC-internal reduction fan-out +(which appears only inside `gc/gen/…` keys and routes by the digest's high 64 bits, read +big-endian). Discovery LISTs use fixed prefixes: `cas/ns/stream/`, `cas/ns/`, `cas/manifests/`, +`blobs/` (deliberately without the algorithm segment, so one recursive LIST covers every +algorithm), `roots/`, `gc/server-roots/`. `staging/` is a top-level sibling that no GC LIST ever +touches — it is reclaimed only by its own server's next mount. + +## Envelope format {#envelope-format} + +Every persisted CAS metadata object is text: a header line, a body, and an optional trailer. + +``` +{"type":"cas_","v":N} <- header line, always present + <- one JSON object, sorted NDJSON records, + or a descriptor + raw payload zone +{"n":…} <- optional trailer (record/entry count) +``` + +`v` is the only version field; a reader rejects `v` above what the build supports with +`UNKNOWN_FORMAT_VERSION`, checked before the body. A `.zst` key suffix means, exactly, that the +object kind's compression policy is `Always`: the object is stored as one zstd frame with the +checksum flag on, and its declared content size is checked against a per-kind cap before +allocation. Always-small and deterministic kinds (`cas_ref_ckpt`, `cas_blob_meta`, `cas_fold_seal`, +`cas_run`, …) are stored raw, with no `.zst` suffix. + +The blob envelope is a special case of the header/body shape: a JSON descriptor padded with ASCII +spaces to a pool-constant `blob_header_len` (256 bytes, a `cas_pool_meta` field), terminated by +`\n`, so the raw payload always starts at that fixed offset with no header parse needed to locate +it. The part manifest is the other `PayloadHybrid` kind: text header, descriptor, sorted NDJSON +entry records, `{"n":…}` trailer, then a banner-framed raw payload zone for small inline file +bytes. + +## Codec table {#codec-table} + +Condensed from the authoritative traits table in `CasFormat.cpp` (`TRAITS`, asserted complete by +`gtest_cas_text_format.cpp`). + +| Type string | Family | Key strictness | Compression | +|---|---|---|---| +| `cas_blob` | `PayloadHybrid` | tolerant | never (raw, fixed offset) | +| `cas_blob_meta` | `Control` | tolerant | never | +| `cas_pool_meta` | `Control` | tolerant | never | +| `cas_ref_log` | `Control` | tolerant | always (`.zst`) | +| `cas_ref_snap` | `Control` | tolerant | always (`.zst`) | +| `cas_ref_ckpt` | `Control` | strict | never | +| `cas_ref_catalog` | `Control` | strict | never | +| `cas_part_manifest` | `PayloadHybrid` | tolerant | always (`.zst`) | +| `cas_run` | `RecordStream` | strict | pinned raw | +| `cas_fold_seal` | `Control` | strict | pinned raw | +| `cas_gc_state` | `Control` | tolerant | never | +| `cas_gc_hb` | `Control` | tolerant | never | +| `cas_gc_outcomes` | `Control` | tolerant | always (`.zst`) | +| `cas_gc_maintenance_state` | `Control` | strict | never | +| `cas_owner` | `Control` | tolerant | never | +| `cas_epoch` | `Control` | tolerant | never | +| `cas_mount_lease` | `Control` | tolerant | never | + +"Strict" means unknown keys are rejected rather than skipped, used for objects where every field +decides a durability or cleanup decision (`cas_ref_ckpt`, `cas_ref_catalog`, `cas_fold_seal`, +`cas_run`, `cas_gc_maintenance_state`); a `!`-prefixed key is always critical regardless of the +kind's strictness. "Pinned raw" objects (`cas_run`, `cas_fold_seal`) need stable bytes across +re-encodes for deterministic-artifact adoption, so their bytes are never recompressed once +written. `cas_blob` and `cas_part_manifest` are the `PayloadHybrid` family: a text descriptor +followed by a raw payload zone, rather than a single JSON body. + +## Worked example tree {#worked-example} + +Pool prefix `ca-pool`, server root `srv1`, one `Atomic` table, one part `all_1_1_0` with one blob +column file, written at `writer_epoch = 1, sequence = 3`: + +``` +ca-pool/_pool_meta + +ca-pool/cas/ns/stream/0123456789abcdef0123456789abcdef/_log/0000000000000001-0000000000000003.zst +ca-pool/cas/ns/stream/0123456789abcdef0123456789abcdef/_snap/0000000000000001-0000000000000003.zst +ca-pool/cas/ns/state/0123456789abcdef0123456789abcdef/_ckpt + +ca-pool/cas/manifests/srv1/store/3f2/3f2a1b7c-…-abcdefabcdef@cas@/0000000000000001-0000000000000003/000001.zst + +ca-pool/blobs/xxh3/a1/a1b2c3d4e5f60708b1c2d3e4f5061728 +ca-pool/blobs/xxh3/a1/a1b2c3d4e5f60708b1c2d3e4f5061728.meta + +ca-pool/roots/srv1/clickhouse_access_check_8f3a1c2d + +ca-pool/gc/state +ca-pool/gc/hb +ca-pool/gc/server-roots/srv1/{owner,epoch,mount} +ca-pool/gc/gen/7/attempt/1/fold_seal +ca-pool/gc/gen/7/attempt/1/blob_target/0/1 +ca-pool/gc/gen/7/attempt/1/outcomes/1/0.zst + +ca-pool/staging/srv1/ +``` + +`0123456789abcdef0123456789abcdef` is the opaque physical `life_id` the catalog maps the table's +namespace to; the ref log and snapshot keys reuse the same `RefTxnId` rendering +(`0000000000000001-0000000000000003`) as the manifest's build-scoped directory, but they are +different counters with different semantics, not the same identifier. The `data.bin` entry inside +the part manifest names the blob by `{XXH3_128, a1b2…1728}`, which is what resolves to the +`blobs/xxh3/a1/…` key above. A small file such as `count.txt` has no object of its own — it is +inline inside the manifest's raw payload zone, not a separate key. + +## Notes {#notes} + +- `cas/ns/state//_ckpt` carries **no** `.zst` suffix: `cas_ref_ckpt`'s compression policy + is `never`, while its `_log`/`_snap` siblings in the same `cas/ns/` tree compress `always`. +- The namespace-stream tree is `cas/ns/stream/` (immutable `_log`/`_snap` objects) and + `cas/ns/state/` (mutable `_ckpt`, verbatim `_files/`). diff --git a/docs/en/antalya/cas/bucket-requirements.md b/docs/en/antalya/cas/bucket-requirements.md new file mode 100644 index 000000000000..ca05ad903aaf --- /dev/null +++ b/docs/en/antalya/cas/bucket-requirements.md @@ -0,0 +1,44 @@ +--- +description: 'The object-store contract a bucket must satisfy to host content-addressed storage, and which providers qualify.' +sidebar_label: 'Bucket requirements' +sidebar_position: 4 +slug: /antalya/cas/bucket-requirements +title: 'CAS Bucket Requirements' +doc_type: 'reference' +--- + +# Bucket requirements {#bucket-requirements} + +`CAS` is built on a small object-store contract (`Backend/CasBackend.h`), checked by a capability +probe that runs at every writable mount and fails closed: an object store that does not enforce +these conditions is refused rather than trusted. + +## The capability table {#capability-table} + +| Requirement | Interface method | Why it is needed | +|---|---|---| +| Read-after-write on a fresh key | `Backend::get` / `Backend::head` | Recovery listings and point reads must see what was just written | +| Conditional create (`If-None-Match: *`) | `Backend::putIfAbsent`, `Backend::putIfAbsentStream` | Write-once creation of blobs, manifests, and ref-log entries | +| Conditional overwrite (`If-Match: `) | `Backend::putOverwrite`, `Backend::casPut` | The one mutual-exclusion primitive: mount leases, `gc/state` | +| Exact-token delete | `Backend::deleteExact` | GC must delete only the incarnation it condemned, never a resurrected replacement | +| Ranged `GET` | `Backend::get` / `Backend::getStream` with a `Range` | Opening one column file of a part costs one bounded read, not a whole-object fetch | +| `LIST` with a resumable cursor | `Backend::list` | GC discovery and the orphan-manifest sweep page through the pool without a separate index | +| No versioning / no delete markers | probed by `runCapabilityProbe`; `created_delete_marker` on `DeleteOutcome` | A delete marker over a live key would break exact-token semantics — GC would archive instead of reclaim | +| `TOKEN ⟹ CONTENT` (a repeated token implies unchanged bytes) | standing requirement on every `Backend` implementation | Not probed — it cannot be tested cheaply. A backend that recycled tokens would serve stale manifests, i.e. wrong query results, not merely an inefficiency | + +Bucket **versioning is not required** — in fact it must be **disabled** on the generation-token +dialect (see below), because a token-exact delete on a versioned bucket archives a noncurrent +generation instead of reclaiming storage, silently stopping GC reclamation. + +## Platform support {#platform-support} + +| Platform | Status | Notes | +|---|---|---| +| AWS S3 | ✓ | Native `ETag`-based conditional dialect: `If-None-Match` / `If-Match` used directly | +| Google Cloud Storage | ✓ | Generation-token dialect: conditional headers are rewritten to `x-goog-if-generation-match`, opted into via `http_client = gcs_hmac` or `gcp_oauth` | +| Azure Blob Storage | probably | Azure's REST API documents the equivalent conditional headers, but ClickHouse's Azure object-storage backend does not yet wire up a `CAS` conditional dialect the way the S3 and GCS paths do — untested, not validated by the capability probe | +| Other S3-compatible stores | only with enforced conditional operations | The capability probe is the actual gate: a store that silently ignores `If-None-Match`/`If-Match` (accepting and applying the write regardless) fails the probe and is refused. `RustFS` passes the full battery and is used as the project's test backend; `Garage` was evaluated and rejected because it silently ignores conditional operations | + +The full mechanics of the two dialects — how the backend detects which one a given endpoint speaks, +what the capability probe actually checks, and how exact-token deletes map onto each provider's +primitives — are in [the Backend architecture page](/antalya/cas/architecture/backend). diff --git a/docs/en/antalya/cas/configuration.md b/docs/en/antalya/cas/configuration.md new file mode 100644 index 000000000000..8e551ef292be --- /dev/null +++ b/docs/en/antalya/cas/configuration.md @@ -0,0 +1,132 @@ +--- +description: 'Every disk-level and server-level setting content-addressed storage exposes, generated from ContentAddressedSettings and ServerSettings at HEAD.' +sidebar_label: 'Configuration' +sidebar_position: 3 +slug: /antalya/cas/configuration +title: 'CAS Configuration Reference' +doc_type: 'reference' +--- + +# Configuration reference {#configuration-reference} + +## The disk config block {#disk-config} + +A `CAS` disk is an `object_storage` disk with `metadata_type` set to `cas` and an explicit +`server_root_id`. The recommended shape layers a `type=cache` disk in front of it — the local +filesystem cache absorbs repeated reads of the same blob, while the `CAS` disk underneath stays the +single source of truth the pool's other members and GC also read from. The storage policy references +the **cached** disk, not the raw `CAS` disk directly: + +```xml + + + + + object_storage + s3 + cas + {replica} + https://bucket.s3.amazonaws.com/cas/ + ... + ... + + + cache + cas + /var/lib/clickhouse/cas_cache/ + 10Gi + + + + + +
+ cas_cache +
+
+
+
+
+
+``` + +`path` and `max_size` are ordinary `type=cache` disk settings (see +[external disk cache](/operations/storing-data#using-local-cache)), not `CAS`-specific — size the +cache to the working set of blobs a node reads repeatedly, not to the pool's total size. `type`, +`object_storage_type`, `metadata_type`, `endpoint`, `access_key_id`, `secret_access_key`, and the +other generic object-storage/disk keys (`path`, `name`, `region`, `use_environment_credentials`, +`readonly`, `use_fake_transaction`, and a handful more) belong to the shared disk layer, not to +`CAS` — they are accepted inside the `cas` disk's own block but are not `CAS` settings. Every key +below this line, and every key not in that shared set, is rejected as unknown. + +The bare, uncached form — a storage policy pointing directly at the `CAS` disk, as used by +[quick start](/antalya/cas/quick-start) — remains valid and is the minimal way to try `CAS` out: + +```xml + + + +
+ cas +
+
+
+
+``` + +## Disk-level settings {#disk-settings} + +None of these keys carry a `cas_`/`ca_` prefix — the disk block already scopes them. + +`CAS` is experimental: any setting below may change semantics, change its default, or disappear +entirely before release. Treat this table as a snapshot of the current build, not a stable contract. + +| Setting | Default | Description | +|---|---|---| +| `server_root_id` | — (required) | Explicit layout subtree identity; macros expand as in the `s3` `endpoint`. Anchored in the pool by a write-once owner claim — a colliding identity is refused at mount | +| `scratch_path` | server data path | Server-local scratch dir for the write-buffer spill; a relative value is anchored to the server data path | +| `gc_enabled` | `true` | Run the background GC scheduler on this disk. `false` is a debugging aid, not an operating mode: garbage then accumulates indefinitely and silently — watch `system.cas_gc_log` for round activity if you ever toggle it | +| `gc_interval_sec` | `60` | Seconds between background GC rounds (≥ 1) | +| `blob_hash` | `cityhash128` | Pool blob content-hash function (`cityhash128` \| `xxh3-128` \| `sha256`). Recorded in the pool at creation; a mismatching config is refused at mount | +| `blob_hash_allow_new` | `false` | Explicit opt-in to admit a new hash algorithm into an existing pool. One-way: once admitted, the pool carries both algorithms permanently | +| `skip_access_check` | `false` | Skip the boot-time capability probe (start now, fix later). Safer than the name suggests: only the preflight probe is skipped — the conditional-write correctness check still runs unconditionally on every writable mount | +| `deduplication_cache_bytes` | 64 MiB | Byte budget of the blob presence cache (`0` disables) | +| `deduplication_head_first_min_bytes` | 1 MiB | Minimum blob size to try a `HEAD` before uploading the body | +| `gc_snapshot_generations_to_keep` | `3` | GC snapshot generations retained | +| `gc_shards` | `1` | Blob-hash-prefix reducer shards (≥ 1). Recorded in the pool at creation; a mismatching config is refused at mount | +| `gcs_max_conditional_put_bytes` | 1 GiB | Largest conditional write on a generation-token store (GCS forces those single-part); does not bound the unconditional resurrect | +| `part_folder_cache_bytes` | 64 MiB | Part-folder view cache byte budget (`0` disables retention) | +| `part_folder_cache_max_entries` | `10000` | Part-folder view cache entry cap | +| `part_folder_cache_max_entry_bytes` | 16 MiB | Oversized part-folder views bypass retention above this size | +| `part_folder_validate` | `always` | Cache body re-proof policy (`always` \| `never` \| `age `). **Leave at `always`**: the other modes trade the fail-closed body-existence check for an optimization — this is a trust decision about unverified data, not a performance knob | +| `manifest_decode_cache_bytes` | 128 MiB | Manifest decode cache byte budget (`0` disables) | +| `gc_meta_pool_size` | `16` | Bounded pool size for GC per-hash freshness-meta writes | +| `staging_backend` | `local` | Blob staging backend (`local` \| `s3`); `s3` is opt-in | + +### Choosing `blob_hash` {#choosing-blob-hash} + +`blob_hash` is fixed at pool creation, so pick it deliberately. `blob_hash_allow_new` is the +escape hatch — it admits a second algorithm into an existing pool's `algos_used` rather than +requiring a fresh pool. + +| Algorithm | Pick it for | Trade-off | +|---|---|---| +| `sha256` | Maximum safety | No known collision classes; slightly slower than the other two | +| `xxh3-128` | Maximum speed | Fastest, 128-bit, no known collision classes | +| `cityhash128` (default) | ClickHouse-ecosystem compatibility, and a possible future hash-reuse mode that avoids recomputation | Fast, but has a known class of collisions that occurs far more often than an ideal hash function would predict | + +## Server-level settings {#server-settings} + +Source: `ServerSettings.cpp`. Unlike the disk-level list, these carry the `cas_` prefix because they +are process-wide, not scoped to one disk block. + +| Setting | Default | Description | +|---|---|---| +| `cas_blob_upload_pool_size` | `16` | Size of the dedicated server-wide thread pool used to upload blobs in parallel when committing a `CAS` part. Zero is rejected: the pool must have at least one thread | + +## `SYSTEM CAS` commands {#system-commands} + +`SYSTEM CAS GC RUN`, `SYSTEM CAS GC STOP`, `SYSTEM CAS GC START`, `SYSTEM CAS GC REBUILD`, +`SYSTEM CAS FSCK`, `SYSTEM CAS FORGET`, and `SYSTEM CAS DROP POOL MEMBER '' FROM +DISK ''` operate on a mounted `CAS` disk. Introspection lives in `system.cas_log`, +`system.cas_gc_log`, and `system.cas_mounts`. diff --git a/docs/en/antalya/cas/index.md b/docs/en/antalya/cas/index.md new file mode 100644 index 000000000000..2bc71046494b --- /dev/null +++ b/docs/en/antalya/cas/index.md @@ -0,0 +1,89 @@ +--- +description: 'What content-addressed storage is, the problem it solves, its current status, and where to go next.' +sidebar_label: 'Overview' +sidebar_position: 1 +slug: /antalya/cas +title: 'Content-Addressed Storage' +doc_type: 'guide' +--- + +# Content-addressed storage {#content-addressed-storage} + +`ReplicatedMergeTree` on object storage has two unattractive options today. Plain replication +stores a byte-identical copy of every part on every replica, so storage cost multiplies with the +replication factor. Zero-copy replication shares the bytes, but at a structural price: every +replica keeps local metadata referencing each shared S3 object, and that state grows with the +data; a commit spans three independent systems — local disk, S3, and `Keeper` — whose interleaving +is easy to get subtly wrong, and a failure in any one of the three hurts availability; sharing is +tracked by a numeric refcount, so a lost or duplicated retry can corrupt the count; and the +special cases supporting all of this are scattered widely through the `MergeTree` code. + +Content-addressed storage (`CAS`) is a `MetadataStorage` back-end for object-storage disks +(`metadata_type = cas`) that takes the same sharing goal and collapses it onto one system: every +`MergeTree` part file is stored once, keyed by the hash of its content, in the object-storage pool +itself. There is no `CAS` state in `Keeper` at all — a commit is one conditional write against a +single object in the pool — and the reachability accounting is a derived in-degree edge set folded +from append-only deltas, not a mutable refcount a lost message can corrupt. + +```mermaid +graph LR + subgraph today["Today: zero-copy replication"] + R1["Replica 1
local disk: object refs
(grows with data)"] -->|"in-flight ops only"| K["Keeper"] + R2["Replica 2
local disk: object refs
(grows with data)"] -->|"in-flight ops only"| K + R1 -.->|"shares bytes"| S1["S3"] + R2 -.->|"shares bytes"| S1 + end + subgraph cas["CAS: content-addressed pool"] + C1["Replica 1"] -->|"publish a ref"| P["S3 pool
(refs, leases, GC — all in-bucket)"] + C2["Replica 2"] -->|"publish a ref"| P + end +``` + +Every CAS bookkeeping object — refs, mount leases, GC leadership, fencing tokens — lives in the +bucket. There is no external coordinator, and no `Keeper` usage inside the pool protocol; `Keeper` +stays exactly where `ReplicatedMergeTree` already used it, for replication log and part-set +consensus, and its load does not grow with pool size. + +## Deployment guidance {#deployment-guidance} + +`GC` throughput is proportional to how much changes in the pool: a pool holding a very large +number of parts from many servers, or data that churns very quickly, means longer `GC` rounds. +Two consequences for planning: + +- **The preferred deployment is a second tier for cold data**: hot, fast-churning parts stay on + the local (or plain S3) tier, and `CAS` holds the large, slow-moving cold tail — where + deduplication pays the most and `GC` traffic is minimal. +- **At large scale, shard the pool by key prefix.** With tens of servers, or thousands of tables + and millions of parts, split the deployment into several independent pools by giving each shard + its own prefix — the shards can share one bucket: + + ```xml + https://bucket.s3.amazonaws.com/cas/{shard} + ``` + + Each prefix is a fully independent pool (its own refs, leases, and `GC`), so rounds stay short + regardless of the total fleet size. + +## Status {#status} + +`CAS` is **experimental**. It ships in Altinity Antalya builds. Experimental means the on-disk +format and the SQL surface can still change between releases — that is deliberate, not a caveat to +apologize for. Pre-release means the format can change cheaply, with zero compatibility +scaffolding, and the design can keep being iterated on invariants rather than migrations. The bet +underneath it: all you need is a good S3 bucket. See [bucket requirements](/antalya/cas/bucket-requirements) +for exactly what "good" means. + +`CAS` coexists with zero-copy replication; it does not replace it. `metadata_type = cas` is opt-in +per disk, so adopting it never requires migrating an existing deployment. + +## Where to go next {#nav} + +| Page | Covers | +|---|---| +| [Quick start](/antalya/cas/quick-start) | A minimal disk config and the first `CREATE TABLE` / `INSERT` / `SELECT` | +| [Configuration](/antalya/cas/configuration) | Every disk-level and server-level setting | +| [Bucket requirements](/antalya/cas/bucket-requirements) | What an object store must support, and which providers qualify | +| [Architecture overview](/antalya/cas/architecture/) | The object model, the Git analogy, and the safety invariants | +| [Correctness](/antalya/cas/architecture/correctness) | How the design was verified: TLA+ models, counterexamples, soak methodology | +| [Design history](/antalya/cas/architecture/design-history) | What earlier designs were tried and rejected, and why | +| [Roadmap](/antalya/cas/roadmap) | What is shipped, planned, and deliberately not pursued | diff --git a/docs/en/antalya/cas/operations/debugging.md b/docs/en/antalya/cas/operations/debugging.md new file mode 100644 index 000000000000..b1ed38791341 --- /dev/null +++ b/docs/en/antalya/cas/operations/debugging.md @@ -0,0 +1,225 @@ +--- +description: 'SQL-first CAS debugging: live investigation queries against cas_log/cas_gc_log/cas_mounts/blob_storage_log, SYSTEM CAS FSCK/GC RUN/GC STOP-START/FORGET, and the offline clickhouse-disks tools for when the server cannot answer.' +sidebar_label: 'Debugging' +sidebar_position: 4 +slug: /antalya/cas/operations/debugging +title: 'CAS Operations — Debugging' +doc_type: 'guide' +--- + +# Operations — debugging {#debugging} + +Debugging a content-addressed (`CAS`) incident starts on a **live server**, with SQL: the three +system tables plus `SYSTEM CAS` commands cover reachability checks, forced GC rounds, and +per-object/per-round forensics without ever touching the bucket directly. The offline +`clickhouse-disks` tools at the [end of this page](#offline-tools) are the fallback for when SQL +cannot reach the pool at all — the server is down, or the access is deliberately read-only forensic. + +## Investigating on a live server {#live-investigation} + +See [monitoring](/antalya/cas/operations/monitoring#system-tables) for the three system tables' +grain and general health queries; this section is investigation queries for a specific incident, +not a health dashboard. + +### What happened to this part or blob {#part-blob-history} + +`system.cas_log` carries one row per writer/GC decision, keyed by `ref_name` (a part name) or +`object_hash` (a blob's content hash): + +```sql +SELECT event_time_microseconds, event_type, outcome, reason, object_kind, object_hash, token, round, detail +FROM system.cas_log +WHERE disk_name = 'cas' AND ref_name = '' +ORDER BY event_time_microseconds; +``` + +```sql +SELECT event_time_microseconds, event_type, outcome, reason, ref_name, round, detail +FROM system.cas_log +WHERE disk_name = 'cas' AND object_kind = 'blob' AND object_hash = '' +ORDER BY event_time_microseconds; +``` + +`outcome` (`ok`, `adopt`, `resurrect`, `deleted`, `replaced`, `spared`, `absent`, `zeroed`, +`skipped`) and `reason` are the two columns to read first; `detail` is a +`Map(LowCardinality(String), String)` of decision-specific facts (`condemn_round`, +`superseded_token`, `code`, `site`) worth `arrayJoin(detail)` when the summary columns alone do not +explain the decision. See [`system.cas_log`](/operations/system-tables/cas_log) for the full column +reference. + +### Why GC is not reclaiming {#why-not-reclaiming} + +Two questions, in order: is this node's scheduler leading, and did its recent rounds actually fold? + +```sql +SELECT server_root_id, is_leader, state, last_success_age_seconds, pending_reclaim +FROM system.cas_mounts WHERE disk = 'cas'; + +SELECT event_time, outcome, candidates_marked, entries_condemned, entries_graduated, + entries_redeleted, anomalies +FROM system.cas_gc_log +WHERE event_type = 'Finish' AND disk_name = 'cas' +ORDER BY event_time DESC LIMIT 10; +``` + +A `0`/`false` `is_leader` means this node never reclaims for this disk — check the peer that holds +leadership instead. A steady `entries_condemned` with `entries_graduated` stuck at `0` means objects +are being found but never crossing the safety floor (recall the grace period is measured in full +rounds, not acks — see [condemnation and deletion](/antalya/cas/architecture/garbage-collection#condemn-delete)). +A specific blob's own story — was it ever condemned, spared, or is it not being seen at all — is the +per-object query in the previous section, filtered to `object_kind = 'blob'`. + +### What one GC round did {#gc-round-detail} + +Every round writes a `Start` and a `Finish` row to +[`system.cas_gc_log`](/operations/system-tables/cas_gc_log), correlated by `round_id` (not `round`, +which is `0` on `Start` and absent on a round that never led). One `Phase` row per phase reached +carries that phase's own `phase_duration_microseconds`, `ProfileEvents` delta, and `phase_metrics` — +group by `round_id` to reconstruct one round in order: + +```sql +SELECT event_type, outcome, phase, phase_duration_microseconds, duration_ms +FROM system.cas_gc_log +WHERE round_id = '' +ORDER BY event_time_microseconds; +``` + +### Who holds the mount {#who-holds-mount} + +```sql +SELECT server_root_id, hostname, process_id, state, writer_epoch, renewal_sequence, + expires_at, is_leader +FROM system.cas_mounts +WHERE disk = 'cas' +ORDER BY is_leader DESC; +``` + +Every `server_root_id` sharing the pool shows up here, not just this node's own — a `state` other +than `live` (`expired`, `terminated`, `fenced`, `corrupt`) on a member that should be up is the first +thing to check before assuming a lease problem is this node's own. `is_leader` and the other +process-local columns are `NULL` on every peer's row; run the query on that peer to see its own view. + +## SQL commands for live diagnosis {#sql-commands} + +### SYSTEM CAS FSCK {#sql-fsck} + +The online consistency check — unlike the offline tools below, this runs against a disk the server +already has **mounted and serving traffic**; the scan re-validates every finding against a fresh +authoritative read, so it needs no quiesce: + +```sql +SYSTEM CAS FSCK cas; +``` + +Returns one row: `disk`, `reachable`, `dangling`, `unreachable`, `pending_gc`, `awaiting_gc`, +`unaccounted`, `stale_edge`, `corrupted_runs`, `chain_broken`, `unchecked`, `lifeless_keys`, +`namespace_janitor_pending` (+`_bytes`/`_lives`), `ref_records_walked`, `physical_bytes`, +`referenced_logical_bytes`, `distinct_blobs`, `total_blob_refs`. `dangling` is the one column that +means data loss — `unreachable`, `pending_gc`, and `awaiting_gc` are objects still +moving through the normal condemn/graduate/delete pipeline, not a problem on their own. +`chain_broken` and `corrupted_runs` are the other two hard findings: a hole in a ref-log stream and a +GC source-edge run that failed its checksum, respectively. This summary-only form has no +per-object `--detail` equivalent yet — for that, the offline `cas-fsck --detail` below is still +needed. + +### SYSTEM CAS GC RUN {#sql-gc-run} + +Runs one round synchronously and returns exactly the shape of a `cas_gc_log` `Finish` row — driving +a round on demand while watching its outcome interactively is one of the most direct diagnostics +available: + +```sql +SYSTEM CAS GC RUN cas; +``` + +One row per disk it ran on: `disk`, `acquired_lease`, `deferred`, `round`, `candidates_marked`, +`objects_deleted`, `objects_absent`, `objects_replaced`, `objects_spared`, `manifests_deleted`, +`entries_condemned`, `entries_graduated`, `entries_redeleted`, `fence_outs`, `anomalies`, +`pending_candidates`, `pending_condemned`, `pending_retired`. Omitting +the disk name runs one round on every content-addressed disk on the node. A manual run executes +regardless of `SYSTEM CAS GC STOP` — `STOP` pauses only the background scheduler. + +### SYSTEM CAS GC STOP / START {#sql-gc-stop-start} + +Pause the background scheduler on one disk while investigating a suspect object, so it cannot be +condemned or deleted mid-investigation, then resume it: + +```sql +SYSTEM CAS GC STOP cas; +-- investigate, e.g. cas-inspect a specific blob's raw key +SYSTEM CAS GC START cas; +``` + +`STOP` is idempotent and stops-in-place (the same scheduler instance resumes on `START`, keeping its +`gc_id` and lease-observation history); it works even on a not-live disk. It does not stop a manual +`SYSTEM CAS GC RUN`. See the [operational surface](/antalya/cas/architecture/garbage-collection#operational-surface) +table for the full command list. + +### SYSTEM CAS FORGET {#sql-forget} + +Node-local operator assertion that a disk is permanently gone — the "fire marshal" verb for a stuck +disk (a transient/`IdentityLost` pool, an operator-asserted decommission): + +```sql +SYSTEM CAS FORGET cas; +``` + +It is an assertion, not a proof of erasure: the disk stays registered and answers further store-class +access with a typed error, and a server restart re-registers the name. This is different from +[`SYSTEM CAS DROP POOL MEMBER`](/antalya/cas/operations/migration#decommission), which permanently +retires one pool *member*'s identity across the whole shared pool — `FORGET` only affects this node's +own local view of one disk. + +## Offline tools {#offline-tools} + +When the server cannot answer — it is down, or the access needs to be read-only forensic against the +bucket directly, disaster recovery of `gc/state`, or a raw object decode — `clickhouse-disks` runs +these against the pool's backend without a live server. All five require the disk to be opened with +`true` in the `clickhouse-disks` config; they must never claim a live server's +mount. + +| Command | Use it for | +|---|---| +| `cas-fsck [--detail] [--timeout N] [--namespace PREFIX] [--partial]` | The same reachability scan as `SYSTEM CAS FSCK`, offline. `--detail` adds a per-object `\t\t` listing (`reachable`, `dangling`, `unreachable`, `pending-gc`, `awaiting-gc`, `unaccounted`, `stale-edge`, `corrupted-run`, `chain-broken`, `unchecked`, `lifeless-key`, `janitor-pending`) — the only way to get per-object, not just per-pool, findings. `--timeout`/`--partial` bound a scan on a large pool | +| `cas-gc-dryrun` | Previews the next round's deletes, read-only, no lease. Over-reports away from quiescence (does not fold new owner events) — a diagnostic only, never a delete source | +| `cas-inspect ''` | Decodes one raw object-storage key (as printed by `cas-fsck`/`cas-gc-dryrun`) straight to JSON | +| `cas-gc-rebuild [--force]` | Disaster recovery: rebuilds a `gc/state` baseline from raw owner state after the GC guard has refused every regular round. `--force` bypasses only the healthy-state refusal, never a competing leader or a failed `CAS`. See [`SYSTEM CAS GC REBUILD`](/sql-reference/statements/system#system-cas-gc-rebuild) for the destructive-tool caveats | + +```bash +clickhouse-disks -C config.xml --disk cas cas-fsck --detail +clickhouse-disks -C config.xml --disk cas cas-gc-dryrun +clickhouse-disks -C config.xml --disk cas cas-inspect '' +clickhouse-disks -C config.xml --disk cas cas-gc-rebuild --force +``` + +`cas-drop-member` — the offline twin of `SYSTEM CAS DROP POOL MEMBER` — is covered on the +[migration page](/antalya/cas/operations/migration#decommission) alongside the SQL form, since +decommissioning a pool member is a migration/scale-down operation, not an incident-time tool. + +## The CLICKHOUSE_USER_FILES gotcha when reproducing a test manually {#user-files-gotcha} + +Running a `CAS` stateless test directly with `tests/clickhouse-test` against a manually started +`clickhouse-server` (outside a configured praktika lane) requires exporting `CLICKHOUSE_USER_FILES` +to match the server's actual data path. The harness's default, +`/var/lib/clickhouse/user_files`, will not match a custom data path, which makes the pool directory +invisible to the server — the symptom is an `Unknown disk` error together with a diagnostic that +reads like an empty pool (e.g. `baseline=0 after_insert=0`) even though the server is otherwise +healthy. + +## What to collect before filing a bug {#filing-a-bug} + +- `SYSTEM CAS FSCK ''` output (or `clickhouse-disks cas-fsck --detail`, if the server cannot + answer or a per-object listing is needed) — the authoritative reachability snapshot at the time of + the incident. +- The `system.cas_gc_log` rows for the relevant `round_id`(s): `Start`, every `Phase`, and `Finish`. +- The `system.cas_log` rows for the specific ref name, blob hash, or object key involved, filtered by + `event_time` around the incident. +- `system.cas_mounts` output from every node sharing the pool, to capture lease/epoch state at + incident time — it is a live view and will not reflect a state that has since changed. +- For a suspected object-store issue, `system.blob_storage_log` rows for the affected `disk_name` + with a nonzero `error_code`, and the relevant `CAS*` `ProfileEvents` (`system.query_log`'s + `ProfileEvents` map for one query, or `system.metric_log`'s `ProfileEvent_*` columns for a window — + see [monitoring](/antalya/cas/operations/monitoring#key-metrics) for which counters matter and the + restart-resets-`system.events` caveat). +- The server version and, if the incident is reproducible, the exact `CREATE TABLE` / `INSERT` / + `ALTER` sequence that triggers it. diff --git a/docs/en/antalya/cas/operations/migration.md b/docs/en/antalya/cas/operations/migration.md new file mode 100644 index 000000000000..0d4f954c2dae --- /dev/null +++ b/docs/en/antalya/cas/operations/migration.md @@ -0,0 +1,209 @@ +--- +description: 'Adding a content-addressed disk to an existing deployment, moving a partition onto it with ALTER TABLE MOVE PARTITION, rolling back, and permanently decommissioning a pool member.' +sidebar_label: 'Migration' +sidebar_position: 1 +slug: /antalya/cas/operations/migration +title: 'CAS Operations — Migration' +doc_type: 'guide' +--- + +# Operations — migration {#migration} + +This page walks through moving `MergeTree` data onto a content-addressed (`CAS`) disk from an +existing disk, and the reverse. `metadata_type = cas` is opt-in per disk (see the +[overview](/antalya/cas)), so this is an additive change to a running deployment: the existing +disk and its data are untouched until a partition is explicitly moved. + +## Add a CAS disk alongside an existing one {#add-disk} + +A storage policy can carry both an ordinary disk and a `CAS` disk as separate volumes. `ALTER TABLE +... MOVE PARTITION ... TO DISK` then moves data between them without an `INSERT`/`DROP` cycle. As on +the [configuration](/antalya/cas/configuration#disk-config) page, the recommended shape layers a +`type=cache` disk over the `CAS` disk, and the policy's volume references the **cached** disk name: + +```xml + + + + + local + /var/lib/clickhouse/local_disk/ + + + object_storage + s3 + cas + {replica} + https://bucket.s3.amazonaws.com/cas/ + ... + ... + + + cache + cas + /var/lib/clickhouse/cas_cache/ + 10Gi + + + + + + + local_disk + + + cas_cache + + + + + + +``` + +See [configuration](/antalya/cas/configuration) for the full disk-level settings surface and +[bucket requirements](/antalya/cas/bucket-requirements) for what the target bucket needs to +support. A table does not need to be created for the first time on `CAS` to use it — an existing +table just needs its storage policy widened to include a volume backed by a `CAS` disk, which is a +metadata-only change (`ALTER TABLE ... MODIFY SETTING storage_policy = ...`, subject to the usual +constraint that the new policy must still contain every volume and disk of the old one — a storage +policy can only grow, never lose a disk it once had). + +## Move a partition onto CAS {#move-partition} + +`ALTER TABLE ... MOVE PARTITION ... TO DISK` moves every part of one partition to the named disk in +place — the ordinary `MergeTree` partition-move mechanism, unchanged by `CAS`: + +```sql +CREATE TABLE events (event_date Date, event_id UInt64, payload String) +ENGINE = MergeTree ORDER BY event_id PARTITION BY event_date +SETTINGS storage_policy = 'tiered'; + +INSERT INTO events VALUES ('2026-08-04', 1, 'hello'), ('2026-08-04', 2, 'world'); + +SELECT name, partition, disk_name FROM system.parts WHERE table = 'events' AND active; +``` + +```text +Row 1: +────── +name: 20260804_1_1_0 +partition: 2026-08-04 +disk_name: local_disk +``` + +The partition starts on `local_disk`, the first volume in the policy. Moving it onto `CAS` uploads +each part's files as content-addressed blobs, writes a part manifest, and publishes a ref — the same +write path an `INSERT` directly onto `CAS` takes (see +[what just happened](/antalya/cas/quick-start#what-happened) in the quick start). `TO DISK` names the +disk actually listed in the policy's volume — with a cache layered in front, that is the **cache** +disk's name (`cas_cache`), not the raw `CAS` disk's name (`cas`) underneath it; naming the raw disk +is refused, because it is not a member of the table's storage policy: + +```sql +ALTER TABLE events MOVE PARTITION '2026-08-04' TO DISK 'cas_cache'; + +SELECT name, partition, disk_name FROM system.parts WHERE table = 'events' AND active; +``` + +```text +Row 1: +────── +name: 20260804_1_1_0 +partition: 2026-08-04 +disk_name: cas_cache +``` + +```sql +SELECT * FROM events ORDER BY event_id; +``` + +```text +2026-08-04 1 hello +2026-08-04 2 world +``` + +`system.parts.disk_name` reports the cache disk's name, not the underlying `CAS` disk's — this is +the ordinary `type=cache` disk behavior (the same happens layering a cache over any other disk type) +and is not `CAS`-specific. `system.filesystem_cache` shows the part's files populated into the +`cas_cache` cache on this read-through. + +## Roll back {#rollback} + +The move is symmetric: `MOVE PARTITION ... TO DISK` back onto the original disk name returns the +partition to its previous location, with the data intact throughout: + +```sql +ALTER TABLE events MOVE PARTITION '2026-08-04' TO DISK 'local_disk'; + +SELECT name, partition, disk_name FROM system.parts WHERE table = 'events' AND active; +``` + +```text +Row 1: +────── +name: 20260804_1_1_0 +partition: 2026-08-04 +disk_name: local_disk +``` + +Moving a partition off `CAS` does not itself delete the blobs it stops referencing — dropping the +old ref makes them eligible for reclamation by the next +[GC round](/antalya/cas/architecture/garbage-collection), the same as dropping a part. + +This exact three-disk, cache-over-`CAS` configuration and the forward/rollback `ALTER TABLE ... MOVE +PARTITION` sequence above were run against a live server before publication, using the `local` +object-storage backend for the `cas` disk: `CREATE TABLE`, `INSERT`, both `MOVE PARTITION` +directions, the `system.parts` checks, the `system.filesystem_cache` check, and the `SELECT` all +completed with zero errors and the shown output. A prior attempt to move onto `TO DISK 'cas'` +directly (the raw disk, not the cache) was refused with `All parts of partition '20260804' are +already on disk 'cas_cache'. (UNKNOWN_DISK)` — a real error message from the run, kept here because +it is exactly what an operator sees after guessing the wrong disk name. + +## Permanently removing a pool member {#decommission} + +A `CAS` pool can be shared by several servers (see [`server_root_id`](/antalya/cas/architecture/mounts-and-leases#server-root-id)). +Scaling down — permanently removing a server that will never rejoin the pool — is a distinct, +irreversible operation from an ordinary restart or a temporary outage: it fences the member's +`server_root_id` and reclaims the storage attributable only to it. + +`SYSTEM CAS DROP POOL MEMBER` claims the victim's mount slot as an administrative writer (refusing +immediately if the member is still alive), drops every table namespace the member owned, sweeps +manifest debris, drains its staging and mountpoint objects, and — only once every drain is +confirmed — retires the mount slot itself. It emits ordinary ref-edge deltas rather than a GC +transition: it does not synchronously reclaim shared blob content, it only makes the now-unreferenced +blobs eligible for an ordinary GC round to reclaim later. + +```sql +SYSTEM CAS DROP POOL MEMBER 'server_root_id' FROM DISK 'disk_name' [ON CLUSTER cluster_name] +``` + +Both `server_root_id` and `disk_name` are required string literals. The offline CLI twin, +`clickhouse-disks cas-drop-member `, does the same work against a disk opened +read-only — the pool-admin claim happens internally, so the disk it runs against must not be the +live server's own mount: + +```bash +clickhouse-disks -C config.xml --disk cas cas-drop-member 'replica-2' +``` + +The command returns one row (or, offline, one line per field) with `namespaces_removed`, +`namespaces_already_removed`, `committed_refs_removed`, `precommits_removed`, +`manifest_debris_removed`, `staging_objects_removed`, `mountpoint_objects_removed`, and +`slot_removed`. It is resumable: a rerun skips namespaces already marked removed and reports them +under `namespaces_already_removed` rather than redoing the work. A per-object drain failure is +recorded as a `warning` rather than raised as an exception, leaving the slot terminated but not +fully drained so a later invocation can resume; a non-empty `warnings` means exactly that, and the +mount slot stays in place as a resume anchor rather than being fully retired. + +**Preconditions.** Confirm the member is actually and permanently dead before running this: the +operation fences that `server_root_id` out even if the server comes back online, and it deletes +namespace and drain state that cannot be recovered. Check `system.cas_mounts` for the member's +`state` and `last_success_age_seconds` first — a `live` row, or one with a recent lease renewal, +means the member is not a decommission candidate yet. + +**Verification.** After the command reports `slot_removed = true` with no warnings, the member's +`server_root_id` no longer appears as a row in `system.cas_mounts` on any peer, and a subsequent +`SYSTEM CAS GC RUN` on the pool will no longer wait on or fence its heartbeat. See +[mount, unmount, crash](/antalya/cas/architecture/mounts-and-leases#mount-lifecycle) for how the +claim, drain, and retirement steps fit into the mount-slot lifecycle. diff --git a/docs/en/antalya/cas/operations/monitoring.md b/docs/en/antalya/cas/operations/monitoring.md new file mode 100644 index 000000000000..6ab8b3dd9bb0 --- /dev/null +++ b/docs/en/antalya/cas/operations/monitoring.md @@ -0,0 +1,101 @@ +--- +description: 'The three content-addressed system tables, a key-metrics table with healthy ranges, and queries for reading GC health from cas_gc_log.' +sidebar_label: 'Monitoring' +sidebar_position: 2 +slug: /antalya/cas/operations/monitoring +title: 'CAS Operations — Monitoring' +doc_type: 'guide' +--- + +# Operations — monitoring {#monitoring} + +Content-addressed (`CAS`) storage exposes three system tables and a family of `CAS`-prefixed +`ProfileEvents`. This page is the entry point for day-to-day health checks; see +[debugging](/antalya/cas/operations/debugging) for incident-time tooling and +[troubleshooting](/antalya/cas/operations/troubleshooting) for symptom-driven diagnosis. + +## The three system tables {#system-tables} + +| Table | Grain | Use it for | +|---|---|---| +| [`system.cas_mounts`](/operations/system-tables/cas_mounts) | One row per mount slot in the pool, read live from the backend on every query | Who is in the pool right now, lease/epoch state, which node holds GC leadership | +| [`system.cas_gc_log`](/operations/system-tables/cas_gc_log) | One `Start`/`Finish` row per GC round, plus one `Phase` row per phase reached | GC round outcomes, duration, and where a round's `LIST`/`GET`/`PUT`/`DELETE` budget went | +| [`system.cas_log`](/operations/system-tables/cas_log) | One row per writer/GC decision (blob puts, dedup adoptions, retire decisions, dangling-access findings) | Fine-grained forensics for one part, one blob hash, or one round | + +`system.cas_mounts` is the only one of the three with no persisted backing log — it is a live view, +so a transient backend error on one disk is skipped rather than blinding the whole query. The other +two are ordinary `system.*_log` tables and follow the usual flush/retention settings. + +## Key metrics {#key-metrics} + +Every `CAS`-related `ProfileEvent` carries the uppercase `CAS`/`CASGC` prefix. This is a curated +subset for a first health pass; the full list groups by object class (`CASBlob*`, `CASManifest*`, +`CASRoot*`, `CASGC*`, `CASServer*`, `CASOther*`, `CASRef*`, `CASMeta*`) and is enumerated in +`src/Common/ProfileEvents.cpp`. + +| Metric | Healthy range | A spike or nonzero means | +|---|---|---| +| `CASBlobCompareSwapConflict` | Near zero relative to `CASBlobCompareSwap` | Concurrent-update contention on blob metadata | +| `CASBlobHeadFirst` vs `CASBlobBodyPutAvoided` | `CASBlobBodyPutAvoided` tracks `CASBlobHeadFirst` closely | A widening gap means the dedup `HEAD`-before-`PUT` gate is firing but not finding matches — expected for genuinely new content, worth checking if it dominates | +| `CASRefAppendWedged` | Zero | A ref-log append lane exhausted its retries after an uncertain `PUT`; ref-log progress on that namespace may be stalled | +| `CASRefNeedsRecovery` | Zero | A ref-append lane could not install a known-durable transaction and now refuses writes, snapshots, and confirmation until durable replay completes | +| `CASRefAppendSealRejected` | Occasional (a deposed writer losing a race is the protocol working); sustained growth is not | A writer keeps retrying after losing its mount and does not yet know it | +| `CASGCHeartbeatFenceOuts` | Zero on a healthy pool | GC fenced an expired mount; check `system.cas_mounts` for a member that should have cleanly unmounted | +| `CASGCUnmatchedRemoveDeltas` | Occasional (benign per-key no-op by design) | A persistent nonzero rate means removal deltas are reaching the reducer without their matching activation — a correctness signal worth a look, not an automatic false deletion | +| `CASGCCondemnMarkerUnconfirmedCarry` | Zero | A durable condemn marker could not be confirmed; deletion is safely postponed but investigate marker write/read failures | +| `CASGCMetaWriteAnomaly` | Zero | The bounded GC metadata pool failed an operation; backend or pool pressure may delay metadata convergence | +| `CASRefRollbackBestEffortDropFailed` | Zero | A rollback cleanup drop hit a backend failure; refs may remain live and GC may be delayed on that namespace | + +Two counter-reading caveats that apply to `system.events`-backed metrics generally, not only `CAS` +ones: a counter that has never incremented can be **absent** from `system.events` rather than +present at zero — query with `system_events_show_zero_values = 1` to tell "never happened" from "not +shown". A server restart resets `system.events` to zero, so a cumulative `CAS` total across a +restart has to be computed from summed per-second deltas in `system.metric_log`, not read directly +off `system.events`. + +## Reading GC health from cas_gc_log {#gc-health} + +Round outcomes over the last day, per disk: + +```sql +SELECT disk_name, outcome, count() AS rounds, avg(duration_ms) AS avg_ms +FROM system.cas_gc_log +WHERE event_type = 'Finish' AND event_time > now() - INTERVAL 1 DAY +GROUP BY disk_name, outcome +ORDER BY disk_name, rounds DESC; +``` + +A steady stream of `Success` and `Deferred` rows is healthy; `Deferred` means the round found no +changed shard needing a fold and no graduation was due — a cheap round, not a stuck one (see +[the round](/antalya/cas/architecture/garbage-collection#the-round)). Recurring `Error` rows, or +`NotALeader` outcomes for the disk's own scheduler, warrant investigation. `anomalies` in the +`Finish` row is worth a steady watch: it is fold clamps surfaced and survived, so a non-zero value +that persists across rounds is more interesting than an isolated one. + +Which phase dominates round duration or the `LIST` budget — reproduced from the +[per-phase rows](/operations/system-tables/cas_gc_log#per-phase-rows) reference: + +```sql +SELECT phase, + count() AS rounds, + quantile(0.99)(phase_duration_microseconds) AS p99_microseconds, + sum(ProfileEvents['S3ListObjects']) AS lists +FROM system.cas_gc_log +WHERE event_type = 'Phase' AND disk_name = 'cas' +GROUP BY phase +ORDER BY p99_microseconds DESC; +``` + +Pending-reclaim backlog and time since a disk's GC last led, from the live mount view: + +```sql +SELECT disk, server_root_id, is_leader, pending_reclaim, last_success_age_seconds, wedged_namespace_count +FROM system.cas_mounts +WHERE is_leader IS NOT NULL +ORDER BY disk, server_root_id; +``` + +`is_leader`, `pending_reclaim`, `last_success_age_seconds`, and `wedged_namespace_count` are +process-local — `NULL` on every row describing a peer's mount — so this query is only informative +run against the node whose GC leadership you are checking; run it on each node to see the whole +pool's view of itself. diff --git a/docs/en/antalya/cas/operations/troubleshooting.md b/docs/en/antalya/cas/operations/troubleshooting.md new file mode 100644 index 000000000000..94c29a8d077c --- /dev/null +++ b/docs/en/antalya/cas/operations/troubleshooting.md @@ -0,0 +1,29 @@ +--- +description: 'Symptom-to-action table for common content-addressed storage incidents: mount lease loss, stalled GC, startup failures, fsck timeouts, and read-only pools.' +sidebar_label: 'Troubleshooting' +sidebar_position: 3 +slug: /antalya/cas/operations/troubleshooting +title: 'CAS Operations — Troubleshooting' +doc_type: 'guide' +--- + +# Operations — troubleshooting {#troubleshooting} + +Start from the symptom, not the mechanism. Each row below names a concrete diagnostic query or +command and the action it points to; see [monitoring](/antalya/cas/operations/monitoring) for the +system tables referenced and [debugging](/antalya/cas/operations/debugging) for the underlying +tools. + +| Symptom | Diagnosis | Action | +|---|---|---| +| A server keeps losing its mount lease and self-remounting | Check `system.cas_mounts` for the server's own row's `state`/`expires_at`; losing the lease is neither read-only mode nor an abort, it trips a local fence and retries with backoff from 1s to 30s | Look for clock issues (the fence clock is `CLOCK_BOOTTIME`) or network latency exceeding `mount_lease_ttl_ms` (default 30s); see [the mount lease](/antalya/cas/architecture/mounts-and-leases#mount-lease) | +| Writes slow down or stall under load, with no exception reaching the client | S3 `SlowDown`/`ServiceUnavailable`/`RequestTimeout`/`InternalError` (5xx) responses are not on `CasRequestController`'s definite-failure whitelist (only malformed-request, entity-too-large, and access-denied are), so they classify as `Unresolved` and are retried automatically. Confirm with `sum(ProfileEvents['CASConditionalWriteUnresolved'])` rising alongside `sum(ProfileEvents['CASConditionalWriteAttempts'])` over `system.query_log` for the affected window (or `ProfileEvent_CASConditionalWriteUnresolved` in `system.metric_log` for a cumulative view across queries), and check `system.blob_storage_log` for `disk_name = ''` rows with a nonzero `error_code` around the same window | Nothing to configure per-request: the controller retries the same `(key, bytes)` with capped-exponential backoff (200ms initial, capped at 5s) for up to 16 attempts inside a 90-second operation deadline, and the mount-lease renewer keeps extending the fence across the disruption — this is the "blips, throttling, partial outages" case the write path is built to survive. Confirm the mount lease itself is still renewing (`system.cas_mounts.expires_at` moving forward, `last_success_age_seconds` not climbing) — if it is, this is expected and self-resolving. If `SlowDown` responses are sustained rather than transient, check the bucket's request-rate limits against the pool's actual PUT/GET rate (see [bucket requirements](/antalya/cas/bucket-requirements)) and consider lowering `cas_blob_upload_pool_size` to reduce concurrent upload traffic; a write only surfaces a client-visible `NETWORK_ERROR` if the 90-second deadline is exhausted before the store recovers, and that error is retried by the ordinary merge/insert backoff, not silently dropped | +| `GC` never seems to reclaim space after tables are dropped | `SELECT * FROM system.cas_gc_log WHERE event_type='Finish' ORDER BY event_time DESC LIMIT 5` — check `outcome`; also `SELECT is_leader FROM system.cas_mounts` on this node | If `outcome != 'Success'`/`'Deferred'`, see [reading GC health](/antalya/cas/operations/monitoring#gc-health); if this node is not the leader (`is_leader = 0`), it never reclaims for this disk — check the peer holding leadership. Reclamation also needs at least two full rounds past condemnation by design (the grace period is rounds, not acks) — a single manual `SYSTEM CAS GC RUN` will not finish it | +| A dangling-access exception or `CORRUPTED_DATA` on read | Run `clickhouse-disks cas-fsck --detail` and check `dangling` specifically — it is the one class that means data loss, distinct from `unreachable`/`awaiting-gc`, which are just waiting for graduation | A nonzero `dangling` count is a real incident: collect the `--detail` output (see [what to collect before filing a bug](/antalya/cas/operations/debugging#filing-a-bug)) before taking any destructive action | +| `SYSTEM CAS FSCK` or `clickhouse-disks cas-fsck` times out on a large pool | The scan is bounded by `--timeout` (default 600s / the `SYSTEM` form has no override); a large `roots/` prefix can make the scan slow | Retry with `--partial` to see the counts accumulated so far instead of aborting empty-handed, or `--namespace ` to scope the scan to a subset of namespaces | +| `SYSTEM CAS DROP POOL MEMBER` returns a non-empty `warnings` column | A per-object drain step could not confirm emptiness; the mount slot is left terminated but not fully drained, as a resume anchor | Rerun the same command — it is resumable and skips namespaces already marked removed, reporting them under `namespaces_already_removed` | +| Writes or `ALTER`s on a `CAS` disk fail with a `READONLY`-class error | The disk's metadata storage rejects every mutating entry point; this is deliberate for a disk opened with `true`, used by every offline `clickhouse-disks` tool | Confirm whether the disk was intentionally configured read-only (offline inspection, `cas-fsck`, `cas-gc-dryrun`, `cas-gc-rebuild`, `cas-drop-member` all require it); a production disk serving writes must not carry `true` | +| A table stays unavailable after a transient network error during startup | `AsyncLoader` has no retry/requeue path for a failed table load job: a transient S3 `NETWORK_ERROR` during `CAS` ref-table startup recovery can leave the job permanently `FAILED` | Restart the server, or issue a fresh load for the table; this is a one-shot job design, not a `CAS`-specific bug | +| A `CAS` server process aborts after its pool directory was removed or renamed while mounted | The lease-renewal thread observes a confirmed mismatch and raises `LOGICAL_ERROR`; in debug/sanitizer builds constructing that exception aborts the process | Never remove or rename a mounted pool's storage path while a server has it live; to retire a member permanently use [`SYSTEM CAS DROP POOL MEMBER`](/antalya/cas/operations/migration#decommission) instead of raw filesystem operations | +| Stale-looking part metadata after an out-of-band change to the pool | The part-folder view cache may be serving a retained (not re-validated) view | Set the disk-level `part_folder_cache_bytes = 0` as a diagnostic kill switch to disable retention, and run `fsck`/integrity checks with `part_folder_validate = always` so every read re-proves the body | +| A wide merge (many thousands of columns) fails with a port-exhaustion error from the network layer | Each column in a wide part can cost a separate object-store operation in one merge, and a very wide part can issue on the order of the column count in requests, exhausting local ephemeral TCP ports under load | Reduce concurrent merge parallelism on that table, or increase the host's ephemeral port range; this is a general high-fan-out-merge limit, not specific to content addressing | diff --git a/docs/en/antalya/cas/quick-start.md b/docs/en/antalya/cas/quick-start.md new file mode 100644 index 000000000000..b40e7d547121 --- /dev/null +++ b/docs/en/antalya/cas/quick-start.md @@ -0,0 +1,145 @@ +--- +description: 'A minimal content-addressed storage disk config and the first CREATE TABLE, INSERT, and SELECT against it, executed live before publication.' +sidebar_label: 'Quick start' +sidebar_position: 2 +slug: /antalya/cas/quick-start +title: 'CAS Quick Start' +doc_type: 'guide' +--- + +# Quick start {#quick-start} + +## The disk config {#disk-config} + +A `CAS` disk is an `object_storage` disk with `metadata_type` set to `cas` and an explicit, +per-server `server_root_id`. This example uses the `local` object-storage backend so it needs +nothing beyond a `ClickHouse` binary — no bucket, no credentials: + +```xml + + + + + object_storage + local + cas + quickstart-demo + cas_pool/ + + + cache + cas + cas_cache/ + 10Gi + + + + + +
+ cas_cache +
+
+
+
+
+
+``` + +The `cas_cache` disk layers a local filesystem cache over `cas`: it absorbs repeated reads of the +same blob while `cas` stays the source of truth, and the policy's volume points at the cached disk +— see [configuration](/antalya/cas/configuration#disk-config) for the sizing note. + +`server_root_id` must be unique per server sharing a pool. On a single, non-replicated server a +literal string, as above, is enough; on a replicated cluster where every replica shares one config, +`{replica}` expands through the same macro substitution an `s3` +disk's `endpoint` already uses, giving each replica a distinct subtree from one template. + +**S3 endpoint variant.** Swap `object_storage_type` to `s3` and add the usual object-storage +connection keys; nothing else in this config changes: + +```xml + + object_storage + s3 + cas + quickstart-demo + https://bucket.s3.amazonaws.com/cas/ + ... + ... + +``` + +`cas_cache` is unaffected by this swap — it wraps `disk cas` regardless of which object-storage +backend `cas` itself uses. See [bucket requirements](/antalya/cas/bucket-requirements) for what the +target bucket needs to support, and [configuration](/antalya/cas/configuration) for the full +settings surface. + +## First table {#first-table} + +```sql +CREATE TABLE events (event_date Date, event_id UInt64, payload String) +ENGINE = MergeTree ORDER BY event_id +SETTINGS storage_policy = 'cas'; + +INSERT INTO events VALUES ('2026-08-04', 1, 'hello'), ('2026-08-04', 2, 'world'); + +SELECT * FROM events ORDER BY event_id; +``` + +```text + ┌─event_date─┬─event_id─┬─payload─┐ +1. │ 2026-08-04 │ 1 │ hello │ +2. │ 2026-08-04 │ 2 │ world │ + └────────────┴──────────┴─────────┘ +``` + +An ordinary `MergeTree` table on a `CAS` disk. `INSERT`, `SELECT`, merges, and mutations all work +exactly as on any other `MergeTree` — the content-addressing is invisible at the SQL surface. + +## Checking the mount {#checking-the-mount} + +```sql +SELECT disk, server_root_id, state, is_leader FROM system.cas_mounts; +``` + +```text +Row 1: +────── +disk: cas +server_root_id: quickstart-demo +state: live +is_leader: 0 + +Row 2: +────── +disk: cas_cache +server_root_id: quickstart-demo +state: live +is_leader: 0 +``` + +`system.cas_mounts` shows every server currently sharing this pool, not just the local one. With a +cache layered in front, the same mount shows up **twice** — once under each configured disk name +(`cas` and `cas_cache`), both reporting the one underlying `server_root_id` — because the table +lists a row per configured disk, not per mount; this is the one visible change the cache layer adds +to this page's output. `is_leader` is `0` on both rows because `GC` leader election is asynchronous +and had not yet run at query time on this freshly mounted disk — see +[mounts and leases](/antalya/cas/architecture/mounts-and-leases) for the full column reference and +[garbage collection](/antalya/cas/architecture/garbage-collection) for leadership. + +## What just happened {#what-happened} + +The `INSERT` wrote two part files as content-addressed blobs, a part manifest listing them, and a +ref pointing the part name at that manifest — the only mutable object the write touched. On a +second replica sharing this same pool, inserting or fetching the identical content publishes a ref +without re-uploading a single byte; see +[garbage collection](/antalya/cas/architecture/garbage-collection) for how a dropped part's blobs +get reclaimed once nothing references them anymore. + +This exact cache-layered configuration and SQL were run against a live server before publication: +`CREATE TABLE`, `INSERT`, `SELECT`, and the `system.cas_mounts` query above all completed with zero +errors, with the two-row `system.cas_mounts` output shown above captured from that run. The +`INSERT`/`SELECT` output is unaffected by the cache — the one visible difference the cache layer +adds anywhere on this page is that second `system.cas_mounts` row. + diff --git a/docs/en/antalya/cas/roadmap.md b/docs/en/antalya/cas/roadmap.md new file mode 100644 index 000000000000..0c8b14110d89 --- /dev/null +++ b/docs/en/antalya/cas/roadmap.md @@ -0,0 +1,108 @@ +--- +description: 'What CAS ships today, what is still planned, known platform limitations, and design directions deliberately not taken.' +sidebar_label: 'Roadmap' +sidebar_position: 5 +slug: /antalya/cas/roadmap +title: 'CAS Roadmap' +doc_type: 'guide' +--- + +# CAS roadmap {#cas-roadmap} + +CAS is experimental (see [status](/antalya/cas/)): the format and SQL surface can still change. +This page tracks what already works, what is still ahead, and — since a project this deep in +adversarial verification collects real dead ends — what was tried and deliberately not shipped. + +## Shipped {#shipped} + +**Storage and object model.** Content-addressed blobs deduplicated across every replica sharing +a pool; immutable part manifests; a pluggable blob-hash algorithm (`cityhash128` default, +`xxh3-128`, or `sha256`) fixed per pool at creation; a JSON-text object format end to end (no +binary framing, no protobuf) so any object can be read with ordinary line-oriented tools. + +**Write path.** Conditional writes (create-if-absent, compare-and-swap) as the only mutual +exclusion primitive the pool needs; an adaptive HEAD-before-PUT dedup gate; a bounded thread pool +fanning out multi-blob part uploads in parallel; carry-forward on mutation for `Wide` parts (an +untouched column is re-referenced, not re-hashed). + +**Read path.** Ref resolution to manifest to ranged blob reads, with a manifest-decode cache and +a part-folder view cache sitting on that path. + +**Replication.** Fetch by relink between replicas sharing a pool — a replicated fetch publishes a +ref pointing at blobs the pool already has, at zero bytes on the wire — with a publish-then-confirm +protocol that closes the sender-crash and stale-cache races a naive relink would be exposed to. + +**Garbage collection.** An 18-phase round built on a causal ack-floor (no separate fence-and-recheck +phase); sharded folding (`gc_shards`); condemn/spare bookkeeping; generation pruning with a +configurable retention window; a dry-run mode and a rebuild path for recovery. + +**Mounts and identity.** Explicit `server_root_id` per disk; a renewable mount lease with +observation-based reclaim of an expired predecessor (never trusting a foreign body's wall-clock +timestamp); clean decommission of a permanently departed pool member +(`SYSTEM CAS DROP POOL MEMBER`). + +**Backends.** AWS S3 (`ETag`-based conditional dialect) and Google Cloud Storage (generation-token +dialect) both live-validated; a capability probe that runs at every writable mount and refuses to +proceed on a backend that does not enforce the conditions CAS depends on. + +**Operability.** `system.cas_log`, `system.cas_gc_log`, and `system.cas_mounts` for introspection; +`clickhouse-disks` commands `ca-fsck`, `ca-inspect`, `ca-gc-dryrun`, and `ca-gc-rebuild`; the +`SYSTEM CAS` SQL control surface (`GC RUN`/`STOP`/`START`/`REBUILD`, `FSCK`, `FORGET`, `DROP POOL +MEMBER`). + +**Coexistence.** `metadata_type = cas` is opt-in per disk; zero-copy replication keeps working +unmodified on disks that do not opt in — see [why CAS exists](/antalya/cas/) for the fuller +positioning. + +## In progress / planned {#in-progress} + +- **Azure real-store validation.** AWS and GCS are live-validated; Azure is not — see + [known limitations](#known-limitations) below. +- **WORM deployments.** A read-only disk mode exists today; a fuller write-once story — a pool + served immutably, with pinned snapshots for read-only replicas — has a draft design and is not + yet implemented. +- **Backup and restore.** See [Backups](#backups) below — this is further along as a design than as + an implementation. +- **First-class local-disk pools.** Today a pool over local paths runs a minimal best-effort + emulation of the conditional-write dialect (single-process, serialized resurrections). Making the + local mode efficient in its own right is under consideration: a local CAS tier is a natural target + for backups, pinned snapshots, and moving data between CAS tiers. + +## Known limitations {#known-limitations} + +- **Azure Blob Storage's REST API documents the equivalent conditional headers CAS needs, but no + CAS conditional-write dialect is wired up for it yet** — untested, not validated by the capability + probe. See [bucket requirements](/antalya/cas/bucket-requirements) and + [the backend page](/antalya/cas/architecture/backend) for the AWS/GCS dialects that are wired. +- **Other S3-compatible object stores qualify only if they pass the capability probe** — a store + that silently ignores conditional writes is refused at mount time rather than trusted. Bucket + versioning must be off; it is not required to be on. +- **The format and settings surface can still change.** CAS is pre-release: there is no persisted + production data to keep compatible, so a format change costs a version bump, not a migration. + Treat every detail on these pages as subject to change until the format is declared stable. + +## Backups {#backups} + +A `snapshot` / `mirror` / `fetch` / `restore` design is **approved but not implemented**. The +model is deliberately git-shaped: `snapshot` is instant and free (like `git tag` — it references +existing manifests, copies nothing); `mirror` is a continuous pull from a production pool into a +backup pool (like `git push --mirror`); `fetch` is a selective pull from a backup pool into a +fresh pool (a partial clone); `restore` is an in-pool relink (like `git checkout`, instant). One +closure-walk-and-hash-verification primitive is meant to serve all three pool-to-pool movements. +None of this is wired into the `BACKUP`/`RESTORE` SQL surface yet. + +## Deliberately rejected directions {#rejected} + +A short pointer list; the reasons and the counterexamples that drove each decision are in +[design history](/antalya/cas/architecture/design-history). + +- A Merkle tree layer as a distinct object kind. +- Epoch-based reclamation as the GC core. +- An integer in-degree refcount instead of a folded edge set. +- A persistent, append-only namespace registry for GC discovery. +- Per-incarnation body keys as an alternative to an in-body incarnation tag. +- Using a blob's freshness metadata as the authority for its lifecycle instead of an advisory hint. +- A separate all-shard fence-and-recheck phase per GC round. +- A sparse ref-id allocator with a certificate stack bolted on to prove completeness. +- Extending zero-copy replication instead of building a new mechanism — CAS is an alternative to + zero-copy, not a replacement; both remain available. diff --git a/docs/en/operations/storing-data.md b/docs/en/operations/storing-data.md index 8a2903cd1ebb..c26316f5cdff 100644 --- a/docs/en/operations/storing-data.md +++ b/docs/en/operations/storing-data.md @@ -46,7 +46,7 @@ It requires specifying:
-Optionally, `metadata_type` can be specified (it is equal to `local` by default), but it can also be set to `plain`, `web` and, starting from `24.4`, `plain_rewritable`. +Optionally, `metadata_type` can be specified (it is equal to `local` by default), but it can also be set to `plain`, `web`, `plain_rewritable` (starting from `24.4`) and `cas`. Usage of `plain` metadata type is described in [plain storage section](/operations/storing-data#plain-storage), `web` metadata type can be used only with `web` object storage type, `local` metadata type stores metadata files locally (each metadata files contains mapping to files in object storage and some additional meta information about them). For example: @@ -452,6 +452,106 @@ is equal to Starting from `24.5` it is possible to configure any object storage disk (`s3`, `azure`, `local`) using the `plain_rewritable` metadata type. +### Using Content-Addressed Storage {#content-addressed-storage} + +Setting `metadata_type` to `cas` turns a disk into a content-addressed (CAS) disk: every +object is addressed by the hash of its content rather than by a randomly generated blob name, so +identical content written by different parts (or different tables) is stored once and shared. A +background garbage collector reclaims objects once no part references them anymore; see +[`SYSTEM CAS GC RUN`](/sql-reference/statements/system#system-cas-gc-run), +[`SYSTEM CAS GC REBUILD`](/sql-reference/statements/system#system-cas-gc-rebuild), +[`SYSTEM CAS DROP POOL MEMBER`](/sql-reference/statements/system#system-cas-drop-pool-member), +and the [`system.cas_gc_log`](/operations/system-tables/cas_gc_log), +[`system.cas_mounts`](/operations/system-tables/cas_mounts), and +[`system.cas_log`](/operations/system-tables/cas_log) system tables. See the +[content-addressed storage documentation](/antalya/cas) for the architecture, operations +runbooks, and a live-validated quick start. + +Configuration: + +```xml + + object_storage + s3 + cas + https://s3.eu-west-1.amazonaws.com/clickhouse-eu-west-1.clickhouse.com/data/ + 1 + + server-{replica} + disks/s3_cas/cas_scratch/ + local + cityhash128 + true + 60 + 1 + 67108864 + 67108864 + always + +``` + +The CAS-specific settings are written directly inside the disk element, alongside +`object_storage` / `` and the connection settings, which are the +same as for any other `object_storage` disk. Since the disk element already scopes every key to this +disk, none of the keys below carry a redundant `cas_`/`ca_` prefix. + +#### Required parameters {#required-parameters-content-addressed} + +- `server_root_id` — the subtree of the shared pool that this server owns. When several replicas + mount the same pool (same `endpoint`), each one must own a distinct subtree, so this is normally + written with a macro, e.g. `server-{replica}`. Missing this key is + a startup error. + +#### Optional parameters {#optional-parameters-content-addressed} + +These are the commonly used settings; see [Configuration](/antalya/cas/configuration) for the full +disk-level and server-level settings surface. + +- `scratch_path` — a real, server-local filesystem directory used to spill the write buffer before it + is committed to the pool (never the object-storage key prefix). Defaults to + `/disks//cas_scratch/`. A relative override is anchored to the server + data path, not the process's current working directory. +- `staging_backend` — `local` (default) or `s3`. Selects where in-flight part data is staged before + being committed into the pool; `local` is byte-for-byte the original write path, `s3` enables + S3-native staging. +- `blob_hash` — `cityhash128` (default), `xxh3-128`, or `sha256`. Selects the pool's blob + content-hash function. The choice is fixed at pool creation; a reopen whose `blob_hash` disagrees + with the pool's recorded algorithm fails closed. See + [choosing `blob_hash`](/antalya/cas/configuration#choosing-blob-hash) for the trade-offs between + the three. +- `blob_hash_allow_new` — `false` by default. Admits a new hash algorithm into an existing pool's set + of recorded algorithms; without it, a `blob_hash` that disagrees with what the pool already recorded + fails closed instead of silently turning the pool mixed-algorithm. +- `gc_enabled` — `true` by default. Enables the background garbage collector for this disk. +- `gc_interval_sec` — `60` by default; must be `>= 1`. Interval between background GC rounds. +- `gc_shards` — `1` by default; must be `>= 1`. Number of blob-hash-prefix shards the GC reducer + splits work across. This is a creation-time-only setting: on reopen the pool's persisted GC state is + authoritative. +- `deduplication_cache_bytes` — `64` MiB by default. Size of the in-memory deduplication lookup cache. +- `deduplication_head_first_min_bytes` — `1` MiB by default. Minimum blob size at which a `HEAD` is sent + before the body, so that an upload of already-present content can be skipped. `0` disables it. +- `gc_snapshot_generations_to_keep` — `3` by default. Number of past GC snapshot generations retained. +- `gcs_max_conditional_put_bytes` — `1` GiB by default. On generation-token backends (Google Cloud + Storage), the body of a conditional write is RAM-buffered up to this size; a larger conditional + write throws `NOT_IMPLEMENTED`. Irrelevant on `ETag`-based backends such as AWS S3. +- `part_folder_cache_bytes` — `64` MiB by default. Size of the part-folder view cache. `0` disables + retention; this is a supported permanent operational configuration, not only a debug aid. +- `part_folder_cache_max_entries` — `10000` by default. Maximum number of entries in the part-folder + view cache. +- `part_folder_cache_max_entry_bytes` — `16` MiB by default. Maximum size of a single cached + part-folder view entry. +- `part_folder_validate` — `always` (default), `never`, or `age `. Controls how often a + `ForceFresh` read re-proves a cached manifest body via a `HEAD` request: `always` re-proves every + time (the original, pre-optimization behavior), `never` trusts the cache without re-proving, and + `age ` re-proves only once the cached entry is older than the given number of seconds. +- `manifest_decode_cache_bytes` — `128` MiB by default. Byte bound for the decoded-manifest cache. + `0` disables decode caching entirely (a diagnostic mode). +- `gc_meta_pool_size` — `16` by default. Bounded thread-pool size for the GC's per-hash freshness-meta + writes (condemn/spare/delete), so a mass `DROP` condemning millions of blobs does not run fully + sequentially. +- `skip_access_check` — `false` by default. Skips the disk's startup access check ("start now, fix + later"), unlike the generic disk-wide startup flag. + ### Using Azure Blob Storage {#azure-blob-storage} `MergeTree` family table engines can store data to [Azure Blob Storage](https://azure.microsoft.com/en-us/services/storage/blobs/) diff --git a/docs/en/operations/system-tables/cas_gc_log.md b/docs/en/operations/system-tables/cas_gc_log.md new file mode 100644 index 000000000000..5fd04b4fb11d --- /dev/null +++ b/docs/en/operations/system-tables/cas_gc_log.md @@ -0,0 +1,156 @@ +--- +description: 'System table containing per-round records of the content-addressed (CAS) MergeTree garbage collector.' +sidebar_label: 'cas_gc_log' +sidebar_position: 30 +slug: /operations/system-tables/cas_gc_log +title: 'system.cas_gc_log' +doc_type: 'reference' +--- + +## Description {#description} + +The `system.cas_gc_log` table contains per-round records of the +content-addressed (CAS) MergeTree garbage collector. For every garbage-collection round it stores a +`Start` row and a `Finish` row (like `system.part_log` stores events per data part), with the counts +of objects marked and deleted, the round duration, the outcome, and a per-round `ProfileEvents` +delta. + +Between them it also stores one `Phase` row per GC phase the round reached, each carrying that +phase's own duration, its `ProfileEvents` delta, and its phase-specific counts. All rows of one round +share a `round_id`. See [Per-phase rows](#per-phase-rows). + +Rounds are emitted both by the background GC scheduler (`trigger = 'Scheduled'`) and by the +synchronous [`SYSTEM CAS GC RUN`](/sql-reference/statements/system#system-cas-gc-run) +command (`trigger = 'Manual'`). + +The table is created only if the `cas_gc_log` server setting is +specified (it is enabled by default in the shipped `config.xml`). + +## Columns {#columns} + +- `hostname` ([LowCardinality(String)](/sql-reference/data-types/lowcardinality)) — Host name of the server executing the round. +- `event_date` ([Date](/sql-reference/data-types/date)) — Event date. +- `event_time` ([DateTime](/sql-reference/data-types/datetime)) — Event time. +- `event_time_microseconds` ([DateTime64(6)](/sql-reference/data-types/datetime64)) — Event time with microseconds precision. +- `event_type` ([Enum8](/sql-reference/data-types/enum)) — `Start` or `Finish` of a GC round, or one `Phase` of it. +- `disk_name` ([LowCardinality(String)](/sql-reference/data-types/lowcardinality)) — The content-addressed disk the round ran on. +- `server_root_id` ([LowCardinality(String)](/sql-reference/data-types/lowcardinality)) — Identifies the mount whose GC scheduler ran this round. Distinguishes concurrent mounters of the same shared pool; join on this column when correlating rounds against [`system.cas_mounts`](/operations/system-tables/cas_mounts). +- `gc_id` ([String](/sql-reference/data-types/string)) — The GC scheduler instance id (which mounter ran the round). +- `trigger` ([Enum8](/sql-reference/data-types/enum)) — `Scheduled` (background tick) or `Manual` (`SYSTEM` command). +- `round` ([UInt64](/sql-reference/data-types/int-uint)) — The GC round number (`0` on a `Start` row). +- `outcome` ([Enum8](/sql-reference/data-types/enum)) — `Unknown` (on a `Start` row), `Success` (led, folded, and completed), `NotALeader` (another replica holds the GC lease), `Deferred` (led but took the skip-unchanged fast path — no fold ran, because no changed shard reached the fold threshold and no graduation was due), or `Error` (the round threw). +- `candidates_marked` ([UInt64](/sql-reference/data-types/int-uint)) — Objects retired (marked) this round. +- `objects_deleted` ([UInt64](/sql-reference/data-types/int-uint)) — Objects physically deleted this round. +- `objects_absent` ([UInt64](/sql-reference/data-types/int-uint)) — Retire candidates found already absent. +- `objects_replaced` ([UInt64](/sql-reference/data-types/int-uint)) — `412`-saves (a resurrection won the race against the delete). +- `objects_spared` ([UInt64](/sql-reference/data-types/int-uint)) — Candidates spared because their in-degree was greater than zero at recheck. +- `manifests_deleted` ([UInt64](/sql-reference/data-types/int-uint)) — Owner-removed manifest bodies physically deleted this round, counted separately from blob deletes. +- `entries_condemned` ([UInt64](/sql-reference/data-types/int-uint)) — Retired entries newly condemned this round (retired-cursor pipeline stage 1). +- `entries_graduated` ([UInt64](/sql-reference/data-types/int-uint)) — Retired entries newly floor-passed and republished `delete_pending` this round (pipeline stage 2; deleted the next round). +- `entries_redeleted` ([UInt64](/sql-reference/data-types/int-uint)) — Pending exact-token blob deletes executed this round (pipeline stage 3). +- `fence_outs` ([UInt64](/sql-reference/data-types/int-uint)) — Expired mounts fenced out by this round's heartbeat floor. +- `anomalies` ([UInt64](/sql-reference/data-types/int-uint)) — Fold clamps surfaced (and survived) this round. A steady non-zero value warrants a look at the round log details. +- `duration_ms` ([UInt64](/sql-reference/data-types/int-uint)) — The round wall-clock duration (on a `Finish` row). +- `error` ([String](/sql-reference/data-types/string)) — The exception text when `outcome = 'Error'`. +- `ProfileEvents` ([Map(LowCardinality(String), UInt64)](/sql-reference/data-types/map)) — On a `Start`/`Finish` row, the per-round `ProfileEvents` delta (the `CAS*` counters and S3/disk events for this round). On a `Phase` row, **that phase's** delta, so `GROUP BY phase` over `ProfileEvents['S3ListObjects']` attributes the round's `LIST` budget to the phase that spent it. +- `round_id` ([String](/sql-reference/data-types/string)) — The correlator for every row of one round attempt: its `Start`, each of its `Phase` rows, and its `Finish`. Minted per attempt, so unlike `round` it exists even for a round that never committed and for a round that never led. Group by this column to reconstruct one round. +- `phase` ([LowCardinality(String)](/sql-reference/data-types/lowcardinality)) — The GC phase this row describes; empty on `Start`/`Finish`. See [Per-phase rows](#per-phase-rows) for the phase list. +- `phase_duration_microseconds` ([UInt64](/sql-reference/data-types/int-uint)) — The wall-clock duration of this phase, in microseconds (`Phase` rows only). Microseconds rather than milliseconds because several phases are routinely sub-millisecond and the point of the row is to see when they are not. +- `phase_metrics` ([Map(LowCardinality(String), UInt64)](/sql-reference/data-types/map)) — Phase-specific semantic counts (`Phase` rows only) that a phase computes for itself and no `ProfileEvents` counter can supply. The verb counts ride the `ProfileEvents` column of the same row. + +## Per-phase rows {#per-phase-rows} + +Besides the `Start` and `Finish` row of each round, the collector emits one `Phase` row per GC phase. +Every row of one round attempt — `Start`, each `Phase`, and `Finish` — shares a `round_id`. A round +that defers, or that never acquires the GC lease, emits only the phases it actually reached; a round +that throws still emits the row of the phase it died in. + +The phases, in execution order: + +| `phase` | What it covers | Dominant I/O | +|---|---|---| +| `lease` | Acquire, renew, or observe the GC lease. The only phase a `NotALeader` round emits. | `gc/state` `GET` + compare-and-swap | +| `pre_fold_ref_drain` | Resolve catalog rows whose terminal fold evidence is already adopted before this invocation publishes or defers. | catalog `GET` + exact compare-and-swap | +| `heartbeat_floor` | Classify every mount slot and fence out the dead ones. | `LIST` of the mount prefix, one `GET` per mount, one `PUT` per fence | +| `defer_decision` | The skip-unchanged decision: graduation check plus the round's one enumeration of the ref prefix. | one full ref-prefix `LIST`, two fold-seal `GET`s | +| `parent_seal_read` | Capture the pre-fold seal's run refs for the hand-off reclaim. | one fold-seal `GET` | +| `fold_ref_group` | Regroup the round's enumeration into per-table listings — what this round will fold. | none | +| `fold_seal_read` | The adopted fold seal, read twice at the same generation and attempt. | two fold-seal `GET`s | +| `fold_ref_intake` | Read and fold every new ref log and the manifest bodies its edges name. | one `GET` per new log, one `GET` per manifest edge | +| `fold_reduce` | The per-shard in-degree merge: condemn, spare, graduate. | prior-run streaming `GET`s, one `HEAD` per zero-transition candidate, run `PUT`s | +| `fold_seal_write` | Publish the new fold seal. | one `PUT` | +| `pending_deletes` | The single content-delete site: exact-token deletes of previously published `delete_pending` entries, plus the outcome logs. | one `DELETE` per entry, one outcome-log `PUT` per shard | +| `meta_pool_wait` | Drain the round's per-hash freshness-meta writes. | none on this thread — see the caveat below | +| `round_commit` | The generation-retention prune and the round's single `gc/state` compare-and-swap. | prune `LIST`s and deletes, one compare-and-swap | +| `handoff_reclaim` | Wholesale-reclaim generations a moved run ref stranded below the retention cursor. | prefix `LIST`s and deletes | +| `manifest_deletes` | Exact-token deletes of owner-removed manifest bodies, after their decrements were adopted. | one `DELETE` per body | +| `namespace_cleanup` | Run one bounded `cas/ns/` page across the stream and state subtrees for the perpetual dead-life janitor. This phase is physical reclamation, not a lifecycle gate. | one namespace-root page `LIST`, catalog cut, exact-token deletes | +| `ref_object_cleanup` | Delete ref logs covered by both the durable fold cursor and a durable snapshot, plus superseded snapshots. | one `HEAD` + one `DELETE` per deletable object | +| `orphan_sweep` | The budgeted, cursor-paced orphan part-manifest backstop. | budgeted `LIST` and deletes | + +Which phase dominates a round: + +```sql +SELECT phase, + count() AS rounds, + quantile(0.5)(phase_duration_microseconds) AS p50_microseconds, + quantile(0.99)(phase_duration_microseconds) AS p99_microseconds, + sum(phase_duration_microseconds) AS total_microseconds +FROM system.cas_gc_log +WHERE event_type = 'Phase' AND disk_name = 'ca' +GROUP BY phase +ORDER BY total_microseconds DESC; +``` + +Which phase spends the `LIST` budget: + +```sql +SELECT phase, sum(ProfileEvents['S3ListObjects']) AS lists +FROM system.cas_gc_log +WHERE event_type = 'Phase' AND disk_name = 'ca' +GROUP BY phase +ORDER BY lists DESC; +``` + +One round, in order — including a round that failed, which is why the correlator is `round_id` and +not `round`: + +```sql +SELECT phase, phase_duration_microseconds, phase_metrics, ProfileEvents['S3ListObjects'] AS lists +FROM system.cas_gc_log +WHERE round_id = '...' AND event_type = 'Phase' +ORDER BY event_time_microseconds; +``` + +Two caveats when reading these rows: + +- Work scheduled onto the GC meta pool runs on other threads, so the `meta_pool_wait` row's + `ProfileEvents` delta is **empty by construction**. Read its `phase_metrics` `jobs_scheduled` / + `jobs_completed` next to its duration instead: they distinguish a deep queue from a slow endpoint. +- Phase durations do not sum to the round's `duration_ms`. The round also performs untimed + bookkeeping between phases, and the `Finish` row's `duration_ms` remains the authority on total + round time. + +## Example {#example} + +```sql +SELECT + event_type, + disk_name, + trigger, + outcome, + candidates_marked, + objects_deleted, + duration_ms +FROM system.cas_gc_log +ORDER BY event_time_microseconds DESC +LIMIT 2 +FORMAT Vertical; +``` + +## See Also {#see-also} + +- [`SYSTEM CAS GC RUN`](/sql-reference/statements/system#system-cas-gc-run) — run one GC round synchronously. +- [`system.cas_mounts`](/operations/system-tables/cas_mounts) — live per-`server_root_id` mount and GC-health state. +- [`system.cas_log`](/operations/system-tables/cas_log) — per-decision event log for the CAS garbage collector and writer. +- [`system.part_log`](/operations/system-tables/part_log) — the analogous per-part event log. diff --git a/docs/en/operations/system-tables/cas_log.md b/docs/en/operations/system-tables/cas_log.md new file mode 100644 index 000000000000..315842d3b9b4 --- /dev/null +++ b/docs/en/operations/system-tables/cas_log.md @@ -0,0 +1,62 @@ +--- +description: 'System table containing a per-decision event log for the content-addressed (CAS) MergeTree writer and garbage collector.' +sidebar_label: 'cas_log' +sidebar_position: 32 +slug: /operations/system-tables/cas_log +title: 'system.cas_log' +doc_type: 'reference' +--- + +## Description {#description} + +The `system.cas_log` table contains a per-decision event log for the content-addressed +(CAS) MergeTree storage engine: blob puts and dedup adoptions, root/ref transitions, in-degree changes, +garbage-collector retire decisions and recheck verdicts, blob deletes, and dangling-access/corruption +findings. It is a much finer-grained, per-event complement to +[`system.cas_gc_log`](/operations/system-tables/cas_gc_log), +which only records one `Start`/`Finish` row per GC round. + +## Columns {#columns} + +- `hostname` ([LowCardinality(String)](/sql-reference/data-types/lowcardinality)) — Host name of the server that emitted the event. +- `event_date` ([Date](/sql-reference/data-types/date)) — Event date. +- `event_time` ([DateTime](/sql-reference/data-types/datetime)) — Event time. +- `event_time_microseconds` ([DateTime64(6)](/sql-reference/data-types/datetime64)) — Event time with microseconds precision. +- `event_type` ([LowCardinality(String)](/sql-reference/data-types/lowcardinality)) — The CAS decision/event, e.g. `blob_put`, `blob_reuse_adopt`, `root_remove`, `indegree_zero`, `gc_retire_decision`, `gc_recheck_verdict`, `blob_delete`, `dangling_access`, `corrupt_dangle`. +- `disk_name` ([LowCardinality(String)](/sql-reference/data-types/lowcardinality)) — The content-addressed disk / pool the event belongs to. +- `namespace` ([String](/sql-reference/data-types/string)) — `roots/` (server/table); empty if not applicable. +- `ref_name` ([String](/sql-reference/data-types/string)) — Part name / ref the event concerns; empty if not applicable. +- `object_kind` ([LowCardinality(String)](/sql-reference/data-types/lowcardinality)) — One of `none`, `blob`, `manifest`, `root`, `snapshot`. +- `object_hash` ([String](/sql-reference/data-types/string)) — Content hash (lowercase hex) of the object; empty if not applicable. +- `token` ([String](/sql-reference/data-types/string)) — Incarnation token (`ETag`) involved; empty if not applicable. +- `round` ([UInt64](/sql-reference/data-types/int-uint)) — GC round (`0` if not applicable). +- `generation` ([UInt64](/sql-reference/data-types/int-uint)) — GC snapshot generation (`0` if not applicable). +- `at_version` ([UInt64](/sql-reference/data-types/int-uint)) — Manifest `shard_version` of the driving journal record (`0` if not applicable). +- `outcome` ([LowCardinality(String)](/sql-reference/data-types/lowcardinality)) — Decision outcome, e.g. `ok`, `adopt`, `resurrect`, `deleted`, `replaced`, `spared`, `absent`, `zeroed`, `skipped`. +- `reason` ([LowCardinality(String)](/sql-reference/data-types/lowcardinality)) — Human-readable rationale for the decision. Templated across rows, so it is `LowCardinality`. +- `thread_id` ([UInt64](/sql-reference/data-types/int-uint)) — OS thread that emitted the event. +- `query_id` ([String](/sql-reference/data-types/string)) — Query id for correlation with [`system.query_log`](/operations/system-tables/query_log); empty if not applicable. +- `detail` ([Map(LowCardinality(String), String)](/sql-reference/data-types/map)) — Structured event-specific facts, e.g. `condemn_round`, `superseded_token`, `code`, `site`. + +## Example {#example} + +```sql +SELECT + event_time_microseconds, + event_type, + disk_name, + ref_name, + object_kind, + outcome, + reason +FROM system.cas_log +ORDER BY event_time_microseconds DESC +LIMIT 10 +FORMAT Vertical; +``` + +## See Also {#see-also} + +- [`system.cas_gc_log`](/operations/system-tables/cas_gc_log) — per-round GC event log. +- [`system.cas_mounts`](/operations/system-tables/cas_mounts) — live per-`server_root_id` mount and GC-health state. +- [`system.query_log`](/operations/system-tables/query_log) — correlate via `query_id`. diff --git a/docs/en/operations/system-tables/cas_mounts.md b/docs/en/operations/system-tables/cas_mounts.md new file mode 100644 index 000000000000..5a9465db04af --- /dev/null +++ b/docs/en/operations/system-tables/cas_mounts.md @@ -0,0 +1,81 @@ +--- +description: 'System table containing the live mount and GC-health state of every server mounted onto a content-addressed (CAS) disk pool.' +sidebar_label: 'cas_mounts' +sidebar_position: 31 +slug: /operations/system-tables/cas_mounts +title: 'system.cas_mounts' +doc_type: 'reference' +--- + +## Description {#description} + +The `system.cas_mounts` table contains one row per mount slot discovered on every +content-addressed (CAS) disk configured on the node. A pool may be shared by several servers (or +several `server_root_id` mounts on the same server), and this table lists every mount visible in +the pool's backend at query time, not only the querying server's own mount — it exists for +incident-time diagnosis of leases, epochs, and GC leadership across a shared pool. + +The table is read directly from the CAS disk's backend on every query (there is no persisted log +behind it); a transient backend error on one disk is skipped and does not blind the rest of the +rows. + +## Columns {#columns} + +- `disk` ([String](/sql-reference/data-types/string)) — Name of the content-addressed disk. +- `server_root_id` ([String](/sql-reference/data-types/string)) — Server root id owning the mount slot. +- `server_uuid` ([UUID](/sql-reference/data-types/uuid)) — UUID of the server incarnation holding the lease. +- `hostname` ([String](/sql-reference/data-types/string)) — Hostname recorded in the lease body. +- `process_id` ([UInt64](/sql-reference/data-types/int-uint)) — Process id recorded in the lease body. +- `writer_epoch` ([UInt64](/sql-reference/data-types/int-uint)) — Fenced writer epoch of the incarnation. +- `renewal_sequence` ([UInt64](/sql-reference/data-types/int-uint)) — Lease renewal sequence number. +- `started_at` ([DateTime64(3)](/sql-reference/data-types/datetime64)) — Time when the lease started. +- `expires_at` ([DateTime64(3)](/sql-reference/data-types/datetime64)) — Time when the lease expires. +- `min_active_build_sequence` ([UInt64](/sql-reference/data-types/int-uint)) — Oldest in-flight build sequence (`UINT64_MAX` means the mount said farewell). +- `gc_fenced` ([UInt8](/sql-reference/data-types/int-uint)) — `1` if GC fenced this slot out (terminal). +- `state` ([String](/sql-reference/data-types/string)) — One of `live`, `expired`, `terminated`, `fenced`, `corrupt`. +- `is_leader` ([Nullable(UInt8)](/sql-reference/data-types/nullable)) — `1` if this server's GC scheduler currently holds this disk's leadership lease. +- `pending_reclaim` ([Nullable(Int64)](/sql-reference/data-types/nullable)) — Cumulative two-phase deletion backlog observed by this process's GC on this disk (condemned entries minus executed exact-token deletes). +- `last_success_age_seconds` ([Nullable(UInt64)](/sql-reference/data-types/nullable)) — Seconds since this disk's GC last led a round (`0` if it has never led or GC is not running here). +- `wedged_namespace_count` ([Nullable(UInt64)](/sql-reference/data-types/nullable)) — Ref-append lanes currently wedged on this disk (an uncertain `PUT` exhausted its retry budget). +- `lifecycle` ([String](/sql-reference/data-types/string)) — This server's content-addressed pool lifecycle for the disk (a non-gated snapshot, always populated so a not-live disk stays visible): one of `live`, `not_live`, `identity_lost`, `vanished`, `constructing` (never started), or `shutdown` (torn down). +- `lifecycle_reason` ([String](/sql-reference/data-types/string)) — The enum-clean sub-state word for a `vanished` disk: `replaced` or `forgotten`. Empty for every other lifecycle, so `lifecycle || '(' || lifecycle_reason || ')'` reads e.g. `vanished(forgotten)`. +- `lifecycle_detail` ([String](/sql-reference/data-types/string)) — The full typed reason text naming the actual cause when not live: the vanish diagnosis (a data root replaced by a foreign pool, or decommissioned by `SYSTEM CAS FORGET` at a given time) or the identity-loss message. Empty when live. +- `lifecycle_since` ([Nullable(DateTime)](/sql-reference/data-types/nullable)) — When this server entered the current non-live lifecycle state. `NULL` when live, or when the state has no backing pool to date from. + +`lifecycle`/`lifecycle_reason`/`lifecycle_detail`/`lifecycle_since` are the SQL surface for +diagnosing an identity-lost or forgotten disk without reading server logs — see the +`IdentityLost`/`VanishedReplaced`/`VanishedForgotten` states on the +[mount-slot behavioral model](/antalya/cas/architecture/mounts-and-leases#mount-state-machines) for +what each lifecycle value means, and [`SYSTEM CAS FORGET`](/antalya/cas/operations/debugging#sql-forget) +for the command that produces `vanished(forgotten)`. + +:::note Local-only GC-health columns +`is_leader`, `pending_reclaim`, `last_success_age_seconds`, and `wedged_namespace_count` are process-local +facts about *this* server's own GC scheduler. They are populated **only** on the row whose `server_root_id` matches +this server's own mount, and are `NULL` on every row describing another server's mount — stamping a local +health fact onto a peer's row would misread as "the peer is the GC leader" during an incident. To see the +peer's own view of these columns, query `system.cas_mounts` on that server. +::: + +## Example {#example} + +```sql +SELECT + disk, + server_root_id, + state, + writer_epoch, + is_leader, + pending_reclaim, + last_success_age_seconds +FROM system.cas_mounts +ORDER BY disk, server_root_id +FORMAT Vertical; +``` + +## See Also {#see-also} + +- [`system.cas_gc_log`](/operations/system-tables/cas_gc_log) — per-round GC event log. +- [`system.cas_log`](/operations/system-tables/cas_log) — per-decision event log for the CAS garbage collector and writer. +- [`SYSTEM CAS GC RUN`](/sql-reference/statements/system#system-cas-gc-run) — run one GC round synchronously. +- [`SYSTEM CAS DROP POOL MEMBER`](/sql-reference/statements/system#system-cas-drop-pool-member) — permanently decommission a dead pool member's `server_root_id`. diff --git a/docs/en/sql-reference/statements/system.md b/docs/en/sql-reference/statements/system.md index 69b2b59cb0ae..ed3a2b97cf87 100644 --- a/docs/en/sql-reference/statements/system.md +++ b/docs/en/sql-reference/statements/system.md @@ -457,6 +457,151 @@ Wait until all asynchronously loading data parts of a table (outdated data parts SYSTEM WAIT LOADING PARTS [ON CLUSTER cluster_name] [db.]merge_tree_family_table_name ``` +### SYSTEM CAS GC RUN {#system-cas-gc-run} + +Runs one garbage-collection round of the content-addressed (CAS) MergeTree garbage collector synchronously and node-local: it reclaims content-addressed objects that are no longer referenced by any part. This is the on-demand counterpart of the background GC scheduler; it is useful for tests and diagnostics. + +```sql +SYSTEM CAS GC RUN [ON CLUSTER cluster_name] [disk_name] +``` + +When `disk_name` is given, the round runs on that content-addressed disk only; targeting a non-content-addressed disk raises an exception. When `disk_name` is omitted, one round runs on every content-addressed disk configured on the node; if none are configured, the command raises an exception. + +Each round is recorded in [`system.cas_gc_log`](/operations/system-tables/cas_gc_log) as a `Start` and a `Finish` row (with `trigger = 'Manual'`). + +The command returns one row per disk it ran on (multiple rows when `disk_name` is omitted), with columns `disk`, `acquired_lease`, `deferred`, `round`, `candidates_marked`, `objects_deleted`, `objects_absent`, `objects_replaced`, `objects_spared`, `manifests_deleted`, `entries_condemned`, `entries_graduated`, `entries_redeleted`, `fence_outs`, `anomalies`, `pending_candidates`, `pending_condemned`, and `pending_retired`, describing the outcome of that round. The `pending_*` columns are the retire pipeline's remaining backlog sizes read from the `gc/state` this round's own commit just published (not this round's own delta, unlike the columns before them) — `0` on a non-authoritative row (`acquired_lease = 0` or `deferred = 1`), same as every other counter. + +A manual run always executes, regardless of [`SYSTEM CAS GC STOP`](#system-cas-gc-stop-start): `STOP` pauses only the background scheduler on that disk. + +### SYSTEM CAS GC REBUILD {#system-cas-gc-rebuild} + +Disaster-recovery command for the content-addressed (CAS) MergeTree garbage collector. It rebuilds a +CAS disk's `gc/state` baseline from scratch, by re-discovering the whole ref universe and re-folding +manifest edges into a fresh generation. It writes only the GC plane (`gc/state` and the `gc/gen/*` +artifacts) and never touches ref shards, manifests, or blobs, and it never deletes anything itself — +but the rebuilt baseline drives every subsequent GC round's retire decisions, so this is a +**destructive disaster-recovery tool**, not something to run routinely: a rebuild performed against +a state that was not actually corrupted discards live bookkeeping, and an incorrect rebuild can make +a later round delete objects that are still referenced. + +```sql +SYSTEM CAS GC REBUILD [FORCE] [ON CLUSTER cluster_name] disk_name +``` + +Unlike `SYSTEM CAS GC RUN`, `disk_name` is **required**: the destructive +baseline rebuild must never fan out across every content-addressed disk on the node from a bare +command; targeting a non-content-addressed disk raises an exception. + +By default the command refuses to run when the disk's existing `gc/state` and every artifact it +references decode successfully and are present — a rebuild would needlessly discard healthy live +bookkeeping. Add `FORCE` to rebuild deliberately even though the existing state looks healthy. The +command also refuses (regardless of `FORCE`) when another GC leader currently holds the disk's +lease. In both refusal cases it raises an exception instead of returning a row. + +On success it returns one row with columns `disk`, `performed`, `round`, `generation`, `namespaces`, +`shards`, `committed_refs`, `live_precommits`, `unowned_alive_manifests`, `edges`, +`clamped_shards`, `virgin_by_enumeration`, and `adopted_seal_generation`, describing the freshly +rebuilt baseline. `virgin_by_enumeration = 1` means the rebuild found no fold seal at all and +carried no durable hold forward, concluding from enumeration alone that the pool never sealed a +baseline — on a pool that has ever completed a GC round this means the object listing lied. +`adopted_seal_generation` names which generation's fold seal the rebuild carried holds from; `0` +when it carried none. + +### SYSTEM CAS GC STOP / SYSTEM CAS GC START {#system-cas-gc-stop-start} + +Pause or resume the background GC scheduler on one content-addressed disk, without affecting reads +or writes on that disk. This is granular operator control of GC alone — for example to pause +reclamation during an incident — not a lifecycle transition; the disk stays fully usable throughout. + +```sql +SYSTEM CAS GC STOP [ON CLUSTER cluster_name] disk_name +SYSTEM CAS GC START [ON CLUSTER cluster_name] disk_name +``` + +`disk_name` is **required** for both — unlike `SYSTEM CAS GC RUN`, there is no fan-out form, since +each command targets exactly one disk's scheduler. + +`GC STOP` stops in place: the scheduler object is retained, so a later `GC START` resumes the *same* +instance, preserving its `gc_id` and lease-observation history. It is idempotent, and works even on +a disk that is not currently live (stopping GC on a sick disk is a legitimate operation). It does +not stop a manual [`SYSTEM CAS GC RUN`](#system-cas-gc-run) on the same disk. + +`GC START` re-enters that same scheduler instance rather than creating a new one; leadership is +**not** automatically restored — the scheduler re-acquires the durable `gc/state` lease through the +next round's normal acquisition, the same as any other contender. It is idempotent (a no-op on an +already-running scheduler), and refuses with a typed error on a decommissioned or uncertain pool, +since restarting GC there would only spin failing rounds. + +Neither command returns a result set. + +### SYSTEM CAS FSCK {#system-cas-fsck} + +Independently verifies content-addressed pool reachability against a **running, mounted** disk — the +scan re-validates every finding against a fresh authoritative read, so unlike the offline +`clickhouse-disks cas-fsck` tool it needs no quiesce and no read-only mount. + +```sql +SYSTEM CAS FSCK [ON CLUSTER cluster_name] disk_name +``` + +`disk_name` is **required**. The command returns one row with columns `disk`, `reachable`, +`dangling`, `unreachable`, `pending_gc`, `awaiting_gc`, `unaccounted`, `stale_edge`, +`corrupted_runs`, `chain_broken`, `unchecked`, `lifeless_keys`, `namespace_janitor_pending`, +`namespace_janitor_pending_bytes`, `namespace_janitor_pending_lives`, `ref_records_walked`, +`physical_bytes`, `referenced_logical_bytes`, `distinct_blobs`, and `total_blob_refs`. `dangling` is +the one column that means data loss; `unreachable`, `pending_gc`, and `awaiting_gc` are objects +still moving through the normal condemn/graduate/delete pipeline, not a problem on their own. This +is a summary-only scan; per-object detail requires the offline `clickhouse-disks cas-fsck --detail`. + +### SYSTEM CAS FORGET {#system-cas-forget} + +Node-local operator assertion that a content-addressed disk is permanently gone — the "fire marshal" +verb for a stuck disk (a transient or identity-lost pool, or an operator-asserted decommission). +Unlike the other `SYSTEM CAS` commands, it deliberately works on a disk that is **not** live, since +that is its whole purpose. + +```sql +SYSTEM CAS FORGET [ON CLUSTER cluster_name] disk_name +``` + +`disk_name` is **required**. It is an assertion, not a proof of erasure: the disk stays registered +and answers further store-class access with a typed error, and a server restart re-registers the +name. Returns no result set. This is different from +[`SYSTEM CAS DROP POOL MEMBER`](#system-cas-drop-pool-member), which permanently retires one pool +*member's* identity across the whole shared pool — `FORGET` only affects this node's own local view +of one disk. + +### SYSTEM CAS DROP POOL MEMBER {#system-cas-drop-pool-member} + +Permanently decommissions a dead member (`server_root_id`) of a content-addressed disk +pool. It claims the member's mount slot as an administrative writer — fencing that `server_root_id` from ever +writing again — then drops every table namespace the member owned, drains eligible manifest debris, +staging objects, and mountpoint objects belonging to it, and retires the mount slot once all drains +are confirmed. This is a **destructive, irreversible** operation: only run it once the `server_root_id` is +confirmed permanently dead, since it fences the member out even if it later comes back online, and +it deletes namespace and drain state that cannot be recovered. + +It is a writer operation, not GC: it emits ordinary ref-edge deltas rather than inventing GC +transitions, and it does not synchronously reclaim shared blob content — removing the ref edges only +makes the now-unreferenced blobs eligible for an ordinary GC round to reclaim later. + +```sql +SYSTEM CAS DROP POOL MEMBER 'server_root_id' FROM DISK 'disk_name' [ON CLUSTER cluster_name] +``` + +Both `server_root_id` and `disk_name` are required string literals (a `server_root_id` is an opaque server-root path +that may contain `/`, not a plain identifier, so it cannot be written unquoted). + +The operation is resumable: a rerun skips namespaces already marked `Removed` and reports them +separately from namespaces newly removed by this invocation. Per-object drain failures are recorded +as warnings and leave the slot in a terminated-but-not-fully-drained state that a later invocation +can resume from, rather than raising an exception. + +The command returns one row with columns `server_root_id`, `namespaces_removed`, `namespaces_already_removed`, +`committed_refs_removed`, `precommits_removed`, `manifest_debris_removed`, `staging_objects_removed`, +`mountpoint_objects_removed`, `slot_removed`, and `warnings`. A non-empty `warnings` means some +drain was not confirmed and the mount slot was left in place as a resume anchor. + ## Managing ReplicatedMergeTree Tables {#managing-replicatedmergetree-tables} ClickHouse can manage background replication related processes in [ReplicatedMergeTree](/engines/table-engines/mergetree-family/replication) tables. diff --git a/programs/disks/CMakeLists.txt b/programs/disks/CMakeLists.txt index 53971ee492e6..6a9ae9c33e65 100644 --- a/programs/disks/CMakeLists.txt +++ b/programs/disks/CMakeLists.txt @@ -18,6 +18,11 @@ set (CLICKHOUSE_DISKS_SOURCES CommandSed.cpp CommandHelp.cpp CommandTouch.cpp + CommandFsck.cpp + CommandCaGcDryRun.cpp + CommandCaGcRebuild.cpp + CommandCaInspect.cpp + CommandCaDropMember.cpp CommandGetCurrentDiskAndPath.cpp Utils.cpp) diff --git a/programs/disks/CommandCaDropMember.cpp b/programs/disks/CommandCaDropMember.cpp new file mode 100644 index 000000000000..f95693c2a02f --- /dev/null +++ b/programs/disks/CommandCaDropMember.cpp @@ -0,0 +1,75 @@ +#include +#include +#include +#include +#include + +#include + +namespace DB +{ + +namespace ErrorCodes +{ + extern const int BAD_ARGUMENTS; +} + +/// Operator-driven decommission of a DEAD pool member's namespaces, debris, staging, roots objects +/// and mount slot (`Cas::decommissionPoolMember`, design 2026-07-13-cas-pool-member-decommission +/// §core). Refuses a live member internally; this command only opens the CA disk read-only and +/// forwards the pool handle -- the admin claim itself happens inside `decommissionPoolMember`. +class CommandCaDropMember final : public ICommand +{ +public: + CommandCaDropMember() : ICommand("CommandCaDropMember") + { + command_name = "cas-drop-member"; + description = "Decommission a DEAD pool member: erase its namespaces, debris, staging, roots " + "objects and mount slot. Refuses a live member. Open the CA disk read-only " + "(the admin claim is made internally)."; + options_description.add_options()("member", po::value(), "server_root_id of the dead member"); + positional_options_description.add("member", 1); + } + + void executeImpl(const CommandLineOptions & options, DisksClient & client) override + { + const String srid = getValueFromCommandLineOptionsThrow(options, "member"); + auto disk = client.getCurrentDiskWithPath().getDisk(); + + auto * dos = dynamic_cast(disk.get()); + if (!dos) + throw Exception(ErrorCodes::BAD_ARGUMENTS, "cas-drop-member: '{}' is not an object-storage disk", disk->getName()); + + auto * ca = dynamic_cast(dos->getMetadataStorage().get()); + if (!ca) + throw Exception(ErrorCodes::BAD_ARGUMENTS, "cas-drop-member: disk '{}' is not content-addressed", disk->getName()); + + if (!ca->isReadOnly()) + throw Exception(ErrorCodes::BAD_ARGUMENTS, + "cas-drop-member: open the CA disk read-only (a writable open would claim this tool's " + "own server_root_id; the decommission claim happens internally)"); + + const auto host_store = ca->store(); + const auto report = Cas::decommissionPoolMember( + host_store->poolBackendPtr(), host_store->poolConfig(), srid); + + std::cout << "server_root_id=" << report.srid << "\n" + << "namespaces_removed=" << report.namespaces_removed << "\n" + << "namespaces_already_removed=" << report.namespaces_already_removed << "\n" + << "committed_refs_removed=" << report.committed_refs_removed << "\n" + << "precommits_removed=" << report.precommits_removed << "\n" + << "manifest_debris_removed=" << report.manifest_debris_removed << "\n" + << "staging_objects_removed=" << report.staging_objects_removed << "\n" + << "mountpoint_objects_removed=" << report.mountpoint_objects_removed << "\n" + << "slot_removed=" << (report.slot_removed ? "true" : "false") << "\n"; + for (const auto & w : report.warnings) + std::cout << "warning=" << w << "\n"; + } +}; + +CommandPtr makeCommandCaDropMember() +{ + return std::make_shared(); +} + +} diff --git a/programs/disks/CommandCaGcDryRun.cpp b/programs/disks/CommandCaGcDryRun.cpp new file mode 100644 index 000000000000..2e4ba6a7849e --- /dev/null +++ b/programs/disks/CommandCaGcDryRun.cpp @@ -0,0 +1,56 @@ +#include +#include +#include +#include +#include + +#include + +namespace DB +{ + +namespace ErrorCodes +{ + extern const int BAD_ARGUMENTS; +} + +class CommandCaGcDryRun final : public ICommand +{ +public: + CommandCaGcDryRun() : ICommand("CommandCaGcDryRun") + { + command_name = "cas-gc-dryrun"; + description = "Preview the next GC round's deletes for a content-addressed pool (read-only, no deletes)."; + } + + void executeImpl(const CommandLineOptions &, DisksClient & client) override + { + auto disk = client.getCurrentDiskWithPath().getDisk(); + + auto * dos = dynamic_cast(disk.get()); + if (!dos) + throw Exception(ErrorCodes::BAD_ARGUMENTS, "cas-gc-dryrun: '{}' is not an object-storage disk", disk->getName()); + + auto * ca = dynamic_cast(dos->getMetadataStorage().get()); + if (!ca) + throw Exception(ErrorCodes::BAD_ARGUMENTS, "cas-gc-dryrun: disk '{}' is not content-addressed", disk->getName()); + + if (!ca->isReadOnly()) + throw Exception(ErrorCodes::BAD_ARGUMENTS, "cas-gc-dryrun: open the CA disk read-only"); + + /// A non-leader, read-only Gc handle: previewDeletes never acquires the lease or writes. + Cas::Gc gc(ca->store(), UInt128(1)); + const auto preview = gc.previewDeletes(); + + std::cout << "preview_deletes=" << preview.size() << "\n"; + for (const auto & p : preview) + std::cout << p.reason << "\t" << p.key << "\t" << p.size << "\n"; + } +}; + +CommandPtr makeCommandCaGcDryRun() +{ + return std::make_shared(); +} + +} diff --git a/programs/disks/CommandCaGcRebuild.cpp b/programs/disks/CommandCaGcRebuild.cpp new file mode 100644 index 000000000000..5e488296ac4e --- /dev/null +++ b/programs/disks/CommandCaGcRebuild.cpp @@ -0,0 +1,85 @@ +#include +#include +#include +#include +#include +#include + +#include + +namespace DB +{ + +namespace ErrorCodes +{ + extern const int BAD_ARGUMENTS; +} + +/// The gc/state disaster-recovery command (spec 2026-07-03): recomputes the in-degree baseline from +/// raw owner state and CASes a fresh gc/state when the guard has refused every regular round (a lost +/// gc/state over trimmed journal history — see docs/superpowers/cas/04-gc-protocol.md#gc-rebuild). +/// +/// REQUIRES a read-only-opened disk, same as fsck/cas-gc-dryrun: this tool must never claim the live +/// server's mount (a second live mounter racing the real GC's lease/writes is exactly the split-brain +/// class the protocol is designed to prevent). Unlike fsck/cas-gc-dryrun, rebuildBaseline DOES write +/// (a single gc/state CAS) — that write is a deliberate, explicit, operator-invoked exception to +/// "read-only means no writes", gated on the SAME `isReadOnly()` check so it can only run against a +/// disk configured with true (i.e. never against the disk a live server has +/// mounted for read-write traffic). +class CommandCaGcRebuild final : public ICommand +{ +public: + CommandCaGcRebuild() : ICommand("CommandCaGcRebuild") + { + command_name = "cas-gc-rebuild"; + description = "Disaster recovery: rebuild a content-addressed pool's gc/state baseline from raw owner " + "state after the GC guard has refused every round (see CORRUPTED_DATA in the gc log). " + "Requires a read-only-opened disk; never run against a disk a live server has mounted."; + options_description.add_options()("force", "bypass the \"healthy state\" refusal (rebuild even though gc/state and every referenced artifact look fine)"); + } + + void executeImpl(const CommandLineOptions & options, DisksClient & client) override + { + const bool force = options.contains("force"); + auto disk = client.getCurrentDiskWithPath().getDisk(); + + auto * dos = dynamic_cast(disk.get()); + if (!dos) + throw Exception(ErrorCodes::BAD_ARGUMENTS, "cas-gc-rebuild: '{}' is not an object-storage disk", disk->getName()); + + auto * ca = dynamic_cast(dos->getMetadataStorage().get()); + if (!ca) + throw Exception(ErrorCodes::BAD_ARGUMENTS, "cas-gc-rebuild: disk '{}' is not content-addressed", disk->getName()); + + if (!ca->isReadOnly()) + throw Exception( + ErrorCodes::BAD_ARGUMENTS, + "cas-gc-rebuild: open the CA disk read-only (true) — this tool must never " + "claim the live server's mount"); + + /// gc_id uniqueness across instances is the Gc caller obligation (a random u128 per invocation); + /// this is a one-shot command, so a fresh mint per run is exactly right (no stable-instance + /// requirement here — rebuildBaseline does its own lease acquire/steal check internally). + const UInt128 gc_id = (static_cast(thread_local_rng()) << 64) | thread_local_rng(); + Cas::Gc gc(ca->store(), gc_id); + const Cas::RebuildReport rep = gc.rebuildBaseline(force); + + std::cout << "performed=" << (rep.performed ? 1 : 0) << " round=" << rep.round << " generation=" << rep.generation + << " namespaces=" << rep.namespaces << " shards=" << rep.shards << " committed_refs=" << rep.committed_refs + << " live_precommits=" << rep.live_precommits << " unowned_alive_manifests=" << rep.unowned_alive_manifests + << " edges=" << rep.edges << " clamped_shards=" << rep.clamped_shards << "\n"; + + if (!rep.performed) + { + std::cout << "refusal=" << rep.refusal << "\n"; + throw Exception(ErrorCodes::BAD_ARGUMENTS, "cas-gc-rebuild: refused: {}", rep.refusal); + } + } +}; + +CommandPtr makeCommandCaGcRebuild() +{ + return std::make_shared(); +} + +} diff --git a/programs/disks/CommandCaInspect.cpp b/programs/disks/CommandCaInspect.cpp new file mode 100644 index 000000000000..b4a3a24dbb89 --- /dev/null +++ b/programs/disks/CommandCaInspect.cpp @@ -0,0 +1,77 @@ +#include +#include +#include +#include +#include +#include +#include + +#include + +namespace DB +{ + +namespace ErrorCodes +{ + extern const int BAD_ARGUMENTS; +} + +/// Read-only "decode any object" command: takes the RAW object-storage key (e.g. as printed by +/// `cas-gc-dryrun` or `fsck`) rather than a ClickHouse-relative path, GETs its bytes straight from +/// the pool's backend, and dispatches to `Cas::caInspectToJson` (the same free function the unit +/// tests exercise directly against encoder output). Never writes; safe to run against a live pool. +class CommandCaInspect final : public ICommand +{ +public: + CommandCaInspect() : ICommand("CommandCaInspect") + { + command_name = "cas-inspect"; + description = "Decode a content-addressed pool object (by its raw object-storage key) to JSON (read-only)."; + options_description.add_options()("key", po::value(), "the raw object-storage key to decode (mandatory, positional)"); + positional_options_description.add("key", 1); + } + + void executeImpl(const CommandLineOptions & options, DisksClient & client) override + { + const String key = getValueFromCommandLineOptionsThrow(options, "key"); + + auto disk = client.getCurrentDiskWithPath().getDisk(); + + auto * dos = dynamic_cast(disk.get()); + if (!dos) + throw Exception(ErrorCodes::BAD_ARGUMENTS, "cas-inspect: '{}' is not an object-storage disk", disk->getName()); + + auto * ca = dynamic_cast(dos->getMetadataStorage().get()); + if (!ca) + throw Exception(ErrorCodes::BAD_ARGUMENTS, "cas-inspect: disk '{}' is not content-addressed", disk->getName()); + + if (!ca->isReadOnly()) + throw Exception(ErrorCodes::BAD_ARGUMENTS, "cas-inspect: open the CA disk read-only"); + + const auto got = ca->store()->backend().get(key); + if (!got) + throw Exception(ErrorCodes::BAD_ARGUMENTS, "cas-inspect: key '{}' does not exist", key); + + const Cas::Layout & layout = ca->store()->layout(); + std::optional resolved_life; + std::optional life_id; + if (const auto parsed = layout.parseRefObjectKey(key)) + life_id = parsed->life_id; + else if (const auto parsed_ckpt = layout.parseRefCkptKey(key)) + life_id = *parsed_ckpt; + if (life_id) + { + const Cas::CasRefCatalog::Snapshot cut = Cas::CasRefCatalog::read(ca->store()->backend(), layout); + resolved_life = cut.life_index.resolve(*life_id); + } + + std::cout << Cas::caInspectToJson(layout, key, got->bytes, resolved_life) << "\n"; + } +}; + +CommandPtr makeCommandCaInspect() +{ + return std::make_shared(); +} + +} diff --git a/programs/disks/CommandFsck.cpp b/programs/disks/CommandFsck.cpp new file mode 100644 index 000000000000..5727f7d69c11 --- /dev/null +++ b/programs/disks/CommandFsck.cpp @@ -0,0 +1,188 @@ +#include +#include +#include +#include +#include + +#include +#include +#include + +namespace DB +{ + +namespace ErrorCodes +{ + extern const int BAD_ARGUMENTS; +} + +class CommandFsck final : public ICommand +{ +public: + CommandFsck() : ICommand("CommandFsck") + { + command_name = "cas-fsck"; + description = "Independently verify content-addressed pool reachability (read-only). " + "Exits nonzero if any reachable object is missing (dangling)."; + options_description.add_options()("detail", "list per-object rows (class, key, size, reachable_from)")( + "timeout", po::value(), "abort the scan after N seconds with a clear error instead of hanging (default 600; 0 = unbounded)")( + "namespace", po::value(), "scope the scan to namespaces with this prefix (skips the pool-wide " + "physical/pipeline classification; still reports the scoped namespaces' " + "dangling refs and orphan-manifest debris as unreachable)")( + "partial", "on --timeout, print the counts accumulated so far flagged partial=1 instead of aborting empty-handed"); + } + + void executeImpl(const CommandLineOptions & options, DisksClient & client) override + { + const bool detail = options.contains("detail"); + const UInt64 timeout_sec = getValueFromCommandLineOptionsWithDefault(options, "timeout", 600); + const String namespace_prefix = options.contains("namespace") ? options["namespace"].as() : ""; + const bool partial = options.contains("partial"); + auto disk = client.getCurrentDiskWithPath().getDisk(); + + auto * dos = dynamic_cast(disk.get()); + if (!dos) + throw Exception(ErrorCodes::BAD_ARGUMENTS, "cas-fsck: '{}' is not an object-storage disk", disk->getName()); + + auto * ca = dynamic_cast(dos->getMetadataStorage().get()); + if (!ca) + throw Exception(ErrorCodes::BAD_ARGUMENTS, "cas-fsck: disk '{}' is not content-addressed", disk->getName()); + + if (!ca->isReadOnly()) + throw Exception( + ErrorCodes::BAD_ARGUMENTS, + "cas-fsck: open the CA disk read-only (true) so inspection never probes/schedules a live pool"); + + /// Progress to stderr so a long scan is visibly working (the reachable=… summary stays on + /// stdout, machine-parseable). The deadline bounds a slow-but-progressing scan with a clear + /// error; for a single LIST page stuck in S3-client retries, lower the disk's S3 retry budget. + Cas::FsckProgress on_progress = [](std::string_view phase, uint64_t objects, uint64_t pages) + { + std::cerr << "cas-fsck: " << phase << " — " << objects << " objects, " << pages << " pages\n"; + }; + std::optional deadline; + if (timeout_sec > 0) + deadline = std::chrono::steady_clock::now() + std::chrono::seconds(timeout_sec); + + const Cas::FsckReport report = Cas::runFsck(*ca->store(), detail, on_progress, deadline, partial, namespace_prefix); + + /// Built by `Cas::formatFsckSummary` rather than here, so the line is reachable from a unit test. + /// It was assembled inline until 2026-07-26, and in that time `corrupted_runs` was added to the + /// report and to `clean()` without ever being rendered — a hard finding no run could report. + std::cout << Cas::formatFsckSummary(report) << "\n"; + + /// De-alarm the pipeline classes for humans: on an active pool a nonzero pending/awaiting + /// count is the ack-floor deletion pipeline working as designed, not a leak. `stale_edge` is + /// deliberately NOT part of this sentence: those blobs look exactly like an `AwaitingGc` + /// backlog but will never drain, and being swept into "expected, no action needed" is what + /// hid them. + if (report.pending_gc + report.awaiting_gc > 0) + std::cout << "note: " << report.pending_gc + report.awaiting_gc + << " unreferenced object(s) are inside the normal GC deletion pipeline " + "(condemn -> graduate -> exact-token delete takes ~2-3 rounds) — expected, no action needed\n"; + if (report.stale_edge > 0) + std::cout << "note: " << report.stale_edge + << " unreferenced object(s) carry ONLY source edges naming manifests that no longer " + "exist: their in-degree can never reach zero, so the incremental GC will never " + "reclaim them — NOT expected, investigate (a rebuild of the in-degree state is the " + "only way to clear them)\n"; + /// `unchecked` is not a finding and does not exit nonzero — it says the audit could not PROVE + /// those namespaces either way, which is a statement about coverage. Saying so out loud is the + /// whole point: a silent verdict of "no complaints" would read as a clean bill of health. + if (report.unchecked > 0) + std::cout << "note: " << report.unchecked + << " namespace(s) could NOT be proved either way (an unprovable epoch crossing, an " + "unreadable record, or a namespace the scan could not examine) — this run says " + "nothing about them; the per-namespace reason is listed as an `unchecked` row " + "under --detail\n"; + if (report.unaccounted > 0) + std::cout << "note: " << report.unaccounted + << " object(s) are outside the current GC view — normal only as a transient " + "(created+dropped between GC rounds); re-run cas-fsck after the next round and " + "investigate any that persist\n"; + /// Not a finding: a canonical namespace-life key whose life is absent from the catalog is the + /// protocol-produced interval between a fenced GC exact-deleting a `Removing` row and the + /// perpetual namespace janitor reaching it on a later bounded page (its own deletes are + /// suppressed for the whole of Stage A). Persistent non-convergence is a leak/liveness question + /// for `CASGCNamespaceCleanupLeaks` and the `namespace_cleanup` GC-log phase, not this scan. + if (report.namespace_janitor_pending > 0) + std::cout << "note: " << report.namespace_janitor_pending + << " namespace-life object(s) (" << report.namespace_janitor_pending_bytes + << " byte(s) across " << report.namespace_janitor_pending_lives + << " life/lives) are janitor-pending — their catalog row is already gone, and the " + "perpetual namespace janitor is the sole intended reclaimer, but its deletes can " + "be deferred (e.g. a destructive-round suppression policy) — not corruption; " + "investigate only if the same objects persist across many completed janitor " + "cycles (listed as `janitor-pending` rows under --detail)\n"; + + if (detail) + { + for (const auto & o : report.objects) + { + const char * c = "unreachable"; // NOLINT(clang-analyzer-deadcode.DeadStores) - defensive fallback if the enum grows + switch (o.cls) + { + case Cas::FsckClass::Reachable: c = "reachable"; break; + case Cas::FsckClass::Dangling: c = "dangling"; break; + case Cas::FsckClass::Unreachable: c = "unreachable"; break; + case Cas::FsckClass::PendingGc: c = "pending-gc"; break; + case Cas::FsckClass::AwaitingGc: c = "awaiting-gc"; break; + case Cas::FsckClass::Unaccounted: c = "unaccounted"; break; + case Cas::FsckClass::StaleEdge: c = "stale-edge"; break; + case Cas::FsckClass::CorruptedRun: c = "corrupted-run"; break; + case Cas::FsckClass::ChainBroken: c = "chain-broken"; break; + case Cas::FsckClass::Unchecked: c = "unchecked"; break; + case Cas::FsckClass::LifelessKey: c = "lifeless-key"; break; + case Cas::FsckClass::JanitorPending: c = "janitor-pending"; break; + } + std::cout << c << "\t" << o.key << "\t" << o.size; + for (const auto & r : o.reachable_from) + std::cout << "\t" << r; + std::cout << "\n"; + } + } + + if (report.dangling > 0) + throw Exception( + ErrorCodes::BAD_ARGUMENTS, "cas-fsck: {} reachable object(s) MISSING (INV-NO-LOSS violation)", report.dangling); + /// A hole in a ref stream is loss of a different kind: the records above it are unreachable, so + /// the table's own history is truncated wherever recovery next reads it. Fatal in the summary AND + /// in the exit code (spec §7) — a verdict only a `--detail` reader would notice is a verdict no + /// automation acts on. + if (report.chain_broken > 0) + throw Exception( + ErrorCodes::BAD_ARGUMENTS, + "cas-fsck: {} namespace(s) have a HOLE in their ref-log stream — an id is absent below a " + "durable id of the same epoch, which contiguity (INV-1) makes impossible without a lost " + "record; every transaction above the hole is unreachable (positions are listed as " + "`chain-broken` rows under --detail)", report.chain_broken); + /// A term of `clean()`, and until 2026-07-26 the only one that neither printed nor exited + /// nonzero — so a corrupt run was invisible twice over. A seal-checksum mismatch is not debris: + /// `fold`/`zeroInDegree`/`previewDeletes` all fail closed on the same run, so GC cannot make + /// progress past it, and the audit deliberately continues only so ONE pass enumerates them all. + if (report.corrupted_runs > 0) + throw Exception( + ErrorCodes::BAD_ARGUMENTS, + "cas-fsck: {} GC source-edge run(s) failed their whole-file seal checksum — the deletion-" + "deriving consumers fail closed on these, so GC cannot advance past them (run keys are " + "listed as `corrupted-run` rows under --detail)", report.corrupted_runs); + /// A key the `Layout` parsers refuse (no current writer can produce it), or a catalog + /// incarnation that is ambiguous or unreadable, is corruption nothing clears on its own: the + /// namespace enumeration now reports it instead of aborting, which is what makes an exit code + /// the only signal automation can act on. A COMPLETE, canonical namespace-life key whose life is + /// simply absent from the catalog is NOT counted here — see the `janitor-pending` note above. + if (report.lifeless_keys > 0) + throw Exception( + ErrorCodes::BAD_ARGUMENTS, + "cas-fsck: {} key(s) under this pool name are malformed or unresolvable — no current " + "writer could have produced them, or their catalog incarnation is ambiguous/unreadable " + "(the keys are listed as `lifeless-key` rows under --detail)", report.lifeless_keys); + } +}; + +CommandPtr makeCommandFsck() +{ + return std::make_shared(); +} + +} diff --git a/programs/disks/DisksApp.cpp b/programs/disks/DisksApp.cpp index dbde5cb4672c..871316672a43 100644 --- a/programs/disks/DisksApp.cpp +++ b/programs/disks/DisksApp.cpp @@ -25,6 +25,7 @@ #include #include +#include #include #include #include "config.h" @@ -34,6 +35,7 @@ #include #include #include +#include #include @@ -44,6 +46,7 @@ namespace ErrorCodes { extern const int BAD_ARGUMENTS; extern const int LOGICAL_ERROR; + extern const int STD_EXCEPTION; }; LineReader::Patterns DisksApp::query_extenders = {"\\"}; @@ -212,6 +215,8 @@ bool DisksApp::processQueryText(const String & text) return false; CommandPtr command; + last_command_exit_code = 0; + auto subqueries = splitOnUnquotedSemicolons(text); for (const auto & subquery : subqueries) { @@ -230,6 +235,7 @@ bool DisksApp::processQueryText(const String & text) { int code = err.code(); error_string = getExceptionMessageForLogging(err, true, false); + last_command_exit_code = code; if (code == ErrorCodes::BAD_ARGUMENTS) { if (command.get()) @@ -246,10 +252,12 @@ bool DisksApp::processQueryText(const String & text) catch (std::exception & err) { error_string = err.what(); + last_command_exit_code = ErrorCodes::STD_EXCEPTION; } catch (...) // Ok: report unknown exception { error_string = "Unknown exception"; + last_command_exit_code = ErrorCodes::STD_EXCEPTION; } if (error_string.has_value()) { @@ -334,6 +342,11 @@ void DisksApp::registerCommands() command_descriptions.emplace("switch-disk", makeCommandSwitchDisk()); command_descriptions.emplace("current_disk_with_path", makeCommandGetCurrentDiskAndPath()); command_descriptions.emplace("touch", makeCommandTouch()); + command_descriptions.emplace("cas-fsck", makeCommandFsck()); + command_descriptions.emplace("cas-gc-dryrun", makeCommandCaGcDryRun()); + command_descriptions.emplace("cas-gc-rebuild", makeCommandCaGcRebuild()); + command_descriptions.emplace("cas-inspect", makeCommandCaInspect()); + command_descriptions.emplace("cas-drop-member", makeCommandCaDropMember()); command_descriptions.emplace("read-checksums", makeCommandReadChecksums()); command_descriptions.emplace("help", makeCommandHelp(*this)); #if CLICKHOUSE_CLOUD @@ -539,6 +552,13 @@ int DisksApp::main(const std::vector & /*args*/) /*max_io_thread_pool_free_size*/ 0, /*io_thread_pool_queue_size*/ 10000); + /// `clickhouse-disks` loads no `ServerSettings`, so this can't read + /// `cas_blob_upload_pool_size`; 16 mirrors that setting's default + /// (`src/Core/ServerSettings.cpp`). A `write` command that commits through a + /// `cas` disk reaches `uploadPendingBlobs`, which calls this pool + /// unconditionally (see the analogous init in `Server.cpp`/`LocalServer.cpp`). + DB::Cas::initializeBlobUploadPool(16); + registerCommands(); registerDisks(/* global_skip_access_check= */ true); @@ -571,6 +591,16 @@ int DisksApp::main(const std::vector & /*args*/) global_context->setPath(path); + /// Load the server UUID so that live CA namespaces resolve correctly. + /// Only load when the uuid file already exists — clickhouse-disks inspects existing + /// pools and must NOT create or mutate the uuid file (the disk may be read-only). + /// If the file is absent, ServerUUID stays Nil and shadow/non-live navigation works. + { + fs::path uuid_file = fs::path(path) / "uuid"; + if (fs::exists(uuid_file)) + ServerUUID::load(uuid_file, &logger()); + } + client = std::make_unique(config(), global_context); suggest.setCompletionsCallback([&](const String & prefix, size_t /* prefix_length */) { return getCompletions(prefix); }); @@ -587,6 +617,10 @@ int DisksApp::main(const std::vector & /*args*/) if (log_file) log_file->close(); + /// Non-interactive runs surface a failing command as a nonzero process exit (CI/cron gating, + /// e.g. `cas-fsck` reporting dangling objects). Interactive sessions are unaffected. + if (query.has_value() && last_command_exit_code != 0) + return last_command_exit_code; return Application::EXIT_OK; } @@ -636,6 +670,7 @@ int mainEntryClickHouseDisks(int argc, char ** argv) /// That way, accesses happen-before destruction. SCOPE_EXIT_SAFE({ DB::StaticThreadPool::shutdownAll(); + DB::Cas::shutdownBlobUploadPool(); GlobalThreadPool::shutdown(); }); diff --git a/programs/disks/DisksApp.h b/programs/disks/DisksApp.h index fbe0639e00f3..27d7e03a6720 100644 --- a/programs/disks/DisksApp.h +++ b/programs/disks/DisksApp.h @@ -90,6 +90,10 @@ class DisksApp : public Poco::Util::Application std::optional query; + /// Set when a command threw during processQueryText; used to make non-interactive (--query) + /// runs exit nonzero (e.g. `fsck` reporting dangling objects). Reset per processQueryText call. + int last_command_exit_code = 0; + const std::unordered_map aliases = { {"cp", "copy"}, {"mv", "move"}, diff --git a/programs/disks/ICommand.h b/programs/disks/ICommand.h index b0293d01b3bb..12ef5f214a09 100644 --- a/programs/disks/ICommand.h +++ b/programs/disks/ICommand.h @@ -133,6 +133,11 @@ DB::CommandPtr makeCommandSwitchDisk(); DB::CommandPtr makeCommandGetCurrentDiskAndPath(); DB::CommandPtr makeCommandHelp(const DisksApp & disks_app); DB::CommandPtr makeCommandTouch(); +DB::CommandPtr makeCommandFsck(); +DB::CommandPtr makeCommandCaGcDryRun(); +DB::CommandPtr makeCommandCaGcRebuild(); +DB::CommandPtr makeCommandCaInspect(); +DB::CommandPtr makeCommandCaDropMember(); DB::CommandPtr makeCommandReadChecksums(); #if CLICKHOUSE_CLOUD DB::CommandPtr makeCommandPackedIO(); diff --git a/programs/local/LocalServer.cpp b/programs/local/LocalServer.cpp index b5b2c9ecb065..d79979cfdcb3 100644 --- a/programs/local/LocalServer.cpp +++ b/programs/local/LocalServer.cpp @@ -64,6 +64,7 @@ #include #include #include +#include #include #include #include @@ -199,6 +200,7 @@ namespace ServerSetting extern const ServerSettingsUInt64 max_format_parsing_thread_pool_size; extern const ServerSettingsUInt64 max_format_parsing_thread_pool_free_size; extern const ServerSettingsUInt64 format_parsing_thread_pool_queue_size; + extern const ServerSettingsUInt64 cas_blob_upload_pool_size; extern const ServerSettingsUInt64 page_cache_history_window_ms; extern const ServerSettingsString page_cache_policy; extern const ServerSettingsDouble page_cache_size_ratio; @@ -419,6 +421,11 @@ void LocalServer::initialize(Poco::Util::Application & self) server_settings[ServerSetting::max_format_parsing_thread_pool_size], server_settings[ServerSetting::max_format_parsing_thread_pool_free_size], server_settings[ServerSetting::format_parsing_thread_pool_queue_size]); + + /// See the explanation near the same line in Server.cpp: `uploadPendingBlobs` reaches this + /// pool unconditionally once a `cas` disk commits a part, so every entry point + /// that can run a CA INSERT must initialize it, not only `clickhouse-server`. + DB::Cas::initializeBlobUploadPool(server_settings[ServerSetting::cas_blob_upload_pool_size]); } @@ -895,6 +902,11 @@ void LocalServer::cleanup() client_context.reset(); + /// Joins any outstanding blob-upload fan-out tasks before the context they reference + /// is torn down. Idempotent and noexcept, so safe even if never initialized (e.g. no + /// `cas` disk was ever used). + DB::Cas::shutdownBlobUploadPool(); + if (global_context) { global_context->shutdown(); diff --git a/programs/server/Server.cpp b/programs/server/Server.cpp index 9260a3c9d2dc..77a608df4b87 100644 --- a/programs/server/Server.cpp +++ b/programs/server/Server.cpp @@ -103,6 +103,7 @@ #include #include #include +#include #include #include #include @@ -394,6 +395,7 @@ namespace ServerSetting extern const ServerSettingsUInt64 max_format_parsing_thread_pool_size; extern const ServerSettingsUInt64 max_format_parsing_thread_pool_free_size; extern const ServerSettingsUInt64 format_parsing_thread_pool_queue_size; + extern const ServerSettingsUInt64 cas_blob_upload_pool_size; extern const ServerSettingsUInt64 page_cache_history_window_ms; extern const ServerSettingsString page_cache_policy; extern const ServerSettingsDouble page_cache_size_ratio; @@ -1495,6 +1497,7 @@ try Stopwatch watch; LOG_INFO(log, "Waiting for background threads"); DB::StaticThreadPool::shutdownAll(); + DB::Cas::shutdownBlobUploadPool(); GlobalThreadPool::instance().shutdown(); LOG_INFO(log, "Background threads finished in {} ms", watch.elapsedMilliseconds()); }); @@ -1716,6 +1719,8 @@ try server_settings[ServerSetting::max_format_parsing_thread_pool_free_size], server_settings[ServerSetting::format_parsing_thread_pool_queue_size]); + DB::Cas::initializeBlobUploadPool(server_settings[ServerSetting::cas_blob_upload_pool_size]); + std::string path_str = getCanonicalPath(String(server_settings[ServerSetting::path]), original_working_directory); fs::path path = path_str; diff --git a/programs/server/config.xml b/programs/server/config.xml index 233aafc84edf..919c3b9881ba 100644 --- a/programs/server/config.xml +++ b/programs/server/config.xml @@ -1195,6 +1195,23 @@ --> + + + system + cas_log
+ toYYYYMM(event_date) + 7500 + 1048576 + 8192 + 524288 + false + +
+ + + system + cas_gc_log
+ toYYYYMM(event_date) + 7500 + 1048576 + 8192 + 524288 + false +
+ + cas + + replica-1 + cas_pool/ + + cas_scratch/ + 1 + 60 +
+ +``` + +`1` opens the disk in observe-only mode: no mount-slot +claim, no capability probe, no writes — the mode `clickhouse-disks` tools and +post-mortem inspection use. The full knob set (staging backend, cache sizes, +GC sharding, hash algorithm, request budgets) is parsed in +`MetadataStorageFactory.cpp`; each knob is documented at its parse site. + +## Operations and observability + +- `clickhouse-disks` verbs (all require the disk opened read-only): `fsck` + (independent reachability audit of refs → manifests → blobs), `cas-inspect` + (decode one pool object by its raw key to JSON), `cas-gc-dryrun` (preview the + next GC round's deletes), `cas-gc-rebuild` (disaster-recovery rebuild of the + `gc/state` baseline), `cas-drop-member` (decommission a dead pool member). +- `system.cas_log` — one row per CAS protocol event + (uploads, adopts, promotes, condemns, deletes, mount-slot writes, ...); + the primary audit trail when investigating pool state. +- The GC and writer paths also emit `ProfileEvents` counters (grep + `ProfileEvents.cpp` for `Cas`). + +## Testing + +- **Unit tests** (`unit_tests_dbms`): every CAS suite name starts with `Cas`, so + `--gtest_filter='Cas*'` runs the whole set — including parameterized suites, + whose instantiation prefixes are `Cas`-prefixed too so the `/` + spelling still matches. `utils/cas-gate/generate_cas_suites.sh` fails loud on a + CAS suite that does not match, so a new suite cannot silently sit outside the + filter; `utils/cas-gate/run_cas_gate_per_suite.sh` runs them one process per + suite, so an abort cannot hide the suites after it. +- **Stateless lanes**: the functional-test jobs "`cas storage`" + (local object storage) and "`cas s3 storage`" run the whole + stateless suite with `MergeTree` defaulting to a CAS disk. Tests that + legitimately cannot run there carry the `no-cas-storage` tag. +- **Soak / chaos**: `utils/ca-soak/` — multi-replica docker-compose + harnesses (fault proxies, GC sharding variants, AWS S3/GCS backends) and + adversarial scenarios. + +## Reading order + +To understand a request end to end, read in this order: + +1. `ContentAddressedMetadataStorage` — the facade / routing. +2. `Parts/PartFolderAccess` (`PartRefKey` → the folder view / cache). +3. `Pool/CasPool` — the pool composition root and `open` protocol. +4. `Pool/CasPartWriteTxn` — one-part write transaction. +5. `Gc/CasGc` — the GC round engine. diff --git a/src/Disks/DiskObjectStorage/MetadataStorages/ContentAddressed/Tools/CasDecommission.cpp b/src/Disks/DiskObjectStorage/MetadataStorages/ContentAddressed/Tools/CasDecommission.cpp new file mode 100644 index 000000000000..416f5c327663 --- /dev/null +++ b/src/Disks/DiskObjectStorage/MetadataStorages/ContentAddressed/Tools/CasDecommission.cpp @@ -0,0 +1,476 @@ +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace DB +{ +namespace ErrorCodes +{ + extern const int CORRUPTED_DATA; +} +} + +namespace DB::Cas +{ + +namespace +{ + +uint64_t nowMs() +{ + return static_cast(std::chrono::duration_cast( + std::chrono::system_clock::now().time_since_epoch()).count()); +} + +/// Delete every object listed under `prefix` by its listed (or, absent a list-token backend, HEAD'd) +/// token. This backs the staging and roots drain phases below: the victim's writers are fenced by the +/// decommission claim (`Pool::openForDecommission`), so nothing should be racing these deletes, and a +/// plain exact-token delete of every listed object is race-free. +/// +/// A per-object failure — a backend exception, a `TokenMismatch` or `NotFound` outcome, or an object +/// disappearing between `LIST` and `HEAD` — is recorded as a warning and does not prevent the remaining +/// objects from being attempted. The caller keeps the pool slot whenever warnings are present, so the +/// terminated slot remains available as a resume anchor instead of being deleted after an unconfirmed +/// drain. Returns only the objects whose exact-token delete was reported as `Deleted`. +uint64_t deleteListedPrefix(Backend & backend, const String & prefix, std::vector & warnings) +{ + uint64_t deleted = 0; + forEachListedKey(backend, prefix, [&](const ListedKey & listed) + { + try + { + Token token; + if (listed.token) + token = *listed.token; + else + { + const HeadResult head = backend.head(listed.key); + if (!head.exists) + { + warnings.push_back("decommission drain: " + listed.key + " vanished before delete"); + return; + } + token = head.token; + } + + const DeleteOutcome outcome = backend.deleteExact(listed.key, token); + const DeleteClass outcome_class = classifyDeleteOutcome(outcome); + if (outcome_class == DeleteClass::Deleted) + ++deleted; + else + warnings.push_back("decommission drain: " + listed.key + " delete outcome " + + String(deleteClassName(outcome_class))); + } + catch (...) + { + warnings.push_back("decommission drain: " + listed.key + " delete failed: " + + getCurrentExceptionMessage(/*with_stacktrace=*/false)); + } + }); + return deleted; +} + +/// Delete one slot control object by a token captured at the protocol-defined fence point. Slot +/// retirement is fail-closed: unlike the debris drains above, any non-`Deleted` outcome or exception +/// stops the tail before it can touch the next control object. +bool deleteSlotObject(Backend & backend, const String & key, const Token & token, std::vector & warnings) +{ + try + { + const DeleteOutcome outcome = backend.deleteExact(key, token); + const DeleteClass outcome_class = classifyDeleteOutcome(outcome); + if (outcome_class == DeleteClass::Deleted) + return true; + + warnings.push_back("slot delete failed: " + key + ": delete outcome " + + String(deleteClassName(outcome_class))); + } + catch (...) + { + warnings.push_back("slot delete failed: " + key + ": " + + getCurrentExceptionMessage(/*with_stacktrace=*/false)); + } + return false; +} + +} + +DecommissionReport decommissionPoolMember(BackendPtr backend, PoolConfig config, + const String & victim_srid, const CasEventSink & sink, + const std::function & request_gc_round) +{ + DecommissionReport report; + report.srid = victim_srid; + bool gc_round_needed = false; + /// A namespace may have reached `Removing` before a later namespace fails closed. Preserve the + /// already-earned liveness signal on every exit: the callback only wakes the existing serialized + /// GC worker and cannot perform catalog work itself. + SCOPE_EXIT({ + if (gc_round_needed && request_gc_round) + request_gc_round(); + }); + + /// Validate one required immutable ownership cut before impersonating the victim. The admin open + /// performs its own fresh catalog observation for mount safety, but namespace selection below + /// must reuse this exact pre-mutation decision rather than read a later authority set. + const Layout catalog_layout(config.pool_prefix); + const CasRefCatalog::Snapshot catalog_cut = CasRefCatalog::read(*backend, catalog_layout); + catalog_cut.life_index.throwIfAmbiguous("CAS decommission"); + + config.event_sink = sink; + PoolPtr admin = Pool::openForDecommission(std::move(backend), std::move(config), victim_srid); + + EventEmitter{*admin}.emit([&](CasEvent & e) + { + e.type = CasEventType::MemberDecommission; + e.outcome = "begin"; + e.reason = "operator decommission of pool member"; + e.detail = {{"server_root_id", victim_srid}}; + }); + + /// The pre-impersonation catalog cut is the complete ownership universe. Physical life keys carry + /// no logical path, and raw string prefixes such as `victim` must not select the distinct owner + /// `victim2`; the slash makes `victim` one canonical path component. + const String victim_namespace_prefix = victim_srid + "/"; + std::vector> owned_lives; + for (const CatalogEntry & entry : catalog_cut.catalog.entries) + { + if (entry.ns.string() != victim_srid && !entry.ns.string().starts_with(victim_namespace_prefix)) + continue; + const auto life = catalog_cut.life_index.resolve(entry.incarnation); + if (!life) + throw Exception(ErrorCodes::CORRUPTED_DATA, + "ca-decommission: catalog entry '{}' has no physical life resolution", entry.ns.string()); + owned_lives.emplace_back(entry, *life); + } + + for (const auto & [selected_entry, life] : owned_lives) + { + const RootNamespace & ns = life.ns; + const String & ns_str = ns.string(); + + /// Refuse a same-name lifecycle move that landed after the immutable selection cut. The + /// exact-life overloads below also pin recovery to `life`, closing the race after this check: + /// a later replacement can never redirect a removal to its new incarnation. + const CasRefCatalog::Snapshot current_catalog = CasRefCatalog::read(admin->backend(), admin->layout()); + const auto current_entry = std::find_if( + current_catalog.catalog.entries.begin(), current_catalog.catalog.entries.end(), + [&](const CatalogEntry & entry) { return entry.ns.string() == ns_str; }); + if (current_entry == current_catalog.catalog.entries.end() || *current_entry != selected_entry) + throw Exception(ErrorCodes::CORRUPTED_DATA, + "ca-decommission: namespace '{}' changed incarnation after the validated catalog cut; " + "refusing destructive work", + ns_str); + + if (selected_entry.state == NsState::Removing) + { + if (!admin->backend().head(admin->layout().refCkptKey(life)).exists) + throw Exception(ErrorCodes::CORRUPTED_DATA, + "ca-decommission: namespace '{}' is Removing but its exact checkpoint is absent; " + "the catalog row remains owned and the victim slot cannot be retired", + ns_str); + + /// `dropNamespace` is the sole terminal writer. On an already-complete removal this is an + /// idempotent observation; on a pre-terminal `Removing` life it resumes the exact terminal + /// append under the administrative writer fence. Catalog deletion remains GC's job. + (void)admin->dropNamespace(life); + ++report.namespaces_already_removed; + gc_round_needed = true; + continue; + } + + const auto stats = admin->dropNamespace(life); + ++report.namespaces_removed; + report.committed_refs_removed += stats.committed_refs; + report.precommits_removed += stats.precommits; + report.edge_deltas_emitted += stats.committed_refs + stats.precommits; + if (selected_entry.state != NsState::Creating) + gc_round_needed = true; + + EventEmitter{*admin}.emit([&](CasEvent & e) + { + e.type = CasEventType::MemberDecommission; + e.outcome = "namespace_removed"; + e.reason = "decommission dropped a victim namespace"; + e.detail = {{"server_root_id", victim_srid}, {"namespace", ns_str}, + {"committed", std::to_string(stats.committed_refs)}, + {"precommits", std::to_string(stats.precommits)}}; + }); + } + + /// Manifest debris must be removed before the mount slot: deleting the mount body removes the + /// watermark authority, after which `floorForNamespace` returns no value and the ordinary orphan + /// sweep cannot prove that old-epoch debris is eligible. The decommission claim has advanced the + /// writer epoch, so every build prefix with `prefix.writer_epoch < w.writer_epoch` is eligible here. + /// Group the listed keys by namespace and build prefix so each group can use the exact-token orphan + /// sweep while the mount body still supplies its authority. + { + const String debris_prefix = admin->layout().casManifestsServerPrefix(victim_srid); + std::set> groups; /// (namespace, writer epoch, build sequence) + forEachListedKey(admin->backend(), debris_prefix, [&](const ListedKey & listed) + { + if (const auto parsed = admin->layout().parseManifestKey(listed.key)) + groups.emplace(parsed->root_namespace.string(), parsed->ref.writer_epoch, parsed->ref.build_sequence); + }); + for (const auto & [ns_str, writer_epoch, build_sequence] : groups) + report.manifest_debris_removed += sweepNamespace( + *admin, RootNamespace(ns_str), BuildPrefix{writer_epoch, build_sequence}, &report.warnings); + } + + /// Drain the victim's own `/staging//` area. The live-mount staging helper uses + /// an `IObjectStorage`, while this command intentionally works at the `Backend` layer, so the same + /// prefix is listed and deleted directly. The claim fences the victim's writers during this sweep. + report.staging_objects_removed += deleteListedPrefix( + admin->backend(), admin->poolConfig().pool_prefix + "/staging/" + victim_srid + "/", report.warnings); + + /// Drain the victim's mountpoint objects. These are loose, non-content-addressed files under + /// `Layout::serverRootDataPrefix`; they have no writer epoch of their own, so the claim is what + /// prevents a returning victim from racing this deletion. + report.mountpoint_objects_removed += deleteListedPrefix( + admin->backend(), admin->layout().serverRootDataPrefix(victim_srid), report.warnings); + + /// The catalog, not physical debris, owns the slot-retirement decision. A terminal append only + /// moves a row to `Removing`; GC must fold/prune/delete it before the member's ownership anchor can + /// disappear. Capture one exact whole-catalog cut after every drain, then revalidate its token and + /// canonical value immediately before entering the retirement tail. The administrative claim fences + /// the victim writer between those observations. + std::optional retirement_catalog_cut; + if (report.warnings.empty()) + { + retirement_catalog_cut = CasRefCatalog::read(admin->backend(), admin->layout()); + const uint64_t victim_owned_count = std::count_if( + retirement_catalog_cut->catalog.entries.begin(), retirement_catalog_cut->catalog.entries.end(), + [&](const CatalogEntry & entry) + { + return entry.ns.string() == victim_srid + || entry.ns.string().starts_with(victim_namespace_prefix); + }); + if (victim_owned_count > 0) + report.warnings.push_back( + "pool member decommission underway: " + std::to_string(victim_owned_count) + + " namespace(s) are still owned by this member; upcoming GC rounds perform the final " + "cleanup — re-run this command afterwards to retire the slot"); + } + + /// Retire the slot strictly last and only after a clean drain. Copy the layout and shared backend + /// before `admin.reset()`: graceful close destroys the `Pool`, while the backend must remain alive to + /// retire the slot objects afterwards. + const Layout layout = admin->layout(); + const BackendPtr pool_backend = admin->poolBackendPtr(); + if (report.warnings.empty()) + { + const CasRefCatalog::Snapshot fresh_retirement_catalog + = CasRefCatalog::read(admin->backend(), admin->layout()); + if (!retirement_catalog_cut + || fresh_retirement_catalog.token != retirement_catalog_cut->token + || fresh_retirement_catalog.catalog != retirement_catalog_cut->catalog) + { + report.warnings.push_back( + "catalog changed after the victim ownership check; refusing slot retirement against a stale cut"); + } + } + if (report.warnings.empty()) + { + const String mount_key = layout.mountKey(victim_srid); + const String epoch_key = layout.epochKey(victim_srid); + const String owner_key = layout.ownerKey(victim_srid); + + /// Capture both the epoch value and its exact token while the decommission claim still fences + /// the victim. A successor can only bump this object after the farewell below releases the + /// claim, so this token is the epoch-side successor fence for the retirement tail. + std::optional claimed_epoch; + try + { + claimed_epoch = pool_backend->get(epoch_key); + if (!claimed_epoch) + report.warnings.push_back("slot capture failed: " + epoch_key + " is absent under the admin claim"); + } + catch (...) + { + report.warnings.push_back("slot capture failed: " + epoch_key + ": " + + getCurrentExceptionMessage(/*with_stacktrace=*/false)); + } + + /// Graceful close stamps an already-expired lease and the watermark farewell + /// (`min_active = UINT64_MAX`), making the slot `terminated` before its mutable control objects + /// are removed and its owner anchor is tombstoned. + admin.reset(); + + /// Read the farewell immediately after `finishTeardown` wrote it. Its exact token is the + /// mount-side fence: deleting by this token can remove only THIS decommission's farewell, not + /// a successor reclaim. Validate the body against the epoch value captured under the claim so + /// a successor that completed before this GET is also recognized and left untouched. + std::optional farewell_mount; + try + { + farewell_mount = pool_backend->get(mount_key); + if (!farewell_mount) + report.warnings.push_back("slot capture failed: " + mount_key + " farewell is absent"); + } + catch (...) + { + report.warnings.push_back("slot capture failed: " + mount_key + ": " + + getCurrentExceptionMessage(/*with_stacktrace=*/false)); + } + + bool captures_match = claimed_epoch && farewell_mount; + if (captures_match) + { + try + { + const ServerEpoch epoch_value = decodeServerEpoch(claimed_epoch->bytes); + const MountLease mount_value = decodeMountLease(farewell_mount->bytes); + captures_match = epoch_value.next_writer_epoch != 0 + && mount_value.writer_epoch == epoch_value.next_writer_epoch - 1 + && mount_value.min_active == std::numeric_limits::max() + && !mount_value.gc_fenced; + if (!captures_match) + { + report.warnings.push_back( + "slot capture failed: " + mount_key + + " is not this decommission's farewell for the epoch captured under the admin claim"); + } + } + catch (...) + { + report.warnings.push_back("slot capture failed while validating " + mount_key + " and " + epoch_key + ": " + + getCurrentExceptionMessage(/*with_stacktrace=*/false)); + captures_match = false; + } + } + + /// Mount first: if a successor reclaimed it after the farewell capture, the stale farewell + /// token yields `TokenMismatch` and the tail stops before touching epoch or owner. Epoch second: + /// its under-claim token similarly detects a successor allocation. Before touching owner, re-read + /// both mutable objects: a same-UUID successor can recreate them after both deletes without + /// rewriting the owner identity anchor. Mere presence proves that the slot is live again. Every + /// delete must be explicitly confirmed as `Deleted`, and the final owner tombstone rewrite must + /// succeed against the exact token read immediately before it. + /// + /// ACCEPTED RESIDUAL WINDOW (final review, not closed by this recheck): a same-UUID successor + /// can still recreate epoch/mount in the narrow gap strictly AFTER this liveness recheck but + /// BEFORE the owner CAS below reads its own token -- the successor's owner anchor (same + /// server_uuid, not yet retired) then gets tombstoned by this decommission run. The successor's + /// live process is not deleted (only its owner anchor is marked retired), but a LATER restart of + /// that same identity would refuse to reclaim it (claimOwnerOrThrow's tombstone guard). This is + /// a narrow, low-probability window, deliberately not closed here: T5's owner-tombstone design + /// (finding #9) intentionally stopped short of making concurrent decommission-vs-recreate + /// airtight to the microsecond, since that was explicitly not the priority for this fix. + report.slot_removed = false; + if (captures_match && deleteSlotObject(*pool_backend, mount_key, farewell_mount->token, report.warnings) + && deleteSlotObject(*pool_backend, epoch_key, claimed_epoch->token, report.warnings)) + { + std::optional current_mount; + std::optional current_epoch; + bool liveness_recheck_succeeded = true; + try + { + current_mount = pool_backend->get(mount_key); + } + catch (...) + { + report.warnings.push_back("slot liveness recheck failed: " + mount_key + ": " + + getCurrentExceptionMessage(/*with_stacktrace=*/false)); + liveness_recheck_succeeded = false; + } + try + { + current_epoch = pool_backend->get(epoch_key); + } + catch (...) + { + report.warnings.push_back("slot liveness recheck failed: " + epoch_key + ": " + + getCurrentExceptionMessage(/*with_stacktrace=*/false)); + liveness_recheck_succeeded = false; + } + + if (liveness_recheck_succeeded && (current_mount || current_epoch)) + { + report.warnings.push_back( + "slot delete aborted: successor reappeared after mutable control-object deletion; owner kept"); + } + else if (liveness_recheck_succeeded) + { + try + { + if (const auto owner = pool_backend->get(owner_key)) + { + OwnerObject tombstoned = decodeOwner(owner->bytes); + tombstoned.retired_at_ms = nowMs(); + /// Controlled, not a bare putOverwrite: a transient transport error here (or + /// one whose response was simply lost) must not be reported as a hard failure + /// when the write actually landed. A standalone controller (decommission is an + /// administrative, non-hot-path operation; no mount-lease fence applies to it + /// -- the exact-token CAS itself is the safety mechanism, same as the mount/ + /// epoch deletes above) resolves an ambiguous attempt with one GET: unchanged + /// token means the write never applied (legitimately retryable within budget); + /// matching bytes means this exact tombstone already landed (Committed, not a + /// failure); anything else is a genuine successor reclaim (Conflict). + CasRequestController controller(pool_backend, CasRequestBudget{}); + const CasOverwriteResult result = controller.putOverwriteControlled( + owner_key, encodeOwner(tombstoned), owner->token, [] { return true; }); + if (result.outcome == CasOverwriteOutcome::Committed) + report.slot_removed = true; + else if (result.outcome == CasOverwriteOutcome::Conflict) + report.warnings.push_back( + "slot tombstone failed: " + owner_key + + ": successor reclaimed the owner anchor before this decommission's tombstone write"); + else + report.warnings.push_back( + "slot tombstone failed: " + owner_key + + ": tombstone write outcome could not be resolved (retry budget exhausted " + "or the resolve GET itself failed) -- rerun the command to retry"); + } + else + report.warnings.push_back( + "slot tombstone failed: " + owner_key + ": object absent before tombstone write"); + } + catch (...) + { + report.warnings.push_back("slot tombstone failed: " + owner_key + ": " + + getCurrentExceptionMessage(/*with_stacktrace=*/false)); + } + } + } + } + else + { + report.slot_removed = false; + LOG_WARNING(getLogger("CasDecommission"), + "CAS decommission '{}': drain incomplete ({} warnings) — mount slot kept (terminated); " + "re-run the command to finish", victim_srid, report.warnings.size()); + admin.reset(); /// Graceful close still stamps the farewell, leaving the slot `terminated`. + } + + /// The `end` event is emitted via `sink` directly, not `EventEmitter{*admin}`: `admin` is gone by + /// now. This also means its `warnings` count reflects the FINAL total, including a slot-retirement + /// failure appended just above -- `EventEmitter`'s own zero-cost-when-absent guard is reproduced by + /// the `if (sink)` below. + if (sink) + { + CasEvent e; + e.type = CasEventType::MemberDecommission; + e.outcome = "end"; + e.reason = "decommission finished"; + e.detail = {{"server_root_id", victim_srid}, + {"namespaces_removed", std::to_string(report.namespaces_removed)}, + {"warnings", std::to_string(report.warnings.size())}, + {"slot_removed", report.slot_removed ? "1" : "0"}}; + sink(std::move(e)); + } + return report; +} + +} diff --git a/src/Disks/DiskObjectStorage/MetadataStorages/ContentAddressed/Tools/CasDecommission.h b/src/Disks/DiskObjectStorage/MetadataStorages/ContentAddressed/Tools/CasDecommission.h new file mode 100644 index 000000000000..a86edf9286c4 --- /dev/null +++ b/src/Disks/DiskObjectStorage/MetadataStorages/ContentAddressed/Tools/CasDecommission.h @@ -0,0 +1,55 @@ +#pragma once + +#include +#include + +#include +#include +#include +#include + +namespace DB::Cas +{ + +/// Counts the work performed by `decommissionPoolMember` for one pool member. The namespace counters +/// describe metadata and ref-log transitions; the object counters describe physical objects deleted by +/// the manifest, staging, and mountpoint drains. Blob bytes are intentionally not reported: removing +/// ref edges makes them eligible for ordinary GC, but this operation does not synchronously reclaim +/// shared content. +/// +/// A decommission is resumable. A previous run may already have moved namespaces to `Removing`, and a +/// warning means that the corresponding drain was not confirmed. In either case the report lets the +/// caller distinguish work done by this invocation from work observed from an earlier invocation. +struct DecommissionReport +{ + String srid; /// The decommissioned member's `server_root_id`. + uint64_t namespaces_removed = 0; /// Namespaces erased by this invocation. + uint64_t namespaces_already_removed = 0; /// Namespaces already `Removing` on entry. + uint64_t committed_refs_removed = 0; /// Committed ref records removed by namespace drops. + uint64_t precommits_removed = 0; /// Precommit records removed by namespace drops. + uint64_t edge_deltas_emitted = 0; /// The sum of `committed_refs_removed` and `precommits_removed`. + uint64_t manifest_debris_removed = 0; /// Eligible manifest objects deleted from old build prefixes. + uint64_t staging_objects_removed = 0; /// Objects deleted from the member's staging prefix. + uint64_t mountpoint_objects_removed = 0; /// Objects deleted from the member's roots/mountpoint prefix. + bool slot_removed = false; /// Whether mount and epoch were deleted and the owner was tombstoned. + std::vector warnings; /// Drain or slot-retirement failures; a non-empty list keeps the slot. +}; + +/// Erases all content owned by a permanently dead pool member. The operation first claims the member's +/// slot as an administrative writer; a live lease is refused, and the claim fences the dead member from +/// writing while cleanup runs. It then drops each table namespace through `Pool::dropNamespace`, drains +/// eligible manifest debris, staging objects, and mountpoint objects, and retires the slot only after all +/// drains are confirmed. Namespace drops are idempotent: a rerun resumes any missing terminal append +/// and leaves exact catalog-row deletion to GC. The member slot remains while any catalog entry still +/// belongs to the victim. +/// +/// This is a writer operation, not GC: it emits the normal ref-edge deltas and does not invent ref +/// transitions. Per-object drain failures are recorded in `DecommissionReport::warnings` and leave the +/// terminated slot as a resume anchor; other failures, including refusal to claim the member, propagate +/// as exceptions. When set, `sink` receives `MemberDecommission` audit events for the run's begin, +/// per-namespace, and end milestones. +DecommissionReport decommissionPoolMember(BackendPtr backend, PoolConfig config, + const String & victim_srid, const CasEventSink & sink = {}, + const std::function & request_gc_round = {}); + +} diff --git a/src/Disks/DiskObjectStorage/MetadataStorages/ContentAddressed/Tools/CasFsck.cpp b/src/Disks/DiskObjectStorage/MetadataStorages/ContentAddressed/Tools/CasFsck.cpp new file mode 100644 index 000000000000..7848ae591f27 --- /dev/null +++ b/src/Disks/DiskObjectStorage/MetadataStorages/ContentAddressed/Tools/CasFsck.cpp @@ -0,0 +1,1180 @@ +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include + +namespace DB +{ +namespace ErrorCodes +{ + extern const int CORRUPTED_DATA; + extern const int TIMEOUT_EXCEEDED; +} +} + +namespace DB::Cas +{ + +namespace +{ +constexpr uint64_t PROGRESS_PAGES = 16; + +using Deadline = std::optional; + +/// Enforce the optional overall scan deadline between backend operations. A timeout is propagated as +/// `TIMEOUT_EXCEEDED`; the public `runFsck` wrapper may convert that exception into a partial report when +/// explicitly requested. +void checkDeadline(const Deadline & deadline, std::string_view phase) +{ + if (deadline && std::chrono::steady_clock::now() > *deadline) + throw Exception(ErrorCodes::TIMEOUT_EXCEEDED, + "fsck: exceeded the deadline during '{}' — run against a QUIESCED pool or raise --timeout.", phase); +} + +void listAll(Backend & backend, const String & prefix, std::unordered_map & out, + const FsckProgress & on_progress, const Deadline & deadline, std::string_view phase) +{ + static constexpr size_t kPageLimit = 1000; + uint64_t pages = 0; + size_t count_in_page = 0; + forEachListedKey(backend, prefix, [&](const ListedKey & k) + { + out[k.key] = k.size; + if (++count_in_page == kPageLimit) + { + count_in_page = 0; + ++pages; + checkDeadline(deadline, phase); + if (on_progress && pages % PROGRESS_PAGES == 0) + on_progress(phase, out.size(), pages); + } + }, kPageLimit); + /// The walk's `backend.list` lands at least once even for an empty/undersized final page -- + /// check it here, mirroring the original per-page loop (deadline checked after every physical page). + if (count_in_page > 0 || pages == 0) + { + ++pages; + checkDeadline(deadline, phase); + } + if (on_progress) + on_progress(phase, out.size(), pages); +} + +/// Parse (writer_epoch, build_sequence) from a manifest object key. Delegates to the one shared +/// `Layout::parseManifestKey` instead of hand-rolling a second parser; returns false on a +/// malformed or foreign key. +bool parseBuildPrefix(const Layout & layout, const String & key, BuildPrefix & out) +{ + const auto parsed = layout.parseManifestKey(key); + if (!parsed) + return false; + out.writer_epoch = parsed->ref.writer_epoch; + out.build_sequence = parsed->ref.build_sequence; + return true; +} + +/// The ref-walk (which builds `reachable_blobs`/`blob_labels`) and the HEAD-confirm below run minutes +/// apart with no snapshot between them. A ref that gets republished (now names a +/// different manifest) or DROPPED in that window, combined with a legitimate GC delete of the OLD +/// blob, makes the stale walk look like a genuine dangle (a "phantom dangling") — this made the fsck +/// oracle dishonest and falsely report a dangle during long-running validation. +/// +/// Before counting a HEAD-absent blob as `Dangling`, re-resolve every `"ns/ref"` label under the same +/// immutable catalog row, using a fresh exact `_ckpt` from that original physical life. This admits a +/// same-life repoint/drop while refusing a competing rebirth. `label` is split on the LAST '/' — +/// mirroring exactly how the walk built it (`ns_str + "/" + ref_name`): `ref_name` never contains '/', +/// but `ns_str` may, so the join separator is always the rightmost one. +/// +/// Fails CLOSED on any ambiguity (a malformed label, a recovery error, a corrupt manifest): treated as +/// "still referenced", i.e. the original conservative verdict. +/// The fix can only SHRINK false positives — it must never hide a real one. +struct FsckRecoveryAuthority +{ + NamespaceLifeId life; + CatalogEntry catalog_entry; + std::optional checkpoint; +}; + +using FsckRecoveryAuthorities = std::unordered_map; +using RecordRecoveryUnchecked = std::function; + +/// Recheck one ref table against a newer `_ckpt` from the SAME physical life selected by fsck's +/// original catalog cut. The catalog row and life id never move; only the monotone checkpoint may +/// advance, which is how a same-life drop/repoint that completed during a long scan becomes visible +/// without admitting a competing rebirth. A missing or unreadable checkpoint cannot prove that an +/// old owner went away, so the caller records lost coverage and keeps the conservative verdict. +std::optional recoverLateRefTable( + Backend & backend, const Layout & layout, const FsckRecoveryAuthority & authority, + const RecordRecoveryUnchecked & record_unchecked) +{ + try + { + const std::optional sampled = readCkpt(backend, layout, authority.life); + if (!sampled) + { + record_unchecked(authority.life.ns, layout.refCkptKey(authority.life), + "late ref recheck: the original life checkpoint is absent"); + return std::nullopt; + } + return recoverRefTableDetailedFromAuthority( + backend, layout, authority.catalog_entry, sampled->ckpt).state; + } + catch (const Exception & e) + { + record_unchecked(authority.life.ns, layout.refCkptKey(authority.life), + "late ref recheck: the original life checkpoint or replay is unreadable: " + e.message()); + return std::nullopt; + } + catch (...) + { + record_unchecked(authority.life.ns, layout.refCkptKey(authority.life), + "late ref recheck: the original life checkpoint or replay could not be read"); + return std::nullopt; + } +} + +bool blobStillReferenced(Pool & store, const Layout & layout, + const FsckRecoveryAuthorities & authorities, const String & bkey, + const std::vector & labels, const Deadline & deadline, + const RecordRecoveryUnchecked & record_unchecked) +{ + if (labels.empty()) + return true; + for (const String & label : labels) + { + checkDeadline(deadline, "re-resolving refs at HEAD-absent"); + const size_t slash = label.rfind('/'); + if (slash == String::npos) + return true; /// malformed label — cannot re-resolve, fail closed + const String ns_part = label.substr(0, slash); + const String ref_name = label.substr(slash + 1); + try + { + /// Never read a second catalog cut here. A later rebirth may name the same logical namespace + /// but it is not the life whose original row made this blob reachable in this fsck pass. + const auto authority_it = authorities.find(ns_part); + if (authority_it == authorities.end()) + { + record_unchecked(RootNamespace{ns_part}, layout.refCatalogKey(), + "late blob recheck: no original Live/Removing authority was retained"); + return true; /// no original Live/Removing authority -- fail closed + } + const RootNamespace rns{ns_part}; + const std::optional table = recoverLateRefTable( + store.backend(), layout, authority_it->second, record_unchecked); + if (!table) + return true; + const auto rit = table->getCommitted().find(ref_name); + if (rit == table->getCommitted().end()) + continue; /// the ref was DROPPED since the walk — this label no longer applies + const PartManifest body = store.readManifest(ManifestId{rns, rit->second.manifest_ref}); + for (const ManifestEntry & e : body.entries) + { + if (e.placement != EntryPlacement::Blob) + continue; + if (layout.blobKey(e.ref) == bkey) + return true; /// an original-life ref still names this exact blob — a real dangle + } + } + catch (...) + { + return true; /// cannot confirm the ref moved away — keep the conservative verdict + } + } + return false; /// no original-life label names this blob — the stale-walk artifact is gone +} + +/// The manifest sibling of the `blobStillReferenced` recheck above. The ref-walk captures each committed +/// `(ref_name -> manifest_ref)` from a FRESH per-namespace recovery, but the `backend.get(mkey)` that +/// confirms the manifest body runs LATER in the same (possibly long) namespace loop. A ref republished to +/// a DIFFERENT manifest — or DROPPED — in that window, combined with a legitimate GC delete of the OLD +/// manifest body, makes the stale captured row look like a committed ref over a missing manifest (a +/// "phantom dangling manifest"), the same dishonest-oracle failure `blobStillReferenced` kills for blobs. +/// +/// Before counting a missing manifest body as `Dangling`, re-resolve the EXACT ref from the SAME frozen +/// catalog row with a fresh exact `_ckpt` from that original physical life, then check whether the +/// committed row still names THIS exact manifest key. A later catalog cut must not replace that row, +/// but a same-life checkpoint advance must be visible. Fails CLOSED on any ambiguity (a throw, a corrupt +/// table): treated as "still referenced", the original conservative verdict — the fix can only SHRINK +/// false positives, never hide a real loss. +bool manifestStillReferenced(Backend & backend, const Layout & layout, const RootNamespace & ns, + const FsckRecoveryAuthorities & authorities, const String & ref_name, + const String & mkey, const Deadline & deadline, + const RecordRecoveryUnchecked & record_unchecked) +{ + checkDeadline(deadline, "re-resolving ref at missing-manifest"); + try + { + const auto authority_it = authorities.find(ns.string()); + if (authority_it == authorities.end()) + { + record_unchecked(ns, layout.refCatalogKey(), + "late manifest recheck: no original Live/Removing authority was retained"); + return true; /// no original Live/Removing authority -- fail closed + } + const std::optional table = recoverLateRefTable( + backend, layout, authority_it->second, record_unchecked); + if (!table) + return true; + const auto rit = table->getCommitted().find(ref_name); + if (rit == table->getCommitted().end()) + return false; /// the ref was DROPPED since the walk — no longer a committed owner + /// A republish moved the ref to a different manifest key: this old key is no longer owned. + return layout.manifestKey(ManifestId{ns, rit->second.manifest_ref}) == mkey; + } + catch (...) + { + return true; /// cannot confirm the ref moved away — keep the conservative verdict + } +} + +String renderId(const RefTxnId & id) +{ + return std::to_string(id.writer_epoch) + "-" + std::to_string(id.ref_sequence); +} + +/// Per-NAMESPACE verdicts of the stream audit. Both counters count namespaces, not rows: a namespace +/// has exactly one answer about its stream even when several checks reach it. +/// +/// A namespace PROVEN broken is never also counted `unchecked`. "Proved broken" and "could not prove" +/// are different answers, and letting the second overwrite or accompany the first would turn a fatal +/// into an ambiguity — the recovery path throws on a holed stream, so a chain-broken namespace reliably +/// produces a downstream failure too, and that failure must not dilute the verdict that explains it. +struct NsVerdicts +{ + std::set chain_broken; + std::set unchecked; + + void recordChainBroken(FsckReport & report, const RootNamespace & ns, const String & key, String note) + { + chain_broken.insert(ns.string()); + unchecked.erase(ns.string()); + push(report, key, FsckClass::ChainBroken, std::move(note)); + } + + void recordUnchecked(FsckReport & report, const RootNamespace & ns, const String & key, String note) + { + if (chain_broken.contains(ns.string())) + return; + unchecked.insert(ns.string()); + push(report, key, FsckClass::Unchecked, std::move(note)); + } + + /// Both classes are emitted in EVERY mode, not just `detail`: they are namespace verdicts, bounded + /// by the namespace count, and a summary run that hid them would report a number nobody could act on. + void push(FsckReport & report, const String & key, FsckClass cls, String note) const + { + FsckObject o; + o.key = key; + o.kind = ObjectKind::Blob; /// ref objects have no ObjectKind; reuse Blob as the generic kind + o.size = 0; + o.cls = cls; + o.reachable_from = {std::move(note)}; + report.objects.push_back(std::move(o)); + } + + void publish(FsckReport & report) const + { + report.chain_broken = chain_broken.size(); + report.unchecked = unchecked.size(); + } +}; + +/// THE ARITHMETIC STREAM WALK (spec §7). Read-only, one namespace. +/// +/// The frozen catalog row and exact `_ckpt` define the complete finite walk. LIST supplies no genesis, +/// witness, frontier or stop condition, and the walker never probes the position after +/// `_ckpt.committed_through`. Every required id is point-read from the checkpoint base's successor (or +/// `{life_epoch, 1}`) through that inclusive frontier. A missing required id is therefore a proven hole; +/// no above-hole listing witness is needed. An epoch seal advances directly to the next epoch's first id, +/// exactly as authoritative read-only recovery does. +void checkRefStream(Backend & backend, const Layout & layout, const NamespaceLifeId & life, + const CatalogEntry & catalog_entry, const std::optional & checkpoint_sample, + const Deadline & deadline, FsckReport & report, NsVerdicts & verdicts) +{ + checkDeadline(deadline, "ref stream"); + const RootNamespace & ns = life.ns; + const std::optional checkpoint + = checkpoint_sample ? std::optional{checkpoint_sample->ckpt} : std::nullopt; + const RecoveryGrounding grounding = chooseRecoveryGrounding(catalog_entry, checkpoint); + if (grounding.base) + { + try + { + /// Even when the base IS the frontier and there is no replay tail, a checkpoint may not + /// turn an `EpochSeal` into a state snapshot by naming a same-id `_snap`. + (void)readCheckpointSnapshotBase(backend, layout, life, *checkpoint); + } + catch (const Exception & e) + { + const String key = layout.refSnapshotKey(life, *grounding.base); + const String note = "ref stream: checkpoint snapshot base " + renderId(*grounding.base) + + " is invalid: " + e.message(); + if (e.code() != ErrorCodes::CORRUPTED_DATA) + { + verdicts.recordUnchecked(report, ns, key, note); + return; + } + + /// A concurrent checkpoint advance may retire the sampled base between these exact reads. + /// Only the SAME checkpoint incarnation turns a missing/invalid member of its required + /// triple into durable corruption. A changed, absent, or unreadable authority proves no + /// such thing and remains the honest `Unchecked` answer. + checkDeadline(deadline, "checkpoint-base authority revalidation"); + try + { + const std::optional current = readCkpt(backend, layout, life); + if (!current || !checkpoint_sample || current->token != checkpoint_sample->token) + { + verdicts.recordUnchecked(report, ns, key, + note + "; checkpoint authority changed while validating its snapshot base"); + return; + } + } + catch (const Exception & revalidation_error) + { + verdicts.recordUnchecked(report, ns, key, + note + "; checkpoint authority could not be revalidated: " + revalidation_error.message()); + return; + } + catch (...) + { + verdicts.recordUnchecked(report, ns, key, + note + "; checkpoint authority could not be revalidated"); + return; + } + + verdicts.recordChainBroken(report, ns, key, note); + return; + } + } + if (!grounding.walk_from || !grounding.committed_through) + return; + + RefTxnId expected = *grounding.walk_from; + while (expected <= *grounding.committed_through) + { + checkDeadline(deadline, "ref stream"); + const auto got = backend.get(layout.refLogKey(life, expected)); + if (!got) + { + verdicts.recordChainBroken(report, ns, layout.refLogKey(life, expected), + "ref stream: checkpoint requires id " + renderId(expected) + " at or below inclusive frontier " + + renderId(*grounding.committed_through) + ", but its exact key is absent"); + return; + } + + bool is_seal = false; + try + { + is_seal = refLogTxnIsEpochSeal( + decodeRefLogTxn(openObject(FormatId::RefLog, got->bytes), ns.string(), expected)); + } + catch (const Exception & e) + { + verdicts.recordUnchecked(report, ns, layout.refLogKey(life, expected), + "ref stream: the checkpoint-required record at " + renderId(expected) + + " could not be decoded: " + e.message()); + return; + } + ++report.ref_records_walked; + + try + { + if (const std::optional next = nextRefLogIdWithinCommittedFrontier( + expected, is_seal, *grounding.committed_through)) + expected = *next; + else + break; + } + catch (const Exception & e) + { + verdicts.recordChainBroken(report, ns, layout.refLogKey(life, expected), + "ref stream: " + e.message()); + return; + } + } +} + +/// Perform the scan and accumulate into `report`. This helper owns the read-only traversal: it first +/// recovers authoritative refs, then checks physical objects and GC labels, while preserving the +/// distinction between a missing live object and expected in-flight cleanup. Deadline exceptions are +/// intentionally left to `runFsck`, which decides whether partial results were requested. +void runFsckImpl(Pool & store, bool detail, const FsckProgress & on_progress, const Deadline & deadline, + const String & namespace_prefix, FsckReport & report) +{ + const Layout & layout = store.layout(); + Backend & backend = store.backend(); + /// Path-derived per-object algorithm parsing: every listed blob-tree key -- across every + /// admitted algo, not just the pool's node-local write algo -- is classified via + /// `Layout::parseBlobKey`, which derives the `BlobRef` from the key's OWN `` path segment + /// (and its `.meta` sibling). A foreign/malformed key (unknown algo segment, wrong-width hex, a + /// non-`.meta`/non-blob shape) parses to `std::nullopt` and is classified as debris, never an + /// exception. + + /// Reachability is recomputed from the authoritative refs (never from GC state): + /// for each namespace, each committed ref resolves to a ManifestId; read its body; a committed ref + /// naming a MISSING body is an ERROR (Dangling); a present body whose blobs are missing is an ERROR. + std::set reachable_blobs; /// blob object keys named by a live owner + std::set owned_manifest_keys; /// manifest object keys named by a committed owner + /// blob key -> "ns/ref" labels of the refs that named it. Always populated (not just under + /// `detail`) — the HEAD-absent re-resolve below needs it in every mode. + std::unordered_map> blob_labels; + + uint64_t refs_walked = 0; + NsVerdicts verdicts; + SCOPE_EXIT({ verdicts.publish(report); }); + const RecordRecoveryUnchecked record_recovery_unchecked = + [&](const RootNamespace & ns, const String & key, const String & detail_text) + { + verdicts.recordUnchecked(report, ns, key, detail_text); + }; + + /// RECORD AND CONTINUE for a key that belongs to no namespace at all. fsck is the forensic tool an + /// operator reaches for once something is already wrong, so a key it cannot attribute must become a + /// FINDING and not an abort: an audit that died on the first bad key would report nothing about the + /// healthy namespaces it never reached, which is the wrong failure order for a read-only diagnostic. + /// + /// `seen` is what makes the count a count of DEFECTS: each sweep below enumerates namespaces again + /// and sees the same offending key, and only the first sighting is recorded. + std::set lifeless_seen; + auto recordLifelessKeys = [&](const NamespaceListing & listing) + { + for (const UnattributableNamespaceKey & bad : listing.skipped) + { + if (!lifeless_seen.insert(bad.key).second) + continue; + ++report.lifeless_keys; + FsckObject o; + o.key = bad.key; + o.kind = ObjectKind::Blob; /// a lifeless key has no ObjectKind; reuse Blob as the generic kind + o.cls = FsckClass::LifelessKey; + o.size = 0; + o.reachable_from = {bad.reason}; + report.objects.push_back(std::move(o)); + } + }; + + /// One immutable cut owns every physical-id join in this walk. `Creating` participates in that + /// attribution (its physical keys may exist) but is never recovered: only Live/Removing rows have a + /// durable publication frontier. A diagnostic records duplicate ids and keeps walking unrelated + /// unique lives. + const CasRefCatalog::Snapshot catalog_cut = CasRefCatalog::read(backend, layout); + struct FsckWalkLife + { + NamespaceLifeId life; + CatalogEntry catalog_entry; + }; + std::vector walk_lives; + walk_lives.reserve(catalog_cut.catalog.entries.size()); + for (const CatalogEntry & entry : catalog_cut.catalog.entries) + { + if (!entry.ns.string().starts_with(namespace_prefix)) + continue; + if (entry.state == NsState::Creating) + continue; + try + { + if (const auto life = catalog_cut.life_index.resolve(entry.incarnation)) + walk_lives.push_back(FsckWalkLife{.life = *life, .catalog_entry = entry}); + } + catch (const Exception & e) + { + if (e.code() != ErrorCodes::CORRUPTED_DATA) + throw; + recordLifelessKeys(NamespaceListing{{}, {{ + layout.refCatalogKey() + "#" + renderIncarnation(entry.incarnation), e.message()}}}); + } + } + + /// Physical life-owned keys carry no logical name. Classify each COMPLETE, canonical key against a + /// catalog cut taken AFTER this physical listing finishes (observe-then-cut), not the earlier + /// `catalog_cut` above: `NamespaceJanitor::runOnePage` (the only real deleter of this debris) uses + /// the identical ordering, and it is what makes "life absent from a LATER cut" sound -- creation + /// always admits a `Creating` catalog row before writing any life-owned object (spec §2), so a life + /// that is absent from a cut taken after the listing cannot be a concurrent birth this listing raced. + /// A malformed shape (the parser refuses, or the reserved segment names no clean relative file) is + /// classified immediately as it cannot become residue no matter which cut resolves it. + if (namespace_prefix.empty()) + { + struct CanonicalNamespaceKey + { + String key; + uint64_t size; + NamespaceLifePhysicalId life_id; + }; + std::vector canonical_candidates; + + forEachListedKey(backend, layout.namespaceRootPrefix(), [&](const ListedKey & listed) + { + std::optional physical_id; + try + { + if (const auto ref_object = layout.parseRefObjectKey(listed.key)) + physical_id = ref_object->life_id; + else if (const auto checkpoint = layout.parseRefCkptKey(listed.key)) + physical_id = *checkpoint; + else if (const auto namespace_file = layout.parseNamespaceFileKey(listed.key)) + physical_id = namespace_file->life_id; + else + { + recordLifelessKeys(NamespaceListing{{}, {{listed.key, "unrecognized key under the namespace ownership tree"}}}); + return; + } + } + catch (const Exception & e) + { + if (e.code() != ErrorCodes::CORRUPTED_DATA) + throw; + recordLifelessKeys(NamespaceListing{{}, {{listed.key, e.message()}}}); + return; + } + canonical_candidates.push_back(CanonicalNamespaceKey{listed.key, listed.size, *physical_id}); + }); + + /// The post-observation cut. All three catalog states -- `Creating`, `Live`, `Removing` -- + /// protect a life for this purpose; only a life absent from every one of them is residue. + const CasRefCatalog::Snapshot post_listing_cut = CasRefCatalog::read(backend, layout); + std::unordered_set pending_lives; + for (const CanonicalNamespaceKey & candidate : canonical_candidates) + { + try + { + if (post_listing_cut.life_index.resolve(candidate.life_id)) + continue; /// protected by some catalog state as of the later cut -- not residue + } + catch (const Exception & e) + { + /// The reverse life index throws `CORRUPTED_DATA` when the post-listing cut carries a + /// duplicated life id: a catalog defect, not evidence about THIS key. Record and keep + /// walking, same as every other catalog-authority failure in this scan -- an audit that + /// aborted on the first bad key would report nothing about the healthy candidates + /// still queued behind it. + if (e.code() != ErrorCodes::CORRUPTED_DATA) + throw; + recordLifelessKeys(NamespaceListing{{}, {{candidate.key, e.message()}}}); + continue; + } + ++report.namespace_janitor_pending; + report.namespace_janitor_pending_bytes += candidate.size; + pending_lives.insert(candidate.life_id); + FsckObject o; + o.key = candidate.key; + o.kind = ObjectKind::Blob; /// no ObjectKind names namespace-life debris; reuse Blob as the generic kind + o.cls = FsckClass::JanitorPending; + o.size = candidate.size; + o.reachable_from = {"physical life id is absent from a catalog cut taken after this listing; " + "janitor-pending, not corruption"}; + report.objects.push_back(std::move(o)); + } + report.namespace_janitor_pending_lives = pending_lives.size(); + } + + /// Every replay and late recheck below reuses the same exact catalog row and physical life. The + /// primary walk also retains its checkpoint sample; a late recheck exact-reads `_ckpt` again at that + /// SAME life so a concurrent same-life drop/repoint is visible without ever accepting a rebirth. + FsckRecoveryAuthorities recovery_authorities; + recovery_authorities.reserve(walk_lives.size()); + + for (const FsckWalkLife & walk_life : walk_lives) + { + const NamespaceLifeId & life = walk_life.life; + const RootNamespace & ns = life.ns; + const String & ns_str = ns.string(); + /// RECORD AND CONTINUE, NEVER WEDGE. Everything below is per-namespace, and every one of these + /// steps can raise `CORRUPTED_DATA` on a namespace whose stream is damaged — the replay refuses a + /// non-contiguous tail, the codecs refuse an invalid body. For RECOVERY that throw is the correct + /// fail-close; for a read-only diagnostic it is a bug, because the audit then reports NOTHING + /// about the namespaces it never reached, including the healthy ones. So one namespace's failure + /// becomes that namespace's verdict and the sweep goes on. + /// + /// `TIMEOUT_EXCEEDED` is deliberately NOT caught: the deadline is a property of the whole scan, + /// and `runFsck`'s `partial` handling owns it. + try + { + /// One materialized `_ckpt` body is part of this namespace's frozen audit authority. The + /// recovery API receives exactly these bytes; `checkRefStream` receives the same decoded + /// value, so the two legs cannot quietly choose different frontiers after a concurrent CAS. + const std::optional checkpoint_sample = readCkpt(backend, layout, life); + const std::optional checkpoint + = checkpoint_sample ? std::optional{checkpoint_sample->ckpt} : std::nullopt; + const auto [authority_it, inserted] = recovery_authorities.emplace( + ns.string(), FsckRecoveryAuthority{ + .life = life, .catalog_entry = walk_life.catalog_entry, .checkpoint = checkpoint}); + chassert(inserted); + + /// The arithmetic stream audit runs FIRST, so a holed stream gets the verdict that EXPLAINS + /// it (`chain-broken`) rather than the downstream `CORRUPTED_DATA` the replay below would + /// raise about the same hole. + checkRefStream( + backend, layout, life, walk_life.catalog_entry, checkpoint_sample, deadline, report, verdicts); + + /// This recovery's finite range comes from the original catalog row and exact `_ckpt`, never + /// from a stream listing, a self-resolved name, or an F+1 probe. + const RefTableState table = recoverRefTableDetailedFromAuthority( + backend, layout, authority_it->second.catalog_entry, authority_it->second.checkpoint).state; + for (const auto [ref_name, row] : table.getCommitted()) + { + const ManifestId id{ns, row.manifest_ref}; + const String mkey = layout.manifestKey(id); + owned_manifest_keys.insert(mkey); + const String label = ns_str + "/" + ref_name; + + const auto got = backend.get(mkey); + if (!got) + { + /// A committed ref naming a missing manifest body would be an INV-NO-DANGLE violation — + /// but the per-ref GET runs later than the namespace's ref recovery, so a stale captured + /// row plus a legitimate GC delete of a since-superseded manifest can masquerade as one, + /// and a bare GET can lag a present object. Revalidate exactly like the blob `Dangling` + /// recheck below: HEAD the exact object AND re-resolve under the original catalog row + /// plus a fresh checkpoint from its physical life. Count the dangle ONLY when the exact + /// object is HEAD-absent AND that life still names THIS exact manifest — otherwise it is + /// LIST/GET lag or a phantom stale-row, never a loss. + if (!backend.head(mkey).exists + && manifestStillReferenced(backend, layout, ns, recovery_authorities, ref_name, mkey, + deadline, record_recovery_unchecked)) + { + ++report.dangling; + FsckObject o; + o.key = mkey; + o.kind = ObjectKind::Blob; /// manifests have no ObjectKind; reuse Blob as the generic kind + o.size = 0; + o.cls = FsckClass::Dangling; + o.reachable_from = {label}; + report.objects.push_back(std::move(o)); + } + /// A present object is GET lag. A row not named by the original-life authority is a + /// stale-walk artifact, not a dangle; its original owner cannot contribute blobs. + ++refs_walked; + continue; + } + + PartManifest body = decodePartManifest(openObject(FormatId::PartManifest, got->bytes)); + if (!refMatchesBody(id.ref, body) || !manifestNamespaceMatches(id.root_namespace, body)) + { + ++report.dangling; + FsckObject o; + o.key = mkey; + o.kind = ObjectKind::Blob; + o.size = got->bytes.size(); + o.cls = FsckClass::Dangling; + o.reachable_from = {label}; + report.objects.push_back(std::move(o)); + ++refs_walked; + continue; + } + + for (const ManifestEntry & e : body.entries) + { + if (e.placement != EntryPlacement::Blob) + continue; + const String bkey = layout.blobKey(e.ref); + reachable_blobs.insert(bkey); + ++report.total_blob_refs; + report.referenced_logical_bytes += e.blob_size; + blob_labels[bkey].push_back(label); + } + + ++refs_walked; + checkDeadline(deadline, "walking refs"); + if (on_progress && refs_walked % 64 == 0) + on_progress("walking refs", reachable_blobs.size(), refs_walked); + } + } + catch (const Exception & e) + { + if (e.code() == ErrorCodes::TIMEOUT_EXCEEDED) + throw; + verdicts.recordUnchecked(report, ns, + layout.namespaceStreamPrefix(life), + "fsck could not examine this namespace: " + e.message()); + } + } + report.distinct_blobs = reachable_blobs.size(); + + /// Scoped mode skips the GLOBAL physical classification below: it is meaningless under a + /// filter (blobs owned by other namespaces would read as unreachable) and would cost a + /// pool-wide LIST for what should be O(scoped refs). + if (namespace_prefix.empty()) + { + /// Physical listing: blobs + manifest bodies. The per-hash `.meta` descriptor sibling + /// (`blobMetaKey(id) == blobKey(id) + ".meta"`) lives under the SAME + /// `blobsPrefix()` as the body, so partition the raw LIST into bodies vs `.meta` objects up + /// front — a `.meta` key must never be classified as a content body (it would otherwise be + /// misread as an unreferenced blob and fall into the dangling/pending/unaccounted pipeline + /// below), and a body must never be misread as a `.meta`. + std::unordered_map present_all; + listAll(backend, layout.blobsPrefix(), present_all, on_progress, deadline, "listing blobs"); + std::unordered_map present_blobs; + std::unordered_set present_meta_hashes; + present_blobs.reserve(present_all.size()); + for (const auto & [key, sz] : present_all) + { + if (key.ends_with(".meta")) + { + if (const std::optional ref = layout.parseBlobKey(key)) + present_meta_hashes.insert(*ref); + /// else: foreign key shape under blobs/ — not ours to pair + } + else + present_blobs.emplace(key, sz); + } + for (const auto & [_, sz] : present_blobs) + report.physical_bytes += sz; + + /// Reachable blobs must be present (HEAD-confirm against LIST lag before declaring loss). + for (const String & bkey : reachable_blobs) + { + auto it = present_blobs.find(bkey); + bool exists = it != present_blobs.end(); + uint64_t size = exists ? it->second : 0; + if (!exists) + { + const HeadResult h = backend.head(bkey); + if (h.exists) + { + exists = true; + size = h.size; + report.physical_bytes += h.size; + } + } + + const auto lit = blob_labels.find(bkey); + if (!exists) + { + /// Before declaring a loss, re-resolve the referencing refs from the original audit + /// authority. A later rebirth must not replace the old owner while this verdict is being + /// decided. + const bool still_referenced = blobStillReferenced(store, layout, recovery_authorities, bkey, + lit != blob_labels.end() ? lit->second : std::vector{}, deadline, + record_recovery_unchecked); + if (!still_referenced) + continue; /// stale-walk artifact: neither reachable nor dangling — skip entirely + } + + if (exists) + ++report.reachable; + else + ++report.dangling; + if (detail || !exists) + { + FsckObject o; + o.key = bkey; + o.kind = ObjectKind::Blob; + o.size = size; + o.cls = exists ? FsckClass::Reachable : FsckClass::Dangling; + if (detail && lit != blob_labels.end()) + o.reachable_from = lit->second; + report.objects.push_back(std::move(o)); + } + } + + /// Present-but-unreferenced blobs: classify through the GC pipeline view instead of one + /// suspicious "unreachable" lump (the multi-stage graduation keeps a nonzero churning + /// set here on ANY active pool, and beta testers read "unreachable" as a leak). The GC state is + /// read for LABELING ONLY — reachability above never consults it. + std::unordered_map retired_by_hash; + std::unordered_set unref_hashes; + std::unordered_set in_run_hashes; + /// The NON-SENTINEL source edges the snapshot still holds on each unreferenced blob, collected in + /// `detail` mode only. `in_run_hashes` alone answers "does GC still see this blob at all"; the + /// stale-edge cross-check below needs the edge IDENTITIES so it can ask whether their source + /// manifests still exist. Sentinel rows (`source_id == 0` — `kZeroMarker`/`kCondemned`) are not + /// edges and are excluded. + std::unordered_map, BlobRefHash> unref_edge_sources; + bool have_gc_state = false; + + for (const auto & [bkey, sz] : present_blobs) + if (!reachable_blobs.contains(bkey)) + { + if (const std::optional ref = layout.parseBlobKey(bkey)) + unref_hashes.insert(*ref); + } + + if (!unref_hashes.empty()) + { + if (const auto state_got = backend.get(layout.gcStateKey())) + { + have_gc_state = true; + const GcState gc_state = decodeGcState(state_got->bytes); + /// The adopted fold seal names the snapshot runs; resolution is by ref, never by key + /// construction. Every row whose hash is in our candidate set marks "known to GC" — + /// edges still counted (drop unfolded), an explicit zero-marker mid-pipeline, or a + /// `kCondemned` sentinel row that carries the condemned state (retired-in-snapshot): + /// the `kCondemned` rows feed `retired_by_hash` (the `PendingGc` classification) in the + /// SAME pass, replacing the removed `retired_refs`/`decodeRetiredSet` loop. + /// + /// These sets are keyed by the full `BlobRef`, not a narrowed digest. The run's own + /// algorithm-prefixed key is parsed by `SourceEdgeKeyCodec` and compared directly with + /// the full identity parsed from the listed blob key. This is required for mixed-algorithm + /// pools: a 64-hex digest must not be truncated or compared as though it used the pool's + /// local write algorithm, or its true GC state could be hidden as `Unaccounted`. + if (const auto seal_got = backend.get(layout.foldSealKey(gc_state.snap_generation, gc_state.snap_attempt))) + { + uint64_t rows = 0; + for (const RunRef & run : decodeFoldSeal(seal_got->bytes, gc_state.snap_generation).blob_target_runs) + { + checkDeadline(deadline, "reading gc snapshot runs"); + /// Typed open: the source-edge run reader goes through openSourceEdgeRun (the NDJSON + /// header gates type == cas_run + kind == source_edge). Fsck keys off the row's hash + /// (the record's own algo-prefixed key, never from pool meta). + SourceEdgeRunView reader = openSourceEdgeRun(backend, run.key); + String key; + String payload; + while (reader.next(key, payload)) + { + BlobRef ref; + UInt128 source_id; + SourceEdgeKeyCodec::parse(key, ref, source_id); // throws CORRUPTED_DATA on malformed (fail-closed) + if (unref_hashes.contains(ref)) + { + in_run_hashes.insert(ref); + if (detail && source_id != UInt128{0}) + unref_edge_sources[ref].push_back(source_id); + if (!payload.empty() && payload[0] == kCondemned) + { + const CondemnedRow row = decodeCondemnedRow(payload); + RetiredEntry e; + e.kind = ObjectKind::Blob; + e.ref = ref; + e.token = row.token; + e.size = row.size; + e.condemn_round = row.condemn_round; + e.delete_pending = row.delete_pending; + retired_by_hash.emplace(ref, std::move(e)); + } + } + if (on_progress && ++rows % 65536 == 0) + on_progress("reading gc snapshot runs", in_run_hashes.size(), rows); + } + /// Whole-file seal checksum: compare the drained run's accumulated + /// checksum to the seal's `RunRef::checksum`. Fsck is a read-only auditor — instead of + /// throwing (which would abort the whole scan on the first corrupt run), catalogue the + /// mismatch as a `CorruptedRun` finding (with the run key) and continue so the audit + /// enumerates every problem in one pass. The deletion-deriving consumers + /// (`fold`/`zeroInDegree`/`previewDeletes`) still fail closed on the same mismatch. + if (reader.accumulatedChecksum() != run.checksum) + { + ++report.corrupted_runs; + if (detail) + report.objects.push_back(FsckObject{.key = run.key, .cls = FsckClass::CorruptedRun, .reachable_from = {}}); + } + } + } + } + } + + /// STALE-EDGE cross-check. A residual `+1` whose matching `-1` never folded pins its blob at + /// in-degree 1 forever: every GC round recomputes the same nonzero in-degree and never nominates + /// the blob, so the `AwaitingGc` "expected, no action needed" label is a lie — nothing will ever + /// reclaim it. The edge names its source, so the check is to ask whether that source still exists: + /// build the set of source ids that every manifest body PRESENT in the pool would contribute, and + /// treat an edge outside that set as one whose source manifest is gone. + /// + /// COST: one LIST per namespace plus one GET per manifest body. It is therefore gated on `detail` + /// — the cheap summary path (the ca-soak fixpoint poll calls it in a loop) must not gain a single + /// extra request — and additionally on some unreferenced blob actually carrying a real edge, so a + /// pool with nothing to cross-check pays nothing. + /// + /// `stale_edge_check_available` is the fail-closed switch: a manifest body we cannot decode would + /// silently withhold its edges from the live set and turn every blob it owns into a false hard + /// finding, so one undecodable body disables the whole cross-check for this scan rather than + /// manufacture an error. The check may only ever SHRINK to silence, never invent a finding. + std::unordered_set live_source_ids; + bool stale_edge_check_available = detail && !unref_edge_sources.empty(); + if (stale_edge_check_available) + { + const NamespaceListing stale_edge_listing = store.listNamespaces(namespace_prefix); + recordLifelessKeys(stale_edge_listing); + for (const String & ns_str : stale_edge_listing.namespaces) + { + const RootNamespace ns{ns_str}; + std::unordered_map manifest_bodies; + listAll(backend, layout.manifestNamespacePrefix(ns), manifest_bodies, on_progress, deadline, + "listing manifests for the stale-edge check"); + for (const auto & [mkey, _] : manifest_bodies) + { + checkDeadline(deadline, "reading manifests for the stale-edge check"); + const std::optional id = layout.parseManifestKey(mkey); + if (!id) + continue; /// foreign/malformed key under `manifests/` — contributes no source edge + const auto got = backend.get(mkey); + if (!got) + continue; /// gone between the LIST and the GET — genuinely not a live source + try + { + const PartManifest body = decodePartManifest(openObject(FormatId::PartManifest, got->bytes)); + for (const ManifestEntry & e : body.entries) + if (e.placement == EntryPlacement::Blob) + live_source_ids.insert(sourceEdgeId(*id, e.path)); + } + catch (...) + { + stale_edge_check_available = false; /// incomplete live set — do not accuse anyone + break; + } + } + if (!stale_edge_check_available) + break; + } + } + + for (const auto & [bkey, sz] : present_blobs) + { + if (reachable_blobs.contains(bkey)) + continue; + ++report.unreachable; + + /// A foreign/malformed key (`parseBlobKey` -> `nullopt`) falls back to the default `BlobRef{}`, + /// which cannot match a real `retired_by_hash`/`in_run_hashes` entry — it lands in the generic + /// `Unaccounted` bucket below, exactly the "debris, not ours" classification `parseBlobKey` + /// documents: foreign algorithm segments are debris, not pool objects. + const BlobRef hash = layout.parseBlobKey(bkey).value_or(BlobRef{}); + + FsckClass cls = FsckClass::Unaccounted; + String note; + if (const auto rit = retired_by_hash.find(hash); rit != retired_by_hash.end() + && backend.head(bkey).token == rit->second.token) + { + /// The PRESENT incarnation is the condemned one — deletion is scheduled. A token + /// mismatch means the listed entry belongs to a displaced older incarnation and says + /// nothing about this object; fall through to the snapshot check. + cls = FsckClass::PendingGc; + note = rit->second.delete_pending + ? "delete_pending: exact-token delete executes next GC round" + : "condemned at round " + std::to_string(rit->second.condemn_round) + + "; graduates once every writer acks past it (expected)"; + } + else if (in_run_hashes.contains(hash)) + { + /// `in_run_hashes` only says the GC snapshot still holds SOMETHING for this blob. Split on + /// whether any of it is still actionable. One edge whose source manifest is PRESENT keeps + /// the ordinary `AwaitingGc` verdict — that manifest's removal still folds its `-1`, and an + /// unowned-but-present manifest is reclaimed by the orphan sweep, so the blob is genuinely + /// mid-pipeline. When EVERY edge names a manifest that no longer exists, no `-1` is left to + /// fold: the in-degree is pinned above zero for good and only a rebuild can clear it. + uint64_t stale_edges = 0; + bool all_edges_stale = false; + if (const auto eit = unref_edge_sources.find(hash); + stale_edge_check_available && eit != unref_edge_sources.end() && !eit->second.empty()) + { + for (const UInt128 & source_id : eit->second) + if (!live_source_ids.contains(source_id)) + ++stale_edges; + all_edges_stale = stale_edges == eit->second.size(); + } + + if (all_edges_stale) + { + cls = FsckClass::StaleEdge; + note = "all " + std::to_string(stale_edges) + " source edges name manifests that no longer " + "exist — unreclaimable by the incremental GC (needs `cas-gc-rebuild`); NOT expected, investigate"; + } + else + { + cls = FsckClass::AwaitingGc; + note = "edges still in the GC snapshot; the drop has not folded yet (expected)"; + } + } + else if (!have_gc_state) + { + cls = FsckClass::AwaitingGc; + note = "GC has not run on this pool yet"; + } + else + { + note = "not in the current GC view — transient for a fast create+drop between rounds; " + "PERSISTENT occurrences violate INV-2 (reachability-before-content), investigate"; + } + + switch (cls) + { + case FsckClass::PendingGc: ++report.pending_gc; break; + case FsckClass::AwaitingGc: ++report.awaiting_gc; break; + case FsckClass::StaleEdge: ++report.stale_edge; break; + default: ++report.unaccounted; break; + } + if (detail) + { + FsckObject o; + o.key = bkey; + o.kind = ObjectKind::Blob; + o.size = sz; + o.cls = cls; + o.reachable_from = {std::move(note)}; + report.objects.push_back(std::move(o)); + } + } + + /// Meta <-> body pairing: a `.meta` object with no + /// body is an INV-META-BODY violation (the fixed meta/body lifecycle never leaves a meta + /// orphaned of its body) — a real ERROR, distinct from `dangling` (which is reachability-driven). + /// A body with no `.meta` is a benign not-yet-adopted (or interrupted-birth) artifact, NOT a dangle + /// — it still classifies through the ordinary present-but-unreferenced pipeline above. + std::unordered_set present_body_hashes; + present_body_hashes.reserve(present_blobs.size()); + for (const auto & [bkey, _] : present_blobs) + if (const std::optional ref = layout.parseBlobKey(bkey)) + present_body_hashes.insert(*ref); + /// else: foreign key shape under blobs/ — not ours to pair + for (const BlobRef & hash : present_meta_hashes) + if (!present_body_hashes.contains(hash)) + ++report.meta_without_body; + for (const BlobRef & hash : present_body_hashes) + if (!present_meta_hashes.contains(hash)) + ++report.body_without_meta; + } + else + { + /// Scoped mode: dangling-only for the selected namespaces. Each blob named by a scoped ref + /// is HEAD-verified (O(scoped refs), no pool-wide LIST); the unreachable/pending pipeline + /// classification needs the whole pool and is intentionally skipped. + for (const String & bkey : reachable_blobs) + { + checkDeadline(deadline, "head-checking scoped blobs"); + const HeadResult h = backend.head(bkey); + const auto lit = blob_labels.find(bkey); + bool exists = h.exists; + if (!exists) + { + /// Use the same HEAD-absent re-resolve as the global-mode loop above. + const bool still_referenced = blobStillReferenced(store, layout, recovery_authorities, bkey, + lit != blob_labels.end() ? lit->second : std::vector{}, deadline, + record_recovery_unchecked); + if (!still_referenced) + continue; /// stale-walk artifact — neither reachable nor dangling + } + if (exists) + { + ++report.reachable; + report.physical_bytes += h.size; + } + else + ++report.dangling; + if (detail || !exists) + { + FsckObject o; + o.key = bkey; + o.kind = ObjectKind::Blob; + o.size = exists ? h.size : 0; + o.cls = exists ? FsckClass::Reachable : FsckClass::Dangling; + if (detail && lit != blob_labels.end()) + o.reachable_from = lit->second; + report.objects.push_back(std::move(o)); + } + } + } + + /// Pre-precommit manifest debris: a `cas/manifests/` body with no committed owner. An ELIGIBLE prefix's + /// orphan is reclaimable debris => INFO (Unreachable); a non-eligible (in-flight) one is also info, + /// never an error. The owner-visible missing-body case is the error above. + const NamespaceListing manifest_debris_listing = store.listNamespaces(namespace_prefix); + recordLifelessKeys(manifest_debris_listing); + for (const String & ns_str : manifest_debris_listing.namespaces) + { + const RootNamespace ns{ns_str}; + const String manifests_prefix = layout.manifestNamespacePrefix(ns); + std::unordered_map manifest_bodies; + listAll(backend, manifests_prefix, manifest_bodies, on_progress, deadline, "listing manifests"); + for (const auto & [mkey, sz] : manifest_bodies) + { + if (owned_manifest_keys.contains(mkey)) + continue; /// owned by a committed ref — accounted above + ++report.unreachable; + if (detail) + { + BuildPrefix prefix; + const bool parsed = parseBuildPrefix(layout, mkey, prefix); + FsckObject o; + o.key = mkey; + o.kind = ObjectKind::Blob; + o.size = sz; + o.cls = FsckClass::Unreachable; + if (parsed && prefixEligible(store, ns, prefix)) + o.reachable_from = {"reclaimable-pre-precommit"}; + else + o.reachable_from = {"in-flight-pre-precommit"}; + report.objects.push_back(std::move(o)); + } + } + } + +} + +} + +FsckReport runFsck(Pool & store, bool detail, FsckProgress on_progress, + std::optional deadline, + bool partial_on_deadline, const String & namespace_prefix) +{ + FsckReport report; + try + { + runFsckImpl(store, detail, on_progress, deadline, namespace_prefix, report); + } + catch (const Exception & e) + { + if (!partial_on_deadline || e.code() != ErrorCodes::TIMEOUT_EXCEEDED) + throw; + report.partial = true; + report.partial_reason = e.message(); + } + return report; +} + +String formatFsckSummary(const FsckReport & report) +{ + /// Field order is load-bearing for humans only; every consumer parses `key=value` tokens. `partial` + /// and its free-text reason go LAST because the reason can contain spaces and quotes, so a parser + /// splitting on whitespace has to trim from the tail (see the harness's `parse_fsck_summary`). + /// `std::ostringstream`, not a ClickHouse write buffer: this reproduces the exact `std::cout` + /// formatting the line has always had, `dedup_ratio`'s default double precision included, so + /// extracting the line from the command changes nothing a parser can observe. + std::ostringstream out; // STYLE_CHECK_ALLOW_STD_STRING_STREAM + out << "reachable=" << report.reachable + << " dangling=" << report.dangling + << " unreachable=" << report.unreachable + << " pending_gc=" << report.pending_gc + << " awaiting_gc=" << report.awaiting_gc + << " unaccounted=" << report.unaccounted + << " stale_edge=" << report.stale_edge + << " corrupted_runs=" << report.corrupted_runs + << " chain_broken=" << report.chain_broken + << " lifeless_keys=" << report.lifeless_keys + << " janitor_pending=" << report.namespace_janitor_pending + << " janitor_pending_bytes=" << report.namespace_janitor_pending_bytes + << " janitor_pending_lives=" << report.namespace_janitor_pending_lives + << " unchecked=" << report.unchecked + << " ref_records_walked=" << report.ref_records_walked + << " physical_bytes=" << report.physical_bytes + << " referenced_logical_bytes=" << report.referenced_logical_bytes + << " distinct_blobs=" << report.distinct_blobs + << " total_blob_refs=" << report.total_blob_refs + << " dedup_ratio=" << report.dedupRatio(); + if (report.partial) + out << " partial=1 reason='" << report.partial_reason << "'"; + return out.str(); +} + +} diff --git a/src/Disks/DiskObjectStorage/MetadataStorages/ContentAddressed/Tools/CasFsck.h b/src/Disks/DiskObjectStorage/MetadataStorages/ContentAddressed/Tools/CasFsck.h new file mode 100644 index 000000000000..620904f4c37f --- /dev/null +++ b/src/Disks/DiskObjectStorage/MetadataStorages/ContentAddressed/Tools/CasFsck.h @@ -0,0 +1,285 @@ +#pragma once + +#include +#include +#include + +#include +#include +#include +#include +#include +#include + +namespace DB::Cas +{ + +/// Optional progress sink for `runFsck`: called periodically during the listing and reachability +/// walk so a long scan over a large/slow pool is visibly progressing (not hung). `phase` names the +/// current step; `objects`/`pages` are running counts. Default {} = no progress (existing callers). +using FsckProgress = std::function; + +/// Classification assigned to each object examined by `runFsck`. +/// +/// The reachability classes are derived only from authoritative refs and the physical object listing. +/// The GC-related classes are an additional explanation for present-but-unreferenced blobs; GC state +/// is used for labeling only and can never make a referenced object appear safe. Integrity classes are +/// hard findings: the report remains unclean when any of them is present. +enum class FsckClass : uint8_t +{ + Reachable, /// reachable from a live ref AND present in the object store + Dangling, /// reachable from a live ref but the object is MISSING — INV-NO-LOSS violation + Unreachable, /// pre-precommit manifest debris (labeled reclaimable / in-flight) + /// The GC pipeline deletes present-but-unreferenced blobs in explicit stages, so these classes + /// distinguish expected in-flight work from an object outside the GC view. They are labels only, + /// never inputs to reachability. + PendingGc, /// listed in the retired set (condemned / delete_pending) — deletion is scheduled; EXPECTED + AwaitingGc, /// edges still in the GC snapshot (drop/reclaim not folded yet) or GC never ran — EXPECTED + Unaccounted, /// absent from the whole GC view — transient for a fast create+drop between rounds; + /// PERSISTENT occurrences should be impossible (INV-2 reachability-before-content) + StaleEdge, /// every source edge the GC snapshot still holds on this blob names a manifest that no + /// longer exists anywhere in the pool, so the matching `-1` can never fold: the blob's + /// in-degree can never reach zero and the incremental GC can never reclaim it. Only a + /// full rebuild of the in-degree state can. ERROR — never an `AwaitingGc` "expected" + /// backlog, which is exactly the label that used to hide it. + CorruptedRun, /// a GC source-edge run's whole-file seal checksum (`RunRef::checksum`) disagrees with + /// the stored bytes — cataloged so the read-only audit enumerates every finding in one + /// pass; deletion-deriving consumers (`fold`, `zeroInDegree`, `previewDeletes`) still + /// fail closed on the same mismatch. ERROR + /// The two verdicts of the arithmetic ref-stream walk (spec §7). They are about a NAMESPACE, not an + /// object; the row's `key` identifies the exact log where the walk stopped or the checkpoint-named + /// snapshot base whose required triple could not be validated. + ChainBroken, /// the exact checkpoint authority is durably inconsistent: its required snapshot-base + /// triple is corrupt, or a ref-log id is absent below its confirmed frontier. Ids are + /// dense `1..T` within `(namespace, epoch)` (INV-1), so neither is a stream end. ERROR + Unchecked, /// the walk could not prove this namespace's stream EITHER WAY (an unprovable epoch + /// crossing, an undecodable body, or unstable authority/transport). Not a finding and + /// not a clean bill of health: the honest third answer, reported so nobody reads a + /// silence as a proof. + LifelessKey, /// a namespace-tree key the `Layout` parsers refuse (a malformed/non-canonical shape, + /// including the un-incarnated Stage A layout), OR a catalog incarnation that is + /// ambiguous or otherwise unreadable. Neither a current writer nor the catalog's own + /// reverse life index can produce this key's meaning, so it belongs to no namespace + /// and no per-namespace verdict can carry it. ERROR + JanitorPending,/// a COMPLETE, canonical namespace-life key (parses via the exact writer grammar, + /// nonzero 32-hex life id) whose life is simply absent from a catalog cut taken AFTER + /// the physical listing. This is the protocol-produced interval between a fenced GC + /// exact-deleting a `Removing` catalog row and the perpetual `NamespaceJanitor` + /// reaching this key on a later bounded page -- inert debris, not damage. Reported + /// as a soft finding: NOT in `kFsckHardFindings`, does not fail the report. +}; + +/// One object or integrity finding emitted in detailed mode, or emitted for every missing reachable +/// object even in summary mode. `key` identifies the physical or logical object; `size` is its listed +/// size and is zero for a missing object. `reachable_from` contains `"namespace/ref"` owners for +/// reachable and dangling objects, or a diagnostic note for other classifications. +struct FsckObject +{ + String key; + ObjectKind kind = ObjectKind::Blob; + uint64_t size = 0; /// on-disk object size (0 when dangling) + FsckClass cls = FsckClass::Reachable; + std::vector reachable_from; /// "ns/ref" labels (populated for reachable/dangling when detail) +}; + +/// Aggregate result of a read-only `runFsck` scan. +/// +/// Reachability and byte counters describe the scan's authoritative-ref view. `unreachable` is the +/// total of all present-but-unreferenced objects, including the GC pipeline classes and manifest debris, +/// and is intentionally retained as one monotone number for residual-settling monitoring. The detailed +/// `objects` list is populated according to the scan's `detail` mode. In partial mode all counters are +/// lower bounds over the portion walked before the deadline; `clean` must not be used as a claim about +/// the unvisited part of the pool. +struct FsckReport +{ + uint64_t reachable = 0; + uint64_t dangling = 0; + /// TOTAL of everything present-but-unreferenced (blob pipeline classes below + manifest debris). + /// Kept as the sum so residual-settling loops (soak) keep one monotone number to watch. + uint64_t unreachable = 0; + uint64_t pending_gc = 0; /// blobs in the retired set — deletion scheduled (expected) + uint64_t awaiting_gc = 0; /// blobs whose drop is not folded yet / GC never ran (expected) + uint64_t unaccounted = 0; /// blobs outside the GC view (transient or anomaly) + /// Blobs whose every remaining source edge names a manifest that no longer exists — permanently + /// stuck at a nonzero in-degree, unreclaimable by the incremental GC. A hard ERROR (see + /// `FsckClass::StaleEdge`). Populated only in `detail` mode: naming the live sources costs one GET + /// per manifest body, and the cheap summary path must stay request-for-request unchanged. + uint64_t stale_edge = 0; + + /// The per-hash `.meta` descriptor sibling of a blob body: + /// pairing check between the `blobs/` physical listing's `.meta` keys and its body keys. + /// ADVISORY, not a hard finding: GC deletes the body FIRST and then drops the `.meta` on a bounded, + /// error-suppressed advisory pool that runs strictly after (and may drop the op — see `CasGc`), so a + /// single raw LIST legitimately observes a body-less `.meta` mid-graduation and NO finite grace makes + /// a persistent one hard evidence. Counted and reported; excluded from `clean()`. + uint64_t meta_without_body = 0; /// a `.meta` object with no body — INV-META-BODY advisory + uint64_t body_without_meta = 0; /// a body with no `.meta` — a not-yet-adopted or interrupted-birth + /// artifact; benign, NOT a dangle + + /// GC source-edge runs whose whole-file seal checksum did not match the stored bytes. Cataloged + /// with the run key in `objects`; the audit CONTINUES — a read-only auditor + /// enumerates all problems in one pass rather than aborting on the first corrupt run. + uint64_t corrupted_runs = 0; + + /// The arithmetic ref-stream walk (spec §7). fsck reads each namespace's stream by EXACT KEY from + /// `_ckpt.checkpoint`'s successor upward — never from a listing, which may omit durable records — + /// and reports one verdict per namespace. + /// + /// `chain_broken` counts namespaces with a proven hole (see `FsckClass::ChainBroken`) and is a HARD + /// ERROR: part of `clean`, and the command exits nonzero on it. `unchecked` counts namespaces the + /// walk could not prove either way; it is COVERAGE, not a finding, so it + /// is reported and printed but does not make a report unclean — exactly like `partial`. A pool with + /// nothing wrong reads `chain_broken=0 unchecked=0`, so `unchecked` is never a resting state. + /// + /// `ref_records_walked` is how many ref-log records the walk actually read and proved, summed over + /// namespaces. It is what makes "the tail above the checkpoint was walked" observable rather than + /// inferred from the absence of a complaint. + uint64_t chain_broken = 0; + uint64_t unchecked = 0; + uint64_t ref_records_walked = 0; + + /// Keys the namespace enumeration could not attribute to any namespace, OR a catalog incarnation + /// that is ambiguous or unreadable (see `FsckClass::LifelessKey` and `Cas::NamespaceListing`). Does + /// NOT include a complete, canonical namespace-life key whose life is simply absent from the catalog + /// -- that is `namespace_janitor_pending`, counted separately and not a hard finding. Counted + /// DISTINCT by key: the scan enumerates namespaces several times and every sweep sees the same + /// offending key, so a per-sweep count would multiply one defect. A hard finding: no current writer + /// can produce this key's meaning, and an audit is where an operator finds out about it. + uint64_t lifeless_keys = 0; + + /// Canonical namespace-life keys whose life is absent from a catalog cut taken AFTER the physical + /// listing (see `FsckClass::JanitorPending`). SOFT: never in `kFsckHardFindings`, never fails the + /// report. Persistent non-convergence across authorized janitor cycles is an operational leak + /// question (`CASGCNamespaceCleanupLeaks`, the `namespace_cleanup` GC-log phase), not an integrity + /// finding this counter can answer on its own -- one snapshot cannot prove an unbounded leak. + uint64_t namespace_janitor_pending = 0; + uint64_t namespace_janitor_pending_bytes = 0; + uint64_t namespace_janitor_pending_lives = 0; /// distinct life ids counted above + + uint64_t physical_bytes = 0; + uint64_t referenced_logical_bytes = 0; + uint64_t total_blob_refs = 0; + uint64_t distinct_blobs = 0; + + /// Set when the scan hit its deadline in partial mode: counts cover only what was walked + /// before the deadline — a lower bound, not the pool truth. + bool partial = false; + String partial_reason; + + std::vector objects; + + /// Return logical blob references per distinct reachable blob, or zero when no distinct blob was seen. + double dedupRatio() const { return distinct_blobs ? double(total_blob_refs) / double(distinct_blobs) : 0.0; } + + /// Return whether the scan found no missing reachable object or hard integrity violation. Expected + /// GC backlog classes do not make a report unclean, and `meta_without_body` is advisory (see its + /// field: GC's body-then-meta delete ordering makes a body-less `.meta` a legitimate transient with + /// no finite hard horizon); a partial report only covers the visited subset. `stale_edge` is a hard + /// finding, but it is only ever nonzero in `detail` mode — a clean summary report says nothing about + /// stale edges, exactly as a partial report says nothing about the unvisited part of the pool. + /// `chain_broken` is a hard finding in every mode. `unchecked` deliberately is NOT one: it says the + /// walk proved nothing about those namespaces, which is a statement about COVERAGE, and folding it + /// in here would make "cannot prove" indistinguishable from "found broken". + /// Defined out-of-line below, over `kFsckHardFindings`, so that "a term of `clean`" and "a row of + /// that list" are the same thing rather than two lists that can drift. + bool clean() const; +}; + +/// ONE hard finding: the name every surface renders it under, and the counter it reads. +struct FsckHardFinding +{ + std::string_view name; + uint64_t FsckReport::* value; +}; + +/// THE HARD FINDINGS, and the single authority on what they are. `FsckReport::clean` is computed from +/// this list, so adding a term means adding a row here. +/// +/// The name is the one the text summary line and the SQL result column both use, which is what lets a +/// test check a rendering surface by iterating this list instead of restating its contents. +/// The SIZE IS DEDUCED, deliberately. A fixed `std::array` rejects an added row with +/// an "excess elements in ..." diagnostic -- which stops the build, but its text carries none of the +/// guidance the assert below does, so the author learns only that they miscounted. (Which noun that +/// diagnostic uses depends on the brace form, so it is not quoted here.) Deduced, an added row compiles +/// and the assert is what speaks. +inline constexpr std::array kFsckHardFindings{ + FsckHardFinding{"dangling", &FsckReport::dangling}, + FsckHardFinding{"corrupted_runs", &FsckReport::corrupted_runs}, + FsckHardFinding{"stale_edge", &FsckReport::stale_edge}, + FsckHardFinding{"chain_broken", &FsckReport::chain_broken}, + FsckHardFinding{"lifeless_keys", &FsckReport::lifeless_keys}, +}; + +/// TRIPWIRE. A hard finding has to reach three CODE surfaces, and each has been forgotten at least once: +/// the text summary line (`formatFsckSummary`), `CommandFsck::executeImpl`'s nonzero-exit set, and the +/// SQL result row (`contentAddressedFsckColumns` + `appendContentAddressedFsckRow`). It has happened +/// repeatedly, on more than one occasion and to more than one term, each time with the rule written down +/// in prose and each time the prose not holding. (No count is given: the records that document those +/// episodes do not support one number, and a tally nobody can reconstruct is the same defect as the rest.) +/// +/// ONE ROW OF THIS LIST IS DELIBERATELY NOT IN THE EXIT SET, so the "three surfaces" rule has a named +/// exception rather than a silent violation: `stale_edge` is nonzero only under `--detail`, and +/// `CommandFsck::executeImpl` prints it as a `note:` and never throws. What licenses that is the pair -- +/// a documented reason AND a compensating gate elsewhere (`stale_edge_verdict` in +/// `utils/ca-soak/soak/fsck.py`, asserted by the soak checkpoint in `soak/run.py`, which fails closed +/// when the key is absent). A new finding may take the same exception only WITH both halves; without +/// them it belongs in the exit set. +/// +/// WHAT THIS ASSERT CHECKS, precisely: that the number of hard findings still equals the number written +/// here. Nothing more. It does NOT check that any surface renders them -- it cannot see the renderers, +/// which is the whole reason it lives with the struct: this header is included by the summary formatter, +/// by `programs/disks/CommandFsck.cpp`, and by `src/Interpreters/InterpreterSystemQuery.cpp`, so changing +/// the list breaks the build in every TU that owes an update, including the two no unit test can reach. +/// +/// The summary line is checked for real, by a test that iterates the list +/// (`CasFsckSummary.EveryHardFindingAppearsOnTheSummaryLine`). The exit set and the SQL row are NOT -- +/// for those, this assert plus the list below it is the whole of the mechanism, so bumping the number +/// without visiting them defeats it. Bump it only after all three are done. +/// +/// AND IT REACHES NO PROSE. The rule is also restated in `docs/superpowers/cas/AGENTS.md` +/// and in the soak harness's comments and messages; those restatements have gone stale before -- more +/// than once, about the exit set -- and nothing here can break a build over them. (No count is given, +/// for the same reason the paragraph above gives none: nobody keeping a tally of restatements can +/// promise its own count will not go stale next.) They are a fourth surface, unfenced by construction. +static_assert(kFsckHardFindings.size() == 5, + "A hard finding was added to or removed from `kFsckHardFindings`, which is `FsckReport::clean`. " + "Before updating this count, render it in ALL THREE code surfaces: `formatFsckSummary`'s line, " + "`CommandFsck::executeImpl`'s nonzero-exit set, and `contentAddressedFsckColumns` + " + "`appendContentAddressedFsckRow`. Two of the three have no test that can fail for you -- the " + "comment above this assert says which. A finding may be left out of the exit set only the way " + "`stale_edge` is: with a documented reason AND a compensating soak assert."); + +inline bool FsckReport::clean() const +{ + for (const FsckHardFinding & finding : kFsckHardFindings) + if (this->*finding.value != 0) + return false; + return true; +} + +/// Independently recompute reachability from authoritative refs (never from GC state or snapshots) and +/// diff it against a raw object listing. The operation is read-only; `detail` populates per-object rows. +/// `deadline`, if set, bounds the WHOLE scan: it is checked between list pages and reachability +/// refs, throwing `TIMEOUT_EXCEEDED` if exceeded (a slow-but-progressing scan surfaces a clear +/// error instead of an opaque hang) — unless `partial_on_deadline` is set, in which case the +/// accumulated lower-bound counts are returned instead, flagged via `FsckReport::partial`. A single +/// LIST page stuck in S3-client retries is bounded separately by the disk's S3 retry/timeout +/// settings, not here. `namespace_prefix`, if non-empty, scopes the scan to namespaces with this +/// prefix and skips the pool-wide unreachable classification (dangling-only mode). +FsckReport runFsck(Pool & store, bool detail, FsckProgress on_progress = {}, + std::optional deadline = {}, + bool partial_on_deadline = false, const String & namespace_prefix = {}); + +/// Render the single machine-parseable summary line (no trailing newline). This is the ONLY view of a +/// report most consumers ever get -- the soak harness parses it, CI greps it, an operator reads it -- so +/// it lives here, next to the report and under test, rather than inline in the command where nothing +/// could reach it. Every term of `FsckReport::clean` MUST appear: a hard finding the line omits is a +/// finding no run will ever report, which is how `corrupted_runs` stayed invisible from the day it was +/// first counted. That requirement is CHECKED for this surface, not merely stated: +/// `CasFsckSummary.EveryHardFindingAppearsOnTheSummaryLine` iterates `kFsckHardFindings` and looks for +/// each name in the line, so a term added to the list and not rendered here fails that test. Zeros are +/// printed, never omitted: "absent" and "zero" are different facts, and consumers (e.g. the harness's +/// `stale_edge_verdict`) fail closed on absence by design. +String formatFsckSummary(const FsckReport & report); + +} diff --git a/src/Disks/DiskObjectStorage/MetadataStorages/ContentAddressed/Tools/CasInspect.cpp b/src/Disks/DiskObjectStorage/MetadataStorages/ContentAddressed/Tools/CasInspect.cpp new file mode 100644 index 000000000000..1dbf597e8a9b --- /dev/null +++ b/src/Disks/DiskObjectStorage/MetadataStorages/ContentAddressed/Tools/CasInspect.cpp @@ -0,0 +1,638 @@ +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace DB +{ +namespace ErrorCodes +{ + extern const int CORRUPTED_DATA; +} +} + +namespace DB::Cas +{ + +namespace +{ + +/// Escapes `s` as a JSON string LITERAL (including the surrounding quotes). Handles the standard +/// two-char escapes plus a `\uXXXX` fallback for any other control byte; everything else (including +/// raw multi-byte UTF-8) passes through unchanged. This is a debug/inspection rendering, not a wire +/// format, so it deliberately does not attempt full Unicode validation. +String jsonEscape(std::string_view s) +{ + String out; + out.reserve(s.size() + 2); + out += '"'; + for (unsigned char c : s) + { + switch (c) + { + case '"': out += "\\\""; break; + case '\\': out += "\\\\"; break; + case '\b': out += "\\b"; break; + case '\f': out += "\\f"; break; + case '\n': out += "\\n"; break; + case '\r': out += "\\r"; break; + case '\t': out += "\\t"; break; + default: + if (c < 0x20) + out += fmt::format("\\u{:04x}", c); + else + out += static_cast(c); + } + } + out += '"'; + return out; +} + +/// u128 fields (hashes, ids, tokens-as-u128) render as a lowercase-hex JSON string, matching +/// `u128ToHex` — never as a nested {high,low} object or a decimal number. +String jsonHex(const UInt128 & v) { return jsonEscape(u128ToHex(v)); } +String jsonUInt(uint64_t v) { return std::to_string(v); } +String jsonBool(bool b) { return b ? "true" : "false"; } + +/// A minimal JSON object builder: each `add` takes a key and an already-rendered JSON fragment +/// (a quoted string, a number, `true`/`false`/`null`, or a nested `{...}`/`[...]`) and joins them +/// with commas. No pretty-printing — this is a debug/inspection tool, not a wire format. +class JsonObj +{ +public: + JsonObj & add(std::string_view key, const String & raw_value) + { + if (!first) + out += ","; + first = false; + out += jsonEscape(key); + out += ":"; + out += raw_value; + return *this; + } + + String str() const { return "{" + out + "}"; } + +private: + String out; + bool first = true; +}; + +String jsonArray(const std::vector & items) +{ + String out = "["; + for (size_t i = 0; i < items.size(); ++i) + { + if (i) + out += ","; + out += items[i]; + } + out += "]"; + return out; +} + +String renderManifestRef(const ManifestRef & r) +{ + return JsonObj() + .add("writer_epoch", jsonUInt(r.writer_epoch)) + .add("build_sequence", jsonUInt(r.build_sequence)) + .add("manifest_ordinal", jsonUInt(r.manifest_ordinal)) + .str(); +} + +/// Snapshot and log ref objects use `RefTxnId` values with `writer_epoch` and `ref_sequence` fields. +/// `renderRefTxnIdObj` renders those raw numeric fields rather than the canonical hex form, which +/// rejects a zero field, so inspection can dump any object, including a malformed one, without +/// failing while rendering its identifiers. +String renderRefTxnIdObj(const RefTxnId & id) +{ + return JsonObj() + .add("writer_epoch", jsonUInt(id.writer_epoch)) + .add("ref_sequence", jsonUInt(id.ref_sequence)) + .str(); +} + +String refOwnerKindName(RefOwnerKind k) +{ + switch (k) + { + case RefOwnerKind::Committed: return "Committed"; + case RefOwnerKind::Precommit: return "Precommit"; + } + return "Unknown"; +} + +String renderRefOwnerBinding(const RefOwnerBinding & b) +{ + return JsonObj() + .add("kind", jsonEscape(refOwnerKindName(b.kind))) + .add("ref_name", jsonEscape(b.ref_name)) + .add("manifest_ref", renderManifestRef(b.manifest_ref)) + .str(); +} + +String renderRefCommittedRow(const RefCommittedRow & r) +{ + return JsonObj() + .add("ref_name", jsonEscape(r.ref_name)) + .add("manifest_ref", renderManifestRef(r.manifest_ref)) + .add("published_at_ms", jsonUInt(r.published_at_ms)) + .str(); +} + +String renderRefTableSnapshot(const RefTableSnapshot & s) +{ + std::vector committed; + committed.reserve(s.committed.size()); + for (const auto & row : s.committed) + committed.push_back(renderRefCommittedRow(row)); + + std::vector precommits; + precommits.reserve(s.precommits.size()); + for (const auto & b : s.precommits) + precommits.push_back(renderRefOwnerBinding(b)); + + return JsonObj() + .add("object", jsonEscape("ref_snapshot")) + .add("namespace", jsonEscape(s.ns)) + .add("snapshot_id", renderRefTxnIdObj(s.snapshot_id)) + .add("committed", jsonArray(committed)) + .add("precommits", jsonArray(precommits)) + .str(); +} + +/// The namespace's checkpoint (spec INV-4). Every field is optional and each absence means something +/// different an operator needs to see: no `life_epoch` means no writer that knew this namespace's +/// genesis epoch has written here yet, no `committed_through` means the life has no committed +/// transaction, no `checkpoint_snapshot_id` means recovery has no snapshot base, and no +/// `last_epoch_seal` means no epoch of this namespace has been closed. They are rendered as explicit +/// `null`s rather than omitted keys so all four cases are visible. +/// `ns` comes from the KEY -- unlike the log and snapshot objects, a `_ckpt` body does not name its +/// namespace, so there is no key-to-body binding to cross-check here. +String renderRefCkpt(const RootNamespace & ns, const RefCkpt & c) +{ + return JsonObj() + .add("object", jsonEscape("ref_ckpt")) + .add("namespace", jsonEscape(ns.string())) + .add("life_epoch", c.life_epoch ? jsonUInt(*c.life_epoch) : "null") + .add("committed_through", c.committed_through ? renderRefTxnIdObj(*c.committed_through) : "null") + .add("checkpoint_snapshot_id", + c.checkpoint_snapshot_id ? renderRefTxnIdObj(*c.checkpoint_snapshot_id) : "null") + .add("last_epoch_seal", c.last_epoch_seal ? renderRefTxnIdObj(*c.last_epoch_seal) : "null") + .str(); +} + +String refOpKindName(RefOpKind k) +{ + switch (k) + { + case RefOpKind::NamespaceBirth: return "NamespaceBirth"; + case RefOpKind::OwnerTransition: return "OwnerTransition"; + case RefOpKind::SetPublishedAt: return "SetPublishedAt"; + case RefOpKind::RemoveNamespace: return "RemoveNamespace"; + case RefOpKind::EpochSeal: return "EpochSeal"; + } + return "Unknown"; +} + +String renderRefOp(const RefOp & op) +{ + return JsonObj() + .add("kind", jsonEscape(refOpKindName(op.kind))) + .add("old_binding", op.old_binding ? renderRefOwnerBinding(*op.old_binding) : "null") + .add("new_binding", op.new_binding ? renderRefOwnerBinding(*op.new_binding) : "null") + .add("ref_name", jsonEscape(op.ref_name)) + .add("expected_manifest_ref", renderManifestRef(op.expected_manifest_ref)) + .add("published_at_ms", jsonUInt(op.published_at_ms)) + .str(); +} + +String renderRefLogTxn(const RefLogTxn & t) +{ + std::vector ops; + ops.reserve(t.ops.size()); + for (const auto & op : t.ops) + ops.push_back(renderRefOp(op)); + + return JsonObj() + .add("object", jsonEscape("ref_log")) + .add("namespace", jsonEscape(t.ns)) + .add("txn_id", renderRefTxnIdObj(t.txn_id)) + .add("ops", jsonArray(ops)) + .add("prev_epoch_seal", t.prev_epoch_seal ? renderRefTxnIdObj(*t.prev_epoch_seal) : "null") + .str(); +} + +String placementName(EntryPlacement p) +{ + switch (p) + { + case EntryPlacement::Inline: return "Inline"; + case EntryPlacement::Blob: return "Blob"; + } + return "Unknown"; +} + +/// `inline_bytes` renders as its LENGTH only, not its content — an inline file's bytes are payload +/// data, not part-manifest identity, and may be arbitrarily large / non-UTF8. +String renderManifestEntry(const ManifestEntry & e) +{ + /// Render `blobIdOf(e.ref)` (":"). The algorithm must remain part of the + /// rendered identity: a bare digest is ambiguous in a pool containing algorithms with different + /// digest widths, and each entry's own `ref.algo` determines its width. + return JsonObj() + .add("path", jsonEscape(e.path)) + .add("placement", jsonEscape(placementName(e.placement))) + .add("blob", jsonEscape(blobIdOf(e.ref))) + .add("blob_size", jsonUInt(e.blob_size)) + .add("inline_bytes_size", jsonUInt(e.inline_bytes.size())) + .str(); +} + +String renderPartManifest(const PartManifest & m) +{ + std::vector entries; + entries.reserve(m.entries.size()); + for (const auto & e : m.entries) + entries.push_back(renderManifestEntry(e)); + + return JsonObj() + .add("ref", renderManifestRef(m.ref)) + .add("root_namespace_id", jsonEscape(m.root_namespace_id.string())) + .add("payload_digest", jsonHex(m.payload_digest)) + .add("entries", jsonArray(entries)) + .str(); +} + +String renderMountLease(const MountLease & m) +{ + return JsonObj() + .add("server_uuid", jsonHex(m.server_uuid)) + .add("writer_epoch", jsonUInt(m.writer_epoch)) + .add("hostname", jsonEscape(m.hostname)) + .add("pid", jsonUInt(m.pid)) + .add("started_at_ms", jsonUInt(m.started_at_ms)) + .add("seq", jsonUInt(m.seq)) + .add("expires_at_ms", jsonUInt(m.expires_at_ms)) + .add("min_active", jsonUInt(m.min_active)) + .add("gc_fenced", jsonBool(m.gc_fenced)) + .str(); +} + +String renderGcLease(const GcLease & l) +{ + return JsonObj() + .add("owner", jsonHex(l.owner)) + .add("seq", jsonUInt(l.seq)) + .str(); +} + +String renderGcState(const GcState & s) +{ + return JsonObj() + .add("round", jsonUInt(s.round)) + .add("gc_shards", jsonUInt(s.gc_shards)) + .add("snap_generation", jsonUInt(s.snap_generation)) + .add("snap_pruned_through", jsonUInt(s.snap_pruned_through)) + .add("snap_attempt", jsonUInt(s.snap_attempt)) + .add("manifest_sweep_cursor", jsonEscape(s.manifest_sweep_cursor)) + .add("lease", renderGcLease(s.lease)) + .str(); +} + +String tokenTypeName(TokenType t) +{ + switch (t) + { + case TokenType::ETag: return "ETag"; + case TokenType::Generation: return "Generation"; + case TokenType::Emulated: return "Emulated"; + } + return "Unknown"; +} + +/// `Token::value` is an opaque backend-native string (e.g. an S3 ETag) — NOT a 128-bit hash — so it +/// renders verbatim (escaped), not hex-converted; `type` names which backend family minted it. +String renderToken(const Token & t) +{ + return JsonObj() + .add("value", jsonEscape(t.value)) + .add("type", jsonEscape(tokenTypeName(t.type))) + .str(); +} + +String objectKindName(ObjectKind k) +{ + switch (k) + { + case ObjectKind::Blob: return "Blob"; + } + return "Unknown"; +} + +String renderRunRef(const RunRef & r) +{ + return JsonObj() + .add("key", jsonEscape(r.key)) + .add("checksum", jsonHex(r.checksum)) + .add("shard", jsonUInt(r.shard)) + .add("generation", jsonUInt(r.generation)) + .str(); +} + +String renderRefCoverage(const RefCoverage & c) +{ + return JsonObj() + .add("classification", jsonUInt(c.classification)) + .add("last_folded_ref_id", renderRefTxnIdObj(c.last_folded_ref_id)) + .str(); +} + +String renderFoldSeal(const CasFoldSeal & seal) +{ + JsonObj ref_lives; + for (const auto & [life_id, state] : seal.ref_lives) + ref_lives.add(renderIncarnation(life_id), JsonObj() + .add("coverage", renderRefCoverage(state.coverage)) + .add("cleanup_evidence", state.cleanup_evidence + ? JsonObj().add("remove_txn_id", renderRefTxnIdObj(state.cleanup_evidence->remove_txn_id)).str() + : "null") + .str()); + + std::vector blob_target_runs; + blob_target_runs.reserve(seal.blob_target_runs.size()); + for (const auto & r : seal.blob_target_runs) + blob_target_runs.push_back(renderRunRef(r)); + + /// A fold seal carries per-GC-shard totals for `kCondemned` rows in its source runs. Render the + /// summary from the seal itself; the older separate retired-reference object is no longer part + /// of the current layout. + JsonObj condemned_summary; + for (const auto & [shard, cs] : seal.condemned_summary) + condemned_summary.add(std::to_string(shard), JsonObj() + .add("condemned_total", jsonUInt(cs.condemned_total)) + .add("pending_total", jsonUInt(cs.pending_total)) + .add("oldest_nonpending_condemn_round", jsonUInt(cs.oldest_nonpending_condemn_round)) + .str()); + + return JsonObj() + .add("generation", jsonUInt(seal.generation)) + .add("parent_generation", jsonUInt(seal.parent_generation)) + .add("ref_lives", ref_lives.str()) + .add("blob_target_runs", jsonArray(blob_target_runs)) + .add("condemned_summary", condemned_summary.str()) + .str(); +} + +String provenanceOpName(ProvenanceOp op) +{ + switch (op) + { + case ProvenanceOp::Other: return "Other"; + case ProvenanceOp::Insert: return "Insert"; + case ProvenanceOp::Merge: return "Merge"; + case ProvenanceOp::Mutation: return "Mutation"; + case ProvenanceOp::Attach: return "Attach"; + case ProvenanceOp::Repack: return "Repack"; + } + return "Unknown"; +} + +String renderProvenance(const Provenance & p) +{ + return JsonObj() + .add("created_at_ms", jsonUInt(p.created_at_ms)) + .add("creator_server_id", jsonHex(p.creator_server_id)) + .add("ch_version", jsonUInt(p.ch_version)) + .add("op", jsonEscape(provenanceOpName(p.op))) + .str(); +} + +String metaStateName(MetaState s) +{ + switch (s) + { + case MetaState::Clean: return "clean"; + case MetaState::Condemned: return "condemned"; + } + return "unknown"; +} + +/// The per-hash `.meta` descriptor is the blob body's sibling and records its freshness state +/// (`Clean` or `Condemned`), not its payload. It is rendered separately from `renderEnvelopeHeader`: +/// the body remains an enveloped object, while the descriptor has its own format. +String renderBlobMeta(const BlobMeta & m) +{ + return JsonObj() + .add("object", jsonEscape("blob_meta")) + .add("version", jsonUInt(m.version)) + .add("state", jsonEscape(metaStateName(m.state))) + .add("condemn_round", jsonUInt(m.condemn_round)) + .add("size", jsonUInt(m.size)) + .str(); +} + +String renderEnvelopeHeader(const EnvelopeHeader & h) +{ + return JsonObj() + .add("kind", jsonEscape(objectKindName(h.kind))) + /// The blob identity is carried by the object key, so the envelope keeps only the provenance + /// fields needed for forensics (`ch` and `bld`) together with its compatibility version. + .add("compatibility_version", jsonUInt(h.compatibility_version)) + .add("incarnation_tag", jsonHex(h.incarnation_tag)) + .add("build_id", jsonHex(h.build_id)) + .add("header_len", jsonUInt(h.header_len)) + .add("provenance", h.provenance ? renderProvenance(*h.provenance) : "null") + .add("intended_ref", h.intended_ref ? jsonEscape(*h.intended_ref) : "null") + .str(); +} + +/// The word vocabulary a row's marker byte renders as, matching the `cas_run` NDJSON's own `"m"` field +/// words (`CasRecordStreamFormat.cpp`'s private `markerToWord`) so cas-inspect speaks the same vocabulary +/// as the on-disk format rather than inventing a second one. +String sourceEdgeRowKindName(char marker) +{ + switch (marker) + { + case kEdgeActive: return "edge"; + case kZeroMarker: return "zero"; + case kCondemned: return "condemned"; + default: return "unknown"; + } +} + +String renderCondemnedRow(const CondemnedRow & r) +{ + return JsonObj() + .add("delete_pending", jsonBool(r.delete_pending)) + .add("token", renderToken(r.token)) + .add("size", jsonUInt(r.size)) + .add("condemn_round", jsonUInt(r.condemn_round)) + .add("marker_confirmed", jsonBool(r.marker_confirmed)) + .str(); +} + +/// Renders one blob-target source-edge run segment (`Layout::blobTargetRunKey`): every row (edge, +/// zero-marker, or condemned sentinel), plus a summary. `parsed` carries the run's own coordinates +/// recovered from the key; `bytes` is decoded with the same typed `SourceEdgeRunView` reader the fold / +/// `zeroInDegree` / `fsck` consumers use (the memory overload, since `caInspectToJson` is a pure +/// function of (key, bytes) with no backend access here). A malformed key or payload propagates the +/// codec's own `CORRUPTED_DATA` (`SourceEdgeKeyCodec::parse`, `decodeCondemnedRow`) -- rows are never +/// silently skipped. +String renderBlobTargetRun(const ParsedBlobTargetRunKey & parsed, std::string_view bytes) +{ + SourceEdgeRunView reader = openSourceEdgeRun(bytes); + + std::vector rows; + std::set distinct_blobs; + uint64_t edge_count = 0; + uint64_t condemned_count = 0; + uint64_t zero_marker_count = 0; + + String key; + String payload; + while (reader.next(key, payload)) + { + BlobRef ref; + UInt128 source_id; + SourceEdgeKeyCodec::parse(key, ref, source_id); // throws CORRUPTED_DATA on a malformed key (fail-closed) + if (payload.empty()) + throw DB::Exception(DB::ErrorCodes::CORRUPTED_DATA, + "cas-inspect: source-edge run row for blob {} has an empty payload", blobIdOf(ref)); + const char marker = payload[0]; + + distinct_blobs.insert(ref); + JsonObj row; + row.add("blob", jsonEscape(blobIdOf(ref))) + /// `source_id` is a `CityHash128` of (namespace, writer_epoch, build_sequence, + /// manifest_ordinal, path) -- not invertible here, so it renders as plain hex, exactly like + /// every other opaque u128 identifier in this file. + .add("source_id", jsonHex(source_id)) + .add("kind", jsonEscape(sourceEdgeRowKindName(marker))); + + switch (marker) + { + case kEdgeActive: + ++edge_count; + break; + case kZeroMarker: + ++zero_marker_count; + break; + case kCondemned: + ++condemned_count; + row.add("condemned", renderCondemnedRow(decodeCondemnedRow(payload))); // CORRUPTED_DATA on malformed (fail-closed) + break; + default: + throw DB::Exception(DB::ErrorCodes::CORRUPTED_DATA, + "cas-inspect: source-edge run row for blob {} has an unknown marker 0x{:02x}", + blobIdOf(ref), static_cast(marker)); + } + rows.push_back(row.str()); + } + + return JsonObj() + .add("object", jsonEscape("blob_target_run")) + .add("generation", jsonUInt(parsed.generation)) + .add("attempt", jsonUInt(parsed.attempt)) + .add("shard", jsonUInt(parsed.shard)) + .add("seq", jsonUInt(parsed.seq)) + .add("rows", jsonArray(rows)) + .add("summary", JsonObj() + .add("rows", jsonUInt(rows.size())) + .add("distinct_blobs", jsonUInt(distinct_blobs.size())) + .add("edges", jsonUInt(edge_count)) + .add("condemned", jsonUInt(condemned_count)) + .add("zero_markers", jsonUInt(zero_marker_count)) + .str()) + .str(); +} + +} + +String caInspectToJson(const Layout & layout, const String & key, std::string_view bytes, + const std::optional & resolved_life) +{ + /// Most-specific first: `cas/manifests/.../NNNNNN.zst` before the pool-wide `cas/ns/stream/` + /// prefix, the `/mount` and `/fold_seal` suffixes before the pool-wide `gc/state` exact match, + /// and the `.meta` sibling suffix before the bare `blobs/` prefix it also matches. + if (key.starts_with(layout.casManifestsPrefix()) && key.ends_with(storedSuffix(FormatId::PartManifest))) + return renderPartManifest(decodePartManifest(openObject(FormatId::PartManifest, bytes))); + + const auto requireResolvedLife = [&](NamespaceLifePhysicalId life_id) -> const NamespaceLifeId & + { + if (!resolved_life || resolved_life->incarnation != life_id) + throw DB::Exception(DB::ErrorCodes::BAD_ARGUMENTS, + "cas-inspect: life_id {} has no unique resolution in the supplied catalog cut", + renderIncarnation(life_id)); + return *resolved_life; + }; + + if (key.starts_with(layout.namespaceStateRootPrefix())) + { + if (const auto life_id = layout.parseRefCkptKey(key)) + return renderRefCkpt(requireResolvedLife(*life_id).ns, decodeRefCkpt(bytes)); + } + + if (key.starts_with(layout.casRefsPrefix())) + { + + const auto parsed = layout.parseRefObjectKey(key); + if (!parsed) + throw DB::Exception(DB::ErrorCodes::BAD_ARGUMENTS, + "cas-inspect: key under cas/ns/stream is not a recognized ref-object key '{}'", key); + const NamespaceLifeId & life = requireResolvedLife(parsed->life_id); + if (parsed->kind == RefObjectKind::Snap) + return renderRefTableSnapshot(decodeRefTableSnapshot( + openObject(FormatId::RefSnapshot, bytes), life.ns.string(), parsed->txn_id)); + if (parsed->kind == RefObjectKind::Log) + return renderRefLogTxn(decodeRefLogTxn( + openObject(FormatId::RefLog, bytes), life.ns.string(), parsed->txn_id)); + throw DB::Exception(DB::ErrorCodes::LOGICAL_ERROR, "cas-inspect: unhandled ref-object kind for key '{}'", key); + } + + if (key == layout.gcStateKey()) + return renderGcState(decodeGcState(bytes)); + + if (key.ends_with("/mount")) + return renderMountLease(decodeMountLease(bytes)); + + if (key.ends_with("/fold_seal")) + return renderFoldSeal(decodeFoldSeal(bytes)); + + /// Blob-target source-edge run segments (`Layout::blobTargetRunKey`) are the ground truth for + /// every in-degree question, so they get a typed decode too, not just the fold seal that names + /// them. Checked before the pool-wide `blobs/` prefix below (disjoint anyway -- these keys live + /// under `gc/gen/`, never `blobs/` -- but most-specific-first stays the dispatch's rule). + if (const auto parsed = layout.parseBlobTargetRunKey(key)) + return renderBlobTargetRun(*parsed, bytes); + + /// `blobMetaKey(id) == blobKey(id) + ".meta"`, so a meta descriptor also matches + /// `blobsPrefix()` below. Check it first or it would be decoded incorrectly as an envelope. A + /// non-`.meta` blob body still carries its envelope. + if (key.starts_with(layout.blobsPrefix()) && key.ends_with(".meta")) + return renderBlobMeta(decodeBlobMeta(bytes)); + + if (key.starts_with(layout.blobsPrefix())) + return renderEnvelopeHeader(decodeEnvelopeHeader(bytes, bytes.size(), ObjectKind::Blob)); + + throw DB::Exception(DB::ErrorCodes::BAD_ARGUMENTS, + "cas-inspect: unrecognized key layout '{}' (recognized: cas/ns/stream, cas/ns/state, cas/manifests, " + "gc/server-roots/*/mount, gc/state, gc/gen/*/fold_seal, gc/gen/*/attempt/*/blob_target/*/*, " + "retired, blobs, blobs/*.meta)", key); +} + +} diff --git a/src/Disks/DiskObjectStorage/MetadataStorages/ContentAddressed/Tools/CasInspect.h b/src/Disks/DiskObjectStorage/MetadataStorages/ContentAddressed/Tools/CasInspect.h new file mode 100644 index 000000000000..0c6bfa3e0cee --- /dev/null +++ b/src/Disks/DiskObjectStorage/MetadataStorages/ContentAddressed/Tools/CasInspect.h @@ -0,0 +1,30 @@ +#pragma once +#include +#include +#include + +namespace DB::Cas +{ + +/// Read-only decode-to-JSON dispatch for `clickhouse-disks cas-inspect` (and its unit tests): given +/// any key that could live in a content-addressed pool plus the raw bytes stored at it, decode with +/// the matching codec and render the struct's fields as human-readable JSON. `layout` supplies the +/// pool's key shapes (there is no live pool/backend access here — pure function of (key, bytes)), so +/// it can be exercised directly against encoder output in unit tests, with no disk / object storage +/// involved. +/// +/// Dispatch is by KEY SHAPE, most-specific first (`cas/manifests/.../NNNNNN.zst` before the +/// `cas/ns/stream/` and `cas/ns/state/` roots, `/mount` and `/fold_seal` suffixes, the +/// `gc/gen/*/attempt/*/blob_target/*/*` source-edge run segments, then the pool-wide `gc/state` +/// and `blobs/` prefix). u128 and hash fields render as lowercase hex strings (matching +/// `u128ToHex`), while backend-native `Token` values render as escaped strings. Neither is exposed +/// as an array of bytes or a raw struct dump. +/// +/// Throws `ErrorCodes::BAD_ARGUMENTS` when `key` matches none of the recognized CA layouts. Any +/// decode failure of a matched key (invalid header, corrupted bytes, future format version, ...) +/// propagates as-is from the underlying `decode*` function (typically `CORRUPTED_DATA` or +/// `UNKNOWN_FORMAT_VERSION`) — this function performs no fallback decode and swallows nothing. +String caInspectToJson(const Layout & layout, const String & key, std::string_view bytes, + const std::optional & resolved_life = std::nullopt); + +} diff --git a/src/Disks/DiskObjectStorage/MetadataStorages/ContentAddressed/benchmarks/CMakeLists.txt b/src/Disks/DiskObjectStorage/MetadataStorages/ContentAddressed/benchmarks/CMakeLists.txt new file mode 100644 index 000000000000..0f792624cea1 --- /dev/null +++ b/src/Disks/DiskObjectStorage/MetadataStorages/ContentAddressed/benchmarks/CMakeLists.txt @@ -0,0 +1,4 @@ +clickhouse_add_executable(benchmark_cas_ref_protocol benchmark_cas_ref_protocol.cpp) +target_link_libraries (benchmark_cas_ref_protocol PRIVATE + ch_contrib::gbenchmark_all + dbms) diff --git a/src/Disks/DiskObjectStorage/MetadataStorages/ContentAddressed/benchmarks/benchmark_cas_ref_protocol.cpp b/src/Disks/DiskObjectStorage/MetadataStorages/ContentAddressed/benchmarks/benchmark_cas_ref_protocol.cpp new file mode 100644 index 000000000000..f23f4dc06bae --- /dev/null +++ b/src/Disks/DiskObjectStorage/MetadataStorages/ContentAddressed/benchmarks/benchmark_cas_ref_protocol.cpp @@ -0,0 +1,553 @@ +#include + +#include +#include + +#include +#include +#include +#include +#include +#include + +/// Pure measurement, no pass/fail assertions -- see the cas-gc-rebuild BACKLOG.md entries +/// "OPTIMIZATION OPPORTUNITY -- ref-ledger JSON encoding writes byte-by-byte" and the (now +/// RESOLVED) "admits() re-encodes the WHOLE ref table once per state-growing op" entry for the +/// investigation these benchmarks measure. Build with `-DENABLE_BENCHMARKS=ON` and run the +/// resulting `benchmark_cas_ref_protocol` binary directly; never wired into `ninja test` +/// or CI. +/// +/// BM_Admits history (synthetic RefTableState, time/call, this binary): +/// Before incremental admits() (2026-07-19) -- full O(N) rebuild+encode per call: +/// N=100: 48.8 us N=1,000: 476 us N=10,000: 5,018 us N=100,000: 55,976 us +/// Google Benchmark complexity fit: O(N log N), RMS 2%. +/// After incremental admits() (2026-07-20) -- O(1) via incremental body-byte counters on +/// RefTableState: +/// N=100: 1842 ns N=1,000: 1875 ns N=10,000: 1864 ns N=100,000: 1919 ns +/// Google Benchmark complexity fit: O(1), RMS 1-2%. +/// +/// BM_EncodeRefLogTxn history (this binary; acceptance gate for the CasJsonWriter migration): +/// Before CasJsonWriter, field-by-field WriteBuffer calls (baseline): 753 ns. +/// After CasJsonWriter bulk-append migration (2026-07-20): 333 ns -- this is the shipped code. +/// BM_MemcpyTxnBytes floor (same bytes, plain String appends of 16-byte fragments): 30.7 ns. +/// Ratio EncodeRefLogTxn / MemcpyTxnBytes = 333 / 30.7 ~= 10.8x -- above the 3x acceptance gate. +/// A `keyLiteral` "rung-1" contingency variant (merging separator+key text into one literal +/// append for the fixed unprefixed keys in writeOp/writeCommittedRow) was also measured: 325 ns +/// ~= 10.8x -- a negligible ~2.5% move, not worth a third key-rendering path. It was NOT shipped; +/// writeOp/writeCommittedRow keep the single `writeKey` path for clarity. Per the contingency +/// ladder, rung 2 was NOT attempted either (it trades readability and needs a human decision); +/// reported as DONE_WITH_CONCERNS. CasEncodingPins.* stayed byte-identical (green) throughout. +/// +/// Phase B baselines, 2026-07-21, pre-encapsulation (this binary; `--benchmark_repetitions=3 +/// --benchmark_report_aggregates_only=true`; medians reported). Recorded ahead of the +/// `RefTableState` encapsulation refactor so later phases can re-run this exact suite unchanged and +/// diff against these numbers. +/// BM_Admits (promote op; stays O(1) via the incremental budget counters, untouched by this round): +/// N=100: 963 ns N=1,000: 979 ns N=10,000: 988 ns N=100,000: 1,029 ns +/// Complexity fit: O(1), RMS 2%. +/// BM_AdmitsAddPrecommit (add op -- THE production hotspot shape: `manifestAlreadyOwned`'s linear +/// value scan AT THIS BASELINE; O(1) via the owned-manifest index since E2 -- see the Final block +/// below): +/// N=100: 995 ns N=1,000: 4,266 ns N=10,000: 38,771 ns N=100,000: 400,222 ns +/// Complexity fit: O(N), ~4.0 ns/row, RMS 2%. +/// BM_ApplyRefLogTxn (scratch copy + validate + apply + install of one promote): +/// N=100: 724 ns N=1,000: 738 ns N=10,000: 784 ns N=100,000: 788 ns +/// Complexity fit: O(1), RMS 4%. +/// BM_ReplayHistory (fold/recovery profile: snapshot of size N, 256 tail txns, 2 ops each): +/// N=100: 6.15 ms N=1,000: 46.1 ms N=10,000: 454.0 ms N=100,000: 4.93 s +/// Complexity fit: O(N), ~48,859 ns/row, RMS 3%. +/// BM_ScratchCopy (one full RefTableState copy off a materialized state -- the isolation floor): +/// N=100: 45.7 ns N=1,000: 46.0 ns N=10,000: 46.7 ns N=100,000: 46.8 ns +/// Complexity fit: O(1), RMS 1%. +/// BM_SnapshotEncode (encodeRefTableSnapshot(snapshotOf(state))): +/// N=100: 14,955 ns N=1,000: 150,061 ns N=10,000: 1,508,586 ns N=100,000: 15,885,841 ns +/// Complexity fit: O(N), ~159 ns/row, RMS 1%. +/// BM_MergedIteration (full base + 10%-overlay merged iteration, post-copy pre-materialize shape): +/// N=100: 759 ns N=1,000: 7,719 ns N=10,000: 81,073 ns N=100,000: 864,552 ns +/// Complexity fit: O(N), ~8.6 ns/row, RMS 4%. +/// BM_Materialize (RefCowMap::materialize after one overlay insert on an N-row base): +/// N=100: 12,069 ns N=1,000: 126,687 ns N=10,000: 1,296,326 ns N=100,000: 18,145,559 ns +/// Complexity fit: O(N log N), RMS 2%. +/// +/// Final, 2026-07-21, shipped tree (post E1+E2+E3; E4 tried and REVERTED -- full per-phase tables in +/// `bench_t5_e3.log`): +/// BM_AdmitsAddPrecommit: ~692-714 ns FLAT across N=100..100,000 -- O(1), RMS 1% +/// (the owned-manifest index replaced the linear scan; ~571x at N=100k). +/// BM_ReplayHistory: 1,725.58 ns/row (was 48,859) -- in-place `TrustedReplay` apply, -96.5%. +/// BM_ApplyRefLogTxn: ~778-822 ns O(1). BM_Admits (promote): ~996-1,056 ns O(1). +/// BM_ScratchCopy: ~58 ns O(1) (+~11 ns vs baseline: one more shared_ptr copy for the index). +/// BM_SnapshotEncode / BM_MergedIteration / BM_Materialize: unchanged from baseline (E4 reverted). +/// +/// Implementation note for later phases: `makeSyntheticState` calls `RefCowMap::materialize()` +/// after `replay` (which never does -- it is the pure state-machine equation, and +/// `stateFromSnapshot` loads every row through `emplace`, which only ever touches the overlay). +/// Skipping that call makes every `RefTableState` copy in this suite (including `admits`'s and +/// `applyRefLogTxn`'s own internal scratch copies) an O(N) deep-copy of an un-materialized overlay +/// map instead of an O(1) shared-base copy -- this was caught during this round because it made +/// BM_Admits regress from the documented O(1) to visibly O(N log N), contradicting its own history +/// above. Production's RETAINED states are all materialized before reuse (the live table materializes +/// once per flush; post-consult the recovery-install site in CasRefLedger.cpp materializes the +/// replayed state before retaining it -- it previously did not, which is the recovery-latency cliff +/// BM_FlushInstall now measures against), so the fix was to materialize in the helper, not to accept +/// the contaminated numbers. (replay's own internal per-txn states are never materialized mid-fold; +/// BM_ReplayHistory models that path on purpose.) + +using namespace DB::Cas; + +namespace +{ + +/// A ref-ledger key shape as actually written on the wire: table_uuid + database + table + part_name. +constexpr std::string_view kSafeKeyLikeString + = "eeeb74a2-606a-4ee9-840a-1aac7b5ac25b_ca_stress_default_part_20260719_0_89811_538"; + +RefLogTxn makeSamplePromoteTxn() +{ + RefLogTxn txn; + txn.ns = "roots/ca_soak_ch1"; + txn.txn_id = RefTxnId{1, 12345}; + + RefOp op; + op.kind = RefOpKind::OwnerTransition; + op.old_binding = RefOwnerBinding{RefOwnerKind::Precommit, "20260719_0_89811_538_89818", ManifestRef{1, 1, 999999}}; + op.new_binding = RefOwnerBinding{RefOwnerKind::Committed, "20260719_0_89811_538_89818", ManifestRef{1, 1, 999999}}; + txn.ops.push_back(op); + return txn; +} + +/// A synthetic snapshot of `n` committed rows plus one pending precommit ready to promote. +/// Built as a RefTableSnapshot and materialized via the public `replay` entry point, so this +/// helper keeps compiling unchanged when RefTableState's fields become private (Phase A). +RefTableSnapshot makeSyntheticSnapshot(size_t n) +{ + RefTableSnapshot snapshot; + snapshot.ns = "roots/bench"; + snapshot.snapshot_id = RefTxnId{1, 1}; + for (size_t i = 0; i < n; ++i) + { + RefCommittedRow row; + row.ref_name = "part_" + std::to_string(i) + "_20260719_0_1000_1"; + row.manifest_ref = ManifestRef{1, 1, static_cast(i + 1)}; + snapshot.committed.push_back(row); + } + std::sort(snapshot.committed.begin(), snapshot.committed.end(), + [](const auto & a, const auto & b) { return a.ref_name < b.ref_name; }); + snapshot.precommits.push_back(RefOwnerBinding{RefOwnerKind::Precommit, "new_part_x", ManifestRef{1, 1, 999999}}); + return snapshot; +} + +/// A synthetic committed-ref table of `n` rows, plus one pending precommit ready to promote -- +/// exactly the shape `admits()` previews on every state-growing ref op. Rebuilt through `replay` +/// (the public state-machine entry point) rather than by poking `RefTableState` fields directly, +/// so this helper survives Phase A's encapsulation of `RefTableState`. +/// +/// `replay` (the pure state-machine equation) never materializes: `stateFromSnapshot` loads every +/// committed row through `RefCowMap::emplace`, which only ever touches the overlay. Left alone, +/// every subsequent `RefTableState` copy here (`admits`'s and `applyRefLogTxn`'s own internal +/// scratch copies, and every benchmark's own scratch copy below) would deep-copy an N-row overlay +/// map instead of sharing an immutable base pointer -- silently turning "the cost of the operation +/// under test" into "the cost of copying an un-materialized map" and swamping the O(1) `admits` +/// result the header history documents. The RETAINED long-lived states production keeps are all +/// materialized: the writer's live table materializes once per flush, and -- post-consult -- the +/// recovery-install site in `CasRefLedger.cpp` now calls `materializeCommitted()` on the replayed +/// state before retaining it (it previously did NOT, so the first flush copied an N-row overlay -- +/// exactly the cliff this fix removed and the reason `BM_FlushInstall` below measures the fully +/// materialized flush cost). So this helper materializes too, matching what every real caller does +/// immediately after building or replaying a state it will keep. (Note that `replay`'s own INTERNAL +/// per-transaction states are never materialized mid-fold -- `BM_ReplayHistory` deliberately models +/// that, feeding `replay(snapshot, tail)` an un-materialized base on purpose.) +RefTableState makeSyntheticState(size_t n) +{ + RefTableState state = replay(makeSyntheticSnapshot(n), {}); + state.materializeCommitted(); + return state; +} + +} + +/// Floor comparison: writeJSONString's per-character escaping loop (WriteHelpers.h) on a string +/// that needs no escaping at all (a real ref-ledger key shape) vs a raw bulk write of the same +/// bytes. See BM_RawBulkWriteSafe below for the delta. +static void BM_WriteJSONStringSafe(benchmark::State & state) +{ + DB::FormatSettings settings; + DB::PODArray buf; + for (auto _ : state) + { + buf.clear(); + DB::WriteBufferFromVector> out(buf); + DB::writeJSONString(kSafeKeyLikeString, out, settings); + benchmark::DoNotOptimize(buf.data()); + } +} +BENCHMARK(BM_WriteJSONStringSafe); + +static void BM_RawBulkWriteSafe(benchmark::State & state) +{ + DB::PODArray buf; + for (auto _ : state) + { + buf.clear(); + DB::WriteBufferFromVector> out(buf); + DB::writeChar('"', out); + out.write(kSafeKeyLikeString.data(), kSafeKeyLikeString.size()); + DB::writeChar('"', out); + benchmark::DoNotOptimize(buf.data()); + } +} +BENCHMARK(BM_RawBulkWriteSafe); + +/// Absolute cost of encoding one ref-log transaction (a single promote op) with +/// `encodeRefLogTxn`'s migrated `CasJsonWriter` bulk-append implementation (see the history +/// comment at the top of this file and the BACKLOG resolution). `BM_MemcpyTxnBytes` right below +/// is the floor to diff this against. +static void BM_EncodeRefLogTxn(benchmark::State & state) +{ + const RefLogTxn txn = makeSamplePromoteTxn(); + for (auto _ : state) + benchmark::DoNotOptimize(encodeRefLogTxn(txn)); +} +BENCHMARK(BM_EncodeRefLogTxn); + +/// The "near-memcpy" floor for BM_EncodeRefLogTxn: the SAME encoded bytes assembled from +/// precomputed 16-byte fragments by plain String appends -- approximating the writer's append +/// granularity with zero formatting/escaping work. Originally an acceptance gate for the +/// CasJsonWriter migration; measurement showed the <=3x-of-floor target is physically unreachable for a validating, +/// JSON-escaping encoder (BM_EncodeRefLogTxn lands at ~10.8x this floor even after the 2.26x +/// CasJsonWriter speedup -- see the BACKLOG resolution for the profiled breakdown). Kept as a +/// documented reference floor, not a pass/fail gate. +static void BM_MemcpyTxnBytes(benchmark::State & state) +{ + const RefLogTxn txn = makeSamplePromoteTxn(); + const String encoded = encodeRefLogTxn(txn); + std::vector fragments; + constexpr size_t kFragment = 16; + for (size_t off = 0; off < encoded.size(); off += kFragment) + fragments.push_back(std::string_view(encoded).substr(off, kFragment)); + + String buf; + buf.reserve(encoded.size()); + for (auto _ : state) + { + buf.clear(); + for (const auto f : fragments) + buf.append(f.data(), f.size()); + benchmark::DoNotOptimize(buf.data()); + } +} +BENCHMARK(BM_MemcpyTxnBytes); + +/// admits() used to re-derive and re-encode the WHOLE committed-ref snapshot on every call +/// (CasRefProtocol.cpp), showing O(N log N) growth with table size; it now maintains +/// incremental body-byte counters on RefTableState instead, so this should show flat (O(1)) +/// time/call across the range. ->Complexity() has Google Benchmark fit and print the +/// empirical big-O across the range. +static void BM_Admits(benchmark::State & state) +{ + const size_t n = static_cast(state.range(0)); + const RefTableState table = makeSyntheticState(n); + RefOp op; + op.kind = RefOpKind::OwnerTransition; + op.old_binding = RefOwnerBinding{RefOwnerKind::Precommit, "new_part_x", ManifestRef{1, 1, 999999}}; + op.new_binding = RefOwnerBinding{RefOwnerKind::Committed, "new_part_x", ManifestRef{1, 1, 999999}}; + + for (auto _ : state) + benchmark::DoNotOptimize(admits(table, op, 1ull << 40, 1ull << 40)); + + state.SetComplexityN(static_cast(n)); +} +BENCHMARK(BM_Admits)->RangeMultiplier(10)->Range(100, 100000)->Complexity(); + +/// THE production hotspot shape: add-precommit runs `manifestAlreadyOwned` (a linear value scan +/// today). Expected O(N) before the experiments, O(1) after the winning combination. Unlike +/// BM_Admits (a promote, which never calls `manifestAlreadyOwned`), this previews a pure add -- +/// the op every part publication starts with -- so it is the shape production traces show as +/// linear even after the incremental-budget fix landed for BM_Admits' promote shape. +static void BM_AdmitsAddPrecommit(benchmark::State & state) +{ + const size_t n = static_cast(state.range(0)); + const RefTableState table = makeSyntheticState(n); + RefOp op; + op.kind = RefOpKind::OwnerTransition; + op.new_binding = RefOwnerBinding{RefOwnerKind::Precommit, "brand_new_part", ManifestRef{2, 1, 1}}; + + for (auto _ : state) + benchmark::DoNotOptimize(admits(table, op, 1ull << 40, 1ull << 40)); + + state.SetComplexityN(static_cast(n)); +} +BENCHMARK(BM_AdmitsAddPrecommit)->RangeMultiplier(10)->Range(100, 100000)->Complexity(); + +/// One transaction end-to-end: scratch copy + validate + apply + install (a promote of the +/// staged precommit). The copy is part of the measured cost on purpose -- it is what E3 attacks. +static void BM_ApplyRefLogTxn(benchmark::State & state) +{ + const size_t n = static_cast(state.range(0)); + const RefTableState table = makeSyntheticState(n); + + RefLogTxn txn; + txn.ns = "roots/bench"; + txn.txn_id = RefTxnId{1, 2}; + RefOp promote; + promote.kind = RefOpKind::OwnerTransition; + promote.old_binding = RefOwnerBinding{RefOwnerKind::Precommit, "new_part_x", ManifestRef{1, 1, 999999}}; + promote.new_binding = RefOwnerBinding{RefOwnerKind::Committed, "new_part_x", ManifestRef{1, 1, 999999}}; + txn.ops.push_back(promote); + + for (auto _ : state) + { + RefTableState scratch = table; + applyRefLogTxn(scratch, txn); + benchmark::DoNotOptimize(&scratch); + } + + state.SetComplexityN(static_cast(n)); +} +BENCHMARK(BM_ApplyRefLogTxn)->RangeMultiplier(10)->Range(100, 100000)->Complexity(); + +/// End-to-end FLUSH-INSTALL cost: apply one state-growing transaction (add a fresh precommit, then +/// promote it -- touching BOTH the committed map AND the owned-manifest index) and then +/// `materializeCommitted()`, which folds BOTH COW overlays into fresh shared bases. THIS is the O(N) +/// critical section production holds `state_mutex` for, once per ref-log flush -- the number the +/// "writer path is flat" claim (drawn from `BM_ApplyRefLogTxn`, which stops before materialize) must be +/// weighed against. `BM_ApplyRefLogTxn` measures apply-without-install; the shipped-report +/// `BM_Materialize` measures only `RefCowMap`'s half; this measures the whole install including the +/// second (`owned_manifests`) container the index added, over the same N range. +static void BM_FlushInstall(benchmark::State & state) +{ + const size_t n = static_cast(state.range(0)); + const RefTableState table = makeSyntheticState(n); // materialized, as a live table is at a flush boundary + + /// add + promote of a fresh ref: the add inserts into `owned_manifests`, the promote grows + /// `committed` -- so materialize below folds a nonempty overlay in BOTH containers. Manifest {4,1,1} + /// and ref name are unique against the synthetic snapshot's {1,1,*} rows and "new_part_x" precommit. + RefLogTxn txn; + txn.ns = "roots/bench"; + txn.txn_id = RefTxnId{1, 2}; + RefOp add; + add.kind = RefOpKind::OwnerTransition; + add.new_binding = RefOwnerBinding{RefOwnerKind::Precommit, "flush_install_new_part", ManifestRef{4, 1, 1}}; + txn.ops.push_back(add); + RefOp promote; + promote.kind = RefOpKind::OwnerTransition; + promote.old_binding = RefOwnerBinding{RefOwnerKind::Precommit, "flush_install_new_part", ManifestRef{4, 1, 1}}; + promote.new_binding = RefOwnerBinding{RefOwnerKind::Committed, "flush_install_new_part", ManifestRef{4, 1, 1}}; + txn.ops.push_back(promote); + + for (auto _ : state) + { + RefTableState working = table; // O(1): shared base + applyRefLogTxn(working, txn); // O(ops): bounded overlay + working.materializeCommitted(); // O(N): the critical-section fold this benchmark exists to measure + benchmark::DoNotOptimize(&working); + } + + state.SetComplexityN(static_cast(n)); +} +BENCHMARK(BM_FlushInstall)->RangeMultiplier(10)->Range(100, 100000)->Complexity(); + +/// Same flush-install as `BM_FlushInstall`, but exercising the E5 uniquely-owned-base fast path that +/// production actually hits. `BM_FlushInstall` copies a shared fixture (`working = table`), so at +/// `materializeCommitted()` the base still has `use_count() == 2` and the fold must build a fresh +/// base -- O(N). Production's live table has NO outstanding scratch copy at the install point: +/// `CasRefLedger::flushRefBatch` EXPLICITLY releases its trial-validation copy (`working = RefTableState{}`) +/// before allocating the id and doing the post-PUT install, so at `materializeCommitted()` the live +/// base is uniquely owned and the fold happens in place -- O(overlay). This variant models that by +/// rebuilding a private, +/// materialized state each iteration (its base `use_count()` is 1), timing only the apply + in-place +/// materialize. The per-iteration rebuild AND the prior iteration's O(N) teardown are excluded from +/// the measurement by hoisting `working` out of the loop and rebuilding it via move-assignment under +/// Pause/ResumeTiming (the reassignment both destroys the previous grown state and installs a fresh +/// materialized one, all untimed). The residual per-iteration Pause/Resume overhead is a constant +/// floor, so the signal to read is FLATNESS across N (O(overlay)), not the absolute small-N number. +static void BM_FlushInstallUniqueOwner(benchmark::State & state) +{ + const size_t n = static_cast(state.range(0)); + + RefLogTxn txn; + txn.ns = "roots/bench"; + txn.txn_id = RefTxnId{1, 2}; + RefOp add; + add.kind = RefOpKind::OwnerTransition; + add.new_binding = RefOwnerBinding{RefOwnerKind::Precommit, "flush_install_new_part", ManifestRef{4, 1, 1}}; + txn.ops.push_back(add); + RefOp promote; + promote.kind = RefOpKind::OwnerTransition; + promote.old_binding = RefOwnerBinding{RefOwnerKind::Precommit, "flush_install_new_part", ManifestRef{4, 1, 1}}; + promote.new_binding = RefOwnerBinding{RefOwnerKind::Committed, "flush_install_new_part", ManifestRef{4, 1, 1}}; + txn.ops.push_back(promote); + + /// Hoisted out of the loop so the O(N) teardown of the previous iteration's grown state is folded + /// into the untimed move-assignment below, not charged to the timed apply + materialize region. + RefTableState working; + for (auto _ : state) + { + state.PauseTiming(); + working = makeSyntheticState(n); // private, materialized: base use_count() == 1 + state.ResumeTiming(); + + applyRefLogTxn(working, txn); // O(ops): bounded overlay + working.materializeCommitted(); // O(overlay): uniquely-owned base folded IN PLACE (the E5 win) + benchmark::DoNotOptimize(&working); + } + + state.SetComplexityN(static_cast(n)); +} +/// Fixed iteration count: the E5 fast path makes the timed apply + in-place-materialize region tiny +/// and N-independent, so google-benchmark's default min-time targeting would demand millions of +/// iterations at every N -- each paying an untimed O(N) `makeSyntheticState` rebuild, which explodes +/// at large N. A fixed, modest count keeps every point cheap while still averaging enough samples to +/// read the flatness across N (the whole point of this variant). +BENCHMARK(BM_FlushInstallUniqueOwner)->RangeMultiplier(10)->Range(100, 100000)->Iterations(500)->Complexity(); + +/// The fold/recovery profile: K transactions replayed over a size-N snapshot. Each txn creates +/// and promotes one new ref (two ops), so each add pays today's `manifestAlreadyOwned` scan. +/// K fixed at 256; complexity fit is over N. +static void BM_ReplayHistory(benchmark::State & state) +{ + const size_t n = static_cast(state.range(0)); + const RefTableSnapshot snapshot = makeSyntheticSnapshot(n); + + constexpr size_t kTailTxns = 256; + std::vector tail; + tail.reserve(kTailTxns); + for (size_t k = 0; k < kTailTxns; ++k) + { + RefLogTxn txn; + txn.ns = "roots/bench"; + txn.txn_id = RefTxnId{1, 2 + k}; + + /// Refs unique per k, and namespaced under writer_epoch 3 so they collide with nothing in + /// the snapshot's own {1,1,i} committed series or its {1,1,999999} precommit. + const String ref_name = "replay_part_" + std::to_string(k); + const ManifestRef manifest_ref{3, 1, static_cast(k + 1)}; + + RefOp add; + add.kind = RefOpKind::OwnerTransition; + add.new_binding = RefOwnerBinding{RefOwnerKind::Precommit, ref_name, manifest_ref}; + txn.ops.push_back(add); + + RefOp promote; + promote.kind = RefOpKind::OwnerTransition; + promote.old_binding = RefOwnerBinding{RefOwnerKind::Precommit, ref_name, manifest_ref}; + promote.new_binding = RefOwnerBinding{RefOwnerKind::Committed, ref_name, manifest_ref}; + txn.ops.push_back(promote); + + tail.push_back(std::move(txn)); + } + + for (auto _ : state) + benchmark::DoNotOptimize(replay(snapshot, tail)); + + state.SetComplexityN(static_cast(n)); +} +BENCHMARK(BM_ReplayHistory)->RangeMultiplier(10)->Range(100, 100000)->Complexity(); + +/// The isolation primitive on its own: one full state copy (COW committed + std::set precommits +/// + counters). Overlay is empty (state fresh from replay+materialize), so this is the floor. +static void BM_ScratchCopy(benchmark::State & state) +{ + const size_t n = static_cast(state.range(0)); + RefTableState table = makeSyntheticState(n); + table.materializeCommitted(); /// makeSyntheticState already materializes; repeated here + /// defensively (a no-op on an empty overlay) so this benchmark's + /// floor claim does not silently depend on that helper's internals. + + for (auto _ : state) + { + RefTableState copy = table; + benchmark::DoNotOptimize(©); + } + + state.SetComplexityN(static_cast(n)); +} +BENCHMARK(BM_ScratchCopy)->RangeMultiplier(10)->Range(100, 100000)->Complexity(); + +/// Canonical snapshot encoding for size N (per-flush cost, expected O(N) -- the question is the +/// constant, which E4's contiguous scan attacks). +static void BM_SnapshotEncode(benchmark::State & state) +{ + const size_t n = static_cast(state.range(0)); + const RefTableState table = makeSyntheticState(n); + + for (auto _ : state) + benchmark::DoNotOptimize(encodeRefTableSnapshot(snapshotOf(table, "roots/bench"))); + + state.SetComplexityN(static_cast(n)); +} +BENCHMARK(BM_SnapshotEncode)->RangeMultiplier(10)->Range(100, 100000)->Complexity(); + +/// Full merged iteration with a 10% overlay (post-copy, pre-materialize shape): an N-row +/// materialized base, then a fresh overlay of N/10 rows layered on top with `materialize()` +/// deliberately not called again -- so iteration must merge base and overlay in sorted order the +/// way the cold full-scan paths (snapshotOf, listRefs, dropNamespace) do against an in-flight batch. +/// Benchmarks `RefCowMap` directly (like `BM_Materialize` below) rather than through +/// `RefTableState::getCommitted()`: this isolates the merge-iteration primitive itself, and building +/// the overlay via `RefTableState`'s promote/precommit transactions would additionally measure the +/// state machine's own per-op bookkeeping, which is not what this benchmark is about. +static void BM_MergedIteration(benchmark::State & state) +{ + const size_t n = static_cast(state.range(0)); + + RefCowMap map; + for (size_t i = 0; i < n; ++i) + { + RefCommittedRow row; + row.ref_name = "part_" + std::to_string(i) + "_20260719_0_1000_1"; + row.manifest_ref = ManifestRef{1, 1, static_cast(i + 1)}; + map.emplace(row.ref_name, row); + } + map.materialize(); + + const size_t overlay_n = std::max(1, n / 10); + for (size_t i = 0; i < overlay_n; ++i) + { + RefCommittedRow row; + row.ref_name = "overlay_part_" + std::to_string(i) + "_20260719_0_1000_1"; + row.manifest_ref = ManifestRef{2, 1, static_cast(i + 1)}; + map.insert_or_assign(row.ref_name, row); + } + + for (auto _ : state) + { + size_t total = 0; + for (const auto [ref_name, row] : map) + total += row.ref_name.size(); + benchmark::DoNotOptimize(total); + } + + state.SetComplexityN(static_cast(n)); +} +BENCHMARK(BM_MergedIteration)->RangeMultiplier(10)->Range(100, 100000)->Complexity(); + +/// RefCowMap::materialize after one overlay insert on an N-row base (per-flush install cost). +/// Benchmarks RefCowMap directly -- it is a public class. +static void BM_Materialize(benchmark::State & state) +{ + const size_t n = static_cast(state.range(0)); + RefCowMap base_map; + for (size_t i = 0; i < n; ++i) + { + RefCommittedRow row; + row.ref_name = "part_" + std::to_string(i) + "_20260719_0_1000_1"; + row.manifest_ref = ManifestRef{1, 1, static_cast(i + 1)}; + base_map.emplace(row.ref_name, row); + } + base_map.materialize(); + + for (auto _ : state) + { + RefCowMap copy = base_map; + RefCommittedRow new_row; + new_row.ref_name = "brand_new_part_20260719_0_1000_1"; + new_row.manifest_ref = ManifestRef{2, 1, 1}; + copy.insert_or_assign(new_row.ref_name, new_row); + copy.materialize(); + benchmark::DoNotOptimize(©); + } + + state.SetComplexityN(static_cast(n)); +} +BENCHMARK(BM_Materialize)->RangeMultiplier(10)->Range(100, 100000)->Complexity(); + +BENCHMARK_MAIN(); diff --git a/src/Disks/DiskObjectStorage/MetadataStorages/IMetadataStorage.h b/src/Disks/DiskObjectStorage/MetadataStorages/IMetadataStorage.h index e82735548a4b..5b1a6ef6d1b0 100644 --- a/src/Disks/DiskObjectStorage/MetadataStorages/IMetadataStorage.h +++ b/src/Disks/DiskObjectStorage/MetadataStorages/IMetadataStorage.h @@ -27,6 +27,8 @@ namespace ErrorCodes extern const int NOT_IMPLEMENTED; } +struct IDiskTransaction; + /// Tries to provide some "transactions" interface, which allow /// to execute (commit) operations simultaneously. We don't provide /// any snapshot isolation here, so no read operations in transactions @@ -115,6 +117,16 @@ class IMetadataTransaction : private boost::noncopyable throwNotImplemented(); } + /// [TXN-ONE-PIPELINE] Optional per-metadata write buffer. Returns a ready-to-use buffer when the + /// metadata implementation owns its write mechanism (e.g. a content-addressed hash-on-write buffer + /// whose blob key is known only after the last byte). `owner` is the disk transaction that must be + /// kept alive for the returned buffer's lifetime and, when `autocommit`, committed from the finalize + /// callback. Default nullptr: the caller uses the generic streaming write path unchanged. + virtual std::unique_ptr tryCreateWriteBuffer( + const std::shared_ptr & /*owner*/, + const std::string & /*path*/, size_t /*buf_size*/, WriteMode /*mode*/, + const WriteSettings & /*settings*/, bool /*autocommit*/) { return nullptr; } + /// Metadata related methods /// Generate blob name for passed absolute local path. @@ -141,6 +153,22 @@ class IMetadataTransaction : private boost::noncopyable throwNotImplemented(); } + /// In-flight read-your-writes for a part being assembled by THIS transaction (B59). A CA part-build + /// transaction stages blobs (uploaded) + mutable bytes before the single commit; these let a reader + /// that holds the transaction resolve those staged files before they are committed. Default: no + /// in-flight visibility (the committed metadata path is authoritative). + virtual std::optional tryGetInFlightStorageObjects(const std::string & /*path*/) const { return {}; } + virtual std::unique_ptr tryReadFileInFlight( + const std::string & /*path*/, const ReadSettings & /*settings*/, std::optional /*read_hint*/) const { return nullptr; } + virtual std::optional tryGetInFlightFileSize(const std::string & /*path*/) const { return {}; } + /// Directory-granularity counterpart of the file trio: true iff this transaction has STAGED at least one + /// file under `path` for `path`'s part. Used so a carried-forward projection dir is visible to + /// loadProjections during finalize. Default: no in-flight directory visibility. + virtual bool hasInFlightDirectory(const std::string & /*path*/) const { return false; } + /// Immediate-child names staged directly under `path` (one level). Used so loadProjections' + /// withPartFormatFromDisk can iterate a staged projection dir to find its mark file. Default: empty. + virtual std::vector listInFlightDirectory(const std::string & /*path*/) const { return {}; } + virtual ~IMetadataTransaction() = default; protected: @@ -289,6 +317,23 @@ class IMetadataStorage : private boost::noncopyable return false; } + /// Returns true if the metadata storage is content-addressed, i.e. blob keys are derived + /// from content hashes and are only known after all bytes have been written. Such a storage + /// cannot use the up-front-key streaming write path of `DiskObjectStorageTransaction`; the + /// disk transaction delegates writes to the metadata transaction's content-addressed buffer. + virtual bool isContentAddressed() const { return false; } + + /// [TXN-ONE-PIPELINE] True when a transaction from this storage stages every mutation into a + /// transaction-private overlay at call time (eager) rather than queuing effects for FIFO replay in + /// commit. When true, DiskObjectStorageTransaction routes every mutating method straight to the + /// metadata transaction and keeps its own operations_to_execute queue empty. Default false + /// (ordinary object storage). + virtual bool transactionIsStagingOverlay() const { return false; } + + /// True when a file write through this metadata storage publishes atomically, i.e. no partial + /// content is ever observable under the file's final name (see `IDataPartStorage::supportsAtomicFileWrites`). + virtual bool supportsAtomicFileWrites() const { return false; } + using BlobsToRemove = std::unordered_map; virtual BlobsToRemove getBlobsToRemove(const ClusterConfigurationPtr & /*cluster*/, int64_t /*max_count*/) { return {}; } virtual int64_t recordAsRemoved(const StoredObjects & /*blobs*/) { return 0; } @@ -325,6 +370,12 @@ class IMetadataStorage : private boost::noncopyable /// True if write with Append mode supported. virtual bool supportWritingWithAppend() const { return false; } + /// True iff this metadata storage can persist the per-part mutable transaction file (txn_version.txt) + /// under MVCC. Distinct from supportWritingWithAppend: transactions rewrite txn_version.txt (tmp + + /// replaceFile), they never WriteMode::Append, so append-capability is the wrong proxy. A + /// content-addressed disk supports the mutable txn file via its per-ref sidecar. + virtual bool supportsTransactionalMutableFiles() const { return false; } + protected: [[noreturn]] static void throwNotImplemented() { diff --git a/src/Disks/DiskObjectStorage/MetadataStorages/MetadataStorageFactory.cpp b/src/Disks/DiskObjectStorage/MetadataStorages/MetadataStorageFactory.cpp index 2b2d6a284a4e..9bb1b1fb355d 100644 --- a/src/Disks/DiskObjectStorage/MetadataStorages/MetadataStorageFactory.cpp +++ b/src/Disks/DiskObjectStorage/MetadataStorages/MetadataStorageFactory.cpp @@ -7,8 +7,11 @@ #endif #include #include +#include +#include #include #include +#include #include @@ -21,6 +24,12 @@ namespace ErrorCodes extern const int UNKNOWN_ELEMENT_IN_CONFIG; extern const int INVALID_CONFIG_PARAMETER; extern const int LOGICAL_ERROR; + extern const int NOT_IMPLEMENTED; +} + +namespace ContentAddressedSetting +{ + extern const ContentAddressedSettingsString scratch_path; } namespace @@ -205,6 +214,35 @@ static void registerPlainRewritableMetadataStorage(MetadataStorageFactory & fact }); } +static void registerContentAddressedMetadataStorage(MetadataStorageFactory & factory) +{ + factory.registerMetadataStorageType("cas", []( + const std::string & name, + const Poco::Util::AbstractConfiguration & config, + const std::string & config_prefix, + const ClusterConfigurationPtr & cluster, + const ObjectStorageRouterPtr & object_storages) -> MetadataStoragePtr + { + checkSingleLocation(cluster); + + const auto local_object_storage = object_storages->takePointingTo(cluster->getLocalLocation()); + std::string key_compatibility_prefix = getObjectKeyCompatiblePrefix(local_object_storage, config, config_prefix); + + auto global_context = Context::getGlobalContextInstance(); + ContentAddressedSettings settings; + settings.loadFromConfig( + config, config_prefix, + /*scratch_path_anchor_if_relative=*/ global_context->getPath(), + /*default_scratch_path=*/ fs::path(global_context->getPath()) / "disks" / name / "cas_scratch" / "", + [&](const std::string & s) { return global_context->getMacros()->expand(s); }); + fs::create_directories(settings[ContentAddressedSetting::scratch_path].value); + + return std::make_shared( + local_object_storage, key_compatibility_prefix, toString(ServerUUID::get()), + name, global_context, settings); + }); +} + static void registerMetadataStorageFromStaticFilesWebServer(MetadataStorageFactory & factory) { factory.registerMetadataStorageType("web", []( @@ -230,6 +268,7 @@ void registerMetadataStorages() registerMetadataStorageFromDisk(factory); registerPlainMetadataStorage(factory); registerPlainRewritableMetadataStorage(factory); + registerContentAddressedMetadataStorage(factory); registerMetadataStorageFromStaticFilesWebServer(factory); #if CLICKHOUSE_CLOUD registerMetadataStorageFromKeeper(factory); diff --git a/src/Disks/DiskObjectStorage/ObjectStorages/IObjectStorage.h b/src/Disks/DiskObjectStorage/ObjectStorages/IObjectStorage.h index 41b16b95e50b..ff77c27915f6 100644 --- a/src/Disks/DiskObjectStorage/ObjectStorages/IObjectStorage.h +++ b/src/Disks/DiskObjectStorage/ObjectStorages/IObjectStorage.h @@ -161,6 +161,23 @@ using ObjectKeysWithMetadata = std::vector; class IObjectStorageIterator; using ObjectStorageIteratorPtr = std::shared_ptr; +/// Outcome of a token-conditional single-object removal (content-addressed disks). +enum class ConditionalRemoveOutcome : uint8_t { Removed, TokenMismatch, NotFound }; +struct ConditionalRemoveResult +{ + ConditionalRemoveOutcome outcome = ConditionalRemoveOutcome::NotFound; + bool created_delete_marker = false; /// backend reported a versioning delete marker +}; + +/// Outcome of a write-once conditional server-side copy (content-addressed disks): `created == true` +/// means this call won the race and created `object_to`; `created == false` means the destination +/// already existed (the precondition was rejected) and `dest_etag` is left empty. +struct ConditionalCopyResult +{ + bool created = false; + String dest_etag; +}; + /// Base class for all object storages which implement some subset of ordinary filesystem operations. /// /// Examples of object storages are S3, Azure Blob Storage, HDFS. @@ -267,6 +284,15 @@ class IObjectStorage /// Remove objects on path if exists virtual void removeObjectsIfExist(const StoredObjects & object) = 0; + /// Remove `object` ONLY if its current entity tag equals `etag`. Backends without enforced + /// conditional removal MUST NOT override this: the content-addressed capability probe relies on the + /// default to fail closed. Supported: S3 (DeleteObject If-Match, GA 2025-09). + virtual ConditionalRemoveResult removeObjectIfTokenMatches(const StoredObject & /*object*/, const std::string & /*etag*/) + { + throw Exception(ErrorCodes::NOT_IMPLEMENTED, + "Conditional (token-exact) object removal is not implemented for {} object storage", getName()); + } + /// Copy object with different attributes if required virtual void copyObject( /// NOLINT const StoredObject & object_from, @@ -275,6 +301,36 @@ class IObjectStorage const WriteSettings & write_settings, std::optional object_to_attributes = {}) = 0; + /// Copy `object_from` to `object_to` WRITE-ONCE: the copy is conditional on `object_to` not + /// already existing (`If-None-Match: *`). Returns `created=true` with the destination ETag if + /// this call created the object, or `created=false` (empty `dest_etag`) if the destination + /// already existed — that is the expected "lost the race" signal, not an error. Any other + /// failure propagates as an exception. + /// + /// Backends without an enforced, native (server-side) conditional copy MUST NOT override this: + /// the content-addressed write-once staging promote relies on the default to fail closed rather + /// than silently falling back to an unconditional overwrite. Supported: S3 (native `CopyObject` + /// / `CompleteMultipartUpload` with `If-None-Match`). + virtual ConditionalCopyResult copyObjectConditional( + const StoredObject & /*object_from*/, + const StoredObject & /*object_to*/, + const ReadSettings & /*read_settings*/, + const WriteSettings & /*write_settings*/, + std::optional /*object_to_attributes*/) + { + throw Exception(ErrorCodes::NOT_IMPLEMENTED, + "Conditional (write-once) object copy is not implemented for {} object storage", getName()); + } + + ConditionalCopyResult copyObjectConditional( + const StoredObject & object_from, + const StoredObject & object_to, + const ReadSettings & read_settings, + const WriteSettings & write_settings) + { + return copyObjectConditional(object_from, object_to, read_settings, write_settings, {}); + } + /// Copy object to another instance of object storage /// by default just read the object from source object storage and write /// to destination through buffers. @@ -323,6 +379,22 @@ class IObjectStorage virtual bool supportParallelWrite() const { return false; } + /// True when the incarnation tokens this storage returns from writes/HEADs are GCS generation + /// numbers riding the ETag plumbing (http_client = gcs_hmac / gcp_oauth conditional dialect). + /// Consumers (the CAS backend) stamp TokenType::Generation and route conditional writes + /// through the single-PUT path (GCS enforces no preconditions on CompleteMultipartUpload). + virtual bool conditionalOpsUseGenerationTokens() const { return false; } + + /// Whether the underlying bucket has object versioning enabled; nullopt when unknown or not + /// applicable. Used by the CAS capability probe to fail closed on GCS: on a versioned bucket + /// a token-exact DELETE archives a noncurrent generation instead of reclaiming storage. + virtual std::optional isBucketVersioningEnabled() const { return std::nullopt; } + + /// True when this object storage can execute writes under the given retry profile. + /// A caller that sets a non-Default profile on WriteSettings MUST check this first and + /// fail closed if unsupported (the profile is advisory only to backends that opt in). + virtual bool supportsRetryProfile(ObjectStorageRetryProfile profile) const { return profile == ObjectStorageRetryProfile::Default; } + virtual ReadSettings patchSettings(const ReadSettings & read_settings) const; virtual WriteSettings patchSettings(const WriteSettings & write_settings) const; diff --git a/src/Disks/DiskObjectStorage/ObjectStorages/Local/LocalObjectStorage.cpp b/src/Disks/DiskObjectStorage/ObjectStorages/Local/LocalObjectStorage.cpp index 11a7cbf29acc..4719e6ae9237 100644 --- a/src/Disks/DiskObjectStorage/ObjectStorages/Local/LocalObjectStorage.cpp +++ b/src/Disks/DiskObjectStorage/ObjectStorages/Local/LocalObjectStorage.cpp @@ -408,6 +408,12 @@ std::optional LocalObjectStorage::tryGetObjectMetadata(const std throw fs::filesystem_error("Got unexpected error while getting last write time", path, error); } + /// A directory is not an object: fs::file_size would throw "Is a directory". Treat it as a + /// missing object (nullopt) so callers probing whether a path is a readable object do not get + /// a raw filesystem error (B38: system.remote_data_paths traversal on a CAS pool). + if (fs::is_directory(path, error)) + return {}; + object_metadata.size_bytes = fs::file_size(path, error); if (error) { diff --git a/src/Disks/DiskObjectStorage/ObjectStorages/S3/S3ObjectStorage.cpp b/src/Disks/DiskObjectStorage/ObjectStorages/S3/S3ObjectStorage.cpp index 34eb1eaebb9d..32aa5b34e4b3 100644 --- a/src/Disks/DiskObjectStorage/ObjectStorages/S3/S3ObjectStorage.cpp +++ b/src/Disks/DiskObjectStorage/ObjectStorages/S3/S3ObjectStorage.cpp @@ -17,6 +17,7 @@ #include #include #include +#include #include #include #include @@ -33,6 +34,7 @@ #include #include #include +#include #include #include @@ -68,8 +70,13 @@ namespace Setting namespace S3RequestSetting { + extern const S3RequestSettingsBool allow_native_copy; + extern const S3RequestSettingsBool check_objects_after_upload; extern const S3RequestSettingsUInt64 list_object_keys_size; extern const S3RequestSettingsUInt64 objects_chunk_size_to_delete; + extern const S3RequestSettingsUInt64 max_single_part_upload_size; + extern const S3RequestSettingsUInt64 min_upload_part_size; + extern const S3RequestSettingsUInt64 max_unexpected_write_error_retries; } @@ -77,6 +84,8 @@ namespace ErrorCodes { extern const int BAD_ARGUMENTS; extern const int LOGICAL_ERROR; + extern const int NOT_IMPLEMENTED; + extern const int S3_ERROR; } namespace @@ -227,7 +236,8 @@ class S3IteratorAsync final : public IObjectStorageIteratorAsync bool S3ObjectStorage::exists(const StoredObject & object) const { auto settings_ptr = s3_settings.get(); - return S3::objectExists(*client.get(), uri.bucket, object.remote_path, {}); + const bool e = S3::objectExists(*client.get(), uri.bucket, object.remote_path, {}); + return e; } std::unique_ptr S3ObjectStorage::readObject( /// NOLINT @@ -312,6 +322,29 @@ std::unique_ptr S3ObjectStorage::writeObject( /// NOLIN request_settings.updateFromSettings(settings, /* if_changed */ true, settings[Setting::s3_validate_request_settings]); } + if (write_settings.s3_check_objects_after_upload_override) + request_settings[S3RequestSetting::check_objects_after_upload] = *write_settings.s3_check_objects_after_upload_override; + + if (write_settings.s3_single_part_upload_max_bytes_override) + { + /// Keep the whole body in ONE buffered part so the single-PUT path stays available up to + /// the cap (conditional writes on generation-token stores; see WriteSettings). + request_settings[S3RequestSetting::max_single_part_upload_size] + = write_settings.s3_single_part_upload_max_bytes_override; + request_settings[S3RequestSetting::min_upload_part_size] + = write_settings.s3_single_part_upload_max_bytes_override; + } + + if (write_settings.s3_max_unexpected_write_error_retries_override) + { + /// WriteBufferFromS3's OWN retry loop (makeSinglepartUpload/completeMultipartUpload) reissues + /// the identical request — WITH its If-None-Match/If-Match condition — on a NO_SUCH_KEY + /// response; this sits ABOVE the S3 client, so a client-level profile override does not bound + /// it. See WriteSettings. + request_settings[S3RequestSetting::max_unexpected_write_error_retries] + = write_settings.s3_max_unexpected_write_error_retries_override; + } + ThreadPoolCallbackRunnerUnsafe scheduler; if (write_settings.s3_allow_parallel_part_upload) scheduler = threadPoolCallbackRunnerUnsafe(getThreadPoolWriter(), ThreadName::REMOTE_FS_WRITE_THREAD_POOL); @@ -320,8 +353,18 @@ std::unique_ptr S3ObjectStorage::writeObject( /// NOLIN if (blob_storage_log) blob_storage_log->local_path = object.local_path; + /// The SingleAttempt profile (e.g. CAS conditional writes, RFC cas-s3-timeout-retry-control) rides + /// on WriteSettings instead of changing this disk's shared client — every other write keeps using + /// client.get() and its normal retry policy unchanged. getSingleAttemptClient() is only invoked + /// when actually selected, so a plain write never pays for building/locking the clone. + std::shared_ptr used_client; + if (write_settings.object_storage_retry_profile == ObjectStorageRetryProfile::SingleAttempt) + used_client = getSingleAttemptClient(); + else + used_client = client.get(); + return std::make_unique( - client.get(), + used_client, uri.bucket, object.remote_path, write_settings.use_adaptive_write_buffer ? write_settings.adaptive_write_buffer_initial_size : buf_size, @@ -443,6 +486,65 @@ void S3ObjectStorage::removeObjectsIfExist(const StoredObjects & objects) removeObjectsImpl(objects, true); } +ConditionalRemoveResult S3ObjectStorage::removeObjectIfTokenMatches(const StoredObject & object, const std::string & etag) +{ + S3::DeleteObjectRequest request; + request.SetBucket(uri.bucket); + request.SetKey(object.remote_path); + request.SetIfMatch(etag); + + ProfileEvents::increment(ProfileEvents::DiskS3DeleteObjects); + + auto outcome = client.get()->DeleteObject(request); + + /// Mirror removeObjectImpl (deleteFileFromS3): every conditional delete lands in + /// system.blob_storage_log too — GC reclaim was invisible there otherwise. TokenMismatch + /// and NotFound are routine protocol outcomes, recorded with the S3 error for filtering. + if (auto blob_storage_log = BlobStorageLogWriter::create(disk_name)) + blob_storage_log->addEvent(BlobStorageLogElement::EventType::Delete, + uri.bucket, object.remote_path, + object.local_path, object.bytes_size, + /* elapsed_microseconds */ 0, + outcome.IsSuccess() ? 0 : static_cast(outcome.GetError().GetErrorType()), + outcome.IsSuccess() ? "" : outcome.GetError().GetMessage()); + + if (outcome.IsSuccess()) + return {ConditionalRemoveOutcome::Removed, outcome.GetResult().GetDeleteMarker()}; + + const auto & err = outcome.GetError(); + + /// The token did not match the current incarnation: the conditional delete is rejected with a 412 + /// (see `S3::isPreconditionFailedError` for the one policy). Callers treat 'mismatch' and 'gone' + /// alike (re-validate); a genuine absence is disambiguated downstream by a HEAD re-check. + if (S3::isPreconditionFailedError(err)) + return {ConditionalRemoveOutcome::TokenMismatch, false}; + + /// The object no longer exists (404). Protocol callers treat 'mismatch' and 'gone' alike (re-validate). + if (S3::isNotFoundError(err.GetErrorType())) + return {ConditionalRemoveOutcome::NotFound, false}; + + throw S3Exception(err.GetErrorType(), + "{} (Code: {}, S3 exception: '{}') while conditionally removing object with path {} from S3", + err.GetMessage(), static_cast(err.GetErrorType()), err.GetExceptionName(), object.remote_path); +} + +bool S3ObjectStorage::conditionalOpsUseGenerationTokens() const +{ + return client.get()->usesGcsConditionalDialect(); +} + +std::optional S3ObjectStorage::isBucketVersioningEnabled() const +{ + S3::GetBucketVersioningRequest request; + request.SetBucket(uri.bucket); + + auto outcome = client.get()->GetBucketVersioning(request); + if (!outcome.IsSuccess()) + return std::nullopt; + + return outcome.GetResult().GetStatus() == Aws::S3::Model::BucketVersioningStatus::Enabled; +} + static void putObjectsTagOnS3( const std::shared_ptr & s3_client, const String & bucket, @@ -667,6 +769,66 @@ void S3ObjectStorage::copyObject( // NOLINT object_to_attributes); } +ConditionalCopyResult S3ObjectStorage::copyObjectConditional( // NOLINT + const StoredObject & object_from, + const StoredObject & object_to, + const ReadSettings & read_settings, + const WriteSettings &, + std::optional object_to_attributes) +{ + auto current_client = client.get(); + auto settings_ptr = s3_settings.get(); + + /// `copyS3File`'s `If-None-Match` precondition is only honored on the native server-side copy + /// path (`CopyObject` / `CompleteMultipartUpload`): if native copy is disabled it silently falls + /// back to an unconditional read-write copy (`copyDataToS3File`), which would defeat the + /// write-once guarantee the content-addressed staging promote relies on. Fail closed instead of + /// racing an unconditional overwrite onto what may already be a live blob. + if (!settings_ptr->request_settings[S3RequestSetting::allow_native_copy]) + throw Exception(ErrorCodes::NOT_IMPLEMENTED, + "Conditional (write-once) object copy requires the native S3 copy path, which is disabled " + "(allow_native_copy=false) for object storage {}", getName()); + + auto size = S3::getObjectSize(*current_client, uri.bucket, object_from.remote_path, {}); + auto scheduler = threadPoolCallbackRunnerUnsafe(getThreadPoolWriter(), ThreadName::S3_COPY_POOL); + const auto read_settings_to_use = patchSettings(read_settings); + + String dest_etag; + try + { + copyS3File( + /*src_s3_client=*/current_client, + /*src_bucket=*/uri.bucket, + /*src_key=*/object_from.remote_path, + /*src_offset=*/0, + /*src_size=*/size, + /*dest_s3_client=*/current_client, + /*dest_bucket=*/uri.bucket, + /*dest_key=*/object_to.remote_path, + settings_ptr->request_settings, + read_settings_to_use, + BlobStorageLogWriter::create(disk_name), + scheduler, + [&, this]{ return readObject(object_from, read_settings_to_use);}, + object_to_attributes, + /*if_none_match=*/"*", + /*out_dest_etag=*/&dest_etag); + } + catch (S3Exception & exc) + { + /// A `412 Precondition Failed` is the expected "lost the race" signal (the destination already + /// exists), not an error — see `S3Exception::isPreconditionFailed` for the one policy. + if (exc.isPreconditionFailed()) + return {.created = false, .dest_etag = {}}; + + /// Any other failure (network error, access denied, etc.) is a real error and must propagate: + /// never silently treat it as "lost the race". + throw; + } + + return {.created = true, .dest_etag = dest_etag}; +} + void S3ObjectStorage::shutdown() { /// This call stops any next retry attempts for ongoing S3 requests. @@ -756,6 +918,29 @@ std::shared_ptr S3ObjectStorage::tryGetS3StorageClient() return client.get(); } +std::shared_ptr S3ObjectStorage::getSingleAttemptClient() const +{ + auto base = client.get(); + std::lock_guard lock(single_attempt_client_mutex); + if (single_attempt_client && single_attempt_client_base == base) + return single_attempt_client; + + auto cfg = base->getClientConfiguration(); + cfg.retry_strategy.max_retries = 0; + cfg.retryStrategy = std::make_shared(); + + /// A server can reject an If-Match/If-None-Match request before accepting its body; waiting for + /// the 100-continue response avoids uploading a large body that cannot commit. Respect the + /// disk's configured expect_continue_min_bytes; if unset, use the established 1 MiB floor. + static constexpr uint64_t fallback_expect_continue_min_bytes = 1024 * 1024; + if (cfg.expect_continue_min_bytes == 0) + cfg.expect_continue_min_bytes = fallback_expect_continue_min_bytes; + + single_attempt_client = base->cloneWithConfigurationOverride(cfg); + single_attempt_client_base = base; + return single_attempt_client; +} + bool S3ObjectStorage::tryRefreshCredentialsViaCallback() { fiu_do_on(FailPoints::object_storage_force_refresh_callback_success, { return true; }); diff --git a/src/Disks/DiskObjectStorage/ObjectStorages/S3/S3ObjectStorage.h b/src/Disks/DiskObjectStorage/ObjectStorages/S3/S3ObjectStorage.h index 1760153a1219..d357c3874e78 100644 --- a/src/Disks/DiskObjectStorage/ObjectStorages/S3/S3ObjectStorage.h +++ b/src/Disks/DiskObjectStorage/ObjectStorages/S3/S3ObjectStorage.h @@ -6,6 +6,7 @@ #include #include +#include #include #include #include @@ -108,6 +109,9 @@ class S3ObjectStorage : public IObjectStorage /// `DeleteObjectsRequest` does not exist on GCS, see https://issuetracker.google.com/issues/162653700 . void removeObjectsIfExist(const StoredObjects & objects) override; + /// Uses `DeleteObjectRequest` with `If-Match` (token-exact removal for content-addressed disks). + ConditionalRemoveResult removeObjectIfTokenMatches(const StoredObject & object, const std::string & etag) override; + void tagObjects(const StoredObjects & objects, const std::string & tag_key, const std::string & tag_value) override; ObjectMetadata getObjectMetadata(const std::string & path, bool with_tags) const override; @@ -121,6 +125,17 @@ class S3ObjectStorage : public IObjectStorage const WriteSettings & write_settings, std::optional object_to_attributes = {}) override; + /// Write-once conditional server-side copy (`CopyObject`/`CompleteMultipartUpload` with + /// `If-None-Match: *`). Only performed via the native copy path (`copyS3File`'s + /// `allow_native_copy` path); if native copy is not available this throws rather than silently + /// falling back to an unconditional overwrite (see `.cpp` for details). + ConditionalCopyResult copyObjectConditional( + const StoredObject & object_from, + const StoredObject & object_to, + const ReadSettings & read_settings, + const WriteSettings & write_settings, + std::optional object_to_attributes) override; + void copyObjectToAnotherObjectStorage( /// NOLINT const StoredObject & object_from, const StoredObject & object_to, @@ -149,6 +164,12 @@ class S3ObjectStorage : public IObjectStorage bool isReadOnly() const override { return s3_settings.get()->request_settings[S3RequestSetting::read_only]; } + bool conditionalOpsUseGenerationTokens() const override; + + std::optional isBucketVersioningEnabled() const override; + + bool supportsRetryProfile(ObjectStorageRetryProfile) const override { return true; } + std::shared_ptr getS3StorageClient() override; std::shared_ptr tryGetS3StorageClient() override; @@ -156,6 +177,12 @@ class S3ObjectStorage : public IObjectStorage S3::URI getURI() const { return uri; } S3Settings getS3Settings() const { return *s3_settings.get(); } + + /// Lazily-built clone of the current disk client with the single-attempt retry profile + /// (SingleAttemptRetryStrategy, max_retries=0, Expect:100-continue floor). Rebuilt whenever the + /// disk client rotates (applyNewSettings/credentials refresh) — the cached clone is keyed by the + /// base client's identity, so a stale clone can never outlive a rotation. + std::shared_ptr getSingleAttemptClient() const; private: void removeObjectImpl(const StoredObject & object, bool if_exists); void removeObjectsImpl(const StoredObjects & objects, bool if_exists); @@ -174,6 +201,17 @@ class S3ObjectStorage : public IObjectStorage const bool for_disk_s3; S3CredentialsRefreshCallback credentials_refresh_callback; + + mutable std::mutex single_attempt_client_mutex; + mutable std::shared_ptr single_attempt_client; + /// The base client the cached clone above was built from. Deliberately held as a shared_ptr (not + /// a raw pointer): a raw pointer would be compared for identity AFTER the object it once pointed + /// to could have been freed and a new client reallocated at the same address by an unrelated + /// rotation (ABA), which would false-match and serve a stale clone (e.g. built from retired + /// credentials) indefinitely. Holding the shared_ptr pins at most one retired client version — + /// released as soon as the next rotation is observed and the clone is rebuilt — which is what + /// makes the identity comparison in getSingleAttemptClient sound. + mutable std::shared_ptr single_attempt_client_base; }; } diff --git a/src/Disks/DiskObjectStorage/ObjectStorages/S3/diskSettings.cpp b/src/Disks/DiskObjectStorage/ObjectStorages/S3/diskSettings.cpp index f95f53763de4..a72f0eb02fed 100644 --- a/src/Disks/DiskObjectStorage/ObjectStorages/S3/diskSettings.cpp +++ b/src/Disks/DiskObjectStorage/ObjectStorages/S3/diskSettings.cpp @@ -51,6 +51,7 @@ namespace S3AuthSetting extern const S3AuthSettingsString access_key_id; extern const S3AuthSettingsUInt64 connect_timeout_ms; extern const S3AuthSettingsBool disable_checksum; + extern const S3AuthSettingsUInt64 expect_continue_min_bytes; extern const S3AuthSettingsUInt64 expiration_window_seconds; extern const S3AuthSettingsBool gcs_issue_compose_request; extern const S3AuthSettingsUInt64 http_keep_alive_max_requests; @@ -178,6 +179,7 @@ getClient(const S3::URI & url, const S3Settings & settings, ContextPtr context, client_configuration.endpointOverride = url.endpoint; client_configuration.s3_use_adaptive_timeouts = auth_settings[S3AuthSetting::use_adaptive_timeouts]; + client_configuration.expect_continue_min_bytes = auth_settings[S3AuthSetting::expect_continue_min_bytes]; if (request_settings.proxy_resolver) { diff --git a/src/Disks/DiskObjectStorage/RegisterDiskObjectStorage.cpp b/src/Disks/DiskObjectStorage/RegisterDiskObjectStorage.cpp index c662bf71facc..304ca5daab4b 100644 --- a/src/Disks/DiskObjectStorage/RegisterDiskObjectStorage.cpp +++ b/src/Disks/DiskObjectStorage/RegisterDiskObjectStorage.cpp @@ -6,12 +6,19 @@ #include #include #include +#include +#include #include namespace DB { +namespace ErrorCodes +{ + extern const int BAD_ARGUMENTS; +} + void registerObjectStorages(); void registerMetadataStorages(); void registerDiskObjectStorage(DiskFactory & factory, bool global_skip_access_check); @@ -77,7 +84,21 @@ void registerDiskObjectStorage(DiskFactory & factory, bool global_skip_access_ch LOG_DEBUG(getLogger("registerDiskObjectStorage"), "Metadata type hint: {}", compatibility_metadata_type_hint); auto metadata_storage = MetadataStorageFactory::instance().create(name, config, config_prefix, cluster, object_storages, compatibility_metadata_type_hint); - bool use_fake_transaction = config.getBool(config_prefix + ".use_fake_transaction", metadata_storage->getType() != MetadataStorageType::Keeper); + /// Content-addressed metadata (like Keeper) requires real, deferred disk transactions: a part's + /// file->blob mappings are accumulated across the whole part write and the manifest + ref are + /// published atomically when the transaction commits. A fake (per-file autocommit) transaction + /// would write each file independently with no commit point for the manifest/ref publish. + const auto metadata_type = metadata_storage->getType(); + const bool needs_real_transaction = metadata_type == MetadataStorageType::Keeper + || metadata_type == MetadataStorageType::CAS; + /// An explicit `use_fake_transaction=true` on a metadata type that requires deferred + /// transactions would silently break the atomic manifest/ref publish (per-file autocommit, + /// no commit point). Reject it instead of honoring it. + if (needs_real_transaction && config.getBool(config_prefix + ".use_fake_transaction", false)) + throw Exception(ErrorCodes::BAD_ARGUMENTS, + "Disk '{}': `use_fake_transaction` cannot be enabled for metadata type '{}'", + name, magic_enum::enum_name(metadata_type)); + bool use_fake_transaction = config.getBool(config_prefix + ".use_fake_transaction", !needs_real_transaction); DiskPtr disk = std::make_shared( name, std::move(cluster), diff --git a/src/Disks/DiskType.cpp b/src/Disks/DiskType.cpp index bf4506b4cbf6..8aef6db87ee0 100644 --- a/src/Disks/DiskType.cpp +++ b/src/Disks/DiskType.cpp @@ -19,6 +19,8 @@ MetadataStorageType metadataTypeFromString(const String & type) return MetadataStorageType::Plain; if (check_type == "plain_rewritable") return MetadataStorageType::PlainRewritable; + if (check_type == "cas") + return MetadataStorageType::CAS; if (check_type == "web") return MetadataStorageType::StaticWeb; if (check_type == "keeper") diff --git a/src/Disks/DiskType.h b/src/Disks/DiskType.h index 726557d5575d..7a612d2336de 100644 --- a/src/Disks/DiskType.h +++ b/src/Disks/DiskType.h @@ -32,6 +32,7 @@ enum class MetadataStorageType : uint8_t Keeper, Plain, PlainRewritable, + CAS, StaticWeb, Memory, }; diff --git a/src/Disks/IDisk.h b/src/Disks/IDisk.h index 478795f523f1..b79a3171bde7 100644 --- a/src/Disks/IDisk.h +++ b/src/Disks/IDisk.h @@ -472,6 +472,13 @@ class IDisk : public Space /// If the disk is plain object storage. virtual bool isPlain() const { return false; } + /// If the disk is a content-addressed object-storage pool (`metadata_type = cas`). + /// A clean predicate so callers do not have to reach through `getDataSourceDescription`. + virtual bool isContentAddressed() const { return false; } + + /// True when a file write on this disk publishes atomically (see `IDataPartStorage::supportsAtomicFileWrites`). + virtual bool supportsAtomicFileWrites() const { return false; } + virtual bool isWriteOnce() const { return false; } virtual bool supportsHardLinks() const { return true; } diff --git a/src/Disks/IDiskTransaction.h b/src/Disks/IDiskTransaction.h index 720de290a4b9..db6d5b615724 100644 --- a/src/Disks/IDiskTransaction.h +++ b/src/Disks/IDiskTransaction.h @@ -138,6 +138,23 @@ struct IDiskTransaction : private boost::noncopyable /// Truncate file to the target size. virtual void truncateFile(const std::string & src_path, size_t size) = 0; + + /// In-flight read-your-writes for a part being assembled by THIS transaction (B59). Forwarded to the + /// metadata transaction by object-storage disk transactions; default (e.g. local disk) is no in-flight + /// visibility, so a reader falls through to the committed path. + virtual std::optional tryGetInFlightStorageObjects(const std::string & /*path*/) const { return {}; } + virtual std::unique_ptr tryReadFileInFlight( + const std::string & /*path*/, const ReadSettings & /*settings*/, std::optional /*read_hint*/) const { return nullptr; } + virtual std::optional tryGetInFlightFileSize(const std::string & /*path*/) const { return {}; } + /// In-flight read-your-writes at DIRECTORY granularity: true iff this transaction has STAGED at least one + /// file under `path` for `path`'s part (mirrors the file trio above). Forwarded to the metadata + /// transaction by object-storage disk transactions; default (e.g. local disk) is no in-flight directory + /// visibility, so a reader falls through to the committed path. + virtual bool hasInFlightDirectory(const std::string & /*path*/) const { return false; } + /// In-flight read-your-writes directory ENUMERATION: the immediate-child names this transaction has + /// STAGED directly under `path` (one level, the directory prefix stripped). Forwarded to the metadata + /// transaction; default (e.g. local disk) is empty. + virtual std::vector listInFlightDirectory(const std::string & /*path*/) const { return {}; } }; using DiskTransactionPtr = std::shared_ptr; diff --git a/src/Disks/ReadOnlyDiskWrapper.h b/src/Disks/ReadOnlyDiskWrapper.h index e75dd8623d24..a0c28d1c0a18 100644 --- a/src/Disks/ReadOnlyDiskWrapper.h +++ b/src/Disks/ReadOnlyDiskWrapper.h @@ -85,6 +85,11 @@ class ReadOnlyDiskWrapper : public IDisk NameSet getCacheLayersNames() const override { return delegate->getCacheLayersNames(); } MetadataStoragePtr getMetadataStorage() override { return delegate->getMetadataStorage(); } + /// Forwarded alongside getMetadataStorage: callers that gate on this predicate before reaching + /// for the metadata storage (ContentAddressedMetadataStorage::tryFromDisk and friends) must see + /// the delegate's answer through the wrapper, or a wrapped content-addressed disk silently + /// drops out of the CAS introspection paths. + bool isContentAddressed() const override { return delegate->isContentAddressed(); } std::unordered_map getSerializedMetadata(const std::vector & file_paths) const override { return delegate->getSerializedMetadata(file_paths); } diff --git a/src/Disks/tests/cas_format_test_battery.h b/src/Disks/tests/cas_format_test_battery.h new file mode 100644 index 000000000000..173bfcc5c4df --- /dev/null +++ b/src/Disks/tests/cas_format_test_battery.h @@ -0,0 +1,111 @@ +#pragma once +#include +#include +#include +#include +#include + +namespace DB::ErrorCodes +{ + extern const int CORRUPTED_DATA; + extern const int UNKNOWN_FORMAT_VERSION; +} + +/// The shape-level failure-mode battery every v3 format registers with (spec §testing): one call +/// exercises decode-of-encode, golden text, truncation at line boundaries and inside line 1, +/// the v+1 gate, wrong-type, and leading garbage. Key-level rules (tolerant/strict/critical/ +/// duplicate) are unit-tested once on JsonObjectReader — the battery stays format-agnostic. + +struct FormatBatteryCase +{ + DB::Cas::FormatId id; + std::function encode; + std::function decode; + String golden; + /// Optional format-specific construction for the unsupported-version sample. Fixed-size formats + /// use this to preserve their physical envelope while growing a version field across a digit + /// boundary; ordinary line-oriented formats use the default textual replacement below. + std::function make_future_version = {}; +}; + +/// Canonical object headers track the current compatibility generation. The type remains an +/// explicit test literal at every call site, so a registry/type mismatch cannot be hidden by a +/// self-derived expectation. +inline String currentFormatHeader(std::string_view type) +{ + return fmt::format("{{\"type\":\"{}\",\"v\":{}}}\n", type, DB::Cas::currentCompatibilityVersion()); +} + +namespace cas_battery_detail +{ +template +void expectCode(int code, F && f, const String & context) +{ + try + { + f(); + FAIL() << context << ": expected exception " << code; + } + catch (const DB::Exception & e) + { + EXPECT_EQ(e.code(), code) << context << ": " << e.message(); + } +} +} + +inline void runFormatBattery(const FormatBatteryCase & c) +{ + using namespace DB::Cas; + namespace ec = DB::ErrorCodes; + const FormatTraits & t = traitsFor(c.id); + + const String stored = c.encode(); + c.decode(stored); /// round-trip: must not throw + + /// Work on the canonical text (identical to `stored` for raw formats). + const String text = openObject(c.id, stored); + ASSERT_TRUE(text.starts_with("{\"type\":\"")) << t.type; + + if (!c.golden.empty()) + EXPECT_EQ(text, c.golden) << "golden text drifted for " << t.type; + if (looksZstd(stored) && !c.golden.empty()) + EXPECT_EQ(stored, sealObject(c.id, c.golden)) << "pinned compressed arm drifted for " << t.type; + + /// Truncation at every line boundary (drop the terminator too) fails closed. + for (size_t i = 0; i < text.size(); ++i) + if (text[i] == '\n') + cas_battery_detail::expectCode(ec::CORRUPTED_DATA, + [&] { c.decode(text.substr(0, i)); }, fmt::format("{}: cut at line boundary {}", t.type, i)); + + /// Truncation inside line 1. + const size_t line1 = text.find('\n'); + ASSERT_NE(line1, String::npos); + for (size_t i = 1; i < line1; i += 3) + cas_battery_detail::expectCode(ec::CORRUPTED_DATA, + [&] { c.decode(text.substr(0, i)); }, fmt::format("{}: cut inside header at {}", t.type, i)); + + /// v+1 gate. + const String v_now = fmt::format("\"v\":{}", currentCompatibilityVersion()); + const String v_next = fmt::format("\"v\":{}", currentCompatibilityVersion() + 1); + String future; + if (c.make_future_version) + future = c.make_future_version(text); + else + { + future = text; + future.replace(future.find(v_now), v_now.size(), v_next); + } + cas_battery_detail::expectCode(ec::UNKNOWN_FORMAT_VERSION, [&] { c.decode(future); }, + fmt::format("{}: v+1", t.type)); + + /// Wrong type: another VALID registered type in the header. + const std::string_view other = (t.id == FormatId::PoolMeta) ? "cas_owner" : "cas_pool_meta"; + String mistyped = text; + mistyped.replace(mistyped.find(t.type), t.type.size(), String(other)); + cas_battery_detail::expectCode(ec::CORRUPTED_DATA, [&] { c.decode(mistyped); }, + fmt::format("{}: wrong type", t.type)); + + /// Leading garbage. + cas_battery_detail::expectCode(ec::CORRUPTED_DATA, [&] { c.decode("X" + text); }, + fmt::format("{}: garbage byte", t.type)); +} diff --git a/src/Disks/tests/cas_sweep_test_support.h b/src/Disks/tests/cas_sweep_test_support.h new file mode 100644 index 000000000000..c1b5466a1cb1 --- /dev/null +++ b/src/Disks/tests/cas_sweep_test_support.h @@ -0,0 +1,36 @@ +#pragma once +#include +#include +#include +#include + +namespace DB::Cas::tests +{ + +/// TEST-ONLY variant of the cursor page: plans a page via the production `planManifestCursorPage` and +/// then exact-token-deletes every nomination immediately, with no source-edge retirement and no +/// `gc/state` adoption of the retirement. Production deletion always goes through `Gc::fold`'s +/// orphan_sweep phase instead, which adopts the retirements in the same round CAS before deleting — +/// this shortcut recreates the accounting hole that path exists to close, so it must never be reached +/// from a production translation unit. +inline ManifestSweepResult sweepManifestCursorPageForTest( + Pool & store, + const String & cursor, + uint64_t list_budget, + uint64_t delete_budget, + GcRoundWorkBudget * work_budget = nullptr) +{ + ManifestSweepResult result = planManifestCursorPage( + store, cursor, list_budget, delete_budget, /*catalog_recovery_authoritative=*/true, work_budget); + for (const ManifestSweepResult::Nomination & nomination : result.nominations) + { + const DeleteOutcome outcome = store.backend().deleteExact(nomination.key, nomination.token); + if (classifyDeleteOutcome(outcome) == DeleteClass::Deleted) + ++result.deleted; + else + ++result.skipped; + } + return result; +} + +} diff --git a/src/Disks/tests/cas_test_helpers.h b/src/Disks/tests/cas_test_helpers.h new file mode 100644 index 000000000000..9947c5899edb --- /dev/null +++ b/src/Disks/tests/cas_test_helpers.h @@ -0,0 +1,1994 @@ +#pragma once + +#include "config.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include + +#include +#include +#include +/// For `ChunkFaultBackend`'s `DefiniteFailure` mode, which needs a real S3-classified error, and for +/// the ambiguity it raises otherwise. +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +/// Per-TU extern declarations for the `ContentAddressedSetting` entries this header's helpers use -- +/// the established pattern for `BaseSettings`-derived classes in this codebase (see e.g. +/// `RegisterDiskCache.cpp`'s `namespace FileCacheSetting` block): the entries are DEFINED once in +/// `ContentAddressedSettings.cpp`, and each consumer TU declares only the ones it references. +namespace DB::ContentAddressedSetting +{ + extern const ContentAddressedSettingsString server_root_id; + extern const ContentAddressedSettingsString scratch_path; +} + +/// Same per-TU pattern for the error codes this header's fault backends raise (`ChunkFaultBackend`'s +/// non-S3 build of the `Definite` mode). +namespace DB::ErrorCodes +{ + extern const int CORRUPTED_DATA; +} + +namespace DB::Cas::tests +{ + +/// Bring up the server-wide blob upload pool (stage-1 §1) if it is not already up, so any test that +/// drives a `ContentAddressedTransaction` commit -- whose `uploadPendingBlobs` fans out on this pool -- +/// finds it initialized. ROBUST (init-if-not-initialized, NOT `call_once`): the raw-lifecycle suite in +/// `gtest_cas_blob_upload_pool.cpp` deliberately shuts the pool down, so a `call_once` helper would fail +/// to bring it back for a later test. A global test-event listener (`gtest_cas_blob_upload_pool_env.cpp`) +/// calls this before every test, which is what makes it robust to test ordering. +inline void ensureBlobUploadPoolForTest(size_t size = 8) +{ + if (!DB::Cas::blobUploadPoolInitializedForTest()) + DB::Cas::initializeBlobUploadPool(size); +} + + +/// Minimal `ContentAddressedSettings` for a direct-construction gtest fixture: sets only +/// `server_root_id` and `scratch_path` (the two values every positional-ctor call site used to pass +/// explicitly) and validates, so the cached enum-valued accessors (`stagingBackend`, `blobHashAlgo`, +/// `partFolderValidate`) are populated from their (default) string settings exactly as the disk-factory +/// path would populate them. Callers that need a non-default setting (e.g. `staging_backend=s3`) apply +/// the override via `settings[ContentAddressedSetting::x] = value;` and re-run `settings.validate()` +/// themselves before constructing. +inline DB::ContentAddressedSettings makeSettingsForTest(const std::string & server_root_id, const std::filesystem::path & scratch_path) +{ + DB::ContentAddressedSettings settings; + settings[DB::ContentAddressedSetting::server_root_id] = server_root_id; + settings[DB::ContentAddressedSetting::scratch_path] = scratch_path.string(); + settings.validate(); + return settings; +} + +/// Run `fn`, expect a DB::Exception with EXACTLY `expected_code` (CORRUPTED_DATA-vs-NOT_IMPLEMENTED +/// is part of the fail-closed contract: an unknown future format must be NOT_IMPLEMENTED, never +/// misreported as corruption). +template +void expectThrowsCode(int expected_code, F && fn) +{ + try + { + fn(); + FAIL() << "expected DB::Exception"; + } + catch (const DB::Exception & e) + { + EXPECT_EQ(e.code(), expected_code); + } +} + +/// Build a `LocalObjectStorage` rooted at a fresh, unique temporary directory (one per call). +/// +/// Used by the unit tests that exercise the `Cas::Backend` seam against a real on-disk object storage +/// (the `EmulatedSingleProcess` adapter mode and the capability probe). For `LocalObjectStorage` the +/// object key IS the local path verbatim, so the unique root keeps every test instance isolated even +/// under the parallel gtest runner. +inline DB::ObjectStoragePtr makeLocalObjectStorageForTest() +{ + static std::atomic counter{0}; + const auto unique = std::to_string(::getpid()) + "_" + std::to_string(counter.fetch_add(1)); + const auto root = (std::filesystem::temp_directory_path() / ("cas_unit_" + unique)).string(); + + std::error_code ec; + std::filesystem::remove_all(root, ec); + std::filesystem::create_directories(root, ec); + + DB::LocalObjectStorageSettings settings("test", root, /*read_only_=*/false); + return std::make_shared(std::move(settings)); +} + +/// Anchor a key under an object storage's own root, for the `Mode::Native` tests. +/// +/// `Mode::Native` uses a key VERBATIM as the physical `LocalObjectStorage` path — no root-prefix +/// mapping the way `EmulatedSingleProcess`'s `emuPath` does (`LocalObjectStorage::writeObject`/ +/// `readObject` pass `object.remote_path` straight through). A bare relative key like `"some/key"` +/// would therefore resolve relative to the TEST PROCESS's working directory rather than the +/// backend's own unique temp root, leaking a real file on disk that outlives the run and that a +/// later run then observes as pre-existing state. Worse for an assertion of ABSENCE: it answers +/// "absent" for a reason that has nothing to do with the property under test. +inline String nativeKeyUnder(const DB::ObjectStoragePtr & storage, const String & suffix) +{ + String root = storage->getCommonKeyPrefix(); + while (!root.empty() && root.back() == '/') + root.pop_back(); + return root + "/" + suffix; +} + +/// ---- on-storage write fixtures (shared by the Pool read/lifecycle/build tests, Tasks 9-13) ---- +/// +/// These produce objects through the SAME codecs the Pool reads — the documented on-storage +/// interface, not white-box pokes — so a test asserts a real round trip across the format boundary. + +/// CityHash128 of bytes, composed into the canonical lowercase-hex id. +inline String hexOf(const String & bytes) +{ + return getHexUIntLowercase(CityHash_v1_0_2::CityHash128(bytes.data(), bytes.size())); +} + +/// The POOL-WIDE streaming content hash (the production `HashingWriteBuffer` convention: chunked +/// CityHash128, block = DBMS_DEFAULT_HASHING_BLOCK_SIZE). Tests that exercise the copy-forward +/// VERIFICATION path must mint blob ids with THIS — the plain `idOf`/`u128Of` below are a +/// test-local convention (fine everywhere hashes are opaque; refused by the verifier). +inline String streamingHexOf(const String & payload) +{ + DB::ReadBufferFromMemory in(payload.data(), payload.size()); + DB::HashingReadBuffer hashing(in); + hashing.ignoreAll(); + return getHexUIntLowercase(hashing.getHash()); +} + +/// The content id of `bytes` as a UInt128 — definitionally consistent with `idOf` (parses the same hex). +inline DB::UInt128 u128Of(const String & bytes) +{ + return DB::Cas::hexToU128(hexOf(bytes)); +} + +/// The content id of `bytes` as a `BlobRef` (CityHash128 — every test pool's default write algo). +inline DB::Cas::BlobRef idOf(const String & bytes) +{ + return DB::Cas::BlobRef{DB::Cas::BlobHashAlgo::CityHash128, DB::Cas::BlobDigest::fromU128(u128Of(bytes))}; +} + +/// Write a Blob object: a fixed-length (blob_header_len) envelope followed by the raw payload, keyed +/// by content. Mirrors what PartWriteTxn::putBlob will emit (Task 11). +inline DB::Cas::BlobRef writeBlobRaw( + DB::Cas::Backend & backend, const DB::Cas::Layout & layout, const String & payload, + uint64_t blob_header_len, [[maybe_unused]] const DB::UInt128 & domain_id) +{ + const DB::Cas::BlobRef id = idOf(payload); + + /// v3 envelope: domain_id/hash_algo dropped (identity is the content key); the `domain_id` param + /// is kept for call-site compatibility but no longer stamped. + DB::Cas::EnvelopeHeader header; + header.kind = DB::Cas::ObjectKind::Blob; + header.incarnation_tag = DB::UInt128(0x1234); + header.build_id = DB::UInt128(0x5678); + + const String head = DB::Cas::encodeEnvelopeHeader(header, static_cast(blob_header_len)); + backend.putIfAbsent(layout.blobKey(id), head + payload); + return id; +} + +/// Forward declaration: `appendOwnerEvent` (below) calls `registerNamespaceRaw`, which after Task 4 +/// is a no-op (LIST-based discovery needs no explicit registration) defined further down. +inline void registerNamespaceRaw( + DB::Cas::Backend & backend, const DB::Cas::Layout & layout, const DB::Cas::RootNamespace & ns); + +/// Write a part-manifest body object directly via the manifest codec, exactly as PartWriteTxn::stageManifest +/// emits it. Returns the ManifestId. Used by GC fold/retire/fsck tests to stage owner targets. +inline DB::Cas::ManifestId writeManifestRaw( + DB::Cas::Backend & backend, const DB::Cas::Layout & layout, + const DB::Cas::RootNamespace & ns, const DB::Cas::ManifestRef & ref, + const std::vector & entries) +{ + const DB::Cas::ManifestId id{ns, ref}; + DB::Cas::PartManifest body; + body.ref = ref; + body.root_namespace_id = ns; + body.entries = entries; + body.payload_digest = DB::Cas::computePayloadDigest(body); + backend.putIfAbsent(layout.manifestKey(id), + DB::Cas::sealObject(DB::Cas::FormatId::PartManifest, DB::Cas::encodePartManifest(body))); + return id; +} + +/// A blob ManifestEntry referencing `hash` at `path` (size 1, the GC fold counts edges, not bytes). +inline DB::Cas::ManifestEntry blobEntryFor(const String & path, const DB::UInt128 & hash, uint64_t size = 1) +{ + DB::Cas::ManifestEntry e; + e.path = path; + e.placement = DB::Cas::EntryPlacement::Blob; + e.ref = DB::Cas::BlobRef{DB::Cas::BlobHashAlgo::CityHash128, DB::Cas::BlobDigest::fromU128(hash)}; + e.blob_size = size; + return e; +} + +/// Forward declarations of the ref snapshot+log raw fixtures defined further down (they emit the +/// snapshot+log objects GC and recovery actually read); the seeding wrappers below emit through them. +inline void writeRefLogTxnRaw( + DB::Cas::Backend & backend, const DB::Cas::Layout & layout, const DB::Cas::RefLogTxn & txn); +namespace fixture +{ + inline DB::Cas::NamespaceLifeId fixtureLife(const DB::Cas::RootNamespace & ns); +} +inline void publishRecoverableCkptForSemanticWrapper( + DB::Cas::Backend & backend, const DB::Cas::Layout & layout, const DB::Cas::RootNamespace & ns, + const DB::Cas::RefTxnId & txn_id); +inline DB::Cas::RefOp namespaceBirthOp(); +inline std::vector publishCommittedOps( + const String & ref_name, const DB::Cas::ManifestRef & manifest_ref); + +/// One `owner_transition` op built from an optional old/new `RefOwnerBinding` (removal = old set / new +/// unset; add-precommit = new set / old unset; promote = both set naming the SAME manifest). +inline DB::Cas::RefOp ownerTransitionOp( + std::optional old_binding, std::optional new_binding) +{ + DB::Cas::RefOp op; + op.kind = DB::Cas::RefOpKind::OwnerTransition; + op.old_binding = std::move(old_binding); + op.new_binding = std::move(new_binding); + return op; +} + +/// Seed ONE ref-log transaction directly into a table's `_log/` stream -- the snapshot+log replacement +/// for the removed mutable-shard `appendOwnerEvent`. LIST the table's ref prefix, find the greatest +/// existing log/snapshot `ref_sequence` (and whether ANY log or snapshot exists at all), prepend a +/// `namespace_birth` op iff the table has none yet, allocate `txn_id = {writer_epoch=1, greatest+1}`, +/// and write `RefLogTxn{ns, txn_id, ops}` (no `prev_epoch_seal` -- this fixture never crosses an +/// epoch transition) via `writeRefLogTxnRaw`. Returns the allocated `ref_sequence`. +/// `ops` must form a REPLAY-VALID transaction: `fsck`/recovery replay them through the same state +/// machine the writer uses, and the GC edge extractor reads their manifest edges. The bytes are real +/// wire-format (the same codec `Pool`'s recovery reads) -- never hand-rolled. +inline uint64_t appendRefLogSeed( + DB::Cas::Backend & backend, const DB::Cas::Layout & layout, + const DB::Cas::RootNamespace & ns, std::vector ops) +{ + /// Stage B (Task 4-C): resolve to whichever life is ALREADY on record (real production birth or the + /// sentinel), exactly as `writeRefLogTxnRaw` below now does -- otherwise this scan can miss a REAL + /// incarnation's existing log/snap objects, wrongly conclude the table has none, and prepend a second + /// `namespaceBirthOp` on top of a namespace that already has one. + const NamespaceLifeId life = CasRefCatalog::lifeIfCataloged(backend, layout, ns).value_or(fixture::fixtureLife(ns)); + const String prefix = layout.namespaceStreamPrefix(life); + uint64_t greatest_seq = 0; + bool any_log_or_snap = false; + String cursor; + while (true) + { + const DB::Cas::ListPage page = backend.list(prefix, cursor, /*limit=*/1000); + for (const DB::Cas::ListedKey & lk : page.keys) + { + const auto parsed = layout.parseRefObjectKey(lk.key); + if (!parsed) + continue; + if (parsed->kind == DB::Cas::RefObjectKind::Log || parsed->kind == DB::Cas::RefObjectKind::Snap) + { + any_log_or_snap = true; + greatest_seq = std::max(greatest_seq, parsed->txn_id.ref_sequence); + } + } + if (page.next_cursor.empty()) + break; + cursor = page.next_cursor; + } + + if (!any_log_or_snap) + ops.insert(ops.begin(), namespaceBirthOp()); + + DB::Cas::RefLogTxn txn; + txn.ns = ns.string(); + txn.txn_id = DB::Cas::RefTxnId{/*writer_epoch=*/1, /*ref_sequence=*/greatest_seq + 1}; + txn.ops = std::move(ops); + writeRefLogTxnRaw(backend, layout, txn); + return txn.txn_id.ref_sequence; +} + +/// Append ONE `owner_transition` op as a standalone ref-log transaction. `shard` is ignored (the +/// immutable ref model has no per-shard journal); it stays in the signature so existing shard-passing +/// callers compile unchanged. Returns the allocated `ref_sequence`. +inline uint64_t appendOwnerEvent( + DB::Cas::Backend & backend, const DB::Cas::Layout & layout, + const DB::Cas::RootNamespace & ns, uint64_t /*shard*/, + std::optional old_binding, + std::optional new_binding) +{ + return appendRefLogSeed(backend, layout, ns, {ownerTransitionOp(std::move(old_binding), std::move(new_binding))}); +} + +/// Publish a committed ref over `ref_name` (no old unless `old_ref` set). Emits a REPLAY-VALID +/// transaction: an optional owner-removal of the old committed binding, then add-precommit + promote of +/// the new manifest (spec §State Transitions has no direct "add committed" shape). Edges: -1(old)+1(new) +/// or +1(new). Returns the allocated `ref_sequence`. +inline uint64_t publishCommittedTransition( + DB::Cas::Backend & backend, const DB::Cas::Layout & layout, const DB::Cas::RootNamespace & ns, + const String & ref_name, std::optional old_ref, const DB::Cas::ManifestRef & new_ref, + uint64_t /*shard*/ = 0) +{ + std::vector ops; + if (old_ref) + ops.push_back(ownerTransitionOp( + DB::Cas::RefOwnerBinding{DB::Cas::RefOwnerKind::Committed, ref_name, *old_ref}, std::nullopt)); + const std::vector commit_ops = publishCommittedOps(ref_name, new_ref); + ops.insert(ops.end(), commit_ops.begin(), commit_ops.end()); + const uint64_t sequence = appendRefLogSeed(backend, layout, ns, std::move(ops)); + publishRecoverableCkptForSemanticWrapper(backend, layout, ns, RefTxnId{1, sequence}); + return sequence; +} + +/// Drop a committed ref (old committed / new none). Edge -1. Returns the allocated `ref_sequence`. +inline uint64_t dropRefTransition( + DB::Cas::Backend & backend, const DB::Cas::Layout & layout, const DB::Cas::RootNamespace & ns, + const String & ref_name, const DB::Cas::ManifestRef & old_ref, uint64_t /*shard*/ = 0) +{ + const uint64_t sequence = appendRefLogSeed(backend, layout, ns, + {ownerTransitionOp(DB::Cas::RefOwnerBinding{DB::Cas::RefOwnerKind::Committed, ref_name, old_ref}, std::nullopt)}); + publishRecoverableCkptForSemanticWrapper(backend, layout, ns, RefTxnId{1, sequence}); + return sequence; +} + +/// Add a precommit binding (optional owner-removal of a stale committed manifest, then add-precommit of +/// the new manifest). Edge -1(old)+1(new) or +1(new). `build_id` is dropped (RefLog bindings carry no +/// build_id; build identity lives in `manifest_ref`). Returns the allocated `ref_sequence`. +inline uint64_t addPrecommitTransition( + DB::Cas::Backend & backend, const DB::Cas::Layout & layout, const DB::Cas::RootNamespace & ns, + const DB::UInt128 & /*build_id*/, const String & final_ref_name, std::optional old_ref, + const DB::Cas::ManifestRef & new_ref, uint64_t /*shard*/ = 0) +{ + std::vector ops; + if (old_ref) + ops.push_back(ownerTransitionOp( + DB::Cas::RefOwnerBinding{DB::Cas::RefOwnerKind::Committed, final_ref_name, *old_ref}, std::nullopt)); + ops.push_back(ownerTransitionOp( + std::nullopt, DB::Cas::RefOwnerBinding{DB::Cas::RefOwnerKind::Precommit, final_ref_name, new_ref})); + const uint64_t sequence = appendRefLogSeed(backend, layout, ns, std::move(ops)); + publishRecoverableCkptForSemanticWrapper(backend, layout, ns, RefTxnId{1, sequence}); + return sequence; +} + +/// Promote a precommit to committed at the SAME manifest_ref (old=Precommit, new=Committed). No edge +/// (net-zero owner move). `build_id` is dropped. Returns the allocated `ref_sequence`. +inline uint64_t promoteTransition( + DB::Cas::Backend & backend, const DB::Cas::Layout & layout, const DB::Cas::RootNamespace & ns, + const DB::UInt128 & /*build_id*/, const String & final_ref_name, const DB::Cas::ManifestRef & ref, + uint64_t /*shard*/ = 0) +{ + const uint64_t sequence = appendRefLogSeed(backend, layout, ns, + {ownerTransitionOp( + DB::Cas::RefOwnerBinding{DB::Cas::RefOwnerKind::Precommit, final_ref_name, ref}, + DB::Cas::RefOwnerBinding{DB::Cas::RefOwnerKind::Committed, final_ref_name, ref})}); + publishRecoverableCkptForSemanticWrapper(backend, layout, ns, RefTxnId{1, sequence}); + return sequence; +} + +/// Exact-token delete of a manifest body (HEAD then deleteExact). No-op when absent. +inline void deleteManifestBody( + DB::Cas::Backend & backend, const DB::Cas::Layout & layout, const DB::Cas::ManifestId & id) +{ + const String key = layout.manifestKey(id); + const DB::Cas::HeadResult h = backend.head(key); + if (h.exists) + backend.deleteExact(key, h.token); +} + +/// Formerly wrote the namespace into `gc/registry`. Real write helpers now admit the authoritative +/// catalog row themselves, so this legacy fixture hook has no independent registration work. +inline void registerNamespaceRaw( + DB::Cas::Backend & /*backend*/, const DB::Cas::Layout & /*layout*/, const DB::Cas::RootNamespace & /*ns*/) +{ + /// No-op: Task 4 deleted the registry; `cas/ref_catalog` is now the discovery authority. +} + +/// Encode a CAGS document carrying only {round} — everything else defaulted. Callers that only care +/// about this field (e.g. `injectRetire`) use this shorthand. +inline String encodeMinimalGcState(uint64_t round) +{ + DB::Cas::GcState state; + state.round = round; + return DB::Cas::encodeGcState(state); +} + +/// Inject condemned bookkeeping + gc/state directly (bypassing a real GC round) so a test can seed the +/// GC ledger's condemned state at an arbitrary round. Retired-in-snapshot: the condemned entries are +/// seeded the way a real round leaves them — as `kCondemned` sentinel rows inside an adopted fold seal's +/// shard run (there is no separate retired-list object). A synthetic +edge/-edge pair nets each blob to +/// in-degree 0 and a `seed_head` replays the captured token/size so the fold mints the `kCondemned` row. +/// Also sets {round} on gc/state. Entries carry a `condemn_round` (default 0 → uses `round`); callers +/// pass fresh (non-pending) condemns. An empty `entries` set just advances {round}. +inline void injectRetire( + DB::Cas::Backend & backend, const DB::Cas::Layout & layout, + uint64_t round, uint64_t shard, std::vector entries) +{ + DB::Cas::GcState gc_state; + const DB::Cas::HeadResult head = backend.head(layout.gcStateKey()); + if (head.exists) + gc_state = DB::Cas::decodeGcState(backend.get(layout.gcStateKey())->bytes); + gc_state.round = round; + + if (!entries.empty()) + { + const uint64_t generation = 1; + const uint64_t attempt = 1; + uint64_t condemn_round = round; + std::unordered_map seeded; + std::vector synth; + synth.reserve(entries.size() * 2); + for (const DB::Cas::RetiredEntry & e : entries) + { + if (e.condemn_round) + condemn_round = e.condemn_round; + seeded.emplace(e.ref, DB::Cas::HeadResult{.exists = true, .size = e.size, .token = e.token, .attributes = {}}); + synth.push_back(DB::Cas::BlobDelta{.ref = e.ref, .source_id = DB::UInt128{1}, .remove = false}); + synth.push_back(DB::Cas::BlobDelta{.ref = e.ref, .source_id = DB::UInt128{1}, .remove = true}); + } + const auto seed_head = [&seeded](const DB::Cas::BlobRef & h) -> std::optional + { + const auto it = seeded.find(h); + return it == seeded.end() ? std::nullopt : std::optional(it->second); + }; + std::vector out; + DB::Cas::foldDeltasIntoGeneration(backend, layout, /*prior_runs*/{}, generation, attempt, + shard, std::move(synth), out, /*current_round*/0, condemn_round, seed_head, + /*peek_head*/{}, /*confirm_condemned_marker*/{}, + /*out_retired*/nullptr, /*suppress_destructive*/false); + + DB::Cas::CasFoldSeal seal; + seal.generation = generation; + for (DB::Cas::RunRef & r : out) + seal.blob_target_runs.push_back(std::move(r)); + /// Totality over gc_shards so a later real round's graduation/carry reads it zero-I/O. + const uint64_t gc_shards = gc_state.gc_shards ? gc_state.gc_shards : 1; + for (uint64_t s = 0; s < gc_shards; ++s) + seal.condemned_summary[s] = DB::Cas::CondemnedSummary{}; + DB::Cas::CondemnedSummary cs; + cs.condemned_total = entries.size(); + cs.oldest_nonpending_condemn_round = condemn_round; + seal.condemned_summary[shard] = cs; + backend.putIfAbsent(layout.foldSealKey(generation, attempt), DB::Cas::encodeFoldSeal(seal)); + + gc_state.snap_generation = generation; + gc_state.snap_attempt = attempt; + } + + const String state = DB::Cas::encodeGcState(gc_state); + if (!head.exists) + backend.putIfAbsent(layout.gcStateKey(), state); + else + backend.putOverwrite(layout.gcStateKey(), state, head.token); +} + +/// Adopt a fold seal carrying a given per-gc-shard `condemned_summary` (retired-in-snapshot T4) and point +/// gc/state at it (snap_generation / snap_attempt / gc_shards), bypassing a real GC round. If a seal +/// already exists at (generation, attempt) it is overwritten with the new summary (its other fields are +/// preserved); otherwise a fresh minimal seal is created. Read-modify-CAS on gc/state preserves the lease. +/// Used by graduationDue tests to drive the zero-I/O signal directly off a controlled seal. +inline void injectCondemnedSummarySeal( + DB::Cas::Backend & backend, const DB::Cas::Layout & layout, + uint64_t generation, uint64_t attempt, uint64_t gc_shards, + const std::map & summary) +{ + const String seal_key = layout.foldSealKey(generation, attempt); + DB::Cas::CasFoldSeal seal; + const auto existing = backend.get(seal_key); + if (existing) + seal = DB::Cas::decodeFoldSeal(existing->bytes); + else + seal.parent_generation = generation ? generation - 1 : 0; + seal.generation = generation; + seal.condemned_summary = summary; + const String seal_bytes = DB::Cas::encodeFoldSeal(seal); + if (existing) + backend.putOverwrite(seal_key, seal_bytes, existing->token); + else + backend.putIfAbsent(seal_key, seal_bytes); + + DB::Cas::GcState gc_state; + const DB::Cas::HeadResult head = backend.head(layout.gcStateKey()); + if (head.exists) + gc_state = DB::Cas::decodeGcState(backend.get(layout.gcStateKey())->bytes); + gc_state.gc_shards = gc_shards; + gc_state.snap_generation = generation; + gc_state.snap_attempt = attempt; + const String state = DB::Cas::encodeGcState(gc_state); + if (!head.exists) + backend.putIfAbsent(layout.gcStateKey(), state); + else + backend.putOverwrite(layout.gcStateKey(), state, head.token); +} + +/// Whether blob `hash` is absent from the backend (its exact-token content object is gone). +inline bool blobAbsent(DB::Cas::Backend & backend, const DB::Cas::Layout & layout, const DB::UInt128 & hash) +{ + return !backend.head(layout.blobKey(DB::Cas::BlobRef{DB::Cas::BlobHashAlgo::CityHash128, DB::Cas::BlobDigest::fromU128(hash)})).exists; +} + +/// ONE round that is allowed to RECLAIM -- the name is the point, so that grepping for the tests whose +/// subject is reclamation finds exactly them. +/// +/// The policy is spelled out rather than defaulted so that grepping this name finds every test whose +/// subject is reclamation, and so that a future change to the default cannot silently change what those +/// tests mean. It says the same thing the production default says (`UniversePolicy`); a test whose +/// subject is a SUPPRESSOR passes `StageA_Suppressed` explicitly instead. +inline DB::Cas::RoundReport runRegularRoundReclaiming(DB::Cas::Gc & gc) +{ + return gc.runRegularRound({}, /*allow_steal*/true, DB::Cas::UniversePolicy::Authoritative); +} + +/// Reclaim loop (the canonical retired-cursor pipeline driver): run regular rounds, renewing the store's +/// own heartbeat after each round (`renewWatermarkOnce` — keeps the lease + build-watermark floor +/// current; unrelated to graduation, which paces on GC rounds alone). A blob condemned at round K is +/// deleted by round K+2 (condemn at K -> graduate to delete_pending at K+1, unconditionally -> physical +/// delete at K+2). Returns true as soon as the blob became absent. Reclamation is the whole point of the +/// loop, so every round it drives is an authoritative one. +inline bool runRoundsUntilAbsent( + const DB::Cas::PoolPtr & store, DB::Cas::Gc & gc, DB::Cas::Backend & backend, + const DB::Cas::Layout & layout, const DB::UInt128 & hash, int max_rounds = 8) +{ + for (int i = 0; i < max_rounds; ++i) + { + runRegularRoundReclaiming(gc); + store->renewWatermarkOnce(); + if (blobAbsent(backend, layout, hash)) + return true; + } + return blobAbsent(backend, layout, hash); +} + +/// The CURRENT condemned entries for `shard`, read from the adopted fold seal's `blob_target_runs` +/// (retired-in-snapshot T4): the round no longer writes a separate retired-list object — condemned +/// entries RIDE the source-edge run as `kCondemned` sentinel rows at the zero-sentinel key. This reads +/// the seal at (snap_generation, snap_attempt), opens every run for `shard`, and reconstructs the +/// `RetiredEntry` shape (hash from the run key, the rest from the decoded `CondemnedRow`). Empty when +/// gc/state / the seal / the runs are absent. Used by ack-floor tests to assert pending/condemn state. +inline std::vector currentRetiredSet( + DB::Cas::Backend & backend, const DB::Cas::Layout & layout, uint64_t shard) +{ + const auto st = backend.get(layout.gcStateKey()); + if (!st) + return {}; + const DB::Cas::GcState gc_state = DB::Cas::decodeGcState(st->bytes); + if (gc_state.snap_generation == 0) + return {}; + const auto seal_bytes = backend.get(layout.foldSealKey(gc_state.snap_generation, gc_state.snap_attempt)); + if (!seal_bytes) + return {}; + const DB::Cas::CasFoldSeal seal = DB::Cas::decodeFoldSeal(seal_bytes->bytes); + + std::vector out; + for (const DB::Cas::RunRef & run : seal.blob_target_runs) + { + if (run.shard != shard) + continue; + auto r = DB::Cas::openSourceEdgeRun(backend, run.key); + String k; + String p; + while (r.next(k, p)) + { + if (p.empty() || p[0] != DB::Cas::kCondemned) + continue; + DB::Cas::BlobRef ref; + DB::UInt128 source_id{}; + DB::Cas::SourceEdgeKeyCodec::parse(k, ref, source_id); // throws CORRUPTED_DATA on malformed (fail-closed) + const DB::Cas::CondemnedRow row = DB::Cas::decodeCondemnedRow(p); + out.push_back(DB::Cas::RetiredEntry{ + .kind = DB::Cas::ObjectKind::Blob, + .ref = ref, + .token = row.token, + .size = row.size, + .condemn_round = row.condemn_round, + .delete_pending = row.delete_pending, + .marker_confirmed = row.marker_confirmed}); + } + } + return out; +} + +/// True iff ANY gc-shard's adopted-seal run still holds a `kCondemned` row — the ack-floor deletion +/// pipeline is in flight while this is true (retired-in-snapshot T4 replacement for the old +/// "iterate gc/state.retired_refs" probe). `gc_shards` is read from gc/state when 0 is passed. +inline bool anyCondemnedInSeal( + DB::Cas::Backend & backend, const DB::Cas::Layout & layout, uint64_t gc_shards = 0) +{ + const auto st = backend.get(layout.gcStateKey()); + if (!st) + return false; + const DB::Cas::GcState gc_state = DB::Cas::decodeGcState(st->bytes); + const uint64_t shards = gc_shards ? gc_shards : gc_state.gc_shards; + for (uint64_t shard = 0; shard < shards; ++shard) + if (!currentRetiredSet(backend, layout, shard).empty()) + return true; + return false; +} + +/// Displace a blob's incarnation out-of-band (as a racing writer would): GET it, mint a fresh +/// incarnation_tag in its envelope header (preserving header_len + payload), putOverwrite against the +/// current token, and return the NEW token. Used to drive the W-REVALIDATE adopt branch (current token +/// differs from the writer's stale observation). +inline DB::Cas::Token displaceObjectToken( + DB::Cas::Backend & backend, const String & key, DB::Cas::ObjectKind kind) +{ + const auto got = backend.get(key); + if (!got) + throw DB::Exception(DB::ErrorCodes::BAD_ARGUMENTS, "displaceObjectToken: object {} absent", key); + + DB::Cas::EnvelopeHeader header = + DB::Cas::decodeEnvelopeHeader(got->bytes, got->bytes.size(), kind); + /// A fresh, distinct incarnation_tag forces a distinct body so the displaced token differs. + header.incarnation_tag = header.incarnation_tag + DB::UInt128(1); + /// Re-encode at the SAME header length the object was decoded with (the v3 pad target). + const String new_head = DB::Cas::encodeEnvelopeHeader(header, header.header_len); + const String body = new_head + got->bytes.substr(header.header_len); + + return backend.putOverwrite(key, body, got->token).token; +} + +inline DB::Cas::Token displaceBlobToken( + DB::Cas::Backend & backend, const DB::Cas::Layout & layout, const DB::Cas::BlobRef & id) +{ + return displaceObjectToken(backend, layout.blobKey(id), DB::Cas::ObjectKind::Blob); +} + +/// ---- GC-core (Phase 1d) test helpers over the part-manifest model ---- + +/// Open a Pool over `backend`. +/// +/// `gc_fold_max_defer_rounds` defaults to the PoolConfig default (8) -- unchanged behaviour for every +/// existing caller. A test that drives MANY consecutive genuinely-idle `runRegularRound` calls and +/// asserts each one performs a full fold (round/generation advance, trim/sweep/retention) -- exactly +/// what Phase-4 Lever A (spec 2026-07-06-cas-gc-round-skip-unchanged) is designed to skip -- passes 0 +/// here to force fold-every-round (shouldDeferRound's liveness bound: rounds_since_last_fold(0) >= 0 +/// is always true). +inline DB::Cas::PoolPtr openPoolForTest( + std::shared_ptr backend, uint64_t gc_fold_max_defer_rounds = 8) +{ + return DB::Cas::Pool::open(std::move(backend), + DB::Cas::PoolConfig{.pool_prefix = "p", .server_root_id = "test", + .gc_fold_max_defer_rounds = gc_fold_max_defer_rounds}); +} + +/// Seed the mandatory control objects for an already-existing pool so a subsequent `Pool::open` +/// VALIDATES a restart instead of bootstrapping a fresh one. Recovery/replay tests seed ref-log, +/// snapshot, manifest, or gc-state residue directly into a bare backend; in production such residue +/// only ever exists inside a pool whose FIRST open already minted `_pool_meta` and explicitly +/// initialized `cas/ref_catalog`. Task 7's zero-write bootstrap check (spec §2 [C4][D2], +/// `probePoolBootstrapResidual`) REFUSES to bootstrap over residual data, so a raw restart fixture must +/// establish both mandatory objects itself rather than rely on a production fallback. +/// +/// Idempotent: `createOrValidate` validates an existing `_pool_meta`. The catalog initializer is +/// deliberately narrower: it accepts only a canonical EMPTY conflict, so raw recovery fixtures that +/// have already populated their catalog must mandatory-read and validate it instead of re-running a +/// new-pool initializer. The default `blob_header_len`/`blob_hash_algo` match `PoolConfig`'s defaults, +/// so a later `Pool::open` with a default config validates cleanly. +inline void seedPoolMetaForRestart( + DB::Cas::Backend & backend, const String & pool_prefix = "p", uint64_t gc_shards = 1) +{ + const DB::Cas::Layout layout(pool_prefix); + DB::Cas::PoolMeta::createOrValidate( + backend, layout, /*blob_header_len=*/256, gc_shards, + DB::Cas::BlobHashAlgo::CityHash128, /*allow_new=*/false, /*allow_mint=*/true); + if (!backend.get(layout.refCatalogKey())) + DB::Cas::CasRefCatalog::initializeEmptyForNewPool(backend, layout); + else + (void)DB::Cas::CasRefCatalog::read(backend, layout); +} + +/// Write a blob object (envelope + payload) addressed by `hash`, so a HEAD returns a token. The bytes +/// are arbitrary (GC never reads them); the hash is what the manifest entry references. +inline void writeBlobBody( + DB::Cas::Backend & backend, const DB::Cas::Layout & layout, const DB::UInt128 & hash, + uint64_t blob_header_len = 256) +{ + DB::Cas::EnvelopeHeader header; + header.kind = DB::Cas::ObjectKind::Blob; + header.incarnation_tag = DB::UInt128(0x1234); + header.build_id = DB::UInt128(0x5678); + const String head = DB::Cas::encodeEnvelopeHeader(header, static_cast(blob_header_len)); + backend.putIfAbsent(layout.blobKey(DB::Cas::BlobRef{DB::Cas::BlobHashAlgo::CityHash128, DB::Cas::BlobDigest::fromU128(hash)}), head + String("x")); +} + +/// Write a raw blob body (payload written verbatim, no envelope) — the raw-body-refinement shape +/// (Phase B): the meta descriptor (via the ops layer below) carries all state, the body carries none. +inline void writeRawBlobBody(DB::Cas::Backend & backend, const DB::Cas::Layout & layout, + const DB::UInt128 & hash, const String & payload) +{ + backend.casPut(layout.blobKey(DB::Cas::BlobRef{DB::Cas::BlobHashAlgo::CityHash128, DB::Cas::BlobDigest::fromU128(hash)}), payload, std::nullopt); +} + +/// These `UInt128`-hash meta-op wrappers are the pre-mixed-algo 128-bit-only test convenience surface: +/// every existing caller operates on a 128-bit (`cityHash128`) test pool, so the ref is built at +/// `CityHash128` here. The shared `.meta` API (Phase 3 T3) is `BlobRef`-keyed directly and derives its +/// own codec internally — no codec is threaded from here anymore. +inline DB::Cas::BlobRef legacyMetaTestRef(const DB::UInt128 & hash) +{ + return DB::Cas::BlobRef{DB::Cas::BlobHashAlgo::CityHash128, DB::Cas::BlobDigest::fromU128(hash)}; +} + +/// Create a Clean meta descriptor for `hash` directly in a test backend. This setup helper deliberately +/// stays usable before a `Pool` is open; production writes use `putMetaIfAbsent` through the controller. +inline void writeMetaClean(DB::Cas::Backend & backend, const DB::Cas::Layout & layout, + const DB::UInt128 & hash, uint64_t size) +{ + const DB::Cas::BlobRef ref = legacyMetaTestRef(hash); + backend.putIfAbsent(layout.blobMetaKey(ref), DB::Cas::encodeBlobMeta( + DB::Cas::BlobMeta{.state = DB::Cas::MetaState::Clean, .condemn_round = 0, .size = size})); +} + +/// Transition an existing meta descriptor to Condemned at `condemn_round`, via a read-modify-CAS on +/// its current token (asserts the meta exists — a direct test setup helper, not production code). +inline void condemnMeta(DB::Cas::Backend & backend, const DB::Cas::Layout & layout, + const DB::UInt128 & hash, uint64_t condemn_round) +{ + const DB::Cas::BlobRef ref = legacyMetaTestRef(hash); + const auto lm = DB::Cas::loadMeta(backend, layout, ref); + ASSERT_TRUE(lm.has_value()); + DB::Cas::BlobMeta c = lm->meta; + c.state = DB::Cas::MetaState::Condemned; + c.condemn_round = condemn_round; + backend.putOverwrite(layout.blobMetaKey(ref), DB::Cas::encodeBlobMeta(c), lm->etag); +} + +/// Load the meta descriptor for `hash` via the shared ops layer (nullopt = absent). +inline std::optional loadMetaForTest(DB::Cas::Backend & backend, + const DB::Cas::Layout & layout, const DB::UInt128 & hash) +{ + return DB::Cas::loadMeta(backend, layout, legacyMetaTestRef(hash)); +} + +/// The latest GC generation (snap_generation pointer in gc/state), or 0 when absent. +inline uint64_t currentGenerationOf(DB::Cas::Backend & backend, const DB::Cas::Layout & layout) +{ + const auto got = backend.get(layout.gcStateKey()); + if (!got) + return 0; + return DB::Cas::decodeGcState(got->bytes).snap_generation; +} + +/// The adopted attempt (snap_attempt pointer in gc/state), or 0 when absent. +inline uint64_t currentAttemptOf(DB::Cas::Backend & backend, const DB::Cas::Layout & layout) +{ + const auto got = backend.get(layout.gcStateKey()); + if (!got) + return 0; + return DB::Cas::decodeGcState(got->bytes).snap_attempt; +} + +/// The current seal's `blob_target_runs` filtered to `shard` (2026-07-02 T0: consumers resolve runs +/// through seal refs, not by key construction). Scans downward from the current generation for the most +/// recent existing fold seal (mirrors `foldCursorOf`'s reasoning); absent => empty. +inline std::vector runsForShard( + DB::Cas::Backend & backend, const DB::Cas::Layout & layout, uint64_t shard) +{ + const uint64_t gen = currentGenerationOf(backend, layout); + const uint64_t attempt = currentAttemptOf(backend, layout); + for (uint64_t g = gen; ; --g) + { + if (const auto got = backend.get(layout.foldSealKey(g, attempt))) + { + const DB::Cas::CasFoldSeal seal = DB::Cas::decodeFoldSeal(got->bytes); + std::vector out; + for (const DB::Cas::RunRef & r : seal.blob_target_runs) + if (r.shard == shard) + out.push_back(r); + return out; + } + if (g == 0) + return {}; + } +} + +/// Stream the sealed in-degree run segments `runs` and count the active source edges (`kEdgeActive` +/// rows) for `ref`. Test-side replacement for the deleted per-blob point query `inDegreeInGeneration` +/// (codecs-v3 phase 5: a `cas_run` is a sequential NDJSON stream with no random access, so a blob's +/// in-degree is recomputed by a full stream-and-count rather than a seek). A condemned / zero-marker +/// row is not an active edge, so it contributes 0 — matching the old point query's semantics. +inline int64_t inDegreeInRuns( + DB::Cas::Backend & backend, const std::vector & runs, const DB::Cas::BlobRef & ref) +{ + int64_t degree = 0; + for (const DB::Cas::RunRef & run : runs) + { + auto r = DB::Cas::openSourceEdgeRun(backend, run.key); + String k; + String p; + while (r.next(k, p)) + { + if (p.empty() || p[0] != DB::Cas::kEdgeActive) + continue; + DB::Cas::BlobRef row_ref; + DB::UInt128 source_id{}; + DB::Cas::SourceEdgeKeyCodec::parse(k, row_ref, source_id); // throws CORRUPTED_DATA on malformed (fail-closed) + if (row_ref == ref) + ++degree; + } + } + return degree; +} + +/// The in-degree of a blob in the current GC generation's sealed run (0 when absent/zeroed). +inline int64_t inDegreeOf(DB::Cas::Backend & backend, const DB::Cas::Layout & layout, const DB::UInt128 & hash) +{ + return inDegreeInRuns(backend, runsForShard(backend, layout, /*shard*/0), legacyMetaTestRef(hash)); +} + +/// The single named entry point for the nonproduction CA shapes this test tree constructs directly, +/// rather than through the production birth/write paths. Every raw fixture below is one of three +/// deliberate divergences from what production can ever produce, gathered here under one name so a +/// future change to any of them has exactly one place to change, not every call site that needs it: +/// 1. `fixtureLife` returns a DETERMINISTIC namespace-derived life identity, never a fresh random +/// mint the way a real birth (`CasRefCatalog::createNamespace`) would -- opaque and catalog-born +/// in production, but every raw fixture below needs to derive the SAME identity a namespace's +/// catalog entry will carry before that entry exists, so its writes and a later read agree on +/// where to look. +/// 2. `admitLive` reaches `Live` with NO `_ckpt` at all. Production only ever reaches `Live` through +/// `completeCreation`, which publishes `_ckpt` FIRST; this shape is kept deliberately, because +/// recovery and failure tests need to exercise a `Live` or `Removing` row missing that authority. +/// 3. `writeRefLogRaw` writes ref-log bytes directly at the resolved fixture identity, bypassing the +/// writer's own birth/append lane entirely -- exercising the on-storage object shape a real writer +/// would emit without driving a real writer to produce it. +namespace fixture +{ + /// The deterministic identity a raw fixture uses for a namespace before any catalog entry exists: + /// a stable hash of the namespace name, so two fixture writes against the same namespace (and a + /// later read) always agree on where to look, without needing a catalog entry to agree through. + /// Production incarnations are always catalog-minted (`CasRefCatalog::createNamespace`); this is + /// deliberately not that, and every raw fixture below depends on it staying stable byte-for-byte. + inline DB::Cas::NamespaceLifeId fixtureLife(const DB::Cas::RootNamespace & ns) + { + UInt128 fixture_incarnation = sipHash128(ns.string().data(), ns.string().size()); + if (fixture_incarnation == 0) + fixture_incarnation = 1; + return DB::Cas::NamespaceLifeId::fromCatalogEntry(ns, fixture_incarnation); + } +} + +/// Resolve the opaque life id that keys this namespace's single fold-coverage row. +inline UInt128 catalogLifeIdForTest( + DB::Cas::Backend & backend, const DB::Cas::Layout & layout, const DB::Cas::RootNamespace & ns) +{ + const std::optional life = + DB::Cas::CasRefCatalog::lifeIfCataloged(backend, layout, ns); + chassert(life.has_value()); + return life->incarnation; +} + +/// Seed the ADOPTED fold seal's catalog-life coverage row for `ns` and point `gc/state` at it, bypassing a +/// real round. This is the durable fact the sweep's §6 deletion premise reads +/// (`CasOrphanManifestSweep.cpp`): `cursor` is the namespace's `last_folded_ref_id`, and a manifest of +/// an epoch-`E` build is deletable only once that cursor sits in an epoch STRICTLY above `E`. +/// `hold`, when set, makes the row classification 4 — the strict grammar `encodeFoldSeal` enforces in +/// both directions, so a hold and a non-4 classification cannot be seeded together. +/// +/// SHARP EDGE, HANDLED HERE SO NO CALLER HAS TO KNOW IT: a fold seal must carry a `condemned_summary` +/// entry for EVERY shard in `0..gc_shards-1`. A later real round adopts this object as its PARENT and +/// throws `CORRUPTED_DATA` — "parent fold seal (generation G, attempt A) lacks a condemned_summary +/// entry for gc-shard N — the seal is not total over gc_shards" — on a seal that is missing one. The +/// symptom is nowhere near the cause: the round fails at fold time, or (if it fails before taking the +/// lease) merely reports `acquired_lease == false`, so a test that seeds a partial seal looks like a +/// leadership problem. This helper fills the map from `gc/state`'s own `gc_shards`, so seeding a +/// coverage row is safe to combine with real rounds. +/// +/// One thing it does NOT do: create `gc/state` in a state a first-ever `Gc` round can take the lease +/// over. `acquireOrRenewLease` only creates-and-owns when `gc/state` is ABSENT, so seeding before the +/// first round makes that round back off. Seed AFTER the first round (passing that round's +/// `currentGenerationOf`/`currentAttemptOf`) when a test drives real rounds. +inline void seedFoldCursorForTest( + DB::Cas::Backend & backend, const DB::Cas::Layout & layout, const DB::Cas::RootNamespace & ns, + DB::Cas::RefTxnId cursor, std::optional hold = std::nullopt, + uint64_t generation = 1, uint64_t attempt = 1) +{ + DB::Cas::NamespaceLifeId life = fixture::fixtureLife(ns); + const DB::Cas::CasRefCatalog::Snapshot catalog_cut = DB::Cas::CasRefCatalog::read(backend, layout); + const auto catalog_it = std::find_if( + catalog_cut.catalog.entries.begin(), catalog_cut.catalog.entries.end(), + [&](const DB::Cas::CatalogEntry & entry) { return entry.ns.string() == ns.string(); }); + if (catalog_it == catalog_cut.catalog.entries.end()) + { + DB::Cas::CatalogEntry entry; + entry.ns = ns; + entry.state = DB::Cas::NsState::Live; + entry.incarnation = fixture::fixtureLife(ns).incarnation; + DB::Cas::CasRefCatalog::casAdmitEntry(backend, layout, 1, entry); + life = DB::Cas::NamespaceLifeId::fromCatalogEntry(ns, entry.incarnation); + } + else + { + life = DB::Cas::NamespaceLifeId::fromCatalogEntry(ns, catalog_it->incarnation); + } + + const String seal_key = layout.foldSealKey(generation, attempt); + DB::Cas::CasFoldSeal seal; + const auto existing = backend.get(seal_key); + if (existing) + seal = DB::Cas::decodeFoldSeal(existing->bytes); + seal.generation = generation; + + DB::Cas::RefCoverage cov; + cov.classification = hold ? 4 : 2; + cov.last_folded_ref_id = cursor; + cov.hold = hold; + seal.ref_lives[life.incarnation].coverage = cov; + + DB::Cas::GcState gc_state; + const DB::Cas::HeadResult head = backend.head(layout.gcStateKey()); + if (head.exists) + gc_state = DB::Cas::decodeGcState(backend.get(layout.gcStateKey())->bytes); + + /// Totality over `gc_shards` — see the doc comment's SHARP EDGE note for what throws without it. + const uint64_t gc_shards = gc_state.gc_shards ? gc_state.gc_shards : 1; + for (uint64_t s = 0; s < gc_shards; ++s) + seal.condemned_summary.emplace(s, DB::Cas::CondemnedSummary{}); + + const String seal_bytes = DB::Cas::encodeFoldSeal(seal); + if (existing) + backend.putOverwrite(seal_key, seal_bytes, existing->token); + else + backend.putIfAbsent(seal_key, seal_bytes); + + gc_state.snap_generation = generation; + gc_state.snap_attempt = attempt; + const String state = DB::Cas::encodeGcState(gc_state); + if (!head.exists) + backend.putIfAbsent(layout.gcStateKey(), state); + else + backend.putOverwrite(layout.gcStateKey(), state, head.token); +} + +/// The folded cursor sealed for (ns, shard) by the latest fold seal, or 0 when absent. After a COMPLETE +/// round the gc/state generation pointer is the recheck's COMPLETION generation (G+2 for a round started +/// at G), but the fold seal is written at the FOLD generation (G+1) — recheck writes a completion seal, +/// not a fold seal. So scan downward from the current generation for the most recent existing fold seal. +inline uint64_t foldCursorOf( + DB::Cas::Backend & backend, const DB::Cas::Layout & layout, const DB::Cas::RootNamespace & ns, uint64_t shard) +{ + chassert(shard == 0); + const std::optional life = + DB::Cas::CasRefCatalog::lifeIfCataloged(backend, layout, ns); + if (!life) + return 0; + const uint64_t gen = currentGenerationOf(backend, layout); + const uint64_t attempt = currentAttemptOf(backend, layout); + for (uint64_t g = gen; ; --g) + { + if (const auto got = backend.get(layout.foldSealKey(g, attempt))) + { + const DB::Cas::CasFoldSeal seal = DB::Cas::decodeFoldSeal(got->bytes); + const auto it = seal.ref_lives.find(life->incarnation); + /// Snapshot+log ref model: the per-table durable cursor is `last_folded_ref_id` (a RefTxnId). + /// Seeds allocate `writer_epoch = 1`, so the `ref_sequence` is the monotone cursor the seeding + /// wrappers return and tests compare against. + return it != seal.ref_lives.end() ? it->second.coverage.last_folded_ref_id.ref_sequence : 0; + } + if (g == 0) + return 0; + } +} + +/// Set a server root's durable floor (so orphan-sweep eligibility can be driven). After the ack-floor +/// merge the floor rides the mount lease body (`mountKey`), so this seeds a MountLease carrying +/// `{writer_epoch, min_active}` — exactly what `prefixEligible` reads. +inline void setWatermarkMinActive( + DB::Cas::Backend & backend, const DB::Cas::Layout & layout, const String & server_root_id, + uint64_t writer_epoch, uint64_t min_active) +{ + DB::Cas::MountLease m; + m.server_uuid = DB::UInt128(0); + m.writer_epoch = writer_epoch; + m.min_active = min_active; + m.seq = 1; + const String key = layout.mountKey(server_root_id); + const DB::Cas::HeadResult h = backend.head(key); + if (h.exists) + backend.putOverwrite(key, DB::Cas::encodeMountLease(m), h.token); + else + backend.putIfAbsent(key, DB::Cas::encodeMountLease(m)); +} + +/// ---- Task 10 ref snapshot+log raw fixtures ---- +/// Mirror the pre-Task-10 `appendOwnerEvent`/`publishRaw` helpers above, but for the new snapshot+log +/// object layout: write a ref-object body directly via the SAME codecs `Pool`'s recovery reads, +/// bypassing the writer's own append lane entirely. Used to seed pre-existing table state before a +/// fresh `Pool` ever touches the namespace (recovery tests), and to control exact keys/bytes +/// (restart-on-vanish tests). + +/// Writes `snapshot` at `_snap/.proto` (create-if-absent). Keys at whichever life the +/// namespace's catalog entry ALREADY names (a prior real birth's random incarnation, or the sentinel +/// if none exists yet -- see `writeRefLogTxnRaw`'s identical note); does NOT itself admit an entry, so +/// a namespace this helper is the ONLY writer for stays exactly as invisible to the catalog as it was +/// before Task 4-C (unchanged from this helper's own pre-existing scope). +inline void writeRefSnapshotRaw( + DB::Cas::Backend & backend, const DB::Cas::Layout & layout, const DB::Cas::RefTableSnapshot & snapshot) +{ + const DB::Cas::RootNamespace ns{snapshot.ns}; + const NamespaceLifeId life = CasRefCatalog::lifeIfCataloged(backend, layout, ns).value_or(fixture::fixtureLife(ns)); + const String key = layout.refSnapshotKey(life, snapshot.snapshot_id); + backend.putIfAbsent(key, DB::Cas::sealObject(DB::Cas::FormatId::RefSnapshot, DB::Cas::encodeRefTableSnapshot(snapshot))); +} + +/// Admits `ns` into the catalog as a `Live` entry, IDEMPOTENTLY (a no-op once `ns` already carries +/// any entry, of any state -- a test that drove one there itself through the real catalog API is left +/// alone). Pinned to the deterministic `fixture::fixtureLife` incarnation, NOT +/// `CasRefCatalog::createNamespace`'s fresh-random mint: every raw fixture below keys its ref-log/ +/// snapshot objects at that SAME derived id, so a randomly minted incarnation would not match them and +/// the fold's own R10 incarnation filter (`{#r10-groupref-alias}`) would drop every one of their keys +/// as belonging to a dead life. +/// +/// All ten raw-write helpers place ref-log bytes at states production's real birth path structurally +/// cannot produce (INV-1 holes, out-of-order ids, a table with no `_ckpt` -- see the Task 4-B map), so +/// they can never route through `createNamespace` and mint a real incarnation of their own. +/// +/// TWO DIVERGENCES from what `createNamespace`/`completeCreation` would produce, both deliberate and +/// both left as-is rather than "fixed": +/// 1. the incarnation is a deterministic namespace-derived fixture id, not a fresh random mint; +/// 2. this entry reaches `Live` with NO `_ckpt` at all, whereas production only ever reaches `Live` +/// through `completeCreation`, which publishes `_ckpt` FIRST (INV-4). Several fixtures exist +/// SPECIFICALLY to build a table with no `_ckpt`, but they must exercise that corruption directly: +/// lifecycle-authoritative recovery correctly rejects a `Live` or `Removing` row without a +/// readable `life_epoch`. Ordinary fixtures use `casAdmitRecoverableEntry` below instead. +inline void casAdmitEntry(DB::Cas::Backend & backend, const DB::Cas::Layout & layout, const DB::Cas::RootNamespace & ns) +{ + const CasRefCatalog::Snapshot snap = CasRefCatalog::read(backend, layout); + for (const CatalogEntry & entry : snap.catalog.entries) + if (entry.ns.string() == ns.string()) + return; /// already admitted -- by an earlier raw write to the same namespace, or by the + /// test itself + CatalogEntry entry; + entry.ns = ns; + entry.state = NsState::Live; + entry.incarnation = fixture::fixtureLife(ns).incarnation; + CasRefCatalog::casAdmitEntry(backend, layout, 1, entry); +} + +namespace fixture +{ + /// The admit-Live-without-`_ckpt` pattern (divergence 2 above), reachable through the seam. + inline void admitLive(DB::Cas::Backend & backend, const DB::Cas::Layout & layout, const DB::Cas::RootNamespace & ns) + { + casAdmitEntry(backend, layout, ns); + } +} + +/// Write the checkpoint frontier that makes a raw `Live` fixture a normal recoverable life. Raw logs +/// intentionally do not synthesize `_ckpt`: many tests need the missing-checkpoint corruption shape. +/// A test that invokes lifecycle-authoritative recovery therefore has to state its exact frontier here. +inline void writeRecoverableCkptForRawFixture( + DB::Cas::Backend & backend, const DB::Cas::Layout & layout, const DB::Cas::RootNamespace & ns, + const DB::Cas::RefCkpt & ckpt) +{ + const CasRefCatalog::Snapshot catalog_cut = CasRefCatalog::read(backend, layout); + const auto it = std::find_if( + catalog_cut.catalog.entries.begin(), catalog_cut.catalog.entries.end(), + [&] (const CatalogEntry & entry) { return entry.ns == ns; }); + if (it == catalog_cut.catalog.entries.end()) + throw DB::Exception(DB::ErrorCodes::CORRUPTED_DATA, + "raw recovery fixture for namespace '{}' has no catalog entry", ns.string()); + + const NamespaceLifeId life = NamespaceLifeId::fromCatalogEntry(it->ns, it->incarnation); + const PutResult put = backend.putIfAbsent(layout.refCkptKey(life), encodeRefCkpt(ckpt)); + if (put.outcome != PutOutcome::Done) + throw DB::Exception(DB::ErrorCodes::CORRUPTED_DATA, + "raw recovery fixture for namespace '{}' could not publish its checkpoint", ns.string()); +} + +/// Advance an existing recoverable raw fixture's exact checkpoint frontier. This intentionally never +/// creates a missing `_ckpt` or repairs an invalid one: those are distinct raw corruption fixtures. +inline void advanceRecoverableCkptForRawFixture( + DB::Cas::Backend & backend, const DB::Cas::Layout & layout, const DB::Cas::RootNamespace & ns, + const DB::Cas::RefTxnId & through) +{ + const CasRefCatalog::Snapshot catalog_cut = CasRefCatalog::read(backend, layout); + const auto it = std::find_if( + catalog_cut.catalog.entries.begin(), catalog_cut.catalog.entries.end(), + [&] (const CatalogEntry & entry) { return entry.ns == ns; }); + if (it == catalog_cut.catalog.entries.end()) + throw DB::Exception(DB::ErrorCodes::CORRUPTED_DATA, + "raw recovery fixture for namespace '{}' has no catalog entry", ns.string()); + + const NamespaceLifeId life = NamespaceLifeId::fromCatalogEntry(it->ns, it->incarnation); + const std::optional sample = readCkpt(backend, layout, life); + if (!sample) + throw DB::Exception(DB::ErrorCodes::CORRUPTED_DATA, + "raw recovery fixture for namespace '{}' has no checkpoint to advance", ns.string()); + + chooseRecoveryGrounding(*it, sample->ckpt); + if (!sample->ckpt.committed_through || through <= *sample->ckpt.committed_through) + throw DB::Exception(DB::ErrorCodes::CORRUPTED_DATA, + "raw recovery fixture for namespace '{}' cannot advance its checkpoint monotonically", ns.string()); + + RefCkpt advanced = sample->ckpt; + advanced.committed_through = through; + if (backend.casPut(layout.refCkptKey(life), encodeRefCkpt(advanced), sample->token).outcome != CasOutcome::Committed) + throw DB::Exception(DB::ErrorCodes::CORRUPTED_DATA, + "raw recovery fixture for namespace '{}' could not advance its checkpoint", ns.string()); +} + +/// Replace an existing recoverable raw fixture checkpoint with the caller's complete next state. +/// Unlike `advanceRecoverableCkptForRawFixture`, this does not preserve any field implicitly: callers +/// that model a snapshot or epoch-seal change must name the entire authoritative checkpoint. Missing or +/// invalid current checkpoints stay corruption fixtures and are never repaired here. +inline void replaceRecoverableCkptForRawFixture( + DB::Cas::Backend & backend, const DB::Cas::Layout & layout, const DB::Cas::RootNamespace & ns, + const DB::Cas::RefCkpt & next) +{ + const CasRefCatalog::Snapshot catalog_cut = CasRefCatalog::read(backend, layout); + const auto it = std::find_if( + catalog_cut.catalog.entries.begin(), catalog_cut.catalog.entries.end(), + [&] (const CatalogEntry & entry) { return entry.ns == ns; }); + if (it == catalog_cut.catalog.entries.end()) + throw DB::Exception(DB::ErrorCodes::CORRUPTED_DATA, + "raw recovery fixture for namespace '{}' has no catalog entry", ns.string()); + + const NamespaceLifeId life = NamespaceLifeId::fromCatalogEntry(it->ns, it->incarnation); + const std::optional existing = readCkpt(backend, layout, life); + if (!existing) + throw DB::Exception(DB::ErrorCodes::CORRUPTED_DATA, + "raw recovery fixture for namespace '{}' has no checkpoint to replace", ns.string()); + + chooseRecoveryGrounding(*it, existing->ckpt); + chooseRecoveryGrounding(*it, next); + if (next.life_epoch != existing->ckpt.life_epoch) + throw DB::Exception(DB::ErrorCodes::CORRUPTED_DATA, + "raw recovery fixture for namespace '{}' cannot replace its checkpoint with a different life epoch", ns.string()); + if (existing->ckpt.committed_through + && (!next.committed_through || *next.committed_through < *existing->ckpt.committed_through)) + throw DB::Exception(DB::ErrorCodes::CORRUPTED_DATA, + "raw recovery fixture for namespace '{}' cannot regress its checkpoint frontier", ns.string()); + + if (backend.casPut(layout.refCkptKey(life), encodeRefCkpt(next), existing->token).outcome != CasOutcome::Committed) + throw DB::Exception(DB::ErrorCodes::CORRUPTED_DATA, + "raw recovery fixture for namespace '{}' could not replace its checkpoint", ns.string()); +} + +/// Publish the checkpoint authority a semantic fixture wrapper owes immediately after its durable raw +/// log transaction. Raw writers deliberately do not call this: missing, stale, and malformed `_ckpt` +/// fixtures are meaningful corruption inputs. A semantic wrapper creates the first valid authority or +/// advances the existing exact checkpoint without discarding its snapshot or epoch-seal fields. +inline void publishRecoverableCkptForSemanticWrapper( + DB::Cas::Backend & backend, const DB::Cas::Layout & layout, const DB::Cas::RootNamespace & ns, + const DB::Cas::RefTxnId & txn_id) +{ + const std::optional life = CasRefCatalog::lifeIfCataloged(backend, layout, ns); + if (!life) + throw DB::Exception(DB::ErrorCodes::CORRUPTED_DATA, + "semantic ref fixture for namespace '{}' was not admitted", ns.string()); + + if (!readCkpt(backend, layout, *life)) + { + const PutResult put = backend.putIfAbsent(layout.refCkptKey(*life), encodeRefCkpt(RefCkpt{ + .life_epoch = 1, + .committed_through = txn_id, + .checkpoint_snapshot_id = std::nullopt, + .last_epoch_seal = std::nullopt, + })); + if (put.outcome == PutOutcome::Done) + return; + } + + advanceRecoverableCkptForRawFixture(backend, layout, ns, txn_id); +} + +/// Admit an otherwise empty `Live` fixture together with the immutable checkpoint authority that a +/// production-created life already has. This is deliberately a SEPARATE helper from `casAdmitEntry`: +/// raw fixtures that exercise a missing or corrupt `_ckpt` must keep constructing that invalid shape +/// explicitly. The empty frontier is valid because no raw log has been published yet; a fixture that +/// seeds logs instead has to name its own exact `committed_through` through +/// `writeRecoverableCkptForRawFixture`. +inline void casAdmitRecoverableEntry( + DB::Cas::Backend & backend, const DB::Cas::Layout & layout, const DB::Cas::RootNamespace & ns, + uint64_t life_epoch = 1) +{ + casAdmitEntry(backend, layout, ns); + + const std::optional life = CasRefCatalog::lifeIfCataloged(backend, layout, ns); + if (!life) + throw DB::Exception(DB::ErrorCodes::CORRUPTED_DATA, + "recoverable raw fixture for namespace '{}' was not admitted", ns.string()); + + if (backend.head(layout.refCkptKey(*life)).exists) + return; + + writeRecoverableCkptForRawFixture(backend, layout, ns, RefCkpt{ + .life_epoch = life_epoch, + .committed_through = std::nullopt, + .checkpoint_snapshot_id = std::nullopt, + .last_epoch_seal = std::nullopt, + }); +} + +/// Recover from the caller's catalog cut, reading `_ckpt` exactly once for the row in that same cut. +/// Keeping the cut an argument forces raw-fixture consumers to make the immutable authority visible; +/// this helper never resolves the namespace or re-reads the catalog on their behalf. +inline DB::Cas::RecoveredRefTable recoverRefTableDetailedAtCatalogCutForTest( + DB::Cas::Backend & backend, const DB::Cas::Layout & layout, const CasRefCatalog::Snapshot & catalog_cut, + const DB::Cas::RootNamespace & ns) +{ + std::optional catalog_entry; + const auto it = std::find_if( + catalog_cut.catalog.entries.begin(), catalog_cut.catalog.entries.end(), + [&] (const CatalogEntry & entry) { return entry.ns == ns; }); + if (it != catalog_cut.catalog.entries.end()) + catalog_entry = *it; + + std::optional ckpt; + if (catalog_entry) + { + const NamespaceLifeId life = NamespaceLifeId::fromCatalogEntry(catalog_entry->ns, catalog_entry->incarnation); + if (const std::optional sample = readCkpt(backend, layout, life)) + ckpt = sample->ckpt; + } + + return recoverRefTableDetailedFromAuthority(backend, layout, catalog_entry, ckpt); +} + +/// Writes `txn` at `_log/` (create-if-absent). Admits `txn.ns` into the catalog first +/// (`casAdmitEntry`, above) -- the fold's universe is catalog-authoritative (Task 4-C), so a raw +/// fixture that skipped this would be invisible to GC/rebuild/fsck no matter what it wrote to `_log`. +/// +/// KEYS AT THE NAMESPACE'S CURRENT CATALOG LIFE, NOT UNCONDITIONALLY AT THE SENTINEL: a test that +/// mixes a REAL birth (`beginPartWrite`/`precommitAdd`, which mints a real random incarnation via +/// `CasRefLedger::resolveNamespaceLife`) with a raw follow-up write to the SAME namespace (a +/// repoint/removal simulation, say) needs this write to land where the real content already lives, not +/// at an unrelated sentinel prefix the fold never reads for that namespace. `casAdmitEntry` above is a +/// no-op once any entry exists, so resolving the catalog life here yields whichever life is ALREADY on +/// record -- the real one if a real birth landed first, the fixture identity if this call is what +/// admitted it (via `casAdmitEntry`, moments ago, in this same function). +inline void writeRefLogTxnRaw( + DB::Cas::Backend & backend, const DB::Cas::Layout & layout, const DB::Cas::RefLogTxn & txn) +{ + const DB::Cas::RootNamespace ns{txn.ns}; + casAdmitEntry(backend, layout, ns); + const NamespaceLifeId life = CasRefCatalog::lifeIfCataloged(backend, layout, ns).value_or(fixture::fixtureLife(ns)); + const String key = layout.refLogKey(life, txn.txn_id); + backend.putIfAbsent(key, DB::Cas::sealObject(DB::Cas::FormatId::RefLog, DB::Cas::encodeRefLogTxn(txn))); +} + +namespace fixture +{ + /// The raw ref-log write pattern (divergence 3 above), reachable through the seam. + inline void writeRefLogRaw(DB::Cas::Backend & backend, const DB::Cas::Layout & layout, const DB::Cas::RefLogTxn & txn) + { + writeRefLogTxnRaw(backend, layout, txn); + } +} + +/// A `Live` snapshot naming exactly `committed` (already-sorted-by-ref_name input expected) with no +/// precommits — the common recovery-fixture shape. +inline DB::Cas::RefTableSnapshot minimalLiveSnapshot( + const String & ns, DB::Cas::RefTxnId snapshot_id, std::vector committed = {}) +{ + DB::Cas::RefTableSnapshot s; + s.ns = ns; + s.snapshot_id = snapshot_id; + s.committed = std::move(committed); + return s; +} + +/// One committed row naming `ref_name` -> `manifest_ref` with `published_at_ms` left at its default +/// (0, unset) — for tests that don't care about the publish stamp. +inline DB::Cas::RefCommittedRow committedRow(const String & ref_name, const DB::Cas::ManifestRef & manifest_ref) +{ + DB::Cas::RefCommittedRow row; + row.ref_name = ref_name; + row.manifest_ref = manifest_ref; + return row; +} + +/// A `namespace_birth` op — the first op any never-born table's first transaction needs. +inline DB::Cas::RefOp namespaceBirthOp() +{ + DB::Cas::RefOp op; + op.kind = DB::Cas::RefOpKind::NamespaceBirth; + return op; +} + +/// An `epoch_seal` op — the record that CLOSES an epoch (INV-2). A seal transaction carries exactly +/// this op and nothing else (grammar enforced by the codec in both directions). The next epoch's first +/// transaction names the seal it consumed in `prev_epoch_seal`, and that back-chain is what lets a fold +/// cross epochs without trusting a listing. +inline DB::Cas::RefOp epochSealOp() +{ + DB::Cas::RefOp op; + op.kind = DB::Cas::RefOpKind::EpochSeal; + return op; +} + +/// Write ONE ref-log transaction at an EXACT id. `appendRefLogSeed` and the wrappers above ALLOCATE +/// ids arithmetically inside writer epoch 1, so anything that needs a chosen id — a gap, an +/// out-of-order arrival, or an epoch CROSSING — writes through here instead. The bytes go through the +/// real codec, so every grammar rule the fold's decoder enforces is enforced here too. +inline void writeTxnAt( + DB::Cas::Backend & backend, const DB::Cas::Layout & layout, const DB::Cas::RootNamespace & ns, + const DB::Cas::RefTxnId & id, std::vector ops, + std::optional prev_epoch_seal = std::nullopt) +{ + DB::Cas::RefLogTxn txn; + txn.ns = ns.string(); + txn.txn_id = id; + txn.ops = std::move(ops); + txn.prev_epoch_seal = prev_epoch_seal; + writeRefLogTxnRaw(backend, layout, txn); +} + +/// Close an epoch at exactly `id`. +inline void writeSealAt( + DB::Cas::Backend & backend, const DB::Cas::Layout & layout, const DB::Cas::RootNamespace & ns, + const DB::Cas::RefTxnId & id, std::optional prev_epoch_seal = std::nullopt) +{ + writeTxnAt(backend, layout, ns, id, {epochSealOp()}, prev_epoch_seal); +} + +/// Publish `ref_name` -> a fresh manifest pinning `blob`, as ONE transaction at exactly `id` +/// (add-precommit + promote, the only shape that reaches a committed owner). `birth` prepends the +/// `namespace_birth` op the table's first transaction owes; `prev_epoch_seal` is required on sequence 1 +/// of every epoch above the namespace's genesis. The manifest's prefix is +/// `{id.writer_epoch, build_sequence}`, so a caller controlling `build_sequence` also controls whether +/// the orphan sweep's watermark considers that manifest eligible. +inline void publishAt( + DB::Cas::Backend & backend, const DB::Cas::Layout & layout, const DB::Cas::RootNamespace & ns, + const DB::Cas::RefTxnId & id, const String & ref_name, uint64_t build_sequence, const DB::UInt128 & blob, + bool birth = false, std::optional prev_epoch_seal = std::nullopt) +{ + const DB::Cas::ManifestRef mref{.writer_epoch = id.writer_epoch, .build_sequence = build_sequence, + .manifest_ordinal = 1}; + writeBlobBody(backend, layout, blob); + writeManifestRaw(backend, layout, ns, mref, {blobEntryFor("data.bin", blob)}); + + std::vector ops; + if (birth) + ops.push_back(namespaceBirthOp()); + for (const DB::Cas::RefOp & op : publishCommittedOps(ref_name, mref)) + ops.push_back(op); + writeTxnAt(backend, layout, ns, id, std::move(ops), prev_epoch_seal); +} + +/// The two ops a fixture transaction needs to go straight from nothing to a committed ref (spec +/// §State Transitions has no direct "add committed" shape — only precommit -> promote): an +/// `owner_transition` add-precommit followed by an `owner_transition` promote of the SAME +/// (ref_name, manifest_ref). Legal as the tail of one transaction whose earlier ops (if any) left the +/// table `Live` (prepend `namespaceBirthOp()` for a never-born table). +inline std::vector publishCommittedOps(const String & ref_name, const DB::Cas::ManifestRef & manifest_ref) +{ + DB::Cas::RefOp add; + add.kind = DB::Cas::RefOpKind::OwnerTransition; + add.new_binding = DB::Cas::RefOwnerBinding{DB::Cas::RefOwnerKind::Precommit, ref_name, manifest_ref}; + + DB::Cas::RefOp promote; + promote.kind = DB::Cas::RefOpKind::OwnerTransition; + promote.old_binding = DB::Cas::RefOwnerBinding{DB::Cas::RefOwnerKind::Precommit, ref_name, manifest_ref}; + promote.new_binding = DB::Cas::RefOwnerBinding{DB::Cas::RefOwnerKind::Committed, ref_name, manifest_ref}; + + return {add, promote}; +} + +/// Counts head/get/putIfAbsent per key for op-count assertions (Pillar B / A1 tests). +class CountingBackend : public DB::Cas::InMemoryBackend +{ +public: + /// Unhide the base convenience overloads (omitted Range/ObjectMeta/expected-token forms): the + /// overrides below would otherwise shadow them for callers holding a concrete backend type. + using DB::Cas::Backend::get; + using DB::Cas::Backend::getStream; + using DB::Cas::Backend::putIfAbsent; + using DB::Cas::Backend::putIfAbsentStream; + using DB::Cas::Backend::putOverwrite; + using DB::Cas::Backend::casPut; + + DB::Cas::HeadResult head(const String & key) override + { + { + std::lock_guard lock(count_mutex); + ++head_counts[key]; + ++head_total; + } + return InMemoryBackend::head(key); + } + + std::optional get(const String & key, DB::Cas::Range range) override + { + { + std::lock_guard lock(count_mutex); + ++get_counts[key]; + ++get_total; + /// Record the request-size shape per key so streaming-memory gates (Task 3/4) can assert + /// the resident-memory bound at the seam: a whole-object read (range.whole()) is a + /// violation for a run object; a ranged read tracks its MAX window length per key. + if (range.whole()) + ++whole_get_counts[key]; + else + { + const uint64_t len = range.length.has_value() ? *range.length : 0; + uint64_t & mx = max_ranged_get_len[key]; + mx = std::max(mx, len); + } + } + return InMemoryBackend::get(key, range); + } + + std::optional getStream(const String & key, DB::Cas::Range range) override + { + { + std::lock_guard lock(count_mutex); + ++get_stream_counts[key]; + ++get_stream_total; + } + return InMemoryBackend::getStream(key, range); + } + + DB::Cas::ListPage list(const String & prefix, const String & cursor, size_t limit) override + { + { + std::lock_guard lock(count_mutex); + ++list_counts[prefix]; + ++list_total; + } + return InMemoryBackend::list(prefix, cursor, limit); + } + + DB::Cas::PutResult putIfAbsent(const String & key, const String & bytes, const DB::Cas::ObjectMeta & meta) override + { + { + std::lock_guard lock(count_mutex); + ++put_counts[key]; + ++put_total; + } + return InMemoryBackend::putIfAbsent(key, bytes, meta); + } + + + /// Counted separately from `putIfAbsent` and `casPut`, for the same reason those two are separate: a + /// replacement conditioned on an expected token is its own op with its own cost. The namespace-file + /// request-profile goldens tell the create path from the replace path on exactly this counter. + DB::Cas::PutResult putOverwrite(const String & key, const String & bytes, const DB::Cas::Token & expected, + const DB::Cas::ObjectMeta & meta) override + { + { + std::lock_guard lock(count_mutex); + ++put_overwrite_counts[key]; + ++put_overwrite_total; + } + return InMemoryBackend::putOverwrite(key, bytes, expected, meta); + } + + /// Counted separately from `putIfAbsent`: a token-CAS is a DIFFERENT op with a different cost, and + /// the `_ckpt` no-op contract ("identical merged body issues no write") is asserted on exactly this + /// counter -- a create-if-absent count would not see the replace path at all. + DB::Cas::CasResult casPut(const String & key, const String & bytes, + const std::optional & expected, const DB::Cas::ObjectMeta & meta) override + { + { + std::lock_guard lock(count_mutex); + ++cas_put_counts[key]; + ++cas_put_total; + } + return InMemoryBackend::casPut(key, bytes, expected, meta); + } + /// Every ATTEMPTED delete is counted, whatever the backend answers. The destructive gate's tests + /// assert that a suppressed round issues NONE, and an attempt that came back `NotFound` is still an + /// attempt -- counting only successful ones would let a gate that leaks deletes over already-absent + /// keys read as green. + DB::Cas::DeleteOutcome deleteExact(const String & key, const DB::Cas::Token & token) override + { + { + std::lock_guard lock(count_mutex); + ++delete_counts[key]; + ++delete_total; + } + return InMemoryBackend::deleteExact(key, token); + + } + + uint64_t headCount(const String & key) const { return lookup(head_counts, key); } + uint64_t casPutCount(const String & key) const { return lookup(cas_put_counts, key); } + uint64_t putOverwriteCount(const String & key) const { return lookup(put_overwrite_counts, key); } + uint64_t getCount(const String & key) const { return lookup(get_counts, key); } + uint64_t putCount(const String & key) const { return lookup(put_counts, key); } + uint64_t deleteCount(const String & key) const { return lookup(delete_counts, key); } + uint64_t deleteTotal() const { std::lock_guard lock(count_mutex); return delete_total; } + /// Attempted deletes against any key whose path CONTAINS `substr` — the per-site assertion the + /// destructive-gate tests make ("the generation prune deleted nothing", "the sweep deleted nothing"). + uint64_t deleteCountForKeysContaining(const String & substr) const + { + std::lock_guard lock(count_mutex); + uint64_t total = 0; + for (const auto & [key, n] : delete_counts) + if (key.find(substr) != String::npos) + total += n; + return total; + } + /// Every key this backend was ever asked to delete, in sorted order — so a failing zero-delete + /// assertion names the sites that leaked instead of just reporting a count. + std::vector deletedKeys() const + { + std::lock_guard lock(count_mutex); + std::vector keys; + keys.reserve(delete_counts.size()); + for (const auto & [key, n] : delete_counts) + keys.push_back(key); + return keys; + } + uint64_t getStreamCount(const String & key) const { return lookup(get_stream_counts, key); } + uint64_t listCount(const String & prefix) const { return lookup(list_counts, prefix); } + /// The max ranged-get window length observed for `key` (0 if only whole-object gets, or none). + uint64_t maxRangedGetLen(const String & key) const { return lookup(max_ranged_get_len, key); } + /// How many whole-object gets (range.whole()) hit `key` — nonzero flags a resident-memory + /// violation for a run/seal object that a streaming caller must never read whole. + uint64_t wholeGetCount(const String & key) const { return lookup(whole_get_counts, key); } + /// Every key any counted operation was issued against, plus every LIST prefix, sorted and + /// de-duplicated. A request-profile gate asserts the SET, not only the totals, so a new request the + /// profile does not allow names its own key in the failure instead of moving an anonymous counter. + std::vector touchedKeys() const + { + std::lock_guard lock(count_mutex); + std::vector keys; + for (const std::map * m : + {&head_counts, &get_counts, &put_counts, &put_overwrite_counts, &cas_put_counts, + &get_stream_counts, &list_counts, &delete_counts}) + for (const auto & [key, n] : *m) + keys.push_back(key); + std::sort(keys.begin(), keys.end()); + keys.erase(std::unique(keys.begin(), keys.end()), keys.end()); + return keys; + } + + uint64_t headTotal() const { std::lock_guard lock(count_mutex); return head_total; } + uint64_t getTotal() const { std::lock_guard lock(count_mutex); return get_total; } + uint64_t putTotal() const { std::lock_guard lock(count_mutex); return put_total; } + uint64_t putOverwriteTotal() const { std::lock_guard lock(count_mutex); return put_overwrite_total; } + uint64_t casPutTotal() const { std::lock_guard lock(count_mutex); return cas_put_total; } + uint64_t getStreamTotal() const { std::lock_guard lock(count_mutex); return get_stream_total; } + uint64_t listTotal() const { std::lock_guard lock(count_mutex); return list_total; } + + /// The total number of get + getStream + putIfAbsent operations against any key whose path + /// CONTAINS `substr` (T0 idle-round gate: zero run I/O touches every `.../blob_target/...` key). + uint64_t ioCountForKeysContaining(const String & substr) const + { + std::lock_guard lock(count_mutex); + uint64_t total = 0; + for (const auto & [key, n] : get_counts) + if (key.find(substr) != String::npos) total += n; + for (const auto & [key, n] : get_stream_counts) + if (key.find(substr) != String::npos) total += n; + for (const auto & [key, n] : put_counts) + if (key.find(substr) != String::npos) total += n; + return total; + } + + void resetCounts() + { + std::lock_guard lock(count_mutex); + head_counts.clear(); + get_counts.clear(); + put_counts.clear(); + put_overwrite_counts.clear(); + cas_put_counts.clear(); + get_stream_counts.clear(); + list_counts.clear(); + delete_counts.clear(); + max_ranged_get_len.clear(); + whole_get_counts.clear(); + head_total = get_total = put_total = cas_put_total = get_stream_total = list_total = delete_total = 0; + put_overwrite_total = 0; + + } + +private: + uint64_t lookup(const std::map & m, const String & key) const + { + std::lock_guard lock(count_mutex); + const auto it = m.find(key); + return it == m.end() ? 0 : it->second; + } + + mutable std::mutex count_mutex; + std::map head_counts; + std::map get_counts; + std::map put_counts; + std::map put_overwrite_counts; + std::map cas_put_counts; + std::map get_stream_counts; + std::map list_counts; + std::map delete_counts; + std::map max_ranged_get_len; + std::map whole_get_counts; + uint64_t head_total = 0; + uint64_t get_total = 0; + uint64_t put_total = 0; + uint64_t put_overwrite_total = 0; + uint64_t cas_put_total = 0; + uint64_t get_stream_total = 0; + uint64_t list_total = 0; + uint64_t delete_total = 0; +}; + +/// A backend whose LIST permanently omits every key under a chosen prefix while those keys stay fully +/// readable by exact key -- the lying-store shape observed in production (`0x1430c`/`0x1430d`), and the +/// premise of every arithmetic-walk test: a record a listing never mentions is still THERE, so a walk +/// that computes the id finds it and a walk that enumerates does not. +/// +/// PERMANENT (not nth-call) omission is deliberate: a lying store need not ever recover the key, and the +/// arithmetic walk that finds it anyway is the property under test -- these fixtures are about the walk, +/// not about any one `list` call. +/// +/// Erasing keys from a page cannot disturb pagination: `ListPage::next_cursor` is computed by the base +/// backend before the erase, so the next page still resumes strictly after the last key it returned. +/// +/// Templated on the base so a suite that also needs request COUNTS composes it over `CountingBackend` +/// without a second copy of the hiding rule (which is a rule about what the store may legally do, and +/// must therefore read the same everywhere it is modelled). +template +class HintHoleBackendOn : public Base +{ +public: + /// Hide every key under `prefix` from LIST -- a whole namespace, including objects a later publish + /// adds. + void hidePrefix(const String & prefix) + { + std::lock_guard lock(hide_mutex); + hidden_prefixes.push_back(prefix); + } + + /// Hide exactly one key. Call AFTER seeding: a fixture that allocates ids by listing would + /// otherwise allocate over a hidden record. + void hide(const String & key) + { + std::lock_guard lock(hide_mutex); + hidden_keys.insert(key); + } + + /// Make the store's enumeration omit EXACTLY `keys` and nothing else -- the whole omission set in + /// one call, replacing whatever was hidden before. + /// + /// This is the RustFS defect reproduced as an interface: every one of these keys stays durable and + /// honestly served by `get` / `head` / `putIfAbsent` / `casPut` / `deleteExact`, and only + /// enumeration pretends they are not there. Stating the omission as a SET is what lets a test say + /// the thing the defect report says -- "ids 3 and 4 are invisible while the LATER id 5 is visible" + /// -- in one line, instead of assembling it from repeated single-key calls whose combined effect a + /// reader has to reconstruct. + /// + /// A setter rather than an adder: the omission set is the store's declared behaviour for the rest + /// of the test, so a second call REPLACES it (pass `{}` to stop lying, same as `revealAll`). + void setListOmissions(std::vector keys) + { + std::lock_guard lock(hide_mutex); + hidden_keys.clear(); + hidden_prefixes.clear(); + hidden_keys.insert(keys.begin(), keys.end()); + } + + /// How many LIST pages actually had a key erased. Every test that hides a key asserts this, so a + /// mistyped key cannot let the test pass vacuously -- the hole has to have been SERVED. + size_t holesServed() const + { + std::lock_guard lock(hide_mutex); + return served; + } + + /// The store stops lying: everything hidden is listed again. + void revealAll() + { + std::lock_guard lock(hide_mutex); + hidden_keys.clear(); + hidden_prefixes.clear(); + } + + DB::Cas::ListPage list(const String & prefix, const String & cursor, size_t limit) override + { + DB::Cas::ListPage page = Base::list(prefix, cursor, limit); + std::lock_guard lock(hide_mutex); + if (hidden_keys.empty() && hidden_prefixes.empty()) + return page; + const size_t before = page.keys.size(); + std::erase_if(page.keys, [&](const DB::Cas::ListedKey & k) + { + if (hidden_keys.contains(k.key)) + return true; + for (const String & hidden : hidden_prefixes) + if (k.key.starts_with(hidden)) + return true; + return false; + }); + if (page.keys.size() != before) + ++served; + return page; + } + +private: + mutable std::mutex hide_mutex; + std::set hidden_keys; + std::vector hidden_prefixes; + size_t served = 0; +}; + +/// The plain form, over a bare `InMemoryBackend`. +using HintHoleBackend = HintHoleBackendOn; + +/// Stand in for the self-remount that `Pool::reportImpossibleInterference` schedules. That reaction +/// trips the local write fence closed AND schedules a remount; a unit-test Pool runs no background +/// remount (`background_watermark` is off by design there), so without this the fence stays closed and +/// every later mutation is refused at the gate -- which is a test-harness artifact, not the production +/// behaviour. Re-arming directly is the smallest faithful stand-in: it restores writability without the +/// claim machinery and without discarding the cached ref runtimes, so a test can observe what happens +/// AFTER the reaction. It bumps the fence GENERATION, exactly as a real re-arm does. +inline void rearmMountFenceAfterAnomalyForTest(const DB::Cas::PoolPtr & store) +{ + store->armMountFence(DB::UInt128{0, 1}, store->liveWriterEpoch(), store->bootMsNow() + 600000); +} + +/// Delegates the FIRST matching `putIfAbsent` to `CountingBackend` -- so the write actually LANDS -- +/// and only THEN throws an ambiguous exception, modelling "our own PUT committed but its response was +/// lost". Every later call behaves normally, so a caller that retries the SAME (key, bytes) meets its +/// OWN earlier write as the occupant: the exact input the every-attempt rule's adoption arm adjudicates +/// (`slotOccupy` reports `Occupied` with bytes equal to the attempt's own). +/// +/// `key_substr` empty means "the first putIfAbsent of any key"; set it to scope the fault to one key +/// family when the caller drives a whole Pool (whose bootstrap PUTs would otherwise consume the fault). +/// +/// Shared rather than TU-local because two suites need exactly this shape: `gtest_cas_slot_occupy.cpp` +/// pins the primitive's same-call resolve, and `gtest_cas_ref_wedge_every_attempt.cpp` drives the +/// writer's wedge adoption through it. +class LandedButAckLostOnceBackend : public CountingBackend +{ +public: + using CountingBackend::putIfAbsent; + using CountingBackend::get; + String key_substr; + bool fired = false; + /// Also lose the caller's IMMEDIATE resolve read of the same key, once. Needed only by a caller + /// whose conditional-write layer resolves before reissuing (`putIfAbsentControlled`): without it + /// that resolve proves the object durable inside the very same attempt and reports `Committed`, so + /// no wedge over a DURABLE object can ever form. `slotOccupy` needs no such thing -- it has no + /// retry loop -- which is why this defaults off and this file's original caller is unaffected. + bool lose_resolve_read = false; + + DB::Cas::PutResult putIfAbsent(const String & key, const String & bytes, const DB::Cas::ObjectMeta & meta) override + { + if (!fired && (key_substr.empty() || key.find(key_substr) != String::npos)) + { + fired = true; + CountingBackend::putIfAbsent(key, bytes, meta); /// the write LANDS + if (lose_resolve_read) + fail_get_once_key = key; + throw Poco::TimeoutException("LandedButAckLostOnceBackend: simulated lost PUT response"); + } + return CountingBackend::putIfAbsent(key, bytes, meta); + } + + std::optional get(const String & key, DB::Cas::Range range) override + { + if (!fail_get_once_key.empty() && key == fail_get_once_key) + { + fail_get_once_key.clear(); + throw Poco::TimeoutException( + "LandedButAckLostOnceBackend: simulated lost GET (read response never arrived)"); + } + return CountingBackend::get(key, range); + } + +private: + String fail_get_once_key; +}; + +/// A `CountingBackend` that can fault selected PUTs by key substring (skip the first `fault_skip` +/// matches, then fault the next `fault_count`), and can latch a matching PUT mid-flight. Same class of +/// seam as the wedge tests in `gtest_cas_ref_writer.cpp` use +/// (`fault_key_substr`/`corrupt_key_substr`/`armPutBlock`), narrowed to what the ref-lane tests need. +/// Shared (rather than TU-local) because the chunk-boundary tests and the post-durable install-safety +/// tests need exactly the same seam. +class ChunkFaultBackend : public CountingBackend +{ +public: + using CountingBackend::putIfAbsent; + using CountingBackend::get; + + /// Unresolved -> a lost-response ambiguity, NOTHING landed; with a single-attempt budget this + /// wedges the lane and a later resolve proves the key ABSENT. + /// LandedThenLost -> our OWN exact bytes land and only the acknowledgement is lost, AND the + /// controller's immediate resolve-before-reissue GET is lost too. Both legs are + /// required to wedge over a DURABLE object: the resolve happens inside the same + /// attempt, so a readable key would prove `Committed` there and no wedge would + /// ever form. Real-world shape: the write succeeded server-side, the connection + /// dropped, and the verification read hit the same transient outage. With a + /// single-attempt budget the lane then wedges over an object that IS durable, so + /// the NEXT flush's `resolveByExactGet` reports `Committed` and drives the + /// wedge-RESOLUTION install (spec §A1 site 2) -- the only mode that reaches it. + /// Definite -> an S3-classified malformed request -> `CasWriteOutcome::DefiniteFailure`. + /// ForeignConflict -> a DIFFERENT object lands at the key, then the response is lost -> the + /// controller's resolve-before-reissue GET observes foreign bytes and throws + /// CORRUPTED_DATA straight out of the PUT (a proven conflict). + enum class Mode { None, Unresolved, LandedThenLost, Definite, ForeignConflict }; + + /// Fault matching is single-threaded during a flush (one leader per table PUTs `_log/`), so these + /// need no lock; set them before driving the flush. + String fault_substr; + Mode mode = Mode::None; + int fault_skip = 0; + int fault_count = 0; + /// One-shot: the next `get` of exactly this key throws, then it is cleared. Armed by + /// `Mode::LandedThenLost` (see above); settable directly for a bare lost-read fault. + String fail_get_once_key; + + std::optional get(const String & key, DB::Cas::Range range) override + { + if (!fail_get_once_key.empty() && key == fail_get_once_key) + { + fail_get_once_key.clear(); + throw Poco::TimeoutException("ChunkFaultBackend: simulated lost GET (read response never arrived)"); + } + return CountingBackend::get(key, range); + } + + DB::Cas::PutResult putIfAbsent(const String & key, const String & bytes, const DB::Cas::ObjectMeta & meta) override + { + if (mode != Mode::None && !fault_substr.empty() && key.find(fault_substr) != String::npos) + { + if (fault_skip > 0) + { + --fault_skip; + } + else if (fault_count > 0) + { + --fault_count; + switch (mode) + { + case Mode::Unresolved: + throw Poco::TimeoutException("ChunkFaultBackend: simulated ambiguous _log PUT (response lost)"); + case Mode::LandedThenLost: + /// The write SUCCEEDS -- byte-for-byte what the caller asked for, through the + /// counting path so the object is indistinguishable from a normal PUT -- and only + /// the acknowledgement is lost. The controller's resolve-before-reissue GET is + /// armed to fail ONCE for this key as well, or it would prove the object durable + /// inside this very attempt and the lane would never wedge; the wedge-resolution + /// GET a flush later then reads it normally. + CountingBackend::putIfAbsent(key, bytes, meta); + fail_get_once_key = key; + throw Poco::TimeoutException("ChunkFaultBackend: object landed; response lost"); + case Mode::Definite: +#if USE_AWS_S3 + throw DB::S3Exception("ChunkFaultBackend: simulated malformed request", + Aws::S3::S3Errors::UNKNOWN, "MalformedXML"); +#else + throw DB::Exception(DB::ErrorCodes::CORRUPTED_DATA, + "ChunkFaultBackend: DefiniteFailure requires S3 error classification (USE_AWS_S3 off)"); +#endif + case Mode::ForeignConflict: + /// A foreign writer lands DIFFERENT bytes at this exact key; then our response is + /// lost, so resolve-before-reissue GETs foreign bytes -> CORRUPTED_DATA. + CountingBackend::putIfAbsent(key, bytes + String("\x01_FOREIGN_DIFFERENT")); + throw Poco::TimeoutException("ChunkFaultBackend: foreign different object landed; response lost"); + case Mode::None: + break; + } + } + } + { + std::unique_lock lk(block_mutex); + if (block_armed && !block_substr.empty() && key.find(block_substr) != String::npos) + { + block_entered = true; + block_cv.notify_all(); + /// Bounded (20s) so a wiring bug bounds the wait rather than hanging the whole suite. + block_cv.wait_for(lk, std::chrono::seconds(20), [&] { return !block_armed; }); + } + } + return CountingBackend::putIfAbsent(key, bytes, meta); + } + + void armBlock(const String & substr) + { + std::lock_guard lk(block_mutex); + block_substr = substr; + block_armed = true; + block_entered = false; + } + void awaitBlockEntered() + { + std::unique_lock lk(block_mutex); + /// Bounded (20s): if the latched publisher never reaches its PUT, fail LOUDLY rather than hang. + /// The assertion is load-bearing -- without it a wiring regression that never parks the publisher + /// would let `SnapshotPublisherLatchedAcrossChunks` pass VACUOUSLY (its final re-fire assertion + /// can still hold via a direct, non-coalesced dispatch). + block_cv.wait_for(lk, std::chrono::seconds(20), [&] { return block_entered; }); + ASSERT_TRUE(block_entered) << "latched publisher never entered its blocked PUT within 20s -- " + "coalescing was not exercised"; + } + void releaseBlock() + { + { + std::lock_guard lk(block_mutex); + block_armed = false; + } + block_cv.notify_all(); + } + +private: + std::mutex block_mutex; + std::condition_variable block_cv; + String block_substr; + bool block_armed = false; + bool block_entered = false; +}; + +/// Fault decorator for the condemn-marker gate tests (codex-review triage 2026-07-17 §3.4): while +/// armed, every conditional-write attempt against a blob `.meta` key throws. The request controller +/// exhausts its budget and reports `Unresolved`, so `writeCondemnedMeta` returns false while the round +/// still commits the unconfirmed retired entry. Every other write passes through. Armed by default; +/// disarm (`fail_meta_writes = false`) to model the backend healing. +class MetaWriteFaultBackend : public DB::Cas::InMemoryBackend +{ +public: + /// Unhide the base convenience overloads (omitted Range/ObjectMeta/expected-token forms): the + /// overrides below would otherwise shadow them for callers holding a concrete backend type. + using DB::Cas::Backend::get; + using DB::Cas::Backend::getStream; + using DB::Cas::Backend::putIfAbsent; + using DB::Cas::Backend::putIfAbsentStream; + using DB::Cas::Backend::putOverwrite; + using DB::Cas::Backend::casPut; + + DB::Cas::PutResult putIfAbsent( + const String & key, const String & bytes, const DB::Cas::ObjectMeta & meta) override + { + if (fail_meta_writes.load() && key.ends_with(".meta")) + throw std::runtime_error("injected fault: blob meta write lost"); + return InMemoryBackend::putIfAbsent(key, bytes, meta); + } + + DB::Cas::PutResult putOverwrite( + const String & key, const String & bytes, const DB::Cas::Token & expected, + const DB::Cas::ObjectMeta & meta) override + { + if (fail_meta_writes.load() && key.ends_with(".meta")) + throw std::runtime_error("injected fault: blob meta write lost"); + return InMemoryBackend::putOverwrite(key, bytes, expected, meta); + } + + DB::Cas::CasResult casPut(const String & key, const String & bytes, + const std::optional & expected, + const DB::Cas::ObjectMeta & meta) override + { + if (fail_meta_writes.load() && key.ends_with(".meta")) + throw std::runtime_error("injected fault: blob meta write lost"); + return InMemoryBackend::casPut(key, bytes, expected, meta); + } + + std::atomic fail_meta_writes{true}; +}; + +} diff --git a/src/Disks/tests/gtest_ca_dedup_cache.cpp b/src/Disks/tests/gtest_ca_dedup_cache.cpp new file mode 100644 index 000000000000..003dd3d14f58 --- /dev/null +++ b/src/Disks/tests/gtest_ca_dedup_cache.cpp @@ -0,0 +1,203 @@ +#include +#include +#include +#include +#include +#include + +namespace ProfileEvents +{ +extern const Event CASDeduplicationCacheHits; +extern const Event CASDeduplicationCacheMisses; +} + +using namespace DB::Cas; +using DB::Cas::tests::idOf; +using DB::Cas::tests::u128Of; + +namespace +{ + +/// A transparent delegating Backend that counts the two ops P1/P2 trade off against each other: +/// `head` (the cheap probe HEAD-before-PUT issues) and `putIfAbsentStream` (the body upload a present +/// HEAD avoids). Everything else is forwarded verbatim so the wrapped backend behaves exactly as a bare +/// InMemoryBackend would. +class CountingBackend final : public Backend +{ +public: + explicit CountingBackend(BackendPtr inner_) : inner(std::move(inner_)) {} + + size_t heads = 0; + size_t stream_puts = 0; + + HeadResult head(const String & k) override { ++heads; return inner->head(k); } + WriteSinkPtr putIfAbsentStream(const String & k, const ObjectMeta & meta) override + { + ++stream_puts; + return inner->putIfAbsentStream(k, meta); + } + + std::optional get(const String & k, Range r) override { return inner->get(k, r); } + std::optional getStream(const String & k, Range r) override { return inner->getStream(k, r); } + ListPage list(const String & p, const String & c, size_t l) override { return inner->list(p, c, l); } + PutResult putIfAbsent(const String & k, const String & b, const ObjectMeta & m) override { return inner->putIfAbsent(k, b, m); } + PutResult putOverwrite(const String & k, const String & b, const Token & e, const ObjectMeta & m) override { return inner->putOverwrite(k, b, e, m); } + CasResult casPut(const String & k, const String & b, const std::optional & e, const ObjectMeta & m) override { return inner->casPut(k, b, e, m); } + DeleteOutcome deleteExact(const String & k, const Token & t) override { return inner->deleteExact(k, t); } + bool supportsListTokens() const override { return inner->supportsListTokens(); } + +private: + BackendPtr inner; +}; + +PoolConfig cfg(uint64_t cache_bytes, uint64_t head_first_min_bytes) +{ + PoolConfig c{.pool_prefix = "p", .server_root_id = "test"}; + c.deduplication_cache_bytes = cache_bytes; + c.deduplication_head_first_min_bytes = head_first_min_bytes; + return c; +} + +} + +/// Task 2: the cache itself — add then contains. +TEST(CASDeduplicationCache, AddThenContains) +{ + auto s = Pool::open(std::make_shared(), cfg(64ULL << 20, 1ULL << 20)); + const DB::UInt128 h = u128Of("x"); + EXPECT_FALSE(s->dedupCacheContains(DB::Cas::BlobRef{DB::Cas::BlobHashAlgo::CityHash128, DB::Cas::BlobDigest::fromU128(h)})); + s->dedupCacheAdd(DB::Cas::BlobRef{DB::Cas::BlobHashAlgo::CityHash128, DB::Cas::BlobDigest::fromU128(h)}); + EXPECT_TRUE(s->dedupCacheContains(DB::Cas::BlobRef{DB::Cas::BlobHashAlgo::CityHash128, DB::Cas::BlobDigest::fromU128(h)})); +} + +/// Task 2: deduplication_cache_bytes == 0 disables the cache — add is a no-op, contains is always false. +TEST(CASDeduplicationCache, DisabledNeverContains) +{ + auto s = Pool::open(std::make_shared(), cfg(/*cache_bytes*/ 0, 1ULL << 20)); + const DB::UInt128 h = u128Of("x"); + s->dedupCacheAdd(DB::Cas::BlobRef{DB::Cas::BlobHashAlgo::CityHash128, DB::Cas::BlobDigest::fromU128(h)}); + EXPECT_FALSE(s->dedupCacheContains(DB::Cas::BlobRef{DB::Cas::BlobHashAlgo::CityHash128, DB::Cas::BlobDigest::fromU128(h)})); +} + +/// Task 2: the cache is bounded by bytes — at 64 B/entry a 256 B ceiling holds ~4 entries, so the +/// earliest-added hash is evicted while a recently-added one survives. +TEST(CASDeduplicationCache, BoundedByBytes) +{ + auto s = Pool::open(std::make_shared(), cfg(/*cache_bytes*/ 256, 1ULL << 20)); + const DB::UInt128 first = u128Of("k0"); + s->dedupCacheAdd(DB::Cas::BlobRef{DB::Cas::BlobHashAlgo::CityHash128, DB::Cas::BlobDigest::fromU128(first)}); + for (int i = 1; i < 100; ++i) + s->dedupCacheAdd(DB::Cas::BlobRef{DB::Cas::BlobHashAlgo::CityHash128, DB::Cas::BlobDigest::fromU128(u128Of("k" + std::to_string(i)))}); + EXPECT_FALSE(s->dedupCacheContains(DB::Cas::BlobRef{DB::Cas::BlobHashAlgo::CityHash128, DB::Cas::BlobDigest::fromU128(first)})); /// evicted long ago + EXPECT_TRUE(s->dedupCacheContains(DB::Cas::BlobRef{DB::Cas::BlobHashAlgo::CityHash128, DB::Cas::BlobDigest::fromU128(u128Of("k99"))})); /// most recent survives +} + +/// Task 5 (P1): a cache hit takes the HEAD-first path and skips the body PUT entirely. +/// (Counters are reset right before each measured putBlob — Pool::open's probe/watermark and +/// beginPartWrite's heartbeat issue their own backend ops that are irrelevant to the trade-off under test.) +TEST(CASDeduplicationCache, HitTakesHeadFirstNoBodyPut) +{ + auto counting = std::make_shared(std::make_shared()); + auto s = Pool::open(counting, cfg(64ULL << 20, 1ULL << 20)); + + /// First writer: small body, cold cache, below the P2 size threshold ⇒ a normal body PUT. + auto b1 = s->beginPartWrite({}); + counting->stream_puts = 0; + b1->putBlob(idOf("dup"), BlobSource::fromString("dup")); + EXPECT_EQ(counting->stream_puts, 1u); + + /// Second writer of the same content: the cache now says present ⇒ HEAD-first, no second body PUT. + /// The head-first hit ADOPTS an existing incarnation, so it must run under a durable precommit edge + /// (EDGE-BEFORE-OBSERVE: stageManifest -> precommitAdd before putBlob). Counters are reset AFTER that + /// ceremony so only the measured putBlob is counted. + PartWriteInfo info2; + info2.intended_ref = "srv/tbl/ref2"; + auto b2 = s->beginPartWrite(info2); + ManifestEntry e2; + e2.path = "data.bin"; + e2.placement = EntryPlacement::Blob; + e2.ref = DB::Cas::BlobRef{DB::Cas::BlobHashAlgo::CityHash128, DB::Cas::BlobDigest::fromU128(u128Of("dup"))}; + + e2.blob_size = 3; + const ManifestId id2 = b2->stageManifest({e2}); + b2->precommitAdd(RootNamespace{"srv/tbl"}, "ref2", id2); + counting->stream_puts = 0; + b2->putBlob(idOf("dup"), BlobSource::fromString("dup")); + EXPECT_EQ(counting->stream_puts, 0u); /// body PUT avoided +} + +/// Task 3 (Round-B §0.3 introspection): the raw dedup_cache presence-lookup counters increment +/// independently of what putBlob does with the answer. First lookup of a fresh hash misses (nothing +/// cached yet); the identical second writer's lookup hits (the first writer's dedupCacheAdd populated +/// the entry). putBlob's HEAD-first branch re-checks dedupCacheContains a second time purely to +/// attribute CASBlobBodyPutAvoided to the cache (CasPartWriteTxn.cpp), so a genuine hit can bump +/// CASDeduplicationCacheHits twice for one putBlob call -- hence GE, not EQ, on the hit delta below. +TEST(CASDeduplicationCache, HitMissCountersIncrement) +{ + using ProfileEvents::global_counters; + auto counting = std::make_shared(std::make_shared()); + auto s = Pool::open(counting, cfg(64ULL << 20, 1ULL << 20)); + + const auto miss_before = global_counters[ProfileEvents::CASDeduplicationCacheMisses].load(); + const auto hits_before = global_counters[ProfileEvents::CASDeduplicationCacheHits].load(); + + /// First writer: cold cache, small body below the P2 size threshold -> the lookup misses. + auto b1 = s->beginPartWrite({}); + b1->putBlob(idOf("dup"), BlobSource::fromString("dup")); + EXPECT_EQ(global_counters[ProfileEvents::CASDeduplicationCacheMisses].load() - miss_before, 1); + + /// Second writer, identical content: the cache now holds the hash -> the lookup hits. The + /// head-first hit ADOPTS an existing incarnation, so it must run under a durable precommit edge + /// (EDGE-BEFORE-OBSERVE: stageManifest -> precommitAdd before putBlob), mirroring + /// HitTakesHeadFirstNoBodyPut above. + PartWriteInfo info2; + info2.intended_ref = "srv/tbl/ref2"; + auto b2 = s->beginPartWrite(info2); + ManifestEntry e2; + e2.path = "data.bin"; + e2.placement = EntryPlacement::Blob; + e2.ref = DB::Cas::BlobRef{DB::Cas::BlobHashAlgo::CityHash128, DB::Cas::BlobDigest::fromU128(u128Of("dup"))}; + e2.blob_size = 3; + const ManifestId id2 = b2->stageManifest({e2}); + b2->precommitAdd(RootNamespace{"srv/tbl"}, "ref2", id2); + b2->putBlob(idOf("dup"), BlobSource::fromString("dup")); + + EXPECT_EQ(global_counters[ProfileEvents::CASDeduplicationCacheMisses].load() - miss_before, 1); + EXPECT_GE(global_counters[ProfileEvents::CASDeduplicationCacheHits].load() - hits_before, 1); +} + +/// Task 5 (P1 safety): a STALE cache hit (hash marked present but absent in the store) must not cause a +/// dangle — the mandatory HEAD sees 404 and the writer falls through to a real body PUT. +TEST(CASDeduplicationCache, StaleHitFallsThroughToPut) +{ + auto counting = std::make_shared(std::make_shared()); + auto s = Pool::open(counting, cfg(64ULL << 20, 1ULL << 20)); + + /// Poison the cache: claim "stale" is present though nothing was ever uploaded. + s->dedupCacheAdd(DB::Cas::BlobRef{DB::Cas::BlobHashAlgo::CityHash128, DB::Cas::BlobDigest::fromU128(u128Of("stale"))}); + + auto b = s->beginPartWrite({}); + counting->heads = 0; + counting->stream_puts = 0; + auto ref = b->putBlob(idOf("stale"), BlobSource::fromString("stale")); + EXPECT_EQ(ref.size, 5u); + EXPECT_GE(counting->heads, 1u); /// the safety HEAD ran + EXPECT_EQ(counting->stream_puts, 1u); /// and the body was actually uploaded + EXPECT_TRUE(counting->head(s->layout().blobKey(ref.ref)).exists); +} + +/// Task 5 (P2): on a cold cache, a body at/above deduplication_head_first_min_bytes still probes HEAD-first +/// (here the size trigger fires for a tiny body because the threshold is set to 1). The miss falls +/// through to a real PUT. +TEST(CASDeduplicationCache, LargeBlobMissTakesHeadFirst) +{ + auto counting = std::make_shared(std::make_shared()); + auto s = Pool::open(counting, cfg(64ULL << 20, /*head_first_min_bytes*/ 1)); + + auto b = s->beginPartWrite({}); + counting->heads = 0; + counting->stream_puts = 0; + b->putBlob(idOf("big"), BlobSource::fromString("big")); + EXPECT_EQ(counting->heads, 1u); /// P2 probed before the PUT + EXPECT_EQ(counting->stream_puts, 1u); /// cold miss ⇒ body uploaded +} diff --git a/src/Disks/tests/gtest_ca_transaction.cpp b/src/Disks/tests/gtest_ca_transaction.cpp new file mode 100644 index 000000000000..d65e63f49401 --- /dev/null +++ b/src/Disks/tests/gtest_ca_transaction.cpp @@ -0,0 +1,754 @@ +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace ProfileEvents +{ +extern const Event CASRefRepoint; +extern const Event CASManifestHead; +} + +namespace DB::ErrorCodes +{ +extern const int NOT_IMPLEMENTED; +extern const int INVALID_STATE; +} + +/// [TXN-ONE-PIPELINE] CA publish-at-commit lock-scope tests. +/// Proves that a freshly-written part's FINAL manifest ref is published only by commit(); the +/// tmp->final rename (moveDirectory) is a pure re-key of the transaction-private overlay and +/// publishes nothing. This inverts the former B151 publish-at-rename behavior. + +namespace +{ + +/// Constructs the storage but deliberately does NOT call `startup()` -- used by tests that need to +/// control when/how startup runs (e.g. injecting a late fault before the atomic publish step). +std::shared_ptr makeUnstartedTxStorage() +{ + auto settings = DB::Cas::tests::makeSettingsForTest( + "test", std::filesystem::temp_directory_path() / "ca_tx_lockscope_scratch"); + return std::make_shared( + DB::Cas::tests::makeLocalObjectStorageForTest(), "pool", "srv1", "", nullptr, settings); +} + +std::shared_ptr openTxStorage() +{ + auto storage = makeUnstartedTxStorage(); + storage->startup(); + return storage; +} + +void writeFileTx(DB::IMetadataTransaction & tx, const std::string & path, const std::string & bytes) +{ + auto & ca_tx = dynamic_cast(tx); + auto buf = ca_tx.writeFile(path, 65536, DB::WriteMode::Rewrite, {}); + buf->write(bytes.data(), bytes.size()); + buf->finalize(); +} + +/// Match a manifest entry by its basename (the canonical `path` is the full part-relative path). +const DB::Cas::ManifestEntry * findByName(const std::vector & entries, const std::string & name) +{ + for (const auto & e : entries) + { + const auto slash = e.path.find_last_of('/'); + const std::string base = slash == std::string::npos ? e.path : e.path.substr(slash + 1); + if (base == name) + return &e; + } + return nullptr; +} + +} + +/// Regression for STID 0883 on the CAS write path: an extreme `max_compress_block_size` (the exact +/// 2^63-1 the `04070_no_crash_extreme_compress_block_size` stateless test sets) flows into +/// `writeFile`'s `buf_size` and, unclamped, reaches `Memory::alloc` -- where the allocator's +/// `checkSize` (>= 0x8000000000000000) fires a `LOGICAL_ERROR` and aborts the server. The ordinary +/// MergeTree writers clamp compress-block sizes to 256 MiB; the CAS write buffer must do the same at +/// its own allocation site. Building the buffer with the extreme size must NOT throw/abort, and the +/// resulting buffer must be clamped -- never allocated at the extreme size. +TEST(CASContentWriteBuffer, ExtremeBufferSizeIsClampedNotPassedToAllocator) +{ + const auto scratch = std::filesystem::temp_directory_path() / "ca_extreme_bufsize_scratch"; + + constexpr size_t extreme = 0x7FFFFFFFFFFFFFFFULL; /// 2^63 - 1; unclamped this crashes the allocator + constexpr size_t max_clamped = 256ULL * 1024 * 1024; + + std::unique_ptr buf; + ASSERT_NO_THROW( + buf = std::make_unique( + scratch.string(), + DB::Cas::BlobHashAlgo::CityHash128, + /*buf_size=*/extreme, + /*use_adaptive_buffer_size=*/false, + /*adaptive_buffer_initial_size=*/extreme, + [](const std::string &, size_t, const std::string &) {})); + ASSERT_TRUE(buf); + EXPECT_LE(buf->internalBuffer().size(), max_clamped) + << "the CAS write buffer must be clamped, never allocated at the extreme compress-block size"; + + buf.reset(); + std::filesystem::remove_all(scratch); +} + +/// [TXN-ONE-PIPELINE] A freshly-written part is published by commit(), NOT at the tmp->final rename. +/// moveDirectory only re-keys the transaction overlay; the durable ref appears at commit(). +TEST(CASTransactionLockScope, PublishHappensAtCommitNotRename) +{ + auto storage = openTxStorage(); + auto tx = storage->createTransaction(); + writeFileTx(*tx, "a11/a11a11a1-1111-4111-8111-111111111111/tmp_insert_all_1_1_0/data.bin", "content-A"); + + EXPECT_FALSE(storage->existsDirectory("a11/a11a11a1-1111-4111-8111-111111111111/tmp_insert_all_1_1_0")); + EXPECT_FALSE(storage->existsDirectory("a11/a11a11a1-1111-4111-8111-111111111111/all_1_1_0")); + + tx->moveDirectory("a11/a11a11a1-1111-4111-8111-111111111111/tmp_insert_all_1_1_0", "a11/a11a11a1-1111-4111-8111-111111111111/all_1_1_0"); + + /// Re-key only: the final ref is NOT durable yet. + EXPECT_FALSE(storage->existsDirectory("a11/a11a11a1-1111-4111-8111-111111111111/all_1_1_0")); + + tx->commit(DB::NoCommitOptions{}); + + /// Published by commit(). + EXPECT_TRUE(storage->existsDirectory("a11/a11a11a1-1111-4111-8111-111111111111/all_1_1_0")); + EXPECT_TRUE(storage->existsFile("a11/a11a11a1-1111-4111-8111-111111111111/all_1_1_0/data.bin")); + EXPECT_EQ(storage->getFileSize("a11/a11a11a1-1111-4111-8111-111111111111/all_1_1_0/data.bin"), 9u); +} + +TEST(CASTransactionOps, TruncateFileIsNotSupported) +{ + auto storage = openTxStorage(); + auto tx = storage->createTransaction(); + auto & ca_tx = dynamic_cast(*tx); + + DB::Cas::tests::expectThrowsCode(DB::ErrorCodes::NOT_IMPLEMENTED, + [&] { ca_tx.truncateFile("a11/a11a11a1-1111-4111-8111-111111111111/all_1_1_0/data.bin", 0); }); +} + +/// [TXN-ONE-PIPELINE] An abandoned transaction (destructed without commit) never published, so the +/// final ref is simply absent — no early-published ref to drop. +TEST(CASTransactionLockScope, AbandonedPartLeavesNoRef) +{ + auto storage = openTxStorage(); + { + auto tx = storage->createTransaction(); + writeFileTx(*tx, "a11/a11a11a1-1111-4111-8111-111111111111/tmp_insert_all_3_3_0/data.bin", "abandoned"); + tx->moveDirectory("a11/a11a11a1-1111-4111-8111-111111111111/tmp_insert_all_3_3_0", "a11/a11a11a1-1111-4111-8111-111111111111/all_3_3_0"); + EXPECT_FALSE(storage->existsDirectory("a11/a11a11a1-1111-4111-8111-111111111111/all_3_3_0")); /// not published at the rename + /// tx goes out of scope WITHOUT commit(). + } + EXPECT_FALSE(storage->existsDirectory("a11/a11a11a1-1111-4111-8111-111111111111/all_3_3_0")); +} + +/// [TXN-ONE-PIPELINE] commit() publishes the re-keyed part. +TEST(CASTransactionLockScope, RefPublishedByCommit) +{ + auto storage = openTxStorage(); + { + auto tx = storage->createTransaction(); + writeFileTx(*tx, "a11/a11a11a1-1111-4111-8111-111111111111/tmp_insert_all_4_4_0/data.bin", "kept"); + tx->moveDirectory("a11/a11a11a1-1111-4111-8111-111111111111/tmp_insert_all_4_4_0", "a11/a11a11a1-1111-4111-8111-111111111111/all_4_4_0"); + tx->commit(DB::NoCommitOptions{}); + } + EXPECT_TRUE(storage->existsDirectory("a11/a11a11a1-1111-4111-8111-111111111111/all_4_4_0")); +} + +/// A committed-ref rename (no staged source) must NOT spuriously publish — it goes via republishRef. +TEST(CASTransactionLockScope, CommittedRefMoveDoesNotSpuriouslyPublish) +{ + auto storage = openTxStorage(); + { + auto tx = storage->createTransaction(); + writeFileTx(*tx, "a11/a11a11a1-1111-4111-8111-111111111111/tmp_insert_all_2_2_0/data.bin", "payload"); + tx->moveDirectory("a11/a11a11a1-1111-4111-8111-111111111111/tmp_insert_all_2_2_0", "a11/a11a11a1-1111-4111-8111-111111111111/all_2_2_0"); + tx->commit(DB::NoCommitOptions{}); + } + ASSERT_TRUE(storage->existsDirectory("a11/a11a11a1-1111-4111-8111-111111111111/all_2_2_0")); + { + auto tx = storage->createTransaction(); + tx->moveDirectory("a11/a11a11a1-1111-4111-8111-111111111111/all_2_2_0", "a11/a11a11a1-1111-4111-8111-111111111111/delete_tmp_all_2_2_0"); + tx->commit(DB::NoCommitOptions{}); + } + EXPECT_TRUE(storage->existsDirectory("a11/a11a11a1-1111-4111-8111-111111111111/delete_tmp_all_2_2_0")); + EXPECT_FALSE(storage->existsDirectory("a11/a11a11a1-1111-4111-8111-111111111111/all_2_2_0")); +} + +/// [TXN-ONE-PIPELINE] B183 migration gate: a scratch ref durably published at the part's own (tmp) +/// BUILD path by a nested sub-storage must be dropped on the staged-source tmp->final finalize, and +/// commit() must publish the AUTHORITATIVE staged manifest (not the scratch content). This mirrors +/// `createTemporaryTextIndexStorage`, which publishes scratch under the new_data_part's STILL-TMP +/// relative path (`MergeTask.cpp` uses `getDataPartStorage().getRelativePath()`) — i.e. the SOURCE of +/// the tmp->final rename, which is exactly what `moveDirectory`'s `dropRefIfPresent(src->refKey())` +/// drops. (The plan's destination-path scenario would not reproduce this: `publishStaging`'s +/// repoint-merge would carry the scratch file forward.) +TEST(CASTransactionLockScope, StagedFinalizeDropsForeignScratchRef) +{ + auto storage = openTxStorage(); + + /// A SEPARATE transaction (the nested text-index sub-storage) durably publishes a committed ref at + /// the tmp BUILD path holding only a scratch file under `text_index_tmp/`. + { + auto scratch_tx = storage->createTransaction(); + writeFileTx(*scratch_tx, "a77/a77a77a7-7777-4777-8777-777777777777/tmp_merge_all_1_1_0/text_index_tmp/scratch.bin", "scratch"); + scratch_tx->commit(DB::NoCommitOptions{}); + } + ASSERT_TRUE(storage->existsDirectory("a77/a77a77a7-7777-4777-8777-777777777777/tmp_merge_all_1_1_0")); + + /// The real part build: stage the authoritative data.bin under the SAME tmp path, then finalize + /// tmp->final. The staged-source finalize drops the foreign scratch ref at the tmp path. + auto tx = storage->createTransaction(); + writeFileTx(*tx, "a77/a77a77a7-7777-4777-8777-777777777777/tmp_merge_all_1_1_0/data.bin", std::string(50000, 'D')); + tx->moveDirectory("a77/a77a77a7-7777-4777-8777-777777777777/tmp_merge_all_1_1_0", "a77/a77a77a7-7777-4777-8777-777777777777/all_1_1_0"); + tx->commit(DB::NoCommitOptions{}); + + /// The published manifest is the authoritative one (has data.bin), not the scratch ref. + const auto ns = storage->liveNamespace("a77a77a7-7777-4777-8777-777777777777"); + const auto resolved = storage->store()->resolveRef(ns, "all_1_1_0"); + ASSERT_TRUE(resolved.has_value()); + const auto manifest = storage->store()->readManifest(resolved->manifest_id); + EXPECT_TRUE(findByName(manifest.entries, "data.bin")); + EXPECT_FALSE(findByName(manifest.entries, "scratch.bin")); + + /// The foreign scratch ref at the tmp build path is gone (dropped, not carried forward). + EXPECT_FALSE(storage->existsDirectory("a77/a77a77a7-7777-4777-8777-777777777777/tmp_merge_all_1_1_0")); +} + +/// [TXN-ONE-PIPELINE] After a tmp->final re-key, a read THROUGH the open transaction resolves the +/// staged content under the FINAL path (read-your-writes), before commit(); the inner-directory +/// overlay is likewise re-keyed and answers under the final path. The staged file lives under an +/// inner projection dir because the directory overlay tracks INNER dirs only — the part dir itself +/// answers `hasInFlightDirectory`=false by contract (removeIfNeeded clean early-return; see +/// `CASWiringInFlight`), so asserting the bare part dir would contradict that invariant. +TEST(CASTransactionLockScope, ReadYourWritesAfterReKey) +{ + auto storage = openTxStorage(); + auto tx = storage->createTransaction(); + auto & ca_tx = dynamic_cast(*tx); + + writeFileTx(*tx, "a33/a33a33a3-3333-4333-8333-333333333333/tmp_insert_all_1_1_0/p.proj/checksums.txt", "the-checksums"); + tx->moveDirectory("a33/a33a33a3-3333-4333-8333-333333333333/tmp_insert_all_1_1_0", "a33/a33a33a3-3333-4333-8333-333333333333/all_1_1_0"); + + /// The overlay answers the final path before commit (read-your-writes). + auto buf = ca_tx.tryReadFileInFlight("a33/a33a33a3-3333-4333-8333-333333333333/all_1_1_0/p.proj/checksums.txt", DB::ReadSettings{}, std::nullopt); + ASSERT_NE(buf, nullptr); + std::string got; + DB::readStringUntilEOF(got, *buf); + EXPECT_EQ(got, "the-checksums"); + /// The inner-directory overlay is re-keyed too and resolves under the final path. + EXPECT_TRUE(ca_tx.hasInFlightDirectory("a33/a33a33a3-3333-4333-8333-333333333333/all_1_1_0/p.proj")); +} + +/// [TXN-ONE-PIPELINE] Program order in the overlay: create -> delete -> create leaves the file PRESENT +/// (no delayed delete fires after the later create); delete of a staged file makes it absent to reads. +TEST(CASTransactionLockScope, OverlayProgramOrder) +{ + auto storage = openTxStorage(); + auto tx = storage->createTransaction(); + auto & ca_tx = dynamic_cast(*tx); + + writeFileTx(*tx, "a55/a55a55a5-5555-4555-8555-555555555555/tmp_insert_all_1_1_0/a.txt", "v1"); + ca_tx.unlinkFile("a55/a55a55a5-5555-4555-8555-555555555555/tmp_insert_all_1_1_0/a.txt", /*if_exists=*/false, /*should_remove_objects=*/true); + EXPECT_EQ(ca_tx.tryReadFileInFlight("a55/a55a55a5-5555-4555-8555-555555555555/tmp_insert_all_1_1_0/a.txt", DB::ReadSettings{}, std::nullopt), nullptr); + + writeFileTx(*tx, "a55/a55a55a5-5555-4555-8555-555555555555/tmp_insert_all_1_1_0/a.txt", "v2"); + tx->moveDirectory("a55/a55a55a5-5555-4555-8555-555555555555/tmp_insert_all_1_1_0", "a55/a55a55a5-5555-4555-8555-555555555555/all_1_1_0"); + tx->commit(DB::NoCommitOptions{}); + + ASSERT_TRUE(storage->existsFile("a55/a55a55a5-5555-4555-8555-555555555555/all_1_1_0/a.txt")); + EXPECT_EQ(storage->getFileSize("a55/a55a55a5-5555-4555-8555-555555555555/all_1_1_0/a.txt"), 2u); +} + +/// [02941 root-cause] A carried-forward projection sidecar (createHardLink from a COMMITTED source part +/// into a mutated tmp part) must be readable through the transaction's in-flight read path BOTH at the +/// tmp build path (loadProjections runs here during MutateTask finalize) AND after the tmp->final re-key. +/// This is the exact sequence MATERIALIZE PROJECTION drives on a part that already has the projection. +/// If the in-flight read returns empty, the mutated part's in-memory projection sub-part loads with 0 +/// marks (the 02941 "Empty marks file: 0, must be: 144" corruption on a same-session projection SELECT). +TEST(CASTransactionLockScope, InFlightReadCarriedForwardProjectionSidecar) +{ + auto storage = openTxStorage(); + + /// 1. Commit a source part with a small INLINE projection sidecar (marks-like) + a blob. + { + auto tx = storage->createTransaction(); + writeFileTx(*tx, "b01/b01b01b0-0101-4101-8101-010101010101/tmp_insert_all_1_1_0/data.bin", "the-main-data-bytes"); + writeFileTx(*tx, "b01/b01b01b0-0101-4101-8101-010101010101/tmp_insert_all_1_1_0/aaaa.proj/data.cmrk4", "PROJMARKS9"); + tx->moveDirectory("b01/b01b01b0-0101-4101-8101-010101010101/tmp_insert_all_1_1_0", "b01/b01b01b0-0101-4101-8101-010101010101/all_1_1_0"); + tx->commit(DB::NoCommitOptions{}); + } + ASSERT_TRUE(storage->existsFile("b01/b01b01b0-0101-4101-8101-010101010101/all_1_1_0/aaaa.proj/data.cmrk4")); + + /// 2. Mutation: build a new tmp part + carry the projection sidecar forward via createHardLink. + auto tx = storage->createTransaction(); + auto & ca_tx = dynamic_cast(*tx); + writeFileTx(*tx, "b01/b01b01b0-0101-4101-8101-010101010101/tmp_mut_all_1_1_0_2/data.bin", "mutated-main-data"); + ca_tx.createHardLink("b01/b01b01b0-0101-4101-8101-010101010101/all_1_1_0/aaaa.proj/data.cmrk4", + "b01/b01b01b0-0101-4101-8101-010101010101/tmp_mut_all_1_1_0_2/aaaa.proj/data.cmrk4"); + + /// 2a. loadProjections timing: read the carried sidecar in-flight at the TMP build path (pre-re-key). + { + auto buf = ca_tx.tryReadFileInFlight("b01/b01b01b0-0101-4101-8101-010101010101/tmp_mut_all_1_1_0_2/aaaa.proj/data.cmrk4", DB::ReadSettings{}, std::nullopt); + ASSERT_NE(buf, nullptr) << "carried-forward projection sidecar not readable in-flight at the tmp path"; + std::string got; DB::readStringUntilEOF(got, *buf); + EXPECT_EQ(got, "PROJMARKS9"); + EXPECT_EQ(ca_tx.tryGetInFlightFileSize("b01/b01b01b0-0101-4101-8101-010101010101/tmp_mut_all_1_1_0_2/aaaa.proj/data.cmrk4"), + std::optional(10)); + } + + /// 2b. After the tmp->final re-key (Phase 1), the sidecar must still resolve at the final path. + tx->moveDirectory("b01/b01b01b0-0101-4101-8101-010101010101/tmp_mut_all_1_1_0_2", "b01/b01b01b0-0101-4101-8101-010101010101/all_1_1_0_2"); + { + auto buf = ca_tx.tryReadFileInFlight("b01/b01b01b0-0101-4101-8101-010101010101/all_1_1_0_2/aaaa.proj/data.cmrk4", DB::ReadSettings{}, std::nullopt); + ASSERT_NE(buf, nullptr) << "carried-forward projection sidecar not readable in-flight at the final path after re-key"; + std::string got; DB::readStringUntilEOF(got, *buf); + EXPECT_EQ(got, "PROJMARKS9"); + } + + /// 3. And after commit it is durable + correct. + tx->commit(DB::NoCommitOptions{}); + EXPECT_EQ(storage->getFileSize("b01/b01b01b0-0101-4101-8101-010101010101/all_1_1_0_2/aaaa.proj/data.cmrk4"), 10u); +} + +/// [TXN-ONE-PIPELINE] Audit 5: on a commit, only refs this commit CREATED are eligible for rollback; +/// a repoint of an already-existing ref is NEVER dropped as compensation. `publishStaging` writes a +/// `CommitOutcome` with `created=false` for the repoint path (a committed ref exists), so `commit`'s +/// rollback loop skips this slot and the pre-existing part survives with its content carried forward. +TEST(CASTransactionLockScope, CommitRollbackSparesPreexistingRef) +{ + auto storage = openTxStorage(); + + /// Pre-existing committed part. + { + auto tx = storage->createTransaction(); + writeFileTx(*tx, "a88/a88a88a8-8888-4888-8888-888888888888/tmp_insert_all_1_1_0/data.bin", "orig"); + tx->moveDirectory("a88/a88a88a8-8888-4888-8888-888888888888/tmp_insert_all_1_1_0", "a88/a88a88a8-8888-4888-8888-888888888888/all_1_1_0"); + tx->commit(DB::NoCommitOptions{}); + } + ASSERT_TRUE(storage->existsDirectory("a88/a88a88a8-8888-4888-8888-888888888888/all_1_1_0")); + + /// A standalone write on the committed part repoints the EXISTING ref. Even if a later part in the + /// same commit were to fail, the existing ref must survive: `publishStaging` writes `created=false` + /// for this slot, so `commit`'s rollback loop skips it and it is never dropped on the error path. + { + auto tx = storage->createTransaction(); + writeFileTx(*tx, "a88/a88a88a8-8888-4888-8888-888888888888/all_1_1_0/metadata_version.txt", "1"); + tx->commit(DB::NoCommitOptions{}); + } + EXPECT_TRUE(storage->existsDirectory("a88/a88a88a8-8888-4888-8888-888888888888/all_1_1_0")); + EXPECT_TRUE(storage->existsFile("a88/a88a88a8-8888-4888-8888-888888888888/all_1_1_0/data.bin")); /// original content carried forward +} + +/// Plan 2d: a small eager metadata file (checksums.txt) is staged INLINE — it rides the single tree +/// object (one-GET part open) — while per-column data (data.bin) stays a standalone Blob (preserving +/// column-read selectivity). The inlined file is still readable through the normal read path. +TEST(CASTransactionInlining, EagerFileInlinedDataBinBlobbed) +{ + auto storage = openTxStorage(); + auto tx = storage->createTransaction(); + writeFileTx(*tx, "a99/a99a99a9-9999-4999-8999-999999999999/tmp_insert_all_1_1_0/checksums.txt", "the-checksums"); + writeFileTx(*tx, "a99/a99a99a9-9999-4999-8999-999999999999/tmp_insert_all_1_1_0/data.bin", std::string(50000, 'D')); + tx->moveDirectory("a99/a99a99a9-9999-4999-8999-999999999999/tmp_insert_all_1_1_0", "a99/a99a99a9-9999-4999-8999-999999999999/all_1_1_0"); + tx->commit(DB::NoCommitOptions{}); + + /// Resolve the published part to its manifest and inspect placements (the Pool read API, as in + /// gtest_cas_pool.cpp: resolveRef -> readManifest). + const auto ns = storage->liveNamespace("a99a99a9-9999-4999-8999-999999999999"); + const auto resolved = storage->store()->resolveRef(ns, "all_1_1_0"); + ASSERT_TRUE(resolved.has_value()); + const DB::Cas::PartManifest manifest = storage->store()->readManifest(resolved->manifest_id); + const auto & entries = manifest.entries; + + const auto * checksums = findByName(entries, "checksums.txt"); + const auto * databin = findByName(entries, "data.bin"); + ASSERT_TRUE(checksums && databin); + EXPECT_EQ(checksums->placement, DB::Cas::EntryPlacement::Inline); + EXPECT_EQ(checksums->inline_bytes, "the-checksums"); + EXPECT_EQ(databin->placement, DB::Cas::EntryPlacement::Blob); + + /// And the inlined file is still readable through the normal read path. + EXPECT_EQ(storage->getFileSize("a99/a99a99a9-9999-4999-8999-999999999999/all_1_1_0/checksums.txt"), 13u); +} + +/// all-tree-part-files Task 4: a standalone +/// write of ONE file onto an ALREADY-COMMITTED part must carry every other file of that part forward +/// (a repoint, Task 3), never replace the manifest with just the touched file. +TEST(CASTransactionRepoint, StandaloneWriteOnCommittedPartRepoints) +{ + auto storage = openTxStorage(); + + /// 1. Write a part (checksums.txt inline + data.bin blob) through a normal transaction; commit. + { + auto tx = storage->createTransaction(); + writeFileTx(*tx, "b02/b02b02b0-0202-4202-8202-020202020202/tmp_insert_all_1_1_0/checksums.txt", "old-checksums"); + writeFileTx(*tx, "b02/b02b02b0-0202-4202-8202-020202020202/tmp_insert_all_1_1_0/data.bin", "the-data-bytes"); + tx->moveDirectory("b02/b02b02b0-0202-4202-8202-020202020202/tmp_insert_all_1_1_0", "b02/b02b02b0-0202-4202-8202-020202020202/all_1_1_0"); + tx->commit(DB::NoCommitOptions{}); + } + ASSERT_TRUE(storage->existsFile("b02/b02b02b0-0202-4202-8202-020202020202/all_1_1_0/checksums.txt")); + ASSERT_TRUE(storage->existsFile("b02/b02b02b0-0202-4202-8202-020202020202/all_1_1_0/data.bin")); + + const uint64_t repoints_before = ProfileEvents::global_counters[ProfileEvents::CASRefRepoint].load(); + + /// 2. New transaction: standalone write of checksums.txt onto the ALREADY-COMMITTED part. + { + auto tx = storage->createTransaction(); + writeFileTx(*tx, "b02/b02b02b0-0202-4202-8202-020202020202/all_1_1_0/checksums.txt", "new-checksums-longer"); + tx->commit(DB::NoCommitOptions{}); + } + + /// 3. The new content is served, the untouched file is carried forward unchanged, exactly one + /// repoint fired, and an independent fsck reachability walk finds nothing dangling. + EXPECT_EQ(storage->getFileSize("b02/b02b02b0-0202-4202-8202-020202020202/all_1_1_0/checksums.txt"), 20u); + EXPECT_EQ(storage->getFileSize("b02/b02b02b0-0202-4202-8202-020202020202/all_1_1_0/data.bin"), 14u) + << "carry-forward: the untouched file must survive a standalone write on the same part"; + EXPECT_EQ(ProfileEvents::global_counters[ProfileEvents::CASRefRepoint].load(), repoints_before + 1); + + const auto rep = DB::Cas::runFsck(*storage->store(), /*detail*/false); + EXPECT_EQ(rep.dangling, 0u); +} + +/// Task 9 coverage gap (closed here, folded in from the T8 review): ONE uncommitted transaction that +/// BOTH writes a file and unlinks a DIFFERENT file of the SAME already-committed part must resolve to +/// exactly ONE repoint carrying the write, the removal, AND every untouched file forward together -- +/// not two independent repoints, and not a lost update from one staged change clobbering the other. +/// `publishStaging`'s Task 4/8 merge already handles `st.entries` and `st.content_removed` together +/// (both conditions can be true on the same staging); this pins that the combined shape actually works +/// end to end through the real transaction, not just through each half in isolation. +TEST(CASTransactionRepoint, CombinedWriteAndUnlinkSameTxnRepointsOnce) +{ + auto storage = openTxStorage(); + + /// 1. Commit a part with three files. + { + auto tx = storage->createTransaction(); + writeFileTx(*tx, "b03/b03b03b0-0303-4303-8303-030303030303/tmp_insert_all_1_1_0/checksums.txt", "old-checksums"); + writeFileTx(*tx, "b03/b03b03b0-0303-4303-8303-030303030303/tmp_insert_all_1_1_0/data.bin", "the-data-bytes"); + writeFileTx(*tx, "b03/b03b03b0-0303-4303-8303-030303030303/tmp_insert_all_1_1_0/txn_version.txt", + "creation_tid: (1,1,00000000-0000-0000-0000-000000000000)"); + tx->moveDirectory("b03/b03b03b0-0303-4303-8303-030303030303/tmp_insert_all_1_1_0", "b03/b03b03b0-0303-4303-8303-030303030303/all_1_1_0"); + tx->commit(DB::NoCommitOptions{}); + } + ASSERT_TRUE(storage->existsFile("b03/b03b03b0-0303-4303-8303-030303030303/all_1_1_0/txn_version.txt")); + + const uint64_t repoints_before = ProfileEvents::global_counters[ProfileEvents::CASRefRepoint].load(); + + /// 2. ONE transaction: write checksums.txt (new bytes) AND unlink txn_version.txt (a DIFFERENT + /// file of the same part) -- must resolve to exactly one repoint carrying both changes plus the + /// untouched data.bin. + { + auto tx = storage->createTransaction(); + writeFileTx(*tx, "b03/b03b03b0-0303-4303-8303-030303030303/all_1_1_0/checksums.txt", "new-checksums-longer"); + tx->unlinkFile("b03/b03b03b0-0303-4303-8303-030303030303/all_1_1_0/txn_version.txt", /*if_exists=*/false, /*should_remove_objects=*/true); + tx->commit(DB::NoCommitOptions{}); + } + + /// 3. The written file is updated, the unlinked file is honestly gone, the untouched file survives + /// (carry-forward), exactly ONE repoint fired (not two, not zero), and fsck finds nothing dangling. + EXPECT_EQ(storage->getFileSize("b03/b03b03b0-0303-4303-8303-030303030303/all_1_1_0/checksums.txt"), 20u); + EXPECT_FALSE(storage->existsFile("b03/b03b03b0-0303-4303-8303-030303030303/all_1_1_0/txn_version.txt")); + EXPECT_EQ(storage->getFileSize("b03/b03b03b0-0303-4303-8303-030303030303/all_1_1_0/data.bin"), 14u) + << "carry-forward: the untouched file must survive a combined write+unlink on the same part"; + EXPECT_EQ(ProfileEvents::global_counters[ProfileEvents::CASRefRepoint].load(), repoints_before + 1) + << "one uncommitted transaction combining a write and an unlink must resolve to exactly one repoint"; + + const auto rep = DB::Cas::runFsck(*storage->store(), /*detail*/false); + EXPECT_EQ(rep.dangling, 0u); +} + +/// all-tree-part-files Task 6: the mutable- +/// per-part-file branch is deleted from `writeFile` -- uuid.txt/metadata_version.txt/txn_version.txt +/// now flow down the ordinary content path, landing in the manifest like any other file. +TEST(CASTransactionAllTree, BuildTimeSidecarsLandInManifest) +{ + auto storage = openTxStorage(); + auto tx = storage->createTransaction(); + writeFileTx(*tx, "b04/b04b04b0-0404-4404-8404-040404040404/tmp_insert_all_1_1_0/uuid.txt", "part-uuid-bytes"); + writeFileTx(*tx, "b04/b04b04b0-0404-4404-8404-040404040404/tmp_insert_all_1_1_0/metadata_version.txt", "3"); + writeFileTx(*tx, "b04/b04b04b0-0404-4404-8404-040404040404/tmp_insert_all_1_1_0/txn_version.txt", "creation_tid: (1,1,00000000-0000-0000-0000-000000000000)"); + writeFileTx(*tx, "b04/b04b04b0-0404-4404-8404-040404040404/tmp_insert_all_1_1_0/data.bin", "the-data-bytes"); + tx->moveDirectory("b04/b04b04b0-0404-4404-8404-040404040404/tmp_insert_all_1_1_0", "b04/b04b04b0-0404-4404-8404-040404040404/all_1_1_0"); + tx->commit(DB::NoCommitOptions{}); + + const auto ns = storage->liveNamespace("b04b04b0-0404-4404-8404-040404040404"); + const auto resolved = storage->store()->resolveRef(ns, "all_1_1_0"); + ASSERT_TRUE(resolved.has_value()); + + const DB::Cas::PartManifest manifest = storage->store()->readManifest(resolved->manifest_id); + const auto & entries = manifest.entries; + const auto * uuid_entry = findByName(entries, "uuid.txt"); + const auto * meta_version_entry = findByName(entries, "metadata_version.txt"); + const auto * txn_version_entry = findByName(entries, "txn_version.txt"); + ASSERT_TRUE(uuid_entry && meta_version_entry && txn_version_entry) + << "all three sidecar files must land in the manifest as ordinary tree entries"; + EXPECT_EQ(uuid_entry->placement, DB::Cas::EntryPlacement::Inline); + EXPECT_EQ(meta_version_entry->placement, DB::Cas::EntryPlacement::Inline); + EXPECT_EQ(txn_version_entry->placement, DB::Cas::EntryPlacement::Inline); + EXPECT_EQ(meta_version_entry->inline_bytes, "3"); + + /// And they are readable through the normal read path — Task 9 deleted the ForceFresh special + /// case these reads used to go through; they now resolve purely via the manifest view like any + /// other entry (existsFile / getFileSize / tryGetInManifestBytes). + EXPECT_TRUE(storage->existsFile("b04/b04b04b0-0404-4404-8404-040404040404/all_1_1_0/metadata_version.txt")); + EXPECT_EQ(storage->getFileSize("b04/b04b04b0-0404-4404-8404-040404040404/all_1_1_0/metadata_version.txt"), 1u); +} + +/// A standalone one-shot write of txn_version.txt onto an ALREADY-COMMITTED part (the MVCC creation- +/// CSN fill-in / removal-TID rewrite shape) must repoint (Task 4), never orphan the rest of the part. +TEST(CASTransactionAllTree, CommittedTxnVersionStoreRepoints) +{ + auto storage = openTxStorage(); + + /// 1. Commit a part WITHOUT txn_version.txt. + { + auto tx = storage->createTransaction(); + writeFileTx(*tx, "b05/b05b05b0-0505-4505-8505-050505050505/tmp_insert_all_1_1_0/checksums.txt", "cs-bytes"); + writeFileTx(*tx, "b05/b05b05b0-0505-4505-8505-050505050505/tmp_insert_all_1_1_0/data.bin", "the-data-bytes"); + tx->moveDirectory("b05/b05b05b0-0505-4505-8505-050505050505/tmp_insert_all_1_1_0", "b05/b05b05b0-0505-4505-8505-050505050505/all_1_1_0"); + tx->commit(DB::NoCommitOptions{}); + } + ASSERT_FALSE(storage->existsFile("b05/b05b05b0-0505-4505-8505-050505050505/all_1_1_0/txn_version.txt")); + + const uint64_t repoints_before = ProfileEvents::global_counters[ProfileEvents::CASRefRepoint].load(); + + /// 2. A single-op transaction writes ONLY txn_version.txt onto the already-committed part (mirrors + /// the MVCC one-shot autocommit shape: no other file touched in this transaction). + { + auto tx = storage->createTransaction(); + writeFileTx(*tx, "b05/b05b05b0-0505-4505-8505-050505050505/all_1_1_0/txn_version.txt", "creation_tid: (2,2,00000000-0000-0000-0000-000000000000)"); + tx->commit(DB::NoCommitOptions{}); + } + + /// 3. Exactly one repoint; the new file is served; the original files are intact (carry-forward). + EXPECT_EQ(ProfileEvents::global_counters[ProfileEvents::CASRefRepoint].load(), repoints_before + 1); + EXPECT_TRUE(storage->existsFile("b05/b05b05b0-0505-4505-8505-050505050505/all_1_1_0/txn_version.txt")); + EXPECT_EQ(storage->getFileSize("b05/b05b05b0-0505-4505-8505-050505050505/all_1_1_0/txn_version.txt"), 56u); + EXPECT_EQ(storage->getFileSize("b05/b05b05b0-0505-4505-8505-050505050505/all_1_1_0/checksums.txt"), 8u); + EXPECT_EQ(storage->getFileSize("b05/b05b05b0-0505-4505-8505-050505050505/all_1_1_0/data.bin"), 14u); + + const auto rep = DB::Cas::runFsck(*storage->store(), /*detail*/false); + EXPECT_EQ(rep.dangling, 0u); +} + +/// all-tree-part-files Task 8 (B123 evolution): +/// a lone surgical unlink of ONE committed content file (not followed by a whole-part removal in the +/// same transaction — the ATTACH `removeVersionMetadata` shape) must actually delete the file via a +/// repoint-remove, closing the pre-Task-8 fail-open (unlinkFile of a committed content file used to be +/// an unconditional no-op). +TEST(CASTransactionRemove, SurgicalUnlinkRepoints) +{ + auto storage = openTxStorage(); + + /// 1. Commit a part with txn_version.txt among its files. + { + auto tx = storage->createTransaction(); + writeFileTx(*tx, "b06/b06b06b0-0606-4606-8606-060606060606/tmp_insert_all_1_1_0/checksums.txt", "cs-bytes"); + writeFileTx(*tx, "b06/b06b06b0-0606-4606-8606-060606060606/tmp_insert_all_1_1_0/data.bin", "the-data-bytes"); + writeFileTx(*tx, "b06/b06b06b0-0606-4606-8606-060606060606/tmp_insert_all_1_1_0/txn_version.txt", + "creation_tid: (1,1,00000000-0000-0000-0000-000000000000)"); + tx->moveDirectory("b06/b06b06b0-0606-4606-8606-060606060606/tmp_insert_all_1_1_0", "b06/b06b06b0-0606-4606-8606-060606060606/all_1_1_0"); + tx->commit(DB::NoCommitOptions{}); + } + ASSERT_TRUE(storage->existsFile("b06/b06b06b0-0606-4606-8606-060606060606/all_1_1_0/txn_version.txt")); + + const uint64_t repoints_before = ProfileEvents::global_counters[ProfileEvents::CASRefRepoint].load(); + + /// 2. A single-op transaction unlinks ONLY txn_version.txt on the already-committed part (mirrors + /// ATTACH's removeVersionMetadata: no dir-drop in the same transaction). + { + auto tx = storage->createTransaction(); + tx->unlinkFile("b06/b06b06b0-0606-4606-8606-060606060606/all_1_1_0/txn_version.txt", /*if_exists=*/false, /*should_remove_objects=*/true); + tx->commit(DB::NoCommitOptions{}); + } + + /// 3. The file is honestly gone, the untouched files survive (carry-forward), exactly one repoint + /// fired, and an independent fsck reachability walk finds nothing dangling. + EXPECT_FALSE(storage->existsFile("b06/b06b06b0-0606-4606-8606-060606060606/all_1_1_0/txn_version.txt")); + EXPECT_EQ(storage->getFileSize("b06/b06b06b0-0606-4606-8606-060606060606/all_1_1_0/checksums.txt"), 8u); + EXPECT_EQ(storage->getFileSize("b06/b06b06b0-0606-4606-8606-060606060606/all_1_1_0/data.bin"), 14u); + EXPECT_EQ(ProfileEvents::global_counters[ProfileEvents::CASRefRepoint].load(), repoints_before + 1); + + const auto rep = DB::Cas::runFsck(*storage->store(), /*detail*/false); + EXPECT_EQ(rep.dangling, 0u); +} + +/// all-tree-part-files Task 8 (B123 evolution, spec §6): the DOMINANT CA removal path — the MergeTree +/// fast-removal shape that unlinks every part file one by one and THEN calls removeDirectory — must +/// stay exactly one ref-drop and pay ZERO repoints. The per-file removal marks staged by the unlink +/// storm are superseded by the ref-drop, not individually repointed. +TEST(CASTransactionRemove, UnlinkStormThenDirDropIsOneRefDrop) +{ + auto storage = openTxStorage(); + + /// 1. Commit a part with three files. + { + auto tx = storage->createTransaction(); + writeFileTx(*tx, "b07/b07b07b0-0707-4707-8707-070707070707/tmp_insert_all_1_1_0/checksums.txt", "cs-bytes"); + writeFileTx(*tx, "b07/b07b07b0-0707-4707-8707-070707070707/tmp_insert_all_1_1_0/data.bin", "the-data-bytes"); + writeFileTx(*tx, "b07/b07b07b0-0707-4707-8707-070707070707/tmp_insert_all_1_1_0/txn_version.txt", + "creation_tid: (1,1,00000000-0000-0000-0000-000000000000)"); + tx->moveDirectory("b07/b07b07b0-0707-4707-8707-070707070707/tmp_insert_all_1_1_0", "b07/b07b07b0-0707-4707-8707-070707070707/all_1_1_0"); + tx->commit(DB::NoCommitOptions{}); + } + ASSERT_TRUE(storage->existsDirectory("b07/b07b07b0-0707-4707-8707-070707070707/all_1_1_0")); + + const uint64_t repoints_before = ProfileEvents::global_counters[ProfileEvents::CASRefRepoint].load(); + + /// 2. The MergeTree fast-removal shape (IMergeTreeDataPart::remove, B123): unlink every file + /// one-by-one, THEN removeDirectory the part — all in one transaction. + { + auto tx = storage->createTransaction(); + tx->unlinkFile("b07/b07b07b0-0707-4707-8707-070707070707/all_1_1_0/checksums.txt", /*if_exists=*/false, /*should_remove_objects=*/true); + tx->unlinkFile("b07/b07b07b0-0707-4707-8707-070707070707/all_1_1_0/data.bin", /*if_exists=*/false, /*should_remove_objects=*/true); + tx->unlinkFile("b07/b07b07b0-0707-4707-8707-070707070707/all_1_1_0/txn_version.txt", /*if_exists=*/false, /*should_remove_objects=*/true); + tx->removeDirectory("b07/b07b07b0-0707-4707-8707-070707070707/all_1_1_0"); + tx->commit(DB::NoCommitOptions{}); + } + + /// 3. The whole part is gone via the single ref-drop; the storm of marks never repointed anything. + EXPECT_FALSE(storage->existsDirectory("b07/b07b07b0-0707-4707-8707-070707070707/all_1_1_0")); + EXPECT_EQ(ProfileEvents::global_counters[ProfileEvents::CASRefRepoint].load(), repoints_before) + << "unlink-storm-then-dir-drop must supersede the marks, not repoint per file"; + + const auto rep = DB::Cas::runFsck(*storage->store(), /*detail*/false); + EXPECT_EQ(rep.dangling, 0u); +} + +/// Task 22 (URF plan phase 7): the MergeTree fast-removal shape's per-file ForceFresh proof is +/// memoized per (transaction, ref) in `unlinkFile` — the first unlink's `ForceFresh` `getView` re-proves +/// the manifest body with one HEAD; the rest of the burst reuses that proof via `CachedForLoad`. This +/// pins the HEAD-count side of `UnlinkStormThenDirDropIsOneRefDrop` above (which already pins the +/// repoint count): before this memoization, N unlinks of the same part paid N manifest-body HEADs; now +/// the whole storm-then-drop transaction pays exactly one. +TEST(CASTransactionRemove, UnlinkStormMemoizesOneForceFreshHead) +{ + auto storage = openTxStorage(); + + /// 1. Commit a part with three files. + { + auto tx = storage->createTransaction(); + writeFileTx(*tx, "b09/b09b09b0-0909-4909-8909-090909090909/tmp_insert_all_1_1_0/checksums.txt", "cs-bytes"); + writeFileTx(*tx, "b09/b09b09b0-0909-4909-8909-090909090909/tmp_insert_all_1_1_0/data.bin", "the-data-bytes"); + writeFileTx(*tx, "b09/b09b09b0-0909-4909-8909-090909090909/tmp_insert_all_1_1_0/txn_version.txt", + "creation_tid: (1,1,00000000-0000-0000-0000-000000000000)"); + tx->moveDirectory("b09/b09b09b0-0909-4909-8909-090909090909/tmp_insert_all_1_1_0", "b09/b09b09b0-0909-4909-8909-090909090909/all_1_1_0"); + tx->commit(DB::NoCommitOptions{}); + } + ASSERT_TRUE(storage->existsDirectory("b09/b09b09b0-0909-4909-8909-090909090909/all_1_1_0")); + + const uint64_t heads_before = ProfileEvents::global_counters[ProfileEvents::CASManifestHead].load(); + + /// 2. The MergeTree fast-removal shape: unlink every file one-by-one, THEN removeDirectory — all + /// in ONE transaction (mirrors UnlinkStormThenDirDropIsOneRefDrop above). + { + auto tx = storage->createTransaction(); + tx->unlinkFile("b09/b09b09b0-0909-4909-8909-090909090909/all_1_1_0/checksums.txt", /*if_exists=*/false, /*should_remove_objects=*/true); + tx->unlinkFile("b09/b09b09b0-0909-4909-8909-090909090909/all_1_1_0/data.bin", /*if_exists=*/false, /*should_remove_objects=*/true); + tx->unlinkFile("b09/b09b09b0-0909-4909-8909-090909090909/all_1_1_0/txn_version.txt", /*if_exists=*/false, /*should_remove_objects=*/true); + tx->removeDirectory("b09/b09b09b0-0909-4909-8909-090909090909/all_1_1_0"); + tx->commit(DB::NoCommitOptions{}); + } + + /// 3. The whole part is gone, and the three-file unlink storm paid exactly ONE manifest-body HEAD + /// (the first unlink's ForceFresh proof) — not three. removeDirectory clears the staged removal + /// marks, so publishStaging's own (unmemoized) ForceFresh getView never fires for this ref either + /// (see UnlinkStormThenDirDropIsOneRefDrop's zero-repoints assertion above). + EXPECT_FALSE(storage->existsDirectory("b09/b09b09b0-0909-4909-8909-090909090909/all_1_1_0")); + EXPECT_EQ(ProfileEvents::global_counters[ProfileEvents::CASManifestHead].load(), heads_before + 1) + << "unlink-storm-then-dir-drop must pay exactly one ForceFresh manifest-body HEAD, not one per file"; +} + +/// A create-then-remove of a new part in one transaction must discard both the manifest entries +/// and the in-flight build, so commit() leaves no ref and no live precommit behind. +TEST(CASTransactionRemove, CreateThenDirDropDoesNotPublish) +{ + auto storage = openTxStorage(); + auto tx = storage->createTransaction(); + writeFileTx(*tx, "b08/b08b08b0-0808-4808-8808-080808080808/all_1_1_0/data.bin", "created-then-removed"); + tx->removeDirectory("b08/b08b08b0-0808-4808-8808-080808080808/all_1_1_0"); + tx->commit(DB::NoCommitOptions{}); + + EXPECT_FALSE(storage->existsDirectory("b08/b08b08b0-0808-4808-8808-080808080808/all_1_1_0")); +} + +/// [Task 3] `startup()` publishes `cas_store`/`part_access`/`gc_scheduler` (and sets +/// `pool_uuid`/`conditional_copy_supported`) atomically as its LAST action. Everything +/// before that point -- opening the pool, building the part-folder facade, the capability probe, +/// starting the GC scheduler -- happens into locals first, so a throw anywhere along the way (here +/// simulated via `startup_fault_injection_for_test`, injected right before the publish step) must +/// leave nothing published: `store()` still refuses (null pool -- the Constructing lifecycle, +/// `INVALID_STATE` "not started") even though `Pool::open` and everything else already succeeded. +/// Clearing the hook and retrying `startup()` must then succeed cleanly. +TEST(CASTransactionLifecycle, StartupFailureLatePublishesNothing) +{ + auto storage = makeUnstartedTxStorage(); + + storage->startup_fault_injection_for_test = [] { throw std::runtime_error("injected late-startup failure"); }; + EXPECT_ANY_THROW(storage->startup()); + /// Nothing was published by the failed attempt: store() must still refuse (null pool, not started). + EXPECT_ANY_THROW(storage->store()); + + storage->startup_fault_injection_for_test = {}; + EXPECT_NO_THROW(storage->startup()); + EXPECT_NO_THROW(storage->store()); +} + +/// [Task 4] The storage-lifecycle gate: `store()` (and every other caller of `poolAccess()`) must +/// refuse with `INVALID_STATE` -- an operational condition, not a programming invariant -- whenever +/// no pool is published (the null-pool ShutDown lifecycle after `shutdown`). This is a deliberate +/// behavior change from the previous `LOGICAL_ERROR "accessed before startup"`, which would abort a +/// debug/sanitizer build on a mis-sequenced access instead of surfacing a catchable, operator-actionable +/// error. +TEST(CASTransactionWiring, OperationsRefuseWithoutPublishedPool) +{ + auto storage = openTxStorage(); + storage->shutdown(); /// terminal path resets cas_store to null (the ShutDown storage lifecycle) + + try + { + storage->store(); + FAIL() << "store() must refuse once the disk has no published pool"; + } + catch (const DB::Exception & e) + { + EXPECT_EQ(e.code(), DB::ErrorCodes::INVALID_STATE) << e.message(); + } +} + +/// (rev.8, Task 15) The Dormant/UNMOUNT lifecycle rollback flips the transitional benign-absent probe +/// behavior to fail-loud on a NULL pool. A storage with no published pool (here: after `shutdown()`, the +/// null-pool ShutDown storage lifecycle) refuses the ENTIRE surface -- including the read-only +/// existence/enumeration probes that the old (now-deleted) `DormantDiskAnswersExistenceProbesAsAbsent` +/// asserted answered benign-absent. This is spec §1's null-pool fail-loud contract: every op class, +/// `Probe` included, throws `INVALID_STATE` ("not started"); a genuinely `Vanished` POOL is the only +/// state that answers truth-absent, and a null pool is not that. (Behavior change documented in the +/// Task 15 report: generic all-disk existence sweeps during server shutdown now see a throw here, not a +/// benign absent; the shutdown window is the deliberate cost of never lying about a not-started disk.) +TEST(CASLifecycle, ShutdownDiskProbesFailLoud) +{ + auto storage = openTxStorage(); + storage->shutdown(); /// null pool -- the ShutDown storage lifecycle + + const std::string file = "a11/a11a11a1-1111-4111-8111-111111111111/all_1_1_0/data.bin"; + const std::string part_dir = "a11/a11a11a1-1111-4111-8111-111111111111/all_1_1_0"; + + /// Every read-only probe now THROWS (not started), where the transitional Dormant path answered benign. + DB::Cas::tests::expectThrowsCode(DB::ErrorCodes::INVALID_STATE, [&] { storage->existsDirectory("store"); }); + DB::Cas::tests::expectThrowsCode(DB::ErrorCodes::INVALID_STATE, [&] { storage->existsFile(file); }); + DB::Cas::tests::expectThrowsCode(DB::ErrorCodes::INVALID_STATE, [&] { storage->existsFileOrDirectory(part_dir); }); + DB::Cas::tests::expectThrowsCode(DB::ErrorCodes::INVALID_STATE, [&] { storage->isDirectoryEmpty("store"); }); + DB::Cas::tests::expectThrowsCode(DB::ErrorCodes::INVALID_STATE, [&] { (void)storage->listDirectory("store"); }); + DB::Cas::tests::expectThrowsCode(DB::ErrorCodes::INVALID_STATE, [&] { (void)storage->iterateDirectory("store"); }); + DB::Cas::tests::expectThrowsCode(DB::ErrorCodes::INVALID_STATE, [&] { (void)storage->getStorageObjectsIfExist(file); }); + + /// The content/size surface stays fail-close too (unchanged from the transitional behavior). + DB::Cas::tests::expectThrowsCode(DB::ErrorCodes::INVALID_STATE, [&] { (void)storage->getFileSize(file); }); + DB::Cas::tests::expectThrowsCode(DB::ErrorCodes::INVALID_STATE, [&] { (void)storage->getStorageObjects(file); }); +} diff --git a/src/Disks/tests/gtest_ca_wiring.cpp b/src/Disks/tests/gtest_ca_wiring.cpp new file mode 100644 index 000000000000..cf9ee1e73dac --- /dev/null +++ b/src/Disks/tests/gtest_ca_wiring.cpp @@ -0,0 +1,2963 @@ +#include +#include +#include +#include +#include +#include + +namespace DB::ErrorCodes +{ + extern const int NOT_IMPLEMENTED; +} + +/// M-W wiring tier (design 2026-06-11 section 7 tier 3): the ClickHouse-facing translation layer +/// tested through its own seams. Task 1: PartPathParser — the path-classification rows plus the +/// shadow/detached/mutable rows the later tasks route on. + +using namespace DB::Cas; + +TEST(CASPartPathParser, ParsePartFilePathAtomic) +{ + auto file = parsePartFilePath("a11/a11a11a1-1111-4111-8111-111111111111/all_1_1_0/columns.txt"); + ASSERT_TRUE(file.has_value()); + EXPECT_EQ(file->table_uuid, "a11a11a1-1111-4111-8111-111111111111"); + EXPECT_EQ(file->part_name, "all_1_1_0"); + EXPECT_EQ(file->file, "columns.txt"); + EXPECT_TRUE(file->backup_name.empty()); + EXPECT_TRUE(file->shadow_table_dir.empty()); + + auto part_dir = parsePartFilePath("a11/a11a11a1-1111-4111-8111-111111111111/all_1_1_0/"); // trailing slash, no file + ASSERT_TRUE(part_dir.has_value()); + EXPECT_EQ(part_dir->part_name, "all_1_1_0"); + EXPECT_TRUE(part_dir->file.empty()); + + EXPECT_FALSE(parsePartFilePath("a11/a11a11a1-1111-4111-8111-111111111111").has_value()); // table dir, not a part + EXPECT_FALSE(parsePartFilePath("123").has_value()); // shallower + + // The real-server shape carries a leading store/; the uuid-pair anchor makes it equivalent. + auto atomic = parsePartFilePath("store/a11/a11a11a1-1111-4111-8111-111111111111/all_1_1_0/data.bin"); + ASSERT_TRUE(atomic.has_value()); + EXPECT_EQ(atomic->table_uuid, "a11a11a1-1111-4111-8111-111111111111"); + EXPECT_EQ(atomic->part_name, "all_1_1_0"); + EXPECT_EQ(atomic->file, "data.bin"); +} + +TEST(CASPartPathParser, ThreeCharDatabaseSharingTablePrefixDoesNotFalseAnchorAsAtomic) +{ + // T12: a non-Atomic 3-char database directory whose table directory happens to start with the + // SAME 3 characters (db "abc", table "abcxyz") used to satisfy the old loose Atomic-anchor shape + // check (`prefix.size() == 3 && uuid.compare(0, 3, prefix) == 0`), false-anchoring "abc" as a + // UUID hash-prefix and "abcxyz" as the table UUID -- even though neither looks anything like a + // real UUID. The anchor now additionally requires the prefix to be lowercase-hex and the + // candidate to have the exact 36-char dashed UUID shape, so this path falls through to the + // non-Atomic fallback split instead (folding the whole leading path into table_uuid, exactly like + // ParsePartFilePathNonAtomic's "data/memory_01069/mt" case). + auto d = parsePartFilePath("data/abc/abcxyz/1_1_1_0/x.bin"); + ASSERT_TRUE(d.has_value()); + EXPECT_EQ(d->table_uuid, "data/abc/abcxyz"); + EXPECT_EQ(d->part_name, "1_1_1_0"); + EXPECT_EQ(d->file, "x.bin"); +} + +TEST(CASPartPathParser, RealHexPrefixUuidPairStillAnchorsAsAtomic) +{ + // Positive control for the tightened anchor: a REAL Atomic on-disk shape -- + // store// with the UUID correctly 36-char dashed and genuinely sharing its first + // 3 characters with the prefix -- still anchors exactly as before. + auto a = parsePartFilePath("store/abc/abc12345-1234-5678-9abc-def012345678/all_1_1_0/x.bin"); + ASSERT_TRUE(a.has_value()); + EXPECT_EQ(a->table_uuid, "abc12345-1234-5678-9abc-def012345678"); + EXPECT_EQ(a->part_name, "all_1_1_0"); + EXPECT_EQ(a->file, "x.bin"); +} + +TEST(CASPartPathParser, ParsePartFilePathProjectionSubPath) +{ + // A projection file keeps its FULL in-part relative path as the file (the tree entry name). + auto proj = parsePartFilePath("a11/a11a11a1-1111-4111-8111-111111111111/all_1_1_0/p.proj/data.bin"); + ASSERT_TRUE(proj.has_value()); + EXPECT_EQ(proj->part_name, "all_1_1_0"); + EXPECT_EQ(proj->file, "p.proj/data.bin"); +} + +TEST(CASPartPathParser, ParsePartFilePathNonAtomic) +{ + // Non-Atomic (Ordinary/Memory/Lazy) layout: data//// — no uuid anchor; + // the part dir is recognized by its block-range suffix (B40). + auto file = parsePartFilePath("data/memory_01069/mt/all_1_1_0/data.cmrk4"); + ASSERT_TRUE(file.has_value()); + EXPECT_EQ(file->table_uuid, "data/memory_01069/mt"); + EXPECT_EQ(file->part_name, "all_1_1_0"); + EXPECT_EQ(file->file, "data.cmrk4"); + + // Temporary/operation prefixes keep the suffix and stay part dirs. + auto tmp = parsePartFilePath("data/memory_01069/mt/tmp_insert_all_1_1_0/data.cmrk4"); + ASSERT_TRUE(tmp.has_value()); + EXPECT_EQ(tmp->part_name, "tmp_insert_all_1_1_0"); + + // Mutation-level form ____. + auto mut = parsePartFilePath("data/db/tbl/20200101_1_1_0_5/data.bin"); + ASSERT_TRUE(mut.has_value()); + EXPECT_EQ(mut->part_name, "20200101_1_1_0_5"); + + // A non-Atomic table-level file is NOT a part file. + EXPECT_FALSE(isPartFilePath("data/memory_01069/mt/format_version.txt")); + auto tf = parseTableFilePath("data/memory_01069/mt/format_version.txt"); + ASSERT_TRUE(tf.has_value()); + EXPECT_EQ(tf->table_uuid, "data/memory_01069/mt"); + EXPECT_EQ(tf->tail, "format_version.txt"); + + EXPECT_EQ(parseTableUuid("data/memory_01069/mt"), std::optional("data/memory_01069/mt")); + + // Generic disk-root files classify as nothing (verbatim passthrough). + EXPECT_FALSE(isPartFilePath("clickhouse_access_check_xyz")); + EXPECT_FALSE(parseTableFilePath("clickhouse_access_check_xyz").has_value()); + EXPECT_FALSE(parseTableUuid("clickhouse_access_check_xyz").has_value()); +} + +TEST(CASPartPathParser, ParseTableUuid) +{ + EXPECT_EQ(parseTableUuid("a11/a11a11a1-1111-4111-8111-111111111111/"), std::optional("a11a11a1-1111-4111-8111-111111111111")); + EXPECT_EQ(parseTableUuid("a11/a11a11a1-1111-4111-8111-111111111111"), std::optional("a11a11a1-1111-4111-8111-111111111111")); + EXPECT_FALSE(parseTableUuid("a11/a11a11a1-1111-4111-8111-111111111111/all_1_1_0").has_value()); // part dir, not table dir + + EXPECT_TRUE(endsWithTableUuidPair("store/a11/a11a11a1-1111-4111-8111-111111111111")); + EXPECT_FALSE(endsWithTableUuidPair("store/a11/a11a11a1-1111-4111-8111-111111111111/all_1_1_0")); + EXPECT_FALSE(endsWithTableUuidPair("shadow/bk1/store")); +} + +TEST(CASPartPathParser, ParseTableFilePathNested) +{ + // The reserved deduplication_logs/ subdir is a table-level namespace, never a part dir. + EXPECT_FALSE(isPartFilePath("a11/a11a11a1-1111-4111-8111-111111111111/deduplication_logs/deduplication_log_1.txt")); + auto tf = parseTableFilePath("a11/a11a11a1-1111-4111-8111-111111111111/deduplication_logs/deduplication_log_1.txt"); + ASSERT_TRUE(tf.has_value()); + EXPECT_EQ(tf->table_uuid, "a11a11a1-1111-4111-8111-111111111111"); + EXPECT_EQ(tf->tail, "deduplication_logs/deduplication_log_1.txt"); + + auto flat = parseTableFilePath("a11/a11a11a1-1111-4111-8111-111111111111/format_version.txt"); + ASSERT_TRUE(flat.has_value()); + EXPECT_EQ(flat->tail, "format_version.txt"); + + EXPECT_FALSE(parseTableFilePath("a11/a11a11a1-1111-4111-8111-111111111111").has_value()); + EXPECT_FALSE(parseTableFilePath("a11/a11a11a1-1111-4111-8111-111111111111/").has_value()); + + EXPECT_TRUE(isPartFilePath("a11/a11a11a1-1111-4111-8111-111111111111/all_1_1_0/data.bin")); +} + +TEST(CASPartPathParser, ShadowFreezePaths) +{ + EXPECT_TRUE(isShadowPath("shadow/bk1/store/a11/a11a11a1-1111-4111-8111-111111111111/all_1_1_0/data.bin")); + EXPECT_TRUE(isShadowPath("/shadow/bk1")); + EXPECT_FALSE(isShadowPath("store/a11/a11a11a1-1111-4111-8111-111111111111/all_1_1_0/data.bin")); + EXPECT_FALSE(isShadowPath("shadowy/bk1")); + + auto s = parsePartFilePath("shadow/bk1/store/a11/a11a11a1-1111-4111-8111-111111111111/all_1_1_0/data.bin"); + ASSERT_TRUE(s.has_value()); + EXPECT_EQ(s->table_uuid, "a11a11a1-1111-4111-8111-111111111111"); + EXPECT_EQ(s->part_name, "all_1_1_0"); + EXPECT_EQ(s->file, "data.bin"); + EXPECT_EQ(s->backup_name, "bk1"); + EXPECT_EQ(s->shadow_table_dir, "shadow/bk1/store/a11/a11a11a1-1111-4111-8111-111111111111"); +} + +TEST(CASPartPathParser, DetachedPathsReportTheSharedDetachedComponent) +{ + // The PoC contract (B36): "detached" parses as the part_name; the real detached part dir is + // the first component of `file`. The transaction/read routing re-splits on this shape. + auto d = parsePartFilePath("a11/a11a11a1-1111-4111-8111-111111111111/detached/attaching_all_0_0_0/metadata_version.txt"); + ASSERT_TRUE(d.has_value()); + EXPECT_EQ(d->table_uuid, "a11a11a1-1111-4111-8111-111111111111"); + EXPECT_EQ(d->part_name, std::string(kDetachedDirName)); + EXPECT_EQ(d->file, "attaching_all_0_0_0/metadata_version.txt"); +} + +TEST(CASPartPathParser, MovingPathsReportTheSharedMovingComponent) +{ + // Atomic layout: "moving" lands on part_idx for free (it is the component right after the + // table , same mechanism as "detached" -- no parser change needed here, only route()). + auto d = parsePartFilePath("a11/a11a11a1-1111-4111-8111-111111111111/moving/all_1_1_0/data.bin"); + ASSERT_TRUE(d.has_value()); + EXPECT_EQ(d->table_uuid, "a11a11a1-1111-4111-8111-111111111111"); + EXPECT_EQ(d->part_name, std::string(kMovingDirName)); + EXPECT_EQ(d->file, "all_1_1_0/data.bin"); +} + +TEST(CASPartPathParser, MovingPathsNonAtomicFoldIntoTheTableNamespace) +{ + // Mirrors DetachedPathsNonAtomicFoldIntoTheTableNamespace (U#6): without an explicit anchor + // the right-to-left part-dir scan would anchor on the INNER real part dir and fold "moving" + // into a spurious table_uuid ("data//
/moving"), diverging from the table's real + // namespace -- the identical bug class the detached anchor was added to prevent. + auto d = parsePartFilePath("data/db/tbl/moving/all_1_1_0/data.bin"); + ASSERT_TRUE(d.has_value()); + EXPECT_EQ(d->table_uuid, "data/db/tbl"); + EXPECT_EQ(d->part_name, std::string(kMovingDirName)); + EXPECT_EQ(d->file, "all_1_1_0/data.bin"); + + // The bare non-Atomic moving CONTAINER dir folds to part_name == "moving" with an empty + // file, exactly like the Atomic container. + auto c = parsePartFilePath("data/db/tbl/moving"); + ASSERT_TRUE(c.has_value()); + EXPECT_EQ(c->table_uuid, "data/db/tbl"); + EXPECT_EQ(c->part_name, std::string(kMovingDirName)); + EXPECT_TRUE(c->file.empty()); +} + +TEST(CASPartPathParser, DetachedPathsNonAtomicFoldIntoTheTableNamespace) +{ + // U#6: the Ordinary/non-Atomic detached form data//
/detached// must fold + // into the table's OWN namespace with part_name == "detached" (mirroring the Atomic form), so + // route() keys the detached/ ref off it. The right-to-left part-dir scan would otherwise + // anchor on the INNER part dir and fold `detached` into a spurious table_uuid + // ("data//
/detached") that DROP TABLE never cleans — a permanently orphaned live ref. + auto d = parsePartFilePath("data/db/tbl/detached/attaching_all_0_0_0/metadata_version.txt"); + ASSERT_TRUE(d.has_value()); + EXPECT_EQ(d->table_uuid, "data/db/tbl"); + EXPECT_EQ(d->part_name, std::string(kDetachedDirName)); + EXPECT_EQ(d->file, "attaching_all_0_0_0/metadata_version.txt"); + + // The bare non-Atomic detached CONTAINER dir folds to part_name == "detached" with an empty file, + // exactly like the Atomic container, so route()'s empty-ref branch is reached for both layouts. + auto c = parsePartFilePath("data/db/tbl/detached"); + ASSERT_TRUE(c.has_value()); + EXPECT_EQ(c->table_uuid, "data/db/tbl"); + EXPECT_EQ(c->part_name, std::string(kDetachedDirName)); + EXPECT_TRUE(c->file.empty()); +} + +TEST(CASPartPathParser, DetachedNamedTableIsKnownAmbiguityFoldedAsReservedDir) +{ + // ACCEPTED LIMITATION (see the anchor-site comment in findPartDirComponent): a non-Atomic + // database or TABLE literally named "detached" is structurally indistinguishable, from the path + // string alone, from the reserved detached subdir of a table one level up — so it gets folded + // as the reserved dir, not as a table name. This test PINS that known, deliberately-accepted + // behavior (backlogged by the stabilization campaign) so any future change to it is a conscious + // one, not an accidental regression. + auto d = parsePartFilePath("data/db/detached/all_1_1_0/data.bin"); + ASSERT_TRUE(d.has_value()); + EXPECT_EQ(d->table_uuid, "data/db"); + EXPECT_EQ(d->part_name, std::string(kDetachedDirName)); + EXPECT_EQ(d->file, "all_1_1_0/data.bin"); + + // Consequently the table dir itself is unrecognized: it looks like a detached container instead. + EXPECT_FALSE(parseTableUuid("data/db/detached").has_value()); +} + +TEST(CASPartPathParser, RawPathSplitMemoizedAcrossClassifiers) +{ + // The CA read path runs isPartFilePath then parsePartFilePath on the SAME raw path several times + // per logical file-open (existsFile -> getFileSize -> getStorageObjects). The split is a pure + // function of the path, so all of those must split the path exactly ONCE (B1). + resetSplitCacheForTest(); + const std::string path = "store/a11/a11a11a1-1111-4111-8111-111111111111/all_1_1_0/columns.txt"; + EXPECT_TRUE(isPartFilePath(path)); + ASSERT_TRUE(parsePartFilePath(path).has_value()); + ASSERT_TRUE(parsePartFilePath(path).has_value()); + EXPECT_EQ(splitCacheMissesForTest(), 1u) << "the same raw path must be split only once"; + + // A distinct raw path is a fresh split (miss #2); repeats of it reuse the memo. + const std::string other = "store/a22/a22a22a2-2222-4222-8222-222222222222/all_1_1_0/data.bin"; + EXPECT_TRUE(isPartFilePath(other)); + EXPECT_TRUE(isPartFilePath(other)); + EXPECT_EQ(splitCacheMissesForTest(), 2u); + + // Correctness is unchanged: the memoized parse yields the same fields the direct parse would. + const auto parsed = parsePartFilePath(path); + ASSERT_TRUE(parsed.has_value()); + EXPECT_EQ(parsed->table_uuid, "a11a11a1-1111-4111-8111-111111111111"); + EXPECT_EQ(parsed->part_name, "all_1_1_0"); + EXPECT_EQ(parsed->file, "columns.txt"); +} + +TEST(CASPartPathParser, SplitCacheEvictionStaysCorrect) +{ + // The split cache is a small fixed-capacity FIFO ring, NOT an LRU/MRU: a hit never promotes its + // slot, so a path seen recently can still be evicted by unrelated churn through the same thread. + // That is only ever a cache-EFFECTIVENESS tradeoff, never a correctness one: pin that once enough + // distinct paths evict the first path's cached split, re-parsing it still yields the exact right + // result (a forced re-split / cache miss on the re-parse is expected and fine here — the + // assertion is correctness under eviction, not hit rate). + resetSplitCacheForTest(); + const std::string first = "store/a11/a11a11a1-1111-4111-8111-111111111111/all_1_1_0/columns.txt"; + ASSERT_TRUE(parsePartFilePath(first).has_value()); + + // 8 more distinct paths churn through the ring (capacity 8), evicting `first`'s slot. + const std::vector table_dirs = { + "", + "a11/a11a11a1-1111-4111-8111-111111111111", + "a22/a22a22a2-2222-4222-8222-222222222222", + "a33/a33a33a3-3333-4333-8333-333333333333", + "a44/a44a44a4-4444-4444-8444-444444444444", + "a55/a55a55a5-5555-4555-8555-555555555555", + "a66/a66a66a6-6666-4666-8666-666666666666", + "a77/a77a77a7-7777-4777-8777-777777777777", + "a88/a88a88a8-8888-4888-8888-888888888888", + "a99/a99a99a9-9999-4999-8999-999999999999", + }; + for (int i = 2; i <= 9; ++i) + { + const std::string path = "store/" + table_dirs[i] + "/all_1_1_0/columns.txt"; + ASSERT_TRUE(parsePartFilePath(path).has_value()); + } + + const size_t misses_before_reparse = splitCacheMissesForTest(); + const auto reparsed = parsePartFilePath(first); + ASSERT_TRUE(reparsed.has_value()); + EXPECT_EQ(reparsed->table_uuid, "a11a11a1-1111-4111-8111-111111111111"); + EXPECT_EQ(reparsed->part_name, "all_1_1_0"); + EXPECT_EQ(reparsed->file, "columns.txt"); + // Confirms the re-parse really was a forced re-split (the slot was evicted), not a lucky hit. + EXPECT_EQ(splitCacheMissesForTest(), misses_before_reparse + 1); +} + +/// ==== M-W Task 2: the read side over Cas::Pool ==== +/// Fixture: publish parts through the CORE API, then read through the IMetadataStorage surface of +/// the rewritten ContentAddressedMetadataStorage (real ctor over a Local object storage; the +/// backend self-selects EmulatedSingleProcess token semantics). + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +using DB::Cas::tests::idOf; +using DB::Cas::tests::u128Of; + +namespace DB::ErrorCodes +{ + extern const int FILE_DOESNT_EXIST; +} + +namespace +{ + +DB::Cas::ManifestEntry wiringBlobEntry(const String & path, const String & payload) +{ + DB::Cas::ManifestEntry e; + e.path = path; + e.placement = DB::Cas::EntryPlacement::Blob; + e.ref = DB::Cas::BlobRef{DB::Cas::BlobHashAlgo::CityHash128, DB::Cas::BlobDigest::fromU128(u128Of(payload))}; + + e.blob_size = payload.size(); + return e; +} + +/// All-tree-part-files Task 6/9: the small per-part files (uuid.txt, metadata_version.txt, ...) are +/// ordinary Inline-placement manifest entries now — this is the low-level PartWriteTxn-API equivalent of +/// what `ContentAddressedTransaction::writeFile`'s inline candidate path stages in production. +DB::Cas::ManifestEntry wiringInlineEntry(const String & path, const String & bytes) +{ + DB::Cas::ManifestEntry e; + e.path = path; + e.placement = DB::Cas::EntryPlacement::Inline; + e.ref = DB::Cas::BlobRef{DB::Cas::BlobHashAlgo::CityHash128, DB::Cas::BlobDigest::fromU128(u128Of(bytes))}; + + e.blob_size = bytes.size(); + e.inline_bytes = bytes; + return e; +} + +/// The one table identity these tests use, as a namespace LIFE: namespace files are life-keyed +/// (directive §2), resolved from the CATALOG exactly as the disk's own write path resolves it. Naming +/// the Stage-A sentinel here instead would put the fixture's files under a prefix the disk no longer +/// reads (Task 4b), so `existsFile`/`listDirectory` below would report them absent -- the fixture and +/// the code under test must agree on the life, and the only way to guarantee that is to ask the same +/// resolver. +DB::Cas::NamespaceLifeId wiringLife(DB::ContentAddressedMetadataStorage & storage) +{ + return storage.store()->namespaceLife( + storage.liveNamespace("a11a11a1-1111-4111-8111-111111111111")); +} + +std::shared_ptr openWiringStorage() +{ + auto settings = DB::Cas::tests::makeSettingsForTest( + "test", std::filesystem::temp_directory_path() / "ca_wiring_scratch"); + auto storage = std::make_shared( + DB::Cas::tests::makeLocalObjectStorageForTest(), "pool", "srv1", "", nullptr, settings); + storage->startup(); + return storage; +} + +/// One part with a content blob, a projection file, and the small per-part files (uuid.txt, +/// metadata_version.txt — ordinary Inline entries now, all-tree-part-files Task 6/9), published +/// through the real PartWriteTxn into `ns` under `ref`. +void publishWiredPart( + DB::ContentAddressedMetadataStorage & storage, const DB::Cas::RootNamespace & ns, const String & ref) +{ + /// Port off the removed PartWriteTxn::putTree/publish API onto the part-manifest write flow + /// (beginPartWrite → stageManifest → precommitAdd → putBlob → promote). The wiring sets the owning + /// namespace EXPLICITLY (intended_namespace) — faithful to ContentAddressedTransaction — so a + /// `detached/` ref (which itself contains '/') is staged in the TABLE namespace, not in a + /// spurious `/detached` namespace. intended_ref stays as "ns/ref" diagnostic forensics. + DB::Cas::PartWriteInfo info; + info.intended_ref = ns.string() + "/" + ref; + info.intended_namespace = ns; + auto build = storage.store()->beginPartWrite(info); + + /// Strictly ascending canonical path order (PartFolderView's binary-search precondition): + /// data.bin < metadata_version.txt < p.proj/data.bin < uuid.txt. + const auto id = build->stageManifest( + {wiringBlobEntry("data.bin", "payload-A"), wiringInlineEntry("metadata_version.txt", "5"), + wiringBlobEntry("p.proj/data.bin", "payload-B"), wiringInlineEntry("uuid.txt", "u-123")}); + build->precommitAdd(ns, ref, id); + build->putBlob(idOf("payload-A"), DB::Cas::BlobSource::fromString("payload-A")); + build->putBlob(idOf("payload-B"), DB::Cas::BlobSource::fromString("payload-B")); + build->promote(ns, ref, build->buildId(), id); + + /// promote stamps published_at_ms with nowMs(); the read assertions want a FIXED stamp, so pin it + /// through the set_published_at path (no journal record for anything but the stamp itself). + storage.store()->updateRefPublishedAt(ns, ref, + [](DB::Cas::RefPublishedAtUpdate & r) { r.published_at_ms = 1700000000ULL * 1000; }); /// epoch ms; getLastModified /1000 +} + +} + +/// `supportsAtomicFileWrites` (all-tree task 5): the CA metadata storage publishes a file write in +/// one shot, so `VersionMetadataOnDisk::storeInfoToDataPartStorage` can skip the tmp+replace dance. +/// A plain (non-content-addressed) metadata storage keeps the base-class default of `false`. +TEST(CASWiringCapability, SupportsAtomicFileWrites) +{ + auto ca_storage = openWiringStorage(); + EXPECT_TRUE(ca_storage->supportsAtomicFileWrites()); + + auto plain_storage = std::make_shared( + DB::Cas::tests::makeLocalObjectStorageForTest(), "", /*object_metadata_cache_size=*/0); + EXPECT_FALSE(plain_storage->supportsAtomicFileWrites()); +} + +TEST(CASWiringRead, ResolvesPublishedPart) +{ + auto storage = openWiringStorage(); + publishWiredPart(*storage, storage->liveNamespace("a11a11a1-1111-4111-8111-111111111111"), "all_1_1_0"); + + EXPECT_TRUE(storage->existsFile("a11/a11a11a1-1111-4111-8111-111111111111/all_1_1_0/data.bin")); + EXPECT_FALSE(storage->existsFile("a11/a11a11a1-1111-4111-8111-111111111111/all_1_1_0/missing.bin")); + EXPECT_EQ(storage->getFileSize("a11/a11a11a1-1111-4111-8111-111111111111/all_1_1_0/data.bin"), 9u); + + EXPECT_TRUE(storage->existsDirectory("a11/a11a11a1-1111-4111-8111-111111111111")); + EXPECT_TRUE(storage->existsDirectory("a11/a11a11a1-1111-4111-8111-111111111111/all_1_1_0")); + EXPECT_FALSE(storage->existsDirectory("a11/a11a11a1-1111-4111-8111-111111111111/all_9_9_9")); + EXPECT_FALSE(storage->existsDirectory("a22/a22a22a2-2222-4222-8222-222222222222")); + + /// Part dir listing: nested keys collapse to their first component; the publish stamp + /// (published_at_ms typed field) never surfaces as a dir entry — every staged file does. + auto names = storage->listDirectory("a11/a11a11a1-1111-4111-8111-111111111111/all_1_1_0"); + std::sort(names.begin(), names.end()); + EXPECT_EQ(names, (std::vector{"data.bin", "metadata_version.txt", "p.proj", "uuid.txt"})); + + auto parts = storage->listDirectory("a11/a11a11a1-1111-4111-8111-111111111111"); + EXPECT_EQ(parts, (std::vector{"all_1_1_0"})); + + /// The part dir reports EMPTY (virtual files; B45) so removeDirectory goes straight to the + /// ref-unlink; the table dir keeps listing-based emptiness. + EXPECT_TRUE(storage->isDirectoryEmpty("a11/a11a11a1-1111-4111-8111-111111111111/all_1_1_0")); + EXPECT_FALSE(storage->isDirectoryEmpty("a11/a11a11a1-1111-4111-8111-111111111111")); + + /// Blob-backed file: a real key, PAYLOAD-sized (the envelope header is a read-path concern). + auto objects = storage->getStorageObjects("a11/a11a11a1-1111-4111-8111-111111111111/all_1_1_0/data.bin"); + ASSERT_EQ(objects.size(), 1u); + EXPECT_FALSE(objects[0].remote_path.empty()); + EXPECT_EQ(objects[0].bytes_size, 9u); + + /// Small Inline entry: bytes live in the shard manifest, not as their own object. + EXPECT_TRUE(storage->existsFile("a11/a11a11a1-1111-4111-8111-111111111111/all_1_1_0/uuid.txt")); + EXPECT_EQ(storage->getFileSize("a11/a11a11a1-1111-4111-8111-111111111111/all_1_1_0/uuid.txt"), 5u); + EXPECT_EQ(storage->tryGetInManifestBytes("a11/a11a11a1-1111-4111-8111-111111111111/all_1_1_0/uuid.txt"), std::optional("u-123")); + auto mobj = storage->getStorageObjects("a11/a11a11a1-1111-4111-8111-111111111111/all_1_1_0/uuid.txt"); + ASSERT_EQ(mobj.size(), 1u); + EXPECT_TRUE(mobj[0].remote_path.empty()); /// sized placeholder; bytes ride prepareInManifestRead + + /// The typed publish stamp (published_at_ms epoch ms) backs getLastModified for the part dir and its files. + EXPECT_EQ(storage->getLastModified("a11/a11a11a1-1111-4111-8111-111111111111/all_1_1_0").epochTime(), 1700000000); + EXPECT_EQ(storage->getLastModified("a11/a11a11a1-1111-4111-8111-111111111111/all_1_1_0/data.bin").epochTime(), 1700000000); +} + +TEST(CASWiringRead, BlobViewPlanRidesTheStandardPipeline) +{ + /// The committed read path (B116): an in-manifest file is served from memory via + /// prepareInManifestRead; a blob-backed file translates to its physical blob object + + /// payload window (getBlobViewPlan) and rides the STANDARD object-storage pipeline, + /// bounded by the FileView stage — composed here the way DiskObjectStorage::prepareRead + /// composes it. + auto object_storage = DB::Cas::tests::makeLocalObjectStorageForTest(); + auto settings = DB::Cas::tests::makeSettingsForTest( + "test", std::filesystem::temp_directory_path() / "ca_wiring_scratch"); + auto storage = std::make_shared( + object_storage, "pool", "srv1", "", nullptr, settings); + storage->startup(); + publishWiredPart(*storage, storage->liveNamespace("a11a11a1-1111-4111-8111-111111111111"), "all_1_1_0"); + + /// In-manifest file: memory source, no blob plan. + DB::ReadPipeline manifest_pipeline; + ASSERT_TRUE(storage->prepareInManifestRead("a11/a11a11a1-1111-4111-8111-111111111111/all_1_1_0/uuid.txt", DB::ReadSettings{}, manifest_pipeline)); + String manifest_bytes; + { + auto buf = manifest_pipeline.build(); + DB::readStringUntilEOF(manifest_bytes, *buf); + } + EXPECT_EQ(manifest_bytes, "u-123"); + /// Not a `getBlobViewPlan` call on the in-manifest path here (all-tree Task 6/9: uuid.txt is now + /// a real Inline manifest entry): `getBlobViewPlan`'s only production caller + /// (`DiskObjectStorage::prepareRead`) never reaches it once `prepareInManifestRead` returns true + /// above — `getBlobViewPlan`'s precondition is "confirmed not in-manifest-servable," which calling + /// it directly on an Inline path violates. Pre-Task-9 this assertion passed only by coincidence + /// (uuid.txt was not a manifest entry at all, so `findFile` returned not-found, not because + /// `getBlobViewPlan` gracefully handles an Inline entry it does find). + + /// Blob-backed file: a real physical key and a payload-sized window whose extent equals + /// the object's readable size (a right-bounded read never overshoots the window). + const std::string path = "a11/a11a11a1-1111-4111-8111-111111111111/all_1_1_0/data.bin"; + auto plan = storage->getBlobViewPlan(path); + ASSERT_TRUE(plan.has_value()); + EXPECT_FALSE(plan->object.remote_path.empty()); + EXPECT_EQ(plan->object.local_path, path); + EXPECT_EQ(plan->payload_end - plan->payload_offset, 9u); + EXPECT_EQ(plan->object.bytes_size, plan->payload_end); + EXPECT_FALSE(storage->prepareInManifestRead(path, DB::ReadSettings{}, manifest_pipeline = {})); + + auto make_pipeline = [&] + { + DB::ReadPipeline pipeline; + pipeline.setSource(object_storage, {plan->object}, DB::ReadSettings{}); + pipeline.needGather(); + pipeline.needFileView(path, plan->payload_offset, plan->payload_end); + return pipeline; + }; + EXPECT_EQ(make_pipeline().describe(), "Source(ObjectStorage) -> Gather -> FileView"); + + { + auto buf = make_pipeline().build(); + EXPECT_EQ(buf->getFileName(), path); + EXPECT_EQ(buf->tryGetFileSize(), std::optional(9)); + String bytes; + DB::readStringUntilEOF(bytes, *buf); + EXPECT_EQ(bytes, "payload-A"); + } + + /// Right-bounded read through the view (the MergeTreeReaderStream::adjustRightMark shape): + /// the bound is window-relative and forwarded down the chain. + { + auto buf = make_pipeline().build(); + buf->setReadUntilPosition(7); + String head(7, '\0'); + buf->readStrict(head.data(), 7); + EXPECT_EQ(head, "payload"); + EXPECT_TRUE(buf->eof()); + buf->setReadUntilEnd(); + String tail; + DB::readStringUntilEOF(tail, *buf); + EXPECT_EQ(tail, "-A"); + } + + /// Seek inside the window. + { + auto buf = make_pipeline().build(); + buf->seek(8, SEEK_SET); + String last; + DB::readStringUntilEOF(last, *buf); + EXPECT_EQ(last, "A"); + } +} + +TEST(CASWiringRead, ProjectionDirectory) +{ + auto storage = openWiringStorage(); + publishWiredPart(*storage, storage->liveNamespace("a11a11a1-1111-4111-8111-111111111111"), "all_1_1_0"); + + EXPECT_TRUE(storage->existsDirectory("a11/a11a11a1-1111-4111-8111-111111111111/all_1_1_0/p.proj")); + EXPECT_FALSE(storage->existsDirectory("a11/a11a11a1-1111-4111-8111-111111111111/all_1_1_0/q.proj")); + EXPECT_EQ(storage->listDirectory("a11/a11a11a1-1111-4111-8111-111111111111/all_1_1_0/p.proj"), (std::vector{"data.bin"})); + EXPECT_TRUE(storage->isDirectoryEmpty("a11/a11a11a1-1111-4111-8111-111111111111/all_1_1_0/p.proj")); /// B60 + EXPECT_TRUE(storage->existsFile("a11/a11a11a1-1111-4111-8111-111111111111/all_1_1_0/p.proj/data.bin")); + EXPECT_EQ(storage->getFileSize("a11/a11a11a1-1111-4111-8111-111111111111/all_1_1_0/p.proj/data.bin"), 9u); +} + +TEST(CASWiringRead, DetachedFoldedIntoTableNamespace) +{ + auto storage = openWiringStorage(); + /// B181: a detached part is a `detached/`-prefixed ref INSIDE the table's own archive namespace, + /// not a separate sibling namespace. Publish it that way through the core, and ALSO a live part + /// that shares the same base name to prove the live↔detached collision is impossible (the ref + /// names `all_1_1_0` and `detached/all_1_1_0` differ — one namespace, no re-split needed). + publishWiredPart(*storage, storage->liveNamespace("a11a11a1-1111-4111-8111-111111111111"), "detached/broken_all_1_1_0"); + publishWiredPart(*storage, storage->liveNamespace("a11a11a1-1111-4111-8111-111111111111"), "broken_all_1_1_0"); + + /// The TABLE dir collapses the `detached/` refs to the single `detached` subdir entry + /// alongside the live part name. + auto top = storage->listDirectory("a11/a11a11a1-1111-4111-8111-111111111111"); + std::sort(top.begin(), top.end()); + EXPECT_EQ(top, (std::vector{"broken_all_1_1_0", "detached"})); + + /// The detached CONTAINER lists the detached part DIRECTORY names (B36's intent), prefix-stripped + /// — and NOT the live part of the same base name. + EXPECT_TRUE(storage->existsDirectory("a11/a11a11a1-1111-4111-8111-111111111111/detached")); + EXPECT_EQ(storage->listDirectory("a11/a11a11a1-1111-4111-8111-111111111111/detached"), (std::vector{"broken_all_1_1_0"})); + /// A single detached part dir + its files (the detached part is its own `detached/`-prefixed ref). + EXPECT_TRUE(storage->existsDirectory("a11/a11a11a1-1111-4111-8111-111111111111/detached/broken_all_1_1_0")); + auto names = storage->listDirectory("a11/a11a11a1-1111-4111-8111-111111111111/detached/broken_all_1_1_0"); + std::sort(names.begin(), names.end()); + EXPECT_EQ(names, (std::vector{"data.bin", "metadata_version.txt", "p.proj", "uuid.txt"})); + /// The B62 shape: a detached part's mutable file resolves through the `detached/`-prefixed ref. + EXPECT_TRUE(storage->existsFile("a11/a11a11a1-1111-4111-8111-111111111111/detached/broken_all_1_1_0/metadata_version.txt")); + EXPECT_EQ(storage->tryGetInManifestBytes("a11/a11a11a1-1111-4111-8111-111111111111/detached/broken_all_1_1_0/metadata_version.txt"), + std::optional("5")); + EXPECT_TRUE(storage->existsFile("a11/a11a11a1-1111-4111-8111-111111111111/detached/broken_all_1_1_0/data.bin")); +} + +TEST(CASWiringRoute, DetachedFoldsIntoTableNamespaceWithPrefixedRef) +{ + /// B181: a detached part file routes to the table's OWN archive namespace under a + /// `detached/`-prefixed ref — NOT a separate sibling namespace. + auto storage = openWiringStorage(); + auto p = parsePartFilePath("store/a11/a11a11a1-1111-4111-8111-111111111111/detached/broken_all_1_1_0/data.bin"); + ASSERT_TRUE(p.has_value()); + auto r = storage->route(*p); + ASSERT_TRUE(r.has_value()); + EXPECT_EQ(r->ns.string(), storage->liveNamespace("a11a11a1-1111-4111-8111-111111111111").string()); + EXPECT_EQ(r->ref, "detached/broken_all_1_1_0"); + EXPECT_EQ(r->file, "data.bin"); + + /// The detached CONTAINER dir routes to the table ns with an empty ref (filtered listing). + auto pc = parsePartFilePath("store/a11/a11a11a1-1111-4111-8111-111111111111/detached/broken_all_1_1_0"); + ASSERT_TRUE(pc.has_value()); + auto rc = storage->route(*pc); + ASSERT_TRUE(rc.has_value()); + EXPECT_EQ(rc->ns.string(), storage->liveNamespace("a11a11a1-1111-4111-8111-111111111111").string()); + EXPECT_EQ(rc->ref, "detached/broken_all_1_1_0"); + EXPECT_TRUE(rc->file.empty()); +} + +TEST(CASWiringRoute, MovingFoldsOntoAPrefixedStagingRef) +{ + /// L1 (MOVE-to-CA fix): the mover clones a part under TABLE/moving// before the + /// atomic rename into place. Mirroring `detached`, a moved part resolves onto a + /// `moving/`-PREFIXED staging ref -- NOT the part's final live ref directly. Publishing under + /// the final ref before the mover's swap would break move crash-atomicity (a crash between the + /// clone commit and the swap would leave a committed live ref that never went through the + /// swap). The staging ref keeps the pre-swap clone un-live; the mover's rename does a real ref + /// repoint moving/ -> . + auto storage = openWiringStorage(); + auto p = parsePartFilePath("store/a11/a11a11a1-1111-4111-8111-111111111111/moving/all_1_1_0/data.bin"); + ASSERT_TRUE(p.has_value()); + EXPECT_EQ(p->part_name, std::string(kMovingDirName)); + EXPECT_EQ(p->file, "all_1_1_0/data.bin"); + + auto r = storage->route(*p); + ASSERT_TRUE(r.has_value()); + EXPECT_EQ(r->ns.string(), storage->liveNamespace("a11a11a1-1111-4111-8111-111111111111").string()); + EXPECT_EQ(r->ref, "moving/all_1_1_0"); + EXPECT_EQ(r->file, "data.bin"); + + /// The bare moving CONTAINER dir TABLE/moving routes to the table ns with an empty ref. + auto pc = parsePartFilePath("store/a11/a11a11a1-1111-4111-8111-111111111111/moving"); + ASSERT_TRUE(pc.has_value()); + auto rc = storage->route(*pc); + ASSERT_TRUE(rc.has_value()); + EXPECT_EQ(rc->ns.string(), storage->liveNamespace("a11a11a1-1111-4111-8111-111111111111").string()); + EXPECT_TRUE(rc->ref.empty()); + EXPECT_TRUE(rc->file.empty()); +} + +TEST(CASWiringRead, ShadowFreezeTree) +{ + auto storage = openWiringStorage(); + publishWiredPart(*storage, DB::ContentAddressedMetadataStorage::shadowNamespace("shadow/bk1/store/a11/a11a11a1-1111-4111-8111-111111111111"), "all_1_1_0"); + + /// Intermediate dirs derive from the registered shadow namespaces. + EXPECT_TRUE(storage->existsDirectory("shadow/bk1")); + EXPECT_TRUE(storage->existsDirectory("shadow/bk1/store")); + EXPECT_FALSE(storage->existsDirectory("shadow/bk2")); + EXPECT_EQ(storage->listDirectory("shadow"), (std::vector{"bk1"})); + EXPECT_EQ(storage->listDirectory("shadow/bk1"), (std::vector{"store"})); + EXPECT_EQ(storage->listDirectory("shadow/bk1/store"), (std::vector{"a11"})); + EXPECT_EQ(storage->listDirectory("shadow/bk1/store/a11"), (std::vector{"a11a11a1-1111-4111-8111-111111111111"})); + /// Shadow TABLE dir (strict uuid-pair anchor) and PART dir. + EXPECT_TRUE(storage->existsDirectory("shadow/bk1/store/a11/a11a11a1-1111-4111-8111-111111111111")); + EXPECT_EQ(storage->listDirectory("shadow/bk1/store/a11/a11a11a1-1111-4111-8111-111111111111"), (std::vector{"all_1_1_0"})); + EXPECT_TRUE(storage->existsDirectory("shadow/bk1/store/a11/a11a11a1-1111-4111-8111-111111111111/all_1_1_0")); + auto names = storage->listDirectory("shadow/bk1/store/a11/a11a11a1-1111-4111-8111-111111111111/all_1_1_0"); + std::sort(names.begin(), names.end()); + EXPECT_EQ(names, (std::vector{"data.bin", "metadata_version.txt", "p.proj", "uuid.txt"})); + EXPECT_TRUE(storage->existsFile("shadow/bk1/store/a11/a11a11a1-1111-4111-8111-111111111111/all_1_1_0/data.bin")); + EXPECT_EQ(storage->getFileSize("shadow/bk1/store/a11/a11a11a1-1111-4111-8111-111111111111/all_1_1_0/data.bin"), 9u); +} + +TEST(CASWiringRead, VerbatimNamespaceFiles) +{ + auto storage = openWiringStorage(); + EXPECT_TRUE(storage->liveNamespace("a11a11a1-1111-4111-8111-111111111111").string().starts_with("test/")) + << storage->liveNamespace("a11a11a1-1111-4111-8111-111111111111").string(); + EXPECT_NE(storage->liveNamespace("a11a11a1-1111-4111-8111-111111111111").string().find("/store/a11/a11a11a1-1111-4111-8111-111111111111@cas@"), std::string::npos) + << storage->liveNamespace("a11a11a1-1111-4111-8111-111111111111").string(); + + publishWiredPart(*storage, storage->liveNamespace("a11a11a1-1111-4111-8111-111111111111"), "all_1_1_0"); + storage->store()->putNamespaceFile(wiringLife(*storage), "format_version.txt", "1\n"); + storage->store()->putNamespaceFile( + wiringLife(*storage), "deduplication_logs/deduplication_log_1.txt", "log-bytes"); + /// Loose disk-root files are plain mountpoint objects (design §5.2), not namespace files. + storage->store()->putMountpointObject(storage->serverRootId() + "/" + "clickhouse_access_check_xyz", "ok"); + + EXPECT_TRUE(storage->existsFile("a11/a11a11a1-1111-4111-8111-111111111111/format_version.txt")); + EXPECT_EQ(storage->getFileSize("a11/a11a11a1-1111-4111-8111-111111111111/format_version.txt"), 2u); + EXPECT_EQ(storage->tryGetInManifestBytes("a11/a11a11a1-1111-4111-8111-111111111111/format_version.txt"), std::optional("1\n")); + + /// Table dir listing merges part names + verbatim file first components. + auto names = storage->listDirectory("a11/a11a11a1-1111-4111-8111-111111111111"); + std::sort(names.begin(), names.end()); + EXPECT_EQ(names, (std::vector{"all_1_1_0", "deduplication_logs", "format_version.txt"})); + + /// The reserved table-level subdir. + EXPECT_TRUE(storage->existsDirectory("a11/a11a11a1-1111-4111-8111-111111111111/deduplication_logs")); + EXPECT_EQ(storage->listDirectory("a11/a11a11a1-1111-4111-8111-111111111111/deduplication_logs"), + (std::vector{"deduplication_log_1.txt"})); + EXPECT_TRUE(storage->existsFile("a11/a11a11a1-1111-4111-8111-111111111111/deduplication_logs/deduplication_log_1.txt")); + + /// Loose disk-root files are plain objects — existsFile checks the mountpoint object, not a namespace file. + EXPECT_TRUE(storage->existsFile("clickhouse_access_check_xyz")); + /// Loose files are real objects — tryGetInManifestBytes returns nullopt (not in-manifest bytes). + EXPECT_EQ(storage->tryGetInManifestBytes("clickhouse_access_check_xyz"), std::nullopt); + EXPECT_EQ(storage->getFileSize("clickhouse_access_check_xyz"), 2u); + EXPECT_FALSE(storage->existsFile("clickhouse_access_check_other")); +} + +/// `DirShape::TableDir`'s `existsDirectory` used to answer "has at least one committed part", so an +/// Atomic table that only ever wrote its namespace-level `format_version.txt` (no part published yet) +/// reported its own root as absent. `existsDirectory` is the precheck `MergeTreeData::dropAllData` +/// uses to decide whether `removeRecursive`/`dropNamespace` needs to run at all -- a false negative +/// here means `DROP TABLE` on such a table never admits removal, leaking a `Live` catalog row forever. +TEST(CASWiringRead, TableRootExistsWithNamespaceFilesButNoCommittedRef) +{ + auto storage = openWiringStorage(); + storage->store()->putNamespaceFile(wiringLife(*storage), "format_version.txt", "1\n"); + EXPECT_TRUE(storage->existsDirectory("a11/a11a11a1-1111-4111-8111-111111111111")); +} + +/// Same defect, non-Atomic fallback shape (`parseTableUuid` folds the whole leading path into the +/// "uuid"): a files-only table under `data//
` must be present too. +TEST(CASWiringRead, TableRootExistsWithNamespaceFilesButNoCommittedRefNonAtomic) +{ + auto storage = openWiringStorage(); + const auto ns = storage->liveNamespace("data/memory_01069/mt"); + storage->store()->putNamespaceFile(storage->store()->namespaceLife(ns), "format_version.txt", "1\n"); + EXPECT_TRUE(storage->existsDirectory("data/memory_01069/mt")); +} + +/// A cataloged `Live` life with ZERO refs and ZERO namespace files -- not just zero refs -- must still +/// report present. `namespaceLife` is the write-side resolution that mints a `Live` catalog row on +/// first touch; calling it alone (no ref, no namespace file written afterward) is the minimal way to +/// reach this state, and it prevents a future regression from "catalog OR files" back to "files only". +TEST(CASWiringRead, EmptyCatalogedLiveTableRootExists) +{ + auto storage = openWiringStorage(); + (void)storage->store()->namespaceLife(storage->liveNamespace("a55a55a5-5555-4555-8555-555555555555")); + EXPECT_TRUE(storage->existsDirectory("a55/a55a55a5-5555-4555-8555-555555555555")); +} + +/// C4: the fixed dispatch order is the invariant. Pins the two ambiguous early guards that make the +/// order load-bearing: store/ (AtomicShard, ambiguous with the non-Atomic table fallback) and a +/// shadow table dir (which also satisfies parseTableUuid). existsDirectory/listDirectory must agree. +TEST(CASWiringRoute, DirShapeDispatchOrderIsStable) +{ + auto storage = openWiringStorage(); + publishWiredPart(*storage, storage->liveNamespace("a11a11a1-1111-4111-8111-111111111111"), "all_1_1_0"); + publishWiredPart(*storage, DB::ContentAddressedMetadataStorage::shadowNamespace("shadow/bk1/store/a11/a11a11a1-1111-4111-8111-111111111111"), "all_1_1_0"); + + using DS = DB::ContentAddressedMetadataStorage::DirShape; + EXPECT_EQ(storage->classifyDirectoryForTest("store/uui").shape, DS::AtomicShard); + EXPECT_EQ(storage->classifyDirectoryForTest("a11/a11a11a1-1111-4111-8111-111111111111").shape, DS::TableDir); + EXPECT_EQ(storage->classifyDirectoryForTest("a11/a11a11a1-1111-4111-8111-111111111111/all_1_1_0").shape, DS::PartDir); + EXPECT_EQ(storage->classifyDirectoryForTest("a11/a11a11a1-1111-4111-8111-111111111111/detached").shape, DS::DetachedContainer); + EXPECT_EQ(storage->classifyDirectoryForTest("a11/a11a11a1-1111-4111-8111-111111111111/moving").shape, DS::MovingContainer); + EXPECT_EQ(storage->classifyDirectoryForTest("shadow/bk1/store/a11/a11a11a1-1111-4111-8111-111111111111").shape, DS::ShadowTable); + EXPECT_EQ(storage->classifyDirectoryForTest("shadow/bk1").shape, DS::ShadowIntermediate); + EXPECT_EQ(storage->classifyDirectoryForTest("a11/a11a11a1-1111-4111-8111-111111111111/deduplication_logs").shape, DS::TableSubdir); + EXPECT_EQ(storage->classifyDirectoryForTest("store").shape, DS::GenericIntermediate); +} + +/// ==== M-W Task 3: the write path through IMetadataTransaction ==== + + +namespace +{ + +void writeThroughTransaction(DB::IMetadataTransaction & tx, const String & path, const String & bytes) +{ + auto & ca_tx = dynamic_cast(tx); + auto buf = ca_tx.writeFile(path, 65536, DB::WriteMode::Rewrite, {}); + buf->write(bytes.data(), bytes.size()); + buf->finalize(); +} + +} + +TEST(CASWiringWrite, ContentRoundTripThroughTransaction) +{ + auto storage = openWiringStorage(); + + auto tx = storage->createTransaction(); + writeThroughTransaction(*tx, "a11/a11a11a1-1111-4111-8111-111111111111/all_1_1_0/data.bin", "content-A"); + writeThroughTransaction(*tx, "a11/a11a11a1-1111-4111-8111-111111111111/all_1_1_0/checksums.txt", "sums"); + writeThroughTransaction(*tx, "a11/a11a11a1-1111-4111-8111-111111111111/all_1_1_0/uuid.txt", "u-42"); + /// Nothing visible before commit. + EXPECT_FALSE(storage->existsDirectory("a11/a11a11a1-1111-4111-8111-111111111111/all_1_1_0")); + tx->commit(DB::NoCommitOptions{}); + + EXPECT_TRUE(storage->existsDirectory("a11/a11a11a1-1111-4111-8111-111111111111/all_1_1_0")); + EXPECT_TRUE(storage->existsFile("a11/a11a11a1-1111-4111-8111-111111111111/all_1_1_0/data.bin")); + EXPECT_EQ(storage->getFileSize("a11/a11a11a1-1111-4111-8111-111111111111/all_1_1_0/data.bin"), 9u); + EXPECT_EQ(storage->tryGetInManifestBytes("a11/a11a11a1-1111-4111-8111-111111111111/all_1_1_0/uuid.txt"), std::optional("u-42")); + auto names = storage->listDirectory("a11/a11a11a1-1111-4111-8111-111111111111/all_1_1_0"); + std::sort(names.begin(), names.end()); + EXPECT_EQ(names, (std::vector{"checksums.txt", "data.bin", "uuid.txt"})); + /// The publish stamp was added automatically and is filtered from listings. + EXPECT_GT(storage->getLastModified("a11/a11a11a1-1111-4111-8111-111111111111/all_1_1_0").epochTime(), 1700000000); +} + +TEST(CASWiringWrite, InlineOnlyPartPublishesWithoutBuildCrash) +{ + /// Regression (CRASH-CA-S3 "staged entries without a PartWriteTxn"): a part whose files are ALL inline + /// — no `partFileMustStayBlob` file (`.bin`/`.mrk*`/`primary.idx`), e.g. an EMPTY merge output that + /// writes only `checksums.txt`/`count.txt` and no `data.bin` — staged manifest entries via the + /// inline write path, which did NOT establish a PartWriteTxn (only the blob path did, via `buildFor`). So + /// `publishStaging` reached its `st.build != nullptr` invariant with entries but no PartWriteTxn and threw + /// LOGICAL_ERROR — a SERVER CRASH under `abort_on_logical_error`. Writing only inline metadata files + /// to a fresh part and committing must SUCCEED and publish the part. (Bug pre-existed the inline-files + /// feature; fix: the inline path now calls `buildFor` like the blob path.) + auto storage = openWiringStorage(); + auto tx = storage->createTransaction(); + writeThroughTransaction(*tx, "a11/a11a11a1-1111-4111-8111-111111111111/all_1_1_0/checksums.txt", "sums"); // inline (no blob) + writeThroughTransaction(*tx, "a11/a11a11a1-1111-4111-8111-111111111111/all_1_1_0/count.txt", "0"); // inline (no blob) + EXPECT_NO_THROW(tx->commit(DB::NoCommitOptions{})); + + EXPECT_TRUE(storage->existsDirectory("a11/a11a11a1-1111-4111-8111-111111111111/all_1_1_0")); + EXPECT_EQ(storage->tryGetInManifestBytes("a11/a11a11a1-1111-4111-8111-111111111111/all_1_1_0/checksums.txt"), std::optional("sums")); + EXPECT_EQ(storage->tryGetInManifestBytes("a11/a11a11a1-1111-4111-8111-111111111111/all_1_1_0/count.txt"), std::optional("0")); + auto names = storage->listDirectory("a11/a11a11a1-1111-4111-8111-111111111111/all_1_1_0"); + std::sort(names.begin(), names.end()); + EXPECT_EQ(names, (std::vector{"checksums.txt", "count.txt"})); +} + +TEST(CASWiringWrite, IdenticalContentDedupsToOneBlob) +{ + auto storage = openWiringStorage(); + + auto tx = storage->createTransaction(); + writeThroughTransaction(*tx, "a11/a11a11a1-1111-4111-8111-111111111111/all_1_1_0/data.bin", "same-bytes"); + tx->commit(DB::NoCommitOptions{}); + auto tx2 = storage->createTransaction(); + writeThroughTransaction(*tx2, "a11/a11a11a1-1111-4111-8111-111111111111/all_2_2_0/data.bin", "same-bytes"); + tx2->commit(DB::NoCommitOptions{}); + + /// Identical content => the SAME blob object (the key is the content hash). + auto a = storage->getStorageObjects("a11/a11a11a1-1111-4111-8111-111111111111/all_1_1_0/data.bin"); + auto b = storage->getStorageObjects("a11/a11a11a1-1111-4111-8111-111111111111/all_2_2_0/data.bin"); + ASSERT_EQ(a.size(), 1u); + ASSERT_EQ(b.size(), 1u); + EXPECT_EQ(a[0].remote_path, b[0].remote_path); +} + +TEST(CASWiringWrite, UncommittedTransactionPublishesNothing) +{ + auto storage = openWiringStorage(); + { + auto tx = storage->createTransaction(); + writeThroughTransaction(*tx, "a11/a11a11a1-1111-4111-8111-111111111111/all_1_1_0/data.bin", "doomed"); + /// destroyed without commit => PartWriteTxn abandoned (uploads are heartbeat-gated debris) + } + EXPECT_FALSE(storage->existsDirectory("a11/a11a11a1-1111-4111-8111-111111111111/all_1_1_0")); + EXPECT_FALSE(storage->existsFile("a11/a11a11a1-1111-4111-8111-111111111111/all_1_1_0/data.bin")); +} + +TEST(CASWiringWrite, MutableOnlyUpdateOnCommittedPart) +{ + auto storage = openWiringStorage(); + auto tx = storage->createTransaction(); + writeThroughTransaction(*tx, "a11/a11a11a1-1111-4111-8111-111111111111/all_1_1_0/data.bin", "content-A"); + writeThroughTransaction(*tx, "a11/a11a11a1-1111-4111-8111-111111111111/all_1_1_0/txn_version.txt", "v1"); + tx->commit(DB::NoCommitOptions{}); + + /// The MVCC autocommit one-shot shape: a fresh transaction rewriting ONLY a mutable file of a + /// COMMITTED part goes through updateRefPublishedAt (no tree rebuild, no journal record). + auto tx2 = storage->createTransaction(); + writeThroughTransaction(*tx2, "a11/a11a11a1-1111-4111-8111-111111111111/all_1_1_0/txn_version.txt", "v2"); + tx2->commit(DB::NoCommitOptions{}); + + EXPECT_EQ(storage->tryGetInManifestBytes("a11/a11a11a1-1111-4111-8111-111111111111/all_1_1_0/txn_version.txt"), std::optional("v2")); + EXPECT_TRUE(storage->existsFile("a11/a11a11a1-1111-4111-8111-111111111111/all_1_1_0/data.bin")); /// the tree is untouched +} + +TEST(CASWiringWrite, VerbatimFilesDurableOnFinalizeAndAppendable) +{ + auto storage = openWiringStorage(); + auto tx = storage->createTransaction(); + /// Verbatim files are durable on FINALIZE, with no commit (the disk layer's autocommit + /// contract for table-level files). + writeThroughTransaction(*tx, "a11/a11a11a1-1111-4111-8111-111111111111/mutation_5.txt", "commands\n"); + EXPECT_TRUE(storage->existsFile("a11/a11a11a1-1111-4111-8111-111111111111/mutation_5.txt")); + + /// Append = read-modify-rewrite (the MVCC mutation-entry CSN append). + { + auto & ca_tx = dynamic_cast(*tx); + auto buf = ca_tx.writeFile("a11/a11a11a1-1111-4111-8111-111111111111/mutation_5.txt", 65536, DB::WriteMode::Append, {}); + buf->write("csn 42\n", 7); + buf->finalize(); + } + EXPECT_EQ(storage->tryGetInManifestBytes("a11/a11a11a1-1111-4111-8111-111111111111/mutation_5.txt"), + std::optional("commands\ncsn 42\n")); +} + +/// ==== M-W Tasks 5-7: carry-forward, renames, removals, detached/ATTACH/FREEZE ==== + +TEST(CASWiringOps, HardLinkCarriesForwardWithoutReupload) +{ + auto storage = openWiringStorage(); + auto tx = storage->createTransaction(); + writeThroughTransaction(*tx, "a11/a11a11a1-1111-4111-8111-111111111111/all_1_1_0/data.bin", "shared-payload"); + writeThroughTransaction(*tx, "a11/a11a11a1-1111-4111-8111-111111111111/all_1_1_0/uuid.txt", "u-1"); + tx->commit(DB::NoCommitOptions{}); + + /// A mutation/merge carries unchanged files into the new part by hardlink. + auto tx2 = storage->createTransaction(); + tx2->createHardLink("a11/a11a11a1-1111-4111-8111-111111111111/all_1_1_0/data.bin", "a11/a11a11a1-1111-4111-8111-111111111111/all_1_1_5/data.bin"); + tx2->createHardLink("a11/a11a11a1-1111-4111-8111-111111111111/all_1_1_0/uuid.txt", "a11/a11a11a1-1111-4111-8111-111111111111/all_1_1_5/uuid.txt"); + tx2->commit(DB::NoCommitOptions{}); + + EXPECT_TRUE(storage->existsFile("a11/a11a11a1-1111-4111-8111-111111111111/all_1_1_5/data.bin")); + EXPECT_EQ(storage->getStorageObjects("a11/a11a11a1-1111-4111-8111-111111111111/all_1_1_0/data.bin")[0].remote_path, + storage->getStorageObjects("a11/a11a11a1-1111-4111-8111-111111111111/all_1_1_5/data.bin")[0].remote_path); + EXPECT_EQ(storage->tryGetInManifestBytes("a11/a11a11a1-1111-4111-8111-111111111111/all_1_1_5/uuid.txt"), std::optional("u-1")); +} + +TEST(CASWiringOps, TmpToFinalRenamePublishesUnderFinalName) +{ + auto storage = openWiringStorage(); + auto tx = storage->createTransaction(); + writeThroughTransaction(*tx, "a11/a11a11a1-1111-4111-8111-111111111111/tmp_insert_all_1_1_0/data.bin", "fresh"); + tx->moveDirectory("a11/a11a11a1-1111-4111-8111-111111111111/tmp_insert_all_1_1_0", "a11/a11a11a1-1111-4111-8111-111111111111/all_1_1_0"); + tx->commit(DB::NoCommitOptions{}); + + EXPECT_TRUE(storage->existsDirectory("a11/a11a11a1-1111-4111-8111-111111111111/all_1_1_0")); + EXPECT_FALSE(storage->existsDirectory("a11/a11a11a1-1111-4111-8111-111111111111/tmp_insert_all_1_1_0")); + EXPECT_TRUE(storage->existsFile("a11/a11a11a1-1111-4111-8111-111111111111/all_1_1_0/data.bin")); +} + +TEST(CASWiringOps, CommittedPartRenameMovesTheRef) +{ + auto storage = openWiringStorage(); + auto tx = storage->createTransaction(); + writeThroughTransaction(*tx, "a11/a11a11a1-1111-4111-8111-111111111111/all_1_1_0/data.bin", "bytes"); + tx->commit(DB::NoCommitOptions{}); + + /// MergeTree renames a part to delete_tmp_ before removing it. + auto tx2 = storage->createTransaction(); + tx2->moveDirectory("a11/a11a11a1-1111-4111-8111-111111111111/all_1_1_0", "a11/a11a11a1-1111-4111-8111-111111111111/delete_tmp_all_1_1_0"); + tx2->commit(DB::NoCommitOptions{}); + + EXPECT_FALSE(storage->existsDirectory("a11/a11a11a1-1111-4111-8111-111111111111/all_1_1_0")); + EXPECT_TRUE(storage->existsDirectory("a11/a11a11a1-1111-4111-8111-111111111111/delete_tmp_all_1_1_0")); + EXPECT_TRUE(storage->existsFile("a11/a11a11a1-1111-4111-8111-111111111111/delete_tmp_all_1_1_0/data.bin")); +} + +TEST(CASWiringOps, ProjectionTmpRenameRekeysStagedEntries) +{ + auto storage = openWiringStorage(); + auto tx = storage->createTransaction(); + writeThroughTransaction(*tx, "a11/a11a11a1-1111-4111-8111-111111111111/all_1_1_0/data.bin", "main"); + writeThroughTransaction(*tx, "a11/a11a11a1-1111-4111-8111-111111111111/all_1_1_0/p_1.tmp_proj/data.bin", "proj"); + auto & ca_tx = dynamic_cast(*tx); + ca_tx.moveDirectory("a11/a11a11a1-1111-4111-8111-111111111111/all_1_1_0/p_1.tmp_proj", "a11/a11a11a1-1111-4111-8111-111111111111/all_1_1_0/p.proj"); + tx->commit(DB::NoCommitOptions{}); + + EXPECT_TRUE(storage->existsFile("a11/a11a11a1-1111-4111-8111-111111111111/all_1_1_0/p.proj/data.bin")); + EXPECT_FALSE(storage->existsFile("a11/a11a11a1-1111-4111-8111-111111111111/all_1_1_0/p_1.tmp_proj/data.bin")); + EXPECT_EQ(storage->listDirectory("a11/a11a11a1-1111-4111-8111-111111111111/all_1_1_0/p.proj"), (std::vector{"data.bin"})); +} + +TEST(CASWiringOps, DetachAttachRoundTrip) +{ + auto storage = openWiringStorage(); + auto tx = storage->createTransaction(); + writeThroughTransaction(*tx, "a11/a11a11a1-1111-4111-8111-111111111111/all_1_1_0/data.bin", "detachable"); + writeThroughTransaction(*tx, "a11/a11a11a1-1111-4111-8111-111111111111/all_1_1_0/metadata_version.txt", "3"); + tx->commit(DB::NoCommitOptions{}); + + /// DETACH: a committed part moves into the detached namespace - pure ref ops. + auto tx2 = storage->createTransaction(); + tx2->moveDirectory("a11/a11a11a1-1111-4111-8111-111111111111/all_1_1_0", "a11/a11a11a1-1111-4111-8111-111111111111/detached/all_1_1_0"); + tx2->commit(DB::NoCommitOptions{}); + EXPECT_FALSE(storage->existsDirectory("a11/a11a11a1-1111-4111-8111-111111111111/all_1_1_0")); + EXPECT_TRUE(storage->existsDirectory("a11/a11a11a1-1111-4111-8111-111111111111/detached/all_1_1_0")); + EXPECT_EQ(storage->tryGetInManifestBytes("a11/a11a11a1-1111-4111-8111-111111111111/detached/all_1_1_0/metadata_version.txt"), + std::optional("3")); + + /// ATTACH: stage-rename within detached, then publish back into the live namespace. + auto tx3 = storage->createTransaction(); + tx3->moveDirectory("a11/a11a11a1-1111-4111-8111-111111111111/detached/all_1_1_0", "a11/a11a11a1-1111-4111-8111-111111111111/detached/attaching_all_1_1_0"); + tx3->commit(DB::NoCommitOptions{}); + auto tx4 = storage->createTransaction(); + tx4->moveDirectory("a11/a11a11a1-1111-4111-8111-111111111111/detached/attaching_all_1_1_0", "a11/a11a11a1-1111-4111-8111-111111111111/all_2_2_0"); + tx4->commit(DB::NoCommitOptions{}); + + EXPECT_TRUE(storage->existsDirectory("a11/a11a11a1-1111-4111-8111-111111111111/all_2_2_0")); + EXPECT_TRUE(storage->existsFile("a11/a11a11a1-1111-4111-8111-111111111111/all_2_2_0/data.bin")); + EXPECT_FALSE(storage->existsDirectory("a11/a11a11a1-1111-4111-8111-111111111111/detached/attaching_all_1_1_0")); + EXPECT_EQ(storage->listDirectory("a11/a11a11a1-1111-4111-8111-111111111111/detached"), (std::vector{})); +} + +TEST(CASWiringOps, RemovalsDropRefsAndNamespaces) +{ + auto storage = openWiringStorage(); + auto tx = storage->createTransaction(); + writeThroughTransaction(*tx, "a11/a11a11a1-1111-4111-8111-111111111111/all_1_1_0/data.bin", "gone-soon"); + writeThroughTransaction(*tx, "a11/a11a11a1-1111-4111-8111-111111111111/all_2_2_0/data.bin", "stays"); + tx->commit(DB::NoCommitOptions{}); + storage->store()->putNamespaceFile(wiringLife(*storage), "format_version.txt", "1\n"); + + /// The fast-removal path (all-tree Task 8, B123 evolution): per-file unlinks stage removal marks + /// (`content_removed`) but nothing durable changes until commit; removeDirectory() drops the + /// ref and supersedes any marks staged for it in the SAME transaction — still exactly one ref-drop, + /// zero repoints. `existsFile` below stays true because this whole sequence is one uncommitted + /// transaction (`tx2`), not because the unlink was a no-op. + auto tx2 = storage->createTransaction(); + tx2->unlinkFile("a11/a11a11a1-1111-4111-8111-111111111111/all_1_1_0/data.bin", false, false); + EXPECT_TRUE(storage->existsFile("a11/a11a11a1-1111-4111-8111-111111111111/all_1_1_0/data.bin")); /// still committed + tx2->removeDirectory("a11/a11a11a1-1111-4111-8111-111111111111/all_1_1_0"); + EXPECT_FALSE(storage->existsDirectory("a11/a11a11a1-1111-4111-8111-111111111111/all_1_1_0")); + EXPECT_TRUE(storage->existsDirectory("a11/a11a11a1-1111-4111-8111-111111111111/all_2_2_0")); + + /// DROP TABLE: removeRecursive on the table dir drops the live + detached namespaces. + tx2->removeRecursive("a11/a11a11a1-1111-4111-8111-111111111111", {}); + EXPECT_FALSE(storage->existsDirectory("a11/a11a11a1-1111-4111-8111-111111111111")); + EXPECT_FALSE(storage->existsFile("a11/a11a11a1-1111-4111-8111-111111111111/format_version.txt")); +} + +/// The negative test that forbids hooking last-part removal as table-drop admission: removing a +/// table's ONLY part is indistinguishable, from that call alone, from a merge, a TTL cleanup, or a +/// `TRUNCATE` that leaves the table usable. The root must stay present, and a fresh part must still be +/// publishable into it. +TEST(CASWiringOps, LastRefRemovalIsNotNamespaceRemoval) +{ + auto storage = openWiringStorage(); + auto tx = storage->createTransaction(); + writeThroughTransaction(*tx, "a66/a66a66a6-6666-4666-8666-666666666666/all_1_1_0/data.bin", "only-part"); + tx->commit(DB::NoCommitOptions{}); + storage->store()->putNamespaceFile( + storage->store()->namespaceLife(storage->liveNamespace("a66a66a6-6666-4666-8666-666666666666")), + "format_version.txt", "1\n"); + + auto tx2 = storage->createTransaction(); + tx2->removeDirectory("a66/a66a66a6-6666-4666-8666-666666666666/all_1_1_0"); + EXPECT_FALSE(storage->existsDirectory("a66/a66a66a6-6666-4666-8666-666666666666/all_1_1_0")); + EXPECT_TRUE(storage->existsDirectory("a66/a66a66a6-6666-4666-8666-666666666666")) + << "removing the table's last part must not be treated as DROP TABLE admission"; + + auto tx3 = storage->createTransaction(); + writeThroughTransaction(*tx3, "a66/a66a66a6-6666-4666-8666-666666666666/all_2_2_0/data.bin", "new-part"); + tx3->commit(DB::NoCommitOptions{}); + EXPECT_TRUE(storage->existsDirectory("a66/a66a66a6-6666-4666-8666-666666666666/all_2_2_0")) + << "the namespace never transitioned to Removing, so a fresh part publishes normally"; +} + +/// A files-only table root (no part ever published) becomes logically absent IMMEDIATELY once +/// `removeRecursive` durably completes the removal -- no GC round required. This is the same-call +/// synchronous half of the fix: `DROP TABLE ... SYNC` must not depend on GC latency to observe removal. +TEST(CASWiringOps, FilesOnlyTableRootRemovalIsImmediatelyAbsentWithoutGc) +{ + auto storage = openWiringStorage(); + const auto ns = storage->liveNamespace("a77a77a7-7777-4777-8777-777777777777"); + storage->store()->putNamespaceFile(storage->store()->namespaceLife(ns), "format_version.txt", "1\n"); + EXPECT_TRUE(storage->existsDirectory("a77/a77a77a7-7777-4777-8777-777777777777")); + + auto tx = storage->createTransaction(); + tx->removeRecursive("a77/a77a77a7-7777-4777-8777-777777777777", {}); + EXPECT_FALSE(storage->existsDirectory("a77/a77a77a7-7777-4777-8777-777777777777")) + << "the terminal remove_namespace transaction is durable synchronously"; + EXPECT_FALSE(storage->existsFile("a77/a77a77a7-7777-4777-8777-777777777777/format_version.txt")); +} + +/// REMOVED (all-tree-part-files Task 6): +/// `MutableTmpMoveOnCommittedPart` exercised `VersionMetadataOnDisk`'s OLD atomic-write dance — +/// autocommit `txn_version.txt.tmp`, then a standalone one-shot `moveFile(.tmp -> txn_version.txt)` +/// — via `ContentAddressedTransaction::moveFile` directly. That dance no longer exists in production: +/// Task 5's `supportsAtomicFileWrites` short-circuit makes `VersionMetadataOnDisk::storeInfoToData- +/// PartStorage` write `txn_version.txt` directly in one shot on a CA disk, with no `.tmp` file and no +/// rename ever produced. Task 9 completed the cleanup this comment used to defer: `moveFile`'s legacy +/// "rename FROM a committed mutable-per-part-file, source not staged in this transaction" branch is +/// now DELETED (it had been provably unreachable since Task 5, and rebuilding it against `entries` +/// would only add unused surface for a dead path). Coverage that remains valid: Task 5's own +/// capability test proves no `.tmp` file is ever created; `CASTransactionAllTree.CommittedTxnVersion- +/// StoreRepoints` (`gtest_ca_transaction.cpp`) proves the real, live path — a standalone write of +/// `txn_version.txt` directly onto an already-committed part — repoints correctly. + +TEST(CASWiringOps, VerbatimMoveAndUnlink) +{ + auto storage = openWiringStorage(); + auto tx = storage->createTransaction(); + writeThroughTransaction(*tx, "a11/a11a11a1-1111-4111-8111-111111111111/tmp_mutation_5.txt", "cmds"); + auto & ca_tx = dynamic_cast(*tx); + ca_tx.moveFile("a11/a11a11a1-1111-4111-8111-111111111111/tmp_mutation_5.txt", "a11/a11a11a1-1111-4111-8111-111111111111/mutation_5.txt"); + EXPECT_TRUE(storage->existsFile("a11/a11a11a1-1111-4111-8111-111111111111/mutation_5.txt")); + EXPECT_FALSE(storage->existsFile("a11/a11a11a1-1111-4111-8111-111111111111/tmp_mutation_5.txt")); + ca_tx.unlinkFile("a11/a11a11a1-1111-4111-8111-111111111111/mutation_5.txt", false, false); + EXPECT_FALSE(storage->existsFile("a11/a11a11a1-1111-4111-8111-111111111111/mutation_5.txt")); +} + +TEST(CASWiringOps, UnlinkHonorsIfExistsForPartFiles) +{ + auto storage = openWiringStorage(); + const String path = "a11/a11a11a1-1111-4111-8111-111111111111/all_1_1_0/data.bin"; + + auto missing_tx = storage->createTransaction(); + DB::Cas::tests::expectThrowsCode(DB::ErrorCodes::FILE_DOESNT_EXIST, + [&] { missing_tx->unlinkFile(path, /*if_exists=*/false, /*should_remove_objects=*/true); }); + + auto ignored_tx = storage->createTransaction(); + EXPECT_NO_THROW(ignored_tx->unlinkFile(path, /*if_exists=*/true, /*should_remove_objects=*/true)); + EXPECT_NO_THROW(ignored_tx->commit(DB::NoCommitOptions{})); + EXPECT_FALSE(storage->existsFile(path)); + + auto create_tx = storage->createTransaction(); + writeThroughTransaction(*create_tx, path, "payload"); + create_tx->commit(DB::NoCommitOptions{}); + ASSERT_TRUE(storage->existsFile(path)); + + auto existing_tx = storage->createTransaction(); + EXPECT_NO_THROW(existing_tx->unlinkFile(path, /*if_exists=*/false, /*should_remove_objects=*/true)); + EXPECT_NO_THROW(existing_tx->commit(DB::NoCommitOptions{})); + EXPECT_FALSE(storage->existsFile(path)); +} + +TEST(CASWiringOps, TableRenameMovesRefsFilesAndDetached) +{ + auto storage = openWiringStorage(); + auto tx = storage->createTransaction(); + writeThroughTransaction(*tx, "a11/a11a11a1-1111-4111-8111-111111111111/all_1_1_0/data.bin", "live"); + tx->commit(DB::NoCommitOptions{}); + auto tx2 = storage->createTransaction(); + tx2->moveDirectory("a11/a11a11a1-1111-4111-8111-111111111111/all_1_1_0", "a11/a11a11a1-1111-4111-8111-111111111111/detached/all_1_1_0"); /// one detached part + tx2->commit(DB::NoCommitOptions{}); + auto tx3 = storage->createTransaction(); + writeThroughTransaction(*tx3, "a11/a11a11a1-1111-4111-8111-111111111111/all_2_2_0/data.bin", "live2"); + tx3->commit(DB::NoCommitOptions{}); + storage->store()->putNamespaceFile(wiringLife(*storage), "format_version.txt", "1\n"); + + auto tx4 = storage->createTransaction(); + tx4->moveDirectory("a11/a11a11a1-1111-4111-8111-111111111111", "a22/a22a22a2-2222-4222-8222-222222222222"); + tx4->commit(DB::NoCommitOptions{}); + + EXPECT_FALSE(storage->existsDirectory("a11/a11a11a1-1111-4111-8111-111111111111")); + EXPECT_TRUE(storage->existsDirectory("a22/a22a22a2-2222-4222-8222-222222222222")); + EXPECT_TRUE(storage->existsFile("a22/a22a22a2-2222-4222-8222-222222222222/all_2_2_0/data.bin")); + EXPECT_TRUE(storage->existsFile("a22/a22a22a2-2222-4222-8222-222222222222/format_version.txt")); + EXPECT_TRUE(storage->existsDirectory("a22/a22a22a2-2222-4222-8222-222222222222/detached/all_1_1_0")); +} + +/// B126: RENAME TABLE move_namespace is idempotent — re-driving the SAME rename after it completed is a +/// clean no-op (the source namespace is already gone), so a partial-failure re-drive is safe. +TEST(CASWiringOps, TableRenameIsIdempotentOnRedrive) +{ + auto storage = openWiringStorage(); + auto tx = storage->createTransaction(); + writeThroughTransaction(*tx, "a11/a11a11a1-1111-4111-8111-111111111111/all_1_1_0/data.bin", "live"); + tx->commit(DB::NoCommitOptions{}); + storage->store()->putNamespaceFile(wiringLife(*storage), "format_version.txt", "1\n"); + + auto tx2 = storage->createTransaction(); + tx2->moveDirectory("a11/a11a11a1-1111-4111-8111-111111111111", "a22/a22a22a2-2222-4222-8222-222222222222"); + tx2->commit(DB::NoCommitOptions{}); + + /// Re-drive the identical rename: a11a11a1-1111-4111-8111-111111111111 is empty/gone, so every step no-ops; must not throw. + auto tx3 = storage->createTransaction(); + EXPECT_NO_THROW(tx3->moveDirectory("a11/a11a11a1-1111-4111-8111-111111111111", "a22/a22a22a2-2222-4222-8222-222222222222")); + tx3->commit(DB::NoCommitOptions{}); + + EXPECT_TRUE(storage->existsFile("a22/a22a22a2-2222-4222-8222-222222222222/all_1_1_0/data.bin")); + EXPECT_TRUE(storage->existsFile("a22/a22a22a2-2222-4222-8222-222222222222/format_version.txt")); + EXPECT_FALSE(storage->existsDirectory("a11/a11a11a1-1111-4111-8111-111111111111")); +} + +/// B123: a verbatim-file move (get->put->remove, no native rename) is idempotent on re-drive — once the +/// source is gone but the destination is present, a re-driven move is a no-op, not a FILE_DOESNT_EXIST. +TEST(CASWiringOps, VerbatimMoveIsIdempotentOnRedrive) +{ + auto storage = openWiringStorage(); + auto tx = storage->createTransaction(); + writeThroughTransaction(*tx, "a11/a11a11a1-1111-4111-8111-111111111111/tmp_mutation_7.txt", "cmds"); + auto & ca_tx = dynamic_cast(*tx); + ca_tx.moveFile("a11/a11a11a1-1111-4111-8111-111111111111/tmp_mutation_7.txt", "a11/a11a11a1-1111-4111-8111-111111111111/mutation_7.txt"); + EXPECT_TRUE(storage->existsFile("a11/a11a11a1-1111-4111-8111-111111111111/mutation_7.txt")); + /// Re-drive: source gone, destination present → no-op (no throw). + EXPECT_NO_THROW(ca_tx.moveFile("a11/a11a11a1-1111-4111-8111-111111111111/tmp_mutation_7.txt", "a11/a11a11a1-1111-4111-8111-111111111111/mutation_7.txt")); + EXPECT_TRUE(storage->existsFile("a11/a11a11a1-1111-4111-8111-111111111111/mutation_7.txt")); + /// Both source and destination absent → genuine missing source still throws. + EXPECT_ANY_THROW(ca_tx.moveFile("a11/a11a11a1-1111-4111-8111-111111111111/tmp_mutation_8.txt", "a11/a11a11a1-1111-4111-8111-111111111111/mutation_8.txt")); +} + +/// B124: moveDirectory's staged-merge is source-wins, and a genuine collision (the same mutable file +/// staged under BOTH the source and destination part keys with DIFFERING bytes) fails loud instead of +/// silently dropping a just-written file. Identical bytes are a benign idempotent re-key. +/// +/// The "fails loud" collision throws LOGICAL_ERROR, which aborts the whole process in debug/sanitizer +/// builds (Exception.cpp's handle_error_code) instead of behaving like a catchable exception -- so +/// EXPECT_ANY_THROW only makes sense in a plain release build. CASWiringOpsDeathTest below proves the +/// SAME collision positively aborts under debug/sanitizer builds instead (same pattern as the existing +/// CASBlobDigestDeathTest precedent). +TEST(CASWiringOps, MoveDirectoryMutableCollisionPolicy) +{ +#ifndef DEBUG_OR_SANITIZER_BUILD + /// Differing bytes → fail loud. + { + auto storage = openWiringStorage(); + auto tx = storage->createTransaction(); + writeThroughTransaction(*tx, "a11/a11a11a1-1111-4111-8111-111111111111/tmp_x/txn_version.txt", "A"); + writeThroughTransaction(*tx, "a11/a11a11a1-1111-4111-8111-111111111111/all_9_9_9/txn_version.txt", "B"); + auto & ca_tx = dynamic_cast(*tx); + EXPECT_ANY_THROW(ca_tx.moveDirectory("a11/a11a11a1-1111-4111-8111-111111111111/tmp_x", "a11/a11a11a1-1111-4111-8111-111111111111/all_9_9_9")); + } +#endif + /// Identical bytes → benign, no throw (source-wins, idempotent). Both parts carry real content so + /// the eager publish-at-rename builds a proper ref (a mutable-only staging would instead hit + /// updateRefPublishedAt on a not-yet-committed ref — unrelated to the collision policy under test). + /// data.bin must ALSO match now: all-tree Task 9 generalized the differing-bytes collision check + /// from the legacy mutable-file names to every entry, so a differing data.bin would (correctly) + /// throw too and defeat this block's "benign" premise. + { + auto storage = openWiringStorage(); + auto tx = storage->createTransaction(); + writeThroughTransaction(*tx, "a11/a11a11a1-1111-4111-8111-111111111111/tmp_y/data.bin", "d1"); + writeThroughTransaction(*tx, "a11/a11a11a1-1111-4111-8111-111111111111/tmp_y/txn_version.txt", "SAME"); + writeThroughTransaction(*tx, "a11/a11a11a1-1111-4111-8111-111111111111/all_8_8_8/data.bin", "d1"); + writeThroughTransaction(*tx, "a11/a11a11a1-1111-4111-8111-111111111111/all_8_8_8/txn_version.txt", "SAME"); + auto & ca_tx = dynamic_cast(*tx); + EXPECT_NO_THROW(ca_tx.moveDirectory("a11/a11a11a1-1111-4111-8111-111111111111/tmp_y", "a11/a11a11a1-1111-4111-8111-111111111111/all_8_8_8")); + } +} + +#if defined(DEBUG_OR_SANITIZER_BUILD) +/// Debug/sanitizer-build counterpart to MoveDirectoryMutableCollisionPolicy's "differing bytes → fail +/// loud" case: LOGICAL_ERROR aborts the process here instead of throwing a catchable exception, so the +/// check must be a death test (same pattern as CASBlobDigestDeathTest in gtest_cas_blob_digest.cpp). +TEST(CASWiringOpsDeathTest, MoveDirectoryMutableCollisionPolicyAborts) +{ + auto storage = openWiringStorage(); + auto tx = storage->createTransaction(); + writeThroughTransaction(*tx, "a11/a11a11a1-1111-4111-8111-111111111111/tmp_x/txn_version.txt", "A"); + writeThroughTransaction(*tx, "a11/a11a11a1-1111-4111-8111-111111111111/all_9_9_9/txn_version.txt", "B"); + auto & ca_tx = dynamic_cast(*tx); + EXPECT_DEATH({ ca_tx.moveDirectory("a11/a11a11a1-1111-4111-8111-111111111111/tmp_x", "a11/a11a11a1-1111-4111-8111-111111111111/all_9_9_9"); }, ""); +} +#endif + +/// D3 review pin: moveDirectory's staged-merge collision code has four (src build?, dst build?) +/// combinations. This one — destination already holds a staged PartWriteTxn, source has none — proved +/// confusable when the plan's author sketched a fix: a naive rewrite of the four-way branch can fall +/// through to `src_st.build->abandon()` on a null build. The merge must be a pure no-op on the +/// destination's build in this combination — no abandon, no adopt — while everything else (any +/// removal marks carried from the source) still merges in and the destination's own content +/// publishes exactly as staged. +/// +/// T9-review fix (all-tree-part-files): the ORIGINAL construction staged the source via a +/// `txn_version.txt` WRITE, relying on the pre-Task-6 "the mutable-file write path never calls +/// buildFor" fact to keep `src_st.build` null. Since Task 6/9, `writeFile`'s inline-candidate path +/// (which `txn_version.txt` now takes — it is an ordinary tree entry, not a mutable sidecar file) +/// unconditionally calls `buildFor` for ANY inline entry, so the source silently acquired a REAL +/// PartWriteTxn and this test drifted onto the *other* merge branch (`else if (src_st.build)`) without +/// failing — both branches produce the same externally-visible result (assertions passed either +/// way), so the drift was invisible. Fixed by staging the source via `unlinkFile` instead of a +/// write: Task 8's removal-mark staging (`content_removed`) is the one remaining staging shape that +/// genuinely never calls `buildFor` (`publishStaging`'s own `!st.build && ...` guard depends on +/// this), so `parts[src_key]` exists but `src_st.build` stays null again, restoring the test's +/// documented precondition. +/// +/// Made RED-able (the review's ask): `PartWriteTxn::abandon()` unconditionally emits a `BuildAbort` +/// `CasEvent` (`CasPartWriteTxn.cpp`) — this only happens if the buggy `else if (src_st.build)` branch +/// runs `src_st.build->abandon()`. Registering an event sink (`Cas::Pool::setEventSink`, the same +/// public test hook `gtest_cas_event_log.cpp` uses) and asserting no `BuildAbort` event fires is a +/// genuine behavioral discriminator between the two merge branches — not just "assertions pass +/// either way" — so a future regression that gives the source a PartWriteTxn again fails this test loudly. +TEST(CASWiringOps, MoveDirectoryOntoExistingDestinationBuildSurvives) +{ + std::vector events; /// declared BEFORE the Pool so it outlives the background syncer's emits (ASan 2026-07-09) + auto storage = openWiringStorage(); + storage->store()->setEventSink([&](const DB::Cas::CasEvent & e) { events.push_back(e); }); + + /// unlinkFile now honors if_exists=false (triage #24, 8fc0c964a5b): the target must be real. Commit + /// it in its own transaction first so the removal below targets a genuinely-committed file, not a + /// never-existed path. + auto setup_tx = storage->createTransaction(); + writeThroughTransaction(*setup_tx, "a11/a11a11a1-1111-4111-8111-111111111111/tmp_z/txn_version.txt", "creation_tid: (7,7,00000000-0000-0000-0000-000000000000)"); + setup_tx->commit(DB::NoCommitOptions{}); + + auto tx = storage->createTransaction(); + /// Destination already has a real blob upload staged -> a live PartWriteTxn. + writeThroughTransaction(*tx, "a11/a11a11a1-1111-4111-8111-111111111111/all_7_7_7/data.bin", "dst-content"); + /// Source is staged with ONLY a removal mark (Task 8's content_removed staging) -> parts[src_key] + /// exists, but src_st.build stays null (unlinkFile never calls buildFor). + tx->unlinkFile("a11/a11a11a1-1111-4111-8111-111111111111/tmp_z/txn_version.txt", /*if_exists=*/false, /*should_remove_objects=*/true); + + auto & ca_tx = dynamic_cast(*tx); + EXPECT_NO_THROW(ca_tx.moveDirectory("a11/a11a11a1-1111-4111-8111-111111111111/tmp_z", "a11/a11a11a1-1111-4111-8111-111111111111/all_7_7_7")); + + /// The discriminator: no BuildAbort event means src_st.build->abandon() was never called during + /// the re-key merge, i.e. the intended neither-branch no-op merge ran, not the two-builds + /// merge-and-abandon branch. Checked right after the re-key so it stays scoped to the merge. + EXPECT_FALSE(std::any_of(events.begin(), events.end(), + [](const DB::Cas::CasEvent & e) { return e.type == DB::Cas::CasEventType::BuildAbort; })) + << "src_st.build->abandon() fired — the source unexpectedly has a real PartWriteTxn again"; + + /// [TXN-ONE-PIPELINE] the re-key does not publish; the destination's build is materialized only at + /// commit(). The destination's own build then publishes its own content untouched; the source's + /// removal mark names a path never committed anywhere, so it is a harmless no-op once merged into + /// the destination's (first-time-published) staging. + tx->commit(DB::NoCommitOptions{}); + EXPECT_TRUE(storage->existsDirectory("a11/a11a11a1-1111-4111-8111-111111111111/all_7_7_7")); + EXPECT_EQ(storage->getFileSize("a11/a11a11a1-1111-4111-8111-111111111111/all_7_7_7/data.bin"), 11u); /// "dst-content" + EXPECT_FALSE(storage->tryGetInManifestBytes("a11/a11a11a1-1111-4111-8111-111111111111/all_7_7_7/txn_version.txt").has_value()); + + storage->store()->setEventSink(nullptr); +} + +TEST(CASWiringOps, FreezeViaHardLinksIntoShadow) +{ + auto storage = openWiringStorage(); + auto tx = storage->createTransaction(); + writeThroughTransaction(*tx, "a11/a11a11a1-1111-4111-8111-111111111111/all_1_1_0/data.bin", "frozen-bytes"); + writeThroughTransaction(*tx, "a11/a11a11a1-1111-4111-8111-111111111111/all_1_1_0/metadata_version.txt", "7"); + tx->commit(DB::NoCommitOptions{}); + + /// FREEZE clones a committed part file-by-file into the shadow tree via hardlinks; the staged + /// shadow part publishes at commit (pool-global - any replica reads the backup). + auto tx2 = storage->createTransaction(); + tx2->createHardLink("a11/a11a11a1-1111-4111-8111-111111111111/all_1_1_0/data.bin", "shadow/bk1/store/a11/a11a11a1-1111-4111-8111-111111111111/all_1_1_0/data.bin"); + tx2->createHardLink("a11/a11a11a1-1111-4111-8111-111111111111/all_1_1_0/metadata_version.txt", + "shadow/bk1/store/a11/a11a11a1-1111-4111-8111-111111111111/all_1_1_0/metadata_version.txt"); + tx2->commit(DB::NoCommitOptions{}); + + EXPECT_TRUE(storage->existsDirectory("shadow/bk1/store/a11/a11a11a1-1111-4111-8111-111111111111/all_1_1_0")); + EXPECT_TRUE(storage->existsFile("shadow/bk1/store/a11/a11a11a1-1111-4111-8111-111111111111/all_1_1_0/data.bin")); + EXPECT_EQ(storage->tryGetInManifestBytes("shadow/bk1/store/a11/a11a11a1-1111-4111-8111-111111111111/all_1_1_0/metadata_version.txt"), + std::optional("7")); + + /// UNFREEZE: removeRecursive of the backup root drops every shadow namespace under it. + auto tx3 = storage->createTransaction(); + tx3->removeRecursive("shadow/bk1", {}); + EXPECT_FALSE(storage->existsDirectory("shadow/bk1/store/a11/a11a11a1-1111-4111-8111-111111111111/all_1_1_0")); + EXPECT_FALSE(storage->existsDirectory("shadow/bk1")); +} + +/// ==== M-W Task 8: in-flight read-your-writes (B59) ==== + +TEST(CASWiringInFlight, StagedFilesVisibleBeforeCommit) +{ + auto storage = openWiringStorage(); + auto tx = storage->createTransaction(); + writeThroughTransaction(*tx, "a11/a11a11a1-1111-4111-8111-111111111111/tmp_mut_all_1_1_0/p.proj/data.bin", "proj-bytes"); + writeThroughTransaction(*tx, "a11/a11a11a1-1111-4111-8111-111111111111/tmp_mut_all_1_1_0/uuid.txt", "u-9"); + + /// B188 precommit-first: content blobs are PENDING (staged locally, not yet uploaded). So + /// tryGetInFlightStorageObjects returns {} — the pool object does not exist yet. The caller + /// (DataPartStorageOnDiskFull::prepareRead) falls back to tryGetInFlightFileSize to get the size + /// and then serves the content via tryReadFileInFlight (local temp file). File sizes and directory + /// overlay still work because they are driven by the staged tree entry, not the pool. + auto objects = tx->tryGetInFlightStorageObjects("a11/a11a11a1-1111-4111-8111-111111111111/tmp_mut_all_1_1_0/p.proj/data.bin"); + EXPECT_FALSE(objects.has_value()); + EXPECT_EQ(tx->tryGetInFlightFileSize("a11/a11a11a1-1111-4111-8111-111111111111/tmp_mut_all_1_1_0/p.proj/data.bin"), std::optional(10)); + EXPECT_EQ(tx->tryGetInFlightFileSize("a11/a11a11a1-1111-4111-8111-111111111111/tmp_mut_all_1_1_0/uuid.txt"), std::optional(3)); + EXPECT_FALSE(tx->tryGetInFlightFileSize("a11/a11a11a1-1111-4111-8111-111111111111/tmp_mut_all_1_1_0/missing.bin").has_value()); + + /// Bytes read back: a pending blob from the local temp file (B188); staged mutable bytes from memory. + { + auto buf = tx->tryReadFileInFlight("a11/a11a11a1-1111-4111-8111-111111111111/tmp_mut_all_1_1_0/p.proj/data.bin", {}, std::nullopt); + ASSERT_TRUE(buf); + String read; + readStringUntilEOF(read, *buf); + EXPECT_EQ(read, "proj-bytes"); /// B188: served from local temp file (pending upload) + } + { + auto buf = tx->tryReadFileInFlight("a11/a11a11a1-1111-4111-8111-111111111111/tmp_mut_all_1_1_0/uuid.txt", {}, std::nullopt); + ASSERT_TRUE(buf); + String read; + readStringUntilEOF(read, *buf); + EXPECT_EQ(read, "u-9"); + } + + /// The directory overlay answers for INNER dirs only (the PoC contract): the part dir itself + /// is FALSE so a rejected temporary part's removeIfNeeded takes the clean early-return path. + EXPECT_FALSE(tx->hasInFlightDirectory("a11/a11a11a1-1111-4111-8111-111111111111/tmp_mut_all_1_1_0")); + EXPECT_TRUE(tx->hasInFlightDirectory("a11/a11a11a1-1111-4111-8111-111111111111/tmp_mut_all_1_1_0/p.proj")); + EXPECT_FALSE(tx->hasInFlightDirectory("a11/a11a11a1-1111-4111-8111-111111111111/tmp_mut_all_1_1_0/q.proj")); + auto top = tx->listInFlightDirectory("a11/a11a11a1-1111-4111-8111-111111111111/tmp_mut_all_1_1_0"); + EXPECT_EQ(top, (std::vector{"p.proj", "uuid.txt"})); + EXPECT_EQ(tx->listInFlightDirectory("a11/a11a11a1-1111-4111-8111-111111111111/tmp_mut_all_1_1_0/p.proj"), + (std::vector{"data.bin"})); +} + +namespace DB::ErrorCodes +{ + extern const int NOT_IMPLEMENTED; +} + +/// ==== M-W Task 10: the GC scheduler end-to-end through the wiring ==== + +TEST(CASWiringGc, DroppedPartIsReclaimedByRounds) +{ + auto storage = openWiringStorage(); + auto tx = storage->createTransaction(); + writeThroughTransaction(*tx, "a11/a11a11a1-1111-4111-8111-111111111111/all_1_1_0/data.bin", "reclaim-me"); + tx->commit(DB::NoCommitOptions{}); + + auto * exchange = dynamic_cast(storage.get()); + ASSERT_NE(exchange, nullptr); + const auto blob_key = storage->getStorageObjects("a11/a11a11a1-1111-4111-8111-111111111111/all_1_1_0/data.bin")[0].remote_path; + + auto tx2 = storage->createTransaction(); + tx2->removeDirectory("a11/a11a11a1-1111-4111-8111-111111111111/all_1_1_0"); /// dropRef - the part is unreachable now + + /// Round 1 folds the drop and retires+deletes the part MANIFEST; the freed blob is retired+deleted + /// by a FOLLOWING round (next-round reclamation, M-C3). The steal needs one extra observation + /// window between rounds (the pacing scheduler is stable across these calls - each call after the + /// first re-acquires via renewal). + storage->runOneGcRoundForTest(); + storage->runOneGcRoundForTest(); + storage->runOneGcRoundForTest(); + + EXPECT_FALSE(storage->existsDirectory("a11/a11a11a1-1111-4111-8111-111111111111/all_1_1_0")); + /// The relink offer (B7 part_manifest_v2): a reclaimed part is no longer a committed CA part here, + /// so getRelinkOffer offers NOTHING and the sender streams bytes — the documented fallback. + EXPECT_FALSE(exchange->getRelinkOffer("a11/a11a11a1-1111-4111-8111-111111111111/all_1_1_0").has_value()); + + /// A fresh identical write re-CREATES the content at the same key and reads back fine. + auto tx3 = storage->createTransaction(); + writeThroughTransaction(*tx3, "a11/a11a11a1-1111-4111-8111-111111111111/all_9_9_0/data.bin", "reclaim-me"); + tx3->commit(DB::NoCommitOptions{}); + EXPECT_EQ(storage->getStorageObjects("a11/a11a11a1-1111-4111-8111-111111111111/all_9_9_0/data.bin")[0].remote_path, blob_key); + EXPECT_TRUE(storage->existsFile("a11/a11a11a1-1111-4111-8111-111111111111/all_9_9_0/data.bin")); +} + +/// B199 (real-path displacement reclamation, ported off the tree model to part manifests): re-writing +/// the SAME part path with DISTINCT content publishes a NEW part ManifestId over the ref (a true-removal +/// of the old owner manifest + an activation of the new one in the single ordered journal — no shared +/// content-addressed identity between the two parts). GC must reclaim the displaced (manifestA) unique +/// blobs while never losing the live (manifestB) closure. +/// +/// NOTE (port): the original repro pre-deleted treeA's TREE OBJECT before the fold to exercise the +/// tree-era inline-closure 404 path (the precommit `Add` carried treeA's closure INLINE so the fold +/// recorded edges without a `readTree`). That mechanism is gone: a part manifest carries its OWN blob +/// edges and the fold reads the ONE removal-target body to release them (a missing removal body clamps +/// + records an anomaly, never guesses). So this port drives the genuine manifest displacement WITHOUT +/// the out-of-band pre-delete twist — the reclamation contract (no leak / no loss) is what survives. +/// +/// PORT (rev. 15 displacement shape): a part is a single-owner ManifestId and `promote` is a PURE OWNER +/// MOVE (precommit→committed). Re-publishing over a LIVE committed ref does NOT emit a removal of the +/// displaced owner (the displaced manifest is not named in any event), so its blobs would never get a +/// -1 — there is no in-place "republish-over-committed". The genuine displacement that DOES journal a +/// true-removal is the real MergeTree pattern: DROP the old part (dropRef appends old→none, leaving the +/// old body present for the fold to read the -1 edges), THEN publish the new part. GC folds manifestA's +/// removal, retires its now-zero-in-degree blobs, and the recheck cleanup deletes the owner-removed +/// body. We do NOT pre-delete manifestA's body — only GC deletes an owner-removed body, after sealing +/// its decrements. +TEST(CASWiringGc, DisplacedTreeBlobsReclaimedThroughRealPath) +{ + auto storage = openWiringStorage(); + + /// Commit manifestA with unique content (data-A / mark-A), through the real precommit-first transaction. + { + auto tx = storage->createTransaction(); + writeThroughTransaction(*tx, "a11/a11a11a1-1111-4111-8111-111111111111/all_0_0_0/data.bin", "data-A"); + writeThroughTransaction(*tx, "a11/a11a11a1-1111-4111-8111-111111111111/all_0_0_0/data.cmrk3", "mark-A"); + tx->commit(DB::NoCommitOptions{}); + } + const auto resolved_a = storage->store()->resolveRef(storage->liveNamespace("a11a11a1-1111-4111-8111-111111111111"), "all_0_0_0"); + ASSERT_TRUE(resolved_a.has_value()); + const DB::Cas::ManifestId manifest_a = resolved_a->manifest_id; + + /// DISPLACE (true-removal repoint): drop the old part so dropRef journals manifestA's removal + /// (old=committed(manifestA)→new=none) — this leaves manifestA's body PRESENT for the fold to read + /// its -1 edges. Then re-write the SAME part path with DISTINCT content (data-B / mark-B), which + /// publishes a NEW part ManifestId over the (now free) ref. Confirm the displacement is real: the + /// ref resolves to a DIFFERENT manifest. + { + auto tx = storage->createTransaction(); + tx->removeDirectory("a11/a11a11a1-1111-4111-8111-111111111111/all_0_0_0"); + tx->commit(DB::NoCommitOptions{}); + } + { + auto tx = storage->createTransaction(); + writeThroughTransaction(*tx, "a11/a11a11a1-1111-4111-8111-111111111111/all_0_0_0/data.bin", "data-B"); + writeThroughTransaction(*tx, "a11/a11a11a1-1111-4111-8111-111111111111/all_0_0_0/data.cmrk3", "mark-B"); + tx->commit(DB::NoCommitOptions{}); + } + const auto resolved_b = storage->store()->resolveRef(storage->liveNamespace("a11a11a1-1111-4111-8111-111111111111"), "all_0_0_0"); + ASSERT_TRUE(resolved_b.has_value()); + ASSERT_FALSE(manifest_a == resolved_b->manifest_id) + << "the second write must displace the ref to a distinct part manifest (last-op-wins)"; + + /// Drive GC to a fixpoint. Displacement reclamation needs the next-round cascade (manifestA's + /// removal folds, its blobs hit zero in-degree, a following round retires+deletes them); give a + /// generous bound so the displaced closure fully drains. + for (int i = 0; i < 8; ++i) + storage->runOneGcRoundForTest(); + + const DB::Cas::FsckReport after = DB::Cas::runFsck(*storage->store(), /*detail=*/false); + EXPECT_EQ(after.dangling, 0u) << "displacement must never lose a reachable object (manifestB stays live)"; + EXPECT_GT(after.reachable, 0u) << "the live ref points at manifestB; manifestB's closure is reachable"; + /// The REAL path: `runOneGcRoundForTest` drives the production scheduler, so the displaced closure + /// is not merely recognized as unreachable but actually reclaimed -- recognition alone would leave + /// every displacement leaking one part's unique blobs forever. + EXPECT_EQ(after.unreachable, 0u) + << "manifestA's unique blobs (data-A / mark-A) must be RECLAIMED once the displacement folds, " + << "not just recognized as unreachable; unreachable=" << after.unreachable; +} + +/// ==== M-W Task 11 / B7: the DataPartsExchange facade (manifest relink, part_manifest_v2) ==== + +/// Publish-then-confirm (Task 14) split the receiver's adoption into `prepare` + `promote`, with the +/// interserver confirm interposed between them. The confirm belongs to `Fetcher`, not to the storage, so +/// the tests below that only care about the ADOPTION drive both halves back to back through this helper +/// -- which is exactly what `publishEntries` does for the atomic callers. `false` is the +/// `MechanismFallbackAllowed` outcome of either half: nothing published, the caller byte-fetches. +namespace +{ + +/// The RECEIVER's disk-relative staging path for every relink test below: the tmp-fetch dir of the +/// receiving table (a22...), which is a DIFFERENT table from the sender's (a11...) -- that is what makes +/// the "the sender's namespace id is ignored" assertions meaningful. `prepareAdoptFromManifest` is +/// addressed by path, exactly like `getRelinkOffer`, so the ref name is the router's business. +constexpr auto kReceiverTmpFetchPath = "a22/a22a22a2-2222-4222-8222-222222222222/tmp-fetch_all_1_1_0"; + +bool adoptPartFromManifestAndPromote(DB::IContentAddressedExchange & exchange, const String & part_path, + const String & manifest_bytes) +{ + std::unique_ptr prepared; + if (exchange.prepareAdoptFromManifest(part_path, manifest_bytes, prepared) + == DB::CaRelinkPrepare::MechanismFallbackAllowed) + return false; + EXPECT_NE(prepared, nullptr) << "a Prepared outcome must carry the handle that owes the terminal operation"; + return prepared->promote() == DB::CaRelinkPromote::Committed; +} + +} + +/// B7 sender side: getRelinkOffer returns the COMMITTED part's encoded PartManifest body — the +/// opaque payload the receiver decodes. The bytes must decode to the same entries the part was +/// published with; an absent part offers nothing (the sender streams bytes — the documented fallback). +/// Task 13 adds the second half of the offer: the confirm token, which must name the SAME manifest the +/// body carries. That equality is the offer's whole safety property — a token naming anything else +/// would have the receiver confirm a manifest whose entries it never adopted. +TEST(CASWiringExchange, GetRelinkOfferReturnsBodyAndTokenForCommittedPart) +{ + auto storage = openWiringStorage(); + /// Publish a real committed part (data.bin + a projection blob + mutable per-part files). + publishWiredPart(*storage, storage->liveNamespace("a11a11a1-1111-4111-8111-111111111111"), "all_1_1_0"); + + auto * exchange = dynamic_cast(storage.get()); + ASSERT_NE(exchange, nullptr); + EXPECT_FALSE(exchange->getPoolUUID().empty()); + + auto offer = exchange->getRelinkOffer("a11/a11a11a1-1111-4111-8111-111111111111/all_1_1_0"); + ASSERT_TRUE(offer.has_value()); + EXPECT_FALSE(offer->manifest_bytes.empty()); + + /// The transferred body decodes to the SAME entries the part names — the blob entries AND the + /// per-part files (uuid.txt/metadata_version.txt are ordinary tree entries now, all-tree Task 6/9). + /// The sender's ManifestRef/namespace/digest are present but non-authoritative downstream. + const DB::Cas::PartManifest decoded = DB::Cas::decodePartManifest(offer->manifest_bytes); + ASSERT_EQ(decoded.entries.size(), 4u); + EXPECT_EQ(decoded.entries[0].path, "data.bin"); + EXPECT_EQ(decoded.entries[0].ref.digest.toU128(), u128Of("payload-A")); + EXPECT_EQ(decoded.entries[2].path, "p.proj/data.bin"); + EXPECT_EQ(decoded.entries[2].ref.digest.toU128(), u128Of("payload-B")); + + /// The token: it decodes, it names this mount and this pool, and it names the manifest that the + /// body just decoded to. + const auto token = DB::decodeCasRelinkSourceToken(offer->confirm_token); + ASSERT_TRUE(token.has_value()) << "the sender minted a token its own decoder rejects: " << offer->confirm_token; + EXPECT_EQ(token->pool_uuid, exchange->getPoolUUID()); + EXPECT_EQ(token->root_namespace, storage->liveNamespace("a11a11a1-1111-4111-8111-111111111111").string()); + EXPECT_EQ(token->ref_name, "all_1_1_0"); + EXPECT_EQ(token->part_name, "all_1_1_0"); + EXPECT_EQ(token->manifest_ref_text, DB::Cas::manifestRefDebugString(decoded.ref)); + EXPECT_TRUE(exchange->ownsNamespace(token->server_root_id, token->root_namespace)) + << "the minted token must route back to the mount that minted it"; + + /// An absent part is not a committed CA part here -> no offer. + EXPECT_FALSE(exchange->getRelinkOffer("a11/a11a11a1-1111-4111-8111-111111111111/all_9_9_9").has_value()); +} + +/// B7 receiver side (the core): take a COMMITTED part's transferred manifest bytes and adopt them into +/// a DIFFERENT table namespace WITHOUT moving any blob body (blobs are shared by hash in the pool). +/// The receiver stages its OWN fresh local manifest, precommitAdd + promote it, and reports success. +/// Asserts: success; the adopted ref is live + loadable; the receiver's ManifestId differs from the +/// sender's (no shared identity); the ref lives in the RECEIVER namespace (no cross-namespace adoption); +/// and NO blob body was uploaded by the receiver (the put-counter stays flat across adopt). +TEST(CASWiringExchange, AdoptPartFromManifestPublishesFreshLocalManifest) +{ + auto storage = openWiringStorage(); + const auto sender_ns = storage->liveNamespace("a11a11a1-1111-4111-8111-111111111111"); + publishWiredPart(*storage, sender_ns, "all_1_1_0"); + + auto * exchange = dynamic_cast(storage.get()); + ASSERT_NE(exchange, nullptr); + + auto offer = exchange->getRelinkOffer("a11/a11a11a1-1111-4111-8111-111111111111/all_1_1_0"); + ASSERT_TRUE(offer.has_value()); + const String & bytes = offer->manifest_bytes; + const DB::Cas::ManifestId sender_id = + storage->store()->resolveRef(sender_ns, "all_1_1_0")->manifest_id; + + /// Count blob PUTs over the adopt: a manifest relink must NOT upload any blob body (the blobs are + /// already in the shared pool, adopted by hash). We assert via the blob keys' presence/incarnation: + /// the receiver never overwrites or re-creates the blobs — their head tokens are unchanged. + const auto data_key = storage->store()->layout().blobKey(idOf("payload-A")); + const auto proj_key = storage->store()->layout().blobKey(idOf("payload-B")); + const auto data_tok_before = storage->store()->backend().head(data_key).token; + const auto proj_tok_before = storage->store()->backend().head(proj_key).token; + + /// Adopt into a DIFFERENT table (a22a22a2-2222-4222-8222-222222222222). The transferred body's root_namespace_id is the sender's + /// (a11a11a1-1111-4111-8111-111111111111) — the receiver must IGNORE it and use a22a22a2-2222-4222-8222-222222222222. + const bool ok = adoptPartFromManifestAndPromote(*exchange, kReceiverTmpFetchPath, bytes); + EXPECT_TRUE(ok); + + /// The adopted ref is live in the RECEIVER namespace and loadable. + const auto receiver_ns = storage->liveNamespace("a22a22a2-2222-4222-8222-222222222222"); + auto receiver_resolved = storage->store()->resolveRef(receiver_ns, "tmp-fetch_all_1_1_0"); + ASSERT_TRUE(receiver_resolved.has_value()); + const DB::Cas::PartManifest receiver_manifest = + storage->store()->readManifest(receiver_resolved->manifest_id); + ASSERT_EQ(receiver_manifest.entries.size(), 4u); + EXPECT_EQ(receiver_manifest.entries[0].ref.digest.toU128(), u128Of("payload-A")); + + /// FRESH receiver-local identity: a DIFFERENT ManifestId from the sender's, in the RECEIVER namespace. + EXPECT_FALSE(sender_id == receiver_resolved->manifest_id) + << "the receiver must mint its OWN manifest id, not share the sender's"; + EXPECT_EQ(receiver_resolved->manifest_id.root_namespace.string(), receiver_ns.string()) + << "the adopted manifest must live in the receiver namespace (derived from table_uuid), not the sender's"; + EXPECT_FALSE(receiver_ns.string() == sender_ns.string()); + + /// NO blob body was uploaded: the shared blobs' incarnations are untouched by the adopt. + EXPECT_EQ(storage->store()->backend().head(data_key).token, data_tok_before) + << "adopt-from-manifest must not re-upload a blob already in the shared pool"; + EXPECT_EQ(storage->store()->backend().head(proj_key).token, proj_tok_before); +} + +/// B7 fail-closed: if a referenced blob is absent/condemned in the pool, adoptPartFromManifest must +/// promote-abort and return FALSE (NOT throw) so the caller byte-fetches — exactly where the old pin +/// protocol fell back. Nothing is published (no dangling ref). +TEST(CASWiringExchange, AdoptFailsClosedAndFallsBackOnCondemnedBlob) +{ + /// §4 manifest-trust (test name is legacy — adopt no longer fails closed on a raced pool blob): + /// adoptPartFromManifest runs the receiver's local promote, which TRUSTS the committed-source adopted + /// leaves via the durable manifest edge — no per-file HEAD/loadMeta probe on the pool blobs. So even if + /// a pool blob raced to absent, adopt SUCCEEDS and publishes the receiver ref. This matches ordinary + /// ReplicatedMergeTree interserver trust: the sender served the manifest from a LIVE part whose refs pin + /// the blobs at in-degree >= 1, so this scenario cannot arise on the real fetch path; a genuinely-absent + /// adopted blob is an fsck finding, not an adopt-time abort. + auto storage = openWiringStorage(); + const auto sender_ns = storage->liveNamespace("a11a11a1-1111-4111-8111-111111111111"); + publishWiredPart(*storage, sender_ns, "all_1_1_0"); + + auto * exchange = dynamic_cast(storage.get()); + ASSERT_NE(exchange, nullptr); + auto offer = exchange->getRelinkOffer("a11/a11a11a1-1111-4111-8111-111111111111/all_1_1_0"); + ASSERT_TRUE(offer.has_value()); + const String & bytes = offer->manifest_bytes; + + /// Artificially delete a referenced pool blob — the live-sender invariant excludes this on the real + /// path; §4 promote does not re-probe it, so adopt trusts the manifest edge and publishes. + const auto data_key = storage->store()->layout().blobKey(idOf("payload-A")); + const auto h = storage->store()->backend().head(data_key); + ASSERT_TRUE(h.exists); + ASSERT_EQ(storage->store()->backend().deleteExact(data_key, h.token).kind, + DB::Cas::DeleteOutcome::Kind::Deleted); + + /// §4: promote trusts the adopted leaves — no re-probe — so adopt SUCCEEDS (returns true) and publishes. + const bool ok = adoptPartFromManifestAndPromote(*exchange, kReceiverTmpFetchPath, bytes); + EXPECT_TRUE(ok) << "§4: adopt trusts the manifest edge; a raced pool blob is not re-probed at promote"; + + /// The receiver ref publishes (the D4 trade-off), and the deleted pool blob surfaces via fsck's + /// reachable-but-absent scan (the backstop — INV-NO-DANGLE-via-fsck). + EXPECT_TRUE(storage->store()->resolveRef(storage->liveNamespace("a22a22a2-2222-4222-8222-222222222222"), "tmp-fetch_all_1_1_0").has_value()); + const DB::Cas::FsckReport rep = DB::Cas::runFsck(*storage->store(), /*detail=*/true); + EXPECT_GE(rep.dangling, 1u) << "§4 D4 backstop: the deleted pool blob must surface as an fsck dangling " + "finding (dangling=" << rep.dangling << ")"; +} + +/// All-tree task 7/9: relink self-containment. Task 6 routes uuid.txt/metadata_version.txt through +/// the content path, so a committed part's manifest ENTRIES already carry these files — the receiver +/// no longer needs a mutable_files sidecar to reconstruct them. Task 9 completed the cleanup: +/// `adoptPartFromManifest` no longer even HAS a sidecar parameter (Fetcher::relinkPartToDisk's call +/// site simply dropped the argument). This publishes a part whose per-part files are ordinary +/// manifest entries and adopts it, mirroring the post-task-9 call site exactly. +TEST(CASWiringExchange, AdoptPartFromManifestSelfContainedWithoutMutableFilesSidecar) +{ + auto storage = openWiringStorage(); + const auto sender_ns = storage->liveNamespace("a11a11a1-1111-4111-8111-111111111111"); + + DB::Cas::PartWriteInfo info; + info.intended_ref = sender_ns.string() + "/all_1_1_0"; + info.intended_namespace = sender_ns; + auto build = storage->store()->beginPartWrite(info); + const auto id = build->stageManifest( + {wiringBlobEntry("data.bin", "payload-A"), + wiringBlobEntry("uuid.txt", "payload-uuid"), + wiringBlobEntry("metadata_version.txt", "payload-mv")}); + build->precommitAdd(sender_ns, "all_1_1_0", id); + build->putBlob(idOf("payload-A"), DB::Cas::BlobSource::fromString("payload-A")); + build->putBlob(idOf("payload-uuid"), DB::Cas::BlobSource::fromString("payload-uuid")); + build->putBlob(idOf("payload-mv"), DB::Cas::BlobSource::fromString("payload-mv")); + build->promote(sender_ns, "all_1_1_0", build->buildId(), id); + + auto * exchange = dynamic_cast(storage.get()); + ASSERT_NE(exchange, nullptr); + auto offer = exchange->getRelinkOffer("a11/a11a11a1-1111-4111-8111-111111111111/all_1_1_0"); + ASSERT_TRUE(offer.has_value()); + const String & bytes = offer->manifest_bytes; + const DB::Cas::PartManifest decoded = DB::Cas::decodePartManifest(bytes); + ASSERT_EQ(decoded.entries.size(), 3u) << "uuid.txt/metadata_version.txt travel as ordinary entries"; + + /// No sidecar parameter to pass anymore — exactly what Fetcher::relinkPartToDisk's call looks like + /// now that the manifest is self-contained (no reconstruction from a wire-transferred header). + const bool ok = adoptPartFromManifestAndPromote(*exchange, kReceiverTmpFetchPath, bytes); + EXPECT_TRUE(ok); + + const auto receiver_ns = storage->liveNamespace("a22a22a2-2222-4222-8222-222222222222"); + auto resolved = storage->store()->resolveRef(receiver_ns, "tmp-fetch_all_1_1_0"); + ASSERT_TRUE(resolved.has_value()); + + const DB::Cas::PartManifest receiver_manifest = storage->store()->readManifest(resolved->manifest_id); + ASSERT_EQ(receiver_manifest.entries.size(), 3u); + bool has_uuid_entry = false; + bool has_metadata_version_entry = false; + for (const auto & entry : receiver_manifest.entries) + { + if (entry.path == "uuid.txt") + has_uuid_entry = true; + if (entry.path == "metadata_version.txt") + has_metadata_version_entry = true; + } + EXPECT_TRUE(has_uuid_entry) << "uuid.txt must read back as an ordinary content entry, not mutable_files"; + EXPECT_TRUE(has_metadata_version_entry) + << "metadata_version.txt must read back as an ordinary content entry, not mutable_files"; +} + +/// Publish-then-confirm, receiver half (Task 14): `prepare` must make the receiver's `+1` DURABLE while +/// publishing NOTHING. That combination is the protocol -- the durable `+1` is what a later `yes` is +/// worth anything against, and the absent committed ref is what makes an unproven source cost nothing. +TEST(CASWiringExchange, PrepareAdoptIsDurableButPublishesNothingUntilPromote) +{ + auto storage = openWiringStorage(); + const auto sender_ns = storage->liveNamespace("a11a11a1-1111-4111-8111-111111111111"); + publishWiredPart(*storage, sender_ns, "all_1_1_0"); + + auto * exchange = dynamic_cast(storage.get()); + ASSERT_NE(exchange, nullptr); + auto offer = exchange->getRelinkOffer("a11/a11a11a1-1111-4111-8111-111111111111/all_1_1_0"); + ASSERT_TRUE(offer.has_value()); + + const auto receiver_ns = storage->liveNamespace("a22a22a2-2222-4222-8222-222222222222"); + std::unique_ptr prepared; + ASSERT_EQ(exchange->prepareAdoptFromManifest(kReceiverTmpFetchPath, offer->manifest_bytes, prepared), + DB::CaRelinkPrepare::Prepared); + ASSERT_NE(prepared, nullptr); + + EXPECT_FALSE(storage->store()->resolveRef(receiver_ns, "tmp-fetch_all_1_1_0").has_value()) + << "prepare must not commit the ref -- the source has not been asked anything yet"; + EXPECT_EQ(storage->store()->livePrecommitsForTest(receiver_ns).size(), 1u) + << "prepare must leave the receiver's +1 durable, or a later confirm proves nothing"; + + EXPECT_EQ(prepared->promote(), DB::CaRelinkPromote::Committed); + EXPECT_TRUE(storage->store()->resolveRef(receiver_ns, "tmp-fetch_all_1_1_0").has_value()); + EXPECT_TRUE(storage->store()->livePrecommitsForTest(receiver_ns).empty()) + << "promote moves the binding out of the precommit view"; +} + +/// The unproven-source branch of the taxonomy (row 3), at the storage seam: `abort` releases the durable +/// `+1` and publishes nothing. A leaked same-epoch precommit is reclaimed by nothing -- not the +/// prior-epoch stale sweep, not GC -- so this removal is the ONLY thing standing between an unproven +/// confirm and permanently retained blobs. +TEST(CASWiringExchange, AbortedPrepareReleasesThePrecommitAndPublishesNothing) +{ + auto storage = openWiringStorage(); + const auto sender_ns = storage->liveNamespace("a11a11a1-1111-4111-8111-111111111111"); + publishWiredPart(*storage, sender_ns, "all_1_1_0"); + + auto * exchange = dynamic_cast(storage.get()); + ASSERT_NE(exchange, nullptr); + auto offer = exchange->getRelinkOffer("a11/a11a11a1-1111-4111-8111-111111111111/all_1_1_0"); + ASSERT_TRUE(offer.has_value()); + + const auto receiver_ns = storage->liveNamespace("a22a22a2-2222-4222-8222-222222222222"); + std::unique_ptr prepared; + ASSERT_EQ(exchange->prepareAdoptFromManifest(kReceiverTmpFetchPath, offer->manifest_bytes, prepared), + DB::CaRelinkPrepare::Prepared); + ASSERT_NE(prepared, nullptr); + ASSERT_EQ(storage->store()->livePrecommitsForTest(receiver_ns).size(), 1u); + + prepared->abort(); + EXPECT_TRUE(storage->store()->livePrecommitsForTest(receiver_ns).empty()) + << "abort must append the exact precommit removal, not merely drop the transaction"; + EXPECT_FALSE(storage->store()->resolveRef(receiver_ns, "tmp-fetch_all_1_1_0").has_value()) + << "an aborted relink must leave no committed ref behind"; + + /// A second `abort` -- what the scope guard does after an explicit one -- must be a silent no-op + /// rather than an error, and destruction of an aborted handle must not re-drive anything. + prepared->abort(); + prepared.reset(); + EXPECT_TRUE(storage->store()->livePrecommitsForTest(receiver_ns).empty()); +} + +/// The `MechanismFallbackAllowed` branch (taxonomy row 2): an undecodable manifest is a mechanism +/// failure, not a source failure -- the sender still has the part, so the receiver byte-fetches. Nothing +/// may be staged, because there is no handle to abort it with. +TEST(CASWiringExchange, PrepareAdoptOfAnUndecodableManifestAllowsTheByteFallback) +{ + auto storage = openWiringStorage(); + auto * exchange = dynamic_cast(storage.get()); + ASSERT_NE(exchange, nullptr); + + const auto receiver_ns = storage->liveNamespace("a22a22a2-2222-4222-8222-222222222222"); + std::unique_ptr prepared; + EXPECT_EQ(exchange->prepareAdoptFromManifest(kReceiverTmpFetchPath, "not a manifest at all", prepared), + DB::CaRelinkPrepare::MechanismFallbackAllowed); + EXPECT_EQ(prepared, nullptr) << "no handle may be returned when nothing was staged"; + EXPECT_TRUE(storage->store()->livePrecommitsForTest(receiver_ns).empty()); + EXPECT_FALSE(storage->store()->resolveRef(receiver_ns, "tmp-fetch_all_1_1_0").has_value()); +} + +/// B66b: a relink whose TARGET is a DETACHED part dir -- what `FETCH PARTITION ... TO detached` now +/// does instead of streaming bytes. Nothing about the detached case is special-cased on the receiver: +/// `Fetcher::relinkPartToDisk` hands over the staging path under the `detached/` parent and the router +/// folds it onto a `detached/`-prefixed ref in the table's OWN namespace, exactly as every other read +/// and write of a detached part is routed. +/// +/// The load-bearing assertion is the NEGATIVE one. A detached fetch must publish a detached ref and +/// nothing else: a live ref of the same name would make an un-attached part visible to the table, which +/// is the one way a detached target could differ from the active one in a way that matters. +TEST(CASWiringExchange, AdoptIntoADetachedTargetPublishesADetachedRefAndNoLiveRef) +{ + auto storage = openWiringStorage(); + const auto sender_ns = storage->liveNamespace("a11a11a1-1111-4111-8111-111111111111"); + publishWiredPart(*storage, sender_ns, "all_1_1_0"); + + auto * exchange = dynamic_cast(storage.get()); + ASSERT_NE(exchange, nullptr); + auto offer = exchange->getRelinkOffer("a11/a11a11a1-1111-4111-8111-111111111111/all_1_1_0"); + ASSERT_TRUE(offer.has_value()); + + /// The receiver's staging path under the detached parent -- the path `relinkPartToDisk` composes + /// with `to_detached`, and the same one `downloadPartToDisk` would have written bytes into. + const String detached_tmp_path + = "a22/a22a22a2-2222-4222-8222-222222222222/detached/tmp-fetch_all_1_1_0"; + EXPECT_TRUE(adoptPartFromManifestAndPromote(*exchange, detached_tmp_path, offer->manifest_bytes)); + + const auto receiver_ns = storage->liveNamespace("a22a22a2-2222-4222-8222-222222222222"); + EXPECT_TRUE(storage->store()->resolveRef(receiver_ns, "detached/tmp-fetch_all_1_1_0").has_value()) + << "the detached target must publish the `detached/`-prefixed ref in the table's own namespace"; + EXPECT_FALSE(storage->store()->resolveRef(receiver_ns, "tmp-fetch_all_1_1_0").has_value()) + << "a detached fetch must NOT publish a live ref of the same name"; + + /// The adopted part reads back through the ordinary path surface, blobs and per-part files alike -- + /// no bytes were transferred for any of them. + EXPECT_TRUE(storage->existsFile(detached_tmp_path + "/data.bin")); + EXPECT_TRUE(storage->existsFile(detached_tmp_path + "/p.proj/data.bin")); + EXPECT_TRUE(storage->existsFile(detached_tmp_path + "/uuid.txt")); + + /// Finalization, unchanged by this task: `IMergeTreeDataPart::renameTo(detached/)` is a + /// moveDirectory of the staged dir to its final detached name, which on a content-addressed disk is + /// a ref repoint WITHIN the same namespace -- the same shape the active path's + /// `renameTempPartAndReplace` uses, and the reason the relinked detached part needs no new + /// finalization of its own. + { + auto tx = storage->createTransaction(); + tx->moveDirectory(detached_tmp_path, "a22/a22a22a2-2222-4222-8222-222222222222/detached/all_1_1_0"); + tx->commit(DB::NoCommitOptions{}); + } + EXPECT_TRUE(storage->existsFile( + "a22/a22a22a2-2222-4222-8222-222222222222/detached/all_1_1_0/data.bin")); + EXPECT_FALSE(storage->store()->resolveRef(receiver_ns, "detached/tmp-fetch_all_1_1_0").has_value()); + EXPECT_EQ(storage->detachedRefNames(receiver_ns), (std::vector{"detached/all_1_1_0"})); + EXPECT_FALSE(storage->store()->resolveRef(receiver_ns, "all_1_1_0").has_value()) + << "the detached finalization must stay inside the `detached/` ref space"; +} + +/// A relink target that is not a part DIRECTORY is a caller bug, and it must be loud rather than +/// answered with `MechanismFallbackAllowed`: the byte fetch that a fallback invites would write to the +/// same wrong place. The table dir stands in for the whole class (a file inside a part, a FREEZE shadow +/// path, a bare `detached` container) -- all of them route to something that is not a part ref. +/// +/// The refusal throws LOGICAL_ERROR, which aborts the whole process in debug/sanitizer builds +/// (Exception.cpp's handle_error_code) instead of behaving like a catchable exception -- so the +/// EXPECT_THROW form only makes sense in a plain release build, and CASWiringExchangeDeathTest below +/// proves the SAME refusals positively abort under debug/sanitizer builds instead (same pattern as +/// CASWiringOpsDeathTest above). +#ifndef DEBUG_OR_SANITIZER_BUILD +TEST(CASWiringExchange, PrepareAdoptRefusesATargetThatIsNotAPartDirectory) +{ + auto storage = openWiringStorage(); + auto * exchange = dynamic_cast(storage.get()); + ASSERT_NE(exchange, nullptr); + + std::unique_ptr prepared; + EXPECT_THROW(exchange->prepareAdoptFromManifest( + "a22/a22a22a2-2222-4222-8222-222222222222", std::string{}, prepared), + DB::Exception); + EXPECT_THROW(exchange->prepareAdoptFromManifest( + "a22/a22a22a2-2222-4222-8222-222222222222/tmp-fetch_all_1_1_0/data.bin", + std::string{}, prepared), + DB::Exception); + EXPECT_THROW(exchange->prepareAdoptFromManifest( + "shadow/bk1/store/a22/a22a22a2-2222-4222-8222-222222222222/all_1_1_0", + std::string{}, prepared), + DB::Exception); + EXPECT_EQ(prepared, nullptr); +} +#else +TEST(CASWiringExchangeDeathTest, PrepareAdoptRefusesATargetThatIsNotAPartDirectoryAborts) +{ + auto storage = openWiringStorage(); + auto * exchange = dynamic_cast(storage.get()); + ASSERT_NE(exchange, nullptr); + + std::unique_ptr prepared; + EXPECT_DEATH(exchange->prepareAdoptFromManifest( + "a22/a22a22a2-2222-4222-8222-222222222222", std::string{}, prepared), + "does not address a content-addressed part directory"); + EXPECT_DEATH(exchange->prepareAdoptFromManifest( + "a22/a22a22a2-2222-4222-8222-222222222222/tmp-fetch_all_1_1_0/data.bin", + std::string{}, prepared), + "does not address a content-addressed part directory"); + EXPECT_DEATH(exchange->prepareAdoptFromManifest( + "shadow/bk1/store/a22/a22a22a2-2222-4222-8222-222222222222/all_1_1_0", + std::string{}, prepared), + "does not address a content-addressed part directory"); + EXPECT_EQ(prepared, nullptr); +} +#endif + +/// ==== Commit atomicity (B122): a publish failing mid-loop must not leave a PARTIAL commit ==== + +#include +#include +#include +#include + +namespace DB::ErrorCodes +{ + extern const int LOGICAL_ERROR; + extern const int CORRUPTED_DATA; + extern const int READONLY; +} + +namespace +{ + +/// A LocalObjectStorage whose writeObject can be armed to throw — the single seam needed to drive a +/// backend write failure at a chosen point. The hook runs BEFORE the write is created; throwing from +/// it fails the put exactly as a real backend error would. Everything else delegates to the base. +class FaultyLocalObjectStorage : public DB::LocalObjectStorage +{ +public: + using DB::LocalObjectStorage::LocalObjectStorage; + + std::function on_write; + + std::unique_ptr writeObject( + const DB::StoredObject & object, + DB::WriteMode mode, + std::optional attributes, + size_t buf_size, + const DB::WriteSettings & write_settings) override + { + if (on_write) + on_write(object.remote_path); + return DB::LocalObjectStorage::writeObject(object, mode, attributes, buf_size, write_settings); + } +}; + +/// True for a per-part manifest BODY object (<...>/cas/manifests///.zst) +/// — the FIRST durable object `publishStaging` writes for a part (via `PartWriteTxn::stageManifest`). Since Task B +/// (chaos-tolerance-report) that write rides the CAS request controller: a transient fault is retried +/// (budgeted attempts + resolve-before-reissue), so an injected fault must be PERSISTENT to fail the +/// publish — the controller exhausts its budget and `stageManifest` throws ABORTED out of `publishStaging`. +/// Exactly one body per part (retries re-PUT the same per-part key), so counting FIRST attempts isolates +/// part publishes one-for-one. Ref-log txns (`cas/ns/stream/.../_log/...`), tree blobs (`blobs/`), GC state +/// (`gc/`) and verbatim files are excluded. +/// +/// The suffix is taken from `storedSuffix(FormatId::PartManifest)` (the registered v3 stored suffix, now +/// `.zst`) rather than hard-coded: codecs-v3 phase-3 made the part manifest an Always-compressed text +/// object, changing the body key from the pre-v3 `.proto` to `.zst`. The old hard-coded +/// `.ends_with(".proto")` stopped matching after that cutover, so the fault never fired and this +/// (test-local) predicate silently no-op'd — the same failure mode this comment already recorded for the +/// earlier `RootShardManifest` removal (commit `318291fe5e5`, whose all-digits key stopped matching). +/// Sourcing the suffix from the format registry keeps the predicate correct across future +/// compression-policy changes. +bool isPartManifestBodyPath(const std::string & path) +{ + return path.find("/cas/manifests/") != std::string::npos + && path.ends_with(DB::Cas::storedSuffix(DB::Cas::FormatId::PartManifest)); +} + +std::shared_ptr makeFaultyStorageForTest() +{ + static std::atomic counter{0}; + const auto unique = std::to_string(::getpid()) + "_" + std::to_string(counter.fetch_add(1)); + const auto root = (std::filesystem::temp_directory_path() / ("ca_b122_" + unique)).string(); + std::error_code ec; + std::filesystem::remove_all(root, ec); + std::filesystem::create_directories(root, ec); + return std::make_shared(DB::LocalObjectStorageSettings("test", root, /*read_only_=*/false)); +} + +} + +TEST(CASWiringWrite, PartialCommitRollsBackPublishedParts) +{ + auto faulty = makeFaultyStorageForTest(); + auto settings = DB::Cas::tests::makeSettingsForTest( + "test", std::filesystem::temp_directory_path() / "ca_b122_scratch"); + auto storage = std::make_shared( + faulty, "pool", "srv1", "", nullptr, settings); + storage->startup(); + /// The manifest-body PUT rides the CAS request controller, whose inter-attempt backoff would + /// otherwise serve the REAL capped-exponential sleeps (~56s at the default budget) while the + /// persistent injected fault exhausts the whole attempt budget. Neutralize only the sleeps — the + /// retry/exhaustion/rollback semantics under test are unchanged. + storage->store()->setCasRetrySleepForTest([](uint64_t) {}); + + /// Two parts in ONE transaction, published sequentially at commit (the staging map orders all_1_1_0 + /// before all_2_2_0). writeThroughTransaction only STAGES to local temp files here — the pool writes + /// (manifest bodies, blob uploads, ref-log promotes) all happen later, inside commit's publishStaging. + auto tx = storage->createTransaction(); + writeThroughTransaction(*tx, "a11/a11a11a1-1111-4111-8111-111111111111/all_1_1_0/data.bin", "content-A"); + writeThroughTransaction(*tx, "a11/a11a11a1-1111-4111-8111-111111111111/all_2_2_0/data.bin", "content-B"); + + /// Fail the SECOND part's manifest-body write (all_2_2_0's stageManifest) — by then all_1_1_0 has + /// fully published (its manifest body + blob + promoted ref). A pre-B122 commit() would leave + /// all_1_1_0 durably visible: a partial commit. PERSISTENT (`>= 2`, not one-shot): the manifest + /// body PUT rides the CAS request controller (Task B), which absorbs a transient fault by design — + /// only a fault that outlasts the whole attempt budget fails the publish (as ABORTED). + /// CORRUPTED_DATA (not LOGICAL_ERROR): `handle_error_code` (Exception.cpp) aborts the whole + /// process for LOGICAL_ERROR under debug/sanitizer builds, since that code means "an internal + /// invariant broke" there -- but this is a simulated BACKEND write failure, not an invariant + /// violation, so it must stay a catchable exception. CORRUPTED_DATA keeps the exact same + /// `isDeterministicLocalFailure` classification LOGICAL_ERROR had (CasRequestControl.cpp), so the + /// controller's retry/exhaustion behavior under test is unchanged. + int manifest_writes = 0; + faulty->on_write = [&](const std::string & path) + { + if (isPartManifestBodyPath(path) && ++manifest_writes >= 2) + throw DB::Exception(DB::ErrorCodes::CORRUPTED_DATA, "injected publish failure (B122)"); + }; + + EXPECT_THROW(tx->commit(DB::NoCommitOptions{}), DB::Exception); + + /// All-or-nothing: the part that DID publish must have been rolled back (commit's compensating + /// `dropRefIfMatches`, keyed on the exact `CommitOutcome` `all_1_1_0`'s own publish produced). + /// Disarm first so the read-back assertions run clean — the rollback itself only writes ref-log + /// ops, never a manifest body, so it does not re-trip the count-2 fault. + faulty->on_write = nullptr; + EXPECT_FALSE(storage->existsDirectory("a11/a11a11a1-1111-4111-8111-111111111111/all_1_1_0")); + EXPECT_FALSE(storage->existsDirectory("a11/a11a11a1-1111-4111-8111-111111111111/all_2_2_0")); +} + +TEST(CASWiringReadOnly, ObserveOnlyOpenReadsButRejectsWrites) +{ + /// 1. Writable storage publishes a part into a fixed root. + const auto root = (std::filesystem::temp_directory_path() + / ("ca_ro_" + std::to_string(::getpid()))).string(); + std::error_code ec; std::filesystem::remove_all(root, ec); std::filesystem::create_directories(root, ec); + auto writable_os = std::make_shared( + DB::LocalObjectStorageSettings("test", root, /*read_only_=*/false)); + { + auto w_settings = DB::Cas::tests::makeSettingsForTest( + "test", std::filesystem::temp_directory_path() / "ca_ro_scratch"); + auto w = std::make_shared( + writable_os, "pool", "srv1", "", nullptr, w_settings); + w->startup(); + auto tx = w->createTransaction(); + writeThroughTransaction(*tx, "a11/a11a11a1-1111-4111-8111-111111111111/all_1_1_0/data.bin", "ro-bytes"); + tx->commit(DB::NoCommitOptions{}); + } + + /// 2. Read-only object storage over the SAME root => observe-only metadata storage. + auto ro_os = std::make_shared( + DB::LocalObjectStorageSettings("test", root, /*read_only_=*/true)); + /// Same `server_root_id` as the writer: live namespaces are rooted by configured layout identity, so an + /// observe-only mount reads the same server-root's data — the WORM scenario. + auto ro_settings = DB::Cas::tests::makeSettingsForTest( + "test", std::filesystem::temp_directory_path() / "ca_ro_scratch2"); + auto ro = std::make_shared( + ro_os, "pool", "srv1", "", nullptr, ro_settings); + ro->startup(); /// must NOT throw (probe skipped — a probe write would fail on a read-only os) + + EXPECT_TRUE(ro->isReadOnly()); + /// Reads work: + EXPECT_TRUE(ro->existsFile("a11/a11a11a1-1111-4111-8111-111111111111/all_1_1_0/data.bin")); + EXPECT_EQ(ro->getFileSize("a11/a11a11a1-1111-4111-8111-111111111111/all_1_1_0/data.bin"), 8u); + /// Writes fail closed: + DB::Cas::tests::expectThrowsCode(DB::ErrorCodes::READONLY, + [&] { ro->createTransaction(); }); + DB::Cas::tests::expectThrowsCode(DB::ErrorCodes::READONLY, + [&] + { + std::unique_ptr prepared; + ro->prepareAdoptFromManifest("a11/a11a11a1-1111-4111-8111-111111111111/tmp-fetch", std::string{}, prepared); + }); +} + +TEST(CASWiringRead, UnsetPublishedAtMsReturnsEpoch) +{ + /// A ref published without a stamp (published_at_ms == 0, the default) must return the epoch + /// (Poco::Timestamp(0)) rather than throwing: stamps only feed cleanup TTLs and system tables, + /// so a missing stamp is harmless. + auto storage = openWiringStorage(); + auto tx = storage->createTransaction(); + writeThroughTransaction(*tx, "a11/a11a11a1-1111-4111-8111-111111111111/all_1_1_0/data.bin", "x"); + tx->commit(DB::NoCommitOptions{}); + + /// Ensure published_at_ms is unset (the default is 0). + storage->store()->updateRefPublishedAt(storage->liveNamespace("a11a11a1-1111-4111-8111-111111111111"), "all_1_1_0", + [](DB::Cas::RefPublishedAtUpdate & r) { r.published_at_ms = 0; }); + + EXPECT_EQ(storage->getLastModified("a11/a11a11a1-1111-4111-8111-111111111111/all_1_1_0").epochTime(), 0); +} + +/// ==== B188 precommit-first order invariant (Task 6) ==== +/// +/// A RecordingLocalObjectStorage records the four IObjectStorage methods the CA emulated-mode backend +/// uses on the commit path — writeObject (PUT), exists + getObjectMetadata (the HEAD), and readObject +/// (the GET) — as (op_name, logical_key). "Logical" means the bare pool key (without the emu_root +/// prefix) — the same string the Layout functions produce, so the `/blobs/`, `/trees/`, and opaque +/// ref-stream (`/cas/ns/stream//`) substring tests are unambiguous. +/// +/// After commit the test asserts: the FIRST write that appends the create-precommit owner event (the +/// first durable CAS to the target ROOT SHARD's key — owner_kind == Precommit; the converged rev. 15 +/// model has NO `_precommits` namespace, the precommit binding lives in the target shard's journal) +/// happened before ALL ops (read OR write) on keys containing "/blobs/" or "/trees/". The precommit +/// owner record is what pins the in-flight build-root closure so GC cannot reclaim the not-yet-uploaded +/// content objects; therefore every pool op touching a content blob or the manifest tree must be AFTER +/// the precommit owner record is durably written. The READ gating is the heart of the B188 fix: the +/// original bug was an EAGER HEAD on a content blob during staging, before any precommit protection +/// existed — a write-only assertion would not catch its reintroduction. + +namespace +{ + +/// Records the four IObjectStorage methods the CA emulated-mode backend uses on the commit path +/// (writeObject/exists/getObjectMetadata/readObject). listObjects/copyObject are deliberately NOT +/// overridden — they are not on the commit path the order invariant gates. +class RecordingLocalObjectStorage final : public DB::LocalObjectStorage +{ +public: + using DB::LocalObjectStorage::LocalObjectStorage; + + struct Record + { + std::string op; /// "writeObject" | "exists" | "getObjectMetadata" | "readObject" + std::string key; /// logical (emu_root stripped) + }; + + /// Append-only; mutable so the const read methods (exists/readObject/tryGetObjectMetadata) can + /// record. No mutex — these tests are single-threaded. + mutable std::vector ops; + + /// Strip the common-key-prefix (emu_root) to recover the logical key. The emu_root is returned by + /// getCommonKeyPrefix() and always ends with a path separator in LocalObjectStorage. + std::string toLogical(const std::string & physical) const + { + const std::string root = getCommonKeyPrefix(); + std::string logical; + if (!root.empty() && physical.starts_with(root)) + logical = physical.substr(root.size()); + else + logical = physical; + /// Strip any leading slash left after prefix removal. + if (!logical.empty() && logical.front() == '/') + logical = logical.substr(1); + return logical; + } + + std::unique_ptr writeObject( + const DB::StoredObject & object, + DB::WriteMode mode, + std::optional attributes, + size_t buf_size, + const DB::WriteSettings & write_settings) override + { + ops.push_back({"writeObject", toLogical(object.remote_path)}); + return DB::LocalObjectStorage::writeObject(object, mode, attributes, buf_size, write_settings); + } + + /// Backs the CA backend's `head` (emuExists) and gates its `get` (emuExists before emuRead). + bool exists(const DB::StoredObject & object) const override + { + ops.push_back({"exists", toLogical(object.remote_path)}); + return DB::LocalObjectStorage::exists(object); + } + + /// Backs the CA backend's `head` size/attributes lookup (emuPath stat). + std::optional tryGetObjectMetadata(const std::string & path, bool with_tags) const override + { + ops.push_back({"getObjectMetadata", toLogical(path)}); + return DB::LocalObjectStorage::tryGetObjectMetadata(path, with_tags); + } + + /// Backs the CA backend's `get` body read (readObjectRanged). + std::unique_ptr readObject( + const DB::StoredObject & object, + const DB::ReadSettings & read_settings, + std::optional read_hint, + bool use_external_buffer, + bool restrict_seek) const override + { + ops.push_back({"readObject", toLogical(object.remote_path)}); + return DB::LocalObjectStorage::readObject(object, read_settings, read_hint, use_external_buffer, restrict_seek); + } +}; + +std::shared_ptr makeRecordingStorageForTest(const std::string & tag) +{ + static std::atomic counter{0}; + const auto unique = std::to_string(::getpid()) + "_" + std::to_string(counter.fetch_add(1)); + const auto root = (std::filesystem::temp_directory_path() / ("ca_b188_" + tag + "_" + unique)).string(); + std::error_code ec; + std::filesystem::remove_all(root, ec); + std::filesystem::create_directories(root, ec); + return std::make_shared( + DB::LocalObjectStorageSettings("test", root, /*read_only_=*/false)); +} + +/// True for a durable ref-object write key under `/cas/ns/stream/`. In the snapshot+log ref model the +/// writer's first durable ref write on the precommit path is an immutable transaction-log object +/// (`<...>/cas/ns/stream//_log/.zst`); a published table snapshot is +/// `<...>/_snap/.zst`. The predicate anchors on whichever durable ref write comes first. It +/// excludes blobs (`/blobs/`), part-manifests (`/cas/manifests/...`), GC state (`/gc/`), and verbatim +/// files (`/_files/...`). +bool isRefWriteKey(const std::string & key) +{ + if (key.find("/cas/ns/stream/") == std::string::npos) + return false; + return key.find("/_log/") != std::string::npos || key.find("/_snap/") != std::string::npos; +} + +/// Index of the first writeObject that durably appends the create-precommit ref transaction — i.e. the +/// first durable write (writeObject) of a ref-object key (a `_log/` object in the snapshot+log +/// model). Anchors on the WRITE, not on any op: recovery READS the ref prefix before the durable write, +/// so an any-op scan would anchor on that READ rather than the durable write. Returns -1 if no ref write +/// was recorded. +int firstPrecommitWriteIdx(const std::vector & log) +{ + for (int i = 0; i < static_cast(log.size()); ++i) + if (log[i].op == "writeObject" && isRefWriteKey(log[i].key)) + return i; + return -1; +} + +} + +/// B188: every pool op (read OR write) on /blobs/ or /trees/ must come AFTER the first write that +/// appends the create-precommit owner event (the first root-shard CAS) — including HEAD +/// (exists/getObjectMetadata) and GET (readObject), since the +/// exact bug was an eager HEAD on a content blob during staging. The transaction writes a fresh +/// content file (pending blob) AND adopts an existing committed blob via hardlink — both paths must +/// satisfy the invariant. +TEST(CASWiringPrecommitOrder, NoContentPoolOpBeforePrecommit) +{ + auto recording = makeRecordingStorageForTest("order"); + auto settings = DB::Cas::tests::makeSettingsForTest( + "test", std::filesystem::temp_directory_path() / "ca_b188_order_scratch"); + auto storage = std::make_shared( + recording, "pool", "srv1", "", nullptr, settings); + storage->startup(); + + /// Phase 1: publish a committed source part — this gives us a committed blob to adopt in Phase 2. + { + auto tx = storage->createTransaction(); + writeThroughTransaction(*tx, "a11/a11a11a1-1111-4111-8111-111111111111/all_0_0_0/data.bin", "source-blob"); + tx->commit(DB::NoCommitOptions{}); + } + + /// Phase 2: a new transaction that BOTH writes a fresh content blob (all_1_1_0/data.bin, pending) + /// AND carries forward that PENDING blob via hardlink into a second fresh part (all_2_2_0/extra.bin, + /// the cross-part pending-source adopt path). We clear the op log after Phase 1 so only Phase 2's + /// ops are analysed. + recording->ops.clear(); + + auto tx = storage->createTransaction(); + writeThroughTransaction(*tx, "a11/a11a11a1-1111-4111-8111-111111111111/all_1_1_0/data.bin", "fresh-content"); + /// Adopt by hardlinking a PENDING blob (the file just written above) into a SECOND fresh part + /// (all_2_2_0). This is the B188-relevant adopt: the cross-part pending-source branch copies the + /// PendingBlob into the dst build (NO eager pool op — the blob is not durable yet, so a HEAD/GET on + /// it before precommit would be the exact bug). We deliberately do NOT adopt from the committed + /// source part here: adoptFromTree(committed source) legitimately READS that source's + /// already-durable, ref-pinned tree during staging — a foreign-tree read that is NOT a B188 + /// violation (the invariant is about THIS build's own not-yet-uploaded content, never a committed + /// object owned by a live part). Gating it would be a false positive; see the committed-source + /// adopt coverage in CASWiringOps.HardLinkCarriesForwardWithoutReupload. + tx->createHardLink("a11/a11a11a1-1111-4111-8111-111111111111/all_1_1_0/data.bin", "a11/a11a11a1-1111-4111-8111-111111111111/all_2_2_0/extra.bin"); + tx->commit(DB::NoCommitOptions{}); + + const auto & log = recording->ops; + + /// The content objects THIS transaction publishes are exactly the BLOB keys it WRITES under + /// /blobs/ (the fresh/pending content blobs). The B188 invariant is that the build must not touch + /// ITS OWN not-yet-protected content before precommit. NOTE (rev. 15 manifest model): the staged + /// part-manifest body (`/_manifests/...`) is the precommit's EVIDENCE and is therefore written + /// BEFORE precommitAdd by design (stageManifest → precommitAdd → putBlob → promote) — it is NOT a + /// gated content object. Only the content BLOBS must wait for the precommit. Reads of foreign + /// committed objects (another part's blob) are legitimate and must not be gated — so we restrict + /// the gate to the set of /blobs/ keys this transaction itself wrote. + std::set own_content_keys; + for (const auto & r : log) + if (r.op == "writeObject" && r.key.find("/blobs/") != std::string::npos) + own_content_keys.insert(r.key); + + /// Anchor on the first precommit WRITE (the durable casPut), not on any precommit-key op. + const int first_precommit_idx = firstPrecommitWriteIdx(log); + ASSERT_GE(first_precommit_idx, 0) + << "No create-precommit owner write (root-shard CAS) was recorded — precommit step did not fire"; + + /// Every op (read OR write) on one of THIS build's own content blobs must have an index AFTER + /// first_precommit_idx. This gates HEAD (exists/getObjectMetadata) and GET (readObject), not just + /// PUT (writeObject) — an eager HEAD/GET on the build's own pending blob before precommit is the + /// exact B188 regression this guards against. + for (int i = 0; i < static_cast(log.size()); ++i) + { + if (!own_content_keys.contains(log[i].key)) + continue; + EXPECT_GT(i, first_precommit_idx) + << "Own-content pool op '" << log[i].op << "' on '" << log[i].key << "' at index " << i + << " came BEFORE the first precommit write at index " << first_precommit_idx + << " — violates B188 precommit-first invariant (no HEAD/GET/PUT on this build's content before precommit)"; + } + + /// Sanity: both parts are readable after commit, with the SAME underlying blob (content identity). + EXPECT_TRUE(storage->existsFile("a11/a11a11a1-1111-4111-8111-111111111111/all_1_1_0/data.bin")); + EXPECT_TRUE(storage->existsFile("a11/a11a11a1-1111-4111-8111-111111111111/all_2_2_0/extra.bin")); + EXPECT_EQ(storage->getFileSize("a11/a11a11a1-1111-4111-8111-111111111111/all_1_1_0/data.bin"), 13u); /// "fresh-content" + EXPECT_EQ(storage->getStorageObjects("a11/a11a11a1-1111-4111-8111-111111111111/all_1_1_0/data.bin")[0].remote_path, + storage->getStorageObjects("a11/a11a11a1-1111-4111-8111-111111111111/all_2_2_0/extra.bin")[0].remote_path); + + /// Confirm at least one blob WRITE and one staged-manifest WRITE were recorded (both the upload + /// path and the manifest-evidence path were exercised), so the gate above actually had content + /// keys to check and the precommit anchored on a real build. + const bool has_blob_write = std::any_of(log.begin(), log.end(), + [](const RecordingLocalObjectStorage::Record & r) + { return r.op == "writeObject" && r.key.find("/blobs/") != std::string::npos; }); + const bool has_tree_write = std::any_of(log.begin(), log.end(), + [](const RecordingLocalObjectStorage::Record & r) + { return r.op == "writeObject" && r.key.find("/cas/manifests/") != std::string::npos; }); + EXPECT_TRUE(has_blob_write) << "No /blobs/ write recorded — fresh blob path not exercised"; + EXPECT_TRUE(has_tree_write) << "No /cas/manifests/ write recorded — manifest staging path not exercised"; + EXPECT_FALSE(own_content_keys.empty()) << "No own content keys collected — gate would be vacuous"; +} + +/// B188 committed-source adopt (the LITERAL bug path): when createHardLink carries forward a blob +/// from a COMMITTED source part (the source is NOT staged in this transaction), it takes the +/// adoptFromTree -> adoptEvidence branch — a TOKENLESS W-EVIDENCE dep with NO eager HEAD on the +/// adopted blob. The regression this guards is reverting adoptEvidence to a reuseBlob(false) (or any +/// observeAndAdmit) that HEADs the adopted blob during staging, before any precommit protection +/// exists. The own-content gate in NoContentPoolOpBeforePrecommit CANNOT catch this: the adopted blob +/// is FOREIGN (owned by the live source part, never written by this transaction), so it is absent from +/// own_content_keys. This test asserts a TARGETED invariant on that exact foreign blob key: no +/// exists/getObjectMetadata/readObject/writeObject on it before first_precommit_idx. +/// +/// adoptFromTree legitimately READS the source TREE during staging (to find the entry) — that is fine +/// and is NOT asserted here; the assertion is scoped to the adopted BLOB key alone. +TEST(CASWiringPrecommitOrder, CommittedSourceAdoptNoHeadBeforePrecommit) +{ + auto recording = makeRecordingStorageForTest("committed_adopt"); + auto settings = DB::Cas::tests::makeSettingsForTest( + "test", std::filesystem::temp_directory_path() / "ca_b188_committed_adopt_scratch"); + auto storage = std::make_shared( + recording, "pool", "srv1", "", nullptr, settings); + storage->startup(); + + /// Phase 1: commit a source part with a content blob. Capture the source blob's logical key from + /// the recorded /blobs/ write (the SAME key derivation the recorder uses, so the substring/index + /// comparisons in Phase 2 line up exactly). + recording->ops.clear(); + { + auto tx = storage->createTransaction(); + writeThroughTransaction(*tx, "a11/a11a11a1-1111-4111-8111-111111111111/all_0_0_0/data.bin", "committed-source-blob"); + tx->commit(DB::NoCommitOptions{}); + } + std::string source_blob_key; + for (const auto & r : recording->ops) + { + if (r.op == "writeObject" && r.key.find("/blobs/") != std::string::npos) + { + source_blob_key = r.key; + break; + } + } + ASSERT_FALSE(source_blob_key.empty()) + << "Phase 1 recorded no /blobs/ write — could not capture the committed-source blob key"; + + /// Phase 2: a FRESH transaction that hardlinks the COMMITTED source blob into a NEW part. The + /// source part (all_0_0_0) is not staged here, so createHardLink takes the committed-source branch + /// (adoptFromTree -> adoptEvidence). Clear the log so only Phase 2's ops are analysed. + recording->ops.clear(); + { + auto tx = storage->createTransaction(); + tx->createHardLink("a11/a11a11a1-1111-4111-8111-111111111111/all_0_0_0/data.bin", "a11/a11a11a1-1111-4111-8111-111111111111/all_5_5_0/data.bin"); + tx->commit(DB::NoCommitOptions{}); + } + + const auto & log = recording->ops; + + /// Anchor on the first precommit WRITE (the durable casPut), not on any precommit-key op. + const int first_precommit_idx = firstPrecommitWriteIdx(log); + ASSERT_GE(first_precommit_idx, 0) + << "No create-precommit owner write (root-shard CAS) was recorded — precommit step did not fire"; + + /// TARGETED assertion: the adopted (foreign, committed) blob key must NOT be touched by ANY op + /// (HEAD via exists/getObjectMetadata, GET via readObject, or PUT via writeObject) before the + /// precommit write. With the bug reintroduced, adoptEvidence -> reuseBlob -> observeAndAdmit would + /// HEAD this exact key during staging at an index < first_precommit_idx, failing here. + bool adopted_blob_touched_before_precommit = false; + for (int i = 0; i < first_precommit_idx; ++i) + { + if (log[i].key == source_blob_key) + { + adopted_blob_touched_before_precommit = true; + ADD_FAILURE() + << "Adopted committed-source blob op '" << log[i].op << "' on '" << log[i].key + << "' at index " << i << " came BEFORE the first precommit write at index " + << first_precommit_idx << " — violates B188 (committed-source adopt must not HEAD/GET/" + << "PUT the adopted blob before precommit; expected a tokenless adoptEvidence dep)"; + } + } + EXPECT_FALSE(adopted_blob_touched_before_precommit); + + /// The committed-source adopt also must NOT re-upload the blob at all (content carried forward by + /// reference): no writeObject on the source blob key in Phase 2. + const bool reuploaded = std::any_of(log.begin(), log.end(), + [&](const RecordingLocalObjectStorage::Record & r) + { return r.op == "writeObject" && r.key == source_blob_key; }); + EXPECT_FALSE(reuploaded) << "Committed-source adopt re-uploaded the blob — should carry by reference"; + + /// Sanity: the new part reads back and shares the source blob object. + EXPECT_TRUE(storage->existsFile("a11/a11a11a1-1111-4111-8111-111111111111/all_5_5_0/data.bin")); + EXPECT_EQ(storage->getStorageObjects("a11/a11a11a1-1111-4111-8111-111111111111/all_0_0_0/data.bin")[0].remote_path, + storage->getStorageObjects("a11/a11a11a1-1111-4111-8111-111111111111/all_5_5_0/data.bin")[0].remote_path); +} + +/// B188 pending-blob hardlink (Task 6 Test 2): within a SINGLE transaction, write a content file +/// into part X (pending blob, not yet uploaded), then createHardLink that SAME file into part Y +/// (the cross-part pending-source branch: `&dst_st != src_st`, copies the PendingBlob record so +/// publishStaging uploads it for the dst part too). After commit both parts must read back the +/// identical content. +TEST(CASWiringPending, HardlinkOfPendingBlobCommitsAndReadsBack) +{ + auto storage = openWiringStorage(); + + auto tx = storage->createTransaction(); + + /// Write fresh content into part X — the blob is PENDING (not uploaded yet, temp-file only). + writeThroughTransaction(*tx, "a11/a11a11a1-1111-4111-8111-111111111111/all_10_10_0/data.bin", "pending-payload"); + + /// Before commit, hardlink part X's file into part Y. At this point: + /// - src_st = staging for all_10_10_0 (exists: contains the pending blob) + /// - dst_st = staging for all_11_11_0 (created fresh here) + /// - &dst_st != src_st => PendingBlob is COPIED into dst_st.pending_blobs + /// - Both builds get recordPendingBlobDep (tokenless dep — no pool op until post-precommit) + tx->createHardLink("a11/a11a11a1-1111-4111-8111-111111111111/all_10_10_0/data.bin", "a11/a11a11a1-1111-4111-8111-111111111111/all_11_11_0/data.bin"); + + /// Nothing visible yet (B188: no uploads before precommit). + EXPECT_FALSE(storage->existsFile("a11/a11a11a1-1111-4111-8111-111111111111/all_10_10_0/data.bin")); + EXPECT_FALSE(storage->existsFile("a11/a11a11a1-1111-4111-8111-111111111111/all_11_11_0/data.bin")); + + tx->commit(DB::NoCommitOptions{}); + + /// Both parts must be visible and carry the same content. + ASSERT_TRUE(storage->existsFile("a11/a11a11a1-1111-4111-8111-111111111111/all_10_10_0/data.bin")); + ASSERT_TRUE(storage->existsFile("a11/a11a11a1-1111-4111-8111-111111111111/all_11_11_0/data.bin")); + + EXPECT_EQ(storage->getFileSize("a11/a11a11a1-1111-4111-8111-111111111111/all_10_10_0/data.bin"), 15u); /// "pending-payload" + EXPECT_EQ(storage->getFileSize("a11/a11a11a1-1111-4111-8111-111111111111/all_11_11_0/data.bin"), 15u); + + /// Both parts must point to the SAME underlying blob object (content-addressed identity). + auto objs_x = storage->getStorageObjects("a11/a11a11a1-1111-4111-8111-111111111111/all_10_10_0/data.bin"); + auto objs_y = storage->getStorageObjects("a11/a11a11a1-1111-4111-8111-111111111111/all_11_11_0/data.bin"); + ASSERT_EQ(objs_x.size(), 1u); + ASSERT_EQ(objs_y.size(), 1u); + EXPECT_EQ(objs_x[0].remote_path, objs_y[0].remote_path) + << "Hardlinked pending blob must map to the SAME pool object in both parts"; +} + +/// ==== B190 Task 4: precommit-first for republishRef and committed-source createHardLink ==== +/// +/// B190-A: republishRef (called by moveDirectory for a COMMITTED part rename — RENAME TABLE, DETACH, +/// ATTACH, delete_tmp_ rename) must carry the source part's BLOBS forward by TOKENLESS W-EVIDENCE +/// (adoptEvidence), NOT by HEAD/GET/PUT on the source blob before precommit. In the rev. 15 manifest +/// model republishRef legitimately READS the FOREIGN source MANIFEST body (to copy its entries into a +/// fresh dst manifest) during staging — that is the manifest-era analog of the old adoptFromTree +/// source-tree read and is NOT a violation (see CommittedSourceAdoptNoHeadBeforePrecommit). The +/// invariant that survives: the source BLOB key must not be touched before the first precommit write. +TEST(CASWiringPrecommitOrder, RepublishRefNoTreeHeadBeforePrecommit) +{ + auto recording = makeRecordingStorageForTest("republish"); + auto settings = DB::Cas::tests::makeSettingsForTest( + "test", std::filesystem::temp_directory_path() / "ca_b190_republish_scratch"); + auto storage = std::make_shared( + recording, "pool", "srv1", "", nullptr, settings); + storage->startup(); + + /// Phase 1: commit a source part. Capture its BLOB key from the /blobs/ write (republishRef must + /// carry this blob by reference, never touching it before precommit). + recording->ops.clear(); + { + auto tx = storage->createTransaction(); + writeThroughTransaction(*tx, "a11/a11a11a1-1111-4111-8111-111111111111/all_1_1_0/data.bin", "republish-source"); + tx->commit(DB::NoCommitOptions{}); + } + std::string source_blob_key; + for (const auto & r : recording->ops) + { + if (r.op == "writeObject" && r.key.find("/blobs/") != std::string::npos) + { + source_blob_key = r.key; + break; + } + } + ASSERT_FALSE(source_blob_key.empty()) + << "Phase 1 recorded no /blobs/ write — could not capture the source blob key"; + + /// Phase 2: a COMMITTED rename (delete_tmp_ pattern) that triggers republishRef. Clear the log + /// so only Phase 2's ops are analysed. + recording->ops.clear(); + { + auto tx = storage->createTransaction(); + tx->moveDirectory("a11/a11a11a1-1111-4111-8111-111111111111/all_1_1_0", "a11/a11a11a1-1111-4111-8111-111111111111/delete_tmp_all_1_1_0"); + tx->commit(DB::NoCommitOptions{}); + } + + const auto & log = recording->ops; + + const int first_precommit_idx = firstPrecommitWriteIdx(log); + ASSERT_GE(first_precommit_idx, 0) + << "No create-precommit owner write (root-shard CAS) was recorded — precommit step did not fire"; + + /// The source BLOB key must NOT be accessed (HEAD via exists/getObjectMetadata, GET via readObject, + /// or PUT via writeObject) before the precommit write. With an eager adopt-by-HEAD on the source + /// blob (the regression), observeAndAdmit HEADs the blob key at an index < first_precommit_idx, + /// failing here. A tokenless adoptEvidence dep touches nothing. + bool blob_touched_before_precommit = false; + for (int i = 0; i < first_precommit_idx; ++i) + { + if (log[i].key == source_blob_key) + { + blob_touched_before_precommit = true; + ADD_FAILURE() + << "republishRef blob op '" << log[i].op << "' on '" << log[i].key + << "' at index " << i << " came BEFORE the first precommit write at index " + << first_precommit_idx << " — violates B190 precommit-first: republishRef must not " + << "HEAD/GET/PUT the source blob before precommit (use tokenless adoptEvidence)"; + } + } + EXPECT_FALSE(blob_touched_before_precommit); + + /// Sanity: the renamed part is visible under the new name and NOT under the old name. + EXPECT_FALSE(storage->existsDirectory("a11/a11a11a1-1111-4111-8111-111111111111/all_1_1_0")); + EXPECT_TRUE(storage->existsDirectory("a11/a11a11a1-1111-4111-8111-111111111111/delete_tmp_all_1_1_0")); + EXPECT_TRUE(storage->existsFile("a11/a11a11a1-1111-4111-8111-111111111111/delete_tmp_all_1_1_0/data.bin")); +} + +/// B190-B: the adoptStagedBlob helper unifies the 6 inline pending/uploaded adopt blocks from +/// createHardLink / moveFile / moveDirectory. The observable invariant: after refactoring, ALL +/// six sites still produce the same result as before — pending blobs are copied (hardlink) or +/// moved (moveFile/moveDirectory), and uploaded blobs are adopted by tokenless evidence. This test +/// exercises the non-trivial CROSS-PART pending path (createHardLink copies; moveFile moves) and +/// verifies both a copy and a move of the SAME pending source produce the correct committed state. +TEST(CASWiringPrecommitOrder, AdoptStagedBlobHelperUnifiesSixSites) +{ + /// Use a recording storage so we can verify no pre-precommit pool ops on own content. + auto recording = makeRecordingStorageForTest("adopt_helper"); + auto settings = DB::Cas::tests::makeSettingsForTest( + "test", std::filesystem::temp_directory_path() / "ca_b190_adopt_scratch"); + auto storage = std::make_shared( + recording, "pool", "srv1", "", nullptr, settings); + storage->startup(); + + recording->ops.clear(); + + /// One transaction: write a pending blob into part A, hardlink (COPY pending) into part B, + /// and moveFile (MOVE pending) of a DIFFERENT pending blob from part A into part C. + auto tx = storage->createTransaction(); + writeThroughTransaction(*tx, "a11/a11a11a1-1111-4111-8111-111111111111/all_A_A_0/data.bin", "blob-for-copy"); + writeThroughTransaction(*tx, "a11/a11a11a1-1111-4111-8111-111111111111/all_A_A_0/extra.bin", "blob-for-move"); + + /// createHardLink = COPY semantics: both src and dst should see the blob after commit. + tx->createHardLink("a11/a11a11a1-1111-4111-8111-111111111111/all_A_A_0/data.bin", "a11/a11a11a1-1111-4111-8111-111111111111/all_B_B_0/data.bin"); + + /// moveFile cross-part = MOVE semantics: src loses the blob, dst gains it. + { + auto & ca_tx = dynamic_cast(*tx); + ca_tx.moveFile("a11/a11a11a1-1111-4111-8111-111111111111/all_A_A_0/extra.bin", "a11/a11a11a1-1111-4111-8111-111111111111/all_C_C_0/extra.bin"); + } + + tx->commit(DB::NoCommitOptions{}); + + const auto & log = recording->ops; + const int first_precommit_idx = firstPrecommitWriteIdx(log); + ASSERT_GE(first_precommit_idx, 0) + << "No create-precommit owner write (root-shard CAS) was recorded — precommit step did not fire"; + + /// Collect own content keys (the content BLOBS this transaction wrote). The staged part-manifest + /// body (`/_manifests/...`) is the precommit's evidence and is written before precommit by design, + /// so it is NOT gated content — only /blobs/ are. + std::set own_content_keys; + for (const auto & r : log) + if (r.op == "writeObject" && r.key.find("/blobs/") != std::string::npos) + own_content_keys.insert(r.key); + + /// No own-content pool op before precommit (B188 invariant extends to all adopt sites). + for (int i = 0; i < static_cast(log.size()); ++i) + { + if (!own_content_keys.contains(log[i].key)) + continue; + EXPECT_GT(i, first_precommit_idx) + << "Own-content op '" << log[i].op << "' on '" << log[i].key << "' at index " << i + << " before precommit at " << first_precommit_idx; + } + + /// COPY semantics: both A and B see the copied blob. + EXPECT_TRUE(storage->existsFile("a11/a11a11a1-1111-4111-8111-111111111111/all_A_A_0/data.bin")); + EXPECT_TRUE(storage->existsFile("a11/a11a11a1-1111-4111-8111-111111111111/all_B_B_0/data.bin")); + EXPECT_EQ(storage->getFileSize("a11/a11a11a1-1111-4111-8111-111111111111/all_A_A_0/data.bin"), 13u); /// "blob-for-copy" (13 bytes) + EXPECT_EQ(storage->getFileSize("a11/a11a11a1-1111-4111-8111-111111111111/all_B_B_0/data.bin"), 13u); + EXPECT_EQ(storage->getStorageObjects("a11/a11a11a1-1111-4111-8111-111111111111/all_A_A_0/data.bin")[0].remote_path, + storage->getStorageObjects("a11/a11a11a1-1111-4111-8111-111111111111/all_B_B_0/data.bin")[0].remote_path) + << "COPY (hardlink): both parts must share the same blob object"; + + /// MOVE semantics: A loses extra.bin, C gains it. + EXPECT_FALSE(storage->existsFile("a11/a11a11a1-1111-4111-8111-111111111111/all_A_A_0/extra.bin")); + EXPECT_TRUE(storage->existsFile("a11/a11a11a1-1111-4111-8111-111111111111/all_C_C_0/extra.bin")); + EXPECT_EQ(storage->getFileSize("a11/a11a11a1-1111-4111-8111-111111111111/all_C_C_0/extra.bin"), 13u); /// "blob-for-move" (13 bytes) +} + +/// ==== B189: orphaned pending blob must NOT be uploaded after unlinkFile / replaceFile ==== +/// +/// When a file is written (pending blob X) and then unlinked (or replaced) within the same +/// transaction, X's tree entry is removed — so X is NOT referenced by the staged tree. Before the +/// B189 fix, publishStaging iterated pending_blobs unconditionally and uploaded X anyway (a wasted +/// PUT of an unreferenced blob). After the fix, publishStaging builds the set of blob hashes +/// referenced by the staged tree entries and uploads ONLY those — orphaned blobs are skipped. +/// +/// The test uses RecordingLocalObjectStorage to capture every writeObject call. After commit it +/// checks that the orphaned blob's pool key received NO writeObject, while a kept blob (written and +/// NOT removed in the same transaction) IS uploaded. +TEST(CASWiringOps, OrphanedPendingBlobNotUploadedAfterUnlink) +{ + auto recording = makeRecordingStorageForTest("b189_unlink"); + auto settings = DB::Cas::tests::makeSettingsForTest( + "test", std::filesystem::temp_directory_path() / "ca_b189_unlink_scratch"); + auto storage = std::make_shared( + recording, "pool", "srv1", "", nullptr, settings); + storage->startup(); + + recording->ops.clear(); + + auto tx = storage->createTransaction(); + + /// Write blob X — this will be unlinked (orphaned) before commit. + writeThroughTransaction(*tx, "a11/a11a11a1-1111-4111-8111-111111111111/all_1_1_0/orphan.bin", "orphan-bytes"); + + /// Write blob Y — this is kept (its tree entry survives to the staged tree). + writeThroughTransaction(*tx, "a11/a11a11a1-1111-4111-8111-111111111111/all_1_1_0/kept.bin", "kept-bytes"); + + /// Unlink blob X — removes its tree entry; the pending_blobs record remains but is now orphaned. + tx->unlinkFile("a11/a11a11a1-1111-4111-8111-111111111111/all_1_1_0/orphan.bin", false, false); + + /// Sanity: the unlinked file is no longer staged (in-flight should not report it). + EXPECT_FALSE(tx->tryGetInFlightFileSize("a11/a11a11a1-1111-4111-8111-111111111111/all_1_1_0/orphan.bin").has_value()); + EXPECT_EQ(tx->tryGetInFlightFileSize("a11/a11a11a1-1111-4111-8111-111111111111/all_1_1_0/kept.bin"), std::optional(10)); + + tx->commit(DB::NoCommitOptions{}); + + const auto & log = recording->ops; + + /// Collect blob BODY keys written by this transaction (only /blobs/ writeObjects). Exclude the + /// per-hash `.meta` freshness descriptor sibling (`blobMetaKey` = body key + `.meta`, spec + /// §meta-protocols v3): it lives under the same /blobs/ prefix but is NOT a blob upload, so it must + /// not inflate the body-upload count. `putBlob` writes exactly one such `.meta` per body. + std::vector blob_writes; + for (const auto & r : log) + if (r.op == "writeObject" && r.key.find("/blobs/") != std::string::npos && !r.key.ends_with(".meta")) + blob_writes.push_back(r.key); + + /// Exactly ONE blob must have been uploaded (the kept one). The orphaned blob's pool key must + /// NOT appear in any writeObject — B189: orphan is filtered out of the publish upload. + EXPECT_EQ(blob_writes.size(), 1u) + << "Expected exactly 1 blob upload (the kept blob); got " << blob_writes.size() + << ". If 2, the orphaned pending blob was uploaded — B189 regression."; + + /// The kept file is visible after commit; the orphaned file is not. + EXPECT_TRUE(storage->existsFile("a11/a11a11a1-1111-4111-8111-111111111111/all_1_1_0/kept.bin")); + EXPECT_EQ(storage->getFileSize("a11/a11a11a1-1111-4111-8111-111111111111/all_1_1_0/kept.bin"), 10u); /// "kept-bytes" + EXPECT_FALSE(storage->existsFile("a11/a11a11a1-1111-4111-8111-111111111111/all_1_1_0/orphan.bin")); +} + +/// B189 companion: the same orphan-filter applies when the tree entry is removed by replaceFile +/// (the destination entry erased before the move). Write blob X to dst, then replaceFile src->dst +/// (erases X's entry, moves src's entry to dst). The orphaned X must not be uploaded. +TEST(CASWiringOps, OrphanedPendingBlobNotUploadedAfterReplace) +{ + auto recording = makeRecordingStorageForTest("b189_replace"); + auto settings = DB::Cas::tests::makeSettingsForTest( + "test", std::filesystem::temp_directory_path() / "ca_b189_replace_scratch"); + auto storage = std::make_shared( + recording, "pool", "srv1", "", nullptr, settings); + storage->startup(); + + recording->ops.clear(); + + auto tx = storage->createTransaction(); + + /// Write blob X into the destination slot — it will be erased by replaceFile. + writeThroughTransaction(*tx, "a11/a11a11a1-1111-4111-8111-111111111111/all_1_1_0/data.bin", "original-bytes"); + + /// Write blob Y into the source slot — it will replace the destination. + writeThroughTransaction(*tx, "a11/a11a11a1-1111-4111-8111-111111111111/all_1_1_0/new.bin", "replacement-bytes"); + + /// replaceFile: erases the dst entry (X orphaned), then moves src->dst. + { + auto & ca_tx = dynamic_cast(*tx); + ca_tx.replaceFile("a11/a11a11a1-1111-4111-8111-111111111111/all_1_1_0/new.bin", "a11/a11a11a1-1111-4111-8111-111111111111/all_1_1_0/data.bin"); + } + + tx->commit(DB::NoCommitOptions{}); + + const auto & log = recording->ops; + + /// Exactly ONE blob must have been uploaded (the replacement blob Y). Exclude the per-hash `.meta` + /// freshness descriptor sibling (see the AfterUnlink test) — it is not a blob body upload. + std::vector blob_writes; + for (const auto & r : log) + if (r.op == "writeObject" && r.key.find("/blobs/") != std::string::npos && !r.key.ends_with(".meta")) + blob_writes.push_back(r.key); + + EXPECT_EQ(blob_writes.size(), 1u) + << "Expected exactly 1 blob upload (the replacement blob); got " << blob_writes.size() + << ". If 2, the orphaned original blob was uploaded — B189 regression."; + + /// After commit the destination slot carries the replacement content. + EXPECT_TRUE(storage->existsFile("a11/a11a11a1-1111-4111-8111-111111111111/all_1_1_0/data.bin")); + EXPECT_EQ(storage->getFileSize("a11/a11a11a1-1111-4111-8111-111111111111/all_1_1_0/data.bin"), 17u); /// "replacement-bytes" + EXPECT_FALSE(storage->existsFile("a11/a11a11a1-1111-4111-8111-111111111111/all_1_1_0/new.bin")); +} + +/// ==== Promote tokened-leaf edge-protection (spec 2026-07-09-cas-writer-gc-simplification, Phase A) ==== +/// +/// A fast GC can PREMATURELY condemn a blob a writer just putBlob'd, in the tiny putBlob->promote window +/// (the precommit->blob edge is not yet folded, so GC reads in-degree 0). Under EDGE-BEFORE-OBSERVE the +/// precommit closure named the blob BEFORE putBlob observed it, so the condemnation cannot graduate to a +/// delete (the next fold sees the edge, d >= 1, spared) — it is doomed, not the blob. promote therefore +/// does NOT re-validate or resurrect a TOKENED leaf; it commits with the blob's token UNCHANGED. The only +/// blob-side abort promote still performs is the owner-liveness check (a reclaimed precommit) — which runs +/// BEFORE any blob work and touches nothing. +/// +/// These tests drive the REAL writer sequence (stageManifest -> precommitAdd -> putBlob -> promote) against +/// a raw in-memory Pool (no background GC → deterministic), and condemn the blob's CURRENT token by seeding +/// gc/state + the per-hash freshness meta the way a real GC condemn does (see `seedCondemnBlobToken` below). + +namespace DB::ErrorCodes +{ + extern const int ABORTED; + extern const int NETWORK_ERROR; +} + +namespace +{ + +DB::Cas::PoolPtr openResurrectStore(std::shared_ptr & out_backend) +{ + out_backend = std::make_shared(); + return DB::Cas::Pool::open( + out_backend, DB::Cas::PoolConfig{.pool_prefix = "p", .server_root_id = "test"}); +} + +/// Condemn (kind=Blob, hash, token) by seeding gc/state + a per-shard retired set (the durable GC ledger +/// shape — RetiredEntry, exact-token delete, unchanged by this task) AND condemning the per-hash freshness +/// meta, which is what the writer's condemned decision ACTUALLY point-reads (spec §meta-protocols v3). +/// Bumps the round so the retirement is a fresh one; leaves the object itself in place (condemn, NOT delete). +void seedCondemnBlobToken(DB::Cas::Pool & store, const DB::UInt128 & hash, + [[maybe_unused]] const DB::Cas::Token & token, [[maybe_unused]] uint64_t size) +{ + using namespace DB::Cas; + Backend & b = store.backend(); + const Layout & layout = store.layout(); + + GcState state; + const HeadResult head = b.head(layout.gcStateKey()); + if (head.exists) + { + const auto got = b.get(layout.gcStateKey()); + state = decodeGcState(got->bytes); + } + state.round += 1; + + /// Retired-in-snapshot: there is no separate retired-list object to seed — condemned state rides the + /// GC snapshot runs, which this writer-side edge-protection test does not exercise. The writer's + /// condemned decision point-reads the per-hash freshness meta (condemned below), so bumping the round + /// and condemning the meta is enough. + if (head.exists) + b.putOverwrite(layout.gcStateKey(), encodeGcState(state), head.token); + else + b.putIfAbsent(layout.gcStateKey(), encodeGcState(state)); + + /// The writer's fresh upload (putBlob) already wrote a Clean meta for `hash` (Task 3), so this is a + /// plain Clean -> Condemned CAS — exactly what GC's real condemn path does. + DB::Cas::tests::condemnMeta(b, layout, hash, state.round); +} + +} + +/// A blob condemned in the putBlob->promote window is EDGE-PROTECTED (spec +/// 2026-07-09-cas-writer-gc-simplification, Phase A): the precommit closure naming the blob was durable +/// BEFORE putBlob observed it, so a condemnation in this window cannot graduate to a delete (the next fold +/// sees the edge, d >= 1, spared). promote therefore does NOT re-check or resurrect a TOKENED leaf — it +/// commits leaving the blob's token UNCHANGED (no resurrect PUT). The premature condemn is doomed on its own. +TEST(CASWiringResurrect, PromoteIgnoresCondemnedTokenedBlobEdgeProtected) +{ + using namespace DB::Cas; + std::shared_ptr backend; + auto store = openResurrectStore(backend); + const RootNamespace ns{"test/tbl"}; + const String ref = "all_1_1_0"; + const String P = "resurrect-me"; + + PartWriteInfo info; + info.intended_ref = ns.string() + "/" + ref; + auto build = store->beginPartWrite(info); + + const ManifestId id = build->stageManifest({wiringBlobEntry("data.bin", P)}); + build->precommitAdd(ns, ref, id); + build->putBlob(idOf(P), BlobSource::fromString(P)); + + /// Condemn the freshly-uploaded blob's CURRENT token (GC condemning the not-yet-folded fresh incarnation). + const String blob_key = store->layout().blobKey(idOf(P)); + const HeadResult h1 = store->backend().head(blob_key); + ASSERT_TRUE(h1.exists); + const Token t0 = h1.token; + seedCondemnBlobToken(*store, u128Of(P), t0, h1.size); + { + const auto lm = DB::Cas::tests::loadMetaForTest(store->backend(), store->layout(), u128Of(P)); + ASSERT_TRUE(lm.has_value() && lm->meta.state == MetaState::Condemned) + << "precondition: the putBlob'd token must be condemned before promote"; + } + + /// promote must NOT abort AND must NOT touch the tokened leaf — it is edge-protected. + EXPECT_NO_THROW(build->promote(ns, ref, build->buildId(), id)); + + /// The ref is committed and the blob's token is UNCHANGED — no resurrect PUT ran (tokened leaves are + /// not re-validated: EDGE-BEFORE-OBSERVE guarantees the condemnation is doomed, not the blob). + EXPECT_TRUE(store->resolveRef(ns, ref).has_value()) << "the ref must resolve after promote"; + const HeadResult h2 = store->backend().head(blob_key); + ASSERT_TRUE(h2.exists); + EXPECT_EQ(h2.token, t0) + << "tokened leaf is edge-protected: promote must not re-upload it (token unchanged)"; +} + +/// promote is a PURE owner MOVE (Δ=0 blob delta) — sound ONLY while this build's precommit is STILL the +/// live owner of the ref (`WPromote owner==bld` / INV_NO_DANGLE): a Δ=0 move over a ref with no live +/// precommit edge would republish a committed manifest onto to-be-deleted blobs. So when the precommit +/// binding is absent from the ref-table state, promote MUST fail closed with ABORTED — at the owner-liveness +/// check in the append closure, which runs BEFORE any blob revalidation, so NO consequential PUT / resurrect +/// happens (a condemned leaf is left untouched, exactly as on the success path). +/// +/// This drives that guard the DETERMINISTIC way: a promote whose precommit was NEVER added (so the binding +/// is simply absent). The original "precommit added, then REMOVED out from under a still-live build" shape is +/// NOT reachable by any deterministic single-threaded in-runtime actor: `PartWriteTxn::abandon` marks the build +/// not-alive (`requireAlive` → LOGICAL_ERROR) and `Pool::dropNamespace` cancels the build (`requireAlive` → +/// ABORTED) — BOTH trip `requireAlive` at promote's first line, before this closure ever runs. Only a narrow +/// promote-vs-dropNamespace RACE (dropNamespace clears the binding in the window between promote's +/// `requireAlive` and its append closure) reaches the closure guard, which is therefore a defensive backstop +/// (a candidate for a later dead-code review — out of scope here). The previous version of this test faked +/// the removal with an out-of-band `appendOwnerEvent` the single-leader runtime never observes — an +/// unreachable state that surfaced as a CORRUPTED_DATA ref-log collision, not the intended ABORTED. +TEST(CASWiringResurrect, PromoteWithoutLivePrecommitAbortsWithoutResurrect) +{ + using namespace DB::Cas; + std::shared_ptr backend; + auto store = openResurrectStore(backend); + const RootNamespace ns{"test/tbl"}; + const String ref = "all_2_2_0"; + const String P = "abandoned-me"; + + PartWriteInfo info; + info.intended_ref = ns.string() + "/" + ref; + auto build = store->beginPartWrite(info); + + /// Stage the manifest and upload the (fresh) blob, but DO NOT precommitAdd — so the precommit owner + /// binding is absent from the ref-table state when promote runs. A fresh putBlob needs no precommit + /// (only the ADOPT path requires the durable edge); it records the tokened leaf t0. + const ManifestId id = build->stageManifest({wiringBlobEntry("data.bin", P)}); + build->putBlob(idOf(P), BlobSource::fromString(P)); + + const String blob_key = store->layout().blobKey(idOf(P)); + const HeadResult h1 = store->backend().head(blob_key); + ASSERT_TRUE(h1.exists); + /// Condemn the leaf so that, WERE the blob gate reached, promote would resurrect it — proving the abort + /// happens strictly BEFORE any blob work. + seedCondemnBlobToken(*store, u128Of(P), h1.token, h1.size); + { + const auto lm = DB::Cas::tests::loadMetaForTest(store->backend(), store->layout(), u128Of(P)); + ASSERT_TRUE(lm.has_value() && lm->meta.state == MetaState::Condemned); + } + + /// promote aborts at the owner-liveness check (NETWORK_ERROR, fix #37 phase 2), before the blob gate. + try + { + build->promote(ns, ref, build->buildId(), id); + FAIL() << "expected promote to abort: the precommit is not the live owner of the ref"; + } + catch (const DB::Exception & e) + { + EXPECT_EQ(e.code(), DB::ErrorCodes::NETWORK_ERROR); + } + + /// No blob work ran before the abort: the leaf's token is UNCHANGED (still the condemned one) and its + /// meta is still Condemned — the owner check aborts before any PUT / resurrect. + const HeadResult h2 = store->backend().head(blob_key); + ASSERT_TRUE(h2.exists); + EXPECT_EQ(h2.token, h1.token) + << "the aborting path must perform no PUT — the tokened leaf is untouched"; + const auto lm_after = DB::Cas::tests::loadMetaForTest(store->backend(), store->layout(), u128Of(P)); + EXPECT_TRUE(lm_after.has_value() && lm_after->meta.state == MetaState::Condemned) + << "no re-upload/resurrect before the owner check — the token is still the condemned one"; +} + +/// tryFromDisk must be exception-free for a plain local disk: it runs on every +/// asynchronous-metrics tick for every configured disk, and probing via +/// `getMetadataStorage`'s NOT_IMPLEMENTED throw pollutes `system.errors` (the Exception +/// constructor counts the error even when the throw is caught) — a steady +N/s stream on a +/// pure-local server, caught as a stray-error failure by strict-error tests +/// (`test_cancel_backup`'s NoTrashChecker, Altinity PR#2073). +TEST(CASWiring, TryFromDiskOnLocalDiskIsExceptionFreeAndCountsNoError) +{ + auto tmp = std::filesystem::temp_directory_path() / "ca_wiring_tryfromdisk_test"; + std::filesystem::create_directories(tmp); + const DB::DiskPtr local = std::make_shared("tryfromdisk_local", tmp.string()); + + const auto before = DB::ErrorCodes::values[DB::ErrorCodes::NOT_IMPLEMENTED].get().local.count; + auto * ca = DB::ContentAddressedMetadataStorage::tryFromDisk(local); + const auto after = DB::ErrorCodes::values[DB::ErrorCodes::NOT_IMPLEMENTED].get().local.count; + + EXPECT_EQ(ca, nullptr); + EXPECT_EQ(after, before) + << "tryFromDisk on a non-content-addressed disk must not construct (and thereby count) " + "a NOT_IMPLEMENTED exception — it runs per disk on every asynchronous-metrics tick"; + std::filesystem::remove_all(tmp); +} diff --git a/src/Disks/tests/gtest_cas_b140_dangle.cpp b/src/Disks/tests/gtest_cas_b140_dangle.cpp new file mode 100644 index 000000000000..35adecc330e2 --- /dev/null +++ b/src/Disks/tests/gtest_cas_b140_dangle.cpp @@ -0,0 +1,127 @@ +#include +#include +#include +#include +#include +#include +#include + +#include + +using namespace DB::Cas; +using DB::Cas::tests::idOf; +using DB::Cas::tests::u128Of; + +namespace +{ + +/// The dangle is about the SINGLE snap shard's in-degree, and one cursor_key covers both refs. +PoolPtr openTestPool(std::shared_ptr & out_backend) +{ + out_backend = std::make_shared(); + return Pool::open(out_backend, PoolConfig{.pool_prefix = "p", .server_root_id = "test"}); +} + +size_t runGcToFixpoint(Gc & gc, size_t max_rounds = 64) +{ + size_t rounds = 0; + for (; rounds < max_rounds; ++rounds) + { + RoundReport rep; + try + { + rep = gc.runRegularRound(); + } + catch (const DB::Exception &) + { + /// The fail-closed coherence guard refused this round (CORRUPTED_DATA): no delete + /// happened, the live blob is safe. Stop — re-running would just throw again. + break; + } + if (!rep.acquired_lease) + continue; + if (rep.candidates == 0 && rep.deleted == 0 && rep.absent == 0 + && rep.replaced == 0 && rep.spared == 0) + break; + } + return rounds; +} + +} + +/// B140-DANGLE — the soak's INV-NO-LOSS finding, ported to the root-local part-manifest model. +/// +/// THE PROPERTY (unchanged across the redesign): a content-shared / deduplicated blob `B` referenced +/// by TWO live parts must NEVER be deleted when only ONE of those refs is dropped. In the old tree +/// model the loss arose from a `GcSnap` cursor-skip under-count (the committed `folded_cursor` ran +/// ahead of the snap's edges, so the second live part's edge was never folded). That white-box +/// failure mode is structurally IMPOSSIBLE in the manifest model: there is no separate snap; per-blob +/// in-degree is derived by folding the ONE ordered `RootOwnerEvent` journal, the fold cursor lives in +/// the `CasFoldSeal` (one durable unit with the sealed deltas, never diverging), and each part's blob +/// edges come from reading its OWN manifest body at fold time. So this is now a black-box no-loss +/// oracle: two live refs share `B`, drop one, GC to a fixpoint, assert `B` survives (`dangling == 0`) +/// because the surviving ref's manifest still contributes its +1 edge — `B`'s in-degree never reaches 0. +TEST(CASGCDangle, SharedBlobSurvivesDropOfOneOfTwoLiveRefs) +{ + std::shared_ptr b; + auto s = openTestPool(b); + const RootNamespace ns{"srv1/tbl"}; + + /// rb_live -> manifest { data.bin: B }. B is uploaded here. + { + PartWriteInfo info; + info.intended_ref = ns.string() + "/rb_live"; + auto build = s->beginPartWrite(info); + build->putBlob(idOf("B"), BlobSource::fromString("B")); + ManifestEntry e; + e.path = "data.bin"; + e.placement = EntryPlacement::Blob; + e.ref = DB::Cas::BlobRef{DB::Cas::BlobHashAlgo::CityHash128, DB::Cas::BlobDigest::fromU128(u128Of("B"))}; + + e.blob_size = std::string("B").size(); + const ManifestId id = build->stageManifest({e}); + build->precommitAdd(ns, "rb_live", id); + build->promote(ns, "rb_live", build->buildId(), id); + s->renewWatermarkOnce(); + } + + /// rb_cur -> a DISTINCT manifest { other.bin: B } that REUSES the same shared blob B (tokenless + /// adopt — the soak's cross-node `adopt`). Still live. + { + PartWriteInfo info; + info.intended_ref = ns.string() + "/rb_cur"; + auto build = s->beginPartWrite(info); + ManifestEntry e; + e.path = "other.bin"; + e.placement = EntryPlacement::Blob; + e.ref = DB::Cas::BlobRef{DB::Cas::BlobHashAlgo::CityHash128, DB::Cas::BlobDigest::fromU128(u128Of("B"))}; + + e.blob_size = std::string("B").size(); + build->adoptEvidence(e); /// tokenless dep (no HEAD) — the cross-node adopt + const ManifestId id = build->stageManifest({e}); + build->precommitAdd(ns, "rb_cur", id); + build->promote(ns, "rb_cur", build->buildId(), id); + s->renewWatermarkOnce(); + } + + /// Drop rb_live: its manifest's -1 on B lands, but rb_cur's manifest still contributes +1, so B's + /// in-degree stays >= 1 and B is never a zero-in-degree candidate. + s->dropRef(ns, "rb_live"); + s->renewWatermarkOnce(); + + Gc gc(s, hexToU128("00000000000000000000000000000001")); + const size_t rounds = runGcToFixpoint(gc); + + /// rb_cur is still LIVE and still resolves through a present manifest — its blob B must survive. + ASSERT_TRUE(s->resolveRef(ns, "rb_cur").has_value()); + + const FsckReport rep = runFsck(*s, /*detail=*/true); + + /// THE DANGLE ASSERTION: GC must NEVER delete a blob a live ref references. + EXPECT_EQ(rep.dangling, 0u) + << "B140-dangle: GC deleted shared blob B still referenced by the live ref rb_cur " + << "after " << rounds << " rounds (dangling=" << rep.dangling << ", reachable=" << rep.reachable + << ", B_present=" << b->head(s->layout().blobKey(idOf("B"))).exists << ")."; + EXPECT_TRUE(b->head(s->layout().blobKey(idOf("B"))).exists) + << "shared blob B must remain present while rb_cur references it"; +} diff --git a/src/Disks/tests/gtest_cas_backend.cpp b/src/Disks/tests/gtest_cas_backend.cpp new file mode 100644 index 000000000000..2538017dc398 --- /dev/null +++ b/src/Disks/tests/gtest_cas_backend.cpp @@ -0,0 +1,1049 @@ +#include + +#include "config.h" + +#include +#include +#include +#include +#include +#include + +#if USE_AWS_S3 +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#endif + +using namespace DB::Cas; + +/// Minimal concrete implementation that overrides every pure virtual with trivial defaults. +/// Purpose: verify the interface compiles, is overridable, and result-type defaults are sane. +struct NullBackend final : Backend +{ + std::optional get(const String & /*key*/, Range /*range*/) override + { + return std::nullopt; + } + + std::optional getStream(const String & /*key*/, Range /*range*/) override + { + return std::nullopt; + } + + HeadResult head(const String & /*key*/) override + { + return HeadResult{}; + } + + PutResult putIfAbsent(const String & /*key*/, const String & /*bytes*/, const ObjectMeta & /*meta*/) override + { + return {PutOutcome::Done, {}}; + } + + WriteSinkPtr putIfAbsentStream(const String & /*key*/, const ObjectMeta & /*meta*/) override + { + return nullptr; /// trivial default — streaming behavior is pinned by the CASBackendContract suite + } + + PutResult putOverwrite(const String & /*key*/, const String & /*bytes*/, const Token & /*expected*/, const ObjectMeta & /*meta*/) override + { + return {PutOutcome::PreconditionFailed, {}}; + } + + CasResult casPut(const String & /*key*/, const String & /*bytes*/, const std::optional & /*expected*/, const ObjectMeta & /*meta*/) override + { + return {CasOutcome::Conflict, {}}; + } + + DeleteOutcome deleteExact(const String & /*key*/, const Token & /*token*/) override + { + return DeleteOutcome{}; + } + + ListPage list(const String & /*prefix*/, const String & /*cursor*/, size_t /*limit*/) override + { + return ListPage{}; + } + + bool supportsListTokens() const override { return false; } +}; + +TEST(CASBackend, NullBackendShapeAndDefaults) +{ + NullBackend b; + // Use the base-class reference so virtual dispatch uses base-class default args. + Backend & ref = b; + + // get returns absent + EXPECT_FALSE(ref.get("k").has_value()); + + // head returns non-existent + HeadResult h = b.head("k"); + EXPECT_FALSE(h.exists); + EXPECT_EQ(h.size, 0u); + EXPECT_TRUE(h.token.empty()); + + // putIfAbsent returns Done + EXPECT_EQ(ref.putIfAbsent("k", "v").outcome, PutOutcome::Done); + + // putOverwrite returns PreconditionFailed + EXPECT_EQ(ref.putOverwrite("k", "v", Token{}).outcome, PutOutcome::PreconditionFailed); + + // casPut returns Conflict + EXPECT_EQ(ref.casPut("k", "v", std::nullopt).outcome, CasOutcome::Conflict); + + // deleteExact default kind is NotFound + DeleteOutcome d = b.deleteExact("k", Token{}); + EXPECT_EQ(d.kind, DeleteOutcome::Kind::NotFound); + EXPECT_FALSE(d.created_delete_marker); + + // list returns empty page + ListPage page = b.list("p/", "", 10); + EXPECT_TRUE(page.keys.empty()); + EXPECT_TRUE(page.next_cursor.empty()); + + // Range::whole() helper + EXPECT_TRUE(Range{}.whole()); + Range r1; r1.offset = 1; + EXPECT_FALSE(r1.whole()); + Range r2; r2.length = 5u; + EXPECT_FALSE(r2.whole()); +} + +// ===================================================================== +// Task 3: CasInMemoryBackend — enforcing token semantics +// ===================================================================== + +TEST(CASInMemory, PutIfAbsentAndGet) +{ + InMemoryBackend b; + const auto put = b.putIfAbsent("k", "v1"); + const Token t1 = put.token; + EXPECT_EQ(put.outcome, PutOutcome::Done); + EXPECT_FALSE(t1.empty()); + EXPECT_EQ(b.putIfAbsent("k", "clobber").outcome, PutOutcome::PreconditionFailed); + auto g = b.get("k"); + ASSERT_TRUE(g.has_value()); + EXPECT_EQ(g->bytes, "v1"); + EXPECT_EQ(g->token, t1); + EXPECT_FALSE(b.get("absent").has_value()); +} + +TEST(CASInMemory, OverwriteIsTokenExactAndMintsFreshToken) +{ + InMemoryBackend b; + const Token t1 = b.putIfAbsent("k", "v1").token; + EXPECT_EQ(b.putOverwrite("k", "v2", Token{"wrong", TokenType::Emulated}).outcome, PutOutcome::PreconditionFailed); + EXPECT_EQ(b.get("k")->bytes, "v1"); // untouched on mismatch + const auto overwrite = b.putOverwrite("k", "v2", t1); + EXPECT_EQ(overwrite.outcome, PutOutcome::Done); + EXPECT_NE(overwrite.token, t1); // tokens never repeat + EXPECT_EQ(b.get("k")->bytes, "v2"); +} + +TEST(CASInMemory, CasPutCreateAndSwap) +{ + InMemoryBackend b; + const auto create = b.casPut("m", "s1", std::nullopt); + const Token t1 = create.token; + EXPECT_EQ(create.outcome, CasOutcome::Committed); // create-if-absent + EXPECT_EQ(b.casPut("m", "s1x", std::nullopt).outcome, CasOutcome::Conflict); // exists now + EXPECT_EQ(b.casPut("m", "s2", Token{"stale", TokenType::Emulated}).outcome, CasOutcome::Conflict); + EXPECT_EQ(b.get("m")->bytes, "s1"); + EXPECT_EQ(b.casPut("m", "s2", t1).outcome, CasOutcome::Committed); + EXPECT_EQ(b.get("m")->bytes, "s2"); +} + +TEST(CASInMemory, DeleteExactEnforced) +{ + InMemoryBackend b; + const Token t1 = b.putIfAbsent("k", "v1").token; + auto d1 = b.deleteExact("k", Token{"wrong", TokenType::Emulated}); + EXPECT_EQ(d1.kind, DeleteOutcome::Kind::TokenMismatch); + EXPECT_TRUE(b.get("k").has_value()); // SURVIVES wrong-token delete + auto d2 = b.deleteExact("k", t1); + EXPECT_EQ(d2.kind, DeleteOutcome::Kind::Deleted); + EXPECT_FALSE(d2.created_delete_marker); + EXPECT_FALSE(b.get("k").has_value()); + EXPECT_EQ(b.deleteExact("k", t1).kind, DeleteOutcome::Kind::NotFound); +} + +TEST(CASInMemory, RangeGetAndHeadAndList) +{ + InMemoryBackend b; + b.putIfAbsent("p/a", "0123456789"); + b.putIfAbsent("p/b", "xy"); + b.putIfAbsent("q/c", "z"); + EXPECT_EQ(b.get("p/a", Range{.offset = 2, .length = 3})->bytes, "234"); + auto h = b.head("p/a"); + EXPECT_TRUE(h.exists); + EXPECT_EQ(h.size, 10u); + auto page = b.list("p/", "", 10); + ASSERT_EQ(page.keys.size(), 2u); // sorted, prefix-scoped + EXPECT_EQ(page.keys[0].key, "p/a"); + EXPECT_EQ(page.keys[1].key, "p/b"); + EXPECT_TRUE(page.next_cursor.empty()); + auto page1 = b.list("p/", "", 1); // pagination + EXPECT_EQ(page1.keys.size(), 1u); + EXPECT_EQ(page1.keys[0].key, "p/a"); + EXPECT_EQ(page1.next_cursor, "p/a"); + EXPECT_FALSE(page1.next_cursor.empty()); + auto page2 = b.list("p/", page1.next_cursor, 1); + EXPECT_EQ(page2.keys[0].key, "p/b"); +} + +// ===================================================================== +// Task 4: CasInMemoryBackend — fault injection and probe-test modes +// ===================================================================== + +TEST(CASInMemoryFaults, HeldDeleteLandsLater) +{ + InMemoryBackend b; + const Token t1 = b.putIfAbsent("k", "v1").token; + b.setHoldDeletes(true); + auto d = b.deleteExact("k", t1); // message "sent", not landed + EXPECT_EQ(d.kind, DeleteOutcome::Kind::Deleted); // caller sees the send accepted + EXPECT_TRUE(b.get("k").has_value()); // ... but nothing landed yet + ASSERT_EQ(b.pendingDeletes(), 1u); + // the object is resurrected before the zombie lands: + b.putOverwrite("k", "v1'", t1); + auto landed = b.landPendingDelete(0); // the zombie lands NOW + EXPECT_EQ(landed.kind, DeleteOutcome::Kind::TokenMismatch); // 412 — INV-NO-RETURN in miniature + EXPECT_EQ(b.get("k")->bytes, "v1'"); +} + +TEST(CASInMemoryFaults, InjectedCasConflictFiresOnce) +{ + InMemoryBackend b; + const Token t1 = b.casPut("m", "s1", std::nullopt).token; + b.failNextCasPut("m"); + EXPECT_EQ(b.casPut("m", "s2", t1).outcome, CasOutcome::Conflict); // injected + EXPECT_EQ(b.get("m")->bytes, "s1"); + EXPECT_EQ(b.casPut("m", "s2", t1).outcome, CasOutcome::Committed); // next attempt is real +} + +TEST(CASInMemoryFaults, NonEnforcingModeMimicsBadBackend) +{ + InMemoryBackend b; + b.setEnforceTokens(false); // MinIO-OSS-shaped backend + b.putIfAbsent("k", "v1"); + auto d = b.deleteExact("k", Token{"totally-wrong", TokenType::Emulated}); + EXPECT_EQ(d.kind, DeleteOutcome::Kind::Deleted); // silently deletes anyway — the dangerous behavior + EXPECT_FALSE(b.get("k").has_value()); +} + +TEST(CASInMemoryFaults, VersioningMarkerMode) +{ + InMemoryBackend b; + b.setSimulateDeleteMarkers(true); + const Token t1 = b.putIfAbsent("k", "v1").token; + EXPECT_TRUE(b.deleteExact("k", t1).created_delete_marker); // probe must reject this pool +} + +TEST(CASInMemoryBackend, RoundTripsUserMetadata) +{ + DB::Cas::InMemoryBackend backend; + const DB::Cas::ObjectMeta meta{{"cas_owner", "ab:7:42"}}; + ASSERT_EQ(backend.putIfAbsent("k/key", "body", meta).outcome, DB::Cas::PutOutcome::Done); + + const auto hr = backend.head("k/key"); + ASSERT_TRUE(hr.exists); + ASSERT_EQ(hr.attributes.at("cas_owner"), "ab:7:42"); + + const auto gr = backend.get("k/key"); + ASSERT_TRUE(gr.has_value()); + ASSERT_EQ(gr->attributes.at("cas_owner"), "ab:7:42"); +} + +// ===================================================================== +// getStream seam (forward-only reads of write-once objects) +// ===================================================================== + +TEST(CASBackendStream, StreamsBodyWindow) +{ + auto backend = std::make_shared(); + backend->putIfAbsent("k", "0123456789"); + auto got = backend->getStream("k", DB::Cas::Range{.offset = 2, .length = 5}); + ASSERT_TRUE(got.has_value()); + String out; + DB::readStringUntilEOF(out, *got->stream); + EXPECT_EQ(out, "23456"); + EXPECT_FALSE(got->token.empty()); + EXPECT_FALSE(backend->getStream("absent").has_value()); +} + +// ===================================================================== +// B168 P0: InstrumentedBackend per-namespace/op ProfileEvents +// ===================================================================== + +namespace ProfileEvents +{ +extern const Event CASBlobPut; +extern const Event CASBlobPutDeduplicated; +extern const Event CASBlobHead; +extern const Event CASBlobHeadMiss; +extern const Event CASGCCompareSwap; +} + +TEST(CASInstrumentedBackend, ClassifierAndPerNamespaceOpEvents) +{ + /// Namespace classification by substring. + EXPECT_EQ(classifyCasNs("pool/blobs/ab/abcdef"), CasNs::Blob); + EXPECT_EQ(classifyCasNs("pool/gc/registry"), CasNs::Gc); /// gc/ prefix covers GC state (state, retired sets, etc.) + EXPECT_EQ(classifyCasNs("pool/roots/default/_files/x"), CasNs::Root); + EXPECT_EQ(classifyCasNs("pool/gc/state"), CasNs::Gc); + /// D3: the old per-server-control key shapes (`_watermark`, `_precommits/`) have no producer + /// anymore -- control state now lives under `/gc/server-roots/...` (classifies as Gc). A key of + /// this legacy shape, if it ever showed up, would fall through to the generic /roots/ rule. + EXPECT_EQ(classifyCasNs("pool/roots/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa/_watermark"), CasNs::Root); + EXPECT_EQ(classifyCasNs("pool/roots/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa/_precommits/3"), CasNs::Root); + EXPECT_EQ(classifyCasNs("pool/_pool_meta"), CasNs::Other); + /// Final opaque-life layout: both immutable streams and point/path-addressed state remain Root + /// instrumentation, while part manifests remain Manifest. None may fall into Other (the + /// 2026-07-03 operator-stand CREATE storm misread as CASOtherHeadMiss=102 because of this). + EXPECT_EQ(classifyCasNs("pool/cas/ns/stream/00000000000000000000000000000017/_log/1-1.zst"), CasNs::Root); + EXPECT_EQ(classifyCasNs("pool/cas/ns/state/00000000000000000000000000000017/_ckpt.zst"), CasNs::Root); + EXPECT_EQ(classifyCasNs("pool/cas/ns/state/00000000000000000000000000000017/_files/format_version.txt"), CasNs::Root); + EXPECT_EQ(classifyCasNs("pool/cas/manifests/0/srv/store/d18/uuid@cas@/24/1/000001.proto"), CasNs::Manifest); + + auto inner = std::make_shared(); + InstrumentedBackend b(inner); + + using ProfileEvents::global_counters; + const auto blob_put_before = global_counters[ProfileEvents::CASBlobPut].load(); + const auto blob_dedup_before = global_counters[ProfileEvents::CASBlobPutDeduplicated].load(); + const auto blob_head_before = global_counters[ProfileEvents::CASBlobHead].load(); + const auto blob_miss_before = global_counters[ProfileEvents::CASBlobHeadMiss].load(); + const auto gc_cas_before = global_counters[ProfileEvents::CASGCCompareSwap].load(); + + const String blob_key = "pool/blobs/ab/abcdef0123456789"; + + /// First put of a blob ⇒ Put. + EXPECT_EQ(b.putIfAbsent(blob_key, "payload").outcome, PutOutcome::Done); + /// Second put of the same key ⇒ PutDeduplicated (content already exists). + EXPECT_EQ(b.putIfAbsent(blob_key, "payload").outcome, PutOutcome::PreconditionFailed); + /// head of an absent blob key ⇒ HeadMiss (the 404 signal). + EXPECT_FALSE(b.head("pool/blobs/zz/absent").exists); + /// head of the present blob key ⇒ Head. + EXPECT_TRUE(b.head(blob_key).exists); + /// casPut create on a gc key ⇒ Gc Cas. + EXPECT_EQ(b.casPut("pool/gc/state", "g1", std::nullopt).outcome, CasOutcome::Committed); + /// Streaming put to a fresh blob key, then finalize ⇒ Put. + { + auto sink = b.putIfAbsentStream("pool/blobs/cd/cafebabe"); + ASSERT_TRUE(sink != nullptr); + DB::writeString(String("streamed"), sink->buffer()); + EXPECT_EQ(sink->finalize().outcome, PutOutcome::Done); + } + + /// Under coverage builds ProfileEvents propagate into a thread-local subtree that does not reach + /// `global_counters`; deltas read 0 there only (see gtest_unique_key_index_cache). +#if !WITH_COVERAGE + EXPECT_EQ(global_counters[ProfileEvents::CASBlobPut].load() - blob_put_before, 2u); + EXPECT_EQ(global_counters[ProfileEvents::CASBlobPutDeduplicated].load() - blob_dedup_before, 1u); + EXPECT_EQ(global_counters[ProfileEvents::CASBlobHead].load() - blob_head_before, 1u); + EXPECT_EQ(global_counters[ProfileEvents::CASBlobHeadMiss].load() - blob_miss_before, 1u); + EXPECT_EQ(global_counters[ProfileEvents::CASGCCompareSwap].load() - gc_cas_before, 1u); +#else + (void)blob_put_before; (void)blob_dedup_before; (void)blob_head_before; + (void)blob_miss_before; (void)gc_cas_before; +#endif +} + +// ===================================================================== +// M-C2 Task 2: typed S3 precondition signal +// ===================================================================== + +#if USE_AWS_S3 + +/// The Native conditional-PUT path discriminates a lost precondition by the canonical S3 error code +/// string ("PreconditionFailed", "NoSuchKey", ...) that `S3Exception` carries from the response XML +/// `` — a 412 is UNMODELED for the AWS SDK (the enum value is UNKNOWN), so the name is the only +/// machine-readable signal. +TEST(CASS3Signal, S3ExceptionCarriesCanonicalErrorName) +{ + DB::S3Exception e("412 from backend", Aws::S3::S3Errors::UNKNOWN, "PreconditionFailed"); + EXPECT_EQ(e.getExceptionName(), "PreconditionFailed"); + DB::S3Exception bare("no name attached", Aws::S3::S3Errors::UNKNOWN); + EXPECT_TRUE(bare.getExceptionName().empty()); +} + +namespace +{ + +/// WriteBuffer stub whose finalize throws a configured S3Exception — drives the classifier directly. +class ThrowOnFinalizeBuffer final : public DB::WriteBuffer +{ +public: + ThrowOnFinalizeBuffer() : DB::WriteBuffer(nullptr, 0) {} + + explicit ThrowOnFinalizeBuffer(DB::S3Exception e) : DB::WriteBuffer(nullptr, 0), to_throw(std::move(e)) {} + +private: + void nextImpl() override {} + + void finalizeImpl() override + { + if (to_throw) + throw *to_throw; /// NOLINT(cert-err09-cpp,cert-err60-cpp,cert-err61-cpp,misc-throw-by-value-catch-by-reference) -- the mock stores the configured exception to throw later, so it cannot be an anonymous temporary + } + + std::optional to_throw; +}; + +} + +/// detail::finalizeConditionalWrite maps a lost precondition to an OUTCOME by exact-matching the +/// canonical S3 error name (plus the modeled NO_SUCH_KEY enum, which WriteBufferFromS3 surfaces +/// nameless on retry exhaustion) and rethrows anything else. +TEST(CASS3Signal, FinalizeClassifierMapsPreconditionLossExactly) +{ + using DB::Cas::detail::finalizeConditionalWrite; + + auto classify = [](DB::S3Exception e) + { + ThrowOnFinalizeBuffer buf(std::move(e)); + return finalizeConditionalWrite(buf); + }; + + EXPECT_EQ(classify(DB::S3Exception("412", Aws::S3::S3Errors::UNKNOWN, "PreconditionFailed")), + PutOutcome::PreconditionFailed); + EXPECT_EQ(classify(DB::S3Exception("404 gone under If-Match", Aws::S3::S3Errors::UNKNOWN, "NoSuchKey")), + PutOutcome::PreconditionFailed); + EXPECT_EQ(classify(DB::S3Exception("retries exhausted, no name attached", Aws::S3::S3Errors::NO_SUCH_KEY)), + PutOutcome::PreconditionFailed); + + ThrowOnFinalizeBuffer unrelated(DB::S3Exception("503", Aws::S3::S3Errors::UNKNOWN, "SlowDown")); + EXPECT_THROW(finalizeConditionalWrite(unrelated), DB::S3Exception); + + ThrowOnFinalizeBuffer clean; + EXPECT_EQ(finalizeConditionalWrite(clean), PutOutcome::Done); +} + +namespace +{ + +/// A `LocalObjectStorage` that round-trips user metadata in-process. The production +/// `LocalObjectStorage` deliberately drops the `attributes` argument of `writeObject` and never +/// populates `ObjectMetadata::attributes` (local files carry no `x-amz-meta-*`), so it cannot stand +/// in for S3/RustFS when verifying the metadata threading. This test-only subclass records the +/// attributes passed on write, keyed by physical path, and injects them back on metadata reads — +/// exactly what a real object store does for `x-amz-meta-*`. It exercises the `EmulatedSingleProcess` +/// `ObjectStorageBackend` threading (`putIfAbsent` → `writeObject` attributes → `head` attributes) +/// without a live S3 backend; the real S3/RustFS round trip is verified empirically out-of-band. +class AttributePreservingLocalObjectStorage final : public DB::LocalObjectStorage +{ +public: + using DB::LocalObjectStorage::LocalObjectStorage; + + std::unique_ptr writeObject( + const DB::StoredObject & object, + DB::WriteMode mode, + std::optional attributes, + size_t buf_size, + const DB::WriteSettings & write_settings) override + { + if (attributes.has_value()) + { + std::lock_guard lock(mutex); + saved_attributes[object.remote_path] = *attributes; + } + return DB::LocalObjectStorage::writeObject(object, mode, attributes, buf_size, write_settings); + } + + std::optional tryGetObjectMetadata(const std::string & path, bool with_tags) const override + { + auto metadata = DB::LocalObjectStorage::tryGetObjectMetadata(path, with_tags); + if (metadata) + inject(path, *metadata); + return metadata; + } + + DB::ObjectMetadata getObjectMetadata(const std::string & path, bool with_tags) const override + { + auto metadata = DB::LocalObjectStorage::getObjectMetadata(path, with_tags); + inject(path, metadata); + return metadata; + } + +private: + void inject(const std::string & path, DB::ObjectMetadata & metadata) const + { + std::lock_guard lock(mutex); + if (auto it = saved_attributes.find(path); it != saved_attributes.end()) + metadata.attributes = it->second; + } + + mutable std::mutex mutex; + mutable std::map saved_attributes; +}; + +DB::ObjectStoragePtr makeAttributePreservingStorageForTest() +{ + static std::atomic counter{0}; + const auto unique = std::to_string(::getpid()) + "_" + std::to_string(counter.fetch_add(1)); + const auto root = (std::filesystem::temp_directory_path() / ("cas_meta_unit_" + unique)).string(); + + std::error_code ec; + std::filesystem::remove_all(root, ec); + std::filesystem::create_directories(root, ec); + + DB::LocalObjectStorageSettings settings("test", root, /*read_only_=*/false); + return std::make_shared(std::move(settings)); +} + +} + +/// The `EmulatedSingleProcess` `ObjectStorageBackend` must thread user metadata through to the +/// underlying object storage's `writeObject` attributes on `putIfAbsent` and read it back into +/// `HeadResult::attributes` on `head`. Verified here over an attribute-preserving object storage +/// (the production `LocalObjectStorage` drops attributes); the live S3/RustFS round trip is verified +/// empirically out-of-band. +TEST(CASObjectStorageBackend, EmulatedRoundTripsUserMetadata) +{ + ObjectStorageBackend backend(makeAttributePreservingStorageForTest(), ObjectStorageBackend::Mode::EmulatedSingleProcess); + + const DB::Cas::ObjectMeta meta{{"cas_owner", "ab:7:42"}}; + ASSERT_EQ(backend.putIfAbsent("k/key", "body", meta).outcome, DB::Cas::PutOutcome::Done); + + const auto hr = backend.head("k/key"); + ASSERT_TRUE(hr.exists); + ASSERT_EQ(hr.attributes.at("cas_owner"), "ab:7:42"); +} + +namespace +{ + +/// A `LocalObjectStorage` whose `readObject` throws `S3Exception(NO_SUCH_KEY)` for a configured +/// physical key, while `tryGetObjectMetadata` still reports that key as PRESENT. +/// This simulates the HEAD→GET race window: the HEAD succeeds, then the object is deleted before +/// the GET arrives. +class NativeReadThrowsNoSuchKeyObjectStorage final : public DB::LocalObjectStorage +{ +public: + using DB::LocalObjectStorage::LocalObjectStorage; + + void setThrowOnRead(const std::string & path) + { + throw_on_read_path = path; + } + + std::unique_ptr readObject( + const DB::StoredObject & object, + const DB::ReadSettings & read_settings, + std::optional read_hint, + bool use_external_buffer, + bool restrict_seek) const override + { + if (object.remote_path == throw_on_read_path) + throw DB::S3Exception( + "NoSuchKey: The specified key does not exist.", + Aws::S3::S3Errors::NO_SUCH_KEY); + + return DB::LocalObjectStorage::readObject(object, read_settings, read_hint, use_external_buffer, restrict_seek); + } + +private: + std::string throw_on_read_path; +}; + +struct ThrowOnReadFixture +{ + DB::ObjectStoragePtr storage; + /// Anchored under `storage`'s own root, because `Mode::Native` hands the key to the object storage + /// verbatim and this one is a real filesystem. + std::string key; +}; + +ThrowOnReadFixture makeThrowOnReadStorageForTest(const std::string & key_suffix) +{ + static std::atomic counter{0}; + const auto unique = std::to_string(::getpid()) + "_" + std::to_string(counter.fetch_add(1)); + const auto root = (std::filesystem::temp_directory_path() / ("cas_midget_unit_" + unique)).string(); + + std::error_code ec; + std::filesystem::remove_all(root, ec); + std::filesystem::create_directories(root, ec); + + DB::LocalObjectStorageSettings settings("test", root, /*read_only_=*/false); + auto storage = std::make_shared(std::move(settings)); + const std::string key = DB::Cas::tests::nativeKeyUnder(storage, key_suffix); + + /// Write the object so tryGetObjectMetadata reports it present (HEAD succeeds). + { + auto buf = storage->writeObject(DB::StoredObject(key), DB::WriteMode::Rewrite, std::nullopt); + buf->write("content", 7); + buf->finalize(); + } + + /// Now configure: future readObject calls for this key will throw NO_SUCH_KEY. + storage->setThrowOnRead(key); + return {std::move(storage), key}; +} + +} + +/// `ObjectStorageBackend::get` in `Native` mode: when `tryGetObjectMetadata` (`nativeHead`) reports the +/// key PRESENT but `readObject` throws `S3Exception(NO_SUCH_KEY)` — simulating a deletion in the +/// HEAD→GET window — `get` MUST return `std::nullopt` rather than letting the raw exception escape. +TEST(CASObjectStorageBackend, NativeModeGetReturnsNulloptOnMidGetNoSuchKey) +{ + /// The Native mode backend uses the key verbatim as the physical path (no emu_root prefix), so the + /// logical key IS the physical one the fixture wrote and armed. + const auto fixture = makeThrowOnReadStorageForTest("pool/blobs/ab/abcdef0123456789abcdef0123456789"); + + ObjectStorageBackend backend(fixture.storage, ObjectStorageBackend::Mode::Native); + + /// `get` HEADs before it reads and answers nullopt for an absent key, so without this the nullopt + /// below would be satisfied by an object the fixture failed to place — the mid-GET race would go + /// untested and the case would still pass. + Backend & iface = backend; + ASSERT_TRUE(iface.head(fixture.key).exists); + + /// HEAD reports the key present; readObject then throws NO_SUCH_KEY. + /// Contract: get must return std::nullopt, not propagate the S3Exception. + /// Call through the base-class interface so the default `Range{}` arg is available. + const auto result = iface.get(fixture.key); + EXPECT_FALSE(result.has_value()); +} + +/// A ranged `get` over a real `LocalObjectStorage` returns exactly the requested window, with the +/// same clamping the old read-whole-then-substr path had: a window whose offset is at or past EOF +/// yields an empty result. The only-the-window I/O property (no whole-object read) is enforced by +/// the `readObjectRanged` rewrite and cross-checked by the request-size gate in a later task. +TEST(CASObjectStorageBackend, RangedGetReadsOnlyTheWindow) +{ + auto backend = std::make_shared( + tests::makeLocalObjectStorageForTest(), ObjectStorageBackend::Mode::EmulatedSingleProcess); + + const String payload = String(300000, 'a') + String(300000, 'b') + String(300000, 'c'); + backend->putIfAbsent("p/obj", payload); + + const auto mid = backend->get("p/obj", DB::Cas::Range{.offset = 300000, .length = 300000}); + ASSERT_TRUE(mid.has_value()); + EXPECT_EQ(mid->bytes, String(300000, 'b')); + + const auto tail = backend->get("p/obj", DB::Cas::Range{.offset = 600000, .length = std::nullopt}); + ASSERT_TRUE(tail.has_value()); + EXPECT_EQ(tail->bytes, String(300000, 'c')); + + const auto past = backend->get("p/obj", DB::Cas::Range{.offset = 1000000, .length = 10}); + ASSERT_TRUE(past.has_value()); + EXPECT_TRUE(past->bytes.empty()); +} + +/// codex-review-triage §3.18, finding 19c: the `EmulatedSingleProcess` adapter used to mint tokens +/// from a plain in-process counter (`emu_seq`), NOT actually seeded from the underlying object's etag +/// despite the class comment's claim. After a process restart (modeled here as a fresh +/// `ObjectStorageBackend` instance over the SAME storage) the counter restarts at 0 and can re-mint a +/// value that TEXTUALLY collides with a token persisted before the restart (e.g. a GC condemned-delete +/// token queued for replay), even though the two values name completely different incarnations of the +/// key. `deleteExact` must never let a stale, pre-restart token match a freshly recreated object. +TEST(CASObjectStorageBackend, EmuTokenSurvivesProcessRestartAcrossRecreate) +{ + auto storage = tests::makeLocalObjectStorageForTest(); + + auto backend1 = std::make_shared(storage, ObjectStorageBackend::Mode::EmulatedSingleProcess); + /// A throwaway prior mutation on a DIFFERENT key: with the old counter this advances backend1's + /// process-wide op counter to 1, so "k/restart"'s own mint below lands on 2 — chosen so it collides + /// with backend2's post-restart recreate mint further down (also its SECOND op; see there). + ASSERT_EQ(backend1->putIfAbsent("k/other", "junk").outcome, PutOutcome::Done); + ASSERT_EQ(backend1->putIfAbsent("k/restart", "v1").outcome, PutOutcome::Done); + const Token stale_token = backend1->head("k/restart").token; + + /// Simulate a process restart: a brand-new `ObjectStorageBackend` instance (fresh emu state) over + /// the SAME underlying storage — exactly what happens when the CAS process restarts. + auto backend2 = std::make_shared(storage, ObjectStorageBackend::Mode::EmulatedSingleProcess); + + /// Delete and recreate the key through the NEW instance — a fresh incarnation with a fresh mtime. + /// This is backend2's first-ever op (op 1) then a delete (no mint) then the recreate (op 2) — the + /// same op-index as `stale_token` above under the old counter, so the two textually collide there. + const Token current = backend2->head("k/restart").token; + ASSERT_EQ(backend2->deleteExact("k/restart", current).kind, DeleteOutcome::Kind::Deleted); + ASSERT_EQ(backend2->putIfAbsent("k/restart", "v2-after-restart").outcome, PutOutcome::Done); + + /// The pre-restart token must NEVER match the post-restart incarnation, however coincidentally a + /// process-local counter would have re-minted the identical textual value. + const auto stale_delete = backend2->deleteExact("k/restart", stale_token); + EXPECT_EQ(stale_delete.kind, DeleteOutcome::Kind::TokenMismatch); + + /// The live (post-restart) incarnation must be untouched by the rejected stale delete. + EXPECT_TRUE(backend2->head("k/restart").exists); +} + +/// codex-review-triage §3.18, finding №18: `list`'s `EmulatedSingleProcess` branch minted its per-key +/// token via `tokenForList`, which always stamps `native_token_type` (ETag) REGARDLESS of `mode` -- +/// while `head`/`get` mint `TokenType::Emulated`. `Token::operator==` compares type AND value, so a +/// list-derived token could never satisfy an emulated `deleteExact`/`putOverwrite` expectation: a +/// fail-safe leak (never a wrong delete), but every consumer of listed tokens (GC namespace cleanup, +/// `deletePrefixWholesale`, orphan sweep, decommission drain) always saw `TokenMismatch` against a +/// LOCAL pool. `list` must surface the SAME (type, value) as `head` for the same key. +TEST(CASObjectStorageBackend, EmulatedListTokenMatchesHeadToken) +{ + auto backend = std::make_shared( + tests::makeLocalObjectStorageForTest(), ObjectStorageBackend::Mode::EmulatedSingleProcess); + + ASSERT_EQ(backend->putIfAbsent("k/listed", "body").outcome, PutOutcome::Done); + + const Token head_token = backend->head("k/listed").token; + ASSERT_EQ(head_token.type, TokenType::Emulated); + + const ListPage page = backend->list("k/", "", /*limit=*/10); + ASSERT_EQ(page.keys.size(), 1u); + ASSERT_TRUE(page.keys.front().token.has_value()); + EXPECT_EQ(*page.keys.front().token, head_token); +} + +namespace +{ + +/// A `LocalObjectStorage` whose reported etag never changes -- simulating a filesystem/clock whose +/// mtime resolution is too coarse to separate two writes issued back-to-back (the "same mtime +/// quantum" hazard flagged for the etag-seeded emu token: two DIFFERENT incarnations must still mint +/// DIFFERENT tokens even when the storage's own etag does not advance between them). +class FixedEtagLocalObjectStorage final : public DB::LocalObjectStorage +{ +public: + using DB::LocalObjectStorage::LocalObjectStorage; + + std::optional tryGetObjectMetadata(const std::string & path, bool with_tags) const override + { + auto metadata = DB::LocalObjectStorage::tryGetObjectMetadata(path, with_tags); + if (metadata) + metadata->etag = "same-quantum"; + return metadata; + } +}; + +DB::ObjectStoragePtr makeFixedEtagStorageForTest() +{ + static std::atomic counter{0}; + const auto unique = std::to_string(::getpid()) + "_" + std::to_string(counter.fetch_add(1)); + const auto root = (std::filesystem::temp_directory_path() / ("cas_fixed_etag_unit_" + unique)).string(); + + std::error_code ec; + std::filesystem::remove_all(root, ec); + std::filesystem::create_directories(root, ec); + + DB::LocalObjectStorageSettings settings("test", root, /*read_only_=*/false); + return std::make_shared(std::move(settings)); +} + +} + +/// The mtime-resolution guard (codex-review-triage §3.18, 19c step 4): two writes to the same key +/// whose underlying etag does not advance between them (stubbed here to model a coarse clock) must +/// still mint DISTINCT emulated tokens, and a stale token from the first incarnation must not match +/// the second. +TEST(CASObjectStorageBackend, EmuTokenDisambiguatesSameEtagRewrite) +{ + ObjectStorageBackend backend(makeFixedEtagStorageForTest(), ObjectStorageBackend::Mode::EmulatedSingleProcess); + + const auto put1 = backend.putIfAbsent("k/tick", "v1"); + ASSERT_EQ(put1.outcome, PutOutcome::Done); + const auto put2 = backend.putOverwrite("k/tick", "v2", put1.token); + ASSERT_EQ(put2.outcome, PutOutcome::Done); + + EXPECT_NE(put1.token.value, put2.token.value); + EXPECT_EQ(put1.token.type, TokenType::Emulated); + EXPECT_EQ(put2.token.type, TokenType::Emulated); + + /// A stale delete using the FIRST incarnation's token must not match the live (second) one. + EXPECT_EQ(backend.deleteExact("k/tick", put1.token).kind, DeleteOutcome::Kind::TokenMismatch); + EXPECT_TRUE(backend.head("k/tick").exists); +} + +namespace +{ + +/// A `LocalObjectStorage` that always reports a caller-supplied, fixed NUMERIC etag string — lets a +/// test pin `emuMintToken`'s etag input to a precise, controlled nanosecond value (an old timestamp +/// vs. one close to "now") regardless of the real filesystem clock. Used to test the +/// `emu_token_state` erase-on-delete bound (codex-review-triage §3.18, Important #1): the entry +/// must be erased only when the deleted incarnation's own etag is comfortably in the past. +class FixedNumericEtagLocalObjectStorage final : public DB::LocalObjectStorage +{ +public: + FixedNumericEtagLocalObjectStorage(DB::LocalObjectStorageSettings settings, String etag_) + : DB::LocalObjectStorage(std::move(settings)), etag(std::move(etag_)) + { + } + + std::optional tryGetObjectMetadata(const std::string & path, bool with_tags) const override + { + auto metadata = DB::LocalObjectStorage::tryGetObjectMetadata(path, with_tags); + if (metadata) + metadata->etag = etag; + return metadata; + } + +private: + String etag; +}; + +DB::ObjectStoragePtr makeFixedNumericEtagStorageForTest(const String & etag) +{ + static std::atomic counter{0}; + const auto unique = std::to_string(::getpid()) + "_" + std::to_string(counter.fetch_add(1)); + const auto root = (std::filesystem::temp_directory_path() / ("cas_fixed_numeric_etag_unit_" + unique)).string(); + + std::error_code ec; + std::filesystem::remove_all(root, ec); + std::filesystem::create_directories(root, ec); + + DB::LocalObjectStorageSettings settings("test", root, /*read_only_=*/false); + return std::make_shared(std::move(settings), etag); +} + +class ClockEtagLocalObjectStorage final : public DB::LocalObjectStorage +{ +public: + ClockEtagLocalObjectStorage(DB::LocalObjectStorageSettings settings, std::shared_ptr> now_ns_) + : DB::LocalObjectStorage(std::move(settings)), now_ns(std::move(now_ns_)) + { + } + + std::optional tryGetObjectMetadata(const std::string & path, bool with_tags) const override + { + auto metadata = DB::LocalObjectStorage::tryGetObjectMetadata(path, with_tags); + if (metadata) + metadata->etag = std::to_string(now_ns->load()); + return metadata; + } + +private: + std::shared_ptr> now_ns; +}; + +DB::ObjectStoragePtr makeClockEtagStorageForTest(const std::shared_ptr> & now_ns) +{ + static std::atomic counter{0}; + const auto unique = std::to_string(::getpid()) + "_" + std::to_string(counter.fetch_add(1)); + const auto root = (std::filesystem::temp_directory_path() / ("cas_clock_etag_unit_" + unique)).string(); + + std::error_code ec; + std::filesystem::remove_all(root, ec); + std::filesystem::create_directories(root, ec); + + DB::LocalObjectStorageSettings settings("test", root, /*read_only_=*/false); + return std::make_shared(std::move(settings), now_ns); +} + +} + +/// codex-review-triage §3.18, Important #1: `emu_token_state` must be BOUNDED, not grow for the +/// lifetime of the backend instance. `deleteExact` erases a key's entry only when its last-minted +/// etag is comfortably (>= 2s) in the past — recent enough to still collide with an immediate +/// same-process recreate must be RETAINED (the mtime-quantum guard stays intact). +TEST(CASObjectStorageBackend, DeleteExactErasesEmuTokenStateOnlyWhenEtagIsComfortablyOld) +{ + /// An etag far in the past (nanoseconds since epoch, ~2001): delete must erase the entry, so an + /// immediate recreate reporting the SAME fixed etag is treated as a brand-new incarnation (bare + /// etag, no disambiguator) rather than a same-quantum tie with the just-consumed delete token. + { + const String old_etag = "1000000000000000000"; + ObjectStorageBackend backend(makeFixedNumericEtagStorageForTest(old_etag), ObjectStorageBackend::Mode::EmulatedSingleProcess); + + const auto put1 = backend.putIfAbsent("k/old", "v1"); + ASSERT_EQ(put1.outcome, PutOutcome::Done); + ASSERT_EQ(put1.token.value, old_etag); + ASSERT_EQ(backend.deleteExact("k/old", put1.token).kind, DeleteOutcome::Kind::Deleted); + + const auto put2 = backend.putIfAbsent("k/old", "v2"); + ASSERT_EQ(put2.outcome, PutOutcome::Done); + EXPECT_EQ(put2.token.value, old_etag) << "entry should have been erased on delete (etag comfortably old), " + "so the recreate mints the bare etag, not a disambiguated one"; + } + + /// An etag within the safety margin of "now": delete must RETAIN the entry, so the same + /// immediate-recreate scenario still gets disambiguated -- the guard this bound must not break. + { + const auto now_ns = std::chrono::duration_cast( + std::chrono::system_clock::now().time_since_epoch()).count(); + const String recent_etag = std::to_string(now_ns); + ObjectStorageBackend backend(makeFixedNumericEtagStorageForTest(recent_etag), ObjectStorageBackend::Mode::EmulatedSingleProcess); + + const auto put1 = backend.putIfAbsent("k/fresh", "v1"); + ASSERT_EQ(put1.outcome, PutOutcome::Done); + ASSERT_EQ(put1.token.value, recent_etag); + ASSERT_EQ(backend.deleteExact("k/fresh", put1.token).kind, DeleteOutcome::Kind::Deleted); + + const auto put2 = backend.putIfAbsent("k/fresh", "v2"); + ASSERT_EQ(put2.outcome, PutOutcome::Done); + EXPECT_EQ(put2.token.value, recent_etag + "#1") << "entry should have been RETAINED on delete (etag recent), " + "so the recreate is disambiguated against it"; + } +} + +TEST(CASObjectStorageBackend, EmuTokenStateEventuallyPrunesDistinctShortLivedKeys) +{ + constexpr uint64_t start_ns = 1'700'000'000'000'000'000ULL; + constexpr uint64_t step_ns = 100'000'000ULL; + constexpr size_t key_count = 128; + constexpr size_t expected_recent_key_bound = 24; + + auto now_ns = std::make_shared>(start_ns); + ObjectStorageBackend backend(makeClockEtagStorageForTest(now_ns), ObjectStorageBackend::Mode::EmulatedSingleProcess); + + for (size_t i = 0; i < key_count; ++i) + { + const uint64_t current_ns = start_ns + i * step_ns; + now_ns->store(current_ns); + backend.setEmuNowNsForTest(current_ns); + + const String key = "k/short-lived-" + std::to_string(i); + const auto put = backend.putIfAbsent(key, "body"); + ASSERT_EQ(put.outcome, PutOutcome::Done); + ASSERT_EQ(backend.deleteExact(key, put.token).kind, DeleteOutcome::Kind::Deleted); + } + + const uint64_t sweep_ns = start_ns + key_count * step_ns + 2'000'000'000ULL; + now_ns->store(sweep_ns); + backend.setEmuNowNsForTest(sweep_ns); + const auto trigger = backend.putIfAbsent("k/sweep-trigger", "body"); + ASSERT_EQ(trigger.outcome, PutOutcome::Done); + ASSERT_EQ(backend.deleteExact("k/sweep-trigger", trigger.token).kind, DeleteOutcome::Kind::Deleted); + + EXPECT_LE(backend.emuTokenStateSizeForTest(), expected_recent_key_bound) + << "token state should track only the bounded recent-key window, not all " << key_count << " deleted keys"; +} + +namespace +{ + +/// A `LocalObjectStorage` that counts `writeObject`/`removeObjectIfTokenMatches` calls -- used to +/// prove that a wrong-dialect expected token is rejected LOCALLY, before anything reaches the wire. +class CallCountingObjectStorage final : public DB::LocalObjectStorage +{ +public: + using DB::LocalObjectStorage::LocalObjectStorage; + + std::unique_ptr writeObject( + const DB::StoredObject & object, + DB::WriteMode mode, + std::optional attributes, + size_t buf_size, + const DB::WriteSettings & write_settings) override + { + ++write_calls; + return DB::LocalObjectStorage::writeObject(object, mode, attributes, buf_size, write_settings); + } + + DB::ConditionalRemoveResult removeObjectIfTokenMatches(const DB::StoredObject & object, const std::string & etag) override + { + ++remove_if_matches_calls; + return DB::LocalObjectStorage::removeObjectIfTokenMatches(object, etag); + } + + std::atomic write_calls{0}; + std::atomic remove_if_matches_calls{0}; +}; + +DB::ObjectStoragePtr makeCallCountingStorageForTest() +{ + static std::atomic counter{0}; + const auto unique = std::to_string(::getpid()) + "_" + std::to_string(counter.fetch_add(1)); + const auto root = (std::filesystem::temp_directory_path() / ("cas_call_counting_unit_" + unique)).string(); + + std::error_code ec; + std::filesystem::remove_all(root, ec); + std::filesystem::create_directories(root, ec); + + DB::LocalObjectStorageSettings settings("test", root, /*read_only_=*/false); + return std::make_shared(std::move(settings)); +} + +} + +/// codex-review-triage §3.18, finding №19: Native-mode conditional mutations forward only +/// `Token::value` to the wire (`object_storage_write_if_match` / `removeObjectIfTokenMatches`), +/// blind to `Token::type`. A wrong-dialect token whose VALUE happens to equal the live incarnation's +/// must be rejected LOCALLY -- before any wire call is made -- never merely rely on the remote +/// backend to reject a foreign-dialect value it was never designed to compare. +TEST(CASObjectStorageBackend, NativeRejectsWrongDialectTokenBeforeTouchingTheWire) +{ + auto storage = std::static_pointer_cast(makeCallCountingStorageForTest()); + ObjectStorageBackend backend(storage, ObjectStorageBackend::Mode::Native); + + ASSERT_EQ(backend.putIfAbsent("k/dialect", "v1").outcome, PutOutcome::Done); + const Token live = backend.head("k/dialect").token; + ASSERT_EQ(live.type, TokenType::ETag); + + storage->write_calls = 0; + storage->remove_if_matches_calls = 0; + + /// Same wire VALUE, wrong dialect TYPE (Emulated instead of this backend's native ETag dialect). + const Token wrong_type_token{live.value, TokenType::Emulated}; + + EXPECT_EQ(backend.putOverwrite("k/dialect", "v2", wrong_type_token).outcome, PutOutcome::PreconditionFailed); + EXPECT_EQ(backend.casPut("k/dialect", "v2", wrong_type_token).outcome, CasOutcome::Conflict); + EXPECT_EQ(backend.deleteExact("k/dialect", wrong_type_token).kind, DeleteOutcome::Kind::TokenMismatch); + + EXPECT_EQ(storage->write_calls.load(), 0); + EXPECT_EQ(storage->remove_if_matches_calls.load(), 0); + + /// The live incarnation must be untouched by all three rejected attempts. + EXPECT_EQ(backend.head("k/dialect").token, live); +} + +/// §1 (opt round-B): the fold/point GETs read tiny bodies but a default `ReadBufferFromS3` preallocates +/// ~1 MiB. `casSizedReadSettings` shrinks the buffer to the known body size + slack, capped at the +/// caller's default — never larger than before, regardless of the reported size. +TEST(CASSizedReadSettings, CapsToKnownSizePlusSlackButNeverAboveBase) +{ + DB::ReadSettings base; + base.remote_fs_settings.buffer_size = 1ULL << 20; /// 1 MiB default + base.local_fs_settings.buffer_size = 1ULL << 20; + + /// A ~3.7 KB fold body: buffer shrinks to size + slack, far below the 1 MiB default. + const auto small = DB::Cas::casSizedReadSettings(base, 3700); + EXPECT_EQ(small.remote_fs_settings.buffer_size, 3700 + DB::Cas::CAS_FOLD_READ_SLACK_BYTES); + EXPECT_EQ(small.local_fs_settings.buffer_size, 3700 + DB::Cas::CAS_FOLD_READ_SLACK_BYTES); + + /// A body larger than the default is capped AT the default (never grown). + const auto big = DB::Cas::casSizedReadSettings(base, 8ULL << 20); + EXPECT_EQ(big.remote_fs_settings.buffer_size, 1ULL << 20); + + /// Unknown size (0) = leave the base untouched (the metadata-fetch fallback path). + const auto unknown = DB::Cas::casSizedReadSettings(base, 0); + EXPECT_EQ(unknown.remote_fs_settings.buffer_size, 1ULL << 20); +} + +/// The CountingBackend request-shape recorders that the streaming-memory gates (Task 3/4) consume: +/// per-key/total getStream counts, the max ranged-get window per key, and the whole-object get flag. +TEST(CountingBackendShape, RecordsGetStreamAndRangeShape) +{ + DB::Cas::tests::CountingBackend backend; + backend.putIfAbsent("k", String(1000, 'x')); + + /// A whole-object get flags the resident-memory violation; a ranged get tracks the max window. + backend.get("k"); + backend.get("k", DB::Cas::Range{.offset = 0, .length = 100}); + backend.get("k", DB::Cas::Range{.offset = 10, .length = 400}); + EXPECT_EQ(backend.wholeGetCount("k"), 1u); + EXPECT_EQ(backend.maxRangedGetLen("k"), 400u); + + /// getStream counters (per-key and total). + backend.getStream("k", DB::Cas::Range{.offset = 2, .length = 5}); + backend.getStream("k"); + backend.getStream("absent"); + EXPECT_EQ(backend.getStreamCount("k"), 2u); + EXPECT_EQ(backend.getStreamTotal(), 3u); + + backend.resetCounts(); + EXPECT_EQ(backend.wholeGetCount("k"), 0u); + EXPECT_EQ(backend.maxRangedGetLen("k"), 0u); + EXPECT_EQ(backend.getStreamTotal(), 0u); +} + +#endif diff --git a/src/Disks/tests/gtest_cas_backend_contract.cpp b/src/Disks/tests/gtest_cas_backend_contract.cpp new file mode 100644 index 000000000000..ee665ac5067e --- /dev/null +++ b/src/Disks/tests/gtest_cas_backend_contract.cpp @@ -0,0 +1,301 @@ +#include +#include +#include +#include +#include +#include +#include +#include + +using namespace DB::Cas; + +/// Parameterized contract suite: every case creates a fresh backend from the factory, +/// then exercises the Backend seam generically (no InMemoryBackend-specific calls). +/// Fault-injection-only features are excluded — those are InMemory-specific tests. +class CASBackendContract : public ::testing::TestWithParam> +{ +}; + +TEST_P(CASBackendContract, PutIfAbsentAndGet) +{ + auto b = GetParam()(); + const auto put = b->putIfAbsent("k", "v1"); + const Token t1 = put.token; + EXPECT_EQ(put.outcome, PutOutcome::Done); + EXPECT_FALSE(t1.empty()); + EXPECT_EQ(b->putIfAbsent("k", "clobber").outcome, PutOutcome::PreconditionFailed); + auto g = b->get("k"); + ASSERT_TRUE(g.has_value()); + EXPECT_EQ(g->bytes, "v1"); + EXPECT_EQ(g->token, t1); + EXPECT_FALSE(b->get("absent").has_value()); +} + +TEST_P(CASBackendContract, OverwriteIsTokenExactAndMintsFreshToken) +{ + auto b = GetParam()(); + const Token t1 = b->putIfAbsent("k", "v1").token; + EXPECT_EQ(b->putOverwrite("k", "v2", Token{"wrong", TokenType::Emulated}).outcome, PutOutcome::PreconditionFailed); + EXPECT_EQ(b->get("k")->bytes, "v1"); // untouched on mismatch + const auto overwrite = b->putOverwrite("k", "v2", t1); + EXPECT_EQ(overwrite.outcome, PutOutcome::Done); + EXPECT_NE(overwrite.token, t1); // tokens never repeat + EXPECT_EQ(b->get("k")->bytes, "v2"); +} + +TEST_P(CASBackendContract, CasPutCreateAndSwap) +{ + auto b = GetParam()(); + const auto create = b->casPut("m", "s1", std::nullopt); + const Token t1 = create.token; + EXPECT_EQ(create.outcome, CasOutcome::Committed); // create-if-absent + EXPECT_EQ(b->casPut("m", "s1x", std::nullopt).outcome, CasOutcome::Conflict); // exists now + EXPECT_EQ(b->casPut("m", "s2", Token{"stale", TokenType::Emulated}).outcome, CasOutcome::Conflict); + EXPECT_EQ(b->get("m")->bytes, "s1"); + EXPECT_EQ(b->casPut("m", "s2", t1).outcome, CasOutcome::Committed); + EXPECT_EQ(b->get("m")->bytes, "s2"); +} + +TEST_P(CASBackendContract, DeleteExactnessAndSurvival) +{ + auto b = GetParam()(); + const Token t1 = b->putIfAbsent("k", "v1").token; + auto d1 = b->deleteExact("k", Token{"wrong", TokenType::Emulated}); + EXPECT_EQ(d1.kind, DeleteOutcome::Kind::TokenMismatch); + EXPECT_TRUE(b->get("k").has_value()); // SURVIVES wrong-token delete + auto d2 = b->deleteExact("k", t1); + EXPECT_EQ(d2.kind, DeleteOutcome::Kind::Deleted); + EXPECT_FALSE(d2.created_delete_marker); + EXPECT_FALSE(b->get("k").has_value()); +} + +TEST_P(CASBackendContract, DeleteNotFound) +{ + auto b = GetParam()(); + const Token t1 = b->putIfAbsent("k", "v1").token; + b->deleteExact("k", t1); + EXPECT_EQ(b->deleteExact("k", t1).kind, DeleteOutcome::Kind::NotFound); +} + +TEST_P(CASBackendContract, RangeGet) +{ + auto b = GetParam()(); + b->putIfAbsent("k", "0123456789"); + Range r; + r.offset = 2; + r.length = 3u; + EXPECT_EQ(b->get("k", r)->bytes, "234"); +} + +TEST_P(CASBackendContract, Head) +{ + auto b = GetParam()(); + b->putIfAbsent("k", "hello"); + auto h = b->head("k"); + EXPECT_TRUE(h.exists); + EXPECT_EQ(h.size, 5u); + EXPECT_FALSE(h.token.empty()); + auto h2 = b->head("missing"); + EXPECT_FALSE(h2.exists); +} + +TEST_P(CASBackendContract, ListPagination) +{ + auto b = GetParam()(); + b->putIfAbsent("p/a", "0123456789"); + b->putIfAbsent("p/b", "xy"); + b->putIfAbsent("q/c", "z"); + auto page = b->list("p/", "", 10); + ASSERT_EQ(page.keys.size(), 2u); // sorted, prefix-scoped + EXPECT_EQ(page.keys[0].key, "p/a"); + EXPECT_EQ(page.keys[1].key, "p/b"); + EXPECT_TRUE(page.next_cursor.empty()); + auto page1 = b->list("p/", "", 1); // pagination + EXPECT_EQ(page1.keys.size(), 1u); + EXPECT_EQ(page1.keys[0].key, "p/a"); + EXPECT_EQ(page1.next_cursor, "p/a"); + EXPECT_FALSE(page1.next_cursor.empty()); + auto page2 = b->list("p/", page1.next_cursor, 1); + EXPECT_EQ(page2.keys[0].key, "p/b"); +} + +TEST_P(CASBackendContract, ReadAfterWrite) +{ + auto b = GetParam()(); + const Token t1 = b->putIfAbsent("rw", "payload").token; + auto g = b->get("rw"); + ASSERT_TRUE(g.has_value()); + EXPECT_EQ(g->bytes, "payload"); + EXPECT_EQ(g->token, t1); + auto h = b->head("rw"); + EXPECT_TRUE(h.exists); + EXPECT_EQ(h.token, t1); +} + +/// After an object is created then deleted (key absent again), BOTH conditional updates against a stale +/// token must be rejected with the object still absent — a token-conditional update can never resurrect a +/// missing key. For the Native S3 adapter this pins the 404-on-If-Match -> PreconditionFailed/Conflict +/// mapping; for every backend it pins that absence is not a write opportunity for a stale token. +TEST_P(CASBackendContract, OverwriteAndCasOnMissingKey) +{ + auto b = GetParam()(); + const Token t1 = b->putIfAbsent("k", "v1").token; + EXPECT_EQ(b->deleteExact("k", t1).kind, DeleteOutcome::Kind::Deleted); + ASSERT_FALSE(b->get("k").has_value()); // key is absent + + EXPECT_EQ(b->putOverwrite("k", "v2", t1).outcome, PutOutcome::PreconditionFailed); + EXPECT_FALSE(b->get("k").has_value()); // still absent + + EXPECT_EQ(b->casPut("k", "v2", t1).outcome, CasOutcome::Conflict); + EXPECT_FALSE(b->get("k").has_value()); // still absent +} + +TEST_P(CASBackendContract, StreamPutRoundTrip) +{ + auto b = GetParam()(); + auto sink = b->putIfAbsentStream("k/stream1"); + sink->buffer().write("hello ", 6); + sink->buffer().write("world", 5); + const auto res = sink->finalize(); + const Token tok = res.token; + ASSERT_EQ(res.outcome, PutOutcome::Done); + ASSERT_FALSE(tok.empty()); + auto got = b->get("k/stream1"); + ASSERT_TRUE(got.has_value()); + EXPECT_EQ(got->bytes, "hello world"); + EXPECT_EQ(got->token, tok); +} + +TEST_P(CASBackendContract, StreamPutPreconditionAtFinalize) +{ + auto b = GetParam()(); + const auto first_put = b->putIfAbsent("k/stream2", "original"); + const Token first = first_put.token; + ASSERT_EQ(first_put.outcome, PutOutcome::Done); + auto sink = b->putIfAbsentStream("k/stream2"); + sink->buffer().write("loser", 5); + ASSERT_EQ(sink->finalize().outcome, PutOutcome::PreconditionFailed); + auto got = b->get("k/stream2"); + ASSERT_TRUE(got.has_value()); + EXPECT_EQ(got->bytes, "original"); /// the failed conditional write left the object unmodified + EXPECT_EQ(got->token, first); +} + +TEST_P(CASBackendContract, StreamPutCancelLeavesNothing) +{ + auto b = GetParam()(); + { + auto sink = b->putIfAbsentStream("k/stream3"); + sink->buffer().write("partial", 7); + sink->cancel(); + } + EXPECT_FALSE(b->head("k/stream3").exists); +} + +TEST_P(CASBackendContract, StreamPutDestructionWithoutFinalizeLeavesNothing) +{ + auto b = GetParam()(); + { + auto sink = b->putIfAbsentStream("k/stream4"); + sink->buffer().write("partial", 7); + /// no finalize, no cancel — destructor must behave as cancel (never publish) + } + EXPECT_FALSE(b->head("k/stream4").exists); +} + +TEST_P(CASBackendContract, StreamPutEmptyBody) +{ + auto b = GetParam()(); + auto sink = b->putIfAbsentStream("k/stream_empty"); + const auto res = sink->finalize(); + const Token tok = res.token; + ASSERT_EQ(res.outcome, PutOutcome::Done); + ASSERT_FALSE(tok.empty()); + auto got = b->get("k/stream_empty"); + ASSERT_TRUE(got.has_value()); + EXPECT_TRUE(got->bytes.empty()); + EXPECT_EQ(got->token, tok); + auto h = b->head("k/stream_empty"); + EXPECT_TRUE(h.exists); + EXPECT_EQ(h.size, 0u); +} + +/// ~1 MB written in chunks: exercises buffer growth in the memory-buffered sinks and, for the +/// future Native sink, the real streaming path. +TEST_P(CASBackendContract, StreamPutLargeBody) +{ + auto b = GetParam()(); + String chunk(4096, '\0'); + for (size_t i = 0; i < chunk.size(); ++i) + chunk[i] = static_cast('a' + i % 26); + + String expected; + auto sink = b->putIfAbsentStream("k/stream_large"); + for (size_t written = 0; written < (1 << 20); written += chunk.size()) + { + sink->buffer().write(chunk.data(), chunk.size()); + expected += chunk; + } + const auto res = sink->finalize(); + const Token tok = res.token; + ASSERT_EQ(res.outcome, PutOutcome::Done); + + auto got = b->get("k/stream_large"); + ASSERT_TRUE(got.has_value()); + ASSERT_EQ(got->bytes.size(), expected.size()); + EXPECT_EQ(got->bytes, expected); + EXPECT_EQ(got->token, tok); +} + +INSTANTIATE_TEST_SUITE_P(CASInMemory, CASBackendContract, + ::testing::Values(+[]() -> BackendPtr { return std::make_shared(); })); + +INSTANTIATE_TEST_SUITE_P(CASLocal, CASBackendContract, + ::testing::Values(+[]() -> BackendPtr + { + return std::make_shared( + DB::Cas::tests::makeLocalObjectStorageForTest(), ObjectStorageBackend::Mode::EmulatedSingleProcess); + })); + +/// The unconditional resurrect counts while streaming and aborts WITHOUT publishing when the reader +/// yields a different byte count than declared. With no precondition on the write, this is the last +/// line of defence against a source truncated after hashing: a post-write check would fire only after +/// the short body had displaced the condemned incarnation. +TEST_P(CASBackendContract, ResurrectWrongSizePublishesNothing) +{ + auto b = GetParam()(); + const auto created = b->putIfAbsent("k/res_short", "condemned-body"); + ASSERT_EQ(created.outcome, PutOutcome::Done); + + DB::ReadBufferFromOwnString in{String("short")}; + EXPECT_THROW(b->resurrect(in, /*payload_size=*/1000, "k/res_short", String("HDR")), DB::Exception); + + const auto got = b->get("k/res_short"); + ASSERT_TRUE(got.has_value()); + EXPECT_EQ(got->bytes, "condemned-body") << "a size-mismatched resurrect must publish nothing"; + EXPECT_EQ(got->token, created.token); +} + +/// The resurrect works on EVERY mode of every backend -- the emulated (local object storage) mode +/// included. The former conditional putOverwrite supported local disks, and losing that would make a +/// condemned blob unrepairable on a local content-addressed disk. +TEST_P(CASBackendContract, ResurrectReplacesBodyAndMintsFreshToken) +{ + auto b = GetParam()(); + const auto created = b->putIfAbsent("k/res_ok", "condemned-body"); + ASSERT_EQ(created.outcome, PutOutcome::Done); + + const String payload = "resurrected-payload"; + DB::ReadBufferFromOwnString in{payload}; + const Token fresh = b->resurrect(in, payload.size(), "k/res_ok", String("HDR")); + EXPECT_FALSE(fresh.empty()); + EXPECT_NE(fresh, created.token); + + const auto got = b->get("k/res_ok"); + ASSERT_TRUE(got.has_value()); + EXPECT_EQ(got->bytes, "HDR" + payload); + + /// INV-NO-RETURN: the queued exact-token delete of the condemned incarnation misses the fresh one. + EXPECT_EQ(b->deleteExact("k/res_ok", created.token).kind, DeleteOutcome::Kind::TokenMismatch); + EXPECT_TRUE(b->head("k/res_ok").exists); +} diff --git a/src/Disks/tests/gtest_cas_backend_generation.cpp b/src/Disks/tests/gtest_cas_backend_generation.cpp new file mode 100644 index 000000000000..c4476718195a --- /dev/null +++ b/src/Disks/tests/gtest_cas_backend_generation.cpp @@ -0,0 +1,132 @@ +#include +#include +#include +#include + +using namespace DB::Cas; + +/// Every Token{...} the backend mints must carry native_token_type instead of a hardcoded +/// TokenType::ETag (Task 5). Mode::Native over a LocalObjectStorage has no write-time ETag, so +/// putIfAbsent's PutResult falls back to a HEAD internally — that HEAD is also a stamping site, +/// so the assertion below exercises both the direct-etag and the HEAD-fallback mint paths. +TEST(CASBackendGeneration, StampedTokenTypeFollowsNativeKind) +{ + auto b = std::make_shared( + DB::Cas::tests::makeLocalObjectStorageForTest(), ObjectStorageBackend::Mode::Native); + b->setNativeTokenTypeForTest(TokenType::Generation); + + const auto put = b->putIfAbsent("p/gen/tok", "v1"); + EXPECT_EQ(put.token.type, TokenType::Generation); + + const auto hr = b->head("p/gen/tok"); + ASSERT_TRUE(hr.exists); + EXPECT_EQ(hr.token.type, TokenType::Generation); +} + +/// checkPoolPreconditions on a Native, generation-dialect (GCS) backend consults +/// isBucketVersioningEnabled. LocalObjectStorage does not override that method, so it inherits the +/// IObjectStorage base default, which returns nullopt (the check is inconclusive). Per the hook's +/// documented behaviour, an inconclusive check must NOT fail closed — only a CONFIRMED `true` throws. +TEST(CASBackendGeneration, CheckPoolPreconditionsProceedsOnUnknownVersioning) +{ + auto b = std::make_shared( + DB::Cas::tests::makeLocalObjectStorageForTest(), ObjectStorageBackend::Mode::Native); + b->setNativeTokenTypeForTest(TokenType::Generation); + + EXPECT_NO_THROW(b->checkPoolPreconditions()); +} + +/// The ETag-dialect (AWS-compatible) backend never consults bucket versioning at all — the check is +/// a silent no-op for any backend that is not Native + TokenType::Generation. +TEST(CASBackendGeneration, CheckPoolPreconditionsNoOpOnEtagDialect) +{ + auto b = std::make_shared( + DB::Cas::tests::makeLocalObjectStorageForTest(), ObjectStorageBackend::Mode::Native); + ASSERT_EQ(b->nativeTokenType(), TokenType::ETag); + + EXPECT_NO_THROW(b->checkPoolPreconditions()); +} + +/// GCS enforces NO preconditions on CompleteMultipartUpload (measured 2026-07-03), so a conditional +/// write on a generation-token store must never take the multipart path. conditionalWriteSettings +/// must force the single-PUT path (and raise the single-part cap to conditional_single_put_cap) when +/// the backend's native token kind is Generation, and stay a no-op otherwise (ETag dialect). +TEST(CASBackendGeneration, ListTokensDisabledOnGenerationStores) +{ + /// XML LIST bodies carry MD5-style ETags that the dialect cannot rewrite to generations; a + /// list-derived token on a generation store is a poisoned If-Match (live GC on GCS died there). + auto b = std::make_shared( + DB::Cas::tests::makeLocalObjectStorageForTest(), ObjectStorageBackend::Mode::Native); + EXPECT_TRUE(b->supportsListTokens()); + b->setNativeTokenTypeForTest(TokenType::Generation); + EXPECT_FALSE(b->supportsListTokens()); + b->setNativeTokenTypeForTest(TokenType::ETag); + EXPECT_TRUE(b->supportsListTokens()); +} + +TEST(CASBackendGeneration, ConditionalWriteSettingsForceSinglePutOnGenerationStores) +{ + auto b = std::make_shared( + DB::Cas::tests::makeLocalObjectStorageForTest(), ObjectStorageBackend::Mode::Native, + /*conditional_single_put_cap=*/123); + b->setNativeTokenTypeForTest(TokenType::Generation); + const auto ws = b->conditionalWriteSettingsForTest(); + EXPECT_TRUE(ws.s3_force_single_part_upload); + EXPECT_EQ(ws.s3_single_part_upload_max_bytes_override, 123u); + + b->setNativeTokenTypeForTest(TokenType::ETag); + const auto ws2 = b->conditionalWriteSettingsForTest(); + EXPECT_FALSE(ws2.s3_force_single_part_upload); + EXPECT_EQ(ws2.s3_single_part_upload_max_bytes_override, 0u); +} + +/// C1: the three token-policy helpers are the single source of truth for how a Native-mode backend +/// mints a HEAD/PUT token, gates a LIST token, and compares tokens. Characterizes the behavior the +/// scattered call sites have today so the consolidation stays byte-for-byte behavior-preserving. +TEST(CASBackendGeneration, TokenPolicyHelpersAreConsistentWithDialect) +{ + auto b = std::make_shared( + DB::Cas::tests::makeLocalObjectStorageForTest(), ObjectStorageBackend::Mode::Native); + + /// ETag dialect: head/put tokens carry ETag; list surfaces the same-typed token for a non-empty etag. + ASSERT_EQ(b->nativeTokenType(), TokenType::ETag); + EXPECT_EQ(b->tokenForHead("abc").type, TokenType::ETag); + EXPECT_EQ(b->tokenForHead("abc"), (Token{"abc", TokenType::ETag})); + ASSERT_TRUE(b->tokenForList("abc").has_value()); + EXPECT_EQ(*b->tokenForList("abc"), b->tokenForHead("abc")); /// list token == head token (same etag) + EXPECT_FALSE(b->tokenForList("").has_value()); /// empty etag => no list token + + /// Generation dialect (GCS): head token flips to Generation; list tokens are disabled wholesale + /// (poisoned If-Match), so tokenForList is always nullopt regardless of the etag. + b->setNativeTokenTypeForTest(TokenType::Generation); + EXPECT_EQ(b->tokenForHead("g1").type, TokenType::Generation); + EXPECT_FALSE(b->tokenForList("g1").has_value()); + + /// tokenMatches is exact identity (value AND type) — a same-value/different-type token never matches. + EXPECT_TRUE(ObjectStorageBackend::tokenMatches(Token{"x", TokenType::ETag}, Token{"x", TokenType::ETag})); + EXPECT_FALSE(ObjectStorageBackend::tokenMatches(Token{"x", TokenType::ETag}, Token{"x", TokenType::Emulated})); +} + +/// The single-PUT cap binds CONDITIONAL writes, because that is where GCS drops the precondition. A +/// resurrect carries no precondition, so it must not be capped -- this is the regression test for a +/// ceiling that used to apply here and no longer does. A future consistency-minded refactor that +/// routes the resurrect through conditionalWriteSettings would silently reintroduce it; this fails +/// then. +TEST(CASBackendGeneration, ResurrectIsNotBoundByTheSinglePutCap) +{ + auto b = std::make_shared( + DB::Cas::tests::makeLocalObjectStorageForTest(), ObjectStorageBackend::Mode::Native, + /*conditional_single_put_cap=*/16); + b->setNativeTokenTypeForTest(TokenType::Generation); + + ASSERT_EQ(b->putIfAbsent("p/gen/res", "original").outcome, PutOutcome::Done); + + const String payload(1024, 'x'); /// far above the 16-byte cap + DB::ReadBufferFromOwnString in{payload}; + const Token tok = b->resurrect(in, payload.size(), "p/gen/res", String("HDR")); + EXPECT_FALSE(tok.empty()); + + auto got = b->get("p/gen/res"); + ASSERT_TRUE(got.has_value()); + EXPECT_EQ(got->bytes, "HDR" + payload); +} diff --git a/src/Disks/tests/gtest_cas_backend_listing.cpp b/src/Disks/tests/gtest_cas_backend_listing.cpp new file mode 100644 index 000000000000..591dfdba65db --- /dev/null +++ b/src/Disks/tests/gtest_cas_backend_listing.cpp @@ -0,0 +1,43 @@ +#include + +#include +#include + +#include +#include + +using namespace DB::Cas; + +TEST(CASBackendListing, ForEachWalksEveryPageOnce) +{ + InMemoryBackend b; + for (int i = 0; i < 2500; ++i) + b.putIfAbsent("p/" + std::to_string(1000000 + i), "v"); + b.putIfAbsent("q/other", "v"); /// out of prefix — must not be visited + + std::vector seen; + forEachListedKey(b, "p/", [&](const ListedKey & k) { seen.push_back(k.key); }, /*page_limit=*/1000); + EXPECT_EQ(seen.size(), 2500u); /// paged (3 pages), no key dropped/duplicated + EXPECT_TRUE(std::is_sorted(seen.begin(), seen.end())); +} + +TEST(CASBackendListing, ForEachEmptyPrefixVisitsNothing) +{ + InMemoryBackend b; + b.putIfAbsent("q/other", "v"); + + size_t visits = 0; + forEachListedKey(b, "p/", [&](const ListedKey &) { ++visits; }); + EXPECT_EQ(visits, 0u); +} + +TEST(CASBackendListing, ClassifyMapsEveryDeleteKind) +{ + EXPECT_EQ(classifyDeleteOutcome({DeleteOutcome::Kind::Deleted, false}), DeleteClass::Deleted); + EXPECT_EQ(classifyDeleteOutcome({DeleteOutcome::Kind::NotFound, false}), DeleteClass::Absent); + EXPECT_EQ(classifyDeleteOutcome({DeleteOutcome::Kind::TokenMismatch, false}), DeleteClass::Replaced); + + EXPECT_EQ(deleteClassName(DeleteClass::Deleted), "deleted"); + EXPECT_EQ(deleteClassName(DeleteClass::Absent), "absent"); + EXPECT_EQ(deleteClassName(DeleteClass::Replaced), "replaced"); +} diff --git a/src/Disks/tests/gtest_cas_blob_digest.cpp b/src/Disks/tests/gtest_cas_blob_digest.cpp new file mode 100644 index 000000000000..9a87f08ccda2 --- /dev/null +++ b/src/Disks/tests/gtest_cas_blob_digest.cpp @@ -0,0 +1,266 @@ +#include + +/// CAS pluggable-blob-hash Phase 2, Task 1: `BlobDigest` (the pool-scoped variable-length content digest, ADDITIVE-ONLY -- no +/// existing `UInt128 blob_hash` field is migrated in this task) + the ONE `PoolMeta`-scoped +/// `DigestCodec` all digest<->hex/bytes conversion must route through. +/// +/// THE KEY GATE (`ShardOfBitIdenticalToOldHighBitsOver200RandomValues` below): `DigestCodec`'s +/// `shardOf` (an explicit big-endian read of the first 8 digest bytes) must be bit-identical to +/// today's `static_cast(blob_hash >> 64)` (`CasGcShardPlan.h`'s `blobShard`) for every +/// 128-bit digest -- otherwise an existing cityHash128/xxh3-128 pool would silently reshard on +/// upgrade. This is load-bearing: it is what makes Phase 2 safe to land under running pools. + +#include +#include +#include +#include +#include +#include +#include "cas_test_helpers.h" + +#include + +#include +#include +#include +#include + +namespace DB::ErrorCodes +{ +extern const int BAD_ARGUMENTS; +} + +using namespace DB::Cas; +using namespace DB::Cas::tests; + +namespace +{ + +UInt128 randomU128(std::mt19937_64 & rng) +{ + const UInt128 hi = rng(); + const UInt128 lo = rng(); + return (hi << 64) | lo; +} + +} + +/// ---- THE KEY GATE ---- + +TEST(CASBlobDigest, ShardOfBitIdenticalToOldHighBitsOver200RandomValues) +{ + std::mt19937_64 rng(0xC0FFEE); // NOLINT(cert-msc32-c,cert-msc51-cpp): deterministic seed is required for reproducible property coverage. + const DigestCodec codec16(/*blob_hash_len*/ 16); + + for (int i = 0; i < 200; ++i) + { + const UInt128 v = randomU128(rng); + const uint64_t old_high64 = static_cast(v >> 64); + const uint64_t got = codec16.shardOf(BlobDigest::fromU128(v)); + EXPECT_EQ(got, old_high64) << "mismatch for random UInt128 iteration " << i; + } + + /// Edge cases: all-zero and all-one high halves. + EXPECT_EQ(codec16.shardOf(BlobDigest::fromU128(UInt128(0))), 0u); + const UInt128 all_ones = ~UInt128(0); + EXPECT_EQ(codec16.shardOf(BlobDigest::fromU128(all_ones)), static_cast(all_ones >> 64)); +} + +/// The same gate, but via `Cas::codecFor` (`CasBlobRef.h`), the ONE way production code obtains a +/// codec (Phase 3 T4 deleted the pool-scoped `DigestCodec(PoolMeta)` constructor -- a mixed-algo +/// pool has no single width; the codec is selected per-algo, never per-pool). +TEST(CASBlobDigest, ShardOfViaPoolMetaConstructedCodecMatchesOldBlobShard) +{ + auto backend = std::make_shared(); + const Layout layout("p"); + const PoolMeta pm = PoolMeta::createOrValidate(*backend, layout, /*blob_header_len*/ 256, BlobHashAlgo::CityHash128, /*allow_new*/ false, /*allow_mint*/ true); + ASSERT_EQ(pm.algos_used, (std::vector{static_cast(BlobHashAlgo::CityHash128)})); + const DigestCodec codec = codecFor(BlobHashAlgo::CityHash128); + + std::mt19937_64 rng(12345); // NOLINT(cert-msc32-c,cert-msc51-cpp): deterministic seed is required for reproducible property coverage. + for (int i = 0; i < 200; ++i) + { + const UInt128 v = randomU128(rng); + EXPECT_EQ(codec.shardOf(BlobDigest::fromU128(v)), static_cast(v >> 64)); + /// `blobShard` (`CasGcShardPlan.h`) additionally takes `% gc_shards`; at `gc_shards == 1` + /// every hash routes to shard 0, so this only pins the trivial single-shard case -- the + /// bit-identical pre-mod value is already pinned by the assertion above. + EXPECT_EQ(blobShard(BlobRef{BlobHashAlgo::CityHash128, BlobDigest::fromU128(v)}, /*gc_shards*/ 1), 0u); + } +} + +/// ---- round-trip ---- + +TEST(CASBlobDigest, HexRoundTripLen16) +{ + const DigestCodec codec(16); + std::mt19937_64 rng(1); // NOLINT(cert-msc32-c,cert-msc51-cpp): deterministic seed is required for reproducible property coverage. + for (int i = 0; i < 50; ++i) + { + const BlobDigest d = BlobDigest::fromU128(randomU128(rng)); + const String hex = codec.toHex(d); + EXPECT_EQ(hex.size(), 32u); + EXPECT_EQ(codec.fromHex(hex), d); + } +} + +TEST(CASBlobDigest, HexRoundTripLen32) +{ + const DigestCodec codec(32); + BlobDigest d; + for (size_t i = 0; i < d.bytes.size(); ++i) + d.bytes[i] = static_cast(i * 7 + 1); + + const String hex = codec.toHex(d); + EXPECT_EQ(hex.size(), 64u); + EXPECT_EQ(codec.fromHex(hex), d); +} + +TEST(CASBlobDigest, BytesBERoundTripLen16) +{ + const DigestCodec codec(16); + std::mt19937_64 rng(2); // NOLINT(cert-msc32-c,cert-msc51-cpp): deterministic seed is required for reproducible property coverage. + for (int i = 0; i < 50; ++i) + { + const BlobDigest d = BlobDigest::fromU128(randomU128(rng)); + const String bytes = codec.toBytesBE(d); + EXPECT_EQ(bytes.size(), 16u); + EXPECT_EQ(codec.fromBytesBE(bytes), d); + } +} + +TEST(CASBlobDigest, BytesBERoundTripLen32) +{ + const DigestCodec codec(32); + BlobDigest d; + for (size_t i = 0; i < d.bytes.size(); ++i) + d.bytes[i] = static_cast(255 - i); + + const String bytes = codec.toBytesBE(d); + EXPECT_EQ(bytes.size(), 32u); + EXPECT_EQ(codec.fromBytesBE(bytes), d); +} + +/// `toBytesBE` at len16 must produce exactly the `u128ToBytesBE` bytes for the 16-byte prefix -- +/// same byte order, so a 128-bit pool's on-wire bytes stay unchanged when a later task migrates a +/// field from `UInt128` to `BlobDigest`. +TEST(CASBlobDigest, BytesBEAgreesWithU128ToBytesBEAtLen16) +{ + const DigestCodec codec(16); + std::mt19937_64 rng(3); // NOLINT(cert-msc32-c,cert-msc51-cpp): deterministic seed is required for reproducible property coverage. + for (int i = 0; i < 20; ++i) + { + const UInt128 v = randomU128(rng); + EXPECT_EQ(codec.toBytesBE(BlobDigest::fromU128(v)), u128ToBytesBE(v)); + } +} + +/// ---- width rejection ---- + +TEST(CASBlobDigest, FromHexRejectsWrongWidth) +{ + const DigestCodec codec16(16); + const DigestCodec codec32(32); + + /// A 16-byte codec must reject a 64-hex (32-byte) string. + expectThrowsCode(DB::ErrorCodes::BAD_ARGUMENTS, [&] { codec16.fromHex(std::string(64, 'a')); }); + /// A 32-byte codec must reject a 32-hex (16-byte) string. + expectThrowsCode(DB::ErrorCodes::BAD_ARGUMENTS, [&] { codec32.fromHex(std::string(32, 'a')); }); + /// Any non-hex character is rejected too. + expectThrowsCode(DB::ErrorCodes::BAD_ARGUMENTS, [&] { codec16.fromHex(std::string(31, 'a') + "z"); }); +} + +TEST(CASBlobDigest, FromBytesBERejectsWrongWidth) +{ + const DigestCodec codec16(16); + const DigestCodec codec32(32); + + expectThrowsCode(DB::ErrorCodes::BAD_ARGUMENTS, [&] { codec16.fromBytesBE(std::string(32, '\0')); }); + expectThrowsCode(DB::ErrorCodes::BAD_ARGUMENTS, [&] { codec32.fromBytesBE(std::string(16, '\0')); }); + expectThrowsCode(DB::ErrorCodes::BAD_ARGUMENTS, [&] { codec16.fromBytesBE(std::string(15, '\0')); }); +} + +/// ---- UInt128 conversion ---- + +TEST(CASBlobDigest, U128RoundTrip) +{ + std::mt19937_64 rng(4); // NOLINT(cert-msc32-c,cert-msc51-cpp): deterministic seed is required for reproducible property coverage. + for (int i = 0; i < 200; ++i) + { + const UInt128 v = randomU128(rng); + EXPECT_EQ(BlobDigest::fromU128(v).toU128(), v); + } + EXPECT_EQ(BlobDigest::fromU128(UInt128(0)).toU128(), UInt128(0)); +} + +TEST(CASBlobDigest, FromU128LeavesTailZero) +{ + std::mt19937_64 rng(5); // NOLINT(cert-msc32-c,cert-msc51-cpp): deterministic seed is required for reproducible property coverage. + const UInt128 v = randomU128(rng); + const BlobDigest d = BlobDigest::fromU128(v); + for (size_t i = 16; i < d.bytes.size(); ++i) + EXPECT_EQ(d.bytes[i], 0u) << "tail byte " << i << " must be zero for a 128-bit-pool digest"; +} + +/// ---- hasher / container use ---- + +TEST(CASBlobDigest, UsableAsUnorderedMapKey) +{ + std::mt19937_64 rng(6); // NOLINT(cert-msc32-c,cert-msc51-cpp): deterministic seed is required for reproducible property coverage. + std::unordered_map m; + std::vector digests; + for (int i = 0; i < 20; ++i) + { + const BlobDigest d = BlobDigest::fromU128(randomU128(rng)); + digests.push_back(d); + m[d] = i; + } + for (int i = 0; i < 20; ++i) + EXPECT_EQ(m.at(digests[static_cast(i)]), i); +} + +/// ---- PoolMeta::algos_used records the creating algo (Phase 3 T4 -- the width itself is no longer +/// pool state at all: `blobHashLenFor(algo)`/`codecFor(algo)` derive it per-algo, never per-pool) ---- + +TEST(CASBlobDigest, PoolMetaRecordsCreatingAlgoAndWidthDerivesFromIt) +{ + { + auto backend = std::make_shared(); + const Layout layout("p1"); + const PoolMeta pm = PoolMeta::createOrValidate(*backend, layout, 256, BlobHashAlgo::CityHash128, /*allow_new*/ false, /*allow_mint*/ true); + EXPECT_EQ(pm.algos_used, (std::vector{static_cast(BlobHashAlgo::CityHash128)})); + EXPECT_EQ(blobHashLenFor(BlobHashAlgo::CityHash128), 16u); + } + { + auto backend = std::make_shared(); + const Layout layout("p2"); + const PoolMeta pm = PoolMeta::createOrValidate(*backend, layout, 256, BlobHashAlgo::XXH3_128, /*allow_new*/ false, /*allow_mint*/ true); + EXPECT_EQ(pm.algos_used, (std::vector{static_cast(BlobHashAlgo::XXH3_128)})); + EXPECT_EQ(blobHashLenFor(BlobHashAlgo::XXH3_128), 16u); + } + { + auto backend = std::make_shared(); + const Layout layout("p3"); + const PoolMeta pm = PoolMeta::createOrValidate(*backend, layout, 256, BlobHashAlgo::Sha256, /*allow_new*/ false, /*allow_mint*/ true); + EXPECT_EQ(pm.algos_used, (std::vector{static_cast(BlobHashAlgo::Sha256)})); + EXPECT_EQ(blobHashLenFor(BlobHashAlgo::Sha256), 32u); + + /// Reopen (decode path) must re-derive the same recorded algo. + const PoolMeta reopened = PoolMeta::createOrValidate(*backend, layout, 256, BlobHashAlgo::Sha256); + EXPECT_EQ(reopened.algos_used, (std::vector{static_cast(BlobHashAlgo::Sha256)})); + } +} + +/// ---- zero-tail len-drift guard (debug/sanitizer builds only: chassert aborts the process) ---- + +#if defined(DEBUG_OR_SANITIZER_BUILD) +TEST(CASBlobDigestDeathTest, ZeroTailChassertFiresOnNonZeroTailAtLen16) +{ + const DigestCodec codec16(16); + BlobDigest d = BlobDigest::fromU128(UInt128(1)); + d.bytes[16] = 0x42; /// corrupt a tail byte beyond the pool's 16-byte width + + EXPECT_DEATH({ (void)codec16.toHex(d); }, ""); + EXPECT_DEATH({ (void)codec16.toBytesBE(d); }, ""); +} +#endif diff --git a/src/Disks/tests/gtest_cas_blob_envelope_format.cpp b/src/Disks/tests/gtest_cas_blob_envelope_format.cpp new file mode 100644 index 000000000000..1119a5be0a64 --- /dev/null +++ b/src/Disks/tests/gtest_cas_blob_envelope_format.cpp @@ -0,0 +1,174 @@ +#include "cas_format_test_battery.h" +#include +#include + +#include + +using namespace DB::Cas; + +namespace DB::ErrorCodes { extern const int CORRUPTED_DATA; extern const int UNKNOWN_FORMAT_VERSION; } + +namespace +{ +EnvelopeHeader sampleHeader(const String & ref) +{ + EnvelopeHeader h; + h.kind = ObjectKind::Blob; + h.incarnation_tag = hexToU128("0102030405060708090a0b0c0d0e0f10"); + h.build_id = hexToU128("1112131415161718191a1b1c1d1e1f20"); + h.provenance = Provenance{1752537600123ULL, hexToU128("2122232425262728292a2b2c2d2e2f30"), 26006001u, ProvenanceOp::Merge}; + h.intended_ref = ref; + return h; +} +constexpr uint32_t L = 256; + +/// The envelope has a fixed physical length. At generation 9 there is no unsupported one-digit +/// version, so replacing `9` with `10` must consume one byte from the space pad rather than silently +/// turning the 256-byte fixture into a different wire shape. +String blobEnvelopeWithFutureVersion(std::string_view text) +{ + const String v_now = fmt::format("\"v\":{}", currentCompatibilityVersion()); + const String v_next = fmt::format("\"v\":{}", currentCompatibilityVersion() + 1); + String future(text); + const size_t version_pos = future.find(v_now); + if (version_pos == String::npos || v_next.size() < v_now.size()) + throw std::logic_error("blob-envelope future-version fixture cannot locate the current version"); + + future.replace(version_pos, v_now.size(), v_next); + const size_t growth = v_next.size() - v_now.size(); + const size_t newline_pos = future.find('\n'); + if (newline_pos == String::npos || newline_pos < growth + || future.substr(newline_pos - growth, growth) != String(growth, ' ')) + throw std::logic_error("blob-envelope future-version fixture has insufficient padding"); + future.erase(newline_pos - growth, growth); + return future; +} +} + +TEST(CASBlobEnvelopeFormat, FixedLengthAndPadZone) +{ + EnvelopeHeader h = sampleHeader("t-abc/all_1_2_0"); + const String head = encodeEnvelopeHeader(h, L); + ASSERT_EQ(head.size(), L); /// exactly blob_header_len + EXPECT_EQ(head[L - 1], '\n'); /// terminator at byte 255 + const String json = fmt::format(R"({{"type":"cas_blob","v":{},)", currentCompatibilityVersion()) + + "\"tag\":\"0102030405060708090a0b0c0d0e0f10\"," + "\"bld\":\"1112131415161718191a1b1c1d1e1f20\",\"ts\":1752537600123," + "\"by\":\"2122232425262728292a2b2c2d2e2f30\",\"op\":\"merge\",\"ch\":26006001," + "\"ref\":\"t-abc/all_1_2_0\"}"; + ASSERT_LT(json.size(), L); + EXPECT_EQ(head.substr(0, json.size()), json); /// '/' UNescaped (local escaper) + EXPECT_EQ(head.substr(json.size(), (L - 1) - json.size()), String((L - 1) - json.size(), ' ')); /// pad = spaces + /// round-trip + const EnvelopeHeader back = decodeEnvelopeHeader(head, head.size(), ObjectKind::Blob); + EXPECT_EQ(back.incarnation_tag, h.incarnation_tag); + EXPECT_EQ(back.build_id, h.build_id); + ASSERT_TRUE(back.provenance.has_value()); + EXPECT_EQ(back.provenance->created_at_ms, 1752537600123ULL); + EXPECT_EQ(back.provenance->ch_version, 26006001u); + EXPECT_EQ(back.provenance->op, ProvenanceOp::Merge); + ASSERT_TRUE(back.intended_ref.has_value()); + EXPECT_EQ(*back.intended_ref, "t-abc/all_1_2_0"); + EXPECT_EQ(back.header_len, L); + EXPECT_EQ(payloadOffset(back), L); +} + +TEST(CASBlobEnvelopeFormat, RefTruncatedToExactBudget) +{ + /// A 200-char ref cannot fit; it is truncated so the header is EXACTLY 256 bytes and the pad holds. + EnvelopeHeader h = sampleHeader(String(200, 'a')); + const String head = encodeEnvelopeHeader(h, L); + ASSERT_EQ(head.size(), L); + EXPECT_EQ(head[L - 1], '\n'); + const EnvelopeHeader back = decodeEnvelopeHeader(head, head.size(), ObjectKind::Blob); + ASSERT_TRUE(back.intended_ref.has_value()); + /// Budget is deterministic. Compute json_len for the SAME header with an empty ref; each extra 'a' + /// is one escaped byte, so the truncated 'a' count is exactly (L-1) - json_len(empty ref). + EnvelopeHeader probe = sampleHeader(""); + const String empty_ref_head = encodeEnvelopeHeader(probe, L); + const size_t json_len_empty = empty_ref_head.find_last_not_of(' ', (L - 1) - 1) + 1; + const size_t budget = (L - 1) - json_len_empty; + EXPECT_EQ(back.intended_ref->size(), budget) << "ref truncated to the exact byte budget"; + for (char c : *back.intended_ref) + EXPECT_EQ(c, 'a'); +} + +TEST(CASBlobEnvelopeFormat, PadZoneSmugglingFailsClosed) +{ + EnvelopeHeader h = sampleHeader("r"); + const String head = encodeEnvelopeHeader(h, L); + const size_t json_len = head.find_last_not_of(' ', (L - 1) - 1) + 1; /// first pad byte index = json_len + ASSERT_LT(json_len, L - 1); + /// A non-space byte smuggled into the pad zone -> CORRUPTED_DATA. + String smuggled = head; + smuggled[json_len + 1] = 'x'; + EXPECT_THROW(decodeEnvelopeHeader(smuggled, smuggled.size(), ObjectKind::Blob), DB::Exception); + /// Byte 255 not '\n' -> CORRUPTED_DATA. + String no_nl = head; + no_nl[L - 1] = ' '; + EXPECT_THROW(decodeEnvelopeHeader(no_nl, no_nl.size(), ObjectKind::Blob), DB::Exception); +} + +TEST(CASBlobEnvelopeFormat, GatesAndCriticalKey) +{ + /// wrong type -> CORRUPTED_DATA; future v -> UNKNOWN_FORMAT_VERSION. + EnvelopeHeader h = sampleHeader("r"); + const String head = encodeEnvelopeHeader(h, L); + String wrong_type = head; + wrong_type.replace(wrong_type.find("cas_blob"), 8, "cas_xxxx"); + EXPECT_THROW(decodeEnvelopeHeader(wrong_type, wrong_type.size(), ObjectKind::Blob), DB::Exception); + const String current_version = fmt::format("\"v\":{}", currentCompatibilityVersion()); + String future = blobEnvelopeWithFutureVersion(head); + cas_battery_detail::expectCode(DB::ErrorCodes::UNKNOWN_FORMAT_VERSION, + [&] { decodeEnvelopeHeader(future, future.size(), ObjectKind::Blob); }, "future blob-envelope version"); + + String out_of_range = head; + const size_t out_of_range_version_at = out_of_range.find(current_version); + ASSERT_NE(out_of_range_version_at, String::npos); + out_of_range.replace(out_of_range_version_at, current_version.size(), "\"v\":4294967299"); + try + { + decodeEnvelopeHeader(out_of_range, out_of_range.size(), ObjectKind::Blob); + FAIL() << "expected CORRUPTED_DATA"; + } + catch (const DB::Exception & e) + { + EXPECT_EQ(e.code(), DB::ErrorCodes::CORRUPTED_DATA); + } + /// an unknown `!`-critical key fails closed. + EnvelopeHeader hc = sampleHeader("r"); + hc.emit_unknown_critical_key = true; + const String crit = encodeEnvelopeHeader(hc, L); + EXPECT_THROW(decodeEnvelopeHeader(crit, crit.size(), ObjectKind::Blob), DB::Exception); +} + +TEST(CASBlobEnvelopeFormat, RefEscaperAlphabetPinned) +{ + /// Pins the LOCAL escaper's alphabet (§ref-escaper): " and \ escape, control chars -> \uXXXX, + /// '/' passes VERBATIM. Goes RED if anyone "unifies" this with writeStringValue/FormatSettings — + /// the 256-byte budget arithmetic depends on this alphabet being codec-owned and frozen. + EnvelopeHeader h = sampleHeader(String("a/b\"c\\d") + '\x01' + "e"); + const String head = encodeEnvelopeHeader(h, L); + const String expected_ref_json = R"("a/b\"c\\d\u0001e")"; + EXPECT_NE(head.find("\"ref\":" + expected_ref_json), String::npos) + << "escaper alphabet drifted: '/' must be verbatim, quote/backslash escaped, control -> \\uXXXX"; +} + +TEST(CASFormatBattery, BlobEnvelope) +{ + /// The golden is CONSTRUCTED from the hand-pinned json literal (same one FixedLengthAndPadZone + /// asserts) + the derived pad — NOT self-computed via encodeEnvelopeHeader, which would compare + /// the encoder to itself and pin nothing. + const String json = fmt::format(R"({{"type":"cas_blob","v":{},)", currentCompatibilityVersion()) + + "\"tag\":\"0102030405060708090a0b0c0d0e0f10\"," + "\"bld\":\"1112131415161718191a1b1c1d1e1f20\",\"ts\":1752537600123," + "\"by\":\"2122232425262728292a2b2c2d2e2f30\",\"op\":\"merge\",\"ch\":26006001," + "\"ref\":\"t-abc/all_1_2_0\"}"; + const String golden = json + String((L - 1) - json.size(), ' ') + '\n'; + runFormatBattery(FormatBatteryCase{ + .id = FormatId::Blob, + .encode = [&] { EnvelopeHeader e = sampleHeader("t-abc/all_1_2_0"); return sealObject(FormatId::Blob, encodeEnvelopeHeader(e, L)); }, + .decode = [](std::string_view s) { decodeEnvelopeHeader(String(openObject(FormatId::Blob, s)), s.size(), ObjectKind::Blob); }, + .golden = golden, + .make_future_version = blobEnvelopeWithFutureVersion}); +} diff --git a/src/Disks/tests/gtest_cas_blob_hasher.cpp b/src/Disks/tests/gtest_cas_blob_hasher.cpp new file mode 100644 index 000000000000..0aa1b3ff35a4 --- /dev/null +++ b/src/Disks/tests/gtest_cas_blob_hasher.cpp @@ -0,0 +1,182 @@ +#include + +#include +/// `CasXxh3Streamer.h` is the isolated xxHash wrapper (a system header): it gives us `Cas::xxh3_128_oneshot` +/// as an independent one-shot reference without pulling raw xxHash symbols (or their warnings) into +/// this test — see the header's own comment for the lz4-shadowing / `-Werror` reasons. +#include +#include +#include +#include +#include + +#include +#include + +using namespace DB; +using namespace DB::Cas; + +namespace +{ + +/// A deterministic, non-repeating-byte payload (not all-zero / all-same, so a byte-order or +/// endianness bug in either hash path would not accidentally cancel out). +std::string makePayload(size_t size) +{ + std::string s; + s.reserve(size); + for (size_t i = 0; i < size; ++i) + s.push_back(static_cast('a' + (i % 23))); + return s; +} + +} + +TEST(CASBlobHasher, Xxh3StreamingMatchesOneShotAndBlobHashHexOneShot) +{ + const std::string payload = makePayload(10000); + + std::string sink_data; + std::string streaming_hex; + { + WriteBufferFromString sink(sink_data); + auto hashing = makeBlobHashingWriteBuffer(BlobHashAlgo::XXH3_128, sink); + + /// Feed the payload through several `write()` chunks to exercise the streaming state across + /// multiple `nextImpl` flushes, not just a single call. + size_t offset = 0; + constexpr size_t chunk = 777; + while (offset < payload.size()) + { + const size_t n = std::min(chunk, payload.size() - offset); + hashing->write(payload.data() + offset, n); + offset += n; + } + + streaming_hex = hashing->getHashHex(); + hashing->finalize(); + sink.finalize(); + } + + /// The passthrough forwarded every byte unchanged. + EXPECT_EQ(sink_data, payload); + EXPECT_EQ(streaming_hex.size(), 32u); + + /// xxh3 streaming == xxh3 one-shot (unlike cityHash128, xxh3's streaming digest is defined to + /// agree with the one-shot digest -- see `ImplXXH3_128` in `Functions/FunctionsHashing.h`). + UInt64 os_low = 0; + UInt64 os_high = 0; + Cas::xxh3_128_oneshot(payload.data(), payload.size(), os_low, os_high); + const std::string one_shot_hex = getHexUIntLowercase(UInt128{os_low, os_high}); + EXPECT_EQ(streaming_hex, one_shot_hex); + + /// The one-shot re-hash helper must agree with both. + EXPECT_EQ(blobHashHexOneShot(BlobHashAlgo::XXH3_128, payload), one_shot_hex); +} + +TEST(CASBlobHasher, CityHash128ByteIdenticalToHashingWriteBuffer) +{ + /// Cover payloads both under and over one `DBMS_DEFAULT_HASHING_BLOCK_SIZE` (2048 B) hash block, + /// plus exactly at the boundary, since the chunked convention only matters once a payload spans + /// more than one block. + for (const size_t size : {size_t(100), size_t(2000), size_t(2048), size_t(5000)}) + { + SCOPED_TRACE(size); + const std::string payload = makePayload(size); + + /// Reference: today's convention, `HashingWriteBuffer` used directly. + std::string ref_sink_data; + std::string ref_hex; + { + WriteBufferFromString ref_sink(ref_sink_data); + HashingWriteBuffer ref_hashing(ref_sink); + ref_hashing.write(payload.data(), payload.size()); + ref_hex = getHexUIntLowercase(ref_hashing.getHash()); + ref_hashing.finalize(); + ref_sink.finalize(); + } + + /// The selectable factory, defaulted to CityHash128 -- must be byte-identical. + std::string sink_data; + std::string hex; + { + WriteBufferFromString sink(sink_data); + auto hashing = makeBlobHashingWriteBuffer(BlobHashAlgo::CityHash128, sink); + hashing->write(payload.data(), payload.size()); + hex = hashing->getHashHex(); + hashing->finalize(); + sink.finalize(); + } + + EXPECT_EQ(hex, ref_hex); + EXPECT_EQ(hex.size(), 32u); + EXPECT_EQ(sink_data, ref_sink_data); + EXPECT_EQ(sink_data, payload); + + /// The one-shot re-hash helper must agree too. + EXPECT_EQ(blobHashHexOneShot(BlobHashAlgo::CityHash128, payload), ref_hex); + } +} + +TEST(CASBlobHasher, AlgoNameAndParseRoundTrip) +{ + EXPECT_EQ(blobHashAlgoName(BlobHashAlgo::CityHash128), "ch128"); + EXPECT_EQ(blobHashAlgoName(BlobHashAlgo::XXH3_128), "xxh3"); + EXPECT_EQ(blobHashAlgoName(BlobHashAlgo::Sha256), "sha256"); + + EXPECT_EQ(parseBlobHashAlgo("cityhash128"), BlobHashAlgo::CityHash128); + EXPECT_EQ(parseBlobHashAlgo("xxh3-128"), BlobHashAlgo::XXH3_128); + /// Parses even though it is rejected downstream (config-layer rejection is a later task). + EXPECT_EQ(parseBlobHashAlgo("sha256"), BlobHashAlgo::Sha256); + + EXPECT_THROW(parseBlobHashAlgo("bogus"), DB::Exception); + EXPECT_THROW(parseBlobHashAlgo("cityHash128"), DB::Exception); // case-sensitive + EXPECT_THROW(parseBlobHashAlgo(""), DB::Exception); +} + +TEST(CASBlobHasher, Sha256OneShotGoldenVectors) +{ + /// NIST/FIPS 180-2 test vectors, the standard SHA-256 sanity check. + EXPECT_EQ(blobHashHexOneShot(BlobHashAlgo::Sha256, "abc"), + "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"); + EXPECT_EQ(blobHashHexOneShot(BlobHashAlgo::Sha256, ""), + "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"); +} + +TEST(CASBlobHasher, Sha256StreamingMatchesOneShotAndIsPassthrough) +{ + /// Bigger than `DBMS_DEFAULT_HASHING_BLOCK_SIZE` (2048 B) so the payload chunks through several + /// `nextImpl` flushes, not just a single call. + const std::string payload = makePayload(200 * 1024); + + std::string sink_data; + std::string streaming_hex; + { + WriteBufferFromString sink(sink_data); + auto hashing = makeBlobHashingWriteBuffer(BlobHashAlgo::Sha256, sink); + + /// Feed the payload through several `write()` chunks to exercise the streaming EVP digest + /// across multiple `nextImpl` flushes. + size_t offset = 0; + constexpr size_t chunk = 4096; + while (offset < payload.size()) + { + const size_t n = std::min(chunk, payload.size() - offset); + hashing->write(payload.data() + offset, n); + offset += n; + } + + streaming_hex = hashing->getHashHex(); + hashing->finalize(); + sink.finalize(); + } + + /// The passthrough forwarded every byte unchanged. + EXPECT_EQ(sink_data, payload); + EXPECT_EQ(streaming_hex.size(), 64u); + + /// SHA-256 streaming == SHA-256 one-shot (like xxh3, unlike cityHash128 -- SHA-256 has no + /// chunked convention to preserve). + const std::string one_shot_hex = blobHashHexOneShot(BlobHashAlgo::Sha256, payload); + EXPECT_EQ(streaming_hex, one_shot_hex); +} diff --git a/src/Disks/tests/gtest_cas_blob_indegree.cpp b/src/Disks/tests/gtest_cas_blob_indegree.cpp new file mode 100644 index 000000000000..e47b7351f063 --- /dev/null +++ b/src/Disks/tests/gtest_cas_blob_indegree.cpp @@ -0,0 +1,1000 @@ +#include + +#include +#include +#include +#include +#include +#include +#include +#include + +namespace DB::ErrorCodes { extern const int CORRUPTED_DATA; extern const int NOT_IMPLEMENTED; } + +using namespace DB::Cas; + +namespace +{ +UInt128 b(uint64_t n) { return UInt128(n); } +UInt128 s(uint64_t n) { return UInt128(n); } // source-edge id +/// A `BlobRef` (CityHash128) for the same literal `n` — every existing test's `BlobDelta.ref` / +/// `BlobCandidate.ref` / `inDegreeInRuns` argument is a `BlobRef` as of Phase 3 T3. +BlobRef bh(uint64_t n) { return BlobRef{BlobHashAlgo::CityHash128, BlobDigest::fromU128(UInt128(n))}; } + +/// Scale thresholds for the "the run genuinely spans several blocks" sanity assertions below. These are +/// NOT format constants — the SourceEdge run is a plain NDJSON stream (`CasRecordStreamFormat`) with no +/// block framing of its own — they only pin the same byte-size scale the (now-deleted, codecs-v3 phase 6) +/// `CasRunFile` block codec used, so the multi-block-sized fixtures below stay meaningfully large. +/// (Previously read straight off `CasRunFile.h`'s own `kRunTargetBlockSize`/`kRunHardCapBlockSize`; this +/// file's `#include` of that header looked removable when `CasRunFile` was deleted in the phase-6 cutover, +/// but these two thresholds turned out to be the only remaining users — hence the local, explicitly-legacy +/// copies here instead of a dangling include. Values unchanged.) +constexpr uint32_t kLegacyBlockSize = 256u * 1024u; +constexpr uint32_t kLegacyHardCapBlockSize = 1024u * 1024u; +} + +TEST(CASBlobInDegree, FoldStartsFromEmptyPriorGeneration) +{ + InMemoryBackend backend; + Layout layout{"pool"}; + + /// Generation 1 from empty prior: two distinct edges on b1 and one on b2. + /// Edge (b1,s1), (b1,s2), (b2,s1) => indeg(b1)=2, indeg(b2)=1. + std::vector deltas{ + {bh(1), s(1), false}, + {bh(1), s(2), false}, + {bh(2), s(1), false}, + }; + std::vector runs; + foldDeltasIntoGeneration(backend, layout, /*prior_runs*/{}, /*new*/1, /*attempt*/0, /*shard*/0, deltas, runs); + ASSERT_FALSE(runs.empty()); + + const auto zero = zeroInDegree(backend, runs); + EXPECT_TRUE(zero.empty()); /// nothing at zero yet +} + +TEST(CASBlobInDegree, PlusMinusCancelToZeroDetectsCandidate) +{ + InMemoryBackend backend; + Layout layout{"pool"}; + + /// Gen 1: activate edge (b1,s1) and (b2,s1). + std::vector runs1; + foldDeltasIntoGeneration(backend, layout, /*prior_runs*/{}, 1, /*attempt*/0, 0, + {{bh(1), s(1), false}, {bh(2), s(1), false}}, runs1); + + /// Generation 2 merges prior gen-1 run (resolved via runs1 refs) with removal of (b1,s1): indeg(b1)=0, indeg(b2)=1. + std::vector runs2; + foldDeltasIntoGeneration(backend, layout, /*prior_runs*/runs1, /*new*/2, /*attempt*/0, 0, + {{bh(1), s(1), true}}, runs2); + + const auto zero = zeroInDegree(backend, runs2); + ASSERT_EQ(zero.size(), 1u); + EXPECT_EQ(zero[0].ref, bh(1)); +} + +TEST(CASBlobInDegree, RunsAreByteDeterministic) +{ + InMemoryBackend a; + InMemoryBackend b2; + Layout layout{"pool"}; + std::vector ra; + std::vector rb; + /// Same deltas in a DIFFERENT input order must produce the same sealed run bytes (sorted by key). + foldDeltasIntoGeneration(a, layout, /*prior_runs*/{}, 1, /*attempt*/0, 0, + {{bh(3), s(1), false}, {bh(1), s(1), false}, {bh(2), s(1), false}}, ra); + foldDeltasIntoGeneration(b2, layout, /*prior_runs*/{}, 1, /*attempt*/0, 0, + {{bh(1), s(1), false}, {bh(2), s(1), false}, {bh(3), s(1), false}}, rb); + const auto ga = a.get(layout.blobTargetRunKey(1, /*attempt*/0, 0, 0)); + const auto gb = b2.get(layout.blobTargetRunKey(1, /*attempt*/0, 0, 0)); + ASSERT_TRUE(ga.has_value()); + ASSERT_TRUE(gb.has_value()); + EXPECT_EQ(ga->bytes, gb->bytes); + ASSERT_EQ(ra.size(), 1u); + ASSERT_EQ(rb.size(), 1u); + EXPECT_EQ(ra[0].checksum, rb[0].checksum); +} + +TEST(CASBlobInDegree, SameEdgeActivatedTwiceCountsOnce) +{ + /// Idempotency: activating the same (blob_hash, source_id) twice must not double-count. + /// The source-edge set is a SET, not a counter — re-adding the same edge is a no-op. + /// indeg(b1) must be 1 after both activations, not 2. + InMemoryBackend backend; + Layout layout{"pool"}; + std::vector deltas{ + {bh(1), s(1), false}, // activate (b1,s1) + {bh(1), s(1), false}, // same edge again — must deduplicate + }; + std::vector runs; + foldDeltasIntoGeneration(backend, layout, /*prior_runs*/{}, 1, /*attempt*/0, 0, deltas, runs); + ASSERT_FALSE(runs.empty()); + + const int64_t deg = DB::Cas::tests::inDegreeInRuns(backend, runs, bh(1)); + EXPECT_EQ(deg, 1); /// deduplicated, not 2 + + const auto zero = zeroInDegree(backend, runs); + EXPECT_TRUE(zero.empty()); /// b1 still has an active edge +} + +TEST(CASBlobInDegree, FoldDeltaByteEqualReplayAdopts) +{ + InMemoryBackend backend; + Layout layout{"pool"}; + std::vector deltas{{bh(1), s(1), false}}; + std::vector runs1; + std::vector runs2; + foldDeltasIntoGeneration(backend, layout, /*prior_runs*/{}, 1, /*attempt*/7, /*shard*/0, deltas, runs1); + /// Same inputs, same attempt => byte-identical run already present => adopt, no throw. + EXPECT_NO_THROW(foldDeltasIntoGeneration(backend, layout, /*prior_runs*/{}, 1, /*attempt*/7, /*shard*/0, deltas, runs2)); + EXPECT_EQ(runs1, runs2); +} + +TEST(CASBlobInDegree, FoldDeltaDivergentBytesThrowsCorrupted) +{ + InMemoryBackend backend; + Layout layout{"pool"}; + /// Pre-occupy the run key (attempt 7) with junk, then fold => divergent => CORRUPTED_DATA. + backend.putIfAbsent(layout.blobTargetRunKey(1, /*attempt*/7, /*shard*/0, /*seq*/0), "not-a-valid-run"); + std::vector deltas{{bh(1), s(1), false}}; + std::vector runs; + EXPECT_THROW(foldDeltasIntoGeneration(backend, layout, /*prior_runs*/{}, 1, /*attempt*/7, /*shard*/0, deltas, runs), + DB::Exception); +} + +/// ==== two-cursor settlement merge (retired-in-snapshot T3, spec §2.1/§3) ==== +/// +/// The retired input is no longer a separate `prior_retired` vector — the prior generation's `kCondemned` +/// rows RIDE the source-edge run at the zero-sentinel key. These helpers build such a prior run directly +/// (via the sorted-NDJSON `SourceEdgeRunWriter`, codecs-v3 phase 5) and decode a run for assertions. + +namespace +{ + +/// A `kCondemned` sentinel record for `h` at the zero source_id, carrying the condemned incarnation. +SourceEdgeRecord condemnedRec(UInt128 h, const CondemnedRow & row) +{ + return SourceEdgeRecord{.ref = BlobRef{BlobHashAlgo::CityHash128, BlobDigest::fromU128(h)}, + .source_id = UInt128{0}, .marker = kCondemned, + .delete_pending = row.delete_pending, .token = row.token, + .size = row.size, .condemn_round = row.condemn_round}; +} + +/// An active-edge record (`kEdgeActive`) for `h` at source `sid`. +SourceEdgeRecord edgeRec(UInt128 h, UInt128 sid) +{ + return SourceEdgeRecord{.ref = BlobRef{BlobHashAlgo::CityHash128, BlobDigest::fromU128(h)}, + .source_id = sid, .marker = kEdgeActive}; +} + +/// head_blob / peek_head stub: present with a fixed token/size. +std::function(const BlobRef &)> headPresent(const String & tok, uint64_t size) +{ + return [tok, size](const BlobRef &) -> std::optional + { + HeadResult hr; + hr.exists = true; + hr.size = size; + hr.token = Token{.value = tok, .type = TokenType::Emulated}; + return hr; + }; +} + +/// A `CondemnedRow` mirroring the old `entry(hash, condemn_round)` fixture (token "t", size 1). +CondemnedRow condemnedRowFor(uint64_t condemn_round, const String & tok = "t", + bool delete_pending = false, uint64_t size = 1) +{ + return CondemnedRow{.delete_pending = delete_pending, + .token = Token{.value = tok, .type = TokenType::Emulated}, + .size = size, .condemn_round = condemn_round}; +} + +/// Build a source-edge run (`kSourceEdgeKeySchema128`) carrying the given `kCondemned` sentinel rows +/// and surviving edges, write it under `blobTargetRunKey(gen, attempt, shard, 0)`, and return its +/// `RunRef`. Rows are emitted in (blob_hash, source_id) order (sentinels at source_id 0 sort first +/// per blob). +RunRef writeSourceEdgeRun(InMemoryBackend & backend, const Layout & layout, + uint64_t gen, uint64_t attempt, uint64_t shard, + const std::vector> & condemned, + const std::vector> & edges = {}) +{ + std::vector recs; + for (const auto & [h, row] : condemned) + recs.push_back(condemnedRec(h, row)); + for (const auto & [h, sid] : edges) + recs.push_back(edgeRec(h, sid)); + /// The writer requires non-decreasing (ref, source_id) order (sentinels at source_id 0 sort first + /// per blob, exactly reproducing the old raw-key order). + std::stable_sort(recs.begin(), recs.end(), [](const SourceEdgeRecord & a, const SourceEdgeRecord & bb) + { + if (a.ref < bb.ref) + return true; + if (bb.ref < a.ref) + return false; + return a.source_id < bb.source_id; + }); + + DB::WriteBufferFromOwnString out; + SourceEdgeRunWriter writer(out); + for (const auto & rec : recs) + writer.append(rec); + writer.finish(); + out.finalize(); + + const String bytes = out.str(); + const String key = layout.blobTargetRunKey(gen, attempt, shard, 0); + backend.putIfAbsent(key, bytes); + return RunRef{.key = key, .checksum = sourceEdgeRunChecksum(bytes), .shard = shard, .generation = gen}; +} + +struct DecodedRun +{ + std::vector> condemned; /// (blob_hash, row) + std::vector zero_markers; /// blob hashes with a zero-transition marker + std::vector> edges; /// (blob_hash, source_id) +}; + +DecodedRun decodeRun(InMemoryBackend & backend, const RunRef & run) +{ + DecodedRun d; + auto r = openSourceEdgeRun(backend, run.key); + /// Every run this test helper decodes is CityHash128 (16-byte), so `.toU128()` is a + /// provably-exact round trip. + String k; + String p; + while (r.next(k, p)) + { + BlobRef bh_ref; + UInt128 sid; + SourceEdgeKeyCodec::parse(k, bh_ref, sid); // throws CORRUPTED_DATA on a malformed key (fail-closed) + const UInt128 bh = bh_ref.digest.toU128(); + EXPECT_FALSE(p.empty()); + if (p.empty()) + continue; + if (p[0] == kCondemned) + d.condemned.emplace_back(bh, decodeCondemnedRow(p)); + else if (p[0] == kZeroMarker) + d.zero_markers.push_back(bh); + else if (p[0] == kEdgeActive) + d.edges.emplace_back(bh, sid); + else + ADD_FAILURE() << "unknown run row type"; + } + return d; +} + +} + +/// Per-consumer whole-file seal-checksum RED tests (codecs-v3 phase 5, Task 6): a run whose ROWS are +/// well-formed (so `cursor.advance()` never aborts first) but whose `RunRef.checksum` disagrees with the +/// stored bytes must fail closed at each deletion-deriving consumer BEFORE any decision is produced. The +/// stored bytes are the valid run; only the seal checksum handed to the consumer is wrong. +TEST(CASBlobInDegree, FoldSealChecksumMismatchFailsClosed) +{ + InMemoryBackend backend; + Layout layout{"pool"}; + const RunRef good = writeSourceEdgeRun(backend, layout, /*gen*/1, /*attempt*/0, /*shard*/0, + /*condemned*/{}, /*edges*/{{b(1), s(1)}}); + RunRef bad = good; + bad.checksum = good.checksum + 1; /// rows still parse; only the seal disagrees + std::vector prior{bad}; + std::vector out; + /// A delta on a DIFFERENT blob forces the two-cursor merge to stream the prior run to completion, so + /// the end-of-segment verifyAgainst fires (not a row-invariant abort). + EXPECT_THROW( + foldDeltasIntoGeneration(backend, layout, prior, /*new*/2, /*attempt*/0, /*shard*/0, + std::vector{{bh(2), s(1), false}}, out), + DB::Exception); +} + +TEST(CASBlobInDegree, ZeroInDegreeSealChecksumMismatchFailsClosed) +{ + InMemoryBackend backend; + Layout layout{"pool"}; + const RunRef good = writeSourceEdgeRun(backend, layout, /*gen*/1, /*attempt*/0, /*shard*/0, + /*condemned*/{}, /*edges*/{{b(1), s(1)}}); + RunRef bad = good; + bad.checksum = good.checksum + 1; + std::vector runs{bad}; + EXPECT_THROW(zeroInDegree(backend, runs), DB::Exception); +} + +TEST(CASThreeCursorMerge, FloorBoundary) +{ + InMemoryBackend backend; + Layout layout{"pool"}; + + /// Gen 1's run holds one unrelated surviving edge (b9) plus the carried kCondemned rows for A=b1 + /// (condemned round 2) and B=b2 (round 3); neither A nor B has any edge (in-degree 0 by definition). + /// current_round = 3: strictly-below graduates, at-the-current-round stays. + const RunRef gen1 = writeSourceEdgeRun(backend, layout, /*gen*/1, 0, 0, + {{b(1), condemnedRowFor(2)}, {b(2), condemnedRowFor(3)}}, {{b(9), s(1)}}); + + std::vector runs2; + RetiredMergeResult rmr; + foldDeltasIntoGeneration(backend, layout, /*prior_runs*/{gen1}, 2, 0, 0, {}, runs2, + /*current_round*/3, /*condemn_round*/4, /*head_blob*/{}, /*peek_head*/{}, /*confirm_condemned_marker*/{}, &rmr); + + /// Two-phase graduation: the floor-passed entry is REPUBLISHED pending (still in the list); + /// its physical delete belongs to the NEXT pass. + ASSERT_EQ(rmr.graduated.size(), 1u); + EXPECT_EQ(rmr.graduated[0].ref, bh(1)); + EXPECT_TRUE(rmr.graduated[0].delete_pending); + ASSERT_EQ(rmr.still_retired.size(), 2u); + EXPECT_EQ(rmr.still_retired[0].ref, bh(1)); + EXPECT_TRUE(rmr.still_retired[0].delete_pending); + EXPECT_EQ(rmr.still_retired[1].ref, bh(2)); + EXPECT_FALSE(rmr.still_retired[1].delete_pending); + EXPECT_EQ(rmr.still_retired[1].condemn_round, 3u); /// carried unchanged, not re-stamped + EXPECT_TRUE(rmr.spared.empty()); + EXPECT_TRUE(rmr.redelete.empty()); + + /// still_retired mirrors exactly the kCondemned rows written into the output run, in order. + const DecodedRun out = decodeRun(backend, runs2[0]); + ASSERT_EQ(out.condemned.size(), 2u); + EXPECT_EQ(out.condemned[0].first, b(1)); + EXPECT_TRUE(out.condemned[0].second.delete_pending); + EXPECT_EQ(out.condemned[1].first, b(2)); + EXPECT_FALSE(out.condemned[1].second.delete_pending); + EXPECT_TRUE(out.zero_markers.empty()); +} + +TEST(CASThreeCursorMerge, PendingRedeletesAndDrops) +{ + InMemoryBackend backend; + Layout layout{"pool"}; + + /// A row the PRIOR pass published as delete_pending (carried on gen 1's run): this pass hands it to + /// `redelete` (executed pre-CAS by the caller) and drops it from the output run. + const RunRef gen1 = writeSourceEdgeRun(backend, layout, /*gen*/1, 0, 0, + {{b(1), condemnedRowFor(1, "t", /*delete_pending*/true)}}); + + std::vector runs2; + RetiredMergeResult rmr; + foldDeltasIntoGeneration(backend, layout, /*prior_runs*/{gen1}, 2, 0, 0, {}, runs2, + /*current_round*/9, /*condemn_round*/9, /*head_blob*/{}, /*peek_head*/{}, /*confirm_condemned_marker*/{}, &rmr); + + ASSERT_EQ(rmr.redelete.size(), 1u); + EXPECT_EQ(rmr.redelete[0].ref, bh(1)); + EXPECT_TRUE(rmr.still_retired.empty()); + EXPECT_TRUE(rmr.graduated.empty()); + EXPECT_TRUE(rmr.spared.empty()); + + /// The redeleted blob leaves the run entirely (no sentinel carried, no zero marker — untouched). + const DecodedRun out = decodeRun(backend, runs2[0]); + EXPECT_TRUE(out.condemned.empty()); + EXPECT_TRUE(out.zero_markers.empty()); +} + +TEST(CASThreeCursorMerge, RecoverySpares) +{ + InMemoryBackend backend; + Layout layout{"pool"}; + + /// A (=b1) is retired at round 1 and would long since have graduated (current_round = 5) — but this + /// pass's delta adds an edge to it: recovery WINS over graduation, the entry is dropped as spared. + const RunRef gen1 = writeSourceEdgeRun(backend, layout, /*gen*/1, 0, 0, {{b(1), condemnedRowFor(1)}}); + + std::vector runs2; + RetiredMergeResult rmr; + foldDeltasIntoGeneration(backend, layout, /*prior_runs*/{gen1}, 2, 0, 0, {{bh(1), s(1), false}}, runs2, + /*current_round*/5, /*condemn_round*/6, /*head_blob*/{}, /*peek_head*/{}, /*confirm_condemned_marker*/{}, &rmr); + + ASSERT_EQ(rmr.spared.size(), 1u); + EXPECT_EQ(rmr.spared[0].ref, bh(1)); + EXPECT_TRUE(rmr.graduated.empty()); + EXPECT_TRUE(rmr.still_retired.empty()); + + /// b1 recovered its edge: the output run carries the surviving edge and no sentinel for it. + const DecodedRun out = decodeRun(backend, runs2[0]); + EXPECT_TRUE(out.condemned.empty()); + ASSERT_EQ(out.edges.size(), 1u); + EXPECT_EQ(out.edges[0].first, b(1)); +} + +TEST(CASThreeCursorMerge, NewCandidateCondemned) +{ + InMemoryBackend backend; + Layout layout{"pool"}; + + /// Gen 1: C (=b3) has one edge. Gen 2 removes it => transition to zero, not retired => + /// condemned with the head-captured token at THIS pass's condemn_round. + std::vector runs1; + foldDeltasIntoGeneration(backend, layout, /*prior_runs*/{}, 1, 0, 0, {{bh(3), s(1), false}}, runs1); + + std::vector runs2; + RetiredMergeResult rmr; + foldDeltasIntoGeneration(backend, layout, /*prior_runs*/runs1, 2, 0, 0, {{bh(3), s(1), true}}, runs2, + /*current_round*/0, /*condemn_round*/7, headPresent("t9", 42), /*peek_head*/{}, /*confirm_condemned_marker*/{}, &rmr); + + ASSERT_EQ(rmr.still_retired.size(), 1u); + EXPECT_EQ(rmr.still_retired[0].ref, bh(3)); + EXPECT_EQ(rmr.still_retired[0].token.value, "t9"); + EXPECT_EQ(rmr.still_retired[0].size, 42u); + EXPECT_EQ(rmr.still_retired[0].condemn_round, 7u); + EXPECT_TRUE(rmr.graduated.empty()); + EXPECT_TRUE(rmr.spared.empty()); + + /// The fresh condemn is emitted as a kCondemned row (not a zero marker) into the output run. + const DecodedRun out = decodeRun(backend, runs2[0]); + ASSERT_EQ(out.condemned.size(), 1u); + EXPECT_EQ(out.condemned[0].first, b(3)); + EXPECT_EQ(out.condemned[0].second.token.value, "t9"); + EXPECT_TRUE(out.zero_markers.empty()); +} + +TEST(CASThreeCursorMerge, AbsentBlobNotCondemned) +{ + InMemoryBackend backend; + Layout layout{"pool"}; + + /// Same transition-to-zero as above, but the blob object is already gone at condemn time: + /// nothing to delete later, so no entry is minted — a plain zero marker is emitted instead. + std::vector runs1; + foldDeltasIntoGeneration(backend, layout, /*prior_runs*/{}, 1, 0, 0, {{bh(3), s(1), false}}, runs1); + + std::vector runs2; + RetiredMergeResult rmr; + foldDeltasIntoGeneration(backend, layout, /*prior_runs*/runs1, 2, 0, 0, {{bh(3), s(1), true}}, runs2, + /*current_round*/0, /*condemn_round*/7, + [](const BlobRef &) -> std::optional { return std::nullopt; }, /*peek_head*/{}, /*confirm_condemned_marker*/{}, &rmr); + + EXPECT_TRUE(rmr.still_retired.empty()); + EXPECT_TRUE(rmr.graduated.empty()); + EXPECT_TRUE(rmr.spared.empty()); + + const DecodedRun out = decodeRun(backend, runs2[0]); + EXPECT_TRUE(out.condemned.empty()); + ASSERT_EQ(out.zero_markers.size(), 1u); + EXPECT_EQ(out.zero_markers[0], b(3)); +} + +TEST(CASThreeCursorMerge, SnapshotEdgesUnperturbedByRetired) +{ + /// Retired-in-snapshot changes the byte-invariant: the retired machinery now WRITES kCondemned + /// sentinel rows into the run, so a retired-engaged run is no longer byte-identical to a plain one. + /// The preserved invariant (spec §2.1) is narrower: the retired machinery touches ONLY the sentinel + /// namespace — the surviving EDGE rows are byte-identical to a plain fold of the same deltas. + InMemoryBackend plain; + InMemoryBackend engaged; + Layout layout{"pool"}; + + std::vector r1; + foldDeltasIntoGeneration(plain, layout, /*prior_runs*/{}, 1, 0, 0, + {{bh(1), s(1), false}, {bh(2), s(1), false}, {bh(2), s(2), true}}, r1); + + /// Engaged: the SAME deltas, but the prior run carries retired rows for b1 (which the delta re-edges + /// => spared) and b5 (no edge => graduates past the floor). + const RunRef prior = writeSourceEdgeRun(engaged, layout, /*gen*/1, 0, 0, + {{b(1), condemnedRowFor(1)}, {b(5), condemnedRowFor(2)}}); + std::vector r2; + RetiredMergeResult rmr; + foldDeltasIntoGeneration(engaged, layout, /*prior_runs*/{prior}, 2, 0, 0, + {{bh(1), s(1), false}, {bh(2), s(1), false}, {bh(2), s(2), true}}, r2, + /*current_round*/9, /*condemn_round*/3, headPresent("t", 1), /*peek_head*/{}, /*confirm_condemned_marker*/{}, &rmr); + + const DecodedRun plain_run = decodeRun(plain, r1[0]); + const DecodedRun engaged_run = decodeRun(engaged, r2[0]); + EXPECT_EQ(plain_run.edges, engaged_run.edges); /// edge rows byte-identical + EXPECT_TRUE(plain_run.condemned.empty()); + /// The engaged run carries only the retired sentinel(s) on top: b1 spared (no row), b5 graduated. + ASSERT_EQ(engaged_run.condemned.size(), 1u); + EXPECT_EQ(engaged_run.condemned[0].first, b(5)); + EXPECT_TRUE(engaged_run.condemned[0].second.delete_pending); +} + +TEST(CASTwoCursorMerge, CarriedSentinelIsNotATouch) +{ + /// Gen 1 condemns b (a real +edge/-edge net-to-zero with head_blob present) -> a kCondemned row. Gen 2 + /// has NO deltas at all: the carried row must (a) survive byte-identically, (b) emit no zero marker, + /// (c) never call peek_head (a carried sentinel is not a touch). + InMemoryBackend backend; + Layout layout{"pool"}; + + /// Gen 1: (b,s1) added then removed => net-to-zero => fresh condemn at round 5 (token "tok", size 7). + std::vector runs1; + RetiredMergeResult rmr1; + foldDeltasIntoGeneration(backend, layout, /*prior_runs*/{}, 1, 0, 0, + {{bh(2), s(1), false}, {bh(2), s(1), true}}, runs1, + /*current_round*/0, /*condemn_round*/5, headPresent("tok", 7), /*peek_head*/{}, /*confirm_condemned_marker*/{}, &rmr1); + ASSERT_EQ(rmr1.still_retired.size(), 1u); + { + const DecodedRun g1 = decodeRun(backend, runs1[0]); + ASSERT_EQ(g1.condemned.size(), 1u); + EXPECT_EQ(g1.condemned[0].first, b(2)); + EXPECT_TRUE(g1.zero_markers.empty()); /// a condemned blob emits kCondemned, never a zero marker + } + + /// Gen 2: empty deltas, current_round 1 (< 5 => b carries, does not graduate). peek_head must NOT fire. + size_t peek_calls = 0; + auto peek = [&](const BlobRef &) -> std::optional { ++peek_calls; return {}; }; + std::vector runs2; + RetiredMergeResult rmr2; + foldDeltasIntoGeneration(backend, layout, /*prior_runs*/runs1, 2, 0, 0, {}, runs2, + /*current_round*/1, /*condemn_round*/6, /*head_blob*/{}, peek, /*confirm_condemned_marker*/{}, &rmr2); + + EXPECT_EQ(peek_calls, 0u); + ASSERT_EQ(rmr2.still_retired.size(), 1u); + EXPECT_EQ(rmr2.still_retired[0].ref, bh(2)); + EXPECT_EQ(rmr2.still_retired[0].condemn_round, 5u); /// carried unchanged + EXPECT_TRUE(rmr2.graduated.empty()); + + const DecodedRun g2 = decodeRun(backend, runs2[0]); + ASSERT_EQ(g2.condemned.size(), 1u); + EXPECT_EQ(g2.condemned[0].first, b(2)); + EXPECT_EQ(g2.condemned[0].second.token.value, "tok"); + EXPECT_EQ(g2.condemned[0].second.size, 7u); + EXPECT_TRUE(g2.zero_markers.empty()); +} + +TEST(CASTwoCursorMerge, MalformedRunFailsClosed) +{ + Layout layout{"pool"}; + + /// (1) An active edge at the reserved sentinel source_id 0 -> the merge cursor fails closed. + { + InMemoryBackend backend; + DB::WriteBufferFromOwnString out; + SourceEdgeRunWriter writer(out); + writer.append(edgeRec(1, UInt128{0})); // edge at sentinel key + writer.finish(); + out.finalize(); + const String bytes = out.str(); + const RunRef bad{.key = layout.blobTargetRunKey(1, 0, 0, 0), + .checksum = sourceEdgeRunChecksum(bytes), .shard = 0, .generation = 1}; + backend.putIfAbsent(bad.key, bytes); + + std::vector runs2; + EXPECT_THROW(foldDeltasIntoGeneration(backend, layout, /*prior_runs*/{bad}, 2, 0, 0, {}, runs2), + DB::Exception); + } + + /// (2) Two sentinel rows for one blob -> duplicate sentinel -> the merge cursor fails closed. + { + InMemoryBackend backend; + DB::WriteBufferFromOwnString out; + SourceEdgeRunWriter writer(out); + /// Same (b,0) key twice (equal keys are allowed by the writer) — two condemned sentinels for b1. + writer.append(condemnedRec(1, condemnedRowFor(1))); + writer.append(condemnedRec(1, condemnedRowFor(2))); + writer.finish(); + out.finalize(); + const String bytes = out.str(); + const RunRef bad{.key = layout.blobTargetRunKey(1, 0, 0, 0), + .checksum = sourceEdgeRunChecksum(bytes), .shard = 0, .generation = 1}; + backend.putIfAbsent(bad.key, bytes); + + std::vector runs2; + EXPECT_THROW(foldDeltasIntoGeneration(backend, layout, /*prior_runs*/{bad}, 2, 0, 0, {}, runs2), + DB::Exception); + } +} + +/// A prior run spanning several blocks folds correctly with the streaming prior cursor AND the backend +/// sees only block-bounded ranged/stream requests for it — never a whole-object get of the prior run +/// key. Byte-reproducibility of the merged output is the load-bearing canary (the merge logic is +/// unchanged; only the prior cursor's byte source moved from materialize-whole to stream). +TEST(CASBlobInDegree, FoldStreamsPriorRunBlockBounded) +{ + using DB::Cas::tests::CountingBackend; + CountingBackend backend; + /// InMemory oracle: the SAME two folds against a plain backend must yield byte-identical runs — + /// the streaming cursor changes I/O shape, not bytes. + InMemoryBackend oracle; + Layout layout{"pool"}; + + /// Gen 1 from empty prior: enough edges that the SourceEdge run spills across many 256KB blocks. + /// Each record is 4 + 32(key) + 4 + 1(payload) = 41 bytes, so ~20000 edges is ~820KB => several + /// blocks under the default block_size, exercising the multi-block streaming path in the fold. + std::vector gen1; + gen1.reserve(20000); + for (uint64_t i = 0; i < 20000; ++i) + gen1.push_back({bh(i), s(1), false}); + + std::vector runs1_c; + std::vector runs1_o; + foldDeltasIntoGeneration(backend, layout, /*prior_runs*/{}, 1, 0, 0, gen1, runs1_c); + foldDeltasIntoGeneration(oracle, layout, /*prior_runs*/{}, 1, 0, 0, gen1, runs1_o); + + const String gen1_run_key = layout.blobTargetRunKey(1, 0, 0, 0); + const auto gen1_run = backend.get(gen1_run_key); + ASSERT_TRUE(gen1_run.has_value()); + const String gen1_run_bytes = gen1_run->bytes; + /// Sanity: the prior run really spans several blocks (else the block-bounded assertions are + /// vacuous). Blocks seal at kLegacyBlockSize (256KB); ~820KB is 3-4 blocks. + ASSERT_GT(gen1_run_bytes.size(), static_cast(kLegacyBlockSize) * 3); + + /// Reset counters and fold gen 2 with a small delta: remove one edge and add another. The prior + /// gen-1 run must be consumed via the streaming cursor (head + tail get + body getStream + per-seq + /// head probe), NEVER a whole-object get. + backend.resetCounts(); + std::vector gen2{{bh(0), s(1), true}, {bh(19999), s(2), false}}; + std::vector runs2_c; + std::vector runs2_o; + foldDeltasIntoGeneration(backend, layout, /*prior_runs*/runs1_c, 2, 0, 0, gen2, runs2_c); + foldDeltasIntoGeneration(oracle, layout, /*prior_runs*/runs1_o, 2, 0, 0, gen2, runs2_o); + + /// Byte-reproducibility canary: streaming and materialized folds produce identical output bytes. + const String gen2_run_key = layout.blobTargetRunKey(2, 0, 0, 0); + const auto gen2_c = backend.get(gen2_run_key); + const auto gen2_o = oracle.get(gen2_run_key); + ASSERT_TRUE(gen2_c.has_value()); + ASSERT_TRUE(gen2_o.has_value()); + EXPECT_EQ(gen2_c->bytes, gen2_o->bytes); + ASSERT_EQ(runs2_c.size(), 1u); + ASSERT_EQ(runs2_o.size(), 1u); + EXPECT_EQ(runs2_c[0].checksum, runs2_o[0].checksum); + + /// The core assertion: no whole-object get of the prior run key — every read carried a Range or a + /// stream (the resident-memory proof at the seam). + EXPECT_EQ(backend.wholeGetCount(gen1_run_key), 0u); + /// The cursor opened the prior run's segment via the streaming reader (head + tail get + getStream). + EXPECT_GE(backend.getStreamCount(gen1_run_key), 1u); + /// Every ranged-get window on the prior run stays within one block + the footer allowance. This + /// bound is strict here because the prior run's footer fits inside the fixed tail probe (only very + /// large runs — ~13k blocks — spill the footer past the probe and add one exact-footer get; a note + /// for that regime lives in the streaming reader's open comment). + EXPECT_LE(backend.maxRangedGetLen(gen1_run_key), + static_cast(kLegacyHardCapBlockSize) + 64u * 1024u); + /// Streaming open touches the prior run's tail probe (and at most one exact-footer get); it is never + /// re-materialized whole. + EXPECT_LE(backend.getCount(gen1_run_key), 2u); +} + +/// The preview consumer `zeroInDegree` streams a multi-block run instead of materializing it whole: the +/// backend sees only block-bounded ranged/stream requests for the run key (never a whole-object get), and +/// the candidate set equals the pre-change (borrowed-mode) result. Byte-parity against an InMemory oracle +/// is the load-bearing canary — the scan logic is unchanged; only the byte source moved to the stream. +TEST(CASBlobInDegree, ZeroInDegreeStreamsBlockBounded) +{ + using DB::Cas::tests::CountingBackend; + CountingBackend backend; + InMemoryBackend oracle; + Layout layout{"pool"}; + + /// Gen 1 from empty prior: ~20000 active edges spill the SourceEdge run across several 256KB blocks. + std::vector gen1; + gen1.reserve(20000); + for (uint64_t i = 0; i < 20000; ++i) + gen1.push_back({bh(i), s(1), false}); + + std::vector runs1_c; + std::vector runs1_o; + foldDeltasIntoGeneration(backend, layout, /*prior_runs*/{}, 1, 0, 0, gen1, runs1_c); + foldDeltasIntoGeneration(oracle, layout, /*prior_runs*/{}, 1, 0, 0, gen1, runs1_o); + + /// Gen 2 removes every edge on two of the blobs => two zero-transition markers in the gen-2 run, + /// which is itself multi-block (the surviving-edge rows still span blocks). + std::vector gen2{{bh(0), s(1), true}, {bh(19999), s(1), true}}; + std::vector runs2_c; + std::vector runs2_o; + foldDeltasIntoGeneration(backend, layout, /*prior_runs*/runs1_c, 2, 0, 0, gen2, runs2_c); + foldDeltasIntoGeneration(oracle, layout, /*prior_runs*/runs1_o, 2, 0, 0, gen2, runs2_o); + + const String gen2_run_key = layout.blobTargetRunKey(2, 0, 0, 0); + const auto gen2_run = backend.get(gen2_run_key); + ASSERT_TRUE(gen2_run.has_value()); + /// Sanity: the run genuinely spans several blocks (else the block-bounded assertions are vacuous). + ASSERT_GT(gen2_run->bytes.size(), static_cast(kLegacyBlockSize) * 3); + + backend.resetCounts(); + const auto zero_c = zeroInDegree(backend, runs2_c); + const auto zero_o = zeroInDegree(oracle, runs2_o); + + /// Equivalence with the borrowed-mode (InMemory oracle) result: same candidates, in the same order. + ASSERT_EQ(zero_c.size(), zero_o.size()); + ASSERT_EQ(zero_c.size(), 2u); + for (size_t i = 0; i < zero_c.size(); ++i) + EXPECT_EQ(zero_c[i].ref, zero_o[i].ref); + + /// The core assertion: no whole-object get of the run key — every read carried a Range or a stream. + EXPECT_EQ(backend.wholeGetCount(gen2_run_key), 0u); + /// The scan opened the run via the streaming reader (head + tail get + getStream). + EXPECT_GE(backend.getStreamCount(gen2_run_key), 1u); + /// Every ranged-get window stays within one block + the footer allowance (the seam memory bound). + EXPECT_LE(backend.maxRangedGetLen(gen2_run_key), + static_cast(kLegacyHardCapBlockSize) + 64u * 1024u); + /// Streaming open touches the tail probe (and at most one exact-footer get); never re-materialized whole. + EXPECT_LE(backend.getCount(gen2_run_key), 2u); +} + +/// ==== kCondemned row codec + typed source-edge open (retired-in-snapshot T2, spec §2.1) ==== + +TEST(CASCondemnedRow, RoundTripAllTokenTypes) +{ + for (auto type : {DB::Cas::TokenType::ETag, DB::Cas::TokenType::Generation, DB::Cas::TokenType::Emulated}) + { + DB::Cas::CondemnedRow row; + row.delete_pending = (type == DB::Cas::TokenType::Generation); + row.marker_confirmed = (type == DB::Cas::TokenType::Emulated); + row.token = DB::Cas::Token{.value = "etag-abc-123", .type = type}; + row.size = 4096; + row.condemn_round = 7; + const auto bytes = DB::Cas::encodeCondemnedRow(row); + ASSERT_EQ(bytes[0], DB::Cas::kCondemned); + EXPECT_EQ(DB::Cas::decodeCondemnedRow(bytes), row); + } +} + +TEST(CASCondemnedRow, UnknownFlagBitsFailClosed) +{ + DB::Cas::CondemnedRow row; + row.token = DB::Cas::Token{.value = "t", .type = DB::Cas::TokenType::ETag}; + auto bytes = DB::Cas::encodeCondemnedRow(row); + bytes[1] = 4; // flags byte: only bits 0 (delete_pending) and 1 (marker_confirmed) are defined + EXPECT_THROW(DB::Cas::decodeCondemnedRow(bytes), DB::Exception); +} + +TEST(CASCondemnedRow, UnknownTokenTypeFailsClosed) +{ + DB::Cas::CondemnedRow row; + row.token = DB::Cas::Token{.value = "t", .type = DB::Cas::TokenType::ETag}; + auto bytes = DB::Cas::encodeCondemnedRow(row); + bytes[2] = 99; // token_type byte (offset: [0]=0x02 [1]=flags [2]=token_type) + EXPECT_THROW(DB::Cas::decodeCondemnedRow(bytes), DB::Exception); +} + +TEST(CASCondemnedRow, TruncatedPayloadFailsClosed) +{ + DB::Cas::CondemnedRow row; + row.token = DB::Cas::Token{.value = "0123456789", .type = DB::Cas::TokenType::ETag}; + auto bytes = DB::Cas::encodeCondemnedRow(row); + bytes.resize(bytes.size() - 3); // token bytes shorter than declared token_len + EXPECT_THROW(DB::Cas::decodeCondemnedRow(bytes), DB::Exception); +} + +TEST(CASSourceEdgeRun, SourceEdgeIdZeroIsReserved) +{ + /// The zero source_id is the sentinel namespace; producers fail closed on a zero hash + /// (probability 2^-128 — the check documents the reservation). + EXPECT_DEATH( + { + DB::abort_on_logical_error.store(true, std::memory_order_relaxed); + DB::Cas::assertValidSourceEdgeId(UInt128{0}); + }, + "source_id 0 is the reserved sentinel key"); + EXPECT_NO_THROW(DB::Cas::assertValidSourceEdgeId(UInt128{1})); +} + +/// ==== schema 3 key codec (Phase 3 T3, mixed-algo pools) ==== + +TEST(CASSourceEdgeKeySchema3, MixedWidthKeysOrderAlgoFirst) +{ + const BlobDigest d16 = BlobDigest::fromU128((UInt128(0xFFFFFFFFFFFFFFFFULL) << 64) | 0xFFULL); + BlobDigest d32{}; /// sha256 digest starting 0x00,0x01 — small bytes + d32.bytes[1] = 0x01; + const BlobRef ch{BlobHashAlgo::CityHash128, d16}; /// algo=1, digest all-FF prefix + const BlobRef sh{BlobHashAlgo::Sha256, d32}; /// algo=3, tiny digest + const String k_ch = SourceEdgeKeyCodec::key(ch, UInt128(7)); /// 33 bytes + const String k_sh = SourceEdgeKeyCodec::key(sh, UInt128(7)); /// 49 bytes + EXPECT_EQ(k_ch.size(), 33u); + EXPECT_EQ(k_sh.size(), 49u); + /// algo byte decides BEFORE any digest byte can: ch128(1) < sha256(3) even though the ch128 + /// digest bytes are all 0xFF and the sha256 digest bytes are almost all zero. + EXPECT_LT(k_ch, k_sh); + /// sentinel-first inside one blob group: + EXPECT_LT(SourceEdgeKeyCodec::key(ch, UInt128(0)), k_ch); +} + +TEST(CASSourceEdgeKeySchema3, ParseFailsClosed) +{ + BlobRef r; UInt128 sid; + String k = SourceEdgeKeyCodec::key(BlobRef{BlobHashAlgo::XXH3_128, BlobDigest::fromU128(UInt128(5))}, UInt128(9)); + SourceEdgeKeyCodec::parse(k, r, sid); + EXPECT_EQ(r.algo, BlobHashAlgo::XXH3_128); + EXPECT_EQ(r.digest.toU128(), UInt128(5)); + EXPECT_EQ(sid, UInt128(9)); + k[0] = static_cast(99); /// unknown algo byte + DB::Cas::tests::expectThrowsCode(DB::ErrorCodes::NOT_IMPLEMENTED, [&]{ SourceEdgeKeyCodec::parse(k, r, sid); }); + k[0] = static_cast(1); /// known algo, wrong length (33 expected, this is 33 — truncate) + DB::Cas::tests::expectThrowsCode(DB::ErrorCodes::CORRUPTED_DATA, [&]{ SourceEdgeKeyCodec::parse(std::string_view(k).substr(0, 20), r, sid); }); +} + +TEST(CASBlobInDegree, TwoAlgoFoldSettlesBothInOneShardRun) +{ + /// Step 3 (Phase 3 T3): extend the fold with deltas for ch128:X and sha256:Y in ONE shard run — + /// both settle (edges present, condemn on removal works per ref), mixed rows in one run, no + /// algo loop. + InMemoryBackend backend; + Layout layout{"pool"}; + + const BlobRef ch_x{BlobHashAlgo::CityHash128, BlobDigest::fromU128(UInt128(11))}; + BlobDigest sha_y{}; + sha_y.bytes[0] = 0xAB; + const BlobRef sha_y_ref{BlobHashAlgo::Sha256, sha_y}; + + std::vector runs1; + foldDeltasIntoGeneration(backend, layout, /*prior_runs*/{}, 1, /*attempt*/0, 0, + {{ch_x, s(1), false}, {sha_y_ref, s(1), false}}, runs1); + ASSERT_FALSE(runs1.empty()); + + EXPECT_EQ(DB::Cas::tests::inDegreeInRuns(backend, runs1, ch_x), 1); + EXPECT_EQ(DB::Cas::tests::inDegreeInRuns(backend, runs1, sha_y_ref), 1); + EXPECT_TRUE(zeroInDegree(backend, runs1).empty()); + + /// Remove both edges in gen 2: each transitions to zero independently, condemned per its own ref. + std::vector runs2; + RetiredMergeResult rmr; + foldDeltasIntoGeneration(backend, layout, /*prior_runs*/runs1, 2, /*attempt*/0, 0, + {{ch_x, s(1), true}, {sha_y_ref, s(1), true}}, runs2, + /*current_round*/0, /*condemn_round*/1, headPresent("t", 1), /*peek_head*/{}, /*confirm_condemned_marker*/{}, &rmr); + + ASSERT_EQ(rmr.still_retired.size(), 2u); + std::vector condemned_refs{rmr.still_retired[0].ref, rmr.still_retired[1].ref}; + EXPECT_NE(std::find(condemned_refs.begin(), condemned_refs.end(), ch_x), condemned_refs.end()); + EXPECT_NE(std::find(condemned_refs.begin(), condemned_refs.end(), sha_y_ref), condemned_refs.end()); + EXPECT_EQ(DB::Cas::tests::inDegreeInRuns(backend, runs2, ch_x), 0); + EXPECT_EQ(DB::Cas::tests::inDegreeInRuns(backend, runs2, sha_y_ref), 0); +} + +/// [UNMATCHED-MINUS-ONE] pin. In-degree is a SET of source edges applied last-wins per +/// (ref, ManifestId, path) key -- NOT a counter. A removal delta whose matching activation was +/// never folded (reachable today via a false-404 at the activation fold plus a dead-build skip) +/// must therefore be a per-key NO-OP: it marks an already-absent edge absent and cannot strip a +/// sibling manifest's edge for the SAME blob. The whole "that interleaving is harmless" argument in +/// the publish-confirm design rests on this; if the model ever regresses to counter arithmetic this +/// test goes red and premature deletion becomes reachable again. +TEST(CASBlobInDegree, UnmatchedRemovalIsAPerKeyNoOpAndSparesSiblingEdges) +{ + InMemoryBackend backend; + Layout layout{"pool"}; + + /// Generation 1: blob b1 is referenced by TWO distinct sources (two manifests). + std::vector runs1; + foldDeltasIntoGeneration(backend, layout, /*prior_runs*/{}, /*new_generation*/1, /*attempt*/0, /*shard*/0, + {{bh(1), s(1), false}, {bh(1), s(2), false}}, runs1); + + /// Generation 2: fold a removal for a THIRD source that never had an activation folded. + std::vector runs2; + foldDeltasIntoGeneration(backend, layout, /*prior_runs*/runs1, /*new_generation*/2, /*attempt*/0, /*shard*/0, + {{bh(1), s(99), true}}, runs2); + + /// Both original edges survive: the unmatched removal touched only its own (absent) key. + const DecodedRun out = decodeRun(backend, runs2[0]); + ASSERT_EQ(out.edges.size(), 2u) << "an unmatched removal must not strip sibling edges"; + /// And the blob is NOT a deletion candidate. + const auto zero = zeroInDegree(backend, runs2); + EXPECT_TRUE(zero.empty()) << "b1 still has two live source edges"; +} + +/// The silence in `UnmatchedRemovalIsAPerKeyNoOpAndSparesSiblingEdges` above is exactly what let a whole +/// class of GC defects survive months of soak runs undetected — the fold's per-key no-op left no trace. +/// This test pins the COUNTING surface added on top: `RetiredMergeResult::unmatched_removes` / +/// `unmatched_remove_example` must report the unmatched remove precisely (one hit, naming the right blob +/// and source id), while the byte-level no-op behaviour (asserted above) is unchanged. +TEST(CASBlobInDegree, UnmatchedRemovalIsCountedWithAnExample) +{ + InMemoryBackend backend; + Layout layout{"pool"}; + + /// Generation 1: blob b1 is referenced by TWO distinct sources (two manifests), same fixture as the + /// no-op test above. + std::vector runs1; + foldDeltasIntoGeneration(backend, layout, /*prior_runs*/{}, /*new_generation*/1, /*attempt*/0, /*shard*/0, + {{bh(1), s(1), false}, {bh(1), s(2), false}}, runs1); + + /// Generation 2: fold a removal for a THIRD source that never had an activation folded. + std::vector runs2; + RetiredMergeResult rmr; + foldDeltasIntoGeneration(backend, layout, /*prior_runs*/runs1, /*new_generation*/2, /*attempt*/0, /*shard*/0, + {{bh(1), s(99), true}}, runs2, + /*current_round*/0, /*condemn_round*/0, /*head_blob*/{}, /*peek_head*/{}, /*confirm_condemned_marker*/{}, &rmr); + + /// The run is byte-identical to the no-op test's outcome for the blob's OTHER edges: both survive. + const DecodedRun out = decodeRun(backend, runs2[0]); + ASSERT_EQ(out.edges.size(), 2u) << "the counting surface must not perturb the no-op fold outcome"; + EXPECT_EQ(out.edges[0].first, b(1)); + EXPECT_EQ(out.edges[1].first, b(1)); + std::vector surviving_sources{out.edges[0].second, out.edges[1].second}; + EXPECT_NE(std::find(surviving_sources.begin(), surviving_sources.end(), s(1)), surviving_sources.end()); + EXPECT_NE(std::find(surviving_sources.begin(), surviving_sources.end(), s(2)), surviving_sources.end()); + + /// The counting surface reports exactly the one unmatched remove, naming the right blob and source id. + EXPECT_EQ(rmr.unmatched_removes, 1u); + ASSERT_TRUE(rmr.unmatched_remove_example.has_value()); + EXPECT_EQ(rmr.unmatched_remove_example->ref, bh(1)); + EXPECT_EQ(rmr.unmatched_remove_example->source_id, s(99)); +} + +namespace +{ +/// N distinct condemned rows for blobs b(1)..b(n), same shape `condemnedRowFor` produces, varying +/// only the token so distinct rows are trivially distinguishable in a failure message. +std::vector> condemnedCohort(uint64_t n, uint64_t condemn_round, bool delete_pending) +{ + std::vector> rows; + for (uint64_t i = 1; i <= n; ++i) + rows.push_back({b(i), condemnedRowFor(condemn_round, "t" + std::to_string(i), delete_pending)}); + return rows; +} +} + +/// The redelete cohort is capped at `GcRoundWorkBudget::max_redeletes` per call. Excess +/// entries stay in `still_retired`, still `delete_pending`, to be redeleted by a later round — the +/// durable pipeline never loses one to the cap. +TEST(CASThreeCursorMerge, RedeleteBudgetCapsCohortAndCarriesExcess) +{ + InMemoryBackend backend; + Layout layout{"pool"}; + const RunRef gen1 = writeSourceEdgeRun(backend, layout, 1, 0, 0, condemnedCohort(10, 1, /*delete_pending*/true)); + + GcRoundWorkBudget budget; + budget.max_redeletes = 3; + + std::vector runs2; + RetiredMergeResult rmr; + foldDeltasIntoGeneration(backend, layout, /*prior_runs*/{gen1}, 2, 0, 0, {}, runs2, + /*current_round*/9, /*condemn_round*/9, /*head_blob*/{}, /*peek_head*/{}, /*confirm_condemned_marker*/{}, + &rmr, /*suppress_destructive*/false, /*out_applied_by_txn_ordinal*/nullptr, + /*source_retirements*/{}, &budget); + + EXPECT_EQ(rmr.redelete.size(), 3u); + EXPECT_EQ(budget.redeletes_used, 3u); + EXPECT_TRUE(rmr.graduated.empty()); + EXPECT_TRUE(rmr.spared.empty()); + ASSERT_EQ(rmr.still_retired.size(), 7u); + for (const RetiredEntry & e : rmr.still_retired) + EXPECT_TRUE(e.delete_pending) << "carried entries stay delete_pending, unexecuted this round"; +} + +/// Mirror test for the graduation cap: entries past `max_graduations` carry unchanged (still +/// condemned, NOT yet delete_pending) rather than being force-graduated; the floor re-evaluates them +/// next round. +TEST(CASThreeCursorMerge, GraduationBudgetCapsCohortAndCarriesExcess) +{ + InMemoryBackend backend; + Layout layout{"pool"}; + const RunRef gen1 = writeSourceEdgeRun(backend, layout, 1, 0, 0, condemnedCohort(10, /*condemn_round*/1, /*delete_pending*/false)); + + GcRoundWorkBudget budget; + budget.max_graduations = 3; + + std::vector runs2; + RetiredMergeResult rmr; + foldDeltasIntoGeneration(backend, layout, /*prior_runs*/{gen1}, 2, 0, 0, {}, runs2, + /*current_round*/5, /*condemn_round*/6, /*head_blob*/{}, /*peek_head*/{}, /*confirm_condemned_marker*/{}, + &rmr, /*suppress_destructive*/false, /*out_applied_by_txn_ordinal*/nullptr, + /*source_retirements*/{}, &budget); + + EXPECT_EQ(rmr.graduated.size(), 3u); + EXPECT_EQ(budget.graduations_used, 3u); + ASSERT_EQ(rmr.still_retired.size(), 10u); + size_t pending_count = 0; + size_t carried_count = 0; + for (const RetiredEntry & e : rmr.still_retired) + e.delete_pending ? ++pending_count : ++carried_count; + EXPECT_EQ(pending_count, 3u) << "only the graduated 3 are republished delete_pending"; + EXPECT_EQ(carried_count, 7u) << "the rest carry unchanged, still eligible next round"; +} + +/// The mandatory convergence proof: a cohort well past the per-round cap fully drains over +/// ceil(N / cap) rounds, feeding each round's output run back as the next round's prior — the exact +/// shape a real GC round repeats every pass. +TEST(CASThreeCursorMerge, RedeleteBudgetDrainsCohortToFixpointOverRounds) +{ + InMemoryBackend backend; + Layout layout{"pool"}; + std::vector priors{writeSourceEdgeRun(backend, layout, 1, 0, 0, condemnedCohort(10, 1, /*delete_pending*/true))}; + + uint64_t total_redeleted = 0; + uint64_t rounds = 0; + while (rounds < 10) + { + GcRoundWorkBudget budget; + budget.max_redeletes = 3; + std::vector out_runs; + RetiredMergeResult rmr; + foldDeltasIntoGeneration(backend, layout, priors, 2 + rounds, 0, 0, {}, out_runs, + /*current_round*/100, /*condemn_round*/100, /*head_blob*/{}, /*peek_head*/{}, /*confirm_condemned_marker*/{}, + &rmr, /*suppress_destructive*/false, /*out_applied_by_txn_ordinal*/nullptr, + /*source_retirements*/{}, &budget); + total_redeleted += rmr.redelete.size(); + ++rounds; + if (rmr.still_retired.empty()) + break; + ASSERT_FALSE(out_runs.empty()); + priors = out_runs; + } + EXPECT_EQ(total_redeleted, 10u) << "no entry lost to the cap across the whole drain"; + EXPECT_EQ(rounds, 4u) << "ceil(10 / 3) rounds to fully drain"; +} diff --git a/src/Disks/tests/gtest_cas_blob_meta.cpp b/src/Disks/tests/gtest_cas_blob_meta.cpp new file mode 100644 index 000000000000..e7094d36d754 --- /dev/null +++ b/src/Disks/tests/gtest_cas_blob_meta.cpp @@ -0,0 +1,212 @@ +#include + +#include +#include +#include +#include "cas_test_helpers.h" + +#include + +namespace DB::ErrorCodes +{ +extern const int CORRUPTED_DATA; +} + +using namespace DB::Cas; +using namespace DB::Cas::tests; + +/// Codec tests (round-trip both states, fail-closed decode) moved to gtest_cas_blob_meta_format.cpp +/// with the v3 text cutover; the lifecycle + inspect tests below stay — they exercise the Core ops +/// and CasInspect against the stable encode/decode signatures and must pass unchanged. + +TEST(CASBlobMeta, PutIfAbsentThenCasTransitions) +{ + auto backend = std::make_shared(); + auto store = openPoolForTest(backend); + const BlobRef ref{BlobHashAlgo::CityHash128, BlobDigest::fromU128(u128Of("hash-a"))}; + const BlobMeta clean{.state = MetaState::Clean, .size = 10}; + + const CasOverwriteResult created = putMetaIfAbsent(*store, ref, clean); + EXPECT_EQ(created.outcome, CasOverwriteOutcome::Committed); + + const CasOverwriteResult dup = putMetaIfAbsent(*store, ref, clean); + EXPECT_EQ(dup.outcome, CasOverwriteOutcome::Committed); /// exact-byte resolution adopts the existing marker + + const auto lm = loadMeta(*backend, store->layout(), ref); + ASSERT_TRUE(lm.has_value()); + EXPECT_EQ(lm->meta.state, MetaState::Clean); + + const CasOverwriteResult condemned = casMeta(*store, ref, lm->etag, + BlobMeta{.state = MetaState::Condemned, .condemn_round = 5, .size = 10}); + EXPECT_EQ(condemned.outcome, CasOverwriteOutcome::Committed); + + const CasOverwriteResult stale = casMeta(*store, ref, lm->etag, /// stale token loses + BlobMeta{.state = MetaState::Clean}); + EXPECT_EQ(stale.outcome, CasOverwriteOutcome::Conflict); +} + +TEST(CASBlobMeta, DeleteMetaExactMatchesEtag) +{ + auto backend = std::make_shared(); + auto store = openPoolForTest(backend); + const BlobRef ref{BlobHashAlgo::CityHash128, BlobDigest::fromU128(u128Of("hash-b"))}; + putMetaIfAbsent(*store, ref, BlobMeta{.state = MetaState::Condemned}); + const auto lm = loadMeta(*backend, store->layout(), ref); + ASSERT_TRUE(lm.has_value()); + EXPECT_EQ(deleteMetaExact(*backend, store->layout(), ref, lm->etag).kind, DeleteOutcome::Kind::Deleted); + EXPECT_FALSE(loadMeta(*backend, store->layout(), ref).has_value()); +} + +/// Phase 3 T3 (mixed-algo pools, was CAS pluggable-blob-hash Phase 2 Task 5 crux Test 2): the `.meta` +/// API round-trips a 32-byte (`sha256`-width) `BlobRef` key — the meta object lands under a 64-hex +/// key, exercising the SAME `putMetaIfAbsent`/`loadMeta`/`casMeta`/`deleteMetaExact` surface PartWriteTxn/Gc +/// use, just at a wider algo. Writes use the `Pool`'s controller; reads and exact deletion retain their +/// direct `Backend`/`Layout` surface. +TEST(CASBlobMeta, PutLoadCasDeleteRoundTripAtWidth32) +{ + auto backend = std::make_shared(); + auto store = openPoolForTest(backend); + const Layout & layout = store->layout(); + + /// A distinguishable 32-byte digest (not merely a 16-byte value zero-tailed): every byte set. + BlobDigest h; + for (size_t i = 0; i < h.bytes.size(); ++i) + h.bytes[i] = static_cast(i + 1); + const BlobRef ref{BlobHashAlgo::Sha256, h}; + const String hex = codecFor(BlobHashAlgo::Sha256).toHex(h); + EXPECT_EQ(hex.size(), 64u) << "a 32-byte digest renders 64 hex chars"; + + const CasOverwriteResult created = putMetaIfAbsent(*store, ref, + BlobMeta{.state = MetaState::Clean, .size = 555}); + ASSERT_EQ(created.outcome, CasOverwriteOutcome::Committed); + EXPECT_TRUE(backend->head(layout.blobMetaKey(ref)).exists) + << "the meta object must land under the 64-hex key, not a truncated 32-hex one"; + + const auto lm = loadMeta(*backend, layout, ref); + ASSERT_TRUE(lm.has_value()); + EXPECT_EQ(lm->meta.state, MetaState::Clean); + EXPECT_EQ(lm->meta.size, 555u); + + const CasOverwriteResult condemned = casMeta(*store, ref, lm->etag, + BlobMeta{.state = MetaState::Condemned, .condemn_round = 7, .size = 555}); + ASSERT_EQ(condemned.outcome, CasOverwriteOutcome::Committed); + const auto lm2 = loadMeta(*backend, layout, ref); + ASSERT_TRUE(lm2.has_value()); + EXPECT_EQ(lm2->meta.state, MetaState::Condemned); + + EXPECT_EQ(deleteMetaExact(*backend, layout, ref, lm2->etag).kind, DeleteOutcome::Kind::Deleted); + EXPECT_FALSE(loadMeta(*backend, layout, ref).has_value()); +} + +namespace +{ + +class ControlledMetaWriteFaultBackend : public InMemoryBackend +{ +public: + bool throw_next_create = false; + bool throw_next_overwrite = false; + uint64_t create_attempts = 0; + uint64_t overwrite_attempts = 0; + + PutResult putIfAbsent(const String & key, const String & bytes, const ObjectMeta & meta) override + { + ++create_attempts; + if (throw_next_create) + { + throw_next_create = false; + throw Poco::TimeoutException("scripted meta create ambiguity"); + } + return InMemoryBackend::putIfAbsent(key, bytes, meta); + } + + PutResult putOverwrite(const String & key, const String & bytes, const Token & expected, const ObjectMeta & meta) override + { + ++overwrite_attempts; + if (throw_next_overwrite) + { + throw_next_overwrite = false; + throw Poco::TimeoutException("scripted meta overwrite ambiguity"); + } + return InMemoryBackend::putOverwrite(key, bytes, expected, meta); + } +}; + +} + +TEST(CASBlobMeta, WritesUsePoolRequestController) +{ + auto backend = std::make_shared(); + auto store = openPoolForTest(backend); + store->setCasRetrySleepForTest([](uint64_t) {}); + backend->create_attempts = 0; + backend->overwrite_attempts = 0; + + const BlobRef ref{BlobHashAlgo::CityHash128, BlobDigest::fromU128(u128Of("hash-controlled"))}; + backend->throw_next_create = true; + EXPECT_EQ( + putMetaIfAbsent(*store, ref, BlobMeta{.state = MetaState::Clean, .size = 10}).outcome, + CasOverwriteOutcome::Committed); + EXPECT_EQ(backend->create_attempts, 2u); + + const auto clean = loadMeta(*backend, store->layout(), ref); + ASSERT_TRUE(clean.has_value()); + backend->throw_next_overwrite = true; + EXPECT_EQ( + casMeta(*store, ref, clean->etag, BlobMeta{.state = MetaState::Condemned, .condemn_round = 1, .size = 10}).outcome, + CasOverwriteOutcome::Committed); + EXPECT_EQ(backend->overwrite_attempts, 2u); +} + +/// Phase 3 T3 (was Phase 2 Task 5 crux Test 2, dedup half): the dedup-cache set is `BlobRef`-keyed and +/// admits a 32-byte digest without truncation/collision against its 16-byte zero-tailed sibling — even +/// under a DIFFERENT algo (the whole point of the pair identity). +TEST(CASBlobMeta, DeduplicationCacheAdmitsWidth32Digest) +{ + auto backend = std::make_shared(); + PoolConfig cfg{.pool_prefix = "p", .server_root_id = "test", .deduplication_cache_bytes = 64ULL << 20}; + auto store = Pool::open(backend, cfg); + + BlobDigest wide; + for (size_t i = 0; i < wide.bytes.size(); ++i) + wide.bytes[i] = static_cast(i + 1); + /// The 16-byte prefix of `wide`, zero-tailed — a DIFFERENT logical identity at width 16. + BlobDigest narrow; + for (size_t i = 0; i < 16; ++i) + narrow.bytes[i] = wide.bytes[i]; + + const BlobRef wide_ref{BlobHashAlgo::Sha256, wide}; + const BlobRef narrow_ref{BlobHashAlgo::CityHash128, narrow}; + EXPECT_FALSE(store->dedupCacheContains(wide_ref)); + EXPECT_FALSE(store->dedupCacheContains(narrow_ref)); + store->dedupCacheAdd(wide_ref); + EXPECT_TRUE(store->dedupCacheContains(wide_ref)); + EXPECT_FALSE(store->dedupCacheContains(narrow_ref)) << "a 32-byte digest must not collide with its zero-tailed 16-byte prefix"; +} + +/// `cas-inspect` dispatch (CasInspect.cpp): a `.meta` key must decode as a BlobMeta, NOT fall through +/// to the `blobs/` envelope branch (the `.meta` key shares the `blobsPrefix()` prefix with a body key). +TEST(CASBlobMeta, InspectRendersCondemnedMeta) +{ + const Layout layout("p"); + const BlobRef ref{BlobHashAlgo::CityHash128, BlobDigest::fromU128(u128Of("hash-inspect"))}; + const String key = layout.blobMetaKey(ref); + const BlobMeta m{.version = 1, .state = MetaState::Condemned, .condemn_round = 9, .size = 123}; + + const String json = caInspectToJson(layout, key, encodeBlobMeta(m)); + EXPECT_NE(json.find("\"object\":\"blob_meta\""), String::npos); + EXPECT_NE(json.find("\"condemned\""), String::npos); + EXPECT_NE(json.find("\"condemn_round\":9"), String::npos); + EXPECT_NE(json.find("\"size\":123"), String::npos); +} + +TEST(CASBlobMeta, InspectRendersCleanMeta) +{ + const Layout layout("p"); + const BlobRef ref{BlobHashAlgo::CityHash128, BlobDigest::fromU128(u128Of("hash-inspect-clean"))}; + const String key = layout.blobMetaKey(ref); + const BlobMeta m{.version = 1, .state = MetaState::Clean, .condemn_round = 0, .size = 7}; + + const String json = caInspectToJson(layout, key, encodeBlobMeta(m)); + EXPECT_NE(json.find("\"clean\""), String::npos); +} diff --git a/src/Disks/tests/gtest_cas_blob_meta_format.cpp b/src/Disks/tests/gtest_cas_blob_meta_format.cpp new file mode 100644 index 000000000000..4aae227d0968 --- /dev/null +++ b/src/Disks/tests/gtest_cas_blob_meta_format.cpp @@ -0,0 +1,49 @@ +#include "cas_format_test_battery.h" +#include +#include + +using namespace DB::Cas; + +namespace DB::ErrorCodes { extern const int CORRUPTED_DATA; } + +TEST(CASFormatBattery, BlobMeta) +{ + BlobMeta m; + m.state = MetaState::Clean; + m.condemn_round = 0; + m.size = 12345; + runFormatBattery(FormatBatteryCase{ + .id = FormatId::BlobMeta, + .encode = [&] { return sealObject(FormatId::BlobMeta, encodeBlobMeta(m)); }, + .decode = [](std::string_view s) { decodeBlobMeta(std::string(openObject(FormatId::BlobMeta, s))); }, + .golden = "{\"type\":\"cas_blob_meta\",\"v\":9}\n" + "{\"st\":\"clean\",\"cr\":\"0\",\"sz\":\"12345\"}\n"}); +} + +TEST(CASBlobMetaFormat, CondemnedRoundTripAllFields) +{ + BlobMeta m; + m.state = MetaState::Condemned; + m.condemn_round = 7; + m.size = 4096; + const BlobMeta back = decodeBlobMeta(encodeBlobMeta(m)); + EXPECT_EQ(back.state, MetaState::Condemned); + EXPECT_EQ(back.condemn_round, 7u); + EXPECT_EQ(back.size, 4096u); + EXPECT_EQ(encodeBlobMeta(m), + "{\"type\":\"cas_blob_meta\",\"v\":9}\n{\"st\":\"condemned\",\"cr\":\"7\",\"sz\":\"4096\"}\n"); +} + +TEST(CASBlobMetaFormat, FailsClosedOnUnknownStateAndTruncation) +{ + /// Unknown state word -> CORRUPTED_DATA (mirrors the old `state > Condemned` reject). + /// `v:3` is deliberate and must NOT follow a future `G_BUILD` bump: any version <= G_BUILD passes + /// the header gate, which is the point — the BODY is what has to fail here. + const String bad_state = "{\"type\":\"cas_blob_meta\",\"v\":3}\n{\"st\":\"zombie\",\"cr\":\"0\",\"sz\":\"0\"}\n"; + EXPECT_THROW(decodeBlobMeta(bad_state), DB::Exception); + /// Missing state key -> CORRUPTED_DATA. + const String no_state = "{\"type\":\"cas_blob_meta\",\"v\":3}\n{\"cr\":\"0\",\"sz\":\"0\"}\n"; + EXPECT_THROW(decodeBlobMeta(no_state), DB::Exception); + /// Truncated (header only) -> CORRUPTED_DATA. + EXPECT_THROW(decodeBlobMeta("{\"type\":\"cas_blob_meta\",\"v\":3}\n"), DB::Exception); +} diff --git a/src/Disks/tests/gtest_cas_blob_ref.cpp b/src/Disks/tests/gtest_cas_blob_ref.cpp new file mode 100644 index 000000000000..a75b5b968502 --- /dev/null +++ b/src/Disks/tests/gtest_cas_blob_ref.cpp @@ -0,0 +1,34 @@ +#include +#include +#include + +using namespace DB::Cas; + +TEST(CASBlobRef, SameDigestDifferentAlgoAreDistinct) +{ + const BlobDigest d = BlobDigest::fromU128(UInt128(0xDEADBEEF)); + const BlobRef a{BlobHashAlgo::CityHash128, d}; + const BlobRef b{BlobHashAlgo::XXH3_128, d}; + EXPECT_NE(a, b); + EXPECT_LT(a, b); /// algo=1 < algo=2 + std::unordered_set s{a, b}; + EXPECT_EQ(s.size(), 2u); +} + +TEST(CASBlobRef, OrderIsAlgoThenDigest) +{ + const BlobRef small_algo_big_digest{BlobHashAlgo::CityHash128, BlobDigest::fromU128(UInt128(0) - 1)}; + const BlobRef big_algo_small_digest{BlobHashAlgo::Sha256, BlobDigest::fromU128(UInt128(1))}; + EXPECT_LT(small_algo_big_digest, big_algo_small_digest); /// algo decides first +} + +TEST(CASBlobRef, HexAndIdRenderAtAlgoWidth) +{ + BlobRef r16{BlobHashAlgo::XXH3_128, BlobDigest::fromU128(UInt128(0xAB))}; + EXPECT_EQ(blobHexOf(r16).size(), 32u); + EXPECT_EQ(blobIdOf(r16).substr(0, 5), "xxh3:"); + BlobRef r32{BlobHashAlgo::Sha256, {}}; + for (size_t i = 0; i < 32; ++i) r32.digest.bytes[i] = static_cast(i); + EXPECT_EQ(blobHexOf(r32).size(), 64u); + EXPECT_EQ(blobIdOf(r32).substr(0, 7), "sha256:"); +} diff --git a/src/Disks/tests/gtest_cas_blob_upload_pool.cpp b/src/Disks/tests/gtest_cas_blob_upload_pool.cpp new file mode 100644 index 000000000000..3e451810f744 --- /dev/null +++ b/src/Disks/tests/gtest_cas_blob_upload_pool.cpp @@ -0,0 +1,144 @@ +#include +#include +#include +#include + +#include +#include + +using namespace DB::Cas; + +namespace DB::ErrorCodes +{ +extern const int BAD_ARGUMENTS; +extern const int LOGICAL_ERROR; +} + +namespace +{ + +/// Mirrors `gtest_cas_part_manifest_format.cpp`'s inlined assertion helper rather than pulling in +/// `Disks/tests/cas_test_helpers.h` for one tiny check. +template +void expectThrowsCode(int expected_code, F && fn) +{ + try + { + fn(); + FAIL() << "expected DB::Exception"; + } + catch (const DB::Exception & e) + { + EXPECT_EQ(e.code(), expected_code); + } +} + +/// The pattern stage-1 T5's fan-out fixtures reuse: lazily bring the server-wide pool up on first +/// need. Deliberately NOT torn down between calls -- `initializeBlobUploadPool` is once-only for +/// the lifetime of the binary via this helper, matching how the real server wires it once at +/// startup. Tests that need to exercise the raw init/shutdown lifecycle contract itself (this file) +/// call `initializeBlobUploadPool`/`shutdownBlobUploadPool` directly instead of through this helper. +void ensureBlobUploadPoolForTest(size_t size) +{ + static std::once_flag once; + std::call_once(once, [size] { initializeBlobUploadPool(size); }); +} + +} + +/// `blobUploadPool()` on an uninitialized pool throws `LOGICAL_ERROR`, which aborts the whole +/// process in debug/sanitizer builds instead of behaving like a catchable exception (see +/// `handle_error_code` in `Common/Exception.cpp`) -- `CASBlobUploadPoolDeathTest` below proves the +/// abort positively in those builds instead, following `gtest_cas_gc_state_format.cpp`'s pattern. +#ifndef DEBUG_OR_SANITIZER_BUILD +TEST(CASBlobUploadPool, GetterThrowsBeforeInit) +{ + shutdownBlobUploadPool(); + EXPECT_FALSE(blobUploadPoolInitializedForTest()); + expectThrowsCode(DB::ErrorCodes::LOGICAL_ERROR, [] { blobUploadPool(); }); +} +#endif + +#if defined(DEBUG_OR_SANITIZER_BUILD) +TEST(CASBlobUploadPoolDeathTest, GetterAbortsBeforeInit) +{ + shutdownBlobUploadPool(); + ASSERT_FALSE(blobUploadPoolInitializedForTest()); + EXPECT_DEATH({ (void)blobUploadPool(); }, ""); +} +#endif + +TEST(CASBlobUploadPool, InitZeroRejected) +{ + shutdownBlobUploadPool(); + expectThrowsCode(DB::ErrorCodes::BAD_ARGUMENTS, [] { initializeBlobUploadPool(0); }); + /// A rejected init must not leave the pool half-initialized. + EXPECT_FALSE(blobUploadPoolInitializedForTest()); + shutdownBlobUploadPool(); +} + +TEST(CASBlobUploadPool, InitThenGetWorks) +{ + shutdownBlobUploadPool(); + initializeBlobUploadPool(4); + EXPECT_TRUE(blobUploadPoolInitializedForTest()); + + std::atomic ran{0}; + blobUploadPool().scheduleOrThrowOnError([&ran] { ++ran; }); + blobUploadPool().wait(); + EXPECT_EQ(ran.load(), 1); + + shutdownBlobUploadPool(); +} + +/// Same debug/sanitizer-abort caveat as `GetterThrowsBeforeInit` above: the second +/// `initializeBlobUploadPool` call throws `LOGICAL_ERROR`, which aborts under +/// `DEBUG_OR_SANITIZER_BUILD`. +#ifndef DEBUG_OR_SANITIZER_BUILD +TEST(CASBlobUploadPool, DoubleInitThrows) +{ + shutdownBlobUploadPool(); + initializeBlobUploadPool(2); + expectThrowsCode(DB::ErrorCodes::LOGICAL_ERROR, [] { initializeBlobUploadPool(2); }); + shutdownBlobUploadPool(); +} +#endif + +#if defined(DEBUG_OR_SANITIZER_BUILD) +TEST(CASBlobUploadPoolDeathTest, DoubleInitAborts) +{ + shutdownBlobUploadPool(); + initializeBlobUploadPool(2); + EXPECT_DEATH({ (void)initializeBlobUploadPool(2); }, ""); + shutdownBlobUploadPool(); +} +#endif + +TEST(CASBlobUploadPool, ShutdownIdempotent) +{ + /// Idempotent even when never initialized. + shutdownBlobUploadPool(); + shutdownBlobUploadPool(); + EXPECT_FALSE(blobUploadPoolInitializedForTest()); + + initializeBlobUploadPool(3); + shutdownBlobUploadPool(); + /// Idempotent after a real init + shutdown too. + shutdownBlobUploadPool(); + EXPECT_FALSE(blobUploadPoolInitializedForTest()); +} + +TEST(CASBlobUploadPool, EnsureForTestHelperLazilyInitializes) +{ + shutdownBlobUploadPool(); + EXPECT_FALSE(blobUploadPoolInitializedForTest()); + + ensureBlobUploadPoolForTest(4); + EXPECT_TRUE(blobUploadPoolInitializedForTest()); + + /// Idempotent: a pool already up must not throw on a repeated call. + ensureBlobUploadPoolForTest(4); + EXPECT_TRUE(blobUploadPoolInitializedForTest()); + + shutdownBlobUploadPool(); +} diff --git a/src/Disks/tests/gtest_cas_blob_upload_pool_env.cpp b/src/Disks/tests/gtest_cas_blob_upload_pool_env.cpp new file mode 100644 index 000000000000..6f1ed8c0171e --- /dev/null +++ b/src/Disks/tests/gtest_cas_blob_upload_pool_env.cpp @@ -0,0 +1,48 @@ +#include +#include + +/// Stage-1 §1: `ContentAddressedTransaction::uploadPendingBlobs` fans out on the server-wide blob +/// upload pool, whose getter is fail-loud (throws `LOGICAL_ERROR` -- an ABORT under a sanitizer build -- +/// if the pool was never initialized). Any CA test that commits a transaction with a pending blob would +/// therefore abort the whole `unit_tests_dbms` process if the pool happened to be down. +/// +/// This listener brings the pool up before EVERY test, so the pool is always initialized at the start of +/// a test body regardless of link/run order. It is deliberately a before-each hook (not a one-shot +/// `Environment::SetUp`): the raw-lifecycle suite in `gtest_cas_blob_upload_pool.cpp` shuts the pool +/// down inside its own bodies, and those tests explicitly re-establish whatever pool state they assert +/// on as their FIRST action, so re-ensuring the pool before them is harmless. +/// +/// It ALSO shuts the pool down once, in `OnTestProgramEnd` (the last gtest event, fired from inside +/// `RUN_ALL_TESTS` before `gtest_main`'s exit `SCOPE_EXIT`). This is mandatory, not cosmetic: the blob +/// upload pool is a `ThreadFromGlobalPool`-backed `ThreadPool`, so its idle workers occupy GlobalThreadPool +/// std::threads. `gtest_main` shuts the GlobalThreadPool down at process exit by JOINING those std::threads +/// -- but a lingering blob-pool worker never returns until the blob pool itself is destroyed, so leaving +/// the pool up at exit deadlocks the whole binary (main joins a std::thread that is running a blob-pool +/// worker that waits for the blob pool to shut down). Draining it here, before `RUN_ALL_TESTS` returns, +/// releases those std::threads first. +namespace +{ + +class BlobUploadPoolEnsuringListener : public ::testing::EmptyTestEventListener +{ +public: + void OnTestStart(const ::testing::TestInfo &) override + { + DB::Cas::tests::ensureBlobUploadPoolForTest(); + } + + void OnTestProgramEnd(const ::testing::UnitTest &) override + { + /// Release the pool's GlobalThreadPool-backed workers BEFORE `gtest_main` joins the GlobalThreadPool + /// at exit (see the class comment) -- otherwise the binary deadlocks at exit. Idempotent. + DB::Cas::shutdownBlobUploadPool(); + } +}; + +const bool registered_blob_upload_pool_listener = [] +{ + ::testing::UnitTest::GetInstance()->listeners().Append(new BlobUploadPoolEnsuringListener); + return true; +}(); + +} diff --git a/src/Disks/tests/gtest_cas_bootstrap_ordering.cpp b/src/Disks/tests/gtest_cas_bootstrap_ordering.cpp new file mode 100644 index 000000000000..aa469021de77 --- /dev/null +++ b/src/Disks/tests/gtest_cas_bootstrap_ordering.cpp @@ -0,0 +1,443 @@ +#include + +#include +#include +#include +#include +#include "cas_test_helpers.h" +#include + +#include +#include +#include +#include + +/// Task 7 (spec §2 "Startup [C4], ordered vs the capability probe [D2]"): the writable `Pool::open` +/// bootstrap sequence is (0) a ZERO-WRITE residual check FIRST — before any probe write — that ignores +/// structurally-valid `_probe/` debris; (1) only then the mutating `_probe/` capability battery; (2) then +/// `PoolMeta::createOrValidate`, which may mint a missing `_pool_meta` only over a genuinely empty prefix. +/// A missing `_pool_meta` over residual (non-`_probe`) data fails startup loud with ZERO writes — closing +/// the "restart poisons a partially-erased pool" hole. These are black-box tests over `Pool::open`, +/// asserting behavior AND ordering via an op-recording backend (they fail on the pre-Task-7 open, which +/// bootstraps a fresh identity unconditionally and performs no residual LIST before the battery). + +namespace DB::ErrorCodes +{ +extern const int INVALID_STATE; +} + +using namespace DB::Cas; + +namespace +{ + +const String kPrefix = "p"; +const String kSrid = "test"; +const String kPoolMetaKey = "p/_pool_meta"; +/// A well-formed per-mount probe uid: exactly 32 lowercase hex chars (`u128ToHex`'s shape). +const String kProbeUid = "0123456789abcdef0123456789abcdef"; +const String kProbeUid2 = "fedcba9876543210fedcba9876543210"; + +/// Records the ORDER of backend operations so a test can assert that the residual LIST precedes the first +/// write, and that a fail path performs zero writes. Delegates every operation to `InMemoryBackend` +/// unchanged; `Pool::open` wraps this in its `InstrumentedBackend`, which forwards every op here. +class RecordingBackend final : public InMemoryBackend +{ +public: + using Backend::get; + using Backend::getStream; + using Backend::putIfAbsent; + using Backend::putIfAbsentStream; + using Backend::putOverwrite; + using Backend::casPut; + + enum class Op : uint8_t { List, PutIfAbsent, PutOverwrite, CasPut, Delete }; + struct Entry + { + Op op; + String key; /// the LIST prefix, or the written key + }; + + ListPage list(const String & prefix, const String & cursor, size_t limit) override + { + record(Op::List, prefix); + return InMemoryBackend::list(prefix, cursor, limit); + } + PutResult putIfAbsent(const String & key, const String & bytes, const ObjectMeta & meta) override + { + record(Op::PutIfAbsent, key); + return InMemoryBackend::putIfAbsent(key, bytes, meta); + } + PutResult putOverwrite(const String & key, const String & bytes, const Token & expected, const ObjectMeta & meta) override + { + record(Op::PutOverwrite, key); + return InMemoryBackend::putOverwrite(key, bytes, expected, meta); + } + CasResult casPut(const String & key, const String & bytes, const std::optional & expected, const ObjectMeta & meta) override + { + record(Op::CasPut, key); + return InMemoryBackend::casPut(key, bytes, expected, meta); + } + DeleteOutcome deleteExact(const String & key, const Token & token) override + { + record(Op::Delete, key); + return InMemoryBackend::deleteExact(key, token); + } + /// The bootstrap path (battery + createOrValidate + mount protocol) issues only whole-String writes, + /// never a streaming create, so recording the four write ops above captures every write `open` can do. + + static bool isWrite(Op op) + { + return op == Op::PutIfAbsent || op == Op::PutOverwrite || op == Op::CasPut || op == Op::Delete; + } + + void clearLog() + { + std::lock_guard l(mutex_); + log_.clear(); + } + std::vector snapshot() const + { + std::lock_guard l(mutex_); + return log_; + } + size_t writeCount() const + { + std::lock_guard l(mutex_); + size_t n = 0; + for (const auto & e : log_) + if (isWrite(e.op)) + ++n; + return n; + } + +private: + void record(Op op, const String & key) + { + std::lock_guard l(mutex_); + log_.push_back({op, key}); + } + mutable std::mutex mutex_; + std::vector log_; +}; + +/// Models a stale LIST result for `cas/ref_catalog`: the object was listed, then disappeared before +/// the exact validation GET. The bootstrap must treat this as residual, never as a new-pool proof. +class CatalogMissingAfterListBackend final : public InMemoryBackend +{ +public: + using Backend::get; + + std::optional get(const String & key, Range range) override + { + if (key == Layout{kPrefix}.refCatalogKey()) + return std::nullopt; + return InMemoryBackend::get(key, range); + } +}; + +PoolConfig makeConfig() +{ + PoolConfig cfg; + cfg.pool_prefix = kPrefix; + cfg.server_root_id = kSrid; + cfg.wait_sleep_fn = [](uint64_t) {}; /// never block a synchronous test on an open/teardown wait + return cfg; +} + +template +void expectThrowsCodeContaining(int expected_code, const String & needle, F && fn); + +void expectCatalogResidueRefusesWithoutPoolMeta(const String & bytes, const String & extra_key = {}) +{ + auto backend = std::make_shared(); + const Layout layout{kPrefix}; + ASSERT_EQ(backend->putIfAbsent(layout.refCatalogKey(), bytes).outcome, PutOutcome::Done); + if (!extra_key.empty()) + ASSERT_EQ(backend->putIfAbsent(extra_key, "residual").outcome, PutOutcome::Done); + backend->clearLog(); + + try + { + Pool::open(backend, makeConfig()); + FAIL() << "expected residual catalog bootstrap refusal"; + } + catch (const DB::Exception & e) + { + EXPECT_EQ(e.code(), DB::ErrorCodes::INVALID_STATE); + } + EXPECT_EQ(backend->writeCount(), 0u); + EXPECT_FALSE(backend->head(layout.poolMetaKey()).exists); +} + +/// Index of the first op matching `pred`, if any. +template +std::optional firstIndex(const std::vector & log, Pred && pred) +{ + for (size_t i = 0; i < log.size(); ++i) + if (pred(log[i])) + return i; + return std::nullopt; +} + +/// Assert `fn` throws a DB::Exception with `expected_code` AND a message containing `needle`. +template +void expectThrowsCodeContaining(int expected_code, const String & needle, F && fn) +{ + try + { + fn(); + FAIL() << "expected a DB::Exception, none thrown"; + } + catch (const DB::Exception & e) + { + EXPECT_EQ(e.code(), expected_code); + EXPECT_NE(e.message().find(needle), String::npos) + << "message did not contain '" << needle << "': " << e.message(); + } +} + +} + +/// (a) Empty prefix → open succeeds, `_pool_meta` is created, AND the op-log proves the residual LIST of +/// the pool prefix happened BEFORE any write (the ordering [D2] mandates: no probe write may precede the +/// emptiness proof). +TEST(CASBootstrapOrdering, EmptyPrefixOpensAndListsBeforeAnyWrite) +{ + auto backend = std::make_shared(); + backend->clearLog(); + + PoolPtr store = Pool::open(backend, makeConfig()); + ASSERT_EQ(store->lifecycle(), PoolLifecycle::Live); + EXPECT_TRUE(backend->get(kPoolMetaKey).has_value()) << "_pool_meta must be created on a fresh empty prefix"; + + const auto log = backend->snapshot(); + const auto residual_list = firstIndex(log, [](const RecordingBackend::Entry & e) + { return e.op == RecordingBackend::Op::List && e.key == kPrefix + "/"; }); + const auto first_write = firstIndex(log, [](const RecordingBackend::Entry & e) + { return RecordingBackend::isWrite(e.op); }); + + ASSERT_TRUE(residual_list.has_value()) << "the zero-write residual LIST of '" << kPrefix << "/' must run"; + ASSERT_TRUE(first_write.has_value()) << "a fresh open must eventually write (battery/meta/mount)"; + EXPECT_LT(*residual_list, *first_write) << "the residual LIST must precede every write"; +} + +/// The residue an incomplete erase would have left behind: a real ref-log object key, built through +/// `Layout` so it carries the life segment every ref key has. The residual check is LIST-based and +/// never parses it, but seeding a shape this build cannot write would make the comment below a lie. +namespace +{ +String residualRefLogKey() +{ + return Layout{"p"}.refLogKey(DB::Cas::tests::fixture::fixtureLife(RootNamespace{"test%2Fabcd"}), RefTxnId{1, 1}); +} +} + +/// (b) A prefix holding `cas/ns/stream/…` residue but NO `_pool_meta` → open fails typed (INVALID_STATE), +/// and ZERO writes hit the backend (the mutating battery must NOT have run — the residual check throws +/// first). +TEST(CASBootstrapOrdering, ResidualWithoutMetaFailsTypedWithZeroWrites) +{ + auto backend = std::make_shared(); + /// Seed residue an incomplete erase would have left behind (a ref-log object), with no `_pool_meta`. + ASSERT_EQ(backend->putIfAbsent(residualRefLogKey(), "x").outcome, + PutOutcome::Done); + backend->clearLog(); + + expectThrowsCodeContaining(DB::ErrorCodes::INVALID_STATE, "refusing to bootstrap over residual data", + [&] { Pool::open(backend, makeConfig()); }); + + EXPECT_EQ(backend->writeCount(), 0u) << "the fail path must perform zero writes (battery never ran)"; + EXPECT_FALSE(backend->get(kPoolMetaKey).has_value()) << "a fresh _pool_meta must NOT have been minted"; +} + +/// (c) A prefix containing ONLY stale, structurally-valid `_probe//…` debris (a crash-mid-battery +/// leftover) → treated as empty → open succeeds and bootstraps a fresh pool. The debris-skip is what makes +/// a normal restart-after-crash recover instead of wedging. +TEST(CASBootstrapOrdering, StaleProbeDebrisOnlyIsTreatedAsEmpty) +{ + auto backend = std::make_shared(); + ASSERT_EQ(backend->putIfAbsent("p/_probe/" + kProbeUid + "/token", "probe-v1").outcome, PutOutcome::Done); + ASSERT_EQ(backend->putIfAbsent("p/_probe/" + kProbeUid + "/cas", "cas-s1").outcome, PutOutcome::Done); + backend->clearLog(); + + PoolPtr store; + ASSERT_NO_THROW(store = Pool::open(backend, makeConfig())); + EXPECT_EQ(store->lifecycle(), PoolLifecycle::Live); + EXPECT_TRUE(backend->get(kPoolMetaKey).has_value()) << "_pool_meta must be created over a probe-only prefix"; +} + +TEST(CASBootstrapOrdering, CanonicalEmptyCatalogOnlyIsTheSoleRetryablePreMetaResidue) +{ + auto backend = std::make_shared(); + const Layout layout{kPrefix}; + ASSERT_EQ(backend->putIfAbsent(layout.refCatalogKey(), encodeRefCatalog(RefCatalog{})).outcome, PutOutcome::Done); + ASSERT_EQ(backend->putIfAbsent(kPrefix + "/_probe/" + kProbeUid + "/token", "probe-v1").outcome, PutOutcome::Done); + backend->clearLog(); + + PoolPtr store; + ASSERT_NO_THROW(store = Pool::open(backend, makeConfig())); + EXPECT_TRUE(backend->head(layout.poolMetaKey()).exists); +} + +TEST(CASBootstrapOrdering, MalformedCatalogOnlyResidueRefusesWithoutPoolMeta) +{ + expectCatalogResidueRefusesWithoutPoolMeta("not a catalog"); +} + +TEST(CASBootstrapOrdering, NoncanonicalCatalogOnlyResidueRefusesWithoutPoolMeta) +{ + String noncanonical = encodeRefCatalog(RefCatalog{}); + noncanonical.insert(noncanonical.find('\n') - 1, ",\"noncanonical\":0"); + ASSERT_TRUE(decodeRefCatalog(noncanonical).entries.empty()) << "fixture must be decodable but noncanonical"; + expectCatalogResidueRefusesWithoutPoolMeta(noncanonical); +} + +TEST(CASBootstrapOrdering, NonemptyCatalogOnlyResidueRefusesWithoutPoolMeta) +{ + const RefCatalog nonempty{.entries = {CatalogEntry{ + .ns = RootNamespace{"test/nonempty"}, .state = NsState::Live, .incarnation = UInt128{1}, .creator = std::nullopt}}}; + expectCatalogResidueRefusesWithoutPoolMeta(encodeRefCatalog(nonempty)); +} + +TEST(CASBootstrapOrdering, CatalogWithAnyOtherCasResidueRefusesWithoutPoolMeta) +{ + const String canonical_empty = encodeRefCatalog(RefCatalog{}); + const Layout layout{kPrefix}; + const std::vector residuals{ + layout.ownerKey("test"), layout.epochKey("test"), layout.mountKey("test"), + layout.refLogKey(DB::Cas::tests::fixture::fixtureLife(RootNamespace{"test/ns"}), RefTxnId{1, 1}), + layout.manifestKey(ManifestId{RootNamespace{"test/ns"}, ManifestRef{1, 1, 1}}), + layout.serverRootDataPrefix("test") + "residual", kPrefix + "/unknown"}; + for (const String & residual : residuals) + expectCatalogResidueRefusesWithoutPoolMeta(canonical_empty, residual); +} + +TEST(CASBootstrapOrdering, ListedCatalogMissingAtExactGetRefusesWithoutPoolMeta) +{ + auto backend = std::make_shared(); + const Layout layout{kPrefix}; + ASSERT_EQ(backend->putIfAbsent(layout.refCatalogKey(), encodeRefCatalog(RefCatalog{})).outcome, PutOutcome::Done); + + expectThrowsCodeContaining(DB::ErrorCodes::INVALID_STATE, "refusing to bootstrap over residual data", + [&] { Pool::open(backend, makeConfig()); }); + EXPECT_FALSE(backend->head(layout.poolMetaKey()).exists); +} + +/// (d) An existing healthy pool (meta present + data) → reopen is unchanged: the pool identity is +/// PRESERVED (the residual check sees `_pool_meta` present → the normal validate path; `_pool_meta` is +/// never re-minted). +TEST(CASBootstrapOrdering, HealthyPoolReopenPreservesIdentity) +{ + auto backend = std::make_shared(); + + UInt128 pool_id_first; + { + PoolPtr store = Pool::open(backend, makeConfig()); + pool_id_first = store->poolMeta().pool_id; + } /// clean teardown: drained farewell, so the reopen reclaims immediately + + PoolPtr store2 = Pool::open(backend, makeConfig()); + EXPECT_EQ(store2->lifecycle(), PoolLifecycle::Live); + EXPECT_EQ(store2->poolMeta().pool_id, pool_id_first) + << "a healthy reopen must NOT re-mint _pool_meta — the pool identity must be preserved"; +} + +/// (e) [D2] concurrent-opener case: debris from a SECOND concurrent fresh opener's in-flight battery (a +/// distinct probe uid) is skipped by the SAME structural rule as (c). Two openers racing over one shared +/// pool prefix must not make each other's zero-write residual check fail. +TEST(CASBootstrapOrdering, ConcurrentOpenerProbeDebrisIsAlsoSkipped) +{ + auto backend = std::make_shared(); + /// This mount's own crashed battery AND a concurrent opener's in-flight battery. + ASSERT_EQ(backend->putIfAbsent("p/_probe/" + kProbeUid + "/token", "probe-v1").outcome, PutOutcome::Done); + ASSERT_EQ(backend->putIfAbsent("p/_probe/" + kProbeUid2 + "/token", "probe-v1").outcome, PutOutcome::Done); + ASSERT_EQ(backend->putIfAbsent("p/_probe/" + kProbeUid2 + "/cas", "cas-s1").outcome, PutOutcome::Done); + backend->clearLog(); + + PoolPtr store; + ASSERT_NO_THROW(store = Pool::open(backend, makeConfig())); + EXPECT_EQ(store->lifecycle(), PoolLifecycle::Live); +} + +/// (f) The reserved subtree boundary: only objects strictly under `/_probe/` are ignorable +/// debris. A SIBLING look-alike that merely starts with `_probe` but is NOT under the `_probe/` subtree +/// (here `_probelike/…`) is genuine residual — the trailing `/` in the reserved prefix keeps it out — so +/// bootstrap fails closed over it. (Any object literally under `_probe/`, whatever its leaf shape, is +/// ephemeral capability-probe scratch a content-addressed pool never uses for durable state.) +TEST(CASBootstrapOrdering, ProbeSiblingLookalikeIsResidualNotDebris) +{ + auto backend = std::make_shared(); + ASSERT_EQ(backend->putIfAbsent("p/_probelike/token", "x").outcome, PutOutcome::Done); + backend->clearLog(); + + expectThrowsCodeContaining(DB::ErrorCodes::INVALID_STATE, "refusing to bootstrap over residual data", + [&] { Pool::open(backend, makeConfig()); }); + EXPECT_EQ(backend->writeCount(), 0u); + EXPECT_FALSE(backend->get(kPoolMetaKey).has_value()); +} + +/// (g) An OBSERVE / read-only open over a partially-erased pool (residual data, `_pool_meta` deleted) +/// must NOT mint a fresh `_pool_meta` — there is no truly-read-only backend, so a mint here is a real +/// write that would poison the next writable mount's residual check. It fails closed (typed INVALID_STATE) +/// with ZERO writes. The read-only path skips the residual check, so the fail-closed gate lives in +/// `createOrValidate` (`allow_mint=false`). +TEST(CASBootstrapOrdering, ReadOnlyOverResidualWithoutMetaFailsClosedNoMint) +{ + auto backend = std::make_shared(); + ASSERT_EQ(backend->putIfAbsent(residualRefLogKey(), "x").outcome, + PutOutcome::Done); + backend->clearLog(); + + PoolConfig cfg = makeConfig(); + cfg.read_only = true; + expectThrowsCodeContaining(DB::ErrorCodes::INVALID_STATE, "refusing to mint outside the verified bootstrap path", + [&] { Pool::open(backend, cfg); }); + + EXPECT_EQ(backend->writeCount(), 0u) << "an observe open must never write (least of all mint _pool_meta)"; + EXPECT_FALSE(backend->get(kPoolMetaKey).has_value()); +} + +/// (h) An observe / read-only open over a HEALTHY pool (meta present) is unchanged: it validates the +/// existing `_pool_meta` and succeeds, preserving the pool identity. `allow_mint=false` is never consulted +/// on the validate path. +TEST(CASBootstrapOrdering, ReadOnlyOverHealthyPoolSucceedsUnchanged) +{ + auto backend = std::make_shared(); + UInt128 pool_id_first; + { + PoolPtr store = Pool::open(backend, makeConfig()); /// writable: creates _pool_meta + pool_id_first = store->poolMeta().pool_id; + } + + PoolConfig cfg = makeConfig(); + cfg.read_only = true; + PoolPtr ro; + ASSERT_NO_THROW(ro = Pool::open(backend, cfg)); + ASSERT_TRUE(ro); + EXPECT_EQ(ro->poolMeta().pool_id, pool_id_first) << "an observe open over a healthy pool must not re-mint"; +} + +/// (i) `openForDecommission` over a pool whose `_pool_meta` is absent but whose owner anchor survives (a +/// partial erase) must NOT bootstrap a fresh identity — it fails closed (typed INVALID_STATE) with no +/// mint. Decommission operates on an existing member; a missing meta is a broken state, not a bootstrap. +TEST(CASBootstrapOrdering, DecommissionWithAbsentMetaFailsClosedNoMint) +{ + auto backend = std::make_shared(); + { + PoolPtr store = Pool::open(backend, makeConfig()); /// establishes owner anchor + _pool_meta + } + /// Delete only `_pool_meta`, leaving the owner anchor (and other control objects) behind. + { + const auto h = backend->head(kPoolMetaKey); + ASSERT_TRUE(h.exists); + ASSERT_EQ(backend->deleteExact(kPoolMetaKey, h.token).kind, DeleteOutcome::Kind::Deleted); + } + backend->clearLog(); + + expectThrowsCodeContaining(DB::ErrorCodes::INVALID_STATE, "refusing to mint outside the verified bootstrap path", + [&] { Pool::openForDecommission(backend, makeConfig(), kSrid); }); + + EXPECT_EQ(backend->writeCount(), 0u) << "decommission must not mint a fresh _pool_meta"; + EXPECT_FALSE(backend->get(kPoolMetaKey).has_value()); +} diff --git a/src/Disks/tests/gtest_cas_confirm_exact_ref.cpp b/src/Disks/tests/gtest_cas_confirm_exact_ref.cpp new file mode 100644 index 000000000000..2ba4c79d3f91 --- /dev/null +++ b/src/Disks/tests/gtest_cas_confirm_exact_ref.cpp @@ -0,0 +1,1011 @@ +#include + +#include "config.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +/// Task 10 (spec §confirm-primitive, "Gate 1 -- exact-token identity under a lane snapshot"): the +/// ledger-side half of the publish-then-confirm relink handoff. +/// +/// `confirmExactRef` is a GATE, and the only direction in which it may fail is `Unknown`. A `Yes` +/// authorizes a remote receiver to promote a manifest it staged from this writer's blobs, so a `Yes` +/// produced from a stale, lagging or partially-recovered view is a live-blob-deletion bug, not a +/// missed optimization. Every test below therefore pins one of the six snapshot rules by constructing +/// the exact state in which a naive "look the row up and compare" implementation would answer `Yes` +/// (or `No`) and asserting `Unknown` instead. +/// +/// Two properties are contract, not detail, and are asserted as such: +/// - ZERO object-store I/O. A cold, evicted or recovering table answers `Unknown`; it must not +/// recover from storage to answer, and it must not even MATERIALIZE a runtime -- a read-only +/// interserver query must never be able to make this writer do work. +/// - The snapshot spans BOTH lane mutexes, so an append admitted concurrently is ordered strictly +/// after it: there is no window in which the confirm says `Yes` while a removal of that ref is +/// already admitted. +/// +/// The suite name is prefixed `Cas` so it is covered by the `Cas*` unit-test gate filter. + +namespace DB::ErrorCodes +{ +extern const int CORRUPTED_DATA; +extern const int MEMORY_LIMIT_EXCEEDED; +} + +using namespace DB::Cas; +using DB::Cas::tests::CountingBackend; + +namespace +{ + +/// A `CountingBackend` with two recovery-side seams: a one-shot NON-transient exact GET failure (which +/// leaves the namespace runtime resident but UNRECOVERED, because recovery fails closed), and a +/// blocking exact GET (which parks a caller inside `ensureRefTableRecovered` with +/// `recovery_in_progress` set). The failure is deliberately `CORRUPTED_DATA`: +/// `isTransientRecoveryError` does not list it, so recovery fails fast instead of burning its retry +/// budget. +class RecoveryLatchBackend : public CountingBackend +{ +public: + using CountingBackend::get; + using CountingBackend::getStream; + using CountingBackend::putIfAbsent; + using CountingBackend::putIfAbsentStream; + using CountingBackend::putOverwrite; + using CountingBackend::casPut; + + /// Set before the driving call; consumed by the first matching recovery GET. + String fail_get_once_key; + + std::optional get(const String & key, Range range) override + { + if (!fail_get_once_key.empty() && key == fail_get_once_key) + { + fail_get_once_key.clear(); + throw DB::Exception(DB::ErrorCodes::CORRUPTED_DATA, + "RecoveryLatchBackend: simulated non-transient exact GET failure"); + } + { + std::unique_lock lk(m); + if (!block_key.empty() && key == block_key) + { + entered = true; + cv.notify_all(); + /// Bounded (20s) so a wiring bug bounds the wait instead of hanging the suite. + cv.wait_for(lk, std::chrono::seconds(20), [&] { return block_key.empty(); }); + } + } + return CountingBackend::get(key, range); + } + + void armBlockedGet(const String & key) + { + std::lock_guard lk(m); + block_key = key; + entered = false; + } + void awaitBlockedGet() + { + std::unique_lock lk(m); + cv.wait_for(lk, std::chrono::seconds(20), [&] { return entered; }); + ASSERT_TRUE(entered) << "the recovery GET never parked -- the in-progress window was not exercised"; + } + void releaseBlockedGet() + { + { + std::lock_guard lk(m); + block_key.clear(); + } + cv.notify_all(); + } + +private: + std::mutex m; + std::condition_variable cv; + String block_key; + bool entered = false; +}; + +/// Parks the append lane's leader in the pre-carve window -- BEFORE it takes `ref_queue_mutex`, before +/// any `PUT`, and therefore with the table's apply-state still `Clean`. That is what isolates the +/// quiescence rule: the only thing wrong with the table while parked is that an append is in flight. +struct LeaderLatch +{ + std::mutex m; + std::condition_variable cv; + bool entered = false; + bool released = false; + + void arm(const PoolPtr & store) + { + store->setRefPreCarveHookForTest([this] + { + std::unique_lock lk(m); + if (entered) + return; /// only the FIRST carve parks; retries proceed straight through + entered = true; + cv.notify_all(); + /// Bounded (20s): a staging bug must bound the wait, not block the whole suite. + cv.wait_for(lk, std::chrono::seconds(20), [this] { return released; }); + }); + } + void awaitEntered() + { + std::unique_lock lk(m); + cv.wait_for(lk, std::chrono::seconds(20), [this] { return entered; }); + ASSERT_TRUE(entered) << "the append lane's leader never reached the pre-carve window"; + } + void release() + { + { + std::lock_guard lk(m); + released = true; + } + cv.notify_all(); + } +}; + +/// Rendezvous for the co-batching pre-carve hook of the chunked-flush case (same shape as +/// `gtest_cas_ref_chunked_flush.cpp`'s `CaseSync`). +struct CaseSync +{ + std::mutex m; + std::condition_variable cv; + bool entered = false; +}; + +/// `num_pairs` add-then-remove precommit op pairs for distinct refs, each naming a distinct manifest. +/// Every pair is undone immediately, so the LIVE state stays ~empty and validating thousands of ops +/// stays linear -- it is the OP COUNT, not the resident state, that drives the chunk split under test. +std::vector precommitAddRemovePairs(const String & prefix, size_t num_pairs, uint64_t manifest_epoch) +{ + std::vector ops; + ops.reserve(num_pairs * 2); + for (size_t i = 0; i < num_pairs; ++i) + { + const String ref = prefix + std::to_string(i); + const ManifestRef manifest{manifest_epoch, i + 1, 1}; + RefOp add; + add.kind = RefOpKind::OwnerTransition; + add.new_binding = RefOwnerBinding{RefOwnerKind::Precommit, ref, manifest}; + ops.push_back(std::move(add)); + RefOp remove; + remove.kind = RefOpKind::OwnerTransition; + remove.old_binding = RefOwnerBinding{RefOwnerKind::Precommit, ref, manifest}; + ops.push_back(std::move(remove)); + } + return ops; +} + +PoolPtr openPool(const BackendPtr & backend) +{ + DB::Cas::tests::seedPoolMetaForRestart(*backend); + return Pool::open(backend, PoolConfig{.pool_prefix = "p", .server_root_id = "test"}); +} + +PoolPtr openPoolWithConfig(const BackendPtr & backend, PoolConfig config) +{ + config.pool_prefix = "p"; + config.server_root_id = "test"; + DB::Cas::tests::seedPoolMetaForRestart(*backend); + return Pool::open(backend, std::move(config)); +} + +/// A legal blob-free part: an empty-entry manifest is enough to drive a real precommit+promote pair +/// through the append lane and leave a committed ref behind. Returns the committed `ManifestId`. +ManifestId publishEmptyPart(const PoolPtr & s, const RootNamespace & ns, const String & ref, + bool allow_repoint = false) +{ + PartWriteInfo info; + info.intended_namespace = ns; + info.intended_ref = ns.string() + "/" + ref; + auto build = s->beginPartWrite(info); + const ManifestId id = build->stageManifest({}); + build->precommitAdd(ns, ref, id); + build->promote(ns, ref, build->buildId(), id, allow_repoint); + return id; +} + +/// Every request class `CountingBackend` observes, summed. The zero-I/O contract is asserted against +/// this total, so a confirm that quietly grew a HEAD or a GET fails the test rather than the review. +uint64_t backendRequests(const CountingBackend & b) +{ + return b.headTotal() + b.getTotal() + b.getStreamTotal() + b.putTotal() + b.listTotal(); +} + +/// One-shot throwing probe in the post-durable install region -- the only way to reach `NeedsRecovery` +/// transition now that §A1 made every install region allocation-free. Copied in shape from +/// `gtest_cas_ref_install_safety.cpp`: the exception is built OUTSIDE the region (constructing one +/// inside would allocate and trip `DENY_ALLOCATIONS_IN_SCOPE`), and it is `MEMORY_LIMIT_EXCEEDED` +/// rather than `LOGICAL_ERROR`, which aborts at construction in debug builds. +void armOneShotInstallFailure(const PoolPtr & store) +{ + auto planned = std::make_exception_ptr(DB::Exception(DB::ErrorCodes::MEMORY_LIMIT_EXCEEDED, + "simulated allocation failure inside the post-durable install region")); + auto fired = std::make_shared>(false); + store->setInstallRegionProbeForTest([planned, fired] + { + if (fired->exchange(true)) + return; + ALLOW_ALLOCATIONS_IN_SCOPE; + std::rethrow_exception(planned); + }); +} + +} + + +/// Rule 5, the affirmative case: a warm, quiescent, `Ready`, fenced table whose committed row for +/// the ref names EXACTLY the asked-about manifest answers `Yes`. Its two negatives share the test +/// because they are the same rule read the other way: a different `ManifestRef` under the right name, +/// and a name that has no committed row at all, are both `No` -- a PROOF of the negative, not an +/// ambiguity. +TEST(CASConfirmExactRef, QuiescentExactMatchIsYes) +{ + auto backend = std::make_shared(); + auto store = openPool(backend); + const RootNamespace ns{"srv1/confirm_yes"}; + + const ManifestId id = publishEmptyPart(store, ns, "x"); + + EXPECT_EQ(store->confirmExactRef(ns, "x", id.ref), ConfirmAnswer::Yes); + + /// Same name, a manifest this ref never named. + ManifestRef other = id.ref; + ++other.manifest_ordinal; + EXPECT_EQ(store->confirmExactRef(ns, "x", other), ConfirmAnswer::No); + + /// A name with no committed row at all -- on a warm table that is knowledge, not ambiguity. + EXPECT_EQ(store->confirmExactRef(ns, "no_such_ref", id.ref), ConfirmAnswer::No); +} + + +/// Rule 5, the repoint case (spec §testing "repointed live part"): the part is still live and the ref +/// name still resolves, but it now names a DIFFERENT manifest. The old token must be `No` -- this is +/// the case gate 0 cannot see at all, because the part object is `Active` throughout. +TEST(CASConfirmExactRef, RepointedRefIsNo) +{ + auto backend = std::make_shared(); + auto store = openPool(backend); + const RootNamespace ns{"srv1/confirm_repoint"}; + + const ManifestId first = publishEmptyPart(store, ns, "x"); + ASSERT_EQ(store->confirmExactRef(ns, "x", first.ref), ConfirmAnswer::Yes); + + const ManifestId second = publishEmptyPart(store, ns, "x", /*allow_repoint=*/true); + ASSERT_NE(first.ref, second.ref) << "a repoint must mint a fresh ManifestRef (the ABA barrier)"; + + EXPECT_EQ(store->confirmExactRef(ns, "x", first.ref), ConfirmAnswer::No); + EXPECT_EQ(store->confirmExactRef(ns, "x", second.ref), ConfirmAnswer::Yes); +} + + +/// Rule 5, the drop-and-recreate case: the ref name is removed and then published again. The name +/// resolves again, so only EXACT `ManifestRef` equality separates the new binding from the old one -- +/// mint-tightening (spec §A3) is what guarantees the two can never collide. +TEST(CASConfirmExactRef, DroppedAndRecreatedRefIsNo) +{ + auto backend = std::make_shared(); + auto store = openPool(backend); + const RootNamespace ns{"srv1/confirm_recreate"}; + + const ManifestId first = publishEmptyPart(store, ns, "x"); + ASSERT_EQ(store->confirmExactRef(ns, "x", first.ref), ConfirmAnswer::Yes); + + store->dropRef(ns, "x"); + EXPECT_EQ(store->confirmExactRef(ns, "x", first.ref), ConfirmAnswer::No) + << "a dropped ref cannot authorize anything"; + + const ManifestId second = publishEmptyPart(store, ns, "x"); + ASSERT_NE(first.ref, second.ref); + EXPECT_EQ(store->confirmExactRef(ns, "x", first.ref), ConfirmAnswer::No); + EXPECT_EQ(store->confirmExactRef(ns, "x", second.ref), ConfirmAnswer::Yes); +} + + +/// Rule 2, the cold case: a namespace this mount has never touched has no resident runtime, so the +/// answer is `Unknown` -- and producing it must cost ZERO object-store requests AND must not create a +/// runtime. Materializing one here would let a remote caller populate this writer's table cache with +/// unrecovered entries by asking about namespaces that do not exist. +TEST(CASConfirmExactRef, ColdTableIsUnknownWithZeroBackendRequests) +{ + auto backend = std::make_shared(); + auto store = openPool(backend); + const RootNamespace warm{"srv1/confirm_cold_warm"}; + const RootNamespace cold{"srv1/confirm_cold_never_touched"}; + + const ManifestId id = publishEmptyPart(store, warm, "x"); + + const size_t cached_before = store->refTablesCachedCountForTest(); + backend->resetCounts(); + + EXPECT_EQ(store->confirmExactRef(cold, "x", id.ref), ConfirmAnswer::Unknown); + + EXPECT_EQ(backendRequests(*backend), 0u) + << "a cold table must answer Unknown without recovering from storage"; + EXPECT_EQ(store->refTablesCachedCountForTest(), cached_before) + << "confirmExactRef must find the runtime, never create one"; +} + + +/// Rule 2, the evicted case: a table that WAS warm and was dropped by the whole-table cache budget is +/// indistinguishable from a cold one here -- the runtime is gone, so the committed view is gone with +/// it, and re-reading it would be object-store I/O. +TEST(CASConfirmExactRef, EvictedTableIsUnknownWithZeroBackendRequests) +{ + auto backend = std::make_shared(); + auto store = openPoolWithConfig(backend, + PoolConfig{.pool_prefix = "p", .server_root_id = "test", .ref_table_cache_bytes = 1}); + const RootNamespace ns_a{"srv1/confirm_evict_a"}; + const RootNamespace ns_b{"srv1/confirm_evict_b"}; + + const ManifestId id_a = publishEmptyPart(store, ns_a, "x"); + ASSERT_EQ(store->confirmExactRef(ns_a, "x", id_a.ref), ConfirmAnswer::Yes); + + /// A 1-byte budget is below one table's weight, so touching another table evicts the idle one. + publishEmptyPart(store, ns_b, "y"); + ASSERT_FALSE(store->refTableCachedForTest(ns_a)) << "ns_a must have been evicted"; + + backend->resetCounts(); + EXPECT_EQ(store->confirmExactRef(ns_a, "x", id_a.ref), ConfirmAnswer::Unknown); + EXPECT_EQ(backendRequests(*backend), 0u) + << "an evicted table must answer Unknown without re-recovering"; + EXPECT_FALSE(store->refTableCachedForTest(ns_a)) + << "the confirm must not have re-recovered the evicted table as a side effect"; +} + + +/// Rule 2, the resident-but-unrecovered case: recovery failed closed, so a runtime EXISTS in the cache +/// with an empty, meaningless `state`. A naive lookup reads that empty state and answers `No`; the +/// correct answer is `Unknown`, because nothing about the durable table is known here. +TEST(CASConfirmExactRef, UnrecoveredResidentTableIsUnknownWithZeroBackendRequests) +{ + auto backend = std::make_shared(); + auto store = openPool(backend); + const RootNamespace ns{"srv1/confirm_unrecovered"}; + DB::Cas::tests::casAdmitRecoverableEntry(*backend, store->layout(), ns); + + const size_t cached_before = store->refTablesCachedCountForTest(); + backend->fail_get_once_key = store->layout().refCkptKey(DB::Cas::tests::fixture::fixtureLife(ns)); + EXPECT_THROW(store->resolveRef(ns, "x"), DB::Exception); + + ASSERT_EQ(store->refTablesCachedCountForTest(), cached_before + 1u) + << "the failed recovery must still leave a runtime resident"; + ASSERT_FALSE(store->refTableCachedForTest(ns)) << "that runtime must be UNRECOVERED"; + + backend->resetCounts(); + EXPECT_EQ(store->confirmExactRef(ns, "x", ManifestRef{1, 1, 1}), ConfirmAnswer::Unknown); + EXPECT_EQ(backendRequests(*backend), 0u) + << "an unrecovered table must answer Unknown without driving recovery"; + EXPECT_FALSE(store->refTableCachedForTest(ns)) + << "the confirm must not have recovered the table as a side effect"; +} + + +/// Rule 2, the recovering case: another caller is INSIDE `ensureRefTableRecovered`, parked on its +/// exact `_ckpt` GET. The runtime is resident, `recovery_in_progress` is set, and the state is still +/// empty. Waiting for that recovery would be exactly the "recover from storage to answer" this +/// primitive refuses. +TEST(CASConfirmExactRef, RecoveryInProgressIsUnknownWithZeroBackendRequests) +{ + auto backend = std::make_shared(); + auto store = openPool(backend); + const RootNamespace ns{"srv1/confirm_recovering"}; + DB::Cas::tests::casAdmitRecoverableEntry(*backend, store->layout(), ns); + backend->armBlockedGet(store->layout().refCkptKey(DB::Cas::tests::fixture::fixtureLife(ns))); + std::exception_ptr recovery_error; + std::thread recoverer([&] + { + try + { + store->resolveRef(ns, "x"); + } + catch (...) + { + recovery_error = std::current_exception(); + } + }); + backend->awaitBlockedGet(); + + backend->resetCounts(); + /// The parked exact GET is outside `state_mutex` (up to the 20s block bound), so this + /// call must NOT be a blocking acquire of that mutex: waiting would make the confirm pay for + /// somebody else's recovery while holding pool-wide append admission. The elapsed bound is what + /// pins that -- it is an order of magnitude below the park, so it cannot pass by luck. + const auto started = std::chrono::steady_clock::now(); + const ConfirmAnswer answer = store->confirmExactRef(ns, "x", ManifestRef{1, 1, 1}); + const auto elapsed = std::chrono::steady_clock::now() - started; + const uint64_t requests = backendRequests(*backend); + + backend->releaseBlockedGet(); + recoverer.join(); + + if (recovery_error) + { + try + { + std::rethrow_exception(recovery_error); + } + catch (const std::exception & e) + { + FAIL() << "the driving recovery unexpectedly failed: " << e.what(); + } + catch (...) + { + FAIL() << "the driving recovery unexpectedly failed with a non-standard exception"; + } + } + + EXPECT_EQ(answer, ConfirmAnswer::Unknown); + EXPECT_EQ(requests, 0u) + << "a recovering table must answer Unknown without issuing (or waiting on) any request"; + EXPECT_LT(elapsed, std::chrono::seconds(5)) + << "the confirm waited for the in-progress recovery instead of answering Unknown"; +} + + +/// Rule 3, the in-flight case: an append is admitted and its leader is parked in the pre-carve window. +/// Nothing is durable yet and the committed row still matches EXACTLY -- which is precisely why a +/// naive implementation answers `Yes` here, and precisely why that is the TOCTOU this design closes. +/// The apply-state is still `Clean` at this point, so rule 3 is what produces the `Unknown`, not +/// rule 4. +TEST(CASConfirmExactRef, InFlightAppendIsUnknown) +{ + auto backend = std::make_shared(); + auto store = openPool(backend); + const RootNamespace ns{"srv1/confirm_inflight"}; + + const ManifestId id = publishEmptyPart(store, ns, "x"); + ASSERT_EQ(store->confirmExactRef(ns, "x", id.ref), ConfirmAnswer::Yes); + + LeaderLatch latch; + latch.arm(store); + std::thread dropper([&] { store->dropRef(ns, "x"); }); + latch.awaitEntered(); + + /// Sampled while parked, asserted after the join: a failed assertion here must not skip the + /// release, or the still-joinable `dropper` would terminate the whole suite instead of failing one + /// test. + const bool leader_active = store->refLeaderActiveForTest(ns); + const RefLaneState apply_state = store->laneStateForTest(ns); + const ConfirmAnswer while_in_flight = store->confirmExactRef(ns, "x", id.ref); + + latch.release(); + dropper.join(); + store->setRefPreCarveHookForTest(nullptr); + + EXPECT_TRUE(leader_active); + EXPECT_EQ(apply_state, RefLaneState::Ready) + << "the pre-carve window is before any PUT, so rule 4 must not be what answers here"; + EXPECT_EQ(while_in_flight, ConfirmAnswer::Unknown) + << "an admitted append makes the whole table's committed view provisional"; + + EXPECT_EQ(store->confirmExactRef(ns, "x", id.ref), ConfirmAnswer::No); +} + + +/// Rule 3, mid-tenure (spec §testing "mid-tenure chunked flush", `CarvePhaseForTest::ChunkReseed`): +/// one leader tenure commits MULTIPLE durable transactions, so at a chunk boundary the table is +/// PARTIALLY durable. `leader_active` covers the whole tenure, so this is already `Unknown` -- a wider +/// unknown window under load, never a hole. The confirm is issued on the leader's own thread, which is +/// safe because the boundary holds neither lane mutex. +TEST(CASConfirmExactRef, MidTenureChunkBoundaryIsUnknown) +{ + auto backend = std::make_shared(); + auto store = openPool(backend); + const RootNamespace ns{"srv1/confirm_mid_tenure"}; + + const ManifestId id = publishEmptyPart(store, ns, "seed"); + ASSERT_EQ(store->confirmExactRef(ns, "seed", id.ref), ConfirmAnswer::Yes); + + std::atomic boundaries{0}; + std::atomic unknown_at_boundary{0}; + std::atomic requests_at_boundary{0}; + store->setCarveHookForTest([&](CasRefLedger::CarvePhaseForTest phase) + { + if (phase != CasRefLedger::CarvePhaseForTest::ChunkReseed) + return; + boundaries.fetch_add(1); + const uint64_t before = backendRequests(*backend); + if (store->confirmExactRef(ns, "seed", id.ref) == ConfirmAnswer::Unknown) + unknown_at_boundary.fetch_add(1); + requests_at_boundary.fetch_add(static_cast(backendRequests(*backend) - before)); + }); + + /// Two co-batched items of 3000 ops each (1500 precommit add/remove pairs). One item may not + /// exceed the 5000-op `ref_txn_max_ops` cap on its own -- that fails the item outright -- so the + /// chunk boundary has to come from a BATCH: 6000 ops carved into one tenure split into two + /// transactions, firing exactly one boundary. The pre-carve hook parks the first caller until the + /// second is queued, which is what makes the co-batching deterministic. + auto sync = std::make_shared(); + store->setRefPreCarveHookForTest([sync, store, ns] + { + std::unique_lock lk(sync->m); + if (sync->entered) + return; + sync->entered = true; + sync->cv.notify_all(); + sync->cv.wait_for(lk, std::chrono::seconds(20), + [&] { return store->refQueuePendingForTest(ns) >= 2; }); + }); + + auto append = [&store, &ns](const String & prefix, uint64_t manifest_epoch) + { + std::vector item_ops = precommitAddRemovePairs(prefix, 1500, manifest_epoch); + store->appendRefOps(ns, MutationScope::ref(prefix), + [ops = std::move(item_ops)](const RefTableState &) { return ops; }, + RootMutationOrigin::Writer, RootMutationKind::Publish); + }; + std::thread a([&] { append("aaa_", 900000001); }); + { + std::unique_lock lk(sync->m); + sync->cv.wait_for(lk, std::chrono::seconds(20), [&] { return sync->entered; }); + } + std::thread b([&] { append("bbb_", 900000002); }); + /// The parked leader re-evaluates its predicate only when notified, so the queue depth is polled + /// here and the leader released explicitly once both items are admitted. + const auto deadline = std::chrono::steady_clock::now() + std::chrono::seconds(20); + while (store->refQueuePendingForTest(ns) < 2 && std::chrono::steady_clock::now() < deadline) + std::this_thread::yield(); + sync->cv.notify_all(); + a.join(); + b.join(); + store->setRefPreCarveHookForTest(nullptr); + store->setCarveHookForTest(nullptr); + + ASSERT_GE(boundaries.load(), 1) << "the flush did not chunk -- the mid-tenure window was not exercised"; + EXPECT_EQ(unknown_at_boundary.load(), boundaries.load()) + << "a mid-tenure, partially-durable table must never confirm"; + EXPECT_EQ(requests_at_boundary.load(), 0) << "the mid-tenure confirm must still be I/O-free"; + + /// The tenure is over: the seed ref is untouched by it and confirms again. + EXPECT_EQ(store->confirmExactRef(ns, "seed", id.ref), ConfirmAnswer::Yes); +} + + +/// Rule 3, the wedge case: the lane holds one conditional `PUT` whose outcome is unknown, so the table +/// may be MISSING a durable transaction -- possibly the very removal being asked about. The committed +/// row still matches exactly, so only the wedge can produce the refusal. +TEST(CASConfirmExactRef, WedgedLaneIsUnknown) +{ + auto backend = std::make_shared(); + auto store = openPool(backend); + const RootNamespace ns{"srv1/confirm_wedge"}; + DB::Cas::tests::casAdmitRecoverableEntry(*backend, store->layout(), ns); + + const ManifestId id = publishEmptyPart(store, ns, "x"); + ASSERT_EQ(store->confirmExactRef(ns, "x", id.ref), ConfirmAnswer::Yes); + + store->forceWedgeForTest(ns, /*writer_epoch=*/1, /*ref_sequence=*/9999, + store->layout().refLogKey(DB::Cas::tests::fixture::fixtureLife(ns), RefTxnId{1, 9999}), "synthetic"); + ASSERT_TRUE(store->refLaneWedgedForTest(ns)); + + backend->resetCounts(); + EXPECT_EQ(store->confirmExactRef(ns, "x", id.ref), ConfirmAnswer::Unknown); + EXPECT_EQ(backendRequests(*backend), 0u) + << "a wedged lane must answer Unknown without trying to resolve the wedge"; +} + + +/// `NeedsRecovery` is table-scoped, so confirmation refuses even a row that still looks perfect. +TEST(CASConfirmExactRef, NeedsRecoveryIsUnknown) +{ + auto backend = std::make_shared(); + auto store = openPool(backend); + const RootNamespace ns{"srv1/confirm_poison"}; + + const ManifestId keep = publishEmptyPart(store, ns, "keep"); + ASSERT_EQ(store->confirmExactRef(ns, "keep", keep.ref), ConfirmAnswer::Yes); + + armOneShotInstallFailure(store); + EXPECT_THROW(publishEmptyPart(store, ns, "other"), DB::Exception); + store->setInstallRegionProbeForTest(nullptr); + + ASSERT_EQ(store->laneStateForTest(ns), RefLaneState::NeedsRecovery); + ASSERT_FALSE(store->refLaneWedgedForTest(ns)); + ASSERT_FALSE(store->refLeaderActiveForTest(ns)); + + EXPECT_EQ(store->confirmExactRef(ns, "keep", keep.ref), ConfirmAnswer::Unknown) + << "a table that may be missing a durable transaction cannot confirm ANY of its rows"; +} + + +/// Rule 6, checked LAST: the committed row matches exactly, the lane is quiescent and clean -- but this +/// node no longer holds the mount incarnation, so it is no longer the namespace's single writer and +/// cannot speak for the durable table at all. Another writer may already have repointed the ref. +TEST(CASConfirmExactRef, LostMountFenceIsUnknown) +{ + auto backend = std::make_shared(); + auto store = openPool(backend); + const RootNamespace ns{"srv1/confirm_fence"}; + + const ManifestId id = publishEmptyPart(store, ns, "x"); + ASSERT_EQ(store->confirmExactRef(ns, "x", id.ref), ConfirmAnswer::Yes); + + store->tripMountLost(); + + ASSERT_TRUE(store->refTableCachedForTest(ns)) + << "the table must still be resident, so it is the FENCE that refuses, not residency"; + backend->resetCounts(); + EXPECT_EQ(store->confirmExactRef(ns, "x", id.ref), ConfirmAnswer::Unknown); + EXPECT_EQ(backendRequests(*backend), 0u); + + /// The fence is checked LAST, so it gates only the `Yes`: a token that does not match the committed + /// row is still reported as `No` under a lost fence. That is deliberate and harmless -- `No` and + /// `Unknown` are the same outcome for the caller (both `SourceProofFailed`) -- and pinning it here + /// keeps a future reordering of the rules from changing the answer silently. + ManifestRef other = id.ref; + ++other.manifest_ordinal; + EXPECT_EQ(store->confirmExactRef(ns, "x", other), ConfirmAnswer::No); +} + + +/// The two-mutex snapshot race (spec §testing, "an append admitted concurrently is ordered strictly +/// after the snapshot"). The confirm holds `ref_queue_mutex` across the whole snapshot, and admission +/// (`pending.push_back`) takes that same mutex, so every append is either entirely before the snapshot +/// (and visible as a pending item -> `Unknown`) or entirely after it. What must NOT exist is a window +/// in which the removal is admitted and the confirm still says `Yes`. +/// +/// The three phases are driven deterministically rather than hammered: before admission -> `Yes`; +/// from admission until the transaction is durable -> `Unknown`; after -> `No`. `Yes` is never +/// observable once the removal has been admitted. +TEST(CASConfirmExactRef, ConcurrentAppendIsOrderedAfterTheSnapshot) +{ + auto backend = std::make_shared(); + auto store = openPool(backend); + const RootNamespace ns{"srv1/confirm_race"}; + + const ManifestId id = publishEmptyPart(store, ns, "x"); + ASSERT_EQ(store->confirmExactRef(ns, "x", id.ref), ConfirmAnswer::Yes) << "phase 1: before admission"; + + LeaderLatch latch; + latch.arm(store); + std::thread dropper([&] { store->dropRef(ns, "x"); }); + latch.awaitEntered(); + + /// Phase 2: admitted, nothing durable. Sampled repeatedly so a single lucky interleaving cannot + /// pass for the invariant, and TALLIED rather than asserted -- an assertion here would skip the + /// release below and terminate the suite on the still-joinable `dropper`. + int not_unknown = 0; + int saw_yes = 0; + for (int i = 0; i < 64; ++i) + { + const ConfirmAnswer a = store->confirmExactRef(ns, "x", id.ref); + if (a != ConfirmAnswer::Unknown) + ++not_unknown; + if (a == ConfirmAnswer::Yes) + ++saw_yes; + } + + latch.release(); + dropper.join(); + store->setRefPreCarveHookForTest(nullptr); + + EXPECT_EQ(saw_yes, 0) << "phase 2: an admitted removal must never leave a Yes visible"; + EXPECT_EQ(not_unknown, 0) << "phase 2: an admitted append makes the committed view provisional"; + + /// Phase 3: durable and applied. + EXPECT_EQ(store->confirmExactRef(ns, "x", id.ref), ConfirmAnswer::No) << "phase 3: after the removal"; +} + + +/// =========================================================================================== +/// Task 11: the EXCHANGE-level confirm -- `IContentAddressedExchange::ownsNamespace` (routing) and +/// `::confirmExactRef` (the storage forward of gate 1, plus the token text and the disk lifecycle). +/// +/// Gate 0 is not exercised here on purpose: it reads a `StorageReplicatedMergeTree` parts set, so +/// `Deleting`, absent, other-disk and the `MOVE ... TO DISK` same-name case are integration-level and +/// belong to the Task 16 pytest battery. +/// =========================================================================================== + +namespace +{ + +/// A storage adapter over its own private local object storage. `startup` is the caller's business: +/// several tests below assert behavior BEFORE it and AFTER `shutdown`. +std::shared_ptr makeExchangeStorage(const std::string & server_root_id) +{ + auto settings = tests::makeSettingsForTest( + server_root_id, std::filesystem::temp_directory_path() / "ca_confirm_exchange_scratch"); + return std::make_shared( + tests::makeLocalObjectStorageForTest(), "pool", "srv1", "", nullptr, settings); +} + +const std::string kExchangeTableDir = "e11/e11e11e1-0808-4808-8808-080808080808"; +const std::string kExchangePartName = "all_1_1_0"; +const std::string kExchangePartDir = kExchangeTableDir + "/" + kExchangePartName; + +/// Commit one real part through the ordinary transaction path, so the committed binding under test is +/// produced exactly the way an INSERT produces it. +void commitExchangePart(DB::ContentAddressedMetadataStorage & storage) +{ + auto tx = storage.createTransaction(); + auto & ca_tx = dynamic_cast(*tx); + auto buf = ca_tx.writeFile(kExchangeTableDir + "/tmp_insert_" + kExchangePartName + "/data.bin", + 65536, DB::WriteMode::Rewrite, {}); + const std::string bytes = "content-of-the-part"; + buf->write(bytes.data(), bytes.size()); + buf->finalize(); + tx->moveDirectory(kExchangeTableDir + "/tmp_insert_" + kExchangePartName, kExchangePartDir); + tx->commit(DB::NoCommitOptions{}); +} + +/// The token the sender mints for the committed part, decoded back into its fields. Read through the +/// real offer path rather than reconstructed, so the tests below exercise exactly what goes on the wire. +DB::CasRelinkSourceToken readSourceToken(const DB::ContentAddressedMetadataStorage & storage) +{ + const auto offer = storage.getRelinkOffer(kExchangePartDir); + EXPECT_TRUE(offer.has_value()) << "the committed part must offer a manifest to relink"; + if (!offer) + return {}; + const auto token = DB::decodeCasRelinkSourceToken(offer->confirm_token); + EXPECT_TRUE(token.has_value()) << "the sender minted a token its own decoder rejects"; + return token.value_or(DB::CasRelinkSourceToken{}); +} + +} + + +/// The canonical text form of a `ManifestRef` becomes wire input with Task 11: it is what the confirm +/// token carries and what `confirmExactRef` compares. It is therefore parsed as untrusted input -- +/// exactly three decimal fields, nothing consumed partially, no sign, no padding -- and the parser is +/// pinned as the exact inverse of the renderer, because a mismatch between the two would silently turn +/// every confirm into an `Unknown` (or, far worse, make two different manifests compare equal). +TEST(CASConfirmExactRef, ManifestRefTextRoundTripsAndRejectsMalformedTokens) +{ + for (const ManifestRef & ref : {ManifestRef{1, 1, 1}, ManifestRef{7, 42, 999999}, + ManifestRef{18446744073709551615ULL, 18446744073709551615ULL, 123}}) + { + const String text = manifestRefDebugString(ref); + const auto parsed = tryParseManifestRef(text); + ASSERT_TRUE(parsed.has_value()) << "the renderer produced text its own parser rejects: " << text; + EXPECT_EQ(*parsed, ref) << text; + } + + EXPECT_EQ(manifestRefDebugString(ManifestRef{7, 42, 3}), "7:42:3") << "the canonical form is epoch:build:ordinal"; + + for (const std::string_view malformed : { + "", "1", "1:2", "1:2:3:4", ":2:3", "1::3", "1:2:", "1:2:x", "x:2:3", " 1:2:3", "1:2:3 ", + "1: 2:3", "+1:2:3", "-1:2:3", "1:2:3\n", "0x1:2:3", + /// `0` is the reserved invalid ordinal and is never emitted; `1000000` is past the six-digit + /// filename range, so neither can name a real manifest. + "1:2:0", "1:2:1000000", + /// One past `uint64` / `uint32` -- `from_chars` reports overflow rather than truncating. + "18446744073709551616:1:1", "1:18446744073709551616:1", "1:1:4294967296"}) + { + EXPECT_FALSE(tryParseManifestRef(malformed).has_value()) + << "accepted a malformed manifest reference: '" << malformed << "'"; + } +} + + +/// Task 13, the confirm token's wire codec (spec §wire-protocol). The token is minted by the sender, +/// stored nowhere, and handed back by an untrusted peer, so the only property that matters is that +/// decode is the exact inverse of encode: the fields the sender meant are the fields that route and +/// compare. A codec that merged two fields, or that let a separator through unescaped, would let a +/// peer aim a confirm at a namespace the sender never named. +TEST(CASConfirmExactRef, SourceTokenRoundTripsThroughItsWireForm) +{ + const auto round_trip = [](const DB::CasRelinkSourceToken & token, const char * what) + { + const auto text = DB::encodeCasRelinkSourceToken(token); + ASSERT_TRUE(text.has_value()) << what; + /// The wire form is cookie-safe and URL-safe by construction: only the RFC 3986 unreserved set, + /// the escape character, and the field separator ever appear in it. + for (const char ch : *text) + EXPECT_TRUE(std::isalnum(static_cast(ch)) + || std::string_view("-._~%|").find(ch) != std::string_view::npos) + << "the wire form leaked an unsafe character '" << ch << "' from " << what << ": " << *text; + + const auto decoded = DB::decodeCasRelinkSourceToken(*text); + ASSERT_TRUE(decoded.has_value()) << what << ": " << *text; + EXPECT_EQ(decoded->pool_uuid, token.pool_uuid) << what; + EXPECT_EQ(decoded->server_root_id, token.server_root_id) << what; + EXPECT_EQ(decoded->root_namespace, token.root_namespace) << what; + EXPECT_EQ(decoded->ref_name, token.ref_name) << what; + EXPECT_EQ(decoded->part_name, token.part_name) << what; + EXPECT_EQ(decoded->manifest_ref_text, token.manifest_ref_text) << what; + }; + + round_trip({"abcdef0123456789", "srv1", "srv1/store/abc/abcdef@cas@", "all_1_1_0", "all_1_1_0", "1:1:1"}, + "the ordinary shape"); + /// The characters that make a naive codec wrong: the separator itself, the escape character, the + /// cookie-forbidden set, and the `/`+`@` a namespace and a detached ref carry as a matter of course. + round_trip({"p|o%o=l", "srv 1;x,y", "srv 1;x,y/store/abc/abcdef@cas@", "detached/broken_all_1_1_0", + "all_1_1_0", "18446744073709551615:18446744073709551615:999999"}, + "the hostile shape"); + /// A field at the cap must survive; one past it must not (asserted below). + round_trip({String(256, 'a'), "srv1", "srv1/store/abc/abcdef@cas@", "all_1_1_0", "all_1_1_0", "1:1:1"}, + "a field at the length cap"); +} + +/// Everything a peer can hand back that is not a token this sender minted. None of these may decode: +/// a decoded-but-wrong token routes a confirm somewhere, and "somewhere" is exactly what routing exists +/// to prevent. Refusing costs a byte fetch and nothing else. +TEST(CASConfirmExactRef, SourceTokenRejectsMalformedAndOverlongInput) +{ + const DB::CasRelinkSourceToken good{"pool", "srv1", "srv1/store/abc/abcdef@cas@", "all_1_1_0", "all_1_1_0", "1:1:1"}; + const String text = DB::encodeCasRelinkSourceToken(good).value(); + + /// Every literal below is a SEVEN-segment token (version + six fields) unless it is testing the + /// segment count itself, so each case fails for the reason it names and not because it is short. + for (const std::string_view malformed : { + /// Empty, no version, the wrong version, and versions that merely start or end right. + "", "|a|b|c|d|e|f", "car0|a|b|c|d|e|f", "car|a|b|c|d|e|f", "car11|a|b|c|d|e|f", + /// Too few and too many fields -- a shape that is one field off must not shift the rest. + "car1|a|b|c|d|e", "car1|a|b|c|d|e|f|g", "car1", "car1|", + /// An empty field: a token with a hole in it routes somewhere it was not meant to. + "car1||b|c|d|e|f", "car1|a|b|c|d|e|", + /// Malformed escapes: truncated, non-hex, and a lone escape character. + "car1|%|b|c|d|e|f", "car1|%4|b|c|d|e|f", "car1|%zz|b|c|d|e|f", "car1|a%|b|c|d|e|f", + /// Unescaped bytes outside the unreserved set: the decoder is the encoder's inverse, so + /// anything the encoder would have escaped is not a token, however readable it looks. + "car1|a/b|c|d|e|f|g", "car1|a b|c|d|e|f|g", "car1|a@b|c|d|e|f|g", "car1|1:1:1|b|c|d|e|f", + /// A control character smuggled in as an escape -- the classic forged-log-line vector. + "car1|a%00b|c|d|e|f|g", "car1|a%0Ab|c|d|e|f|g"}) + { + EXPECT_FALSE(DB::decodeCasRelinkSourceToken(malformed).has_value()) + << "accepted a malformed source token: '" << malformed << "'"; + } + + /// Over-long: refused in BOTH directions, so an over-long field can neither be minted nor accepted. + DB::CasRelinkSourceToken too_long = good; + too_long.ref_name = String(257, 'a'); + EXPECT_FALSE(DB::encodeCasRelinkSourceToken(too_long).has_value()); + EXPECT_FALSE(DB::decodeCasRelinkSourceToken( + "car1|pool|srv1|ns|" + String(257, 'a') + "|all_1_1_0|1%3A1%3A1").has_value()); + /// A field whose ENCODED form blows the whole-token cap (every byte escapes to three). + DB::CasRelinkSourceToken all_escaped = good; + all_escaped.root_namespace = String(200, ' '); + all_escaped.ref_name = String(200, ' '); + EXPECT_FALSE(DB::encodeCasRelinkSourceToken(all_escaped).has_value()); + EXPECT_FALSE(DB::decodeCasRelinkSourceToken(String(2000, 'a')).has_value()); + + /// An empty field is refused on the way out too, not only on the way in. + DB::CasRelinkSourceToken empty_field = good; + empty_field.part_name.clear(); + EXPECT_FALSE(DB::encodeCasRelinkSourceToken(empty_field).has_value()); + + /// The control-character refusal is symmetric: the sender cannot mint one either. + DB::CasRelinkSourceToken control = good; + control.server_root_id = "srv\n1"; + EXPECT_FALSE(DB::encodeCasRelinkSourceToken(control).has_value()); + + /// Sanity: the good token itself decodes, so the rejections above are about the input and not about + /// a codec that refuses everything. + EXPECT_TRUE(DB::decodeCasRelinkSourceToken(text).has_value()); +} + + +/// Routing (spec §wire-protocol). A pool UUID is shared by every server root writing into the pool, so +/// it cannot select the mount entitled to answer for a namespace; `ownsNamespace` is what does. It is a +/// pure string question about the mount's own identity: no pool, no I/O, no lifecycle -- asserted here +/// by answering the same before `startup`, while live, and after `shutdown`. A routing predicate that +/// could throw would turn a misrouted question into an error instead of an unproven answer. +TEST(CASConfirmExactRef, OwnsNamespaceSelectsTheMountByServerRootInEveryLifecycleState) +{ + auto storage = makeExchangeStorage("srv1"); + + const auto assert_routing = [&](const char * phase) + { + /// Live and detached namespaces are `/` (`liveNamespace`). + EXPECT_TRUE(storage->ownsNamespace("srv1", "srv1/store/abc/abcdef@cas@")) << phase; + EXPECT_TRUE(storage->ownsNamespace("srv1", storage->liveNamespace("abcdef").string())) << phase; + + /// A different server root's namespace, and this namespace asked about under a different server + /// root: the same pool, a different owner. Both must miss, or a confirm could be answered by a + /// mount that never wrote the ref. + EXPECT_FALSE(storage->ownsNamespace("srv2", "srv1/store/abc/abcdef@cas@")) << phase; + EXPECT_FALSE(storage->ownsNamespace("srv1", "srv2/store/abc/abcdef@cas@")) << phase; + + /// The prefix trap: a bare `starts_with(server_root_id)` would let `srv1` claim `srv10`. + EXPECT_FALSE(storage->ownsNamespace("srv1", "srv10/store/abc/abcdef@cas@")) << phase; + EXPECT_FALSE(storage->ownsNamespace("srv10", "srv10/store/abc/abcdef@cas@")) << phase; + + /// The server root itself is not a namespace, and a pool-global FREEZE tree belongs to no mount. + EXPECT_FALSE(storage->ownsNamespace("srv1", "srv1")) << phase; + EXPECT_FALSE(storage->ownsNamespace("srv1", "shadow/backup/store/abc/abcdef")) << phase; + + /// Empty fields are never a match -- an absent token field must not route anywhere. + EXPECT_FALSE(storage->ownsNamespace("", "")) << phase; + EXPECT_FALSE(storage->ownsNamespace("", "srv1/store/abc/abcdef@cas@")) << phase; + EXPECT_FALSE(storage->ownsNamespace("srv1", "")) << phase; + }; + + assert_routing("before startup"); + storage->startup(); + assert_routing("while live"); + storage->shutdown(); + assert_routing("after shutdown"); +} + + +/// The storage forward of gate 1, driven end to end: a part committed through the ordinary transaction +/// path, and a token read out of the very manifest body the sender puts on the wire. The three +/// non-`Yes` cases pin the two halves this layer adds on top of the ledger -- the token text is decoded +/// here, and a namespace this mount holds no resident runtime for is an ambiguity, not a `No`. +TEST(CASConfirmExactRef, StorageConfirmAnswersForTheCommittedBinding) +{ + auto storage = makeExchangeStorage("test"); + storage->startup(); + commitExchangePart(*storage); + + const DB::CasRelinkSourceToken token = readSourceToken(*storage); + ASSERT_TRUE(storage->ownsNamespace("test", token.root_namespace)) + << "the sender must route its own committed namespace to itself: " << token.root_namespace; + + EXPECT_EQ(storage->confirmExactRef(token.root_namespace, kExchangePartName, token.manifest_ref_text), + DB::CasConfirmAnswer::Yes); + + /// A manifest this ref never named, and a ref name that was never committed: both are knowledge on + /// a warm table, and both are `No` -- which the caller must still treat as "not proven". + const auto other_ref = tryParseManifestRef(token.manifest_ref_text); + ASSERT_TRUE(other_ref.has_value()); + EXPECT_EQ(storage->confirmExactRef(token.root_namespace, kExchangePartName, + manifestRefDebugString(ManifestRef{other_ref->writer_epoch, + other_ref->build_sequence, + other_ref->manifest_ordinal + 1})), + DB::CasConfirmAnswer::No); + EXPECT_EQ(storage->confirmExactRef(token.root_namespace, "all_9_9_9", token.manifest_ref_text), + DB::CasConfirmAnswer::No); + + /// A namespace with no resident runtime: the ledger will not recover one to answer, so this is an + /// ambiguity. It must not read as "the ref does not exist". + EXPECT_EQ(storage->confirmExactRef("test/store/zzz/zzzzzz@cas@", kExchangePartName, token.manifest_ref_text), + DB::CasConfirmAnswer::Unknown); + + /// An unparsable token: the question cannot be understood, so it cannot be answered `No`. + EXPECT_EQ(storage->confirmExactRef(token.root_namespace, kExchangePartName, "not-a-manifest-ref"), + DB::CasConfirmAnswer::Unknown); + EXPECT_EQ(storage->confirmExactRef(token.root_namespace, kExchangePartName, ""), + DB::CasConfirmAnswer::Unknown); + + storage->shutdown(); +} + + +/// The disk's own lifecycle is an answer, not an exception. The confirm is served on an interserver +/// request, and the caller has a durable precommit waiting on it: a thrown `INVALID_STATE` would have +/// to be classified by the HTTP layer, whereas `Unknown` is already the taxonomy's "not proven". Only +/// `Yes` authorizes, and no lifecycle state can produce one. +TEST(CASConfirmExactRef, StorageConfirmIsUnknownWhenTheDiskCannotSpeakForItsView) +{ + auto storage = makeExchangeStorage("test"); + + /// Never started: no pool has ever been published, so there is no committed view at all. + EXPECT_EQ(storage->confirmExactRef("test/store/abc/abcdef@cas@", kExchangePartName, "1:1:1"), + DB::CasConfirmAnswer::Unknown); + + storage->startup(); + commitExchangePart(*storage); + const DB::CasRelinkSourceToken token = readSourceToken(*storage); + ASSERT_EQ(storage->confirmExactRef(token.root_namespace, kExchangePartName, token.manifest_ref_text), + DB::CasConfirmAnswer::Yes); + + /// Shut down: the same question, the same token, and the same table -- but this process no longer + /// speaks for the namespace. + storage->shutdown(); + EXPECT_EQ(storage->confirmExactRef(token.root_namespace, kExchangePartName, token.manifest_ref_text), + DB::CasConfirmAnswer::Unknown); +} diff --git a/src/Disks/tests/gtest_cas_decommission.cpp b/src/Disks/tests/gtest_cas_decommission.cpp new file mode 100644 index 000000000000..138d69b0f9b6 --- /dev/null +++ b/src/Disks/tests/gtest_cas_decommission.cpp @@ -0,0 +1,1366 @@ +#include "cas_test_helpers.h" +#include +#include +#include +#include +#include +#include +#include +#include + +namespace DB::ErrorCodes +{ + extern const int CORRUPTED_DATA; +} + +using namespace DB; +using namespace DB::Cas; +using namespace DB::Cas::tests; + +namespace +{ + +/// Open a store for the VICTIM srid over `backend` (the pool's future dead member). +PoolPtr openVictim(std::shared_ptr backend) +{ + return Pool::open(backend, PoolConfig{.pool_prefix = "p", .server_root_id = "victim"}); +} + +void drainCompletedNamespaceRemovals(const std::shared_ptr & backend) +{ + PoolConfig config{ + .pool_prefix = "p", + .server_root_id = "gc", + .gc_fold_threshold = 1, + .gc_fold_max_defer_rounds = 0}; + auto store = Pool::open(backend, config); + Gc gc(store, UInt128{991}); + ASSERT_FALSE(runRegularRoundReclaiming(gc).deferred); + ASSERT_FALSE(runRegularRoundReclaiming(gc).deferred); +} + +/// Fails `deleteExact` for one or two designated keys -- either by throwing (a transient backend +/// hiccup) or by returning a synthetic `TokenMismatch` (a "listed but raced" outcome) -- delegating +/// every other key to the base `InMemoryBackend` untouched. Drives the drain phases' per-object +/// fail-close path (`deleteListedPrefix`/`sweepNamespace`, `CasDecommission.cpp`/ +/// `CasOrphanManifestSweep.cpp`): a failure on one listed object must record a warning and let the rest +/// of the sweep proceed, never abort the whole phase. +/// +class FailingDeleteBackend : public InMemoryBackend +{ +public: + void failWithThrow(const String & key) { throw_key = key; } + void failWithTokenMismatch(const String & key) { mismatch_key = key; } + /// Clears every injected failure -- the resume half of a fail-then-retry test (Task 4). + void disarm() { throw_key.clear(); mismatch_key.clear(); } + + DeleteOutcome deleteExact(const String & key, const Token & token) override + { + if (key == throw_key) + throw std::runtime_error("injected transient delete failure for " + key); + if (key == mismatch_key) + return DeleteOutcome{.kind = DeleteOutcome::Kind::TokenMismatch}; + return InMemoryBackend::deleteExact(key, token); + } + +private: + String throw_key; + String mismatch_key; +}; + +/// Replaces the durable catalog immediately after returning the first armed catalog read. This +/// distinguishes the immutable cut validated before decommission impersonation from a later mount +/// safety observation without assuming those two decisions share one GET. +class CatalogChangesAfterFirstReadBackend : public InMemoryBackend +{ +public: + using Backend::get; + + void armCatalogReplacement( + const String & key, RefCatalog replacement_, size_t completed_reads_before_replacement = 0) + { + catalog_key = key; + replacement = std::move(replacement_); + reads_to_skip = completed_reads_before_replacement; + armed = true; + } + + bool fired() const { return replacement_fired; } + + std::optional get(const String & key, Range range) override + { + auto got = InMemoryBackend::get(key, range); + if (!armed || replacement_fired || key != catalog_key) + return got; + if (reads_to_skip > 0) + { + --reads_to_skip; + return got; + } + if (!got) + throw std::runtime_error("catalog replacement fixture: catalog is absent"); + + replacement_fired = true; + const PutResult put = InMemoryBackend::putOverwrite( + key, encodeRefCatalog(replacement), got->token, {}); + if (put.outcome != PutOutcome::Done) + throw std::runtime_error("catalog replacement fixture: rewrite conflicted"); + return got; + } + +private: + String catalog_key; + RefCatalog replacement; + size_t reads_to_skip = 0; + bool armed = false; + bool replacement_fired = false; +}; + +std::vector> snapshotPrefixObjects( + InMemoryBackend & backend, const String & prefix) +{ + std::vector> objects; + String cursor; + while (true) + { + const ListPage page = backend.list(prefix, cursor, 1000); + for (const ListedKey & listed : page.keys) + { + const auto got = backend.get(listed.key); + if (!got) + throw std::runtime_error("prefix snapshot fixture: listed object disappeared"); + objects.emplace_back(listed.key, got->bytes, got->token); + } + if (page.next_cursor.empty()) + return objects; + cursor = page.next_cursor; + } +} + +/// Installs a same-UUID successor deterministically in the retirement tail's read/delete window. +/// Once armed, the backend recognizes the admin's clean farewell `putOverwrite`. On the next read of +/// either mutable control object it first captures the value that read observed, then bumps `epoch` +/// and reclaims `mount` with fresh tokens before returning the captured result. Thus the caller holds +/// exactly the stale token it would have obtained immediately before a concurrent restart reclaimed +/// the slot, without threads or sleeps. +class SuccessorReclaimAfterFarewellBackend : public InMemoryBackend +{ +public: + using Backend::get; + using Backend::putOverwrite; + + void armForSuccessorReclaim() { armed = true; } + + std::optional get(const String & key, Range range) override + { + std::optional result = InMemoryBackend::get(key, range); + if (farewell_seen && !successor_injected && (key == mount_key || key == epoch_key)) + injectSuccessor(); + return result; + } + + PutResult putOverwrite( + const String & key, const String & bytes, const Token & expected, const ObjectMeta & meta) override + { + const PutResult result = InMemoryBackend::putOverwrite(key, bytes, expected, meta); + if (armed && key == mount_key && result.outcome == PutOutcome::Done) + { + const MountLease mount = decodeMountLease(bytes); + if (mount.min_active == std::numeric_limits::max()) + farewell_seen = true; + } + return result; + } + + bool successorInjected() const { return successor_injected; } + const Token & successorMountToken() const { return successor_mount_token; } + const Token & successorEpochToken() const { return successor_epoch_token; } + const String & successorMountBytes() const { return successor_mount_bytes; } + const String & successorEpochBytes() const { return successor_epoch_bytes; } + +private: + void injectSuccessor() + { + const auto epoch = InMemoryBackend::get(epoch_key, {}); + const auto mount = InMemoryBackend::get(mount_key, {}); + if (!epoch || !mount) + throw std::runtime_error("successor-reclaim fixture: control object disappeared before reclaim"); + + ServerEpoch epoch_value = decodeServerEpoch(epoch->bytes); + const uint64_t successor_writer_epoch = epoch_value.next_writer_epoch; + ++epoch_value.next_writer_epoch; + successor_epoch_bytes = encodeServerEpoch(epoch_value); + const CasResult epoch_put = InMemoryBackend::casPut( + epoch_key, successor_epoch_bytes, std::optional{epoch->token}, {}); + if (epoch_put.outcome != CasOutcome::Committed) + throw std::runtime_error("successor-reclaim fixture: epoch bump conflicted"); + successor_epoch_token = epoch_put.token; + + MountLease mount_value = decodeMountLease(mount->bytes); + mount_value.writer_epoch = successor_writer_epoch; + ++mount_value.seq; + ++mount_value.started_at_ms; + mount_value.expires_at_ms = mount_value.started_at_ms + 30'000; + mount_value.min_active = 0; + mount_value.gc_fenced = false; + successor_mount_bytes = encodeMountLease(mount_value); + const PutResult mount_put = InMemoryBackend::putOverwrite( + mount_key, successor_mount_bytes, mount->token, {}); + if (mount_put.outcome != PutOutcome::Done) + throw std::runtime_error("successor-reclaim fixture: mount reclaim conflicted"); + successor_mount_token = mount_put.token; + successor_injected = true; + } + + inline static const String mount_key = "p/gc/server-roots/victim/mount"; + inline static const String epoch_key = "p/gc/server-roots/victim/epoch"; + bool armed = false; + bool farewell_seen = false; + bool successor_injected = false; + Token successor_mount_token; + Token successor_epoch_token; + String successor_mount_bytes; + String successor_epoch_bytes; +}; + +/// Recreates the mutable slot objects immediately after decommission successfully deletes `epoch`. +/// This models a same-UUID successor starting in the final retirement window: `owner` remains the +/// unchanged identity anchor, while the successor legitimately creates a fresh `epoch` and `mount`. +class SuccessorReclaimAfterEpochDeleteBackend : public InMemoryBackend +{ +public: + using Backend::get; + using Backend::putOverwrite; + + void armForSuccessorReclaim() { armed = true; } + + DeleteOutcome deleteExact(const String & key, const Token & token) override + { + const DeleteOutcome result = InMemoryBackend::deleteExact(key, token); + if (armed && !successor_injected && key == epoch_key + && classifyDeleteOutcome(result) == DeleteClass::Deleted) + { + injectSuccessor(); + } + return result; + } + + bool successorInjected() const { return successor_injected; } + uint64_t ownerRewriteAttempts() const { return owner_rewrite_attempts; } + const Token & successorMountToken() const { return successor_mount_token; } + const Token & successorEpochToken() const { return successor_epoch_token; } + const String & successorMountBytes() const { return successor_mount_bytes; } + const String & successorEpochBytes() const { return successor_epoch_bytes; } + +private: + PutResult putOverwrite( + const String & key, const String & bytes, const Token & expected, const ObjectMeta & meta) override + { + if (key == owner_key) + ++owner_rewrite_attempts; + return InMemoryBackend::putOverwrite(key, bytes, expected, meta); + } + + void injectSuccessor() + { + successor_epoch_bytes = encodeServerEpoch(ServerEpoch{.next_writer_epoch = 102}); + const PutResult epoch_put = InMemoryBackend::putIfAbsent(epoch_key, successor_epoch_bytes, {}); + if (epoch_put.outcome != PutOutcome::Done) + throw std::runtime_error("late-successor fixture: epoch recreation conflicted"); + successor_epoch_token = epoch_put.token; + + successor_mount_bytes = encodeMountLease(MountLease{ + .server_uuid = UInt128(0x1234), + .writer_epoch = 101, + .hostname = "successor", + .pid = 42, + .started_at_ms = 1'000, + .seq = 1, + .expires_at_ms = 31'000, + .min_active = 0, + }); + const PutResult mount_put = InMemoryBackend::putIfAbsent(mount_key, successor_mount_bytes, {}); + if (mount_put.outcome != PutOutcome::Done) + throw std::runtime_error("late-successor fixture: mount recreation conflicted"); + successor_mount_token = mount_put.token; + successor_injected = true; + } + + inline static const String mount_key = "p/gc/server-roots/victim/mount"; + inline static const String epoch_key = "p/gc/server-roots/victim/epoch"; + inline static const String owner_key = "p/gc/server-roots/victim/owner"; + bool armed = false; + bool successor_injected = false; + uint64_t owner_rewrite_attempts = 0; + Token successor_mount_token; + Token successor_epoch_token; + String successor_mount_bytes; + String successor_epoch_bytes; +}; + +/// Rewrites the owner anchor after decommission reads it but before its conditional tombstone write. +/// Returning the captured result gives decommission a stale owner token, deterministically modeling +/// the successor race without threads or sleeps. +class SuccessorOwnerRewriteBeforeTombstoneBackend : public InMemoryBackend +{ +public: + using Backend::get; + + void armForSuccessorRewrite() { armed = true; } + + std::optional get(const String & key, Range range) override + { + std::optional result = InMemoryBackend::get(key, range); + if (armed && epoch_deleted && !successor_injected && key == owner_key && result) + { + successor_owner_bytes = encodeOwner(OwnerObject{ + .server_uuid = decodeOwner(result->bytes).server_uuid, + .retired_at_ms = std::nullopt, + }); + const PutResult put = InMemoryBackend::putOverwrite( + owner_key, successor_owner_bytes, result->token, {}); + if (put.outcome != PutOutcome::Done) + throw std::runtime_error("owner-successor fixture: owner rewrite conflicted"); + successor_owner_token = put.token; + successor_injected = true; + } + return result; + } + + DeleteOutcome deleteExact(const String & key, const Token & token) override + { + const DeleteOutcome result = InMemoryBackend::deleteExact(key, token); + if (armed && key == epoch_key && classifyDeleteOutcome(result) == DeleteClass::Deleted) + epoch_deleted = true; + return result; + } + + bool successorInjected() const { return successor_injected; } + const Token & successorOwnerToken() const { return successor_owner_token; } + const String & successorOwnerBytes() const { return successor_owner_bytes; } + +private: + inline static const String epoch_key = "p/gc/server-roots/victim/epoch"; + inline static const String owner_key = "p/gc/server-roots/victim/owner"; + bool armed = false; + bool epoch_deleted = false; + bool successor_injected = false; + Token successor_owner_token; + String successor_owner_bytes; +}; + +/// Models an "ambiguous success" on the final owner tombstone write: the conditional overwrite +/// actually lands (InMemoryBackend applies it), but the response is then lost (a transient +/// exception is thrown on the SAME call, exactly as a real SDK timeout after a landed write would +/// look). Before the fix, decommission caught any exception here and reported failure +/// unconditionally; the controlled overwrite must resolve this via a GET (the current bytes match +/// what was intended) and report Committed instead. +class AmbiguousOwnerTombstoneBackend : public InMemoryBackend +{ +public: + using Backend::putOverwrite; + + void armForAmbiguousTombstone() { armed = true; } + + PutResult putOverwrite(const String & key, const String & bytes, const Token & expected, const ObjectMeta & meta) override + { + const PutResult result = InMemoryBackend::putOverwrite(key, bytes, expected, meta); + if (armed && !fired && key == owner_key && result.outcome == PutOutcome::Done) + { + fired = true; + throw std::runtime_error("ambiguous-tombstone fixture: response lost after the write landed"); + } + return result; + } + +private: + inline static const String owner_key = "p/gc/server-roots/victim/owner"; + bool armed = false; + bool fired = false; +}; + +/// Seed one victim table with `committed` committed refs and `precommits` dangling precommit bindings, +/// via the raw ref-log seeding helpers (fixture idiom of e.g. `gtest_cas_gc_fold.cpp`: `writeManifestRaw` +/// + `publishCommittedTransition`/`addPrecommitTransition` against `victim`'s own backend/layout) -- this +/// fixture only needs the ref-table SHAPE `dropNamespace` erases, not a real build. Precommit bindings +/// are seeded at an artificially high `writer_epoch` so the writer's own stale-precommit sweep (armed +/// unconditionally by this table's recovery, unrelated to decommission -- spec §Clean Up Old Precommits) +/// never reclaims them, in its OWN separate transaction, ahead of `dropNamespace`'s removal. +void makeTableWithRefs(Pool & victim, const String & ns_str, uint64_t committed, uint64_t precommits) +{ + const RootNamespace ns(ns_str); + Backend & backend = victim.backend(); + const Layout & layout = victim.layout(); + + /// Final physical ids are pool-wide. The generic raw-write helper intentionally uses one shared + /// transition sentinel, so this multi-namespace fixture admits a distinct deterministic test life + /// before invoking it; the helper then resolves and preserves that existing catalog identity. + const CasRefCatalog::Snapshot catalog = CasRefCatalog::read(backend, layout); + const auto existing = std::find_if(catalog.catalog.entries.begin(), catalog.catalog.entries.end(), + [&](const CatalogEntry & entry) { return entry.ns.string() == ns.string(); }); + if (existing == catalog.catalog.entries.end()) + { + static std::atomic next_test_life{1000}; + CatalogEntry entry; + entry.ns = ns; + entry.state = NsState::Live; + entry.incarnation = UInt128{next_test_life.fetch_add(1)}; + CasRefCatalog::casAdmitEntry(backend, layout, 1, entry); + } + + uint64_t last_ref_sequence = 0; + for (uint64_t i = 0; i < committed; ++i) + { + const ManifestRef ref{.writer_epoch = 1, .build_sequence = i + 1, .manifest_ordinal = 1}; + writeManifestRaw(backend, layout, ns, ref, {}); + last_ref_sequence = publishCommittedTransition(backend, layout, ns, "committed_" + std::to_string(i), std::nullopt, ref); + } + for (uint64_t i = 0; i < precommits; ++i) + { + const ManifestRef ref{.writer_epoch = 999999, .build_sequence = i + 1, .manifest_ordinal = 1}; + writeManifestRaw(backend, layout, ns, ref, {}); + last_ref_sequence = addPrecommitTransition(backend, layout, ns, UInt128(1), "precommit_" + std::to_string(i), std::nullopt, ref); + } + + /// Semantic transition helpers already publish `_ckpt`; replace their final checkpoint through + /// the exact token-CAS fixture helper to make this fixture's complete intended state explicit. + replaceRecoverableCkptForRawFixture(backend, layout, ns, RefCkpt{ + .life_epoch = 1, + .committed_through = last_ref_sequence ? std::optional{RefTxnId{1, last_ref_sequence}} : std::nullopt, + .checkpoint_snapshot_id = std::nullopt, + .last_epoch_seal = std::nullopt, + }); + + /// Self-checking: `listRefs` must observe exactly `committed` committed refs before returning. + ASSERT_EQ(victim.listRefs(ns).size(), committed); +} + +/// Pre-precommit manifest debris: a staged manifest body under `ns_str`, at the store's own +/// `writer_epoch`, named by NO owner event -- a build the writer staged and never finished (fixture +/// idiom of `gtest_cas_orphan_manifest_sweep.cpp`'s `EligibleAndUnownedIsDeleted`). `build_sequence = 99` +/// is picked well clear of `makeTableWithRefs`'s own committed/precommit build sequences so it can never +/// collide with a real owned manifest key. Returns the seeded body's `ManifestId` so a caller can target +/// it (e.g. its exact object key) for further fixture setup. +ManifestId seedOrphanManifestBody(Pool & victim, const String & ns_str) +{ + const RootNamespace ns(ns_str); + const ManifestRef ref{.writer_epoch = victim.writerEpoch(), .build_sequence = 99, .manifest_ordinal = 1}; + const ManifestId id = writeManifestRaw(victim.backend(), victim.layout(), ns, ref, {}); + /// EXPECT, not ASSERT: this function returns a value now, and ASSERT_* expands to a bare `return;` + /// -- invalid in a non-void function. + EXPECT_TRUE(victim.backend().head(victim.layout().manifestKey(id)).exists); + return id; +} + +/// THE MANIFEST-DEBRIS DRAIN NO LONGER DELETES, AND THE FIXTURES BELOW SAY SO RATHER THAN WORKING +/// AROUND IT. The drain goes through `sweepNamespace`, which is subject to the §6 deletion premise: a +/// manifest of an epoch-`E` build is deletable only once the namespace's sealed fold cursor sits in an +/// epoch STRICTLY above `E`. Every object in these fixtures -- the table's ref stream, the debris, and +/// the removal transaction decommission itself appends -- lives in ONE writer epoch, and a single-epoch +/// pool cannot satisfy that: any cursor high enough to clear the debris's epoch also sits above the +/// removal record, which would strip the tail-removal protection off the table's real manifests. The +/// two facts are mutually exclusive here, so there is no honest seeding that restores the deletions; +/// the tests assert retention, and the drain's reclaim path returns with registers R2/R3 (Stage B). + +} + +TEST(CASDecommission, RefusesLiveMember) +{ + auto backend = std::make_shared(); + auto victim = openVictim(backend); /// keeps its mount lease unexpired — the member is alive + + expectThrowsCode(ErrorCodes::ABORTED, [&] + { + Pool::openForDecommission(backend, PoolConfig{.pool_prefix = "p", .server_root_id = "admin"}, "victim"); + }); +} + +TEST(CASDecommission, ClaimsDeadMemberAndBumpsEpoch) +{ + auto backend = std::make_shared(); + uint64_t victim_epoch = 0; + { + auto victim = openVictim(backend); + victim_epoch = victim->writerEpoch(); + } /// graceful close: lease stamped already-expired + farewell — the slot is claimable + + auto admin = Pool::openForDecommission(backend, PoolConfig{.pool_prefix = "p", .server_root_id = "admin"}, "victim"); + ASSERT_TRUE(admin != nullptr); + EXPECT_GT(admin->writerEpoch(), victim_epoch); + /// The admin store IS the victim server root now (impersonation). + EXPECT_EQ(admin->poolConfig().server_root_id, "victim"); +} + +TEST(CASDecommission, AlwaysRenewsAdminClaimEvenWhenHostDiskIsObserveOnly) +{ + auto backend = std::make_shared(); + { + auto victim = openVictim(backend); + } /// graceful close: lease stamped already-expired + farewell — the slot is claimable + + /// The calling (host) disk may be observe-only, i.e. its own PoolConfig carries + /// background_watermark = false. The decommission admin claim must renew its lease + /// regardless -- a long drain must not expire midway just because the host mount doesn't + /// run a background renewer for its OWN mount. + auto admin = Pool::openForDecommission( + backend, PoolConfig{.pool_prefix = "p", .server_root_id = "admin", .background_watermark = false}, "victim"); + ASSERT_TRUE(admin != nullptr); + EXPECT_TRUE(admin->poolConfig().background_watermark); +} + +TEST(CASDecommission, RefusesUnknownMember) +{ + auto backend = std::make_shared(); + expectThrowsCode(ErrorCodes::BAD_ARGUMENTS, [&] + { + Pool::openForDecommission(backend, PoolConfig{.pool_prefix = "p", .server_root_id = "admin"}, "never_existed"); + }); +} + +TEST(CASDecommission, SecondConcurrentDecommissionRefused) +{ + auto backend = std::make_shared(); + { auto victim = openVictim(backend); } + + auto first = Pool::openForDecommission(backend, PoolConfig{.pool_prefix = "p", .server_root_id = "admin"}, "victim"); + expectThrowsCode(ErrorCodes::ABORTED, [&] + { + Pool::openForDecommission(backend, PoolConfig{.pool_prefix = "p", .server_root_id = "admin2"}, "victim"); + }); +} + +TEST(CASDecommission, DuplicateLifeIdRefusesBeforeAnyNamespaceOrSlotMutation) +{ + auto backend = std::make_shared(); + { auto victim = openVictim(backend); } + const Layout layout("p"); + RefCatalog catalog; + catalog.entries = { + CatalogEntry{.ns = RootNamespace{"victim/a"}, .state = NsState::Live, .incarnation = UInt128{77}}, + CatalogEntry{ + .ns = RootNamespace{"victim/b"}, + .state = NsState::Removing, + .incarnation = UInt128{77}, + .removal_started_round = 1}, + }; + const auto empty_catalog = backend->get(layout.refCatalogKey()); + ASSERT_TRUE(empty_catalog); + ASSERT_EQ(backend->putOverwrite( + layout.refCatalogKey(), encodeRefCatalog(catalog), empty_catalog->token).outcome, PutOutcome::Done); + const auto owner_before = backend->get(layout.ownerKey("victim")); + const auto epoch_before = backend->get(layout.epochKey("victim")); + const auto mount_before = backend->get(layout.mountKey("victim")); + ASSERT_TRUE(owner_before); + ASSERT_TRUE(epoch_before); + ASSERT_TRUE(mount_before); + + EXPECT_THROW(decommissionPoolMember( + backend, PoolConfig{.pool_prefix = "p", .server_root_id = "admin"}, "victim"), DB::Exception); + const auto owner_after = backend->get(layout.ownerKey("victim")); + const auto epoch_after = backend->get(layout.epochKey("victim")); + const auto mount_after = backend->get(layout.mountKey("victim")); + ASSERT_TRUE(owner_after); + ASSERT_TRUE(epoch_after); + ASSERT_TRUE(mount_after); + EXPECT_EQ(owner_after->bytes, owner_before->bytes); + EXPECT_EQ(owner_after->token, owner_before->token); + EXPECT_EQ(epoch_after->bytes, epoch_before->bytes); + EXPECT_EQ(epoch_after->token, epoch_before->token); + EXPECT_EQ(mount_after->bytes, mount_before->bytes); + EXPECT_EQ(mount_after->token, mount_before->token); +} + +TEST(CASDecommission, CatalogCutIsValidatedBeforeImpersonationAndReusedForSelection) +{ + auto backend = std::make_shared(); + { auto victim = Pool::open(backend, PoolConfig{.pool_prefix = "p", .server_root_id = "victim"}); } + const Layout layout("p"); + + RefCatalog ambiguous; + ambiguous.entries = { + CatalogEntry{.ns = RootNamespace{"other/a"}, .state = NsState::Live, .incarnation = UInt128{77}}, + CatalogEntry{ + .ns = RootNamespace{"other/b"}, + .state = NsState::Removing, + .incarnation = UInt128{77}, + .removal_started_round = 1}, + }; + + const auto owner_before = backend->get(layout.ownerKey("victim")); + const auto epoch_before = backend->get(layout.epochKey("victim")); + const auto mount_before = backend->get(layout.mountKey("victim")); + ASSERT_TRUE(owner_before); + ASSERT_TRUE(epoch_before); + ASSERT_TRUE(mount_before); + backend->armCatalogReplacement(layout.refCatalogKey(), std::move(ambiguous)); + + EXPECT_THROW(decommissionPoolMember( + backend, PoolConfig{.pool_prefix = "p", .server_root_id = "admin"}, "victim"), DB::Exception); + ASSERT_TRUE(backend->fired()); + + const auto owner_after = backend->get(layout.ownerKey("victim")); + const auto epoch_after = backend->get(layout.epochKey("victim")); + const auto mount_after = backend->get(layout.mountKey("victim")); + ASSERT_TRUE(owner_after); + ASSERT_TRUE(epoch_after); + ASSERT_TRUE(mount_after); + EXPECT_EQ(owner_after->bytes, owner_before->bytes); + EXPECT_EQ(owner_after->token, owner_before->token); + EXPECT_EQ(epoch_after->bytes, epoch_before->bytes); + EXPECT_EQ(epoch_after->token, epoch_before->token); + EXPECT_EQ(mount_after->bytes, mount_before->bytes); + EXPECT_EQ(mount_after->token, mount_before->token); +} + +TEST(CASDecommission, NamespaceSelectionUsesThePreImpersonationCut) +{ + auto backend = std::make_shared(); + { auto victim = Pool::open(backend, PoolConfig{.pool_prefix = "p", .server_root_id = "victim"}); } + const Layout layout("p"); + + RefCatalog later; + later.entries = { + CatalogEntry{.ns = RootNamespace{"victim/late"}, .state = NsState::Live, .incarnation = UInt128{88}}, + }; + backend->armCatalogReplacement(layout.refCatalogKey(), std::move(later)); + + const DecommissionReport report = decommissionPoolMember( + backend, PoolConfig{.pool_prefix = "p", .server_root_id = "admin"}, "victim"); + ASSERT_TRUE(backend->fired()); + EXPECT_EQ(report.namespaces_removed, 0u) + << "a namespace visible only to mount safety's later observation is outside the validated cut"; + EXPECT_EQ(report.namespaces_already_removed, 0u); +} + +TEST(CASDecommission, SameNameRebirthAfterTheCutIsRefusedWithoutTouchingTheNewLife) +{ + auto backend = std::make_shared(); + { auto victim = Pool::open(backend, PoolConfig{.pool_prefix = "p", .server_root_id = "victim"}); } + const Layout layout("p"); + const RootNamespace ns{"victim/same"}; + const NamespaceLifeId old_life = NamespaceLifeId::fromCatalogEntry(ns, UInt128{70}); + const NamespaceLifeId new_life = NamespaceLifeId::fromCatalogEntry(ns, UInt128{71}); + + RefCatalog old_catalog; + old_catalog.entries = { + CatalogEntry{.ns = ns, .state = NsState::Live, .incarnation = old_life.incarnation}, + }; + const auto empty_catalog = backend->get(layout.refCatalogKey()); + ASSERT_TRUE(empty_catalog); + ASSERT_EQ(backend->putOverwrite( + layout.refCatalogKey(), encodeRefCatalog(old_catalog), empty_catalog->token).outcome, + PutOutcome::Done); + + RefLogTxn new_birth; + new_birth.ns = ns.string(); + new_birth.txn_id = RefTxnId{1, 1}; + new_birth.ops = {namespaceBirthOp()}; + ASSERT_EQ(backend->putIfAbsent( + layout.refLogKey(new_life, new_birth.txn_id), + sealObject(FormatId::RefLog, encodeRefLogTxn(new_birth))).outcome, + PutOutcome::Done); + RefLogTxn new_seal; + new_seal.ns = ns.string(); + new_seal.txn_id = RefTxnId{1, 2}; + new_seal.ops = {epochSealOp()}; + ASSERT_EQ(backend->putIfAbsent( + layout.refLogKey(new_life, new_seal.txn_id), + sealObject(FormatId::RefLog, encodeRefLogTxn(new_seal))).outcome, + PutOutcome::Done); + const auto new_life_before = snapshotPrefixObjects(*backend, layout.namespaceStreamPrefix(new_life)); + + RefCatalog replacement; + replacement.entries = { + CatalogEntry{.ns = ns, .state = NsState::Live, .incarnation = new_life.incarnation}, + }; + /// Read 1 captures the immutable selection cut. Read 2 is mount safety; replace immediately + /// after returning that old observation, so the name-only call is the first consumer of the + /// same-name new incarnation. + backend->armCatalogReplacement( + layout.refCatalogKey(), std::move(replacement), /*completed_reads_before_replacement=*/1); + + String refusal; + try + { + (void)decommissionPoolMember( + backend, PoolConfig{.pool_prefix = "p", .server_root_id = "admin"}, "victim"); + } + catch (const DB::Exception & e) + { + refusal = e.message(); + } + EXPECT_NE(refusal.find("changed incarnation after the validated catalog cut"), String::npos) + << refusal; + ASSERT_TRUE(backend->fired()); + EXPECT_EQ(snapshotPrefixObjects(*backend, layout.namespaceStreamPrefix(new_life)), new_life_before) + << "decommission must not append a removal transaction to the post-cut incarnation"; +} + +TEST(CASDecommission, VictimNameMatchesOneCanonicalPathComponent) +{ + auto backend = std::make_shared(); + { auto victim = openVictim(backend); } + + const RootNamespace neighbor_ns{"victim2/db/t1"}; + { + auto neighbor = Pool::open(backend, PoolConfig{.pool_prefix = "p", .server_root_id = "victim2"}); + makeTableWithRefs(*neighbor, neighbor_ns.string(), /*committed=*/1, /*precommits=*/0); + } + + const auto report = decommissionPoolMember( + backend, PoolConfig{.pool_prefix = "p", .server_root_id = "admin"}, "victim"); + EXPECT_EQ(report.namespaces_removed, 0u); + + auto neighbor = Pool::open(backend, PoolConfig{.pool_prefix = "p", .server_root_id = "victim2"}); + EXPECT_EQ(neighbor->listRefs(neighbor_ns).size(), 1u) + << "decommissioning victim must not select victim2 by raw string prefix"; +} + +TEST(CASDecommission, ErasesAllVictimNamespaces) +{ + auto backend = std::make_shared(); + { + auto victim = openVictim(backend); + /// Two tables: ns "victim/db/t1" with 2 committed refs, ns "victim/db/t2" with 1 committed + /// ref + 1 stale precommit (fixture idiom of gtest_cas_ref_writer.cpp). + makeTableWithRefs(*victim, "victim/db/t1", /*committed=*/2, /*precommits=*/0); + makeTableWithRefs(*victim, "victim/db/t2", /*committed=*/1, /*precommits=*/1); + } + + const auto report = decommissionPoolMember( + backend, PoolConfig{.pool_prefix = "p", .server_root_id = "admin"}, "victim"); + + EXPECT_EQ(report.srid, "victim"); + EXPECT_EQ(report.namespaces_removed, 2u); + EXPECT_EQ(report.namespaces_already_removed, 0u); + EXPECT_EQ(report.committed_refs_removed, 3u); + EXPECT_EQ(report.precommits_removed, 1u); + EXPECT_EQ(report.edge_deltas_emitted, 4u); + + /// Terminal publication is writer work; exact catalog-row deletion remains GC work. The first + /// command therefore keeps the slot as an ownership anchor, and a retry may retire it only after + /// GC's next invocation drains the completed `Removing` rows. + EXPECT_FALSE(report.warnings.empty()); + EXPECT_FALSE(report.slot_removed); + drainCompletedNamespaceRemovals(backend); + const auto retired = decommissionPoolMember( + backend, PoolConfig{.pool_prefix = "p", .server_root_id = "admin2"}, "victim"); + EXPECT_TRUE(retired.warnings.empty()); + EXPECT_TRUE(retired.slot_removed); +} + +/// Task 2 review finding 1: `makeTableWithRefs`'s precommit seed uses an artificially high +/// `writer_epoch` (999999) specifically to dodge the writer's OWN stale-precommit sweep -- which +/// means it never exercised the path a REAL victim precommit takes. A genuine writer stamps +/// `manifest_ref.writer_epoch` from its OWN `liveWriterEpoch()` at precommit time +/// (`PartWriteTxn::precommitAdd`, CasPool.cpp:2087), i.e. the victim's era -- always LOWER than the admin +/// mount's freshly-minted epoch (`openForDecommission` always bumps strictly higher). `appendRefOps` +/// hoists `maybeSweepStalePrecommits` at its top (CasPool.cpp:1716), so without the +/// `skip_stale_precommit_sweep` fix that sweep would reclaim this realistic-epoch precommit in its +/// OWN transaction before `dropNamespace`'s removal transaction ever counts it, leaving +/// `precommits_removed` at 0 for exactly the case that matters. +TEST(CASDecommission, CountsRealisticEpochPrecommit) +{ + auto backend = std::make_shared(); + uint64_t victim_epoch = 0; + { + auto victim = openVictim(backend); + victim_epoch = victim->writerEpoch(); + makeTableWithRefs(*victim, "victim/db/t1", /*committed=*/1, /*precommits=*/0); + + const RootNamespace ns("victim/db/t1"); + /// `build_sequence = 2`: distinct from `makeTableWithRefs`'s committed ref (`build_sequence = 1`) + /// -- a REAL build's `ManifestRef` is unique per build, and a colliding one would trip the ref + /// state machine's "manifest already has a conflicting owner" guard. + const ManifestRef ref{.writer_epoch = victim_epoch, .build_sequence = 2, .manifest_ordinal = 1}; + writeManifestRaw(victim->backend(), victim->layout(), ns, ref, {}); + addPrecommitTransition(victim->backend(), victim->layout(), ns, UInt128(1), "precommit_0", std::nullopt, ref); + } + + const auto report = decommissionPoolMember( + backend, PoolConfig{.pool_prefix = "p", .server_root_id = "admin"}, "victim"); + + EXPECT_EQ(report.namespaces_removed, 1u); + EXPECT_EQ(report.committed_refs_removed, 1u); + EXPECT_EQ(report.precommits_removed, 1u); + EXPECT_EQ(report.edge_deltas_emitted, 2u); +} + +/// Task 2 review finding 2: the `member_decommission` begin/namespace_removed/end events +/// (CasDecommission.cpp) had no assertion at all. Wire a capturing sink (the `gtest_cas_event_log.cpp` +/// idiom) into `decommissionPoolMember` and check the emitted sequence and its per-namespace detail. +TEST(CASDecommission, EmitsMemberDecommissionEvents) +{ + auto backend = std::make_shared(); + { + auto victim = openVictim(backend); + makeTableWithRefs(*victim, "victim/db/t1", /*committed=*/1, /*precommits=*/0); + } + + std::vector seen; + (void)decommissionPoolMember(backend, PoolConfig{.pool_prefix = "p", .server_root_id = "admin"}, "victim", + [&](const CasEvent & e) { seen.push_back(e); }); + + std::vector member_events; + for (const auto & e : seen) + if (e.type == CasEventType::MemberDecommission) + member_events.push_back(e); + + ASSERT_EQ(member_events.size(), 3u); + EXPECT_EQ(member_events[0].outcome, "begin"); + EXPECT_EQ(member_events[1].outcome, "namespace_removed"); + EXPECT_EQ(member_events[1].detail.at("namespace"), "victim/db/t1"); + EXPECT_EQ(member_events[1].detail.at("committed"), "1"); + EXPECT_EQ(member_events[1].detail.at("precommits"), "0"); + EXPECT_EQ(member_events[2].outcome, "end"); + EXPECT_EQ(member_events[2].detail.at("namespaces_removed"), "1"); +} + +/// Task 3: the manifest-debris / staging / roots drain phases fill their three `DecommissionReport` +/// counters and leave nothing of the victim behind under `staging/` or `roots/`. +TEST(CASDecommission, DrainsDebrisStagingAndRoots) +{ + auto backend = std::make_shared(); + { + auto victim = openVictim(backend); + makeTableWithRefs(*victim, "victim/db/t1", 1, 0); + seedOrphanManifestBody(*victim, "victim/db/t1"); + } + /// Foreign staging + mountpoint objects, written raw (no writer machinery needed): the victim's + /// writers are fenced by the claim before decommission ever gets here, so these are ordinary debris, + /// not a live in-flight write. + backend->putIfAbsent("p/staging/victim/upload1.tmp", "x"); + backend->putIfAbsent("p/staging/victim/upload2.tmp", "x"); + backend->putIfAbsent("p/roots/victim/clickhouse_access_check_abc", "x"); + + const auto report = decommissionPoolMember( + backend, PoolConfig{.pool_prefix = "p", .server_root_id = "admin"}, "victim"); + + /// The staging and mountpoint phases are unchanged and still drain completely. The manifest-debris + /// phase retains under the §6 premise (see the note on the helpers above) and reports why, which is + /// what keeps the slot; `RetainsDebrisWhoseEpochSealIsUnconsumed` is that outcome's own test. + EXPECT_EQ(report.manifest_debris_removed, 0u); + EXPECT_EQ(report.staging_objects_removed, 2u); + EXPECT_EQ(report.mountpoint_objects_removed, 1u); + EXPECT_FALSE(report.warnings.empty()) + << "the retained debris is reported, so the incomplete drain is visible"; + + /// Nothing of the victim remains under staging/ or roots/ (scoped LISTs are empty). Those two phases + /// run to completion even though the debris phase retained -- the drain is per-phase, not all-or-nothing. + EXPECT_TRUE(backend->list("p/staging/victim/", "", 10).keys.empty()); + EXPECT_TRUE(backend->list("p/roots/victim/", "", 10).keys.empty()); +} + +/// The §6 deletion premise applies to the decommission drain too, and this pins what that COSTS. With no +/// sealed fold cursor for the victim's namespace — the state of a pool whose GC has not folded past the +/// victim's closed epoch — the drain cannot show the debris is unreferenced, so it RETAINS it, says why +/// in `warnings`, and therefore keeps the slot for a later re-run. Delay, not damage: the objects are +/// untouched and a re-run after GC catches up drains them (`DrainsDebrisStagingAndRoots`). +/// +/// This is the visible edge of a real Stage-A limitation, not a test-only artifact: debris under a +/// namespace GC never folds — the pure pre-precommit orphan, whose whole point is that no ref record was +/// ever appended for it — has no cursor to consume any seal, so the premise retains it indefinitely. +/// Reclaiming it needs the sweep's own rework (registers R2/R3, Stage B), which is why the premise ships +/// as the safety floor and not as the reclaim policy. +TEST(CASDecommission, RetainsDebrisWhoseEpochSealIsUnconsumed) +{ + auto backend = std::make_shared(); + String debris_key; + { + auto victim = openVictim(backend); + makeTableWithRefs(*victim, "victim/db/t1", 1, 0); + const ManifestId debris_id = seedOrphanManifestBody(*victim, "victim/db/t1"); + debris_key = victim->layout().manifestKey(debris_id); + /// Deliberately NO `seedFoldedPastVictimEpoch` here — that absence is the subject. + } + + const auto report = decommissionPoolMember( + backend, PoolConfig{.pool_prefix = "p", .server_root_id = "admin"}, "victim"); + + EXPECT_EQ(report.manifest_debris_removed, 0u); + EXPECT_TRUE(backend->head(debris_key).exists) + << "the body is retained untouched, not deleted and not corrupted"; + ASSERT_FALSE(report.warnings.empty()) + << "a retained manifest is a visible decision -- the operator must be able to see why the drain " + "did not complete"; + bool named = false; + for (const String & w : report.warnings) + if (w.find(debris_key) != String::npos && w.find("seal") != String::npos) + named = true; + EXPECT_TRUE(named) << "the warning names the object and the premise that retained it"; + EXPECT_FALSE(report.slot_removed) + << "an incomplete drain keeps the slot as the resume anchor, exactly as a per-key failure does"; +} + +/// Task 3 fail-close nuance (spec §core "Fail-close"): a per-object failure in the staging/roots drain +/// -- a thrown exception (a transient hiccup) or a `TokenMismatch` outcome (a "listed but raced" miss) +/// -- must record a warning and let the rest of the sweep proceed, never abort the whole phase or the +/// whole command. One staging object throws, the roots object comes back `TokenMismatch`; the OTHER +/// staging object must still be deleted and counted. +TEST(CASDecommission, PerObjectFailureWarnsAndContinuesDrain) +{ + auto backend = std::make_shared(); + { + auto victim = openVictim(backend); + makeTableWithRefs(*victim, "victim/db/t1", 1, 0); + } + backend->putIfAbsent("p/staging/victim/upload_ok.tmp", "x"); + backend->putIfAbsent("p/staging/victim/upload_throws.tmp", "x"); + backend->putIfAbsent("p/roots/victim/clickhouse_access_check_abc", "x"); + backend->failWithThrow("p/staging/victim/upload_throws.tmp"); + backend->failWithTokenMismatch("p/roots/victim/clickhouse_access_check_abc"); + + const auto report = decommissionPoolMember( + backend, PoolConfig{.pool_prefix = "p", .server_root_id = "admin"}, "victim"); + + EXPECT_EQ(report.staging_objects_removed, 1u) + << "the OTHER staging object must still be deleted despite the injected failure on its sibling"; + EXPECT_EQ(report.mountpoint_objects_removed, 0u); + EXPECT_EQ(report.warnings.size(), 2u) + << "one warning for the thrown exception, one for the TokenMismatch outcome"; + + EXPECT_FALSE(backend->head("p/staging/victim/upload_ok.tmp").exists) + << "the healthy staging object was actually deleted, not merely skipped"; + EXPECT_TRUE(backend->head("p/staging/victim/upload_throws.tmp").exists) + << "the failing object is left behind (untouched) so a re-run can retry it"; + EXPECT_TRUE(backend->head("p/roots/victim/clickhouse_access_check_abc").exists) + << "TokenMismatch means nothing was actually deleted -- the object survives"; +} + +/// Opaque physical debris carries no logical owner and therefore cannot widen or redirect +/// decommission's catalog-derived victim set. Task 5's ownership-tree janitor owns that debris. +TEST(CASDecommission, LifelessPhysicalKeyCannotRedirectCatalogOwnedDecommission) +{ + auto backend = std::make_shared(); + String lifeless; + { + auto victim = openVictim(backend); + makeTableWithRefs(*victim, "victim/db/t1", 1, 0); + /// Hand-built: no helper can mint the un-incarnated shape any more. + lifeless = victim->layout().casRefsPrefix() + String("victim/db/t1/_log/") + + renderRefTxnId(RefTxnId{1, 1}) + ".zst"; + ASSERT_EQ(backend->putIfAbsent(lifeless, "garbage").outcome, PutOutcome::Done); + } + + const auto report = decommissionPoolMember( + backend, PoolConfig{.pool_prefix = "p", .server_root_id = "admin"}, "victim"); + EXPECT_EQ(report.namespaces_removed, 1u); + EXPECT_TRUE(backend->head(lifeless).exists) + << "decommission must neither adopt nor delete an unowned physical life key"; +} + +/// The manifest-debris drain honors the same tolerate-and-continue contract as +/// `deleteListedPrefix`: a per-key `deleteExact` failure becomes a warning, while the namespace +/// erasure and subsequent staging drain continue. Protection reads now use opaque physical life +/// prefixes, so a logical-name substring can no longer target an otherwise unlisted namespace. +TEST(CASDecommission, ManifestDebrisDeleteFailureWarnsAndContinues) +{ + auto backend = std::make_shared(); + String debris_key; + { + auto victim = openVictim(backend); + makeTableWithRefs(*victim, "victim/db/t1", 1, 0); + const ManifestId debris_id = seedOrphanManifestBody(*victim, "victim/db/t1"); + debris_key = victim->layout().manifestKey(debris_id); + } + backend->failWithThrow(debris_key); + backend->putIfAbsent("p/staging/victim/upload_ok.tmp", "x"); + + const auto report = decommissionPoolMember( + backend, PoolConfig{.pool_prefix = "p", .server_root_id = "admin"}, "victim"); + + EXPECT_EQ(report.namespaces_removed, 1u) + << "victim/db/t1's namespace erasure (Task 2) is untouched by either injected failure"; + EXPECT_EQ(report.manifest_debris_removed, 0u); + EXPECT_EQ(report.warnings.size(), 1u) + << "the thrown per-key delete must keep the retirement tail fail-closed"; + EXPECT_EQ(report.staging_objects_removed, 1u) + << "the staging phase still ran to completion after the manifest-debris phase's failures -- " + "the whole command did not abort"; + + EXPECT_TRUE(backend->head(debris_key).exists) + << "the failing object is left behind (untouched) so a re-run can retry it"; +} + +/// GC owns the completed catalog-row deletion. Once it drains the row, a clean decommission retry +/// removes the mutable slot objects and tombstones the owner anchor. +TEST(CASDecommission, RemovesMutableSlotAndRefusesTombstonedRerun) +{ + auto backend = std::make_shared(); + { + auto victim = openVictim(backend); + makeTableWithRefs(*victim, "victim/db/t1", 1, 0); + } + const auto pending = decommissionPoolMember( + backend, PoolConfig{.pool_prefix = "p", .server_root_id = "admin"}, "victim"); + EXPECT_FALSE(pending.slot_removed); + EXPECT_FALSE(pending.warnings.empty()); + drainCompletedNamespaceRemovals(backend); + const auto report = decommissionPoolMember( + backend, PoolConfig{.pool_prefix = "p", .server_root_id = "admin2"}, "victim"); + EXPECT_TRUE(report.slot_removed); + EXPECT_TRUE(report.warnings.empty()); + EXPECT_FALSE(backend->get("p/gc/server-roots/victim/mount").has_value()); + const auto owner = backend->get("p/gc/server-roots/victim/owner"); + ASSERT_TRUE(owner.has_value()); + EXPECT_TRUE(decodeOwner(owner->bytes).retired_at_ms.has_value()); + EXPECT_FALSE(backend->get("p/gc/server-roots/victim/epoch").has_value()); + + expectThrowsCode(ErrorCodes::CORRUPTED_DATA, [&] + { + decommissionPoolMember(backend, PoolConfig{.pool_prefix = "p", .server_root_id = "a2"}, "victim"); + }); +} + +/// Triage #9: a successor may reclaim the same UUID immediately after the decommission admin writes +/// its farewell. The retirement tail must use the farewell/claimed-epoch tokens captured around that +/// release, delete `mount` first, and stop on its `TokenMismatch`; re-reading current tokens would +/// delete the live successor's control objects and falsely report the slot removed. +TEST(CASDecommission, SuccessorReclaimFencesSlotRetirementTail) +{ + auto backend = std::make_shared(); + { auto victim = openVictim(backend); } + backend->armForSuccessorReclaim(); + + std::vector seen; + const auto report = decommissionPoolMember( + backend, PoolConfig{.pool_prefix = "p", .server_root_id = "admin"}, "victim", + [&](const CasEvent & event) { seen.push_back(event); }); + + ASSERT_TRUE(backend->successorInjected()); + EXPECT_FALSE(report.slot_removed); + ASSERT_EQ(report.warnings.size(), 1u); + EXPECT_NE(report.warnings.front().find("p/gc/server-roots/victim/mount"), String::npos); + EXPECT_NE(report.warnings.front().find("replaced"), String::npos); + + const auto mount = backend->get("p/gc/server-roots/victim/mount"); + ASSERT_TRUE(mount.has_value()); + EXPECT_EQ(mount->token, backend->successorMountToken()); + EXPECT_EQ(mount->bytes, backend->successorMountBytes()); + + const auto epoch = backend->get("p/gc/server-roots/victim/epoch"); + ASSERT_TRUE(epoch.has_value()); + EXPECT_EQ(epoch->token, backend->successorEpochToken()); + EXPECT_EQ(epoch->bytes, backend->successorEpochBytes()); + EXPECT_TRUE(backend->get("p/gc/server-roots/victim/owner").has_value()); + + ASSERT_FALSE(seen.empty()); + EXPECT_EQ(seen.back().outcome, "end"); + EXPECT_EQ(seen.back().detail.at("slot_removed"), "0"); +} + +/// A successor can also restart after both stale mutable objects were deleted but before `owner` is +/// retired. Mere presence of either freshly recreated mutable object must stop owner retirement. +TEST(CASDecommission, SuccessorReclaimAfterEpochDeleteKeepsOwnerAnchor) +{ + auto backend = std::make_shared(); + { auto victim = openVictim(backend); } + + const String owner_key = "p/gc/server-roots/victim/owner"; + const auto original_owner = backend->get(owner_key); + ASSERT_TRUE(original_owner.has_value()); + backend->armForSuccessorReclaim(); + + const auto report = decommissionPoolMember( + backend, PoolConfig{.pool_prefix = "p", .server_root_id = "admin"}, "victim"); + + ASSERT_TRUE(backend->successorInjected()); + EXPECT_FALSE(report.slot_removed); + EXPECT_FALSE(report.warnings.empty()); + EXPECT_EQ(backend->ownerRewriteAttempts(), 0u); + + const auto owner = backend->get(owner_key); + ASSERT_TRUE(owner.has_value()); + EXPECT_EQ(owner->token, original_owner->token); + EXPECT_EQ(owner->bytes, original_owner->bytes); + + const auto mount = backend->get("p/gc/server-roots/victim/mount"); + ASSERT_TRUE(mount.has_value()); + EXPECT_EQ(mount->token, backend->successorMountToken()); + EXPECT_EQ(mount->bytes, backend->successorMountBytes()); + + const auto epoch = backend->get("p/gc/server-roots/victim/epoch"); + ASSERT_TRUE(epoch.has_value()); + EXPECT_EQ(epoch->token, backend->successorEpochToken()); + EXPECT_EQ(epoch->bytes, backend->successorEpochBytes()); +} + +/// Triage #9 control: absent a successor interleaving, the fenced tail removes both mutable control +/// objects, tombstones the owner anchor, and preserves the existing successful `slot_removed=1` result. +TEST(CASDecommission, FencedSlotRetirementTailRetiresUncontendedSlot) +{ + auto backend = std::make_shared(); + { auto victim = openVictim(backend); } + + const auto report = decommissionPoolMember( + backend, PoolConfig{.pool_prefix = "p", .server_root_id = "admin"}, "victim"); + + EXPECT_TRUE(report.warnings.empty()); + EXPECT_TRUE(report.slot_removed); + EXPECT_FALSE(backend->get("p/gc/server-roots/victim/mount").has_value()); + EXPECT_FALSE(backend->get("p/gc/server-roots/victim/epoch").has_value()); + const auto owner = backend->get("p/gc/server-roots/victim/owner"); + ASSERT_TRUE(owner.has_value()); + EXPECT_TRUE(decodeOwner(owner->bytes).retired_at_ms.has_value()); +} + +TEST(CASDecommission, SuccessfulDecommissionLeavesTombstonedOwnerAnchor) +{ + auto backend = std::make_shared(); + { auto victim = openVictim(backend); } + + const String owner_key = "p/gc/server-roots/victim/owner"; + const auto before = backend->get(owner_key); + ASSERT_TRUE(before.has_value()); + EXPECT_FALSE(decodeOwner(before->bytes).retired_at_ms.has_value()); + + const auto report = decommissionPoolMember( + backend, PoolConfig{.pool_prefix = "p", .server_root_id = "admin"}, "victim"); + + EXPECT_TRUE(report.warnings.empty()); + EXPECT_TRUE(report.slot_removed); + const auto after = backend->get(owner_key); + ASSERT_TRUE(after.has_value()); + EXPECT_NE(after->token, before->token); + EXPECT_EQ(decodeOwner(after->bytes).server_uuid, decodeOwner(before->bytes).server_uuid); + EXPECT_TRUE(decodeOwner(after->bytes).retired_at_ms.has_value()); +} + +TEST(CASDecommission, SuccessorOwnerRewriteWinsBeforeTombstone) +{ + auto backend = std::make_shared(); + { auto victim = openVictim(backend); } + backend->armForSuccessorRewrite(); + + const auto report = decommissionPoolMember( + backend, PoolConfig{.pool_prefix = "p", .server_root_id = "admin"}, "victim"); + + ASSERT_TRUE(backend->successorInjected()); + EXPECT_FALSE(report.slot_removed); + ASSERT_EQ(report.warnings.size(), 1u); + EXPECT_NE(report.warnings.front().find("successor reclaimed"), String::npos); + + const auto owner = backend->get("p/gc/server-roots/victim/owner"); + ASSERT_TRUE(owner.has_value()); + EXPECT_EQ(owner->token, backend->successorOwnerToken()); + EXPECT_EQ(owner->bytes, backend->successorOwnerBytes()); + EXPECT_FALSE(decodeOwner(owner->bytes).retired_at_ms.has_value()); +} + +/// Final whole-branch review finding (Important): +/// a transient exception on the owner tombstone write must not be reported as a hard failure when the +/// write actually landed -- the controlled overwrite resolves this via GET (current bytes already +/// match the intended tombstone) instead of the old bare putOverwrite's "any exception = failure". +TEST(CASDecommission, OwnerTombstoneAmbiguousSuccessResolvesToCommitted) +{ + auto backend = std::make_shared(); + { auto victim = openVictim(backend); } + backend->armForAmbiguousTombstone(); + + const auto report = decommissionPoolMember( + backend, PoolConfig{.pool_prefix = "p", .server_root_id = "admin"}, "victim"); + + EXPECT_TRUE(report.slot_removed) << "the ambiguous write actually landed and must resolve to Committed"; + EXPECT_TRUE(report.warnings.empty()); + + const auto owner = backend->get("p/gc/server-roots/victim/owner"); + ASSERT_TRUE(owner.has_value()); + EXPECT_TRUE(decodeOwner(owner->bytes).retired_at_ms.has_value()); +} + +/// Delegates every op to `inner`, except `deleteExact`: while `armed`, any key starting with +/// `fail_prefix` throws an injected transient failure instead of deleting -- models a real backend +/// transiently failing to delete under one whole prefix. `disarm()` clears the failure (the resume +/// half of `FailedDrainKeepsSlotThenResumes`). Forwards every pure-virtual `Backend` member (the +/// `CasBackend.h` list) to `inner` untouched. +class FailDeletesUnderPrefixBackend : public Backend +{ +public: + using Backend::get; + using Backend::getStream; + using Backend::putIfAbsent; + using Backend::putIfAbsentStream; + using Backend::putOverwrite; + using Backend::casPut; + + FailDeletesUnderPrefixBackend(std::shared_ptr inner_, String fail_prefix_) + : inner(std::move(inner_)), fail_prefix(std::move(fail_prefix_)) + { + } + + void disarm() { armed = false; } + + std::optional get(const String & key, Range range) override { return inner->get(key, range); } + std::optional getStream(const String & key, Range range) override { return inner->getStream(key, range); } + HeadResult head(const String & key) override { return inner->head(key); } + PutResult putIfAbsent(const String & key, const String & bytes, const ObjectMeta & meta) override + { + return inner->putIfAbsent(key, bytes, meta); + } + WriteSinkPtr putIfAbsentStream(const String & key, const ObjectMeta & meta) override + { + return inner->putIfAbsentStream(key, meta); + } + PutResult putOverwrite(const String & key, const String & bytes, const Token & expected, const ObjectMeta & meta) override + { + return inner->putOverwrite(key, bytes, expected, meta); + } + CasResult casPut(const String & key, const String & bytes, const std::optional & expected, const ObjectMeta & meta) override + { + return inner->casPut(key, bytes, expected, meta); + } + DeleteOutcome deleteExact(const String & key, const Token & token) override + { + if (armed && key.starts_with(fail_prefix)) + throw Exception(ErrorCodes::S3_ERROR, "injected transient delete failure for {}", key); + return inner->deleteExact(key, token); + } + ListPage list(const String & prefix, const String & cursor, size_t limit) override { return inner->list(prefix, cursor, limit); } + bool supportsListTokens() const override { return inner->supportsListTokens(); } + +private: + std::shared_ptr inner; + String fail_prefix; + bool armed = true; +}; + +/// Task 4 fail-close: a drain failure under the roots prefix keeps the slot terminated-but-present +/// (`report.slot_removed == false`, the mount object survives as the resume anchor). Once the fault is +/// cleared, a re-run finishes the job: the already-erased namespace is counted as +/// `namespaces_already_removed`, the leftover roots object is finally swept, and the slot is removed. +TEST(CASDecommission, FailedDrainKeepsSlotThenResumes) +{ + auto inner = std::make_shared(); + { + auto victim = Pool::open(inner, PoolConfig{.pool_prefix = "p", .server_root_id = "victim"}); + makeTableWithRefs(*victim, "victim/db/t1", 1, 0); + } + inner->putIfAbsent("p/roots/victim/loose_file", "x"); + + auto failing = std::make_shared(inner, "p/roots/victim/"); + const auto first = decommissionPoolMember( + failing, PoolConfig{.pool_prefix = "p", .server_root_id = "a1"}, "victim"); + EXPECT_FALSE(first.warnings.empty()); + EXPECT_FALSE(first.slot_removed); + EXPECT_TRUE(inner->get("p/gc/server-roots/victim/mount").has_value()) + << "slot kept -- resume anchor"; + + failing->disarm(); + const auto second = decommissionPoolMember( + failing, PoolConfig{.pool_prefix = "p", .server_root_id = "a2"}, "victim"); + EXPECT_FALSE(second.warnings.empty()); + EXPECT_FALSE(second.slot_removed); + EXPECT_EQ(second.namespaces_already_removed, 1u); + EXPECT_EQ(second.mountpoint_objects_removed, 1u); + + drainCompletedNamespaceRemovals(inner); + const auto third = decommissionPoolMember( + failing, PoolConfig{.pool_prefix = "p", .server_root_id = "a3"}, "victim"); + EXPECT_TRUE(third.warnings.empty()); + EXPECT_TRUE(third.slot_removed); +} + +/// Task 4 fail-close, manifest-debris variant (review follow-up: the plan's own example only exercises +/// a roots-phase failure). A per-key `deleteExact` throw inside the manifest-debris drain must ALSO +/// keep the slot: `report.slot_removed == false`, the mount object survives, and once the injected +/// failure is cleared a re-run drains the leftover debris and removes the slot. +TEST(CASDecommission, ManifestDebrisFailureKeepsSlotThenResumes) +{ + auto backend = std::make_shared(); + String debris_key; + { + auto victim = openVictim(backend); + makeTableWithRefs(*victim, "victim/db/t1", 1, 0); + const ManifestId debris_id = seedOrphanManifestBody(*victim, "victim/db/t1"); + debris_key = victim->layout().manifestKey(debris_id); + } + backend->failWithThrow(debris_key); + + const auto first = decommissionPoolMember( + backend, PoolConfig{.pool_prefix = "p", .server_root_id = "a1"}, "victim"); + EXPECT_FALSE(first.warnings.empty()); + EXPECT_FALSE(first.slot_removed); + EXPECT_EQ(first.manifest_debris_removed, 0u); + EXPECT_TRUE(backend->get("p/gc/server-roots/victim/mount").has_value()) + << "slot kept -- resume anchor"; + EXPECT_TRUE(backend->head(debris_key).exists) + << "the failing object is left behind (untouched) so a re-run can retry it"; + + /// COVERAGE LOST HERE, DELIBERATELY NAMED. Before the §6 premise, clearing the injected failure let + /// a re-run drain the debris and retire the slot, which is what proved the per-key fail-close path + /// RESUMES rather than merely refuses. Under the premise the sweep never reaches `deleteExact` for + /// this body at all (single-epoch pool -- see the note on the helpers above), so disarming changes + /// nothing and the resume half of this test is no longer expressible. What survives is the half that + /// still has a mechanism: the slot stays kept across the re-run, and the object stays untouched. + /// The resume assertion comes back with the drain's reclaim path (registers R2/R3, Stage B). + backend->disarm(); + const auto second = decommissionPoolMember( + backend, PoolConfig{.pool_prefix = "p", .server_root_id = "a2"}, "victim"); + EXPECT_EQ(second.namespaces_already_removed, 1u); + EXPECT_EQ(second.manifest_debris_removed, 0u); + EXPECT_FALSE(second.slot_removed); + EXPECT_TRUE(backend->head(debris_key).exists); + EXPECT_TRUE(backend->get("p/gc/server-roots/victim/mount").has_value()) + << "the slot is still the resume anchor -- nothing was retired against unreclaimed debris"; +} + +/// Task 5 (Task-1 carry-forward, escalated by review): preserve recovery from the legacy partial +/// hand-cleanup shape where owner and epoch are absent but the mount lease remains. Triage #9 changed +/// new retirements to delete `mountKey`/`epochKey` and tombstone `ownerKey`, so the current tail no +/// longer creates this shape, but `openForDecommission`'s owner-anchor-absent + +/// mount-lease-present fallback ("partial hand-cleanup: adopt from the lease", `CasPool.cpp`) remains +/// compatibility-critical for slots left by older binaries or manual repair. +/// +/// `claimOwnerOrThrow` (`CasServerRoot.cpp`) gates the owner-absent path a SECOND, stricter way: the +/// same catalog cut must name no `Creating`, `Live` or `Removing` namespace under this canonical root, +/// and the name-bearing `cas/manifests//` and `roots//` families must be empty. Opaque +/// stream/state debris cannot be attributed to a server root and is deliberately inert. This test +/// therefore uses a victim with NO namespaces at all: identity persisted +/// (mount/owner/epoch exist from a real graceful close), data subtree genuinely empty -- the exact +/// precondition the fallback is designed for. Simulate the crash directly: claim the slot once (exactly +/// `decommissionPoolMember`'s own first step), let it close gracefully (the mount-lease keeper's +/// farewell stamp, same as a real `admin.reset()`), then manually strike `epochKey`+`ownerKey`, leaving +/// `mountKey`. A `decommissionPoolMember` re-run must resolve identity via the mount-lease fallback and +/// finish retiring the slot; a further re-run then sees the tombstone and refuses to resume it. +TEST(CASDecommission, MidRetirementCrashResumesViaMountLeaseFallback) +{ + auto backend = std::make_shared(); + { auto victim = openVictim(backend); } /// identity only -- no namespace, so the subtree stays empty + + const Layout layout("p"); + /// Claim the slot once, exactly as `decommissionPoolMember`'s own first step would -- this (re)writes + /// fresh epoch/owner/mount control objects. Closing gracefully (scope exit) stamps the mount lease's + /// farewell, matching what a real slot retirement's `admin.reset()` does right before its delete loop. + { + auto admin = Pool::openForDecommission(backend, PoolConfig{.pool_prefix = "p", .server_root_id = "chk"}, "victim"); + } + + /// Manually strike epoch + owner, leaving the mount -- the legacy partial hand-cleanup shape. + for (const String & key : {layout.epochKey("victim"), layout.ownerKey("victim")}) + { + const auto head = backend->head(key); + ASSERT_TRUE(head.exists); + backend->deleteExact(key, head.token); + } + ASSERT_FALSE(backend->get(layout.epochKey("victim")).has_value()); + ASSERT_FALSE(backend->get(layout.ownerKey("victim")).has_value()); + ASSERT_TRUE(backend->get(layout.mountKey("victim")).has_value()) + << "the mount lease must survive -- it is the resume anchor the fallback reads"; + + const auto report = decommissionPoolMember( + backend, PoolConfig{.pool_prefix = "p", .server_root_id = "a2"}, "victim"); + + EXPECT_TRUE(report.warnings.empty()); + EXPECT_EQ(report.namespaces_removed, 0u); + EXPECT_TRUE(report.slot_removed); + EXPECT_FALSE(backend->get(layout.epochKey("victim")).has_value()); + const auto owner = backend->get(layout.ownerKey("victim")); + ASSERT_TRUE(owner.has_value()); + EXPECT_TRUE(decodeOwner(owner->bytes).retired_at_ms.has_value()); + EXPECT_FALSE(backend->get(layout.mountKey("victim")).has_value()); + + expectThrowsCode(ErrorCodes::CORRUPTED_DATA, [&] + { + decommissionPoolMember(backend, PoolConfig{.pool_prefix = "p", .server_root_id = "a3"}, "victim"); + }); +} diff --git a/src/Disks/tests/gtest_cas_decommission_catalog_duties.cpp b/src/Disks/tests/gtest_cas_decommission_catalog_duties.cpp new file mode 100644 index 000000000000..a674f13667b6 --- /dev/null +++ b/src/Disks/tests/gtest_cas_decommission_catalog_duties.cpp @@ -0,0 +1,330 @@ +#include "cas_test_helpers.h" + +#include +#include + +#include +#include +#include + +namespace DB::ErrorCodes +{ + extern const int CORRUPTED_DATA; +} + +using namespace DB; +using namespace DB::Cas; +using namespace DB::Cas::tests; + +namespace +{ + +PoolPtr openVictim(const std::shared_ptr & backend) +{ + return Pool::open(backend, PoolConfig{.pool_prefix = "p", .server_root_id = "victim"}); +} + +CatalogEntry catalogEntry(Backend & backend, const Layout & layout, const RootNamespace & ns) +{ + const RefCatalog catalog = CasRefCatalog::read(backend, layout).catalog; + const auto it = std::find_if(catalog.entries.begin(), catalog.entries.end(), + [&](const CatalogEntry & entry) { return entry.ns == ns; }); + if (it == catalog.entries.end()) + throw Exception(ErrorCodes::CORRUPTED_DATA, "fixture catalog entry '{}' is absent", ns.string()); + return *it; +} + +void makeRemoving(Backend & backend, const Layout & layout, const CatalogEntry & live) +{ + CasRefCatalog::casUpdate(backend, layout, [&](const RefCatalog & current) + { + RefCatalog next = current; + const auto it = std::find(next.entries.begin(), next.entries.end(), live); + if (it == next.entries.end()) + throw Exception(ErrorCodes::CORRUPTED_DATA, "fixture exact Live row changed"); + it->state = NsState::Removing; + it->removal_started_round = 0; + return next; + }); +} + +bool slotObjectExists(Backend & backend, const String & leaf) +{ + return backend.head("p/gc/server-roots/victim/" + leaf).exists; +} + +class AddVictimEntryDuringRootDrainBackend final : public InMemoryBackend +{ +public: + void arm() { armed = true; } + bool fired() const { return added; } + + ListPage list(const String & prefix, const String & cursor, size_t limit) override + { + ListPage page = InMemoryBackend::list(prefix, cursor, limit); + if (armed && !added && prefix == "p/roots/victim/" && cursor.empty()) + { + added = true; + CasRefCatalog::casAdmitEntry( + *this, Layout("p"), 1, + CatalogEntry{ + .ns = RootNamespace("victim/db/late"), + .state = NsState::Live, + .incarnation = UInt128{707}}); + } + return page; + } + +private: + bool armed = false; + bool added = false; +}; + +/// Admits the late catalog entry between the retirement tail's two exact catalog reads +/// (`retirement_catalog_cut`, then `fresh_retirement_catalog`), never before. The mountpoint drain's +/// `list("p/roots/victim/", ...)` is the last LIST call in `decommissionPoolMember` before either +/// read, so it orders the two `get("p/cas/ref_catalog")` calls that follow it: the first is +/// `retirement_catalog_cut`, the second is `fresh_retirement_catalog`. Mutating on the second call +/// makes that read observe a catalog the first read did not. +class MutateCatalogBetweenRetirementReadsBackend final : public InMemoryBackend +{ +public: + void arm() { armed = true; } + bool fired() const { return added; } + + ListPage list(const String & prefix, const String & cursor, size_t limit) override + { + ListPage page = InMemoryBackend::list(prefix, cursor, limit); + if (armed && !past_mountpoint_drain && prefix == "p/roots/victim/" && cursor.empty()) + past_mountpoint_drain = true; + return page; + } + + std::optional get(const String & key, Range range) override + { + if (armed && past_mountpoint_drain && !added && key == "p/cas/ref_catalog") + { + if (!seen_retirement_catalog_cut) + seen_retirement_catalog_cut = true; + else + { + added = true; + CasRefCatalog::casAdmitEntry( + *this, Layout("p"), 1, + CatalogEntry{ + .ns = RootNamespace("victim/db/late"), + .state = NsState::Live, + .incarnation = UInt128{707}}); + } + } + return InMemoryBackend::get(key, range); + } + +private: + bool armed = false; + bool past_mountpoint_drain = false; + bool seen_retirement_catalog_cut = false; + bool added = false; +}; + +TEST(CASDecommissionCatalogDuties, RemovingWithoutCheckpointIsCorruptionAndKeepsSlot) +{ + auto backend = std::make_shared(); + { + auto victim = openVictim(backend); + const CatalogEntry live{ + .ns = RootNamespace("victim/db/missing_ckpt"), + .state = NsState::Live, + .incarnation = UInt128{701}}; + CasRefCatalog::casAdmitEntry( + *backend, victim->layout(), victim->poolConfig().gc_shards, + live); + makeRemoving(*backend, victim->layout(), live); + } + + expectThrowsCode(ErrorCodes::CORRUPTED_DATA, [&] + { + (void)decommissionPoolMember( + backend, PoolConfig{.pool_prefix = "p", .server_root_id = "admin"}, "victim"); + }); + + EXPECT_TRUE(slotObjectExists(*backend, "owner")); + EXPECT_TRUE(slotObjectExists(*backend, "epoch")); + EXPECT_TRUE(slotObjectExists(*backend, "mount")); + EXPECT_EQ(catalogEntry(*backend, Layout("p"), RootNamespace("victim/db/missing_ckpt")).state, + NsState::Removing); +} + +TEST(CASDecommissionCatalogDuties, RemovingWithCheckpointResumesTerminalAndKeepsSlotForGc) +{ + auto backend = std::make_shared(); + const RootNamespace ns("victim/db/pending_terminal"); + std::optional life; + { + auto victim = openVictim(backend); + life = victim->namespaceLife(ns); + const CatalogEntry live = catalogEntry(*backend, victim->layout(), ns); + makeRemoving(*backend, victim->layout(), live); + ASSERT_TRUE(backend->head(victim->layout().refCkptKey(*life)).exists); + ASSERT_TRUE(backend->list(victim->layout().namespaceStreamPrefix(*life), "", 100).keys.empty()); + } + + std::atomic wake_requests{0}; + const DecommissionReport report = decommissionPoolMember( + backend, PoolConfig{.pool_prefix = "p", .server_root_id = "admin"}, "victim", {}, + [&] { wake_requests.fetch_add(1); }); + + EXPECT_EQ(report.namespaces_already_removed, 1u); + EXPECT_EQ(wake_requests.load(), 1u); + EXPECT_FALSE(report.slot_removed); + EXPECT_FALSE(report.warnings.empty()); + EXPECT_TRUE(slotObjectExists(*backend, "owner")); + + const ListPage stream = backend->list(Layout("p").namespaceStreamPrefix(*life), "", 100); + ASSERT_EQ(stream.keys.size(), 1u); + const auto parsed = Layout("p").parseRefObjectKey(stream.keys.front().key); + ASSERT_TRUE(parsed); + const auto body = backend->get(stream.keys.front().key); + ASSERT_TRUE(body); + const RefLogTxn terminal = decodeRefLogTxn( + openObject(FormatId::RefLog, body->bytes), ns.string(), parsed->txn_id); + ASSERT_EQ(terminal.ops.size(), 2u); + EXPECT_EQ(terminal.ops.front().kind, RefOpKind::NamespaceBirth); + EXPECT_EQ(terminal.ops.back().kind, RefOpKind::RemoveNamespace); +} + +TEST(CASDecommissionCatalogDuties, PartialRemovalProgressStillWakesGcWhenLaterNamespaceFails) +{ + auto backend = std::make_shared(); + const RootNamespace progressed_ns("victim/db/a_progressed"); + const RootNamespace broken_ns("victim/db/z_missing_ckpt"); + std::optional progressed_life; + { + auto victim = openVictim(backend); + progressed_life = victim->namespaceLife(progressed_ns); + const CatalogEntry progressed_live = catalogEntry(*backend, victim->layout(), progressed_ns); + makeRemoving(*backend, victim->layout(), progressed_live); + + const CatalogEntry broken_live{ + .ns = broken_ns, + .state = NsState::Live, + .incarnation = UInt128{713}}; + CasRefCatalog::casAdmitEntry( + *backend, victim->layout(), victim->poolConfig().gc_shards, broken_live); + makeRemoving(*backend, victim->layout(), broken_live); + ASSERT_FALSE(backend->head(victim->layout().refCkptKey( + NamespaceLifeId::fromCatalogEntry(broken_ns, broken_live.incarnation))).exists); + } + + std::atomic wake_requests{0}; + expectThrowsCode(ErrorCodes::CORRUPTED_DATA, [&] + { + (void)decommissionPoolMember( + backend, PoolConfig{.pool_prefix = "p", .server_root_id = "admin"}, "victim", {}, + [&] { wake_requests.fetch_add(1); }); + }); + + EXPECT_EQ(wake_requests.load(), 1u) + << "progress already made for an earlier life must wake GC even when a later life fails closed"; + EXPECT_TRUE(slotObjectExists(*backend, "owner")); + const ListPage progressed_stream + = backend->list(Layout("p").namespaceStreamPrefix(*progressed_life), "", 100); + ASSERT_EQ(progressed_stream.keys.size(), 1u); +} + +TEST(CASDecommissionCatalogDuties, VictimEntryAppearingBeforeTheOwnershipCutKeepsSlot) +{ + auto backend = std::make_shared(); + { auto victim = openVictim(backend); } + backend->arm(); + + const DecommissionReport report = decommissionPoolMember( + backend, PoolConfig{.pool_prefix = "p", .server_root_id = "admin"}, "victim"); + + EXPECT_TRUE(backend->fired()); + EXPECT_FALSE(report.slot_removed); + ASSERT_FALSE(report.warnings.empty()); + EXPECT_NE(report.warnings.front().find("pool member decommission underway: 1 namespace(s)"), String::npos) + << report.warnings.front(); + EXPECT_TRUE(slotObjectExists(*backend, "owner")); + EXPECT_EQ(catalogEntry(*backend, Layout("p"), RootNamespace("victim/db/late")).state, NsState::Live); +} + +TEST(CASDecommissionCatalogDuties, CatalogTokenMovedBetweenOwnershipCutAndRetirementKeepsSlot) +{ + auto backend = std::make_shared(); + { auto victim = openVictim(backend); } + backend->arm(); + + const DecommissionReport report = decommissionPoolMember( + backend, PoolConfig{.pool_prefix = "p", .server_root_id = "admin"}, "victim"); + + EXPECT_TRUE(backend->fired()); + EXPECT_FALSE(report.slot_removed); + ASSERT_FALSE(report.warnings.empty()); + EXPECT_NE(report.warnings.front().find("catalog changed after the victim ownership check"), String::npos) + << report.warnings.front(); + EXPECT_TRUE(slotObjectExists(*backend, "owner")); + EXPECT_EQ(catalogEntry(*backend, Layout("p"), RootNamespace("victim/db/late")).state, NsState::Live); +} + +TEST(CASDecommissionCatalogDuties, FoldedTerminalRemainsGcOwnedAndOnlyRequestsAnotherRound) +{ + auto backend = std::make_shared(); + const RootNamespace ns("victim/db/folded_terminal"); + std::optional life; + std::vector stream_before; + { + PoolConfig config{ + .pool_prefix = "p", + .server_root_id = "victim", + .gc_fold_threshold = 1, + .gc_fold_max_defer_rounds = 0}; + auto victim = Pool::open(backend, config); + life = victim->namespaceLife(ns); + victim->putNamespaceFile(*life, "format_version.txt", "1\n"); + victim->dropNamespace(ns); + + Gc gc(victim, UInt128{811}); + ASSERT_FALSE(runRegularRoundReclaiming(gc).deferred); + ASSERT_EQ(catalogEntry(*backend, victim->layout(), ns).state, NsState::Removing); + for (const ListedKey & key : backend->list(victim->layout().namespaceStreamPrefix(*life), "", 100).keys) + stream_before.push_back(key.key); + ASSERT_FALSE(stream_before.empty()); + } + + std::atomic wake_requests{0}; + const DecommissionReport report = decommissionPoolMember( + backend, PoolConfig{.pool_prefix = "p", .server_root_id = "admin"}, "victim", {}, + [&] { wake_requests.fetch_add(1); }); + + EXPECT_EQ(wake_requests.load(), 1u); + EXPECT_EQ(report.namespaces_already_removed, 1u); + EXPECT_FALSE(report.slot_removed); + EXPECT_EQ(catalogEntry(*backend, Layout("p"), ns).state, NsState::Removing); + std::vector stream_after; + for (const ListedKey & key : backend->list(Layout("p").namespaceStreamPrefix(*life), "", 100).keys) + stream_after.push_back(key.key); + EXPECT_EQ(stream_after, stream_before) + << "decommission must not append a second terminal or become a catalog deletion driver"; +} + +TEST(CASDecommissionCatalogDuties, OpaqueLifeDebrisWithoutCatalogOwnershipDoesNotBlockRetirement) +{ + auto backend = std::make_shared(); + { auto victim = openVictim(backend); } + const Layout layout("p"); + const NamespaceLifeId dead_life + = NamespaceLifeId::fromCatalogEntry(RootNamespace("historical/name"), UInt128{709}); + const String debris_key = layout.refCkptKey(dead_life); + ASSERT_EQ(backend->putIfAbsent(debris_key, "debris").outcome, PutOutcome::Done); + + const DecommissionReport report = decommissionPoolMember( + backend, PoolConfig{.pool_prefix = "p", .server_root_id = "admin"}, "victim"); + + EXPECT_TRUE(report.warnings.empty()); + EXPECT_TRUE(report.slot_removed); + EXPECT_TRUE(backend->head(debris_key).exists); +} + +} diff --git a/src/Disks/tests/gtest_cas_empty_proof.cpp b/src/Disks/tests/gtest_cas_empty_proof.cpp new file mode 100644 index 000000000000..4754786e7624 --- /dev/null +++ b/src/Disks/tests/gtest_cas_empty_proof.cpp @@ -0,0 +1,281 @@ +#include + +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include + +/// Task 9 (rev.7 spec §1 "empty-proof rule" [B3]): the last silent-empty-load killer. On a PRE-TERMINAL +/// (Live) or READ-ONLY pool, an enumeration about to answer EMPTY at a table root must first CONFIRM the +/// pool identity object (`_pool_meta`) exists with an AUTHORITATIVE, UNCACHED probe -- because "empty" at a +/// table root is exactly what a silently-erased backing looks like, and a read-only pool has no +/// keeper/lease/observer to catch that erasure any other way. These tests build a real +/// `ContentAddressedMetadataStorage` over a Local object storage (the gtest_cas_operation_gate.cpp harness) +/// and exercise the rule across the six cells the brief enumerates. + +namespace DB::ErrorCodes +{ +extern const int CORRUPTED_DATA; +extern const int INVALID_STATE; +extern const int NETWORK_ERROR; +} + +using namespace DB; +using DB::Cas::PoolLifecycle; +using DB::Cas::ProbeOutcome; +using DB::Cas::SentinelProbeResult; + +namespace +{ + +/// A committed (non-empty) table dir + part reused across the tests (the exact shape +/// gtest_ca_transaction.cpp / gtest_cas_operation_gate.cpp use). +const std::string kTableDir = "g80/g80g80g8-0808-4808-8808-080808080808"; +const std::string kPartDir = kTableDir + "/all_1_1_0"; +/// A DIFFERENT, never-committed-to table dir: genuinely empty for every test, distinct uuid so a +/// commit to kTableDir can never make it non-empty. +const std::string kEmptyTableDir = "g99/g99g99g9-0909-4909-8909-090909090909"; + +std::shared_ptr openStorage() +{ + auto settings = Cas::tests::makeSettingsForTest( + "test", std::filesystem::temp_directory_path() / "ca_empty_proof_scratch"); + auto storage = std::make_shared( + Cas::tests::makeLocalObjectStorageForTest(), "pool", "srv1", "", nullptr, settings); + storage->startup(); + return storage; +} + +/// Commit one real part into `kTableDir`, leaving that table dir non-empty (tmp -> final rename -> commit). +void commitOnePart(ContentAddressedMetadataStorage & storage) +{ + auto tx = storage.createTransaction(); + auto & ca_tx = dynamic_cast(*tx); + auto buf = ca_tx.writeFile(kTableDir + "/tmp_insert_all_1_1_0/data.bin", 65536, WriteMode::Rewrite, {}); + const std::string bytes = "content-of-the-part"; + buf->write(bytes.data(), bytes.size()); + buf->finalize(); + tx->moveDirectory(kTableDir + "/tmp_insert_all_1_1_0", kPartDir); + tx->commit(NoCommitOptions{}); +} + +/// A read-only mount over a backing a writable mount already bootstrapped (`_pool_meta` present). The +/// writable mount minted the pool identity then shut down; the read-only mount validates `_pool_meta`, +/// takes NO lease and runs NO erasure observer (it stays `Live` forever) -- exactly the state in which +/// enumeration is the ONLY line of defense against a later erasure. Returns {ro storage, backing root}. +struct ReadOnlyMount +{ + std::shared_ptr ro; + std::string root; +}; + +/// Delete ONLY the physical `_pool_meta` object under `root`, leaving the container directory and every +/// other object intact — so a subsequent authoritative `probeSentinel` verdicts `KeyAbsent` (the identity +/// key is gone while the container is alive), NOT `ContainerAbsent` (which a whole-root `remove_all` yields). +/// This models the realistic "someone rm'd just the identity object" / partial-erase shape. Returns whether +/// exactly one `_pool_meta` file was found and removed, so the test can guard against a vacuous pass. +bool deleteOnlyPoolMetaUnder(const std::string & root) +{ + size_t removed = 0; + for (const auto & entry : std::filesystem::recursive_directory_iterator(root)) + { + if (entry.is_regular_file() && entry.path().filename() == "_pool_meta") + { + std::filesystem::remove(entry.path()); + ++removed; + } + } + return removed == 1; +} + +/// The message thrown by `fn`, or a failure if it did not throw a `DB::Exception`. +std::string messageOf(const std::function & fn) +{ + try + { + fn(); + } + catch (const Exception & e) + { + return std::string(e.message()); + } + ADD_FAILURE() << "expected a DB::Exception"; + return {}; +} + +ReadOnlyMount openReadOnlyOverBootstrappedBacking() +{ + auto settings = Cas::tests::makeSettingsForTest( + "test", std::filesystem::temp_directory_path() / "ca_empty_proof_ro_scratch"); + + /// (1) A writable mount bootstraps `_pool_meta` over a fresh backing, then shuts down. + auto rw_os = Cas::tests::makeLocalObjectStorageForTest(); + const std::string root = rw_os->getCommonKeyPrefix(); + { + auto w = std::make_shared( + rw_os, "pool", "srv1", "", nullptr, settings); + w->startup(); + w->shutdown(); + } + + /// (2) A read-only mount over the SAME backing validates `_pool_meta` and mounts `Live` (no lease, + /// no watermark, no observer -- read-only opens never enter the lifecycle machinery). + DB::LocalObjectStorageSettings ro_settings("test", root, /*read_only_=*/true); + auto ro_os = std::make_shared(std::move(ro_settings)); + auto ro = std::make_shared( + ro_os, "pool", "srv1", "", nullptr, settings); + ro->startup(); + return {std::move(ro), root}; +} + +} + +/// (a) THE RO-ATTACH silent-empty killer: a read-only pool whose whole backing was erased must throw, +/// never answer empty. Both mandatory authorities disappear; table enumeration observes the missing +/// `cas/ref_catalog` first, so `CORRUPTED_DATA` takes precedence over the later `_pool_meta` empty-proof +/// check. The pool-meta-only companion below keeps the typed 668 contract pinned separately. +TEST(CASEmptyProof, ReadOnlyOverErasedBackingThrowsInsteadOfEmpty) +{ + auto mount = openReadOnlyOverBootstrappedBacking(); + + /// Baseline while the backing is intact: the empty table root answers empty truthfully (`_pool_meta` + /// present authorizes it), issuing exactly one confirming probe. + mount.ro->resetEmptyProofProbeCountForTest(); + EXPECT_TRUE(mount.ro->listDirectory(kEmptyTableDir).empty()); + EXPECT_EQ(mount.ro->emptyProofProbeCountForTest(), 1u); + + /// Erase the backing out from under the (still Live) read-only mount: `_pool_meta` and everything. + std::filesystem::remove_all(mount.root); + + /// Now the SAME empty listing must refuse on the first missing mandatory control it observes. + Cas::tests::expectThrowsCode(ErrorCodes::CORRUPTED_DATA, [&] { mount.ro->listDirectory(kEmptyTableDir); }); + Cas::tests::expectThrowsCode(ErrorCodes::CORRUPTED_DATA, [&] { mount.ro->iterateDirectory(kEmptyTableDir); }); + Cas::tests::expectThrowsCode(ErrorCodes::CORRUPTED_DATA, [&] { mount.ro->isDirectoryEmpty(kEmptyTableDir); }); +} + +/// (a2, acceptance matrix — T9 review's KeyAbsent-specific real-backend follow-up) Test (a) erases the +/// WHOLE backing (`remove_all(root)`), so its probe verdicts `ContainerAbsent`. This test deletes ONLY the +/// `_pool_meta` object against the REAL Local backend — the container directory and every other object stay +/// intact — so the authoritative probe verdicts `KeyAbsent` instead. Both flavours must reach the SAME +/// "backing may be erased" refusal (distinct from the transient "transport or permission fault" one), so a +/// targeted deletion of just the identity object (a partial erase) is caught exactly like a whole-root wipe. +TEST(CASEmptyProof, ReadOnlyWithOnlyPoolMetaDeletedThrowsErasedFlavoredOnKeyAbsent) +{ + auto mount = openReadOnlyOverBootstrappedBacking(); + + /// Baseline while the backing is intact: the empty table root answers empty truthfully with one probe. + mount.ro->resetEmptyProofProbeCountForTest(); + EXPECT_TRUE(mount.ro->listDirectory(kEmptyTableDir).empty()); + EXPECT_EQ(mount.ro->emptyProofProbeCountForTest(), 1u); + + /// Delete ONLY `_pool_meta` (container + every sibling object intact) → the probe verdicts KeyAbsent. + ASSERT_TRUE(deleteOnlyPoolMetaUnder(mount.root)) + << "expected exactly one _pool_meta object to remove; otherwise this test is vacuous"; + + /// The KeyAbsent miss reaches the erased-flavored typed 668, NOT the transient one, and never answers empty. + const std::string msg = messageOf([&] { mount.ro->listDirectory(kEmptyTableDir); }); + EXPECT_NE(msg.find("pool identity object absent"), std::string::npos) << msg; + EXPECT_NE(msg.find("the backing may be erased"), std::string::npos) << msg; + EXPECT_EQ(msg.find("transport or permission fault"), std::string::npos) + << "a KeyAbsent miss must give the erased message, not the transient/retry one: " << msg; + + /// The other enumeration entry points refuse identically. + Cas::tests::expectThrowsCode(ErrorCodes::INVALID_STATE, [&] { mount.ro->iterateDirectory(kEmptyTableDir); }); + Cas::tests::expectThrowsCode(ErrorCodes::INVALID_STATE, [&] { mount.ro->isDirectoryEmpty(kEmptyTableDir); }); +} + +/// (b) A Live pool over a genuinely-empty table dir with `_pool_meta` present answers empty AND issues +/// EXACTLY ONE uncached sentinel probe -- and it happens on the empty (`isDirectoryEmpty` == true) path. +TEST(CASEmptyProof, LiveEmptyTableDirAnswersEmptyWithExactlyOneProbe) +{ + auto storage = openStorage(); + + storage->resetEmptyProofProbeCountForTest(); + EXPECT_TRUE(storage->isDirectoryEmpty(kEmptyTableDir)); + EXPECT_EQ(storage->emptyProofProbeCountForTest(), 1u) + << "the empty table-root answer must confirm the pool identity with exactly one probe"; + + /// listDirectory / iterateDirectory each independently issue exactly one confirming probe too. + storage->resetEmptyProofProbeCountForTest(); + EXPECT_TRUE(storage->listDirectory(kEmptyTableDir).empty()); + EXPECT_EQ(storage->emptyProofProbeCountForTest(), 1u); +} + +/// (c) The zero-cost hot path: a NON-empty table dir issues NO probe at all. +TEST(CASEmptyProof, LiveNonEmptyTableDirIssuesNoProbe) +{ + auto storage = openStorage(); + commitOnePart(*storage); + + storage->resetEmptyProofProbeCountForTest(); + EXPECT_FALSE(storage->listDirectory(kTableDir).empty()); + EXPECT_FALSE(storage->isDirectoryEmpty(kTableDir)); + EXPECT_EQ(storage->emptyProofProbeCountForTest(), 0u) + << "the non-empty hot path must never touch the empty-proof probe"; +} + +/// (d) A Vanished pool answers truth-empty WITHOUT any probe: `checkOpAdmitted`'s Probe -> TruthAbsent +/// short-circuit answers before classification, so the terminal path never pays the empty-proof. +TEST(CASEmptyProof, VanishedPoolAnswersTruthEmptyWithoutProbe) +{ + auto storage = openStorage(); + commitOnePart(*storage); + auto pool = storage->store(); /// captured while Live + pool->setLifecycleForTest(PoolLifecycle::VanishedForgotten); + + storage->resetEmptyProofProbeCountForTest(); + EXPECT_TRUE(storage->listDirectory(kTableDir).empty()); + EXPECT_TRUE(storage->isDirectoryEmpty(kTableDir)); + EXPECT_FALSE(storage->iterateDirectory(kTableDir)->isValid()); + EXPECT_EQ(storage->emptyProofProbeCountForTest(), 0u) + << "a Vanished pool answers truth-empty directly -- the gate short-circuits before the empty-proof"; +} + +/// (e) Scope discipline: a deeper (non-root) part-dir enumeration that answers empty is NOT gated. +TEST(CASEmptyProof, DeeperPartDirEmptyAnswerIsNotGated) +{ + auto storage = openStorage(); + + /// A never-committed part dir under a table root: classifies as PartDir, answers empty, no probe. + const std::string absent_part_dir = kEmptyTableDir + "/all_9_9_0"; + storage->resetEmptyProofProbeCountForTest(); + EXPECT_TRUE(storage->listDirectory(absent_part_dir).empty()); + EXPECT_EQ(storage->emptyProofProbeCountForTest(), 0u) + << "only the TableDir/DetachedContainer roots are gated -- deeper part-dirs are not"; +} + +/// (f) A probe that cannot establish absence (transport/permission fault) throws the typed TRANSIENT +/// refusal, never an empty answer. Unproven absence is unavailability, so the refusal carries the +/// upstream-retryable class -- unlike the `KeyAbsent`/`ContainerAbsent` arm, where absence IS proven and +/// the 668 stands. The fault is injected through the empty-proof override seam. +TEST(CASEmptyProof, IndeterminateProbeThrowsTransientNeverEmpty) +{ + auto storage = openStorage(); + storage->setEmptyProofProbeOverrideForTest( + [] { return SentinelProbeResult{ProbeOutcome::Indeterminate, std::nullopt}; }); + + storage->resetEmptyProofProbeCountForTest(); + Cas::tests::expectThrowsCode(ErrorCodes::NETWORK_ERROR, [&] { storage->listDirectory(kEmptyTableDir); }); + EXPECT_EQ(storage->emptyProofProbeCountForTest(), 1u); + + /// The transient message names the fault (a retryable condition), distinct from the erased message. + std::string msg; + try + { + storage->listDirectory(kEmptyTableDir); + } + catch (const Exception & e) + { + msg = std::string(e.message()); + } + EXPECT_NE(msg.find("transport or permission fault"), std::string::npos) << msg; + EXPECT_NE(msg.find("TRANSIENT"), std::string::npos) << msg; +} diff --git a/src/Disks/tests/gtest_cas_encoding_pins.cpp b/src/Disks/tests/gtest_cas_encoding_pins.cpp new file mode 100644 index 000000000000..f4c3a913fda7 --- /dev/null +++ b/src/Disks/tests/gtest_cas_encoding_pins.cpp @@ -0,0 +1,110 @@ +#include +#include +#include +#include +#include +#include +#include + +using namespace DB; +using namespace DB::Cas; + +/// These literals pin the CANONICAL BYTES of the CAS text encoders as of the commit that +/// introduced this file. The CasJsonWriter migration (2026-07-20 spec) must keep every one of +/// them green UNMODIFIED: canonical text is byte-compared on retries and deterministic adoption, +/// and the incremental ref budget counters assume these exact sizes. Never edit an expected +/// string here to make a test pass — that means the encoder's bytes drifted, which is the bug. + +TEST(CASEncodingPins, RefLogTxnAllOpKinds) +{ + RefLogTxn txn; + txn.ns = "roots/pin"; + txn.txn_id = RefTxnId{7, 9}; + + RefOp birth; + birth.kind = RefOpKind::NamespaceBirth; + txn.ops.push_back(birth); + + RefOp transition; + transition.kind = RefOpKind::OwnerTransition; + transition.old_binding = RefOwnerBinding{RefOwnerKind::Precommit, "20260101_0_1_1_1", ManifestRef{1, 2, 3}}; + transition.new_binding = RefOwnerBinding{RefOwnerKind::Committed, "20260101_0_1_1_1", ManifestRef{1, 2, 3}}; + txn.ops.push_back(transition); + + RefOp set_published_at; + set_published_at.kind = RefOpKind::SetPublishedAt; + /// NOTE the split literals: "\x01" "e" (else the hex escape would swallow the 'e') and + /// "\xA8" "f" (else it would swallow the 'f'). `checkCanonicalRefName` forbids '\\' and NUL but + /// not quote/newline/control bytes/U+2028, so `ref_name` -- the only free-form string `RefOp` + /// still carries now that `payload` is gone -- exercises quote, newline, a bare control byte, + /// and the three-byte U+2028 sequence. Backslash escaping is pinned separately, over an + /// unrestricted string, by `gtest_cas_json_writer.cpp`'s `CASJsonWriterEscaping` suite. + set_published_at.ref_name = String("20260101_0_1_1_1\"c\nd") + "\x01" "e" + "\xE2\x80\xA8" "f"; + set_published_at.expected_manifest_ref = ManifestRef{1, 2, 3}; + set_published_at.published_at_ms = 1234; + txn.ops.push_back(set_published_at); + + RefOp removal; + removal.kind = RefOpKind::RemoveNamespace; + txn.ops.push_back(removal); + + const String expected = fmt::format("{{\"type\":\"cas_ref_log\",\"v\":{}}}\n", currentCompatibilityVersion()) + + "{\"ns\":\"roots/pin\",\"we\":\"7\",\"rs\":\"9\"}\n" + "{\"op\":\"namespace_birth\"}\n" + "{\"op\":\"owner_transition\",\"obk\":\"precommit\",\"orn\":\"20260101_0_1_1_1\"," + "\"ome\":\"1\",\"omb\":\"2\",\"omo\":3,\"nbk\":\"committed\",\"nrn\":\"20260101_0_1_1_1\"," + "\"nme\":\"1\",\"nmb\":\"2\",\"nmo\":3}\n" + "{\"op\":\"set_published_at\",\"rn\":\"20260101_0_1_1_1\\\"c\\nd\\u0001e\\u2028f\"," + "\"me\":\"1\",\"mb\":\"2\",\"mo\":3,\"ts\":1234}\n" + "{\"op\":\"remove_namespace\"}\n" + "{\"n\":4}\n"; + EXPECT_EQ(encodeRefLogTxn(txn), expected); +} + +TEST(CASEncodingPins, RefSnapshotLive) +{ + RefTableSnapshot snap; + snap.ns = "roots/pin"; + snap.snapshot_id = RefTxnId{7, 9}; + + RefCommittedRow row; + row.ref_name = "20260101_0_1_1_1"; + row.manifest_ref = ManifestRef{1, 2, 3}; + row.published_at_ms = 5; + snap.committed.push_back(row); + + snap.precommits.push_back(RefOwnerBinding{RefOwnerKind::Precommit, "20260102_0_2_2_2", ManifestRef{4, 5, 6}}); + + const String expected = fmt::format("{{\"type\":\"cas_ref_snap\",\"v\":{}}}\n", currentCompatibilityVersion()) + + "{\"ns\":\"roots/pin\",\"we\":\"7\",\"rs\":\"9\",\"lc\":\"live\"}\n" + "{\"k\":\"c\",\"rn\":\"20260101_0_1_1_1\",\"me\":\"1\",\"mb\":\"2\",\"mo\":3,\"ts\":5}\n" + "{\"k\":\"p\",\"rn\":\"20260102_0_2_2_2\",\"me\":\"4\",\"mb\":\"5\",\"mo\":6}\n" + "{\"n\":2}\n"; + EXPECT_EQ(encodeRefTableSnapshot(snap), expected); +} + +TEST(CASEncodingPins, SourceEdgeRunLines) +{ + WriteBufferFromOwnString out; + SourceEdgeRunWriter writer(out); + + SourceEdgeRecord active; + active.ref = BlobRef{BlobHashAlgo::CityHash128, BlobDigest::fromU128(UInt128(2))}; + active.source_id = UInt128(5); + active.marker = kEdgeActive; + writer.append(active); + + writer.finish(); + out.finalize(); + + /// The exact "b" rendering (algo byte + digest hex) is pinned as a whole line; the point is + /// that Task 8's line-scratch rewrite must reproduce it byte-for-byte. + const String text = out.str(); + const String header = fmt::format("{{\"type\":\"cas_run\",\"v\":{},\"kind\":\"source_edge\"}}\n", currentCompatibilityVersion()); + const String expected_record = + "{\"b\":\"0100000000000000000000000000000002\",\"s\":\"00000000000000000000000000000005\",\"m\":\"edge\"}\n"; + const String trailer = "{\"n\":1}\n"; + /// There is exactly one record, so the whole buffer must be byte-identical to header + record + trailer. + const String expected_full = header + expected_record + trailer; + EXPECT_EQ(text, expected_full) << text; +} diff --git a/src/Disks/tests/gtest_cas_envelope.cpp b/src/Disks/tests/gtest_cas_envelope.cpp new file mode 100644 index 000000000000..7dac7ddf6eed --- /dev/null +++ b/src/Disks/tests/gtest_cas_envelope.cpp @@ -0,0 +1,55 @@ +#include +#include +#include +#include + +using namespace DB; +using namespace DB::Cas; + +/// The v3 blob-envelope shape (256-byte JSON header + payload). Full round-trip / gate / pad-zone / +/// budget / critical-key coverage lives in gtest_cas_blob_envelope_format.cpp; these two keep the +/// cases that file does not exercise: a header with NO provenance/ref, and the incarnation-zone +/// independence of the payload. + +TEST(CASEnvelope, BlobRoundTripNoExtensions) +{ + const std::string payload = "hello payload"; + EnvelopeHeader h; + h.kind = ObjectKind::Blob; + h.incarnation_tag = 0x22; + h.build_id = 0x33; + const std::string obj = encodeEnvelopeHeader(h, 256) + payload; + + const EnvelopeHeader d = decodeEnvelopeHeader(obj, obj.size(), ObjectKind::Blob); + EXPECT_EQ(d.kind, ObjectKind::Blob); + EXPECT_EQ(d.compatibility_version, G_BUILD); + EXPECT_FALSE(d.provenance.has_value()); /// none set -> the ts/by/op/ch keys are absent + EXPECT_FALSE(d.intended_ref.has_value()); /// none set -> the ref key is omitted + EXPECT_EQ(d.header_len, 256u); + /// payload starts right after the fixed-length header. + EXPECT_EQ(obj.substr(payloadOffset(d)), payload); +} + +TEST(CASEnvelope, IncarnationZoneDoesNotAffectPayload) +{ + /// Two objects with the SAME payload but DIFFERENT incarnation_tag/build_id encode to different + /// header bytes, yet both carry the same payload at the same fixed offset — the incarnation zone + /// never affects the payload. Identity is the content key, not any header field. + const std::string payload = "same content"; + EnvelopeHeader a; + a.kind = ObjectKind::Blob; + a.incarnation_tag = 0xAAAA; + a.build_id = 0xBBBB; + EnvelopeHeader b = a; + b.incarnation_tag = 0xCCCC; + b.build_id = 0xDDDD; + + const std::string ha = encodeEnvelopeHeader(a, 256); + const std::string hb = encodeEnvelopeHeader(b, 256); + EXPECT_NE(ha, hb); /// headers differ (incarnation zone) + + const EnvelopeHeader da = decodeEnvelopeHeader(ha + payload, ha.size() + payload.size(), ObjectKind::Blob); + const EnvelopeHeader db = decodeEnvelopeHeader(hb + payload, hb.size() + payload.size(), ObjectKind::Blob); + EXPECT_EQ((ha + payload).substr(payloadOffset(da)), payload); + EXPECT_EQ((hb + payload).substr(payloadOffset(db)), payload); +} diff --git a/src/Disks/tests/gtest_cas_event_dispatcher.cpp b/src/Disks/tests/gtest_cas_event_dispatcher.cpp new file mode 100644 index 000000000000..010d55d90455 --- /dev/null +++ b/src/Disks/tests/gtest_cas_event_dispatcher.cpp @@ -0,0 +1,194 @@ +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +using namespace DB::Cas; +using DB::Cas::tests::idOf; +using DB::Cas::tests::u128Of; + +namespace +{ + +/// A single-blob part: upload one blob, stage a one-entry manifest naming it, precommit + promote the +/// ref. Mirrors `publishOneBlobPart` in `gtest_cas_event_log.cpp` so a committed ref exists for +/// `resolveRef` to resolve and emit against. +void publishOneBlobPart(const PoolPtr & s, const String & ns, const String & ref, const String & payload) +{ + const RootNamespace nsr{ns}; + PartWriteInfo info; + info.intended_ref = ns + "/" + ref; + auto build = s->beginPartWrite(info); + build->putBlob(idOf(payload), BlobSource::fromString(payload)); + ManifestEntry e; + e.path = "data.bin"; + e.placement = EntryPlacement::Blob; + e.ref = BlobRef{BlobHashAlgo::CityHash128, BlobDigest::fromU128(u128Of(payload))}; + e.blob_size = payload.size(); + const ManifestId id = build->stageManifest({e}); + build->precommitAdd(nsr, ref, id); + build->promote(nsr, ref, build->buildId(), id); +} + +} + +/// Concurrent emitters must be serialized: N threads emit M events each into a sink that appends to a +/// DELIBERATELY UNGUARDED vector. If the dispatcher did not serialize delivery the concurrent +/// `push_back`s would tear the vector (and TSan on that lane would flag the data race); serialized +/// delivery makes the unguarded append correct. The count/uniqueness assertions catch dropped or +/// duplicated events on any lane. +TEST(CASEventDispatcher, SerializesConcurrentEmitters) +{ + EventDispatcher disp; + std::vector seen; /// unguarded on purpose -- the dispatcher is the only serialization + disp.setSink([&](CasEvent e) { seen.push_back(std::move(e)); }); + + constexpr int N = 8; /// emitter threads + constexpr int M = 250; /// emits per thread + std::latch start{N}; /// release all emitters together to maximize contention on the dispatcher + std::vector threads; + threads.reserve(N); + for (int t = 0; t < N; ++t) + threads.emplace_back([&, t] + { + start.arrive_and_wait(); + for (int m = 0; m < M; ++m) + { + CasEvent e; + e.type = CasEventType::BlobPut; + e.object_hash = std::to_string(t * M + m); + disp.emit(std::move(e)); + } + }); + for (auto & th : threads) + th.join(); + + ASSERT_EQ(seen.size(), static_cast(N * M)); + std::set ids; + for (const auto & e : seen) + ids.insert(e.object_hash); + EXPECT_EQ(ids.size(), static_cast(N * M)) << "every emitted event delivered exactly once"; +} + +/// A sink that emits again from inside its own delivery must not deadlock. The drain-loop design +/// never holds the dispatcher mutex across the sink call, so the reentrant `emit` acquires the mutex, +/// finds a drain already running, enqueues, and returns; the running loop delivers it after the +/// current sink returns. Delivery is synchronous on the emitting thread, so no timed wait is needed: +/// `emit` returns only after the whole queue (including the reentrant event) has drained. +TEST(CASEventDispatcher, ReentrantSinkDoesNotDeadlock) +{ + EventDispatcher disp; + std::vector delivered; + std::atomic reentered_once{false}; + disp.setSink([&](CasEvent e) + { + delivered.push_back(e.type); + if (e.type == CasEventType::BlobPut && !reentered_once.exchange(true)) + { + CasEvent second; + second.type = CasEventType::BlobDelete; + disp.emit(std::move(second)); + } + }); + + CasEvent first; + first.type = CasEventType::BlobPut; + disp.emit(std::move(first)); + + ASSERT_EQ(delivered.size(), 2u) << "both the original and the reentrant event must be delivered"; + EXPECT_EQ(delivered[0], CasEventType::BlobPut); + EXPECT_EQ(delivered[1], CasEventType::BlobDelete) + << "the reentrant event is drained AFTER the current sink returns, not recursively"; +} + +/// Test 17: a ledger emission must fire OUTSIDE the ledger lock. Install a sink that, on delivery of +/// a `RefResolve` event, re-enters a ledger read (`resolveRef`) that itself takes `state_mutex`; then +/// drive a real emitting `resolveRef` on a worker thread while a second thread emits upload-style +/// events concurrently. If `resolveRef` emitted while holding `state_mutex` (the pre-fix defect), the +/// worker would re-lock `state_mutex` on the same thread from inside the sink and self-deadlock. The +/// restructured emit (after the lock scope) lets the reentrant read take the lock freshly, and the +/// dispatcher serializes the concurrent upload emissions. +TEST(CASEventDispatcher, LedgerEmissionOutsideLocks) +{ + auto b = std::make_shared(); + std::vector seen; /// declared before the Pool so it outlives any late background emit + std::mutex seen_mutex; + auto s = Pool::open(b, PoolConfig{.pool_prefix = "p", .server_root_id = "test"}); + + const RootNamespace ns{"srv1/tbl"}; + const String ref = "all_0_0_0"; + publishOneBlobPart(s, ns.string(), ref, "the-resolvable-payload"); + + std::atomic reentered{false}; + s->setEventSink([&](CasEvent e) + { + { + std::lock_guard g(seen_mutex); + seen.push_back(e); + } + /// Re-enter a ledger read that takes `state_mutex`, exactly once (`Deferred` => this read + /// itself emits nothing, so there is no unbounded emit recursion). Under the pre-fix code the + /// outer `resolveRef` still holds `state_mutex` here, so this call self-deadlocks. + if (e.type == CasEventType::RefResolve && !reentered.exchange(true)) + (void)s->resolveRef(ns, ref, false, ResolveAudit::Deferred); + }); + + std::promise resolve_done; + auto resolve_future = resolve_done.get_future(); + std::thread resolver([&] + { + (void)s->resolveRef(ns, ref); /// ResolveAudit::Emit (default) -> emits RefResolve -> drives the sink + resolve_done.set_value(); + }); + + /// A second thread emits upload-task-style events concurrently with the resolve, so the dispatcher's + /// serialization is exercised alongside the reentrancy path. + std::thread uploader([&] + { + for (int i = 0; i < 32; ++i) + { + CasEvent up; + up.type = CasEventType::BlobPut; + up.object_hash = "up-" + std::to_string(i); + up.reason = "concurrent upload-task emission"; + s->emitEvent(std::move(up)); + } + }); + + /// Bounded wait: the resolve is two in-memory map lookups plus queue drains -- microseconds of + /// real work. 10 seconds is many orders of magnitude above that and only elapses if the + /// emit-under-lock defect self-deadlocks the worker on `state_mutex`. + const auto status = resolve_future.wait_for(std::chrono::seconds(10)); + ASSERT_EQ(status, std::future_status::ready) + << "resolveRef with a re-entrant sink did not complete: emission is happening under state_mutex"; + resolver.join(); + uploader.join(); + + EXPECT_TRUE(reentered.load()) << "the reentrant ledger read must have run"; + std::lock_guard g(seen_mutex); + size_t resolves = 0; + size_t uploads = 0; + for (const auto & e : seen) + { + if (e.type == CasEventType::RefResolve) + ++resolves; + else if (e.type == CasEventType::BlobPut) + ++uploads; + } + EXPECT_GE(resolves, 1u) << "the driving resolve emitted its RefResolve"; + EXPECT_EQ(uploads, 32u) << "every concurrent upload emission was delivered exactly once"; +} diff --git a/src/Disks/tests/gtest_cas_event_log.cpp b/src/Disks/tests/gtest_cas_event_log.cpp new file mode 100644 index 000000000000..3cba6eb671de --- /dev/null +++ b/src/Disks/tests/gtest_cas_event_log.cpp @@ -0,0 +1,240 @@ +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +using namespace DB::Cas; +using DB::Cas::tests::idOf; +using DB::Cas::tests::u128Of; + +/// Round-B opt §6: `reason` is templated rationale (a handful of distinct strings repeated across +/// every row), unlike `object_hash`/`token` which are genuinely per-row varied -- it belongs alongside +/// the log's other LowCardinality columns (event_type/object_kind/outcome), not as a full String. +TEST(CASContentAddressedLog, ReasonColumnIsLowCardinality) +{ + const auto columns = DB::ContentAddressedLogElement::getColumnsDescription(); + const auto & reason_col = columns.get("reason"); + EXPECT_TRUE(typeid_cast(reason_col.type.get())) + << "reason column must be LowCardinality(String) (Round-B opt §6)"; +} +TEST(CASEvent, ConstructAndCopyAndName) +{ + CasEvent e; + e.type = CasEventType::BlobDelete; + e.object_kind = CasEventObjectKind::Blob; + e.object_hash = "abcd"; + e.token = "tok"; + e.round = 7; e.gen = 3; + e.reason = "in-degree 0 after strip"; + e.detail["freed"] = "10"; + CasEvent c = e; + EXPECT_EQ(c.type, CasEventType::BlobDelete); + EXPECT_EQ(c.object_hash, "abcd"); + EXPECT_EQ(c.detail.at("freed"), "10"); + EXPECT_EQ(toString(CasEventType::BlobDelete), "blob_delete"); + EXPECT_EQ(toString(CasEventType::IndegZero), "indegree_zero"); + EXPECT_EQ(toString(CasEventType::GcRecheckVerdict), "gc_recheck_verdict"); + EXPECT_EQ(toString(CasEventObjectKind::Manifest), "manifest"); +} + +TEST(CASEvent, PoolEmitsToSink) +{ + auto b = std::make_shared(); + std::vector seen; /// declared BEFORE the Pool so it outlives the background syncer's emits (ASan 2026-07-09) + auto s = Pool::open(b, PoolConfig{.pool_prefix = "p", .server_root_id = "test"}); + s->setEventSink([&](const CasEvent & e){ seen.push_back(e); }); + CasEvent e; + e.type = CasEventType::BlobPut; + e.object_hash = "h"; + s->emitEvent(std::move(e)); + ASSERT_EQ(seen.size(), 1u); + EXPECT_EQ(seen[0].type, CasEventType::BlobPut); + /// null sink => no-op (no crash, no row); a fresh event, not the one already moved above. + s->setEventSink(nullptr); + CasEvent e2; + e2.type = CasEventType::BlobPut; + s->emitEvent(std::move(e2)); + EXPECT_EQ(seen.size(), 1u); +} + +/// Round-B opt §6: `emitEvent` takes the event BY VALUE (moved-through, not `const &`), so a +/// caller's local is genuinely moved-from -- not merely copied via a const reference -- by the time +/// the sink runs. Mirrors `makeCasEventSink`'s own move-out-of-the-by-value-event idiom (a small test +/// double stands in for the `ContentAddressedLogElement` it would normally build). +TEST(CASEvent, EmitEventMovesSourceIntoSink) +{ + auto b = std::make_shared(); + String captured_reason; + std::map captured_detail; + auto s = Pool::open(b, PoolConfig{.pool_prefix = "p", .server_root_id = "test"}); + s->setEventSink([&](CasEvent ev) + { + captured_reason = std::move(ev.reason); + captured_detail = std::move(ev.detail); + }); + CasEvent e; + e.type = CasEventType::BlobPut; + e.reason = "sentinel-reason"; + e.detail["k"] = "v"; + s->emitEvent(std::move(e)); + EXPECT_EQ(captured_reason, "sentinel-reason"); + EXPECT_EQ(captured_detail.at("k"), "v"); + /// the source event must be MOVED-FROM after emit, not merely aliased/copied through -- reading + /// `e` here is the whole point of the test, not an oversight. + EXPECT_TRUE(e.reason.empty()); // NOLINT(bugprone-use-after-move, hicpp-invalid-access-moved) + EXPECT_TRUE(e.detail.empty()); // NOLINT(bugprone-use-after-move, hicpp-invalid-access-moved) +} + +namespace +{ + +/// A single-blob part: upload one blob, stage a one-entry manifest naming it, precommit + promote the +/// ref. Returns the blob's object_hash (lowercase hex) so the test can filter the captured rows by it. +String publishOneBlobPart(const PoolPtr & s, const String & ns, const String & ref, const String & payload) +{ + const RootNamespace nsr{ns}; + PartWriteInfo info; + info.intended_ref = ns + "/" + ref; + auto build = s->beginPartWrite(info); + build->putBlob(idOf(payload), BlobSource::fromString(payload)); + ManifestEntry e; + e.path = "data.bin"; + e.placement = EntryPlacement::Blob; + e.ref = DB::Cas::BlobRef{DB::Cas::BlobHashAlgo::CityHash128, DB::Cas::BlobDigest::fromU128(u128Of(payload))}; + + e.blob_size = payload.size(); + const ManifestId id = build->stageManifest({e}); + build->precommitAdd(nsr, ref, id); + build->promote(nsr, ref, build->buildId(), id); + /// Phase 3 (mixed-algo pools): every blob-content-hash event render is `blobIdOf(ref)` + /// (":"), never a bare hex -- the prime directive that a digest never appears + /// without its algo. + return DB::Cas::blobIdOf(e.ref); +} + +/// Whether the CURRENT retired list (any gc-shard) still holds an entry (ack-floor pipeline in flight). +bool anyRetiredPending(const PoolPtr & s) +{ + /// Retired-in-snapshot (T4): condemned state rides the adopted fold seal's kCondemned rows, not a + /// separate retired list — reconstruct the in-flight set from the seal. + return DB::Cas::tests::anyCondemnedInSeal(s->backend(), s->layout()); +} + +/// Drive regular GC to a fixpoint over the ACK-FLOOR round (renew the store's mount ack after each round; +/// stay alive while any work counter is nonzero OR an in-flight retired entry remains). +void runGcToFixpoint(const PoolPtr & s, Gc & gc, size_t max_rounds = 64) +{ + for (size_t r = 0; r < max_rounds; ++r) + { + const RoundReport rep = DB::Cas::tests::runRegularRoundReclaiming(gc); + if (!rep.acquired_lease) + continue; + s->renewWatermarkOnce(); + const bool no_work = rep.candidates == 0 && rep.deleted == 0 && rep.absent == 0 + && rep.replaced == 0 && rep.spared == 0; + if (no_work && !anyRetiredPending(s)) + break; + } +} + +bool hasType(const std::vector & events, CasEventType t) +{ + for (const auto & e : events) + if (e.type == t) + return true; + return false; +} + +} + +/// B170 Task 4 acceptance: drive a full publish -> drop -> GC-to-delete lifecycle through a capturing +/// sink and assert (a) the taxonomy of events is emitted, (b) EVERY event carries a non-empty reason, +/// (c) filtering by a deleted blob's object_hash reconstructs its edge/retire/delete chain in order. +TEST(CASEvent, LifecycleReconstructionFromRows) +{ + auto b = std::make_shared(); + /// Declared BEFORE the Pool so they OUTLIVE it: the Pool's background retired-view syncer can emit + /// (e.g. a view-advance event) right up to the Pool's destructor, and a sink capturing locals that + /// die first is a use-after-scope (found by ASan 2026-07-09; the production sink captures the Context + /// shared_ptr by value and is immune). + std::vector events; + std::mutex events_mutex; + auto s = Pool::open(b, PoolConfig{.pool_prefix = "p", .server_root_id = "test"}); + + s->setEventSink([&](const CasEvent & e) + { + std::lock_guard lock(events_mutex); + events.push_back(e); + }); + + const RootNamespace ns{"srv1/tbl"}; + const String ref = "all_0_0_0"; + const String payload = "the-doomed-blob-payload"; + + /// publish -> the blob's whole closure is born and a ref names it. + const String blob_hash = publishOneBlobPart(s, ns.string(), ref, payload); + + /// drop the ref and advance the watermark so the now-unreferenced closure is collectable. + s->dropRef(ns, ref); + s->renewWatermarkOnce(); + + /// GC reclaims the tree and the blob to a fixpoint. + Gc gc(s, u128Of("gc-event-log")); + runGcToFixpoint(s, gc); + + /// The blob must actually be gone (the delete fired). + ASSERT_FALSE(b->head(s->layout().blobKey(BlobRef{BlobHashAlgo::CityHash128, BlobDigest::fromU128(u128Of(payload))})).exists) + << "GC must have deleted the now-unreferenced blob"; + + /// (a) the expected taxonomy was emitted across the lifecycle (manifest model: no standalone trees). + EXPECT_TRUE(hasType(events, CasEventType::BlobPut)); + EXPECT_TRUE(hasType(events, CasEventType::RootAdd)) + << "a fold must have recorded the manifest owner's blob edge (+1)"; + EXPECT_TRUE(hasType(events, CasEventType::RefDrop)); + EXPECT_TRUE(hasType(events, CasEventType::IndegZero)); + EXPECT_TRUE(hasType(events, CasEventType::GcRetireObserve) + || hasType(events, CasEventType::GcRetireDecision) + || hasType(events, CasEventType::GcRecheckVerdict)) + << "a GC retire/recheck transition must be recorded"; + EXPECT_TRUE(hasType(events, CasEventType::BlobDelete) || hasType(events, CasEventType::ManifestDelete)) + << "the single content-delete site must emit a delete row"; + + /// (b) completeness mandate: every emitted event has a non-empty reason (the human WHY). + for (const auto & e : events) + EXPECT_FALSE(e.reason.empty()) + << "event " << toString(e.type) << " (" << e.object_hash << ") has an empty reason"; + + /// (c) lifecycle reconstruction: filtering by the deleted blob's object_hash yields, in time + /// order, at least its in-degree-zero -> retire-observe -> delete chain — its whole story. + std::vector chain; + for (const auto & e : events) + if (e.object_hash == blob_hash) + chain.push_back(e.type); + + ASSERT_FALSE(chain.empty()) << "no rows reference the deleted blob " << blob_hash; + + /// The decisive ordering: the blob's in-degree hit 0 BEFORE GC observed/condemned it, which was + /// BEFORE it was deleted. Find the first index of each and assert the order. + auto firstIndexOf = [&](CasEventType t) -> int + { + for (size_t i = 0; i < chain.size(); ++i) + if (chain[i] == t) + return static_cast(i); + return -1; + }; + const int i_indeg = firstIndexOf(CasEventType::IndegZero); + const int i_observe = firstIndexOf(CasEventType::GcRetireObserve); + const int i_delete = firstIndexOf(CasEventType::BlobDelete); + ASSERT_GE(i_indeg, 0) << "the blob's indegree_zero must be in its chain"; + ASSERT_GE(i_observe, 0) << "the blob's gc_retire_observe must be in its chain"; + ASSERT_GE(i_delete, 0) << "the blob's blob_delete must be in its chain"; + EXPECT_LT(i_indeg, i_observe) << "in-degree hit 0 before GC observed it"; + EXPECT_LT(i_observe, i_delete) << "GC observed it before deleting it"; +} diff --git a/src/Disks/tests/gtest_cas_fence_generation.cpp b/src/Disks/tests/gtest_cas_fence_generation.cpp new file mode 100644 index 000000000000..74404f68c203 --- /dev/null +++ b/src/Disks/tests/gtest_cas_fence_generation.cpp @@ -0,0 +1,471 @@ +#include +#include "cas_test_helpers.h" + +#include +#include +#include +#include + +#include +#include +#include +#include +#include + +namespace DB::ErrorCodes +{ + extern const int NETWORK_ERROR; +} + +/// Task 4 (spec §1 "Gate lifetime [C2]"): every durable-effect path on the plain-object surface +/// (`CasPlainObjects::casPutObject`/`casRemoveObject`), the S3-native staging-buffer finalize +/// (`Cas::CaContentWriteBuffer`), and the part-write condemned-displacement raw writes capture the mount +/// runtime's fence generation at admission and re-check it -- and `mayMutate()` -- immediately before their +/// durable backend call, throwing the typed transient error (`NETWORK_ERROR` -- the upstream-retryable +/// class every CA write-plane transient uses) on a mismatch instead of letting a stale-incarnation write +/// land. +/// +/// These tests drive a real `Cas::Pool` over `InMemoryBackend` (the "Emulated"-style in-memory +/// backend) via `Pool::open`, exactly like `gtest_cas_mount.cpp`/`gtest_cas_s3_staging.cpp` -- the +/// fence is tripped/observed through `Pool`'s public forwarders (`tripMountLost`, `mayMutate`, +/// `fenceGeneration`, `checkFenceOrThrow`). + +using namespace DB::Cas; + +namespace +{ + +/// A backend whose `head()` call can trigger an injected side-effect exactly once -- deterministically +/// simulates a fence trip landing BETWEEN a durable-effect operation's admission and its durable +/// backend call, with no real concurrency at all (mirrors the injected-fault shape of +/// `TransportFaultBackend` in gtest_cas_sentinel_probe.cpp, but fires a callback instead of throwing). +class TripOnHeadBackend final : public InMemoryBackend +{ +public: + HeadResult head(const String & key) override + { + if (trigger) + std::exchange(trigger, {})(); + return InMemoryBackend::head(key); + } + + std::function trigger; +}; + +/// Same idea as `TripOnHeadBackend`, but fires on the SECOND `head()` call and forces a first-attempt +/// `PreconditionFailed` so the retry loop actually reaches a second iteration -- proves the fence +/// re-check runs on EVERY conditional-retry iteration, not just the admission-time first attempt. +class TripOnSecondHeadBackend final : public InMemoryBackend +{ +public: + using Backend::putIfAbsent; + + HeadResult head(const String & key) override + { + ++head_calls; + if (head_calls == 2 && trigger) + std::exchange(trigger, {})(); + return InMemoryBackend::head(key); + } + + PutResult putIfAbsent(const String & key, const String & bytes, const ObjectMeta & meta) override + { + if (fail_first_put) + { + fail_first_put = false; + return PutResult{.outcome = PutOutcome::PreconditionFailed, .token = {}}; + } + return InMemoryBackend::putIfAbsent(key, bytes, meta); + } + + int head_calls = 0; + /// Default false: `Pool::open`'s own capability probe issues `putIfAbsent` calls before the test + /// gets to arm this, and those must succeed normally. The test flips this to `true` only right + /// before driving the write it actually targets. + bool fail_first_put = false; + std::function trigger; +}; + +/// A minimal in-memory `WriteBufferFromFileBase` standing in for an object-store sink, trimmed to just +/// what these tests observe (whether `finalizeImpl` ran) -- mirrors `FakeStagingSink` in +/// gtest_cas_s3_staging.cpp (not reusable from here: that one lives in that file's own anonymous +/// namespace). +class RecordingSink final : public DB::WriteBufferFromFileBase +{ +public: + explicit RecordingSink(std::string key_) + : DB::WriteBufferFromFileBase(/*buf_size=*/8192, nullptr, 0), key(std::move(key_)) + { + } + + void sync() override {} + std::string getFileName() const override { return key; } + bool wasFinalizedForTest() const { return did_finalize; } + +protected: + void nextImpl() override + { + if (offset()) + written.append(working_buffer.begin(), offset()); + } + + void finalizeImpl() override + { + next(); + did_finalize = true; + } + + void cancelImpl() noexcept override { cancelled = true; } + +private: + std::string key; + std::string written; + bool did_finalize = false; + bool cancelled = false; +}; + +PoolPtr openTestPool(BackendPtr backend) +{ + return Pool::open(std::move(backend), PoolConfig{.pool_prefix = "p", .server_root_id = "test"}); +} + +/// I2 (backlog {#c2-resurrect-putoverwrite-fence-check}): a backend that (1) trips an injected side-effect +/// on the FIRST head() -- deterministically landing a fence trip BETWEEN `streamIfAbsent`'s conditional +/// create (which returns Occupied over a present condemned blob WITHOUT a head -- see +/// `conditionalCreateControlled`) and `uploadFromSource`'s condemned-displacement decision (whose first +/// head is the `head(key)` right before the raw `resurrect`/`putOverwrite` write) -- and (2) records +/// those two raw displacement calls, so a test proves the [C2] fence check aborted BEFORE any durable +/// displacement landed. Mirrors `TripOnHeadBackend` above and `RecordingStagingBackend` in +/// gtest_cas_s3_staging.cpp (neither reusable across their anonymous namespaces). +class TripOnHeadDisplacementBackend final : public InMemoryBackend +{ +public: + using Backend::putOverwrite; + + HeadResult head(const String & key) override + { + if (trigger) + std::exchange(trigger, {})(); + return InMemoryBackend::head(key); + } + + PutResult putOverwrite(const String & key, const String & bytes, const Token & expected, const ObjectMeta & meta) override + { + ++put_overwrite_calls; + return InMemoryBackend::putOverwrite(key, bytes, expected, meta); + } + + Token resurrect(DB::ReadBuffer & payload, uint64_t payload_size, const String & blob_key, const String & fresh_header) override + { + ++resurrect_staged_calls; + return InMemoryBackend::resurrect(payload, payload_size, blob_key, fresh_header); + } + + std::function trigger; + int put_overwrite_calls = 0; + int resurrect_staged_calls = 0; +}; + +/// A `BlobSource` that promotes via the S3 server-side-copy path (no local `open`) -- mirrors +/// `serverSideCopySource` in gtest_cas_s3_staging.cpp. +BlobSource serverSideCopySource(const std::string & staging_key, uint64_t size) +{ + BlobSource source; + source.size = size; + source.server_side_copy_from = staging_key; + return source; +} + +TEST(CASFenceGeneration, RearmPublishesTheNewGenerationBeforeOpeningTheFence) +{ + auto backend = std::make_shared(); + auto store = openTestPool(backend); + const RootNamespace ns{"srv1/rearm-publication-order"}; + DB::Cas::tests::casAdmitRecoverableEntry(*backend, store->layout(), ns, store->liveWriterEpoch()); + ASSERT_EQ(store->refTableRuntimeIdentityForTest(ns), 0u); + + store->tripMountLost(); + const uint64_t dead_generation = store->fenceGeneration(); + bool admitted_in_interposition = false; + store->setArmMountFenceInterpositionHookForTest([&] + { + EXPECT_EQ(store->fenceGeneration(), dead_generation + 1) + << "the fresh generation must be visible before the fence can become live"; + try + { + (void)store->namespaceLife(ns); + admitted_in_interposition = true; + } + catch (const DB::Exception & e) + { + EXPECT_EQ(e.code(), DB::ErrorCodes::NETWORK_ERROR); + } + EXPECT_EQ(store->refTableRuntimeIdentityForTest(ns), 0u) + << "no runtime may be published in the re-arm interposition"; + }); + + store->armMountFence(DB::UInt128{0, 1}, store->writerEpoch(), store->bootMsNow() + 600000); + store->setArmMountFenceInterpositionHookForTest(nullptr); + + EXPECT_FALSE(admitted_in_interposition); + EXPECT_EQ(store->refTableRuntimeIdentityForTest(ns), 0u); + EXPECT_NO_THROW((void)store->namespaceLife(ns)); + EXPECT_EQ(store->refTableRuntimeAdmittedFenceGenerationForTest(ns), store->fenceGeneration()); +} + +} + +/// (a) `casPutObject` (reached via `Pool::putNamespaceFile`) with the fence tripped BETWEEN admission +/// and the durable PUT: the typed transient refusal, and the object is never actually written. +TEST(CASFenceGeneration, PlainObjectPutAbortsWhenFenceTripsBetweenAdmissionAndDurableCall) +{ + auto backend = std::make_shared(); + auto store = openTestPool(backend); + ASSERT_TRUE(store->mayMutate()); + + const RootNamespace ns{"test/ns"}; + backend->trigger = [&] { store->tripMountLost(); }; + + DB::Cas::tests::expectThrowsCode(DB::ErrorCodes::NETWORK_ERROR, [&] + { + store->putNamespaceFile(DB::Cas::tests::fixture::fixtureLife(ns), "somefile", "hello"); + }); + + /// No durable write ever landed -- assert via the Emulated backend listing. + EXPECT_TRUE(store->listNamespaceFiles(DB::Cas::tests::fixture::fixtureLife(ns)).empty()); +} + +/// `casRemoveObject`'s delete sibling, same shape: the fence trips between admission and the durable +/// delete, so the victim object survives untouched. +TEST(CASFenceGeneration, PlainObjectRemoveAbortsWhenFenceTripsBetweenAdmissionAndDurableCall) +{ + auto backend = std::make_shared(); + auto store = openTestPool(backend); + const RootNamespace ns{"test/ns"}; + + /// Seed the victim BEFORE arming the trigger -- the seeding write itself must not trip the fence. + store->putNamespaceFile(DB::Cas::tests::fixture::fixtureLife(ns), "victim", "still here"); + ASSERT_TRUE(store->mayMutate()); + + backend->trigger = [&] { store->tripMountLost(); }; + + DB::Cas::tests::expectThrowsCode(DB::ErrorCodes::NETWORK_ERROR, [&] + { + store->removeNamespaceFile(DB::Cas::tests::fixture::fixtureLife(ns), "victim"); + }); + + /// The durable delete never ran -- the object survives (reads are not fence-gated by this task). + const auto still_there = store->getNamespaceFile(DB::Cas::tests::fixture::fixtureLife(ns), "victim"); + ASSERT_TRUE(still_there.has_value()); + EXPECT_EQ(*still_there, "still here"); +} + +/// The fence re-check must run before EVERY conditional-retry iteration, not just the first attempt +/// (spec wording, verbatim): a synthetic `PreconditionFailed` forces a second loop iteration, and the +/// fence trips on the SECOND `head()` call. If the check ran only once, at admission, this write would +/// incorrectly succeed on the retry. +TEST(CASFenceGeneration, PlainObjectPutRechecksFenceOnEveryRetryIterationNotJustFirst) +{ + auto backend = std::make_shared(); + auto store = openTestPool(backend); + ASSERT_TRUE(store->mayMutate()); + + const RootNamespace ns{"test/ns"}; + backend->head_calls = 0; /// reset past whatever `Pool::open`'s own probe/mount claim already did + backend->fail_first_put = true; + backend->trigger = [&] { store->tripMountLost(); }; + + DB::Cas::tests::expectThrowsCode(DB::ErrorCodes::NETWORK_ERROR, [&] + { + store->putNamespaceFile(DB::Cas::tests::fixture::fixtureLife(ns), "somefile", "hello"); + }); + + EXPECT_EQ(backend->head_calls, 2); + EXPECT_TRUE(store->listNamespaceFiles(DB::Cas::tests::fixture::fixtureLife(ns)).empty()); +} + +/// (b) The S3-native staging-buffer finalize: the fence trips AFTER the buffer is constructed +/// (admission) but BEFORE `finalize()` reaches the durable `sink->finalize()` call -- same typed abort, +/// and the sink is never actually finalized (`on_finalized` never fires either, so the transaction +/// never learns of a promote-worthy hash/size for bytes that were never durable). +TEST(CASFenceGeneration, S3StagingFinalizeAbortsWhenFenceTripsBeforeDurableCall) +{ + auto backend = std::make_shared(); + auto store = openTestPool(backend); + ASSERT_TRUE(store->mayMutate()); + + const std::string staging_key = "staging/mount1/racer.tmp"; + auto * sink_ptr = new RecordingSink(staging_key); + std::unique_ptr sink(sink_ptr); + + bool on_finalized_called = false; + const uint64_t admitted_generation = store->fenceGeneration(); + + auto buf = std::make_unique( + std::move(sink), + staging_key, + /*envelope_header=*/std::string(), + BlobHashAlgo::CityHash128, + /*buf_size=*/8192, + /*use_adaptive_buffer_size=*/false, + /*adaptive_buffer_initial_size=*/0, + [&](const std::string &, size_t, const std::string &) { on_finalized_called = true; }, + [store, admitted_generation] { store->checkFenceOrThrow(admitted_generation); }); + + const std::string payload = "some bytes that must never become durable"; + buf->write(payload.data(), payload.size()); + + /// The race this test targets: admission already captured `admitted_generation` above, and now the + /// fence trips before `finalize()` runs. + store->tripMountLost(); + + DB::Cas::tests::expectThrowsCode(DB::ErrorCodes::NETWORK_ERROR, [&] { buf->finalize(); }); + + EXPECT_FALSE(on_finalized_called); + EXPECT_FALSE(sink_ptr->wasFinalizedForTest()); +} + +/// (d) Happy path unchanged: an ordinary plain-object write/read/remove, and an ordinary S3-staging +/// finalize, both succeed exactly as before when the fence stays live throughout. +TEST(CASFenceGeneration, HappyPathPlainObjectWriteReadRemoveUnaffected) +{ + auto backend = std::make_shared(); + auto store = openTestPool(backend); + const RootNamespace ns{"test/ns"}; + + store->putNamespaceFile(DB::Cas::tests::fixture::fixtureLife(ns), "a", "hello"); + const auto got = store->getNamespaceFile(DB::Cas::tests::fixture::fixtureLife(ns), "a"); + ASSERT_TRUE(got.has_value()); + EXPECT_EQ(*got, "hello"); + + store->removeNamespaceFile(DB::Cas::tests::fixture::fixtureLife(ns), "a"); + EXPECT_FALSE(store->getNamespaceFile(DB::Cas::tests::fixture::fixtureLife(ns), "a").has_value()); +} + +TEST(CASFenceGeneration, HappyPathS3StagingFinalizeUnaffected) +{ + auto backend = std::make_shared(); + auto store = openTestPool(backend); + + const std::string staging_key = "staging/mount1/happy.tmp"; + auto * sink_ptr = new RecordingSink(staging_key); + std::unique_ptr sink(sink_ptr); + + bool on_finalized_called = false; + const uint64_t admitted_generation = store->fenceGeneration(); + + auto buf = std::make_unique( + std::move(sink), + staging_key, + /*envelope_header=*/std::string(), + BlobHashAlgo::CityHash128, + /*buf_size=*/8192, + /*use_adaptive_buffer_size=*/false, + /*adaptive_buffer_initial_size=*/0, + [&](const std::string &, size_t, const std::string &) { on_finalized_called = true; }, + [store, admitted_generation] { store->checkFenceOrThrow(admitted_generation); }); + + const std::string payload = "unaffected happy path bytes"; + buf->write(payload.data(), payload.size()); + buf->finalize(); + + EXPECT_TRUE(on_finalized_called); + EXPECT_TRUE(sink_ptr->wasFinalizedForTest()); +} + +/// (I2, backlog {#c2-resurrect-putoverwrite-fence-check}) The condemned-displacement `putOverwrite` branch +/// of `PartWriteTxn::uploadFromSource` -- a RAW backend write outside the request controller's fence gate, +/// the last durable write left uncovered by Task 4. Seed a PRESENT, CONDEMNED blob, then trip the fence on +/// the head() that precedes the displacement decision: the [C2] `checkFenceOrThrow` must abort with the +/// typed transient refusal BEFORE `putOverwrite`, leaving the condemned incarnation untouched (no +/// stale-incarnation displacement). +TEST(CASFenceGeneration, CondemnedPutOverwriteAbortsWhenFenceTripsBeforeDurableCall) +{ + auto backend = std::make_shared(); + auto store = openTestPool(backend); + ASSERT_TRUE(store->mayMutate()); + + const std::string payload = "condemned-overwrite-victim"; + const DB::UInt128 hash = DB::Cas::tests::u128Of(payload); + const BlobRef id = DB::Cas::tests::idOf(payload); + const std::string blob_key = store->layout().blobKey(id); + + /// A PRESENT, CONDEMNED incarnation (raw body + Clean meta flipped to Condemned) -- the only state that + /// routes `uploadFromSource` into the streaming `putOverwrite` displacement branch. + const uint64_t header_len = store->poolMeta().blob_header_len; + std::string raw_body(header_len, '\0'); + raw_body += payload; + DB::Cas::tests::writeRawBlobBody(*backend, store->layout(), hash, raw_body); + DB::Cas::tests::writeMetaClean(*backend, store->layout(), hash, payload.size()); + DB::Cas::tests::condemnMeta(*backend, store->layout(), hash, /*condemn_round=*/1); + const DB::Cas::Token condemned_token = backend->head(blob_key).token; + ASSERT_FALSE(condemned_token.empty()); + + auto build = store->beginPartWrite({}); + /// Reset counters past the setup writes (`condemnMeta` itself putOverwrites the meta key) and arm AFTER + /// beginPartWrite (whose W-HEARTBEAT would otherwise consume the one-shot trigger). The first head() + /// after this is the `head(key)` at uploadFromSource's condemned-displacement decision. + backend->put_overwrite_calls = 0; + backend->resurrect_staged_calls = 0; + backend->trigger = [&] { store->tripMountLost(); }; + + DB::Cas::tests::expectThrowsCode(DB::ErrorCodes::NETWORK_ERROR, [&] + { + build->putBlob(id, BlobSource::fromString(payload)); + }); + + /// No displacement write landed: no putOverwrite at all, and the condemned incarnation survives with its + /// original token. + EXPECT_EQ(backend->put_overwrite_calls, 0) << "the fence check must abort before the raw putOverwrite"; + const DB::Cas::HeadResult after = backend->head(blob_key); + ASSERT_TRUE(after.exists); + EXPECT_EQ(after.token, condemned_token) << "the condemned blob must be untouched (INV: never a stale displacement)"; +} + +/// (I2, backlog {#c2-resurrect-putoverwrite-fence-check}) The S3-native-staging sibling: the condemned +/// displacement `resurrect` branch (`BlobSource::server_side_copy_from` set) is the same RAW, +/// controller-uncoupled backend write. Same setup + fence trip -> the [C2] `checkFenceOrThrow` aborts with +/// the typed transient refusal BEFORE `resurrect`, so no unconditional re-upload displaces +/// the condemned incarnation. +TEST(CASFenceGeneration, CondemnedResurrectStagedAbortsWhenFenceTripsBeforeDurableCall) +{ + auto backend = std::make_shared(); + auto store = openTestPool(backend); + ASSERT_TRUE(store->mayMutate()); + + const std::string payload(300, 'c'); + const DB::UInt128 hash = DB::Cas::tests::u128Of(payload); + const BlobRef id = DB::Cas::tests::idOf(payload); + const std::string blob_key = store->layout().blobKey(id); + const std::string staging_key = "p/staging/mount1/ccc.tmp"; + + /// The staging object holds `[header][payload]`; the condemned blob is a plausible prior incarnation. + DB::Cas::EnvelopeHeader staging_h; + staging_h.kind = DB::Cas::ObjectKind::Blob; + staging_h.incarnation_tag = DB::UInt128(0xC0FFEE); + const std::string staging_header = DB::Cas::encodeEnvelopeHeader( + staging_h, static_cast(store->poolMeta().blob_header_len)); + const std::string staging_bytes = staging_header + payload; + backend->putIfAbsent(staging_key, staging_bytes); + backend->putIfAbsent(blob_key, staging_bytes); + DB::Cas::tests::writeMetaClean(*backend, store->layout(), hash, payload.size()); + DB::Cas::tests::condemnMeta(*backend, store->layout(), hash, /*condemn_round=*/5); + const DB::Cas::Token condemned_token = backend->head(blob_key).token; + ASSERT_FALSE(condemned_token.empty()); + + auto build = store->beginPartWrite({}); + backend->put_overwrite_calls = 0; + backend->resurrect_staged_calls = 0; + backend->trigger = [&] { store->tripMountLost(); }; + + DB::Cas::tests::expectThrowsCode(DB::ErrorCodes::NETWORK_ERROR, [&] + { + build->putBlob(id, serverSideCopySource(staging_key, payload.size())); + }); + + /// No unconditional server-side copy displaced the condemned incarnation. + EXPECT_EQ(backend->resurrect_staged_calls, 0) << "the fence check must abort before the raw resurrect"; + const DB::Cas::HeadResult after = backend->head(blob_key); + ASSERT_TRUE(after.exists); + EXPECT_EQ(after.token, condemned_token) << "the condemned blob must be untouched (INV: never a stale displacement)"; +} diff --git a/src/Disks/tests/gtest_cas_fold_seal_codec.cpp b/src/Disks/tests/gtest_cas_fold_seal_codec.cpp new file mode 100644 index 000000000000..feee48a70069 --- /dev/null +++ b/src/Disks/tests/gtest_cas_fold_seal_codec.cpp @@ -0,0 +1,41 @@ +#include +#include + +using namespace DB::Cas; + +/// The GC-reclaim tests that used to live here (`AbandonedPrecommitOrphansManifestUntilFix`, +/// `ReclaimIsIdempotentAndSelfTerminating`, `SkipPreservedForLivePrecommitAndForNoPrecommit`, +/// `DoubleRemovalOfReclaimedPrecommitIsIdempotent`) were removed with the snapshot+log ref model. +/// They asserted that GC reclaims an abandoned precommit once the mount watermark proves it dead, and +/// that the token-diff Skip optimization self-terminates. Per spec §Responsibility Boundary, reclaiming +/// an abandoned precommit is now the WRITER's job (it appends the exact `owner_transition` removal), and +/// the token-diff Skip machinery (`computeDiscoverDecisions`/`discoverDecisionsForTest`) no longer exists +/// -- the "did it change" signal is simply logs above the durable cursor. There is no GC-side reclaim to +/// assert, so these tests are obsolete rather than adaptable. +/// +/// The live-precommit watermark fields (`has_live_precommit`/`min_live_precommit_*`) that fed that +/// removed reclaim were deleted from `RefCoverage` with it (T13). The still-meaningful fold-seal +/// assertion is the round-trip of `last_folded_ref_id` -- the per-table durable ref cursor that replaced +/// them in the same struct under the snapshot+log ref model. +TEST(CASFoldSealCodec, RefLifeCoverageRoundTripsLastFoldedRefId) +{ + CasFoldSeal seal; + seal.generation = 3; + seal.parent_generation = 2; + RefCoverage cov; + cov.classification = 1; + cov.last_folded_ref_id = RefTxnId{4, 11}; + constexpr UInt128 life_id{1}; + seal.ref_lives[life_id].coverage = cov; + + const CasFoldSeal back = decodeFoldSeal(encodeFoldSeal(seal)); + const RefCoverage & r = back.ref_lives.at(life_id).coverage; + EXPECT_EQ(r.last_folded_ref_id, (RefTxnId{4, 11})); + + /// Default (nothing folded) round-trips as {0,0}. + CasFoldSeal empty_seal; + constexpr UInt128 empty_life_id{2}; + empty_seal.ref_lives[empty_life_id].coverage = RefCoverage{}; + const CasFoldSeal e_back = decodeFoldSeal(encodeFoldSeal(empty_seal)); + EXPECT_EQ(e_back.ref_lives.at(empty_life_id).coverage.last_folded_ref_id, (RefTxnId{})); +} diff --git a/src/Disks/tests/gtest_cas_fold_seal_format.cpp b/src/Disks/tests/gtest_cas_fold_seal_format.cpp new file mode 100644 index 000000000000..afe6331a4ed8 --- /dev/null +++ b/src/Disks/tests/gtest_cas_fold_seal_format.cpp @@ -0,0 +1,327 @@ +#include "cas_format_test_battery.h" +#include +#include +#include +#include + +using namespace DB::Cas; + +namespace DB::ErrorCodes { extern const int CORRUPTED_DATA; extern const int LOGICAL_ERROR; } + +namespace +{ +CasFoldSeal sampleFoldSeal() +{ + CasFoldSeal seal; + seal.generation = 7; + seal.parent_generation = 6; + seal.ref_lives[UInt128{1}].coverage = RefCoverage{.classification = 2, .last_folded_ref_id = RefTxnId{3, 4}}; + seal.ref_lives[UInt128{2}].coverage = RefCoverage{.classification = 1}; + seal.blob_target_runs.push_back(RunRef{.key = "gc/gen/7/blob_target/0/0", .checksum = UInt128(0xABCDEF)}); + return seal; +} + +void eraseRequiredField(String & encoded, std::string_view field) +{ + const size_t pos = encoded.find(field); + ASSERT_NE(pos, String::npos); + encoded.erase(pos, field.size()); +} +} + +TEST(CASFormatBattery, FoldSeal) +{ + CasFoldSeal seal; + seal.generation = 5; + seal.parent_generation = 4; + seal.ref_lives[UInt128{1}].coverage = RefCoverage{.classification = 2, .last_folded_ref_id = RefTxnId{7, 11}}; + seal.blob_target_runs.push_back(RunRef{.key = "r0", .checksum = UInt128(0x0f), .shard = 0, .generation = 5}); + seal.condemned_summary[0] = CondemnedSummary{.condemned_total = 3, .pending_total = 1, + .oldest_nonpending_condemn_round = 4}; + runFormatBattery({FormatId::FoldSeal, + [&] { return sealObject(FormatId::FoldSeal, encodeFoldSeal(seal)); }, + [](std::string_view s) { decodeFoldSeal(std::string(openObject(FormatId::FoldSeal, s))); }, + currentFormatHeader("cas_fold_seal") + + "{\"g\":\"5\",\"pg\":\"4\"}\n" + "{\"k\":\"rfl\",\"life\":\"00000000000000000000000000000001\",\"cls\":2,\"lfe\":\"7\",\"lfs\":\"11\"}\n" + "{\"k\":\"btr\",\"key\":\"r0\",\"ck\":\"0000000000000000000000000000000f\",\"shard\":0,\"gen\":\"5\"}\n" + "{\"k\":\"cnd\",\"shard\":0,\"ct\":3,\"pt\":1,\"ocr\":\"4\"}\n" + "{\"n\":3}\n"}); +} + +TEST(CASFoldSealFormat, RoundTripsAllFields) +{ + const CasFoldSeal in = sampleFoldSeal(); + const CasFoldSeal out = decodeFoldSeal(encodeFoldSeal(in)); + + EXPECT_EQ(out.generation, in.generation); + EXPECT_EQ(out.parent_generation, in.parent_generation); + ASSERT_EQ(out.ref_lives.size(), in.ref_lives.size()); + EXPECT_EQ(out.ref_lives.at(UInt128{1}).coverage.classification, 2); + EXPECT_EQ(out.ref_lives.at(UInt128{1}).coverage.last_folded_ref_id, (RefTxnId{3, 4})); + ASSERT_EQ(out.blob_target_runs.size(), 1u); + EXPECT_EQ(out.blob_target_runs[0].key, "gc/gen/7/blob_target/0/0"); + EXPECT_EQ(out.blob_target_runs[0].checksum, UInt128(0xABCDEF)); + EXPECT_EQ(out, in); +} + +TEST(CASFoldSealFormat, AuthoritativeDecodeRejectsTwoBlobTargetRunsForOneShard) +{ + const Layout layout("p"); + CasFoldSeal seal; + seal.generation = 7; + seal.parent_generation = 6; + seal.blob_target_runs = { + RunRef{.key = layout.blobTargetRunKey(7, 1, 0, 0), .checksum = UInt128{1}, .shard = 0, .generation = 7}, + RunRef{.key = layout.blobTargetRunKey(7, 2, 0, 0), .checksum = UInt128{2}, .shard = 0, .generation = 7}, + }; + seal.condemned_summary[0] = CondemnedSummary{}; + + cas_battery_detail::expectCode(DB::ErrorCodes::CORRUPTED_DATA, + [&] { decodeFoldSeal(encodeFoldSeal(seal), layout, /*gc_shards=*/1); }, + "duplicate blob-target shard"); +} + +#if defined(DEBUG_OR_SANITIZER_BUILD) +TEST(CASFoldSealFormatDeathTest, ProducerValidationRejectsMalformedSealBeforePut) +{ + const Layout layout("p"); + CasFoldSeal seal; + seal.blob_target_runs = { + RunRef{.key = layout.blobTargetRunKey(7, 1, 0, 0), .checksum = UInt128{1}, .shard = 0, .generation = 7}, + RunRef{.key = layout.blobTargetRunKey(7, 2, 0, 0), .checksum = UInt128{2}, .shard = 0, .generation = 7}}; + seal.condemned_summary[0] = CondemnedSummary{}; + EXPECT_DEATH({ validateFoldSealForWrite(seal, layout, 1); }, "duplicate blob-target shard"); +} +#else +TEST(CASFoldSealFormat, ProducerValidationRejectsMalformedSealBeforePut) +{ + const Layout layout("p"); + CasFoldSeal seal; + seal.blob_target_runs = { + RunRef{.key = layout.blobTargetRunKey(7, 1, 0, 0), .checksum = UInt128{1}, .shard = 0, .generation = 7}, + RunRef{.key = layout.blobTargetRunKey(7, 2, 0, 0), .checksum = UInt128{2}, .shard = 0, .generation = 7}}; + seal.condemned_summary[0] = CondemnedSummary{}; + cas_battery_detail::expectCode(DB::ErrorCodes::LOGICAL_ERROR, + [&] { validateFoldSealForWrite(seal, layout, 1); }, "duplicate blob-target shard"); +} +#endif + +TEST(CASFoldSealFormat, AuthoritativeDecodeRequiresEveryBlobTargetAndSummaryField) +{ + const Layout layout("p"); + CasFoldSeal seal; + seal.generation = 7; + seal.parent_generation = 6; + seal.blob_target_runs.push_back(RunRef{ + .key = layout.blobTargetRunKey(7, 1, 0, 0), + .checksum = UInt128{1}, + .shard = 0, + .generation = 7}); + seal.condemned_summary[0] = CondemnedSummary{}; + const String valid = encodeFoldSeal(seal); + + for (const std::string_view field : { + R"(,"key":"p/gc/gen/7/attempt/1/blob_target/0/0")", + R"(,"ck":"00000000000000000000000000000001")", + R"(,"gen":"7")", + ",\"ct\":0", + ",\"pt\":0", + R"(,"ocr":"18446744073709551615")"}) + { + String malformed = valid; + eraseRequiredField(malformed, field); + cas_battery_detail::expectCode(DB::ErrorCodes::CORRUPTED_DATA, + [&] { decodeFoldSeal(malformed, layout, 1); }, "missing"); + } + + /// `shard` occurs once on each row; remove each occurrence independently. + String missing_btr_shard = valid; + eraseRequiredField(missing_btr_shard, ",\"shard\":0"); + cas_battery_detail::expectCode(DB::ErrorCodes::CORRUPTED_DATA, + [&] { decodeFoldSeal(missing_btr_shard, layout, 1); }, "missing"); + + String missing_cnd_shard = valid; + const size_t first_shard = missing_cnd_shard.find(",\"shard\":0"); + ASSERT_NE(first_shard, String::npos); + const size_t second_shard = missing_cnd_shard.find(",\"shard\":0", first_shard + 1); + ASSERT_NE(second_shard, String::npos); + missing_cnd_shard.erase(second_shard, std::string_view(",\"shard\":0").size()); + cas_battery_detail::expectCode(DB::ErrorCodes::CORRUPTED_DATA, + [&] { decodeFoldSeal(missing_cnd_shard, layout, 1); }, "missing"); +} + +TEST(CASFoldSealFormat, AuthoritativeDecodeRejectsNoncanonicalRowsAndIncompleteSummaryDomain) +{ + const Layout layout("p"); + CasFoldSeal seal; + seal.generation = 7; + seal.parent_generation = 6; + seal.blob_target_runs.push_back(RunRef{ + .key = layout.blobTargetRunKey(7, 1, 1, 0), + .checksum = UInt128{1}, + .shard = 1, + .generation = 7}); + seal.condemned_summary[0] = CondemnedSummary{}; + + cas_battery_detail::expectCode(DB::ErrorCodes::CORRUPTED_DATA, + [&] { decodeFoldSeal(encodeFoldSeal(seal), layout, 1); }, "outside"); + + seal.blob_target_runs[0].shard = 0; + cas_battery_detail::expectCode(DB::ErrorCodes::CORRUPTED_DATA, + [&] { decodeFoldSeal(encodeFoldSeal(seal), layout, 1); }, "not canonical"); + + seal.blob_target_runs.clear(); + seal.condemned_summary.clear(); + cas_battery_detail::expectCode(DB::ErrorCodes::CORRUPTED_DATA, + [&] { decodeFoldSeal(encodeFoldSeal(seal), layout, 1); }, "exactly 1"); + + seal.condemned_summary[0] = CondemnedSummary{}; + seal.condemned_summary[1] = CondemnedSummary{}; + cas_battery_detail::expectCode(DB::ErrorCodes::CORRUPTED_DATA, + [&] { decodeFoldSeal(encodeFoldSeal(seal), layout, 1); }, "exactly 1"); +} + +TEST(CASFoldSealFormat, AuthoritativeDecodeRejectsContradictorySummaryCounts) +{ + const Layout layout("p"); + CasFoldSeal seal; + seal.condemned_summary[0] = CondemnedSummary{ + .condemned_total = 1, + .pending_total = 2, + .oldest_nonpending_condemn_round = 3}; + cas_battery_detail::expectCode(DB::ErrorCodes::CORRUPTED_DATA, + [&] { decodeFoldSeal(encodeFoldSeal(seal), layout, 1); }, "greater than"); + + seal.condemned_summary[0] = CondemnedSummary{ + .condemned_total = 2, + .pending_total = 1, + .oldest_nonpending_condemn_round = std::numeric_limits::max()}; + cas_battery_detail::expectCode(DB::ErrorCodes::CORRUPTED_DATA, + [&] { decodeFoldSeal(encodeFoldSeal(seal), layout, 1); }, "real oldest"); +} + +TEST(CASFoldSealFormat, RejectsUnexpectedGeneration) +{ + CasFoldSeal seal; + seal.generation = 5; + const String encoded = encodeFoldSeal(seal); + + cas_battery_detail::expectCode(DB::ErrorCodes::CORRUPTED_DATA, + [&] { decodeFoldSeal(encoded, /*expected_generation=*/6); }, "unexpected generation"); + EXPECT_EQ(decodeFoldSeal(encoded, /*expected_generation=*/5).generation, 5); + EXPECT_EQ(decodeFoldSeal(encoded).generation, 5); +} + +TEST(CASFoldSeal, EncodingIsByteDeterministic) +{ + const CasFoldSeal in = sampleFoldSeal(); + EXPECT_EQ(encodeFoldSeal(in), encodeFoldSeal(in)); +} + +TEST(CASFoldSealFormat, TextIsByteDeterministic) +{ + CasFoldSeal a; + a.generation = 5; + a.parent_generation = 4; + a.blob_target_runs = {RunRef{"z", UInt128(2), 1, 5}, RunRef{"a", UInt128(1), 0, 5}}; + CasFoldSeal b = a; + std::reverse(b.blob_target_runs.begin(), b.blob_target_runs.end()); /// same set, different order + EXPECT_EQ(encodeFoldSeal(a), encodeFoldSeal(b)); /// encoder must sort runs by key +} + +TEST(CASFoldSeal, RejectsEmptyAndBadMagic) +{ + EXPECT_ANY_THROW(decodeFoldSeal("")); + EXPECT_ANY_THROW(decodeFoldSeal("not-a-seal")); +} + +TEST(CASFoldSeal, CoverageRecordsEveryCatalogLife) +{ + CasFoldSeal in = sampleFoldSeal(); + in.ref_lives[UInt128{3}].coverage = RefCoverage{.classification = 0}; + const CasFoldSeal out = decodeFoldSeal(encodeFoldSeal(in)); + EXPECT_TRUE(out.ref_lives.contains(UInt128{3})); + EXPECT_EQ(out.ref_lives.size(), 3u); +} + +TEST(CASFoldSeal, FoldSealCondemnedSummaryRoundTrips) +{ + /// A seal carrying a non-empty condemned_summary over 2 shards (one a zero entry) round-trips and + /// compares equal, and the UINT64_MAX "none" sentinel survives. + CasFoldSeal s; + s.generation = 9; + s.parent_generation = 8; + s.ref_lives[UInt128{1}].coverage = RefCoverage{.classification = 2}; + s.blob_target_runs.push_back(RunRef{.key = "gc/gen/9/blob_target/0/0", .checksum = UInt128(0x77), + .shard = 0, .generation = 9}); + s.condemned_summary[0] = CondemnedSummary{.condemned_total = 3, .pending_total = 1, + .oldest_nonpending_condemn_round = 5}; + s.condemned_summary[1] = CondemnedSummary{}; /// explicit zero entry (totality over gc_shards) + + const CasFoldSeal out = decodeFoldSeal(encodeFoldSeal(s)); + EXPECT_EQ(out, s); + ASSERT_EQ(out.condemned_summary.size(), 2u); + EXPECT_EQ(out.condemned_summary.at(0).condemned_total, 3u); + EXPECT_EQ(out.condemned_summary.at(0).pending_total, 1u); + EXPECT_EQ(out.condemned_summary.at(0).oldest_nonpending_condemn_round, 5u); + EXPECT_EQ(out.condemned_summary.at(1).oldest_nonpending_condemn_round, + std::numeric_limits::max()); /// UINT64_MAX sentinel survives + + EXPECT_TRUE(decodeFoldSeal(encodeFoldSeal(CasFoldSeal{})).condemned_summary.empty()); +} + +/// Mutation caught: restoring separate `cov` and `nsc` rows, dropping the cleanup evidence, or +/// serializing the row under a logical namespace changes these literal generation-8 bytes. +TEST(CASFoldSealFormat, UnifiedRefLifeRowRoundTripsCoverageHoldAndCleanupEvidence) +{ + CasFoldSeal seal; + seal.generation = 8; + seal.parent_generation = 7; + const UInt128 life_id{0x1234}; + seal.ref_lives.emplace(life_id, RefLifeFoldState{ + .coverage = RefCoverage{ + .classification = 4, + .last_folded_ref_id = RefTxnId{3, 4}, + .hold = RefHold{ + .reason = HoldReason::ManifestBodyMissing, + .offending_position = RefTxnId{5, 6}, + .retry_count = 7, + .next_retry_round = 8}}, + .cleanup_evidence = RefCleanupEvidence{.remove_txn_id = RefTxnId{9, 10}}}); + + const String expected = currentFormatHeader("cas_fold_seal") + + "{\"g\":\"8\",\"pg\":\"7\"}\n" + "{\"k\":\"rfl\",\"life\":\"00000000000000000000000000001234\",\"cls\":4," + "\"lfe\":\"3\",\"lfs\":\"4\",\"hr\":\"manifest_body_missing\",\"hpe\":\"5\"," + "\"hps\":\"6\",\"hrc\":7,\"hnr\":\"8\",\"rte\":\"9\",\"rts\":\"10\"}\n" + "{\"n\":1}\n"; + + EXPECT_EQ(encodeFoldSeal(seal), expected); + EXPECT_EQ(decodeFoldSeal(expected), seal); +} + +/// Mutation caught: accepting the generation-6 split coverage collection would leave a second +/// namespace-keyed source of lifecycle work in a generation-7 process. +TEST(CASFoldSealFormat, UnifiedCodecRejectsLegacyCoverageRecord) +{ + const String old = + "{\"type\":\"cas_fold_seal\",\"v\":7}\n" + "{\"g\":\"8\",\"pg\":\"7\"}\n" + "{\"k\":\"cov\",\"key\":\"name/0\",\"cls\":2,\"lfe\":\"3\",\"lfs\":\"4\"}\n" + "{\"n\":1}\n"; + cas_battery_detail::expectCode(DB::ErrorCodes::CORRUPTED_DATA, [&] { decodeFoldSeal(old); }, "legacy coverage"); +} + +/// Mutation caught: accepting the generation-6 cleanup-item state would restore the independent +/// marker-driven `Pending`/`Completed` handshake. +TEST(CASFoldSealFormat, UnifiedCodecRejectsLegacyNamespaceCleanupRecord) +{ + const String old = + "{\"type\":\"cas_fold_seal\",\"v\":7}\n" + "{\"g\":\"8\",\"pg\":\"7\"}\n" + "{\"k\":\"nsc\",\"ns\":\"name\",\"rte\":\"3\",\"rts\":\"4\",\"st\":\"completed\"}\n" + "{\"n\":1}\n"; + cas_battery_detail::expectCode( + DB::ErrorCodes::CORRUPTED_DATA, [&] { decodeFoldSeal(old); }, "legacy namespace cleanup"); +} diff --git a/src/Disks/tests/gtest_cas_forget.cpp b/src/Disks/tests/gtest_cas_forget.cpp new file mode 100644 index 000000000000..f92b2f67ac4f --- /dev/null +++ b/src/Disks/tests/gtest_cas_forget.cpp @@ -0,0 +1,600 @@ +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +/// Task 10 (rev.7 spec §5): `SYSTEM CAS FORGET` — the operator force-Vanish. FORGET drives a +/// content-addressed pool to `Vanished(forgotten)` with the fence-first protocol: (1) publish terminal +/// intent, (2) trip the local fence, (3+4) stop the GC scheduler, (5) join keeper/remount, drain, retire +/// the keeper WITHOUT an unearned clean farewell, (6) publish `Vanished(forgotten)` with the [D5] message +/// carrying the decommission timestamp. These tests exercise the Pool-level protocol body (`Pool::forgetDisk`) +/// and the end-to-end verb through a real `ContentAddressedMetadataStorage` (the six-class gate wired to the +/// new state). Harness patterns follow gtest_cas_lifecycle_condition.cpp and gtest_cas_operation_gate.cpp. + +namespace DB::ErrorCodes +{ +extern const int INVALID_STATE; +} + +using namespace DB; +using DB::Cas::PoolLifecycle; +using DB::Cas::tests::CountingBackend; + +namespace +{ + +const String kSrid = "test"; + +/// A test-authored [D5] reason with a RECOGNIZABLE timestamp — the Pool-level tests assert this exact +/// string flows through `enterVanished` into the `throwIfLifecycleTerminal` message (the timestamp +/// threading the metadata storage does in production). It keeps the two [D5] substrings the gate relies on. +const String kForgetReason = + "decommissioned by SYSTEM CAS FORGET at 2099-01-02 03:04:05 UTC — erasure was NOT " + "verified; if this was a mistake the data may be intact (restart re-registers the name)"; + +/// Delete an existing key exactly (its current token comes from the same GET). Mirrors +/// gtest_cas_lifecycle_condition.cpp — used to drive a live pool into `IdentityLost`. +void deleteKeyExact(DB::Cas::Backend & backend, const String & key) +{ + const auto got = backend.get(key); + ASSERT_TRUE(got.has_value()) << "expected '" << key << "' to exist before deletion"; + if (got) + backend.deleteExact(key, got->token); +} + +/// GC's fence-out applied directly to the mount lease (preserve the body, set `gc_fenced`, bump `seq`) — +/// a subsequent `tryRemountOnce` verdicts `Recover` and reclaims a FRESH incarnation immediately (no +/// lease-expiry wait), reaching `armMountFence`. Mirrors gtest_cas_lifecycle_condition.cpp's helper. +void fenceOutMount(DB::Cas::Backend & backend, const String & mount_key) +{ + const auto got = backend.get(mount_key); + ASSERT_TRUE(got.has_value()); + DB::Cas::MountLease m = DB::Cas::decodeMountLease(got->bytes); + m.gc_fenced = true; + m.seq += 1; + ASSERT_EQ(backend.putOverwrite(mount_key, DB::Cas::encodeMountLease(m), got->token).outcome, + DB::Cas::PutOutcome::Done); +} + +/// A Backend decorator whose head/get/list throw an untyped transport error while `fail` is armed — so a +/// self-remount attempt verdicts `StayTransient` (fast, no lease-expiry wait) and the remount loop keeps +/// spinning. Starts DISARMED so `Pool::open` succeeds. Mirrors gtest_cas_lifecycle_condition.cpp's decorator. +class ToggleableTransportFaultBackend final : public DB::Cas::InMemoryBackend +{ +public: + using Backend::get; + using Backend::getStream; + using Backend::putIfAbsent; + using Backend::putIfAbsentStream; + using Backend::putOverwrite; + using Backend::casPut; + + DB::Cas::HeadResult head(const String & key) override + { + if (fail.load()) + throw std::runtime_error("injected fault: transport error"); + return InMemoryBackend::head(key); + } + std::optional get(const String & key, DB::Cas::Range range) override + { + if (fail.load()) + throw std::runtime_error("injected fault: transport error"); + return InMemoryBackend::get(key, range); + } + DB::Cas::ListPage list(const String & prefix, const String & cursor, size_t limit) override + { + if (fail.load()) + throw std::runtime_error("injected fault: transport error"); + return InMemoryBackend::list(prefix, cursor, limit); + } + + std::atomic fail{false}; +}; + +/// The message thrown by `fn`, or a failure if it did not throw a `DB::Exception`. +std::string messageOf(const std::function & fn) +{ + try + { + fn(); + } + catch (const Exception & e) + { + return std::string(e.message()); + } + ADD_FAILURE() << "expected a DB::Exception"; + return {}; +} + +/// A live table dir + committed part reused by the end-to-end gate test (the shape +/// gtest_cas_operation_gate.cpp uses). +const std::string kTableDir = "gg0/gg0gg0g0-0808-4808-8808-080808080808"; +const std::string kPartDir = kTableDir + "/all_1_1_0"; +const std::string kPartFile = kPartDir + "/data.bin"; + +std::shared_ptr openForgetStorage() +{ + auto settings = Cas::tests::makeSettingsForTest( + "test", std::filesystem::temp_directory_path() / "ca_forget_scratch"); + auto storage = std::make_shared( + Cas::tests::makeLocalObjectStorageForTest(), "pool", "srv1", "", nullptr, settings); + storage->startup(); + return storage; +} + +void commitOnePart(ContentAddressedMetadataStorage & storage) +{ + auto tx = storage.createTransaction(); + auto & ca_tx = dynamic_cast(*tx); + auto buf = ca_tx.writeFile(kTableDir + "/tmp_insert_all_1_1_0/data.bin", 65536, WriteMode::Rewrite, {}); + const std::string bytes = "content-of-the-part"; + buf->write(bytes.data(), bytes.size()); + buf->finalize(); + tx->moveDirectory(kTableDir + "/tmp_insert_all_1_1_0", kPartDir); + tx->commit(NoCommitOptions{}); +} + +/// Deterministically interleave a real FORGET into the admission->lock window of a manual GC verb (the +/// I-1/I-2 admission TOCTOU), with BOUNDED condition-variable waits and never a sleep. The sequence pinned: +/// M (this thread, running `gc_verb`): passes the verb's pre-lock admission gate while `Live`, then the +/// installed seam signals `admitted` and blocks until `forget_done`, then M resumes to acquire +/// `gc_scheduler_mutex` and hit the under-lock re-check. +/// F (the FORGET thread): waits for `admitted`, runs the REAL `forgetDisk` (acquiring lifecycle + +/// gc_scheduler mutexes while M holds NEITHER -- M is parked in the seam BEFORE the lock), settling the +/// pool `Vanished(forgotten)`, then signals `forget_done`. +/// Returns the exception message `gc_verb` threw (via `messageOf`), so the caller asserts the typed [D5] +/// refusal. The 30s bounds trip ONLY on a genuine deadlock regression, never in the happy path. +std::string raceForgetIntoGcVerbWindow(ContentAddressedMetadataStorage & storage, + const std::function & gc_verb) +{ + std::mutex m; + std::condition_variable cv; + bool admitted = false; + bool forget_done = false; + + storage.setGcVerbAdmitWindowHookForTest([&] + { + { + std::lock_guard lk(m); + admitted = true; + } + cv.notify_all(); + std::unique_lock lk(m); + EXPECT_TRUE(cv.wait_for(lk, std::chrono::seconds(30), [&] { return forget_done; })) + << "the concurrent FORGET must complete within the bound (else the interleave deadlocked)"; + }); + + std::thread forgetter([&] + { + { + std::unique_lock lk(m); + EXPECT_TRUE(cv.wait_for(lk, std::chrono::seconds(30), [&] { return admitted; })) + << "the GC verb must reach the admission->lock window before FORGET runs"; + } + storage.forgetDisk(); + { + std::lock_guard lk(m); + forget_done = true; + } + cv.notify_all(); + }); + + const std::string msg = messageOf(gc_verb); + forgetter.join(); + storage.setGcVerbAdmitWindowHookForTest({}); /// clear the seam (references this frame's locals) + return msg; +} + +} + +/// (a) FORGET on a LIVE pool: the local fence is tripped, the injected GC-stop step runs, the pool settles +/// `Vanished(forgotten)`, and store-class access fails loud with the timestamped [D5] message. +TEST(CASForget, ForgetOnLivePoolTripsFenceAndVanishes) +{ + auto backend = std::make_shared(); + auto store = DB::Cas::tests::openPoolForTest(backend); + ASSERT_EQ(store->lifecycle(), PoolLifecycle::Live); + ASSERT_TRUE(store->mayMutate()); + + bool gc_stopped = false; + store->forgetDisk([&] { gc_stopped = true; }, kForgetReason); + + /// Step 3/4 ran (the GC-stop callback was invoked from inside the protocol). + EXPECT_TRUE(gc_stopped); + /// Terminal truth, fence tripped. + EXPECT_EQ(store->lifecycle(), PoolLifecycle::VanishedForgotten); + EXPECT_TRUE(store->isVanished()); + EXPECT_FALSE(store->mayMutate()); + + /// The [D5] message carries the operator's FORGET timestamp (threaded through the reason) and still + /// names the sub-state ("erasure was NOT verified"). + const std::string msg = messageOf([&] { store->throwIfLifecycleTerminal(); }); + EXPECT_NE(msg.find("SYSTEM CAS FORGET at "), std::string::npos) << msg; + EXPECT_NE(msg.find("2099-01-02 03:04:05 UTC"), std::string::npos) << msg; + EXPECT_NE(msg.find("erasure was NOT verified"), std::string::npos) << msg; +} + +/// (a') FORGET stops AND joins a real `CasGcScheduler`'s worker + heartbeat threads (the injected GC-stop +/// step). A long interval keeps any round from firing during the test window, so this isolates the +/// thread-lifecycle: `start()` spawns the two workers, FORGET's callback `stop()`s + joins them, and the +/// test completing (no hang) plus a clean `isQuiescent()` proves the join. +TEST(CASForget, ForgetStopsAndJoinsRealGcScheduler) +{ + auto backend = std::make_shared(); + auto store = DB::Cas::tests::openPoolForTest(backend); + + Cas::CasGcScheduler sched(store, std::chrono::seconds(3600), "CasForgetTest", "forget-disk"); + sched.start(); + + bool gc_joined = false; + store->forgetDisk([&] { sched.stop(); gc_joined = true; }, kForgetReason); + + EXPECT_TRUE(gc_joined); + /// What this proves is the JOIN: `stop()` returned, so the worker + heartbeat threads are joined and + /// the test could not have hung; `isQuiescent()` confirms no round is in flight. NOTE: the callback is + /// only `sched.stop()`, which does NOT itself clear the in-process `i_am_leader` hint — the + /// metadata-storage handler clears leadership by DESTROYING the scheduler (see + /// `ContentAddressedMetadataStorage::forgetDisk`), so asserting `is_leader == false` here would be + /// vacuous (this 3600s scheduler never led) or, after a real round, wrong. + EXPECT_TRUE(sched.isQuiescent()) << "no GC round may be in flight after FORGET joined the scheduler"; + EXPECT_EQ(store->lifecycle(), PoolLifecycle::VanishedForgotten); +} + +/// (c) Double FORGET is idempotent: the second call is a no-op (the pool is already `Vanished(forgotten)`), +/// so it never re-runs the protocol — the GC-stop callback is NOT invoked again, and the first reason wins. +TEST(CASForget, DoubleForgetIsIdempotent) +{ + auto backend = std::make_shared(); + auto store = DB::Cas::tests::openPoolForTest(backend); + + int gc_stops = 0; + store->forgetDisk([&] { ++gc_stops; }, kForgetReason); + ASSERT_EQ(store->lifecycle(), PoolLifecycle::VanishedForgotten); + ASSERT_EQ(gc_stops, 1); + + /// A second FORGET with a DIFFERENT reason must change nothing (first terminal transition wins) and + /// must NOT re-enter the teardown (idempotent short-circuit on `isVanished()`). + store->forgetDisk([&] { ++gc_stops; }, "a different reason that must be ignored"); + EXPECT_EQ(store->lifecycle(), PoolLifecycle::VanishedForgotten); + EXPECT_EQ(gc_stops, 1) << "the idempotent second FORGET must not re-run the protocol"; + + const std::string msg = messageOf([&] { store->throwIfLifecycleTerminal(); }); + EXPECT_NE(msg.find("2099-01-02 03:04:05 UTC"), std::string::npos) + << "the first FORGET's reason must win: " << msg; +} + +/// (d) FORGET on an `IdentityLost` pool → `Vanished(forgotten)` — the escape hatch. `IdentityLost` is +/// non-absorbing and has no benign answer, so FORGET is the operator's way out. +TEST(CASForget, ForgetOnIdentityLostPoolVanishesForgotten) +{ + auto backend = std::make_shared(); + auto store = DB::Cas::tests::openPoolForTest(backend); + + /// Delete both pool sentinels while other objects remain, then drive the identity gate: the pool enters + /// `IdentityLost` (never `Vanished`) — exactly gtest_cas_lifecycle_condition.cpp scenario (a). + deleteKeyExact(*backend, store->layout().poolMetaKey()); + deleteKeyExact(*backend, store->layout().ownerKey(kSrid)); + EXPECT_FALSE(store->tryRemountOnce()); + ASSERT_EQ(store->lifecycle(), PoolLifecycle::IdentityLost); + ASSERT_FALSE(store->isVanished()); + + bool gc_stopped = false; + store->forgetDisk([&] { gc_stopped = true; }, kForgetReason); + + EXPECT_TRUE(gc_stopped); + EXPECT_EQ(store->lifecycle(), PoolLifecycle::VanishedForgotten); + EXPECT_TRUE(store->isVanished()); +} + +/// (a'') The clean-farewell is EARNED, never unconditional: on a drained pool FORGET stamps the mount lease +/// with the terminated sentinel (`min_active == UINT64_MAX`) so a same-server restart reclaims immediately, +/// but with an UNSETTLED (wedged) ref lane it must NOT — the lease is left to expire by observation. +TEST(CASForget, ForgetCleanFarewellGatedOnDrain) +{ + using DB::Cas::decodeMountLease; + constexpr uint64_t kTerminated = std::numeric_limits::max(); + + /// Drained pool → clean farewell written (lease stamped terminated). + { + auto backend = std::make_shared(); + auto store = DB::Cas::tests::openPoolForTest(backend); + const String mount_key = store->layout().mountKey(kSrid); + ASSERT_NE(decodeMountLease(backend->get(mount_key)->bytes).min_active, kTerminated); /// baseline + + store->forgetDisk([] {}, kForgetReason); + ASSERT_EQ(store->lifecycle(), PoolLifecycle::VanishedForgotten); + + const auto got = backend->get(mount_key); + ASSERT_TRUE(got.has_value()); + EXPECT_EQ(decodeMountLease(got->bytes).min_active, kTerminated) + << "a drained FORGET earns the clean-release farewell"; + } + + /// Unsettled (wedged) ref lane → NO clean farewell (the drain cannot certify a clean death). + { + auto backend = std::make_shared(); + auto store = DB::Cas::tests::openPoolForTest(backend); + const String mount_key = store->layout().mountKey(kSrid); + + const DB::Cas::RootNamespace ns{"test/forget_wedge"}; + store->forceWedgeForTest(ns, /*writer_epoch*/ 1, /*ref_sequence*/ 1, "bogus/_log/key", "bogus-bytes"); + ASSERT_TRUE(store->refLaneWedgedForTest(ns)); + + store->forgetDisk([] {}, kForgetReason); + ASSERT_EQ(store->lifecycle(), PoolLifecycle::VanishedForgotten); + + const auto got = backend->get(mount_key); + ASSERT_TRUE(got.has_value()) << "the lease object must still be present (expiry by observation)"; + EXPECT_NE(decodeMountLease(got->bytes).min_active, kTerminated) + << "an unearned clean farewell must NOT be written when the ref lanes did not drain"; + } +} + +/// (b1) BOUNDED COMPLETION: FORGET racing an ACTIVE self-remount thread joins it without deadlock. Here the +/// faulting backend keeps every attempt at `StayTransient` (it never reaches `armMountFence`), so this +/// isolates the join/no-deadlock property; the fence re-arm path is covered by (b2) below. Uses a +/// `std::future` timeout wait (never a sleep) — the timeout only fires on a genuine deadlock regression. +TEST(CASForget, ForgetRacingActiveRemountThreadCompletesBounded) +{ + auto backend = std::make_shared(); + /// `background_watermark = true` so `scheduleRemount` actually spawns a recovery thread (mirrors + /// gtest_cas_pool.cpp's ShutdownGuardRefusesToArmRemount setup). + auto store = DB::Cas::Pool::open(backend, + DB::Cas::PoolConfig{.pool_prefix = "p", .server_root_id = "test", .background_watermark = true}); + + /// Arm the fault so every remount attempt verdicts `StayTransient` fast (no lease-expiry wait), then + /// trip the fence and spawn the recovery thread — it now loops `tryRemountOnce` against the fault. + backend->fail.store(true); + store->tripMountLost(); + ASSERT_TRUE(store->scheduleRemountForTest()) << "the recovery thread must be armed and running"; + + /// FORGET from ANOTHER thread must join the active remount thread and finish in bounded time. + std::promise done; + auto fut = done.get_future(); + std::thread forgetter([&] + { + store->forgetDisk([] {}, kForgetReason); + done.set_value(); + }); + EXPECT_EQ(fut.wait_for(std::chrono::seconds(30)), std::future_status::ready) + << "FORGET must not deadlock against an in-flight self-remount"; + forgetter.join(); + + /// Disarm before ~Pool so its residual teardown is not fighting the injected fault. + backend->fail.store(false); + + EXPECT_EQ(store->lifecycle(), PoolLifecycle::VanishedForgotten); + EXPECT_FALSE(store->mayMutate()) << "the fence must stay latched even if a raced reclaim re-armed it"; +} + +/// (b2) FENCE RE-LATCH REGRESSION GUARD (the fix's raison d'être): a self-remount that reaches +/// `armMountFence` re-arms the local fence (`lost=false`) after FORGET has already tripped it. FORGET's +/// SECOND `tripMountLost` — placed AFTER the remount thread is joined — must override it. +/// +/// (b1)'s fault keeps every attempt at `StayTransient`, so it can NOT catch removal of that second trip. To +/// make EXACTLY ONE reclaim reach `armMountFence` inside FORGET's window, deterministically and without a +/// sleep, we drive a REAL `tryRemountOnce` from FORGET's own GC-stop step (invoked at spec §5 step 3/4, +/// strictly AFTER the fence trip): the mount is fenced-out so the reclaim succeeds fast and re-arms the +/// fence, and `tryRemountOnce`'s step-0 gate checks `isVanished()` — still false in this window — so it does +/// NOT bail. The re-arm therefore lands after trip#1 and before trip#2, exactly the interval trip#2 guards. +/// Verified to go RED when trip#2 is removed (see task-10-report.md — test_task10b_reddemo.log). +TEST(CASForget, ForgetReLatchesFenceAfterAReclaimReachesArmMountFence) +{ + auto backend = std::make_shared(); + auto store = DB::Cas::tests::openPoolForTest(backend); + + /// Make the current mount claimable so a self-remount SUCCEEDS fast and reaches `armMountFence`. + fenceOutMount(*backend, store->layout().mountKey(kSrid)); + + bool reclaimed = false; + store->forgetDisk([&] { reclaimed = store->tryRemountOnce(); }, kForgetReason); + + /// Guard against a vacuous pass: if the injected reclaim did not actually succeed (reach + /// `armMountFence`), there is no re-arm for trip#2 to override and the test proves nothing. + ASSERT_TRUE(reclaimed) << "the injected reclaim must reach armMountFence, else this guard is vacuous"; + EXPECT_EQ(store->lifecycle(), PoolLifecycle::VanishedForgotten); + EXPECT_FALSE(store->mayMutate()) + << "FORGET's post-join fence re-latch (trip#2) must override the fence the reclaim re-armed"; +} + +/// (b3) PROMOTION-GUARD REGRESSION (spec §9 rev.8 item 7): with the erasure-proof excised, the natural +/// `Vanished(replaced)` verdict is the ONLY remaining mid-FORGET natural-terminal race. A `tryRemountOnce` +/// in flight during FORGET — one that passed step 0's `isVanished()` gate before FORGET published its intent +/// — must NOT settle `Vanished(replaced)` and mislabel the operator-visible reason; FORGET's +/// `Vanished(forgotten)` must win. We drive a REAL `tryRemountOnce` from FORGET's own GC-stop step (spec §5 +/// step 3/4, strictly AFTER the step-1 intent publish, BEFORE the step-6 settle), against a FOREIGN +/// `_pool_meta` (the `Replaced` verdict), and assert the guard bailed. +TEST(CASForget, ForgetIntentBlocksNaturalReplacedPromotion) +{ + auto backend = std::make_shared(); + auto store = DB::Cas::tests::openPoolForTest(backend); + + /// Make the identity gate verdict `Replaced`: overwrite `_pool_meta` with a FOREIGN pool_id (present, + /// mismatched identity) — exactly gtest_cas_lifecycle_condition.cpp scenario (b). + const String meta_key = store->layout().poolMetaKey(); + const auto got = backend->get(meta_key); + ASSERT_TRUE(got.has_value()); + DB::Cas::PoolMeta foreign = DB::Cas::decodePoolMeta(got->bytes); + foreign.pool_id = foreign.pool_id + DB::UInt128(1); + ASSERT_EQ(backend->putOverwrite(meta_key, DB::Cas::encodePoolMeta(foreign), got->token).outcome, + DB::Cas::PutOutcome::Done); + + /// The in-flight gate (run from the GC-stop callback) reaches the `Replaced` verdict but must BAIL on the + /// already-published intent rather than settle `Vanished(replaced)`. + bool replaced_settled_midforget = false; + store->forgetDisk( + [&] + { + store->tryRemountOnce(); + replaced_settled_midforget = (store->lifecycle() == PoolLifecycle::VanishedReplaced); + }, + kForgetReason); + + EXPECT_FALSE(replaced_settled_midforget) + << "a mid-FORGET Replaced verdict must NOT settle — the intent guard bails before enterVanished(Replaced)"; + EXPECT_EQ(store->lifecycle(), PoolLifecycle::VanishedForgotten) + << "FORGET's Vanished(forgotten) must win (first terminal STATE transition)"; + const std::string msg = messageOf([&] { store->throwIfLifecycleTerminal(); }); + EXPECT_NE(msg.find("erasure was NOT verified"), std::string::npos) << msg; + EXPECT_EQ(msg.find("foreign pool"), std::string::npos) + << "the reason must NOT be the mislabeled Replaced text: " << msg; +} + +/// (e) End-to-end through the verb entry `ContentAddressedMetadataStorage::forgetDisk` and the six-class +/// gate: after FORGET, a Probe answers truth-absent, a Remove no-ops, and a content read throws the [D5] +/// message with the REAL decommission timestamp produced by the handler. +TEST(CASForget, ForgetEndToEndGatesTruthWithTimestampedMessage) +{ + auto storage = openForgetStorage(); + commitOnePart(*storage); + ASSERT_TRUE(storage->existsFile(kPartFile)); /// Live baseline + + storage->forgetDisk(); + + /// Probe → truth-absent (no throw): the committed part reads absent on a forgotten disk. + EXPECT_FALSE(storage->existsFile(kPartFile)); + EXPECT_FALSE(storage->existsDirectory(kPartDir)); + + /// Remove → no-op success (this is what lets a forgotten-disk table's DROP complete). + EXPECT_NO_THROW({ + auto tx = storage->createTransaction(); + tx->removeRecursive(kTableDir, /*should_remove_objects=*/nullptr); + tx->commit(NoCommitOptions{}); + }); + + /// Content read → the typed [D5] message, with the handler's real UTC timestamp. + const std::string msg = messageOf([&] { storage->getFileSize(kPartFile); }); + EXPECT_NE(msg.find("SYSTEM CAS FORGET at "), std::string::npos) << msg; + EXPECT_NE(msg.find(" UTC"), std::string::npos) << msg; + EXPECT_NE(msg.find("erasure was NOT verified"), std::string::npos) << msg; +} + +/// (I-1 regression) A manual `SYSTEM CAS GC RUN` admitted while `Live` but that acquires +/// `gc_scheduler_mutex` strictly AFTER a concurrent FORGET completes must NOT resurrect a `CasGcScheduler` +/// on the now-`Vanished` pool: the under-lock admission re-check refuses with the typed [D5] message. The +/// interleave is deterministic (bounded cv waits, no sleep) — the GC-verb seam parks the RUN in the +/// admission→lock window while the FORGET thread drives the real teardown. The lasting-damage observable is +/// `gcHealth()` staying empty: a resurrected scheduler (the pre-fix behavior) would make it non-empty. +/// Verified RED against the pre-fix ordering (see task-17-report.md). +TEST(CASForget, GcRunAdmittedWhileLiveRefusesAfterConcurrentForget) +{ + auto storage = openForgetStorage(); + /// Capture the pool while Live (store() is fail-closed once Vanished) to assert its terminal state after. + auto pool = storage->store(); + ASSERT_EQ(pool->lifecycle(), PoolLifecycle::Live); + ASSERT_FALSE(storage->gcHealth().has_value()) + << "no scheduler exists before the first GC round (unit-test null context creates none at startup)"; + + const std::string msg = raceForgetIntoGcVerbWindow( + *storage, [&] { storage->runGarbageCollectionRoundNow(); }); + + /// The refusal is the typed FORGET [D5] message (an under-lock admission throw), not a round-internal + /// error and not a silently-run round. + EXPECT_NE(msg.find("erasure was NOT verified"), std::string::npos) << msg; + /// The I-1 lasting-damage observable: NO scheduler was created on the decommissioned pool. + EXPECT_FALSE(storage->gcHealth().has_value()) + << "a GC RUN refused post-FORGET must NOT resurrect the scheduler on a Vanished pool"; + EXPECT_EQ(pool->lifecycle(), PoolLifecycle::VanishedForgotten); +} + +/// (I-2 regression) A `SYSTEM CAS GC REBUILD` holds `gc_scheduler_mutex` for its whole +/// duration, so a concurrent FORGET must SERIALIZE behind it — FORGET cannot report the disk decommissioned +/// while the rebuild is still issuing durable `gc/`-plane writes. Deterministic (bounded cv waits + a bounded +/// negative future poll anchored by a positive control, never a sleep-to-fix-a-race): the in-lock seam parks +/// the rebuild WHILE it holds the mutex; a FORGET launched in that window must NOT complete until the rebuild +/// releases the lock. The pre-fix `runGcRebuildNow` took NO lock, so an in-flight rebuild was invisible to +/// FORGET and FORGET would complete immediately. Verified RED against the pre-fix code (see task-17-report.md). +TEST(CASForget, GcRebuildInFlightSerializesForget) +{ + auto storage = openForgetStorage(); + auto pool = storage->store(); /// captured while Live + ASSERT_EQ(pool->lifecycle(), PoolLifecycle::Live); + + std::mutex m; + std::condition_variable cv; + bool rebuild_holds_lock = false; + bool may_release = false; + + /// In-lock seam: fires WHILE the rebuild holds `gc_scheduler_mutex`. It parks there (bounded) until the + /// coordinator has verified FORGET is blocked, then lets the rebuild finish and release the lock. + storage->setGcVerbAdmitWindowHookForTest([&] + { + { + std::lock_guard lk(m); + rebuild_holds_lock = true; + } + cv.notify_all(); + std::unique_lock lk(m); + EXPECT_TRUE(cv.wait_for(lk, std::chrono::seconds(30), [&] { return may_release; })) + << "the coordinator must release the in-flight rebuild within the bound"; + }); + + /// The rebuild runs on its own thread; it holds the lock through the seam above. + std::promise rebuild_done_p; + auto rebuild_done = rebuild_done_p.get_future(); + std::thread rebuilder([&] + { + /// On release the pool may already be Vanished (RED path: FORGET ran unserialized) — `store()` then + /// throws; swallow it, the assertions below carry the verdict. + try { storage->runGcRebuildNow(/*force=*/false); } catch (...) {} // NOLINT(bugprone-empty-catch) + rebuild_done_p.set_value(); + }); + + /// Wait until the rebuild is genuinely in flight (holding the lock). + { + std::unique_lock lk(m); + ASSERT_TRUE(cv.wait_for(lk, std::chrono::seconds(30), [&] { return rebuild_holds_lock; })) + << "the rebuild must reach its in-lock seam"; + } + + /// Launch FORGET while the rebuild holds the lock. With the fix it BLOCKS on `gc_scheduler_mutex`; + /// without the fix (pre-fix rebuild took no lock) it runs straight through. + std::promise forget_done_p; + auto forget_done = forget_done_p.get_future(); + std::thread forgetter([&] { storage->forgetDisk(); forget_done_p.set_value(); }); + + /// The discriminator: FORGET must NOT complete while the rebuild holds the lock. This bounded negative + /// observation is anchored by the positive control below (FORGET DOES complete once the lock releases), + /// so the window's meaning is real, not a race hidden behind a sleep. + EXPECT_EQ(forget_done.wait_for(std::chrono::seconds(2)), std::future_status::timeout) + << "FORGET must serialize behind an in-flight GC rebuild (the rebuild holds gc_scheduler_mutex)"; + + /// Release the in-flight rebuild; it finishes and drops the lock, and FORGET can now proceed. + { + std::lock_guard lk(m); + may_release = true; + } + cv.notify_all(); + + ASSERT_EQ(rebuild_done.wait_for(std::chrono::seconds(30)), std::future_status::ready) + << "the rebuild must complete after release"; + ASSERT_EQ(forget_done.wait_for(std::chrono::seconds(30)), std::future_status::ready) + << "once the rebuild releases the lock, the serialized FORGET completes (positive control)"; + + rebuilder.join(); + forgetter.join(); + storage->setGcVerbAdmitWindowHookForTest({}); + + EXPECT_EQ(pool->lifecycle(), PoolLifecycle::VanishedForgotten) + << "FORGET settles the pool Vanished(forgotten) once it is no longer serialized behind the rebuild"; +} diff --git a/src/Disks/tests/gtest_cas_format.cpp b/src/Disks/tests/gtest_cas_format.cpp new file mode 100644 index 000000000000..de1b3e5109b0 --- /dev/null +++ b/src/Disks/tests/gtest_cas_format.cpp @@ -0,0 +1,85 @@ +#include +#include +#include + +namespace DB::ErrorCodes +{ + extern const int UNKNOWN_FORMAT_VERSION; + extern const int LOGICAL_ERROR; +} + +using namespace DB::Cas; + +TEST(CASFormat, ChangePointsExistForEveryClass) +{ + /// Every class that existed from the start has a non-empty, gen-1 baseline. + for (auto id : {FormatId::Blob, + FormatId::GcState, + FormatId::PoolMeta, FormatId::Roster, + FormatId::GcOutcomes, + FormatId::PartManifest, FormatId::RunFile, + FormatId::FoldSeal}) + { + auto cps = changePoints(id); + ASSERT_FALSE(cps.empty()); + EXPECT_EQ(cps.front().generation, 1u); + EXPECT_EQ(cps.front().min_reader, 1u); + } +} + +/// A class BORN after generation 1 begins its history at its birth generation, not at 1. `RefCkpt` +/// (spec INV-4) was introduced at generation 4: there is no such thing as a generation-1 `_ckpt`, and a +/// `{1, 1}` baseline would assert that a generation-1 reader could read one. Its history then gained a +/// three later breaking entries: generation 5 re-keyed it under `//`, generation 6 +/// moved it to opaque life-owned state, and generation 9 added the exact committed frontier. Neither +/// change touches the gen-1 baseline. Pinned because the decision is +/// invisible otherwise — nothing consults `changePoints` at decode time yet, so a wrong entry here +/// would sit unnoticed until the day a per-class reader floor is wired and starts admitting objects it +/// should refuse. +TEST(CASFormat, ChangePointsOfAClassBornAfterGenerationOneStartAtItsBirth) +{ + const auto cps = changePoints(FormatId::RefCkpt); + ASSERT_EQ(cps.size(), 4u); + EXPECT_EQ(cps.front().generation, kContiguousRefStreamsGeneration); + EXPECT_EQ(cps.front().min_reader, kContiguousRefStreamsGeneration); + EXPECT_GT(cps.front().generation, 1u) << "the point of this test is that it is NOT the gen-1 baseline"; + EXPECT_EQ(cps[1].generation, kNamespaceLifeKeyedGeneration); + EXPECT_EQ(cps[1].min_reader, kNamespaceLifeKeyedGeneration); + EXPECT_EQ(cps[2].generation, kOpaqueNamespaceLifeLayoutGeneration); + EXPECT_EQ(cps[2].min_reader, kOpaqueNamespaceLifeLayoutGeneration); + EXPECT_EQ(cps.back().generation, kCommittedRefFrontierGeneration); + EXPECT_EQ(cps.back().min_reader, kCommittedRefFrontierGeneration); +} + +TEST(CASFormat, PoolMetaTracksTheRecreateOnlyRecoveryFrontierGeneration) +{ + const auto cps = changePoints(FormatId::PoolMeta); + ASSERT_EQ(cps.size(), 3u); + EXPECT_EQ(cps.back().generation, kCommittedRefFrontierGeneration); + EXPECT_EQ(cps.back().min_reader, kCommittedRefFrontierGeneration); +} + +TEST(CASFormat, CurrentVersionsAreGBuild) +{ + EXPECT_EQ(currentWriterVersion(), G_BUILD); + EXPECT_EQ(currentCompatibilityVersion(), G_BUILD); +} + +TEST(CASFormat, CheckCompatibilityPassesWhenKnown) +{ + EXPECT_NO_THROW(checkCompatibility(1u, "manifest")); + EXPECT_NO_THROW(checkCompatibility(G_BUILD, "manifest")); +} + +TEST(CASFormat, CheckCompatibilityFailsClosedOnFuture) +{ + try + { + checkCompatibility(G_BUILD + 1, "manifest"); + FAIL() << "expected UNKNOWN_FORMAT_VERSION"; + } + catch (const DB::Exception & e) + { + EXPECT_EQ(e.code(), DB::ErrorCodes::UNKNOWN_FORMAT_VERSION); + } +} diff --git a/src/Disks/tests/gtest_cas_format_battery.cpp b/src/Disks/tests/gtest_cas_format_battery.cpp new file mode 100644 index 000000000000..f6ba9bcdbcd0 --- /dev/null +++ b/src/Disks/tests/gtest_cas_format_battery.cpp @@ -0,0 +1,23 @@ +#include "cas_format_test_battery.h" +#include +#include + +using namespace DB::Cas; + +/// The real cas_pool_meta case replaces the phase-1 toy proving instance. Every other control-plane +/// format registers its own battery row in its own gtest_cas__format.cpp file (Tasks 3-6). + +TEST(CASFormatBattery, PoolMeta) +{ + PoolMeta pm; + pm.pool_id = hexToU128("00112233445566778899aabbccddeeff"); + pm.blob_header_len = 256; + pm.min_reader_generation = 3; + pm.algos_used = {static_cast(BlobHashAlgo::CityHash128)}; + runFormatBattery(FormatBatteryCase{ + .id = FormatId::PoolMeta, + .encode = [&] { return sealObject(FormatId::PoolMeta, encodePoolMeta(pm)); }, + .decode = [](std::string_view s) { decodePoolMeta(std::string(openObject(FormatId::PoolMeta, s))); }, + .golden = currentFormatHeader("cas_pool_meta") + + "{\"pid\":\"00112233445566778899aabbccddeeff\",\"hln\":256,\"gcs\":1,\"mrg\":3,\"alg\":\"ch128\"}\n"}); +} diff --git a/src/Disks/tests/gtest_cas_fsck.cpp b/src/Disks/tests/gtest_cas_fsck.cpp new file mode 100644 index 000000000000..71abb917eaa2 --- /dev/null +++ b/src/Disks/tests/gtest_cas_fsck.cpp @@ -0,0 +1,1584 @@ +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include "cas_test_helpers.h" + +#include +#include +#include +#include +#include +#include + +namespace DB::ErrorCodes +{ +extern const int INVALID_STATE; +extern const int NETWORK_ERROR; +} + +using namespace DB::Cas; +using namespace DB::Cas::tests; + +namespace +{ +constexpr uint64_t kWriterEpoch = 7; +const String kServerRoot = "00"; +ManifestRef ref(uint64_t seq, uint64_t inst) +{ + return ManifestRef{.writer_epoch = kWriterEpoch, .build_sequence = seq, .manifest_ordinal = static_cast(inst)}; +} + +/// B207 race-simulation harness: `InMemoryBackend` is documented "not final: tests subclass it to +/// distort single behaviors". `runFsck`'s ref-walk and its physical blob listing (`listAll` over +/// `layout.blobsPrefix()`) are two separate calls to `Backend::list` minutes apart in production; here +/// we fire an injected mutation the FIRST time `list` is called against the armed prefix — i.e. +/// strictly AFTER the ref-walk has captured its (now stale) `reachable_blobs`/`blob_labels` view, and +/// strictly BEFORE the HEAD-confirm loop sees the physical listing. That reproduces the race +/// deterministically, without any real timing. +class RepublishOnListBackend : public InMemoryBackend +{ +public: + void armOnFirstList(String prefix, std::function mutation) + { + std::lock_guard lock(arm_mutex); + armed_prefix = std::move(prefix); + pending_mutation = std::move(mutation); + } + + ListPage list(const String & prefix, const String & cursor, size_t limit) override + { + std::function to_run; + { + std::lock_guard lock(arm_mutex); + if (pending_mutation && prefix == armed_prefix) + { + to_run = std::move(pending_mutation); + pending_mutation = nullptr; + } + } + if (to_run) + to_run(); + return InMemoryBackend::list(prefix, cursor, limit); + } + +private: + std::mutex arm_mutex; + String armed_prefix; + std::function pending_mutation; +}; + +/// Companion to `RepublishOnListBackend` for the MANIFEST phantom-dangle race: the ref-walk's +/// per-namespace recovery captures each committed `(ref -> manifest)` minutes before the per-ref +/// `backend.get(mkey)` that confirms the manifest body. This backend fires an injected mutation the +/// FIRST time `get` is called for the armed manifest key — strictly AFTER the walk captured its (now +/// stale) row and AT the GET that would otherwise read the manifest — reproducing "ref republished/ +/// dropped + old manifest legitimately GC-deleted" deterministically, with no real timing. +class MutateOnFirstGetBackend : public InMemoryBackend +{ +public: + void armOnFirstGet(String key, std::function mutation) + { + std::lock_guard lock(arm_mutex); + armed_key = std::move(key); + pending_mutation = std::move(mutation); + } + + std::optional get(const String & key, Range range) override + { + std::function to_run; + { + std::lock_guard lock(arm_mutex); + if (pending_mutation && key == armed_key) + { + to_run = std::move(pending_mutation); + pending_mutation = nullptr; + } + } + if (to_run) + to_run(); + return InMemoryBackend::get(key, range); + } + +private: + std::mutex arm_mutex; + String armed_key; + std::function pending_mutation; +}; + +enum class FsckListingMode : uint8_t +{ + Full, + Empty, + Partial, + Reordered, +}; + +/// Distort only one namespace stream LIST after fixture deposition. Exact GET/HEAD and every other +/// prefix retain ordinary backend semantics, so the test varies the hint and nothing authoritative. +class FsckListingBackend : public InMemoryBackend +{ +public: + void distort(String prefix_, FsckListingMode mode_) + { + prefix = std::move(prefix_); + mode = mode_; + } + + ListPage list(const String & listed_prefix, const String & cursor, size_t limit) override + { + ListPage page = InMemoryBackend::list(listed_prefix, cursor, limit); + if (listed_prefix != prefix) + return page; + if (mode == FsckListingMode::Empty) + page.keys.clear(); + else if (mode == FsckListingMode::Partial && !page.keys.empty()) + page.keys.erase(page.keys.begin()); + else if (mode == FsckListingMode::Reordered) + std::reverse(page.keys.begin(), page.keys.end()); + return page; + } + +private: + String prefix; + FsckListingMode mode = FsckListingMode::Full; +}; + +/// Fail one exact GET without disturbing LIST or any other object read. This keeps the checkpoint +/// authority stable while proving that fsck distinguishes a transport failure from durable corruption. +class FailExactGetBackend : public InMemoryBackend +{ +public: + void fail(String key_) + { + key = std::move(key_); + } + + std::optional get(const String & requested_key, Range range) override + { + if (requested_key == key) + throw DB::Exception(DB::ErrorCodes::NETWORK_ERROR, "injected exact GET failure"); + return InMemoryBackend::get(requested_key, range); + } + +private: + String key; +}; + +/// Publish the exact `_ckpt` authority an ordinary Live test life would have after its first committed +/// record. Raw ref-log helpers deliberately do not do this: several protocol tests need malformed or +/// pre-creation states. Fsck tests that exercise a recoverable Live life must make the durable authority +/// explicit instead of accidentally borrowing the legacy LIST-only recovery rule. +void writeFsckCheckpoint(Backend & backend, const Layout & layout, const RootNamespace & ns, RefTxnId committed_through) +{ + const CasRefCatalog::Snapshot cut = CasRefCatalog::read(backend, layout); + const auto it = std::find_if(cut.catalog.entries.begin(), cut.catalog.entries.end(), + [&](const CatalogEntry & entry) { return entry.ns == ns; }); + ASSERT_NE(it, cut.catalog.entries.end()); + const NamespaceLifeId life = NamespaceLifeId::fromCatalogEntry(it->ns, it->incarnation); + const String key = layout.refCkptKey(life); + const String body = encodeRefCkpt(RefCkpt{ + .life_epoch = committed_through.writer_epoch, + .committed_through = committed_through, + .checkpoint_snapshot_id = std::nullopt, + .last_epoch_seal = std::nullopt}); + const HeadResult current = backend.head(key); + const PutResult put = current.exists + ? backend.putOverwrite(key, body, current.token) + : backend.putIfAbsent(key, body); + ASSERT_EQ(put.outcome, PutOutcome::Done); +} + +void writeFsckCheckpointWithBase( + Backend & backend, const Layout & layout, const RootNamespace & ns, RefTxnId base, + std::optional last_epoch_seal = std::nullopt) +{ + const NamespaceLifeId life = *CasRefCatalog::lifeIfCataloged(backend, layout, ns); + ASSERT_EQ(backend.putIfAbsent(layout.refCkptKey(life), encodeRefCkpt(RefCkpt{ + .life_epoch = 1, + .committed_through = base, + .checkpoint_snapshot_id = base, + .last_epoch_seal = last_epoch_seal})).outcome, PutOutcome::Done); +} + +void expectCheckpointBaseVerdict( + const FsckReport & report, const String & exact_base_key, FsckClass expected_class, + std::string_view expected_reason) +{ + EXPECT_EQ(report.chain_broken, expected_class == FsckClass::ChainBroken ? 1u : 0u); + EXPECT_EQ(report.unchecked, expected_class == FsckClass::Unchecked ? 1u : 0u); + EXPECT_EQ(report.clean(), expected_class != FsckClass::ChainBroken); + + size_t matching = 0; + for (const FsckObject & object : report.objects) + { + if (object.cls != expected_class || object.key != exact_base_key) + continue; + ++matching; + ASSERT_EQ(object.reachable_from.size(), 1u); + EXPECT_NE(object.reachable_from.front().find(expected_reason), String::npos); + } + EXPECT_EQ(matching, 1u) << "the checkpoint-base verdict must identify its exact named base and cause"; +} + +/// Test-only external catalog writer. It changes the namespace's current logical life after fsck took +/// its catalog cut, precisely the competing-cut mutation that fsck must not splice into its verdict. +void replaceCatalogLife(Backend & backend, const Layout & layout, const RootNamespace & ns, UInt128 incarnation) +{ + CasRefCatalog::Snapshot current = CasRefCatalog::read(backend, layout); + const auto it = std::find_if(current.catalog.entries.begin(), current.catalog.entries.end(), + [&](const CatalogEntry & entry) { return entry.ns == ns; }); + ASSERT_NE(it, current.catalog.entries.end()); + it->incarnation = incarnation; + it->state = NsState::Live; + it->creator.reset(); + it->removal_started_round.reset(); + ASSERT_TRUE(current.token.has_value()); + ASSERT_EQ(backend.putOverwrite(layout.refCatalogKey(), encodeRefCatalog(current.catalog), *current.token).outcome, + PutOutcome::Done); +} + +FsckReport runFsckWithListingMode(FsckListingMode mode, std::string_view suffix) +{ + auto backend = std::make_shared(); + auto store = openPoolForTest(backend); + const Layout & layout = store->layout(); + const RootNamespace ns{"00/listing_" + String(suffix) + "@cas@"}; + const ManifestRef r1 = ref(1, 0xD1); + const ManifestRef r2 = ref(2, 0xD2); + const DB::UInt128 h1 = u128Of("fsck-listing-old-" + String(suffix)); + const DB::UInt128 h2 = u128Of("fsck-listing-new-" + String(suffix)); + writeBlobBody(*backend, layout, h1); + writeBlobBody(*backend, layout, h2); + writeManifestRaw(*backend, layout, ns, r1, {blobEntryFor("a", h1)}); + writeManifestRaw(*backend, layout, ns, r2, {blobEntryFor("a", h2)}); + publishCommittedTransition(*backend, layout, ns, "tbl", std::nullopt, r1); + const uint64_t frontier = publishCommittedTransition(*backend, layout, ns, "tbl", r1, r2); + writeFsckCheckpoint(*backend, layout, ns, RefTxnId{1, frontier}); + + const NamespaceLifeId life = *CasRefCatalog::lifeIfCataloged(*backend, layout, ns); + backend->distort(layout.namespaceStreamPrefix(life), mode); + return runFsck(*store, /*detail=*/true); +} + +void expectListingIndependentFsck(const FsckReport & report) +{ + EXPECT_EQ(report.chain_broken, 0u); + EXPECT_EQ(report.unchecked, 0u); + EXPECT_EQ(report.dangling, 0u); + EXPECT_EQ(report.ref_records_walked, 2u); + EXPECT_EQ(report.reachable, 1u); +} + +struct FsckAuthorityVerdict +{ + bool clean = false; + uint64_t hard_findings = 0; + uint64_t chain_broken = 0; + uint64_t unchecked = 0; + uint64_t ref_records_walked = 0; + uint64_t reachable = 0; + uint64_t dangling = 0; + + bool operator==(const FsckAuthorityVerdict &) const = default; +}; + +FsckAuthorityVerdict authorityVerdict(const FsckReport & report) +{ + uint64_t hard_findings = 0; + for (const FsckHardFinding & finding : kFsckHardFindings) + hard_findings += report.*(finding.value); + return FsckAuthorityVerdict{ + .clean = report.clean(), + .hard_findings = hard_findings, + .chain_broken = report.chain_broken, + .unchecked = report.unchecked, + .ref_records_walked = report.ref_records_walked, + .reachable = report.reachable, + .dangling = report.dangling, + }; +} + +FsckReport runCheckpointBaseFsckWithListingMode( + FsckListingMode mode, std::string_view suffix, bool corrupt_exact_base) +{ + auto backend = std::make_shared(); + auto store = openPoolForTest(backend); + const Layout & layout = store->layout(); + const RootNamespace ns{"00/listing_base_" + String(suffix) + "@cas@"}; + fixture::admitLive(*backend, layout, ns); + + const RefTxnId base{1, 1}; + const RefLogTxn birth{ + .ns = ns.string(), .txn_id = base, .ops = {namespaceBirthOp()}, .prev_epoch_seal = std::nullopt}; + fixture::writeRefLogRaw(*backend, layout, birth); + RefTableState base_state; + applyRefLogTxn(base_state, birth); + writeRefSnapshotRaw(*backend, layout, snapshotOf(base_state, ns.string())); + writeFsckCheckpointWithBase(*backend, layout, ns, base); + + const NamespaceLifeId life = *CasRefCatalog::lifeIfCataloged(*backend, layout, ns); + if (corrupt_exact_base) + { + const String base_snapshot_key = layout.refSnapshotKey(life, base); + const HeadResult head = backend->head(base_snapshot_key); + EXPECT_TRUE(head.exists); + if (head.exists) + EXPECT_EQ(backend->deleteExact(base_snapshot_key, head.token).kind, DeleteOutcome::Kind::Deleted); + } + else + { + /// This newer pair is deliberately outside `_ckpt.committed_through`. It is inert garbage: + /// changing whether LIST happens to reveal it must not add or remove an fsck finding. + const RefTxnId unadopted{1, 2}; + const RefOwnerBinding listed_binding{RefOwnerKind::Precommit, "listed", ref(9, 0xE9)}; + const RefLogTxn listed_log{ + .ns = ns.string(), + .txn_id = unadopted, + .ops = {ownerTransitionOp(std::nullopt, listed_binding)}, + .prev_epoch_seal = std::nullopt}; + fixture::writeRefLogRaw(*backend, layout, listed_log); + RefTableState listed_state = base_state; + applyRefLogTxn(listed_state, listed_log); + RefTableSnapshot unadopted_snapshot = snapshotOf(listed_state, ns.string()); + unadopted_snapshot.precommits.push_back( + RefOwnerBinding{RefOwnerKind::Precommit, "unlisted", ref(10, 0xEA)}); + writeRefSnapshotRaw(*backend, layout, unadopted_snapshot); + } + + backend->distort(layout.namespaceStreamPrefix(life), mode); + return runFsck(*store, /*detail=*/true); +} +} + +/// A committed ref whose manifest body is present and whose blobs exist => clean. +TEST(CASFsck, CleanManifestPoolHasNoDangling) +{ + auto backend = std::make_shared(); + auto store = openPoolForTest(backend); + const RootNamespace ns{"00/aa@cas@"}; + const ManifestRef r = ref(1, 0xAA); + writeBlobBody(*backend, store->layout(), DB::UInt128(1)); + writeManifestRaw(*backend, store->layout(), ns, r, {blobEntryFor("a", DB::UInt128(1))}); + const uint64_t sequence = publishCommittedTransition(*backend, store->layout(), ns, "tbl", std::nullopt, r); + writeFsckCheckpoint(*backend, store->layout(), ns, RefTxnId{1, sequence}); + const FsckReport rep = runFsck(*store, /*detail*/true); + EXPECT_TRUE(rep.clean()); + EXPECT_EQ(rep.dangling, 0u); +} + +/// A committed ref naming a MISSING manifest body is an ERROR (Dangling). +TEST(CASFsck, OwnerVisibleMissingManifestBodyIsError) +{ + auto backend = std::make_shared(); + auto store = openPoolForTest(backend); + const RootNamespace ns{"00/aa@cas@"}; + const ManifestRef r = ref(1, 0xAA); + const uint64_t sequence = publishCommittedTransition( + *backend, store->layout(), ns, "tbl", std::nullopt, r); // no body written + writeFsckCheckpoint(*backend, store->layout(), ns, RefTxnId{1, sequence}); + const FsckReport rep = runFsck(*store, /*detail*/true); + EXPECT_FALSE(rep.clean()); + EXPECT_GE(rep.dangling, 1u); +} + +/// A committed ref whose blob body is missing is an ERROR (Dangling). +TEST(CASFsck, ReachableBlobMissingIsError) +{ + auto backend = std::make_shared(); + auto store = openPoolForTest(backend); + const RootNamespace ns{"00/aa@cas@"}; + const ManifestRef r = ref(1, 0xAA); + writeManifestRaw(*backend, store->layout(), ns, r, {blobEntryFor("a", DB::UInt128(1))}); // no blob body + const uint64_t sequence = publishCommittedTransition(*backend, store->layout(), ns, "tbl", std::nullopt, r); + writeFsckCheckpoint(*backend, store->layout(), ns, RefTxnId{1, sequence}); + const FsckReport rep = runFsck(*store, /*detail*/true); + EXPECT_FALSE(rep.clean()); + EXPECT_GE(rep.dangling, 1u); +} + +/// fsck RECORDS AND CONTINUES over a key that names no namespace life. It is the forensic tool an +/// operator reaches for after something has already gone wrong, so one bad key must not make it report +/// NOTHING -- including about the healthy namespaces it would never reach. The finding is hard (an +/// un-incarnated key is corruption behind the format bump) and counted once per key, not once per sweep. +TEST(CASFsck, LifelessKeyIsRecordedAndTheHealthyNamespaceIsStillReported) +{ + auto backend = std::make_shared(); + auto store = openPoolForTest(backend); + const RootNamespace ns{"00/aa@cas@"}; + const ManifestRef r = ref(1, 0xA1); + writeBlobBody(*backend, store->layout(), DB::UInt128(1)); + writeManifestRaw(*backend, store->layout(), ns, r, {blobEntryFor("a", DB::UInt128(1))}); + const uint64_t sequence = publishCommittedTransition(*backend, store->layout(), ns, "tbl", std::nullopt, r); + writeFsckCheckpoint(*backend, store->layout(), ns, RefTxnId{1, sequence}); + + /// Hand-built: no helper can mint the un-incarnated shape any more. + const String lifeless = store->layout().casRefsPrefix() + ns.string() + "/_log/" + + renderRefTxnId(RefTxnId{1, 1}) + ".zst"; + ASSERT_EQ(backend->putIfAbsent(lifeless, "garbage").outcome, PutOutcome::Done); + + FsckReport rep; + ASSERT_NO_THROW(rep = runFsck(*store, /*detail*/true)) + << "the audit must not be taken out by the damage it exists to report"; + + /// The finding, named, and counted ONCE even though several sweeps enumerate namespaces. + EXPECT_EQ(rep.lifeless_keys, 1u); + EXPECT_FALSE(rep.clean()); + bool saw = false; + for (const FsckObject & o : rep.objects) + if (o.cls == FsckClass::LifelessKey) + { + saw = true; + EXPECT_EQ(o.key, lifeless); + } + EXPECT_TRUE(saw) << "a counted finding with no row is a number nobody can act on"; + + /// And the healthy namespace was still reached: its committed ref resolved to a present manifest and + /// a present blob, which only a sweep that ran can report. + EXPECT_GE(rep.reachable, 1u); + EXPECT_EQ(rep.dangling, 0u); +} + +/// A COMPLETE, canonical namespace-life key (a real `_files` write under a real admitted life) whose +/// catalog row is then removed entirely -- exactly what a fenced GC's exact-CAS row deletion leaves +/// behind, before the perpetual namespace janitor's next page reaches it -- must classify as +/// `janitor_pending`, a SOFT finding, never `lifeless_keys`. The report stays clean. +TEST(CASFsck, CanonicalDeadLifeResidueIsJanitorPendingNotHardFinding) +{ + auto backend = std::make_shared(); + auto store = openPoolForTest(backend); + const RootNamespace ns{"00/aa@cas@"}; + const NamespaceLifeId life = store->namespaceLife(ns); + store->putNamespaceFile(life, "format_version.txt", "1\n"); + + /// Simulate a fenced GC's exact-CAS catalog-row deletion: the row is gone, the life-owned physical + /// object above survives it (the janitor's own job, not GC's own round). `casUpdate` deliberately + /// refuses to add or delete rows (there is no generic catalog remove-by-name API -- deletion is + /// only `deleteCompletedRemoving`/`cancelStalledCreating`, both requiring the full fenced-GC + /// protocol this fixture is not driving), so inject the post-deletion catalog snapshot directly, + /// mirroring `DuplicateLifeIdIsReportedWhileAnUnrelatedUniqueNamespaceStillProgresses` below. + { + CasRefCatalog::Snapshot snapshot = CasRefCatalog::read(*backend, store->layout()); + const auto it = std::find_if(snapshot.catalog.entries.begin(), snapshot.catalog.entries.end(), + [&](const CatalogEntry & entry) { return entry.ns == ns; }); + ASSERT_NE(it, snapshot.catalog.entries.end()); + snapshot.catalog.entries.erase(it); + const auto catalog_head = backend->head(store->layout().refCatalogKey()); + ASSERT_TRUE(catalog_head.exists); + ASSERT_EQ(backend->putOverwrite(store->layout().refCatalogKey(), encodeRefCatalog(snapshot.catalog), + catalog_head.token).outcome, PutOutcome::Done); + } + + FsckReport rep; + ASSERT_NO_THROW(rep = runFsck(*store, /*detail*/true)) + << "janitor-pending residue must never abort the scan"; + + EXPECT_EQ(rep.lifeless_keys, 0u); + EXPECT_GE(rep.namespace_janitor_pending, 1u); + EXPECT_EQ(rep.namespace_janitor_pending_lives, 1u); + EXPECT_TRUE(rep.clean()) << "janitor-pending residue is not a hard finding"; + bool saw = false; + for (const FsckObject & o : rep.objects) + if (o.cls == FsckClass::JanitorPending) + saw = true; + EXPECT_TRUE(saw) << "a counted soft finding with no row is a number nobody can act on"; +} + +/// The observe-then-cut race: a life admitted between fsck's namespace-tree LIST and the catalog cut +/// it takes AFTER that listing must NOT be misread as residue. Mirrors +/// `CASNamespaceJanitor.PostListCatalogCutProtectsConcurrentCreationWithOneGet` -- the same ordering, +/// the same reason: creation admits `Creating` before writing any life-owned object, so a life visible +/// only in the LATER cut cannot have raced this listing. +namespace +{ +class AdmitLifeAfterNamespaceListingBackend : public InMemoryBackend +{ +public: + explicit AdmitLifeAfterNamespaceListingBackend(NamespaceLifeId life_) : protected_life(std::move(life_)) {} + + ListPage list(const String & prefix, const String & cursor, size_t limit) override + { + ListPage page = InMemoryBackend::list(prefix, cursor, limit); + if (!published && prefix.ends_with("/cas/ns/")) + { + published = true; + CasRefCatalog::casAdmitEntry(*this, Layout("p"), /*gc_shards*/1, + CatalogEntry{.ns = protected_life.ns, .state = NsState::Live, + .incarnation = protected_life.incarnation}); + } + return page; + } + +private: + NamespaceLifeId protected_life; + bool published = false; +}; +} + +TEST(CASFsck, LifeAdmittedBetweenNamespaceListingAndLaterCutIsNotResidue) +{ + const RootNamespace ns{"00/late@cas@"}; + const NamespaceLifeId life = NamespaceLifeId::fromCatalogEntry(ns, UInt128{909}); + auto backend = std::make_shared(life); + auto store = openPoolForTest(backend); + /// The physical object exists before the listing runs, exactly as a legitimate late admission would + /// leave it: written only after `casAdmitEntry` above, but here pre-seeded since the injected + /// backend admits the CATALOG row, not the physical file, on the list callback. + ASSERT_EQ(backend->putIfAbsent(store->layout().namespaceFilesPrefix(life) + "format_version.txt", "1\n").outcome, + PutOutcome::Done); + + FsckReport rep; + ASSERT_NO_THROW(rep = runFsck(*store, /*detail*/true)); + EXPECT_EQ(rep.namespace_janitor_pending, 0u) + << "a life visible in the post-listing cut must not be misclassified as residue"; + EXPECT_EQ(rep.lifeless_keys, 0u); + EXPECT_TRUE(rep.clean()); +} + +/// Malformed or non-canonical namespace-tree shapes must stay HARD findings even after the +/// janitor-pending split: a dirty `_files` relative name (the parser-asymmetry fix), a zero life id, an +/// uppercase life id, and an unrecognized kind directory all name no current writer's grammar. +TEST(CASFsck, MalformedNamespaceTreeShapesStayHardFindings) +{ + const Layout layout("p"); + const NamespaceLifeId life = NamespaceLifeId::fromCatalogEntry(RootNamespace{"00/bb@cas@"}, UInt128{909}); + const struct { String key; String description; } cases[] = { + {layout.namespaceFilesPrefix(life) + "../escape", "dirty _files relative name"}, + {"p/cas/ns/state/" + String(32, '0') + "/_files/format_version.txt", "zero life id"}, + {"p/cas/ns/state/112233445566778899AABBCCDDEEFF01/_files/format_version.txt", "uppercase life id"}, + {"p/cas/ns/stream/" + renderIncarnation(UInt128{909}) + "/_unknown_kind/x.zst", "unknown kind directory"}, + }; + for (const auto & c : cases) + { + auto backend = std::make_shared(); + auto store = openPoolForTest(backend); + ASSERT_EQ(backend->putIfAbsent(c.key, "garbage").outcome, PutOutcome::Done) << c.description; + + FsckReport rep; + ASSERT_NO_THROW(rep = runFsck(*store, /*detail*/true)) << c.description; + EXPECT_GE(rep.lifeless_keys, 1u) << c.description; + EXPECT_EQ(rep.namespace_janitor_pending, 0u) << c.description; + EXPECT_FALSE(rep.clean()) << c.description; + } +} + +/// Mutation caught: calling the destructive consumer's global `throwIfAmbiguous` from fsck aborts +/// before the unique row is audited. The read-only tool reports the ambiguous physical id and +/// continues through an unrelated unique namespace. +TEST(CASFsck, DuplicateLifeIdIsReportedWhileAnUnrelatedUniqueNamespaceStillProgresses) +{ + auto backend = std::make_shared(); + auto store = openPoolForTest(backend); + const Layout & layout = store->layout(); + const RootNamespace unique_ns{"00/unique@cas@"}; + const ManifestRef r = ref(1, 0xA1); + writeBlobBody(*backend, layout, DB::UInt128(1)); + writeManifestRaw(*backend, layout, unique_ns, r, {blobEntryFor("a", DB::UInt128(1))}); + const uint64_t sequence = publishCommittedTransition(*backend, layout, unique_ns, "tbl", std::nullopt, r); + writeFsckCheckpoint(*backend, layout, unique_ns, RefTxnId{1, sequence}); + + CasRefCatalog::Snapshot snapshot = CasRefCatalog::read(*backend, layout); + snapshot.catalog.entries.push_back(CatalogEntry{ + .ns = RootNamespace{"bad/a"}, .state = NsState::Live, .incarnation = UInt128{777}}); + snapshot.catalog.entries.push_back(CatalogEntry{ + .ns = RootNamespace{"bad/b"}, + .state = NsState::Removing, + .incarnation = UInt128{777}, + .removal_started_round = 1}); + std::sort(snapshot.catalog.entries.begin(), snapshot.catalog.entries.end(), + [](const CatalogEntry & lhs, const CatalogEntry & rhs) { return lhs.ns.string() < rhs.ns.string(); }); + const auto catalog_head = backend->head(layout.refCatalogKey()); + ASSERT_TRUE(catalog_head.exists); + ASSERT_EQ(backend->putOverwrite(layout.refCatalogKey(), encodeRefCatalog(snapshot.catalog), catalog_head.token).outcome, + PutOutcome::Done); + + FsckReport report; + ASSERT_NO_THROW(report = runFsck(*store, /*detail=*/true)); + EXPECT_GE(report.lifeless_keys, 1u); + EXPECT_GE(report.reachable, 1u) << "the unrelated unique namespace must still be audited"; + EXPECT_EQ(report.dangling, 0u); +} + +/// A physical namespace-life key whose life id is ambiguous in the POST-LISTING cut (two catalog rows +/// share one incarnation) must be recorded as a `lifeless_keys` finding and must NOT abort the scan: +/// `CatalogLifeIndex::resolve` throws `CORRUPTED_DATA` on a duplicate, and the janitor-pending +/// classification loop must catch it exactly like every other catalog-authority failure in this scan. +/// Mirrors `DuplicateLifeIdIsReportedWhileAnUnrelatedUniqueNamespaceStillProgresses`, but that fixture +/// has no physical object under the duplicated life id, so it never drives a candidate into the new +/// post-listing loop at all -- this is the case that actually exercises it. +TEST(CASFsck, AmbiguousLifeUnderAPhysicalKeyIsRecordedNotAborted) +{ + auto backend = std::make_shared(); + auto store = openPoolForTest(backend); + const Layout & layout = store->layout(); + const RootNamespace unique_ns{"00/unique@cas@"}; + const ManifestRef r = ref(1, 0xA1); + writeBlobBody(*backend, layout, DB::UInt128(1)); + writeManifestRaw(*backend, layout, unique_ns, r, {blobEntryFor("a", DB::UInt128(1))}); + const uint64_t sequence = publishCommittedTransition(*backend, layout, unique_ns, "tbl", std::nullopt, r); + writeFsckCheckpoint(*backend, layout, unique_ns, RefTxnId{1, sequence}); + + const NamespaceLifeId duplicated_life = NamespaceLifeId::fromCatalogEntry(RootNamespace{"bad/a"}, UInt128{777}); + ASSERT_EQ(backend->putIfAbsent(layout.namespaceFilesPrefix(duplicated_life) + "format_version.txt", "1\n").outcome, + PutOutcome::Done); + + CasRefCatalog::Snapshot snapshot = CasRefCatalog::read(*backend, layout); + snapshot.catalog.entries.push_back(CatalogEntry{ + .ns = RootNamespace{"bad/a"}, .state = NsState::Live, .incarnation = UInt128{777}}); + snapshot.catalog.entries.push_back(CatalogEntry{ + .ns = RootNamespace{"bad/b"}, + .state = NsState::Removing, + .incarnation = UInt128{777}, + .removal_started_round = 1}); + std::sort(snapshot.catalog.entries.begin(), snapshot.catalog.entries.end(), + [](const CatalogEntry & lhs, const CatalogEntry & rhs) { return lhs.ns.string() < rhs.ns.string(); }); + const auto catalog_head = backend->head(layout.refCatalogKey()); + ASSERT_TRUE(catalog_head.exists); + ASSERT_EQ(backend->putOverwrite(layout.refCatalogKey(), encodeRefCatalog(snapshot.catalog), catalog_head.token).outcome, + PutOutcome::Done); + + FsckReport report; + ASSERT_NO_THROW(report = runFsck(*store, /*detail=*/true)) + << "an ambiguous life under a physical key must be a recorded finding, never an abort"; + EXPECT_GE(report.lifeless_keys, 1u); + EXPECT_GE(report.reachable, 1u) << "the unrelated unique namespace must still be audited"; + EXPECT_EQ(report.dangling, 0u); +} + +/// Fsck's namespace universe is catalog-authoritative. Admit `ns`, publish one real ref-log record, +/// then hide its whole stream prefix from LIST. Fsck must still retain the namespace from the catalog +/// cut and use the checkpoint-anchored arithmetic walk to read the record. +/// +/// Proves `ref_records_walked`, not `dangling`/`clean()`: `checkRefStream` (which this proves runs) has +/// its own `_ckpt`-anchored arithmetic walk and so is reachable here. The distinct +/// `manifestStillReferenced` recheck now receives the same frozen catalog row and exact `_ckpt` authority; +/// its competing-cut regression is pinned separately by `MissingManifestRecheckStaysOnInitialCatalogCut`. +TEST(CASFsck, CatalogLiveNamespaceHiddenFromListIsStillWalked) +{ + auto backend = std::make_shared(); + auto store = openPoolForTest(backend); + const Layout & layout = store->layout(); + const RootNamespace ns{"00/hidden_from_list@cas@"}; + + fixture::admitLive(*backend, layout, ns); + const uint64_t sequence = appendRefLogSeed( + *backend, layout, ns, {}); // one real record: a birth-only ref-log transaction + + /// `casAdmitEntry` never publishes a `_ckpt` (by its own design), and the write above used + /// `appendRefLogSeed`'s hardcoded writer_epoch 1. `checkRefStream`'s own walk needs SOME anchor -- a + /// `_ckpt.life_epoch`, a listed snapshot, or a listed log -- to know where to start reading, and this + /// test is about to hide every listed one. Without an anchor the walk sees nothing at all and + /// correctly treats the namespace as never-born, the same "nothing to probe" trap the I4 replacement + /// controls hit and were restructured around (fold-before-hide). fsck has no "fold" step to run + /// first, so the anchor is published directly, by exact key, before the hide -- the exact-key GET + /// this enables is unaffected by list-hiding either way. + writeFsckCheckpoint(*backend, layout, ns, RefTxnId{1, sequence}); + const NamespaceLifeId life = fixture::fixtureLife(ns); + + backend->hidePrefix(layout.namespaceStreamPrefix(life)); + + FsckReport rep; + ASSERT_NO_THROW(rep = runFsck(*store, /*detail*/true)); + EXPECT_GT(backend->holesServed(), 0u) + << "the hide must actually have been exercised by the stream LIST, or this test passes vacuously"; + EXPECT_GE(rep.ref_records_walked, 1u) + << "the namespace must be discovered and its stream actually read even when LIST omits every one " + "of its keys, or the catalog-authoritative universe supplement did not run"; +} + +TEST(CASFsckAuthority, FullListingDoesNotDefineStreamGeometry) +{ + expectListingIndependentFsck(runFsckWithListingMode(FsckListingMode::Full, "full")); +} + +TEST(CASFsckAuthority, EmptyListingDoesNotDefineStreamGeometry) +{ + expectListingIndependentFsck(runFsckWithListingMode(FsckListingMode::Empty, "empty")); +} + +TEST(CASFsckAuthority, PartialListingDoesNotDefineStreamGeometry) +{ + expectListingIndependentFsck(runFsckWithListingMode(FsckListingMode::Partial, "partial")); +} + +TEST(CASFsckAuthority, ReorderedListingDoesNotDefineStreamGeometry) +{ + expectListingIndependentFsck(runFsckWithListingMode(FsckListingMode::Reordered, "reordered")); +} + +/// Stream LIST is not fsck authority. The same exact catalog + `_ckpt` + checkpoint-base triple must +/// yield the same result when LIST is complete, empty, partial, or reordered. A newer unadopted log and +/// snapshot are inert garbage, while damage to the exact checkpoint base remains a hard finding under +/// every listing. Mutation caught: the old LIST-derived snapshot oracle makes only listings that reveal +/// the unadopted pair non-clean. +TEST(CASFsckAuthority, StreamListingDoesNotChangeCheckpointBaseVerdict) +{ + const std::array modes{ + FsckListingMode::Full, + FsckListingMode::Empty, + FsckListingMode::Partial, + FsckListingMode::Reordered, + }; + + std::optional clean_reference; + std::optional corrupt_reference; + for (size_t i = 0; i < modes.size(); ++i) + { + const FsckAuthorityVerdict clean = authorityVerdict(runCheckpointBaseFsckWithListingMode( + modes[i], "clean_" + std::to_string(i), /*corrupt_exact_base=*/false)); + if (!clean_reference) + clean_reference = clean; + EXPECT_EQ(clean, *clean_reference); + EXPECT_TRUE(clean.clean); + EXPECT_EQ(clean.hard_findings, 0u); + + const FsckAuthorityVerdict corrupt = authorityVerdict(runCheckpointBaseFsckWithListingMode( + modes[i], "corrupt_" + std::to_string(i), /*corrupt_exact_base=*/true)); + if (!corrupt_reference) + corrupt_reference = corrupt; + EXPECT_EQ(corrupt, *corrupt_reference); + EXPECT_FALSE(corrupt.clean); + EXPECT_EQ(corrupt.chain_broken, 1u); + EXPECT_EQ(corrupt.unchecked, 0u); + EXPECT_EQ(corrupt.hard_findings, 1u); + } +} + +/// A durable but unfrontiered F+1 is not part of this fsck cut. Mutation caught: probing one position +/// beyond `_ckpt.committed_through` walks the visible record and changes both coverage and reachability. +TEST(CASFsckAuthority, VisibleFPlusOneDoesNotAffectVerdict) +{ + auto backend = std::make_shared(); + auto store = openPoolForTest(backend); + const Layout & layout = store->layout(); + const RootNamespace ns{"00/visible_f_plus_one@cas@"}; + const ManifestRef committed_ref = ref(1, 0xE1); + const ManifestRef unfrontiered_ref = ref(2, 0xE2); + const DB::UInt128 committed_blob = u128Of("fsck-frontier-committed"); + writeBlobBody(*backend, layout, committed_blob); + writeManifestRaw(*backend, layout, ns, committed_ref, {blobEntryFor("a", committed_blob)}); + const uint64_t frontier = publishCommittedTransition( + *backend, layout, ns, "tbl", std::nullopt, committed_ref); + writeFsckCheckpoint(*backend, layout, ns, RefTxnId{1, frontier}); + + /// The object is durable and visible, but `_ckpt` is deliberately NOT advanced to it. The semantic + /// convenience wrapper advances `_ckpt`, so deposit this unfrontiered F+1 as the raw transaction + /// shape that a stopped writer can leave behind. Its missing manifest would become a false dangle if + /// either fsck leg adopted F+1. + std::vector unfrontiered_ops; + unfrontiered_ops.push_back(ownerTransitionOp( + RefOwnerBinding{RefOwnerKind::Committed, "tbl", committed_ref}, std::nullopt)); + const std::vector commit_ops = publishCommittedOps("tbl", unfrontiered_ref); + unfrontiered_ops.insert(unfrontiered_ops.end(), commit_ops.begin(), commit_ops.end()); + ASSERT_EQ(appendRefLogSeed(*backend, layout, ns, std::move(unfrontiered_ops)), frontier + 1); + + const FsckReport report = runFsck(*store, /*detail=*/true); + EXPECT_EQ(report.chain_broken, 0u); + EXPECT_EQ(report.unchecked, 0u); + EXPECT_EQ(report.ref_records_walked, 1u); + EXPECT_EQ(report.dangling, 0u); + EXPECT_EQ(report.reachable, 1u); +} + +/// INV-2 materializes every burned global epoch, including an empty one, as a sequence-1 seal. A +/// direct `{1,2}` -> `{7,1}` chain that omits `{2,1}` is therefore data loss, not a legal sparse epoch +/// transition. Mutation caught: accepting the later head as a shortcut blesses the missing seal. +TEST(CASFsckAuthority, MissingBurnedEpochSealIsChainBroken) +{ + auto backend = std::make_shared(); + auto store = openPoolForTest(backend); + const Layout & layout = store->layout(); + const RootNamespace ns{"00/skipped_writer_epoch@cas@"}; + + fixture::writeRefLogRaw(*backend, layout, RefLogTxn{ + .ns = ns.string(), .txn_id = RefTxnId{1, 1}, .ops = {namespaceBirthOp()}, + .prev_epoch_seal = std::nullopt}); + RefOp seal; + seal.kind = RefOpKind::EpochSeal; + fixture::writeRefLogRaw(*backend, layout, RefLogTxn{ + .ns = ns.string(), .txn_id = RefTxnId{1, 2}, .ops = {seal}, + .prev_epoch_seal = std::nullopt}); + + const NamespaceLifeId life = *CasRefCatalog::lifeIfCataloged(*backend, layout, ns); + /// The codec now rejects this skip. Deposit its old on-disk corruption shape by changing only the + /// fixed-width epoch token of an otherwise encodable body, so fsck still proves that a missing + /// intermediate epoch is reported rather than treated as a sparse legal transition. + String skipped_bytes = encodeRefLogTxn(RefLogTxn{ + .ns = ns.string(), .txn_id = RefTxnId{7, 1}, .ops = {}, .prev_epoch_seal = RefTxnId{6, 1}}); + const String old_epoch_token = R"("!pse":"6")"; + const auto old_epoch = skipped_bytes.find(old_epoch_token); + ASSERT_NE(old_epoch, String::npos); + skipped_bytes.replace(old_epoch, old_epoch_token.size(), R"("!pse":"1")"); + ASSERT_EQ(backend->putIfAbsent(layout.refLogKey(life, RefTxnId{7, 1}), + sealObject(FormatId::RefLog, skipped_bytes)).outcome, PutOutcome::Done); + + ASSERT_EQ(backend->putIfAbsent(layout.refCkptKey(life), encodeRefCkpt(RefCkpt{ + .life_epoch = 1, + .committed_through = RefTxnId{7, 1}, + .checkpoint_snapshot_id = std::nullopt, + .last_epoch_seal = RefTxnId{6, 1}})).outcome, PutOutcome::Done); + + const FsckReport report = runFsck(*store, /*detail=*/true); + EXPECT_EQ(report.chain_broken, 1u); + EXPECT_EQ(report.unchecked, 0u); + EXPECT_EQ(report.ref_records_walked, 2u); +} + +/// The checkpoint base is the inclusive frontier, so there is no replay tail in which another hole +/// could satisfy this test. The missing same-id log itself makes the stable exact authority corrupt. +/// Mutation caught: mapping every `readCheckpointSnapshotBase` failure to `Unchecked` leaves `clean` true. +TEST(CASFsckAuthority, MissingCheckpointBaseLogIsChainBroken) +{ + auto backend = std::make_shared(); + auto store = openPoolForTest(backend); + const Layout & layout = store->layout(); + const RootNamespace ns{"00/missing_checkpoint_base_log@cas@"}; + fixture::admitLive(*backend, layout, ns); + + const RefTxnId base{1, 1}; + const NamespaceLifeId life = *CasRefCatalog::lifeIfCataloged(*backend, layout, ns); + writeFsckCheckpointWithBase(*backend, layout, ns, base); + + const FsckReport report = runFsck(*store, /*detail=*/true); + EXPECT_EQ(report.ref_records_walked, 0u); + expectCheckpointBaseVerdict( + report, layout.refSnapshotKey(life, base), FsckClass::ChainBroken, "has no matching log"); +} + +/// A present, valid non-seal base log rules out a stream hole; only its checkpoint-named same-id +/// snapshot is absent. Stable exact absence is damage, not lost diagnostic coverage. +TEST(CASFsckAuthority, MissingCheckpointBaseSnapshotIsChainBroken) +{ + auto backend = std::make_shared(); + auto store = openPoolForTest(backend); + const Layout & layout = store->layout(); + const RootNamespace ns{"00/missing_checkpoint_base_snapshot@cas@"}; + fixture::admitLive(*backend, layout, ns); + + const RefTxnId base{1, 1}; + const NamespaceLifeId life = *CasRefCatalog::lifeIfCataloged(*backend, layout, ns); + fixture::writeRefLogRaw(*backend, layout, RefLogTxn{ + .ns = ns.string(), .txn_id = base, .ops = {namespaceBirthOp()}, .prev_epoch_seal = std::nullopt}); + writeFsckCheckpointWithBase(*backend, layout, ns, base); + + const FsckReport report = runFsck(*store, /*detail=*/true); + EXPECT_EQ(report.ref_records_walked, 0u); + expectCheckpointBaseVerdict( + report, layout.refSnapshotKey(life, base), FsckClass::ChainBroken, + "is absent under the supplied immutable lifecycle authority"); +} + +/// `_ckpt.checkpoint_snapshot_id` names a state snapshot, never an `EpochSeal`. The forged base is an +/// OLDER seal, deliberately different from `last_epoch_seal`, so comparing checkpoint metadata cannot +/// expose it: the stream audit must exact-read the base log and reject it before recovery can bless the +/// same-id snapshot. +TEST(CASFsckAuthority, CheckpointSnapshotAtOlderEpochSealIsChainBroken) +{ + auto backend = std::make_shared(); + auto store = openPoolForTest(backend); + const Layout & layout = store->layout(); + const RootNamespace ns{"00/checkpoint_base_seal@cas@"}; + fixture::admitLive(*backend, layout, ns); + + const RefLogTxn birth{ + .ns = ns.string(), .txn_id = RefTxnId{1, 1}, .ops = {namespaceBirthOp()}, + .prev_epoch_seal = std::nullopt}; + fixture::writeRefLogRaw(*backend, layout, birth); + RefOp seal; + seal.kind = RefOpKind::EpochSeal; + const RefLogTxn seal_txn{ + .ns = ns.string(), .txn_id = RefTxnId{1, 2}, .ops = {seal}, + .prev_epoch_seal = std::nullopt}; + fixture::writeRefLogRaw(*backend, layout, seal_txn); + RefOp later_seal; + later_seal.kind = RefOpKind::EpochSeal; + fixture::writeRefLogRaw(*backend, layout, RefLogTxn{ + .ns = ns.string(), .txn_id = RefTxnId{2, 1}, .ops = {later_seal}, + .prev_epoch_seal = RefTxnId{1, 2}}); + + RefTableState through_seal; + applyRefLogTxn(through_seal, birth); + applyRefLogTxn(through_seal, seal_txn); + writeRefSnapshotRaw(*backend, layout, snapshotOf(through_seal, ns.string())); + + const NamespaceLifeId life = *CasRefCatalog::lifeIfCataloged(*backend, layout, ns); + ASSERT_EQ(backend->putIfAbsent(layout.refCkptKey(life), encodeRefCkpt(RefCkpt{ + .life_epoch = 1, + .committed_through = RefTxnId{2, 1}, + .checkpoint_snapshot_id = RefTxnId{1, 2}, + .last_epoch_seal = RefTxnId{2, 1}})).outcome, PutOutcome::Done); + + const FsckReport report = runFsck(*store, /*detail=*/true); + EXPECT_EQ(report.ref_records_walked, 0u) + << "the seal is rejected as the checkpoint base, not walked as a normal replay record"; + expectCheckpointBaseVerdict( + report, layout.refSnapshotKey(life, RefTxnId{1, 2}), FsckClass::ChainBroken, + "names an EpochSeal, not a snapshot base"); +} + +/// An unstable transport failure while exact-reading the same valid checkpoint base proves neither +/// presence nor absence. It remains the honest third answer and must not become a hard finding. +TEST(CASFsckAuthority, CheckpointBaseTransportFailureIsUnchecked) +{ + auto backend = std::make_shared(); + auto store = openPoolForTest(backend); + const Layout & layout = store->layout(); + const RootNamespace ns{"00/checkpoint_base_transport@cas@"}; + fixture::admitLive(*backend, layout, ns); + + const RefTxnId base{1, 1}; + const NamespaceLifeId life = *CasRefCatalog::lifeIfCataloged(*backend, layout, ns); + fixture::writeRefLogRaw(*backend, layout, RefLogTxn{ + .ns = ns.string(), .txn_id = base, .ops = {namespaceBirthOp()}, .prev_epoch_seal = std::nullopt}); + RefTableState state; + applyRefLogTxn(state, RefLogTxn{ + .ns = ns.string(), .txn_id = base, .ops = {namespaceBirthOp()}, .prev_epoch_seal = std::nullopt}); + writeRefSnapshotRaw(*backend, layout, snapshotOf(state, ns.string())); + writeFsckCheckpointWithBase(*backend, layout, ns, base); + backend->fail(layout.refLogKey(life, base)); + + const FsckReport report = runFsck(*store, /*detail=*/true); + EXPECT_EQ(report.ref_records_walked, 0u); + expectCheckpointBaseVerdict( + report, layout.refSnapshotKey(life, base), FsckClass::Unchecked, "injected exact GET failure"); +} + +/// The sampled checkpoint is immutable input, but cleanup may advance `_ckpt` after that sample and +/// retire its old base before fsck exact-reads it. The miss is then authority instability, not evidence +/// that either durable checkpoint incarnation was internally corrupt. +/// Mutation caught: classifying `CORRUPTED_DATA` without rechecking the sampled checkpoint token. +TEST(CASFsckAuthority, CheckpointBaseVanishingAfterAuthorityAdvanceIsUnchecked) +{ + auto backend = std::make_shared(); + auto store = openPoolForTest(backend); + const Layout & layout = store->layout(); + const RootNamespace ns{"00/checkpoint_base_advanced@cas@"}; + fixture::admitLive(*backend, layout, ns); + + const RefTxnId old_base{1, 1}; + const NamespaceLifeId life = *CasRefCatalog::lifeIfCataloged(*backend, layout, ns); + writeFsckCheckpointWithBase(*backend, layout, ns, old_base); + backend->armOnFirstGet(layout.refLogKey(life, old_base), [&] + { + const String ckpt_key = layout.refCkptKey(life); + const HeadResult head = backend->head(ckpt_key); + ASSERT_TRUE(head.exists); + ASSERT_EQ(backend->putOverwrite(ckpt_key, encodeRefCkpt(RefCkpt{ + .life_epoch = 1, + .committed_through = std::nullopt, + .checkpoint_snapshot_id = std::nullopt, + .last_epoch_seal = std::nullopt}), head.token).outcome, PutOutcome::Done); + }); + + const FsckReport report = runFsck(*store, /*detail=*/true); + EXPECT_EQ(report.ref_records_walked, 0u); + expectCheckpointBaseVerdict( + report, layout.refSnapshotKey(life, old_base), FsckClass::Unchecked, + "checkpoint authority changed while validating its snapshot base"); +} + +/// A Live catalog row without `_ckpt` is not a recoverable table, even when a listing happens to show a +/// complete ref log. Mutation caught: replacing the authority-taking recovery with the old LIST replay +/// makes this audit look clean and silently blesses a life whose durable frontier is unknown. +TEST(CASFsck, LiveNamespaceWithoutCheckpointIsUnchecked) +{ + auto backend = std::make_shared(); + auto store = openPoolForTest(backend); + const RootNamespace ns{"00/live_without_checkpoint@cas@"}; + const ManifestRef r = ref(1, 0xC1); + writeBlobBody(*backend, store->layout(), DB::UInt128(1)); + writeManifestRaw(*backend, store->layout(), ns, r, {blobEntryFor("a", DB::UInt128(1))}); + /// `publishCommittedTransition` correctly advances `_ckpt`; this test instead deposits the raw + /// missing-checkpoint corruption shape that fsck must refuse to recover. + std::vector ops = publishCommittedOps("tbl", r); + appendRefLogSeed(*backend, store->layout(), ns, std::move(ops)); + + const FsckReport report = runFsck(*store, /*detail=*/true); + EXPECT_GE(report.unchecked, 1u) + << "a Live life with no exact checkpoint cannot be recovered from a convenient LIST"; + EXPECT_EQ(report.reachable, 0u) + << "fsck must not consume refs after the mandatory recovery authority was absent"; +} + +/// fsck's missing-manifest recheck runs after its primary walk. If it re-resolves the name from a second +/// catalog cut, a concurrent rebirth can make the old durable owner disappear from the recheck and hide +/// a real dangle. The initial cut's row and exact checkpoint must remain the sole authority throughout +/// the whole fsck call. +/// +/// Mutation caught: re-resolve `ns` from `manifestStillReferenced`. The first manifest GET changes the +/// catalog to a fresh, empty life; the second resolution then sees no owner and suppresses the dangle. A +/// recovery from the original cut continues to see the original owner and reports it. +TEST(CASFsck, MissingManifestRecheckStaysOnInitialCatalogCut) +{ + auto backend = std::make_shared(); + auto store = openPoolForTest(backend); + const Layout & layout = store->layout(); + const RootNamespace ns{"00/frozen_fsck_cut@cas@"}; + const ManifestRef r = ref(1, 0xC2); + const uint64_t sequence = publishCommittedTransition(*backend, layout, ns, "tbl", std::nullopt, r); + /// `publishCommittedTransition`'s raw fixture log writer uses its documented epoch 1. + writeFsckCheckpoint(*backend, layout, ns, RefTxnId{1, sequence}); + + backend->armOnFirstGet(layout.manifestKey(ManifestId{ns, r}), [&] + { + replaceCatalogLife(*backend, layout, ns, UInt128{0xC3}); + }); + + const FsckReport report = runFsck(*store, /*detail=*/true); + EXPECT_GE(report.dangling, 1u) + << "the initial catalog-cut owner still names the absent manifest despite a later rebirth"; +} + +/// A pre-precommit body in an eligible prefix (no owner) is INFO (Unreachable), not an error. +TEST(CASFsck, ReclaimablePrePrecommitBodyIsInfo) +{ + auto backend = std::make_shared(); + auto store = openPoolForTest(backend); + const RootNamespace ns{"00/aa@cas@"}; + /// Seed a birth-only ref log through the fixture helper, which also admits the catalog row, while + /// leaving NO committed owner; the manifest body below is orphan debris. + const uint64_t sequence = appendRefLogSeed(*backend, store->layout(), ns, {}); + writeFsckCheckpoint(*backend, store->layout(), ns, RefTxnId{1, sequence}); + const ManifestRef r = ref(5, 0xAB); + writeManifestRaw(*backend, store->layout(), ns, r, {blobEntryFor("a", DB::UInt128(1))}); // body, no owner + setWatermarkMinActive(*backend, store->layout(), kServerRoot, kWriterEpoch, 6); // eligible + const FsckReport rep = runFsck(*store, /*detail*/true); + EXPECT_TRUE(rep.clean()); // not an error + EXPECT_GE(rep.unreachable, 1u); // counted as info/unreachable +} + +/// Pipeline classification (2026-07-02): a condemned-but-present blob is PendingGc — an EXPECTED +/// pipeline state (deletion is scheduled), never the suspicious "unreachable" lump beta testers +/// read as a leak. clean() is unaffected. +TEST(CASFsck, CondemnedBlobClassifiesPendingGc) +{ + auto backend = std::make_shared(); + auto store = openPoolForTest(backend); + const RootNamespace ns{"00/aa@cas@"}; + const ManifestRef r = ref(1, 0xA1); + writeBlobBody(*backend, store->layout(), DB::UInt128(1)); + writeManifestRaw(*backend, store->layout(), ns, r, {blobEntryFor("a", DB::UInt128(1))}); + const uint64_t publish_sequence = publishCommittedTransition( + *backend, store->layout(), ns, "tbl", std::nullopt, r); + writeFsckCheckpoint(*backend, store->layout(), ns, RefTxnId{1, publish_sequence}); + Gc gc(store, hexToU128("00000000000000000000000000000001")); + gc.runRegularRound(); + const uint64_t drop_sequence = dropRefTransition(*backend, store->layout(), ns, "tbl", r); + writeFsckCheckpoint(*backend, store->layout(), ns, RefTxnId{1, drop_sequence}); + gc.runRegularRound(); /// -1 folds => zero => condemned into the retired list; blob still present + + const FsckReport rep = runFsck(*store, /*detail*/true); + EXPECT_TRUE(rep.clean()); + EXPECT_EQ(rep.pending_gc, 1u); + EXPECT_EQ(rep.unaccounted, 0u); + bool saw = false; + for (const FsckObject & o : rep.objects) + if (o.cls == FsckClass::PendingGc) + { + saw = true; + ASSERT_FALSE(o.reachable_from.empty()); + EXPECT_NE(o.reachable_from[0].find("condemned at round"), String::npos); + } + EXPECT_TRUE(saw); +} + +/// A drop whose -1 has NOT folded yet: the blob's edges are still in the GC snapshot => AwaitingGc +/// (expected), not Unaccounted. +TEST(CASFsck, DroppedButUnfoldedBlobClassifiesAwaitingGc) +{ + auto backend = std::make_shared(); + auto store = openPoolForTest(backend); + const RootNamespace ns{"00/aa@cas@"}; + const ManifestRef r = ref(1, 0xA1); + writeBlobBody(*backend, store->layout(), DB::UInt128(1)); + writeManifestRaw(*backend, store->layout(), ns, r, {blobEntryFor("a", DB::UInt128(1))}); + const uint64_t publish_sequence = publishCommittedTransition( + *backend, store->layout(), ns, "tbl", std::nullopt, r); + writeFsckCheckpoint(*backend, store->layout(), ns, RefTxnId{1, publish_sequence}); + Gc gc(store, hexToU128("00000000000000000000000000000001")); + gc.runRegularRound(); /// +1 folded into the snapshot + const uint64_t drop_sequence = dropRefTransition( + *backend, store->layout(), ns, "tbl", r); /// -1 NOT folded (no round) + writeFsckCheckpoint(*backend, store->layout(), ns, RefTxnId{1, drop_sequence}); + + const FsckReport rep = runFsck(*store, /*detail*/true); + EXPECT_TRUE(rep.clean()); + EXPECT_EQ(rep.awaiting_gc, 1u); + EXPECT_EQ(rep.unaccounted, 0u); +} + +/// Stale-edge cross-check, NEGATIVE side: the residual edge's source manifest body is still PRESENT in +/// the pool, so its removal still has a `-1` to fold (and the orphan sweep still has a body to reclaim). +/// That is a genuine mid-pipeline backlog and must keep the `AwaitingGc` verdict — the new check may +/// never turn an ordinary unfolded drop into a hard finding. +TEST(CASFsck, UnfoldedDropWithPresentSourceManifestStaysAwaitingGc) +{ + auto backend = std::make_shared(); + auto store = openPoolForTest(backend); + const RootNamespace ns{"00/aa@cas@"}; + const ManifestRef r = ref(1, 0xA1); + writeBlobBody(*backend, store->layout(), DB::UInt128(1)); + writeManifestRaw(*backend, store->layout(), ns, r, {blobEntryFor("a", DB::UInt128(1))}); + const uint64_t publish_sequence = publishCommittedTransition( + *backend, store->layout(), ns, "tbl", std::nullopt, r); + writeFsckCheckpoint(*backend, store->layout(), ns, RefTxnId{1, publish_sequence}); + Gc gc(store, hexToU128("00000000000000000000000000000001")); + gc.runRegularRound(); /// +1 folded into the snapshot + const uint64_t drop_sequence = dropRefTransition( + *backend, store->layout(), ns, "tbl", r); /// -1 NOT folded; the BODY survives + writeFsckCheckpoint(*backend, store->layout(), ns, RefTxnId{1, drop_sequence}); + + const FsckReport rep = runFsck(*store, /*detail*/true); + EXPECT_TRUE(rep.clean()); + EXPECT_EQ(rep.awaiting_gc, 1u); + EXPECT_EQ(rep.stale_edge, 0u); + bool saw = false; + for (const FsckObject & o : rep.objects) + if (o.cls == FsckClass::AwaitingGc) + saw = true; + EXPECT_TRUE(saw); +} + +/// Stale-edge cross-check, POSITIVE side: the blob's only residual `+1` names a manifest that no longer +/// exists anywhere in the pool, so no `-1` is left to fold — the in-degree stays at 1 for every future +/// round and the incremental GC can never nominate the blob. It must NOT be labeled `AwaitingGc` +/// ("expected, no action needed", the sentence that hid 56 permanently retained blobs); it is the hard +/// `StaleEdge` finding and the report is not `clean()`. +TEST(CASFsck, ResidualEdgeNamingAnAbsentManifestClassifiesStaleEdge) +{ + auto backend = std::make_shared(); + auto store = openPoolForTest(backend); + const RootNamespace ns{"00/aa@cas@"}; + const ManifestRef r = ref(1, 0xA1); + writeBlobBody(*backend, store->layout(), DB::UInt128(1)); + const ManifestId id = writeManifestRaw(*backend, store->layout(), ns, r, {blobEntryFor("a", DB::UInt128(1))}); + const uint64_t publish_sequence = publishCommittedTransition( + *backend, store->layout(), ns, "tbl", std::nullopt, r); + writeFsckCheckpoint(*backend, store->layout(), ns, RefTxnId{1, publish_sequence}); + Gc gc(store, hexToU128("00000000000000000000000000000001")); + gc.runRegularRound(); /// +1 folded into the snapshot + const uint64_t drop_sequence = dropRefTransition( + *backend, store->layout(), ns, "tbl", r); /// the owner is gone ... + writeFsckCheckpoint(*backend, store->layout(), ns, RefTxnId{1, drop_sequence}); + deleteManifestBody(*backend, store->layout(), id); /// ... and so is the body, un-folded + + const FsckReport rep = runFsck(*store, /*detail*/true); + EXPECT_EQ(rep.stale_edge, 1u); + EXPECT_EQ(rep.awaiting_gc, 0u); + EXPECT_EQ(rep.dangling, 0u) << "no committed ref names the manifest any more — this is not a dangle"; + EXPECT_FALSE(rep.clean()); + bool saw = false; + for (const FsckObject & o : rep.objects) + if (o.cls == FsckClass::StaleEdge) + { + saw = true; + ASSERT_FALSE(o.reachable_from.empty()); + EXPECT_NE(o.reachable_from[0].find("no longer exist"), String::npos); + } + EXPECT_TRUE(saw); +} + +/// GC never ran on the pool: nothing is classifiable through the GC view — everything unreferenced +/// is AwaitingGc ("GC has not run yet"), never a false Unaccounted alarm. +TEST(CASFsck, GcNeverRanClassifiesAwaitingGc) +{ + auto backend = std::make_shared(); + auto store = openPoolForTest(backend); + writeBlobBody(*backend, store->layout(), DB::UInt128(5)); /// present, never referenced, no gc/state + + const FsckReport rep = runFsck(*store, /*detail*/true); + EXPECT_TRUE(rep.clean()); + EXPECT_EQ(rep.awaiting_gc, 1u); + EXPECT_EQ(rep.unaccounted, 0u); +} + +/// A blob outside the WHOLE GC view on a pool where GC runs: Unaccounted — expected only as a +/// transient (fast create+drop between rounds); persistent occurrences violate INV-2. +TEST(CASFsck, ForeignBlobClassifiesUnaccounted) +{ + auto backend = std::make_shared(); + auto store = openPoolForTest(backend); + const RootNamespace ns{"00/aa@cas@"}; + const ManifestRef r = ref(1, 0xA1); + writeBlobBody(*backend, store->layout(), DB::UInt128(1)); + writeManifestRaw(*backend, store->layout(), ns, r, {blobEntryFor("a", DB::UInt128(1))}); + const uint64_t sequence = publishCommittedTransition(*backend, store->layout(), ns, "tbl", std::nullopt, r); + writeFsckCheckpoint(*backend, store->layout(), ns, RefTxnId{1, sequence}); + Gc gc(store, hexToU128("00000000000000000000000000000001")); + gc.runRegularRound(); + + writeBlobBody(*backend, store->layout(), DB::UInt128(0xF0F0)); /// never referenced anywhere + + const FsckReport rep = runFsck(*store, /*detail*/true); + EXPECT_TRUE(rep.clean()); + EXPECT_EQ(rep.unaccounted, 1u); + EXPECT_EQ(rep.pending_gc, 0u); +} + +/// A `.meta` descriptor whose body is missing is ADVISORY (meta_without_body), NOT a hard finding: +/// GC deletes the body FIRST and drops the `.meta` afterwards on a bounded, error-suppressed advisory +/// pool that may drop the op, so a single raw LIST legitimately observes a body-less `.meta` mid- +/// graduation and no finite grace makes a persistent one hard evidence. It is still counted/reported; +/// it must NOT be a `dangling` (nothing referenced it) and NOT one of the present-but-unreferenced blob +/// pipeline classes (the `.meta` key is excluded from body classification entirely). `clean()` stays TRUE. +TEST(CASFsck, MetaWithoutBodyIsAdvisoryNotHard) +{ + auto backend = std::make_shared(); + auto store = openPoolForTest(backend); + const DB::UInt128 h = u128Of("meta-without-body"); + writeMetaClean(*backend, store->layout(), h, /*size*/ 10); /// meta only, no body written + + const FsckReport rep = runFsck(*store, /*detail*/true); + EXPECT_GE(rep.meta_without_body, 1u); // still counted and reported in the full report + EXPECT_EQ(rep.dangling, 0u); + EXPECT_EQ(rep.unreachable, 0u); + EXPECT_EQ(rep.pending_gc, 0u); + EXPECT_EQ(rep.awaiting_gc, 0u); + EXPECT_EQ(rep.unaccounted, 0u); + EXPECT_TRUE(rep.clean()); // meta_without_body is advisory — excluded from clean() +} + +/// A body with no `.meta` sibling is a BENIGN not-yet-adopted (or crashed-birth) artifact — NOT a +/// dangle, and it must still classify through the ordinary present-but-unreferenced pipeline. +TEST(CASFsck, BodyWithoutMetaIsBenign) +{ + auto backend = std::make_shared(); + auto store = openPoolForTest(backend); + const DB::UInt128 h = u128Of("body-without-meta"); + writeBlobBody(*backend, store->layout(), h); /// body only, no meta written + + const FsckReport rep = runFsck(*store, /*detail*/true); + EXPECT_GE(rep.body_without_meta, 1u); + EXPECT_EQ(rep.dangling, 0u); + EXPECT_EQ(rep.meta_without_body, 0u); + EXPECT_TRUE(rep.clean()); +} + +/// A scan whose deadline is already in the past: partial_on_deadline=false keeps the old +/// throw-on-timeout contract; partial_on_deadline=true returns the accumulated lower-bound counts +/// instead of failing empty-handed (the 2026-07-05 campaign lost 5 verdicts to this). +TEST(CASFsckPartial, DeadlineReturnsAccumulatedCountsInsteadOfThrowing) +{ + auto backend = std::make_shared(); + auto store = openPoolForTest(backend); + const RootNamespace ns{"00/aa@cas@"}; + const ManifestRef r = ref(1, 0xAA); + writeBlobBody(*backend, store->layout(), DB::UInt128(1)); + writeManifestRaw(*backend, store->layout(), ns, r, {blobEntryFor("a", DB::UInt128(1))}); + const uint64_t sequence = publishCommittedTransition(*backend, store->layout(), ns, "tbl", std::nullopt, r); + writeFsckCheckpoint(*backend, store->layout(), ns, RefTxnId{1, sequence}); + + const auto past = std::chrono::steady_clock::now() - std::chrono::seconds(1); + /// partial_on_deadline=false keeps the old contract: + EXPECT_THROW(DB::Cas::runFsck(*store, /*detail=*/false, {}, past), DB::Exception); + /// partial_on_deadline=true returns a flagged report: + const auto report = DB::Cas::runFsck(*store, false, {}, past, /*partial_on_deadline=*/true); + EXPECT_TRUE(report.partial); + EXPECT_FALSE(report.partial_reason.empty()); +} + +/// A `namespace_prefix` scopes the scan to only the matching namespaces' refs (dangling-only): no +/// pool-wide unreachable/pending/awaiting/unaccounted classification, since that needs the whole pool. +TEST(CASFsckScoped, NamespacePrefixChecksOnlyMatchingRefsDanglingOnly) +{ + auto backend = std::make_shared(); + auto store = openPoolForTest(backend); + + const RootNamespace ns_a{"nsa"}; + const ManifestRef r_a = ref(1, 0xA1); + writeBlobBody(*backend, store->layout(), DB::UInt128(1)); + writeManifestRaw(*backend, store->layout(), ns_a, r_a, {blobEntryFor("a", DB::UInt128(1))}); + const uint64_t sequence_a = publishCommittedTransition( + *backend, store->layout(), ns_a, "tbl", std::nullopt, r_a); + writeFsckCheckpoint(*backend, store->layout(), ns_a, RefTxnId{1, sequence_a}); + + const RootNamespace ns_b{"nsb"}; + const ManifestRef r_b = ref(1, 0xB1); + writeBlobBody(*backend, store->layout(), DB::UInt128(2)); + writeManifestRaw(*backend, store->layout(), ns_b, r_b, {blobEntryFor("b", DB::UInt128(2))}); + const uint64_t sequence_b = publishCommittedTransition( + *backend, store->layout(), ns_b, "tbl", std::nullopt, r_b); + writeFsckCheckpoint(*backend, store->layout(), ns_b, RefTxnId{1, sequence_b}); + + const auto scoped = DB::Cas::runFsck(*store, false, {}, {}, false, /*namespace_prefix=*/"nsa"); + EXPECT_EQ(scoped.dangling, 0u); + EXPECT_GT(scoped.reachable, 0u); + /// Scoped mode skips only the POOL-WIDE physical/pipeline classification; the manifest-debris + /// pass stays active for the scoped namespaces, so `unreachable` here counts THEIR orphan + /// manifest bodies — zero in this clean setup, legitimately nonzero on a churned pool. + EXPECT_EQ(scoped.unreachable, 0u); + EXPECT_EQ(scoped.pending_gc + scoped.awaiting_gc + scoped.unaccounted, 0u); +} + +/// B207: the ref-walk and the HEAD-confirm run minutes apart with no snapshot. A ref that gets +/// RE-PUBLISHED to a different manifest in that window, combined with a legitimate GC delete of the +/// blob it used to name, must NOT surface as a phantom `dangling` — only a CURRENT ref over an absent +/// object is a real dangle. +TEST(CASFsck, PhantomDanglingFromRepublishedRefIsReresolvedAway) +{ + auto backend = std::make_shared(); + auto store = openPoolForTest(backend); + const RootNamespace ns{"00/aa@cas@"}; + const ManifestRef r1 = ref(1, 0xA1); + const ManifestRef r2 = ref(2, 0xA2); + const DB::UInt128 h1 = u128Of("b207-phantom-old"); + const DB::UInt128 h2 = u128Of("b207-phantom-new"); + + writeBlobBody(*backend, store->layout(), h1); + writeManifestRaw(*backend, store->layout(), ns, r1, {blobEntryFor("a", h1)}); + const uint64_t initial_sequence = publishCommittedTransition( + *backend, store->layout(), ns, "tbl", std::nullopt, r1); + writeFsckCheckpoint(*backend, store->layout(), ns, RefTxnId{1, initial_sequence}); + + /// Fires strictly between the ref-walk (which captures ref "tbl" -> r1, blob h1, as reachable) and + /// the HEAD-confirm's physical listing — exactly the window B207 is about. + backend->armOnFirstList(store->layout().blobsPrefix(), [&] + { + writeBlobBody(*backend, store->layout(), h2); + writeManifestRaw(*backend, store->layout(), ns, r2, {blobEntryFor("a", h2)}); + const uint64_t repoint_sequence = publishCommittedTransition( + *backend, store->layout(), ns, "tbl", r1, r2); /// re-publish + writeFsckCheckpoint(*backend, store->layout(), ns, RefTxnId{1, repoint_sequence}); + + const String old_key = store->layout().blobKey(BlobRef{BlobHashAlgo::CityHash128, BlobDigest::fromU128(h1)}); + const HeadResult head = backend->head(old_key); + ASSERT_TRUE(head.exists); + backend->deleteExact(old_key, head.token); /// legitimate GC delete of the now-unreferenced blob + }); + + const FsckReport rep = runFsck(*store, /*detail*/true); + EXPECT_EQ(rep.dangling, 0u); + EXPECT_TRUE(rep.clean()); +} + +/// Same race, but the ref is DROPPED (not re-published) in the window between the walk and the +/// HEAD-confirm — also must not surface as a phantom dangle. +TEST(CASFsck, PhantomDanglingFromDroppedRefIsReresolvedAway) +{ + auto backend = std::make_shared(); + auto store = openPoolForTest(backend); + const RootNamespace ns{"00/aa@cas@"}; + const ManifestRef r1 = ref(1, 0xA1); + const DB::UInt128 h1 = u128Of("b207-phantom-dropped"); + + writeBlobBody(*backend, store->layout(), h1); + writeManifestRaw(*backend, store->layout(), ns, r1, {blobEntryFor("a", h1)}); + const uint64_t initial_sequence = publishCommittedTransition( + *backend, store->layout(), ns, "tbl", std::nullopt, r1); + writeFsckCheckpoint(*backend, store->layout(), ns, RefTxnId{1, initial_sequence}); + + backend->armOnFirstList(store->layout().blobsPrefix(), [&] + { + const uint64_t drop_sequence = dropRefTransition( + *backend, store->layout(), ns, "tbl", r1); /// ref dropped since the walk + writeFsckCheckpoint(*backend, store->layout(), ns, RefTxnId{1, drop_sequence}); + + const String old_key = store->layout().blobKey(BlobRef{BlobHashAlgo::CityHash128, BlobDigest::fromU128(h1)}); + const HeadResult head = backend->head(old_key); + ASSERT_TRUE(head.exists); + backend->deleteExact(old_key, head.token); /// legitimate GC delete after the drop folds + }); + + const FsckReport rep = runFsck(*store, /*detail*/true); + EXPECT_EQ(rep.dangling, 0u); + EXPECT_TRUE(rep.clean()); +} + +/// Companion: the fix must never HIDE a real loss. A blob that a CURRENT ref still names, but whose +/// object is genuinely gone (an operator error, a storage-layer bug — NOT a legitimate GC delete), +/// stays `dangling` after the re-resolve. +TEST(CASFsck, RealDanglingStillCaughtAfterReresolve) +{ + auto backend = std::make_shared(); + auto store = openPoolForTest(backend); + const RootNamespace ns{"00/aa@cas@"}; + const ManifestRef r = ref(1, 0xA1); + const DB::UInt128 h = u128Of("b207-real-dangle"); + + writeBlobBody(*backend, store->layout(), h); + writeManifestRaw(*backend, store->layout(), ns, r, {blobEntryFor("a", h)}); + const uint64_t sequence = publishCommittedTransition(*backend, store->layout(), ns, "tbl", std::nullopt, r); + writeFsckCheckpoint(*backend, store->layout(), ns, RefTxnId{1, sequence}); + + const String key = store->layout().blobKey(BlobRef{BlobHashAlgo::CityHash128, BlobDigest::fromU128(h)}); + const HeadResult head = backend->head(key); + ASSERT_TRUE(head.exists); + backend->deleteExact(key, head.token); /// genuine loss — the ref is UNCHANGED, still names this blob + + const FsckReport rep = runFsck(*store, /*detail*/true); + EXPECT_EQ(rep.dangling, 1u); + EXPECT_FALSE(rep.clean()); +} + +/// The MANIFEST analogue of the blob phantom-dangle. The ref-walk captures "tbl" -> r1's manifest, then +/// the ref is RE-PUBLISHED to a different manifest r2 and the OLD r1 manifest body is legitimately +/// GC-deleted before the per-ref body GET. The missing OLD manifest must be revalidated away — a fresh +/// re-resolve shows the CURRENT ref no longer names it — never surfacing as a phantom `dangling`. +TEST(CASFsck, PhantomDanglingManifestFromRepublishedRefIsReresolvedAway) +{ + auto backend = std::make_shared(); + auto store = openPoolForTest(backend); + const RootNamespace ns{"00/aa@cas@"}; + const ManifestRef r1 = ref(1, 0xA1); + const ManifestRef r2 = ref(2, 0xA2); + const DB::UInt128 h1 = u128Of("phantom-manifest-old"); + const DB::UInt128 h2 = u128Of("phantom-manifest-new"); + + writeBlobBody(*backend, store->layout(), h1); + writeManifestRaw(*backend, store->layout(), ns, r1, {blobEntryFor("a", h1)}); + const uint64_t initial_sequence = publishCommittedTransition( + *backend, store->layout(), ns, "tbl", std::nullopt, r1); + writeFsckCheckpoint(*backend, store->layout(), ns, RefTxnId{1, initial_sequence}); + + const String m1_key = store->layout().manifestKey(ManifestId{ns, r1}); + /// Fires strictly between the ref-walk (captures "tbl" -> r1) and the per-ref GET of r1's manifest: + /// re-publish "tbl" to r2 and legitimately GC-delete the now-superseded r1 manifest body. + backend->armOnFirstGet(m1_key, [&] + { + writeBlobBody(*backend, store->layout(), h2); + writeManifestRaw(*backend, store->layout(), ns, r2, {blobEntryFor("a", h2)}); + const uint64_t repoint_sequence = publishCommittedTransition( + *backend, store->layout(), ns, "tbl", r1, r2); /// re-publish + writeFsckCheckpoint(*backend, store->layout(), ns, RefTxnId{1, repoint_sequence}); + + const HeadResult head = backend->head(m1_key); + ASSERT_TRUE(head.exists); + backend->deleteExact(m1_key, head.token); /// legitimate GC delete of the superseded manifest + }); + + const FsckReport rep = runFsck(*store, /*detail*/true); + EXPECT_EQ(rep.dangling, 0u); + EXPECT_TRUE(rep.clean()); +} + +namespace +{ +/// Build a real `ContentAddressedMetadataStorage` over Local object storage and start it (Mounted) -- +/// the same harness gtest_cas_operation_gate.cpp uses. Each call gets an isolated pool root. +std::shared_ptr openRunningStorageForTest() +{ + auto settings = makeSettingsForTest("test", std::filesystem::temp_directory_path() / "ca_fsck_running_scratch"); + auto storage = std::make_shared( + makeLocalObjectStorageForTest(), "pool", "srv1", "", nullptr, settings); + storage->startup(); + return storage; +} + +/// Commit one real part (tmp -> final rename -> commit) so a RUNNING FSCK has live committed content. +void commitOneRunningPart(DB::ContentAddressedMetadataStorage & storage) +{ + const std::string table_dir = "g80/g80g80g8-0808-4808-8808-080808080808"; + auto tx = storage.createTransaction(); + auto & ca_tx = dynamic_cast(*tx); + auto buf = ca_tx.writeFile(table_dir + "/tmp_insert_all_1_1_0/data.bin", 65536, DB::WriteMode::Rewrite, {}); + const std::string bytes = "content-of-the-part"; + buf->write(bytes.data(), bytes.size()); + buf->finalize(); + tx->moveDirectory(table_dir + "/tmp_insert_all_1_1_0", table_dir + "/all_1_1_0"); + tx->commit(DB::NoCommitOptions{}); +} +} + +/// (rev.8) FSCK runs on a RUNNING disk: scanning a live pool with one committed part succeeds and reports +/// its content (the one-row summary the SQL verb renders from this report). +TEST(CASFsckRunning, FsckOnMountedDiskSucceeds) +{ + auto storage = openRunningStorageForTest(); + commitOneRunningPart(*storage); + + FsckReport rep; + EXPECT_NO_THROW(rep = storage->runFsckNow(/*detail=*/false)); + EXPECT_TRUE(rep.clean()); + EXPECT_GE(rep.distinct_blobs, 1u) << "the running scan must see the live committed part's blob"; + EXPECT_EQ(rep.dangling, 0u); +} + +/// (rev.8) FSCK is Admin-class: on a not-live pool (a lease blip / IdentityLost) it refuses before +/// scanning, exactly like the GC entry points -- an FSCK of a disk whose data root may be gone or replaced +/// is meaningless (the operator has the snapshot / FORGET path). The two states refuse in DIFFERENT +/// classes, and the pairing is the point: a lease blip is transient unavailability (upstream-retryable), +/// an identity loss is terminal (668). +TEST(CASFsckRunning, FsckOnNotLiveDiskRefusesTransientRetryableAndIdentityLostTerminal) +{ + for (const auto & [lc, code] : {std::pair{PoolLifecycle::TransientNotLive, DB::ErrorCodes::NETWORK_ERROR}, + std::pair{PoolLifecycle::IdentityLost, DB::ErrorCodes::INVALID_STATE}}) + { + auto storage = openRunningStorageForTest(); + storage->store()->setLifecycleForTest(lc); /// one force from Live; no later store() call + expectThrowsCode(code, [&] { storage->runFsckNow(/*detail=*/false); }); + } +} + +/// The summary line is the ONLY thing most consumers ever read: the soak harness parses it, an operator +/// eyeballs it, and `exit_code` gates CI on it. So a field that `clean()` treats as a hard finding but the +/// summary omits is invisible in practice, however faithfully it is counted -- which is exactly what +/// happened to `corrupted_runs`: counted since the seal check landed, part of `clean()`, rendered in +/// `--detail` rows, and absent from the summary, so no run has ever reported one. +/// +/// This test ITERATES `kFsckHardFindings` -- the list `clean()` is computed from -- and never names a +/// finding itself, so a term added to that list and not rendered fails HERE. It used to claim exactly +/// that while its body was a hand-listed set of five names, and the claim was false: `lifeless_keys` was +/// added to `clean()` and nothing failed anywhere, which is how it reached the SQL row's absence too. +/// `formatFsckSummary` exists to be testable at all: the line used to be built inline in +/// `CommandFsck::executeImpl`, where nothing could reach it. +/// +/// A per-finding DISTINCT value is what makes this more than a substring sweep: it catches a formatter +/// that prints the right names against the wrong counters. +TEST(CASFsckSummary, EveryHardFindingAppearsOnTheSummaryLine) +{ + FsckReport rep; + uint64_t value = 11; + for (const FsckHardFinding & finding : kFsckHardFindings) + { + rep.*finding.value = value; + value += 11; + } + + const String line = formatFsckSummary(rep); + + value = 11; + for (const FsckHardFinding & finding : kFsckHardFindings) + { + const String token = String(finding.name) + "=" + std::to_string(value); + EXPECT_NE(line.find(token), String::npos) + << "hard finding '" << finding.name << "' is missing from the summary line (expected `" + << token << "`); the line was: " << line; + value += 11; + } + + /// A report carrying these values is NOT clean; the line must not be mistakable for a clean one. + EXPECT_FALSE(rep.clean()); +} + +/// A zero must be PRINTED, not omitted. The harness's `stale_edge_verdict` fails closed on an absent key +/// precisely because "field missing" and "field zero" are different facts, and a formatter that skips +/// zeros would turn every clean pool into an unparseable one. +TEST(CASFsckSummary, ZeroValuedHardFindingsAreStillPrinted) +{ + const String line = formatFsckSummary(FsckReport{}); + /// Iterated for the same reason the test above is: a new hard finding printed only when nonzero is a + /// finding the harness's fail-closed-on-absence consumers would read as missing. + for (const FsckHardFinding & finding : kFsckHardFindings) + EXPECT_NE(line.find(String(finding.name) + "=0"), String::npos) + << "hard finding '" << finding.name << "' prints no zero; the line was: " << line; + EXPECT_EQ(line.find("partial="), String::npos) << "a non-partial report must not claim partial: " << line; +} + +/// A partial scan is a lower bound over the visited subset, so the flag and its reason must travel WITH +/// the counts -- a consumer that sees the numbers but not `partial=1` reads a truncated walk as the pool +/// truth. +TEST(CASFsckSummary, PartialFlagAndReasonTravelWithTheCounts) +{ + FsckReport rep; + rep.partial = true; + rep.partial_reason = "deadline exceeded after 180s"; + const String line = formatFsckSummary(rep); + EXPECT_NE(line.find("partial=1"), String::npos) << line; + EXPECT_NE(line.find("reason='deadline exceeded after 180s'"), String::npos) << line; +} diff --git a/src/Disks/tests/gtest_cas_gc_ack_floor.cpp b/src/Disks/tests/gtest_cas_gc_ack_floor.cpp new file mode 100644 index 000000000000..10c745b30bc7 --- /dev/null +++ b/src/Disks/tests/gtest_cas_gc_ack_floor.cpp @@ -0,0 +1,1262 @@ +#include + +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include "cas_test_helpers.h" + +namespace ProfileEvents +{ +extern const Event CASMetaDelete; +extern const Event CASGCCondemnMarkerUnconfirmedCarry; +} + +using namespace DB::Cas; +using namespace DB::Cas::tests; + +namespace +{ +const UInt128 kGc = hexToU128("00000000000000000000000000000001"); +ManifestRef ref(const String &, uint64_t seq, uint64_t inst) +{ + return ManifestRef{.writer_epoch = 1, .build_sequence = seq, .manifest_ordinal = static_cast(inst)}; +} +bool blobExists(InMemoryBackend & b, const Layout & layout, const UInt128 & hash) +{ + return b.head(layout.blobKey(BlobRef{BlobHashAlgo::CityHash128, BlobDigest::fromU128(hash)})).exists; +} + +/// The current retired entry for `hash` (dereferenced through gc/state.retired_refs, shard 0), or nullopt. +std::optional currentEntryFor(Backend & backend, const Layout & layout, const UInt128 & hash) +{ + for (const RetiredEntry & e : currentRetiredSet(backend, layout, /*shard*/0)) + if (e.ref == BlobRef{BlobHashAlgo::CityHash128, BlobDigest::fromU128(hash)}) + return e; + return std::nullopt; +} + +/// Decorator reproducing the rustfs quirk (observed 2026-07-11): a conditional exact-token delete against +/// an object that is ALREADY absent can answer HTTP 412 (precondition failed), which this backend layer +/// maps to `TokenMismatch` -- not the 404-shaped `NotFound` an in-memory backend naturally returns. For +/// keys marked via `quirkOnAbsent`, `deleteExact` forces exactly that answer whenever the underlying +/// object is gone, letting a test drive the GC redelete site through the disambiguation path +/// backend-agnostically (without guessing at real rustfs HTTP mappings). +class TokenMismatchOnAbsentBackend : public InMemoryBackend +{ +public: + DeleteOutcome deleteExact(const String & key, const Token & token) override + { + if (quirk_keys.contains(key) && !InMemoryBackend::head(key).exists) + { + DeleteOutcome d; + d.kind = DeleteOutcome::Kind::TokenMismatch; + return d; + } + return InMemoryBackend::deleteExact(key, token); + } + + void quirkOnAbsent(const String & key) { quirk_keys.insert(key); } + +private: + std::set quirk_keys; +}; + +class CkptReplacementConflictBackend : public InMemoryBackend +{ +public: + CasResult casPut( + const String & key, const String & bytes, const std::optional & expected, + const ObjectMeta & meta) override + { + if (conflict_once && key == watched_key) + { + conflict_once = false; + return CasResult{CasOutcome::Conflict, {}}; + } + return InMemoryBackend::casPut(key, bytes, expected, meta); + } + + String watched_key; + bool conflict_once = false; +}; +} + +TEST(CASSemanticRefFixture, WrapperCreatesInitialRecoverableCheckpoint) +{ + auto backend = std::make_shared(); + auto store = openPoolForTest(backend); + const RootNamespace ns{"00/semantic-create@cas@"}; + const ManifestRef manifest = ref("srv-a:1", 1, 0xAB); + + const uint64_t sequence = publishCommittedTransition(*backend, store->layout(), ns, "tbl", std::nullopt, manifest); + const RefTxnId expected_id{manifest.writer_epoch, sequence}; + const NamespaceLifeId life = *CasRefCatalog::lifeIfCataloged(*backend, store->layout(), ns); + const auto ckpt = readCkpt(*backend, store->layout(), life); + + ASSERT_TRUE(ckpt.has_value()); + EXPECT_EQ(ckpt->ckpt.life_epoch, 1); + EXPECT_EQ(ckpt->ckpt.committed_through, expected_id); + EXPECT_FALSE(ckpt->ckpt.checkpoint_snapshot_id.has_value()); + EXPECT_FALSE(ckpt->ckpt.last_epoch_seal.has_value()); +} + +TEST(CASSemanticRefFixture, WrapperAdvancesCheckpointWithoutDiscardingSnapshot) +{ + auto backend = std::make_shared(); + auto store = openPoolForTest(backend); + const RootNamespace ns{"00/semantic-advance@cas@"}; + const ManifestRef manifest = ref("srv-a:1", 1, 0xAC); + + const uint64_t publish_sequence = publishCommittedTransition(*backend, store->layout(), ns, "tbl", std::nullopt, manifest); + const RefTxnId publish_id{manifest.writer_epoch, publish_sequence}; + writeRefSnapshotRaw(*backend, store->layout(), minimalLiveSnapshot(ns.string(), publish_id, {committedRow("tbl", manifest)})); + const NamespaceLifeId life = *CasRefCatalog::lifeIfCataloged(*backend, store->layout(), ns); + const auto before_drop = readCkpt(*backend, store->layout(), life); + ASSERT_TRUE(before_drop.has_value()); + RefCkpt with_snapshot = before_drop->ckpt; + with_snapshot.checkpoint_snapshot_id = publish_id; + ASSERT_EQ(backend->casPut( + store->layout().refCkptKey(life), encodeRefCkpt(with_snapshot), before_drop->token).outcome, + CasOutcome::Committed); + + const uint64_t drop_sequence = dropRefTransition(*backend, store->layout(), ns, "tbl", manifest); + const RefTxnId drop_id{manifest.writer_epoch, drop_sequence}; + const auto ckpt = readCkpt(*backend, store->layout(), life); + + ASSERT_TRUE(ckpt.has_value()); + EXPECT_EQ(ckpt->ckpt.committed_through, drop_id); + EXPECT_EQ(ckpt->ckpt.checkpoint_snapshot_id, publish_id); + EXPECT_FALSE(ckpt->ckpt.last_epoch_seal.has_value()); +} + +TEST(CASSemanticRefFixture, CheckpointAdvanceRejectsNonMonotoneAndInvalidState) +{ + auto backend = std::make_shared(); + auto store = openPoolForTest(backend); + const RootNamespace ns{"00/semantic-refusal@cas@"}; + const ManifestRef manifest = ref("srv-a:1", 1, 0xAD); + + const uint64_t sequence = publishCommittedTransition(*backend, store->layout(), ns, "tbl", std::nullopt, manifest); + const RefTxnId id{manifest.writer_epoch, sequence}; + EXPECT_THROW(advanceRecoverableCkptForRawFixture(*backend, store->layout(), ns, id), DB::Exception); + + const RootNamespace invalid_ns{"00/semantic-invalid@cas@"}; + fixture::admitLive(*backend, store->layout(), invalid_ns); + const NamespaceLifeId invalid_life = *CasRefCatalog::lifeIfCataloged(*backend, store->layout(), invalid_ns); + const String invalid_key = store->layout().refCkptKey(invalid_life); + ASSERT_EQ(backend->putIfAbsent(invalid_key, "not a checkpoint").outcome, PutOutcome::Done); + EXPECT_THROW(advanceRecoverableCkptForRawFixture(*backend, store->layout(), invalid_ns, id), DB::Exception); + EXPECT_EQ(backend->get(invalid_key)->bytes, "not a checkpoint"); +} + +TEST(CASRawRefFixture, RawLogWriteDoesNotCreateCheckpoint) +{ + auto backend = std::make_shared(); + auto store = openPoolForTest(backend); + const RootNamespace ns{"00/raw-no-ckpt@cas@"}; + const RefTxnId id{1, 1}; + + fixture::writeRefLogRaw(*backend, store->layout(), RefLogTxn{ + .ns = ns.string(), + .txn_id = id, + .ops = {namespaceBirthOp()}, + .prev_epoch_seal = std::nullopt, + }); + + const NamespaceLifeId life = *CasRefCatalog::lifeIfCataloged(*backend, store->layout(), ns); + EXPECT_FALSE(readCkpt(*backend, store->layout(), life).has_value()); +} + +TEST(CASRawRefFixture, ReplaceRecoverableCheckpointWritesTheSuppliedFullState) +{ + auto backend = std::make_shared(); + auto store = openPoolForTest(backend); + const RootNamespace ns{"00/replace-ckpt@cas@"}; + const ManifestRef manifest = ref("srv-a:1", 1, 0xAE); + const RefTxnId first_id{manifest.writer_epoch, + publishCommittedTransition(*backend, store->layout(), ns, "tbl", std::nullopt, manifest)}; + const RefTxnId seal_id{first_id.writer_epoch, first_id.ref_sequence + 1}; + writeRefSnapshotRaw(*backend, store->layout(), minimalLiveSnapshot(ns.string(), first_id, {committedRow("tbl", manifest)})); + writeSealAt(*backend, store->layout(), ns, seal_id); + + const RefCkpt next{ + .life_epoch = 1, + .committed_through = seal_id, + .checkpoint_snapshot_id = first_id, + .last_epoch_seal = seal_id, + }; + replaceRecoverableCkptForRawFixture(*backend, store->layout(), ns, next); + + const NamespaceLifeId life = *CasRefCatalog::lifeIfCataloged(*backend, store->layout(), ns); + const auto replaced = readCkpt(*backend, store->layout(), life); + ASSERT_TRUE(replaced.has_value()); + EXPECT_EQ(replaced->ckpt.life_epoch, next.life_epoch); + EXPECT_EQ(replaced->ckpt.committed_through, next.committed_through); + EXPECT_EQ(replaced->ckpt.checkpoint_snapshot_id, next.checkpoint_snapshot_id); + EXPECT_EQ(replaced->ckpt.last_epoch_seal, next.last_epoch_seal); +} + +TEST(CASRawRefFixture, ReplaceRecoverableCheckpointRejectsStaleRegressiveAndWrongLife) +{ + auto backend = std::make_shared(); + auto store = openPoolForTest(backend); + const RootNamespace ns{"00/replace-ckpt-refusal@cas@"}; + const ManifestRef manifest = ref("srv-a:1", 1, 0xAF); + const RefTxnId id{manifest.writer_epoch, + publishCommittedTransition(*backend, store->layout(), ns, "tbl", std::nullopt, manifest)}; + const NamespaceLifeId life = *CasRefCatalog::lifeIfCataloged(*backend, store->layout(), ns); + const auto existing = readCkpt(*backend, store->layout(), life); + ASSERT_TRUE(existing.has_value()); + + RefCkpt wrong_life = existing->ckpt; + wrong_life.life_epoch = *wrong_life.life_epoch + 1; + wrong_life.committed_through = RefTxnId{*wrong_life.life_epoch, 1}; + EXPECT_THROW(replaceRecoverableCkptForRawFixture(*backend, store->layout(), ns, wrong_life), DB::Exception); + + RefCkpt regressive = existing->ckpt; + regressive.committed_through = std::nullopt; + EXPECT_THROW(replaceRecoverableCkptForRawFixture(*backend, store->layout(), ns, regressive), DB::Exception); + + backend->watched_key = store->layout().refCkptKey(life); + backend->conflict_once = true; + EXPECT_THROW(replaceRecoverableCkptForRawFixture(*backend, store->layout(), ns, existing->ckpt), DB::Exception); + EXPECT_EQ(readCkpt(*backend, store->layout(), life)->ckpt.committed_through, id); +} + +/// The owner-removed manifest body is deleted only after a full round (its decrement is sealed — #11). +TEST(CASGCRetire, ManifestBodyDeletedAfterDecrementsSealed) +{ + auto backend = std::make_shared(); + auto store = openPoolForTest(backend); + const RootNamespace ns{"00/aa@cas@"}; + const ManifestRef r = ref("srv-a:1", 1, 0xAA); + writeManifestRaw(*backend, store->layout(), ns, r, {blobEntryFor("a", DB::UInt128(1))}); + publishCommittedTransition(*backend, store->layout(), ns, "tbl", std::nullopt, r); + Gc gc(store, kGc); + runRegularRoundReclaiming(gc); + EXPECT_TRUE(backend->head(store->layout().manifestKey(ManifestId{ns, r})).exists); + + dropRefTransition(*backend, store->layout(), ns, "tbl", r); + runRegularRoundReclaiming(gc); + EXPECT_FALSE(backend->head(store->layout().manifestKey(ManifestId{ns, r})).exists); +} + +/// A publish racing the pass (in-degree restored) is SPARED, not deleted (#14). +TEST(CASGCRecheck, PublishRacingFenceSparesBlob) +{ + auto backend = std::make_shared(); + auto store = openPoolForTest(backend); + const RootNamespace ns{"00/aa@cas@"}; + const ManifestRef r1 = ref("srv-a:1", 1, 0xA1); + const ManifestRef r2 = ref("srv-a:1", 2, 0xA2); + writeBlobBody(*backend, store->layout(), DB::UInt128(1)); + writeManifestRaw(*backend, store->layout(), ns, r1, {blobEntryFor("a", DB::UInt128(1))}); + publishCommittedTransition(*backend, store->layout(), ns, "tbl", std::nullopt, r1); + Gc gc(store, kGc); + gc.runRegularRound(); + // Repoint the ref from r1 to r2 (both reference blob 1) in the same window before the next round + // folds. ONE repoint event {old=committed(r1), new=committed(r2)} — the -1 (r1's body) and +1 + // (r2's body) net to in-degree 1, so blob 1 is re-pinned and must be SPARED. (Not a separate drop + // THEN repoint — that would double-count the -1 on r1's body and over-delete.) + writeManifestRaw(*backend, store->layout(), ns, r2, {blobEntryFor("a", DB::UInt128(1))}); + publishCommittedTransition(*backend, store->layout(), ns, "tbl", r1, r2); + gc.runRegularRound(); // net in-degree 1 => spared + EXPECT_TRUE(blobExists(*backend, store->layout(), DB::UInt128(1))); +} + +/// A genuinely unreferenced blob is deleted with its exact token (the single content-delete site). The +/// delete is not one-round-after-drop: the entry condemns, graduates the round AFTER the condemning +/// round (round-paced, unconditional), then the NEXT pass executes the exact-token delete. +TEST(CASGCRecheck, UnreferencedBlobDeletedExactToken) +{ + auto backend = std::make_shared(); + auto store = openPoolForTest(backend); + const RootNamespace ns{"00/aa@cas@"}; + const ManifestRef r = ref("srv-a:1", 1, 0xAA); + writeBlobBody(*backend, store->layout(), DB::UInt128(1)); + writeManifestRaw(*backend, store->layout(), ns, r, {blobEntryFor("a", DB::UInt128(1))}); + publishCommittedTransition(*backend, store->layout(), ns, "tbl", std::nullopt, r); + Gc gc(store, kGc); + gc.runRegularRound(); + dropRefTransition(*backend, store->layout(), ns, "tbl", r); + // The drop's -1 condemns blob 1; the retired-cursor pipeline (condemn -> graduate -> delete) reclaims it. + EXPECT_TRUE(runRoundsUntilAbsent(store, gc, *backend, store->layout(), DB::UInt128(1))); + EXPECT_FALSE(backend->head(store->layout().manifestKey(ManifestId{ns, r})).exists); +} + +/// Task 5 (spec 2026-07-09 §raw-body-refinement, v3): GC writes the writer's freshness meta ALONGSIDE +/// the unchanged ledger retire (RetiredEntry, body token) — the meta is the writer/promote gate's +/// point-read signal (Task 3/4), not a replacement for the ledger or the exact-token body delete. +TEST(CASGCRetire, CondemnWritesMetaCondemned) +{ + auto backend = std::make_shared(); + auto store = openPoolForTest(backend); + const RootNamespace ns{"00/aa@cas@"}; + const ManifestRef r = ref("srv-a:1", 1, 0xAA); + writeBlobBody(*backend, store->layout(), DB::UInt128(1)); + writeManifestRaw(*backend, store->layout(), ns, r, {blobEntryFor("a", DB::UInt128(1))}); + publishCommittedTransition(*backend, store->layout(), ns, "tbl", std::nullopt, r); + Gc gc(store, kGc); + gc.runRegularRound(); /// +1 folds; blob referenced (`writeBlobBody` never wrote a meta itself) + dropRefTransition(*backend, store->layout(), ns, "tbl", r); + gc.runRegularRound(); /// -1 folds => in-degree 0 => condemned THIS round + + const auto lm = loadMetaForTest(*backend, store->layout(), DB::UInt128(1)); + ASSERT_TRUE(lm.has_value()) << "GC must write the freshness meta at condemn time (Task 5)"; + EXPECT_EQ(lm->meta.state, MetaState::Condemned); + EXPECT_TRUE(blobExists(*backend, store->layout(), DB::UInt128(1))) << "condemned, NOT yet deleted"; +} + +/// Task 5: the round's exact-token body delete drops the meta alongside it (advisory, no tombstone — +/// an absent meta reads exactly like a Clean one for the writer's point-read gate). +TEST(CASGCRetire, DeleteRemovesBodyAndMeta) +{ + auto backend = std::make_shared(); + auto store = openPoolForTest(backend); + const RootNamespace ns{"00/aa@cas@"}; + const ManifestRef r = ref("srv-a:1", 1, 0xAA); + writeBlobBody(*backend, store->layout(), DB::UInt128(1)); + writeManifestRaw(*backend, store->layout(), ns, r, {blobEntryFor("a", DB::UInt128(1))}); + publishCommittedTransition(*backend, store->layout(), ns, "tbl", std::nullopt, r); + Gc gc(store, kGc); + gc.runRegularRound(); + dropRefTransition(*backend, store->layout(), ns, "tbl", r); + /// §0 introspection: the meta drop below rides `deleteMetaExact` (`CASMetaDelete` choke point). + const auto delete_before = ProfileEvents::global_counters[ProfileEvents::CASMetaDelete].load(); + // condemn -> graduate (round-paced) -> delete (the retired-cursor pipeline). + ASSERT_TRUE(runRoundsUntilAbsent(store, gc, *backend, store->layout(), DB::UInt128(1))); + + EXPECT_FALSE(blobExists(*backend, store->layout(), DB::UInt128(1))) << "body gone via exact-token delete"; + EXPECT_FALSE(loadMetaForTest(*backend, store->layout(), DB::UInt128(1)).has_value()) + << "the meta must be dropped alongside the exact-token body delete (Task 5)"; + EXPECT_EQ(ProfileEvents::global_counters[ProfileEvents::CASMetaDelete].load() - delete_before, 1); +} + +/// GC freshness meta is ADD-ONLY (spec 2026-07-11 deposed-leader `clearSparedMeta` fix): an entry whose +/// in-degree recovers before graduation is SPARED (unchanged ledger behavior) but GC must NEVER flip its +/// meta `Condemned -> Clean` on the spare. A deposed leader that cleared-then-lost the round would leave a +/// stray-`Clean` over a still-condemned body; a writer reading `Clean` would reuse the exact condemned +/// token, which a stale exact-token redelete then deletes (INV_NO_LOSS live-blob loss). +/// The spare leaves the meta `Condemned`; +/// ONLY a writer that displaces the body with a fresh incarnation token publishes `Clean`. +TEST(CASGCRetire, SpareLeavesMetaCondemned) +{ + auto backend = std::make_shared(); + auto store = openPoolForTest(backend); + const RootNamespace ns{"00/aa@cas@"}; + + /// A content-addressed body + Clean meta via a real fresh upload, so a later writer dedup-attempt + /// resolves to THIS exact hash (GC condemns it; the writer resurrects it). + const String payload = "spare-add-only-payload"; + const UInt128 hash = u128Of(payload); + const BlobRef id = idOf(payload); + { + auto seed = store->beginPartWrite({}); + seed->putBlob(id, BlobSource::fromString(payload)); + } + const Token t_seed = backend->head(store->layout().blobKey(id)).token; + + const ManifestRef r1 = ref("srv-a:1", 1, 0xA1); + writeManifestRaw(*backend, store->layout(), ns, r1, {blobEntryFor("a", hash)}); + publishCommittedTransition(*backend, store->layout(), ns, "tbl", std::nullopt, r1); + Gc gc(store, kGc); + gc.runRegularRound(); /// +1 folds + dropRefTransition(*backend, store->layout(), ns, "tbl", r1); + gc.runRegularRound(); /// -1 folds => in-degree 0 => condemned; meta flipped Condemned + ASSERT_TRUE(currentEntryFor(*backend, store->layout(), hash).has_value()); + { + const auto lm = loadMetaForTest(*backend, store->layout(), hash); + ASSERT_TRUE(lm.has_value()); + ASSERT_EQ(lm->meta.state, MetaState::Condemned); + } + + /// Re-reference the SAME blob (same body/token — never re-uploaded) via a fresh ref before + /// graduation. The pass merge nets in-degree back to 1: the prior retired entry is SPARED + /// (recovery wins, even past the floor) -- not the resurrect-supersede path (the token never changed). + const ManifestRef r2 = ref("srv-a:1", 2, 0xA2); + writeManifestRaw(*backend, store->layout(), ns, r2, {blobEntryFor("a", hash)}); + publishCommittedTransition(*backend, store->layout(), ns, "tbl_detached", std::nullopt, r2); + gc.runRegularRound(); /// +1 folds => spared + + EXPECT_FALSE(currentEntryFor(*backend, store->layout(), hash).has_value()) + << "the spared entry drops from the retired set"; + EXPECT_TRUE(blobExists(*backend, store->layout(), hash)); + EXPECT_EQ(backend->head(store->layout().blobKey(id)).token, t_seed) + << "spare does not touch the body — the incarnation token is unchanged"; + + /// ADD-ONLY: the spare must NOT clear the meta back to Clean (that is the deposed-leader hole). + { + const auto lm = loadMetaForTest(*backend, store->layout(), hash); + ASSERT_TRUE(lm.has_value()); + EXPECT_EQ(lm->meta.state, MetaState::Condemned) + << "GC freshness meta is add-only: a spare leaves the meta Condemned (never -> Clean)"; + } + + /// Only a WRITER re-publishes Clean, and only by displacing the body with a fresh incarnation token: + /// a dedup-attempt on the condemned hash resurrects (uploadFromSource) — the body token CHANGES and + /// the meta flips to Clean WITH that token change. + auto build = store->beginPartWrite({}); + auto ref_w = build->putBlob(id, BlobSource::fromString(payload)); + EXPECT_EQ(ref_w.ref, id); + const Token t_resurrect = backend->head(store->layout().blobKey(id)).token; + EXPECT_NE(t_resurrect, t_seed) << "resurrect displaces the body with a fresh incarnation token"; + const auto lm_after = loadMetaForTest(*backend, store->layout(), hash); + ASSERT_TRUE(lm_after.has_value()); + EXPECT_EQ(lm_after->meta.state, MetaState::Clean) + << "the writer's resurrect path is the SOLE Condemned -> Clean transition"; +} + +/// Two-leader stale-redelete regression — the executable form of the deposed-leader spec §2. A stale +/// leader's pre-CAS exact-token redelete `deleteExact(h, t1)` must never delete a live reuse. With the +/// buggy clear-on-spare, a spare publishes `Clean`; a writer reads `Clean` and REUSES `t1`; the stale +/// `deleteExact(t1)` then deletes the LIVE body (INV_NO_LOSS). Add-only meta closes it: the spare leaves +/// `Condemned`, the writer resurrects to `t2`, and the stale `deleteExact(t1)` is a `TokenMismatch` no-op. +/// +/// Interleaving fidelity (APPROXIMATED): the deposed leader's destructive side effect is its pre-CAS +/// exact-token `deleteExact(h, t1)`. We reproduce it deterministically by CAPTURING `t1` at condemn time +/// (exactly the token a paused leader's `delete_pending` snapshot holds) and firing that exact +/// `deleteExact` AFTER the surviving leader's spare and the writer's resurrect — the faithful destructive +/// op, without a mid-round CAS-interrupt seam on the delete path (which the backend does not expose). +TEST(CASGCRetire, StaleRedeleteAfterSpareDoesNotDeleteLiveReuse) +{ + auto backend = std::make_shared(); + auto store = openPoolForTest(backend); + const RootNamespace ns{"00/aa@cas@"}; + + const String payload = "two-leader-stale-redelete-payload"; + const UInt128 hash = u128Of(payload); + const BlobRef id = idOf(payload); + const String blob_key = store->layout().blobKey(id); + { + auto seed = store->beginPartWrite({}); + seed->putBlob(id, BlobSource::fromString(payload)); + } + + const ManifestRef r1 = ref("srv-a:1", 1, 0xA1); + writeManifestRaw(*backend, store->layout(), ns, r1, {blobEntryFor("a", hash)}); + publishCommittedTransition(*backend, store->layout(), ns, "tbl", std::nullopt, r1); + Gc gc(store, kGc); + gc.runRegularRound(); /// +1 folds + dropRefTransition(*backend, store->layout(), ns, "tbl", r1); + gc.runRegularRound(); /// -1 => in-degree 0 => condemned at t1 + + /// The OLD leader L1's planned pre-CAS delete uses the EXACT token it observed at condemn: capture t1. + const auto condemned_entry = currentEntryFor(*backend, store->layout(), hash); + ASSERT_TRUE(condemned_entry.has_value()); + const Token t1 = condemned_entry->token; + ASSERT_EQ(backend->head(blob_key).token, t1); + + /// A NEW leader L2 folds a +1 that recovered h's in-degree and adopts a SPARE for h. + const ManifestRef r2 = ref("srv-a:1", 2, 0xA2); + writeManifestRaw(*backend, store->layout(), ns, r2, {blobEntryFor("a", hash)}); + publishCommittedTransition(*backend, store->layout(), ns, "tbl_live", std::nullopt, r2); + gc.runRegularRound(); /// +1 => spared + + /// Add-only: the spare left the meta Condemned (the stale-redelete guard depends on it). + { + const auto lm = loadMetaForTest(*backend, store->layout(), hash); + ASSERT_TRUE(lm.has_value()); + EXPECT_EQ(lm->meta.state, MetaState::Condemned) + << "add-only: a spare must not clear the meta (the writer must still see the hash condemned)"; + } + + /// A writer dedup-hits h. It point-reads Condemned and RESURRECTS to a fresh token t2 + /// (uploadFromSource) — it never reuses t1. + { + auto build = store->beginPartWrite({}); + build->putBlob(id, BlobSource::fromString(payload)); + } + const Token t2 = backend->head(blob_key).token; + EXPECT_NE(t2, t1) << "the writer resurrected to a fresh incarnation, not a reuse of t1"; + + /// L1 resumes and executes its stale pre-CAS exact-token redelete `deleteExact(h, t1)`: it must be a + /// TokenMismatch no-op (the live body is now t2), NEVER a Deleted of the live reuse. + const DeleteOutcome stale = backend->deleteExact(blob_key, t1); + EXPECT_EQ(stale.kind, DeleteOutcome::Kind::TokenMismatch) + << "the stale exact-token redelete must miss the live reuse (add-only closes INV_NO_LOSS)"; + + /// The live body under t2 survives, stays reachable via the committed r2, and fsck sees no dangle. + const HeadResult hr = backend->head(blob_key); + ASSERT_TRUE(hr.exists); + EXPECT_EQ(hr.token, t2); + replaceRecoverableCkptForRawFixture( + *backend, store->layout(), ns, + RefCkpt{.life_epoch = 1, .committed_through = RefTxnId{1, 3}, + .checkpoint_snapshot_id = std::nullopt, .last_epoch_seal = std::nullopt}); + EXPECT_EQ(runFsck(*store, /*detail=*/false).dangling, 0u) + << "no live reference dangles: the stale redelete did not delete the reused body"; +} + +/// Copy-forward aftermath, republished arm (spec 2026-07-02-cas-copy-forward-condemned-evidence.md): +/// after a condemned incarnation (hash, t0) is displaced by a verified copy-forward (fresh token t1) +/// and the republished part's +1 lands, the listed (hash, t0) entry settles WITHOUT touching the new +/// incarnation: its exact-token delete is a mismatch no-op and the entry drops; the blob survives at t1. +TEST(CASGCRetire, CopyForwardedBlobSurvivesWhenRepublished) +{ + auto backend = std::make_shared(); + auto store = openPoolForTest(backend); + const RootNamespace ns{"00/aa@cas@"}; + const ManifestRef r1 = ref("srv-a:1", 1, 0xA1); + writeBlobBody(*backend, store->layout(), DB::UInt128(1)); + writeManifestRaw(*backend, store->layout(), ns, r1, {blobEntryFor("a", DB::UInt128(1))}); + publishCommittedTransition(*backend, store->layout(), ns, "tbl", std::nullopt, r1); + Gc gc(store, kGc); + gc.runRegularRound(); + dropRefTransition(*backend, store->layout(), ns, "tbl", r1); + gc.runRegularRound(); /// -1 folds => in-degree 0 => entry (1, t0) condemned + ASSERT_TRUE(currentEntryFor(*backend, store->layout(), DB::UInt128(1)).has_value()); + + /// The raw equivalent of a writer resurrect (PartWriteTxn::uploadFromSource): displace EXACTLY t0 with the + /// same verified bytes under a fresh token t1, then republish a part referencing the blob (the + /// promoted dst ref of a republishRef move). + const String blob_key = store->layout().blobKey(BlobRef{BlobHashAlgo::CityHash128, BlobDigest::fromU128(DB::UInt128(1))}); + const Token t0 = backend->head(blob_key).token; + const auto res = backend->putOverwrite(blob_key, backend->get(blob_key)->bytes, t0); + ASSERT_EQ(res.outcome, PutOutcome::Done); + const ManifestRef r2 = ref("srv-a:1", 2, 0xA2); + writeManifestRaw(*backend, store->layout(), ns, r2, {blobEntryFor("a", DB::UInt128(1))}); + publishCommittedTransition(*backend, store->layout(), ns, "tbl_detached", std::nullopt, r2); + + /// The +1 folds => spared; the (1, t0) entry drops; the t1 incarnation is never deleted. + for (int i = 0; i < 4; ++i) + gc.runRegularRound(); + EXPECT_FALSE(currentEntryFor(*backend, store->layout(), DB::UInt128(1)).has_value()); + const HeadResult hr = backend->head(blob_key); + ASSERT_TRUE(hr.exists); + EXPECT_EQ(hr.token, res.token); +} + +/// Copy-forward aftermath, stale-entry arm: a listed (hash, t0) entry whose incarnation was +/// displaced (token now t1) with NO accompanying owner events. The entry graduates and its +/// exact-token delete MISMATCHES — a no-op, the entry drops, the t1 incarnation is NEVER +/// wrong-token-deleted (no wedge, no unsafe delete). This is a RAW-displacement model, stronger +/// than the real flow: in real `republishRef` the dst precommit + body are durable BEFORE the +/// promote pre-pass runs (reachability-before-content, B188), so an abandoned real copy-forward +/// is fully reclaimed by the pipeline (+1 spare -> reclaim -1 -> transition to zero -> fresh +/// (hash, t1) entry -> exact delete). The raw shape pins the GC-side invariant in isolation. +TEST(CASGCRetire, AbandonedCopyForwardDropsEntryWithoutWrongTokenDelete) +{ + auto backend = std::make_shared(); + auto store = openPoolForTest(backend); + const RootNamespace ns{"00/aa@cas@"}; + const ManifestRef r1 = ref("srv-a:1", 1, 0xA1); + writeBlobBody(*backend, store->layout(), DB::UInt128(1)); + writeManifestRaw(*backend, store->layout(), ns, r1, {blobEntryFor("a", DB::UInt128(1))}); + publishCommittedTransition(*backend, store->layout(), ns, "tbl", std::nullopt, r1); + Gc gc(store, kGc); + runRegularRoundReclaiming(gc); + dropRefTransition(*backend, store->layout(), ns, "tbl", r1); + runRegularRoundReclaiming(gc); + ASSERT_TRUE(currentEntryFor(*backend, store->layout(), DB::UInt128(1)).has_value()); + + const String blob_key = store->layout().blobKey(BlobRef{BlobHashAlgo::CityHash128, BlobDigest::fromU128(DB::UInt128(1))}); + const Token t0 = backend->head(blob_key).token; + const auto res = backend->putOverwrite(blob_key, backend->get(blob_key)->bytes, t0); + ASSERT_EQ(res.outcome, PutOutcome::Done); + + /// No events land at all (raw displacement). Drive rounds with the store's ack kept current so + /// the (1, t0) entry graduates; its exact-token delete mismatches t1 and the entry drops. + for (int i = 0; i < 6; ++i) + { + runRegularRoundReclaiming(gc); + store->renewWatermarkOnce(); + } + EXPECT_FALSE(currentEntryFor(*backend, store->layout(), DB::UInt128(1)).has_value()) + << "the stale (hash, t0) entry must settle (mismatch redelete drops it), not wedge the list"; + const HeadResult hr = backend->head(blob_key); + ASSERT_TRUE(hr.exists) << "the fresh incarnation must never be deleted under the stale token"; + EXPECT_EQ(hr.token, res.token); +} + +/// A completed round adopts the SAME attempt its fold minted (the round's single gc/state CAS commits the +/// fold's (snap_generation, snap_attempt) together). Completion seals are a retired concept, so the durable +/// index of the adopted round is the FOLD seal at (snap_generation, snap_attempt). Across rounds each +/// `runRegularRound` re-acquires the lease (bumping `lease.seq`), so a later round mints a FRESH attempt. +TEST(CASGCRecheck, CompletionInheritsFoldAttempt) +{ + auto backend = std::make_shared(); + auto store = openPoolForTest(backend); + const RootNamespace ns{"00/aa@cas@"}; + const ManifestRef r = ref("srv-a:1", 1, 0xAA); + writeBlobBody(*backend, store->layout(), DB::UInt128(1)); + writeManifestRaw(*backend, store->layout(), ns, r, {blobEntryFor("a", DB::UInt128(1))}); + publishCommittedTransition(*backend, store->layout(), ns, "tbl", std::nullopt, r); + Gc gc(store, kGc); + + gc.runRegularRound(); // round 1: one pass, single CAS commits (snap_generation, snap_attempt) + const auto after_round1 = decodeGcState(backend->get(store->layout().gcStateKey())->bytes); + // The round adopted the attempt of THIS round's fold: snap_attempt == the lease.seq that folded it. + EXPECT_EQ(after_round1.snap_attempt, after_round1.lease.seq); + EXPECT_GT(after_round1.snap_generation, 0u); + // The fold seal is durable under the adopted (snap_generation, snap_attempt) pair (no completion seal). + EXPECT_TRUE(backend->head(store->layout() + .foldSealKey(after_round1.snap_generation, after_round1.snap_attempt)).exists); + + dropRefTransition(*backend, store->layout(), ns, "tbl", r); + gc.runRegularRound(); // round 2: re-acquire (bump lease.seq) -> fresh attempt at its fold + const auto after_round2 = decodeGcState(backend->get(store->layout().gcStateKey())->bytes); + EXPECT_EQ(after_round2.snap_attempt, after_round2.lease.seq); + EXPECT_GT(after_round2.snap_attempt, after_round1.snap_attempt); // per-round monotone attempt + EXPECT_GT(after_round2.snap_generation, after_round1.snap_generation); + EXPECT_TRUE(backend->head(store->layout() + .foldSealKey(after_round2.snap_generation, after_round2.snap_attempt)).exists); +} + +/// ---- round-paced graduation suite (spec 2026-07-02 + Task-9 amendment; re-keyed off acks in v3 Task 6) ---- + +/// A regular round performs NO writes to the ref objects: ref state is writer-owned (immutable +/// `_log`/`_snap`), and GC only reads it (plus deletes covered objects via ref-object cleanup, which +/// needs a covering snapshot -- none exists here). So a no-op round adds and removes NO ref object. +TEST(CASGCAckFloor, NoOpRoundDoesNotMutateRefShards) +{ + auto backend = std::make_shared(); + auto store = openPoolForTest(backend); + const RootNamespace ns{"00/aa@cas@"}; + publishCommittedTransition(*backend, store->layout(), ns, "tbl", std::nullopt, + ManifestRef{.writer_epoch = 1, .build_sequence = 1, .manifest_ordinal = 1}); + Gc gc(store, kGc); + gc.runRegularRound(); // first round folds the publish + + const auto listRefKeys = [&] + { + std::set keys; + String cursor; + for (;;) + { + const ListPage page = backend->list(store->layout().namespaceStreamPrefix(fixture::fixtureLife(ns)), cursor, 1000); + for (const ListedKey & lk : page.keys) + keys.insert(lk.key); + if (page.next_cursor.empty()) + break; + cursor = page.next_cursor; + } + return keys; + }; + + const std::set before = listRefKeys(); + ASSERT_FALSE(before.empty()) << "the publish must have written at least one ref object"; + + gc.runRegularRound(); // a second, no-op round must not add or remove any ref object + const std::set after = listRefKeys(); + EXPECT_EQ(before, after) << "a no-op GC round must not mutate the table's ref objects"; + // The registry object is gone (Task 4); the fence never existed to write it. + EXPECT_FALSE(backend->get("p/gc/registry").has_value()); +} + +/// The canonical pipeline: a blob condemned at round K stays present after the condemning round; the +/// VERY NEXT round graduates it (round-paced, unconditional — condemn_round < current_round the first +/// round current_round exceeds it) and publishes it delete_pending — the blob still exists; the round +/// AFTER THAT executes the exact-token delete and the blob becomes absent. This pins the critical +/// off-by-one: current_round MUST equal state.round + 1 (the SAME basis condemn_round is stamped at), +/// so an entry graduates exactly one round after it was condemned — never the same round, never never. +TEST(CASGCAckFloor, CondemnThenGraduatesNextRoundThenDeletes) +{ + auto backend = std::make_shared(); + auto store = openPoolForTest(backend); + const RootNamespace ns{"00/aa@cas@"}; + const ManifestRef r = ref("srv-a:1", 1, 0xAA); + const UInt128 blob = DB::UInt128(1); + writeBlobBody(*backend, store->layout(), blob); + writeManifestRaw(*backend, store->layout(), ns, r, {blobEntryFor("a", blob)}); + publishCommittedTransition(*backend, store->layout(), ns, "tbl", std::nullopt, r); + Gc gc(store, kGc); + + runRegularRoundReclaiming(gc); // round 1: folds the +1; blob referenced + dropRefTransition(*backend, store->layout(), ns, "tbl", r); + + // The condemning round: the -1 drops in-degree to 0; the blob is condemned into the current retired + // list but NOT deleted. The entry is present and NOT yet pending. report.condemned counts it. + { + const RoundReport rep = runRegularRoundReclaiming(gc); + EXPECT_EQ(rep.condemned, 1u); // one blob condemned this round + EXPECT_EQ(rep.graduated, 0u); // must NOT graduate the same round it was condemned + EXPECT_EQ(rep.redeleted, 0u); // nothing pending to delete yet + EXPECT_TRUE(blobExists(*backend, store->layout(), blob)); + const auto e = currentEntryFor(*backend, store->layout(), blob); + ASSERT_TRUE(e.has_value()); + EXPECT_FALSE(e->delete_pending); // condemned, not yet graduated + } + + // The VERY NEXT round graduates it deterministically (no ack/heartbeat dependency). + { + const RoundReport rep = runRegularRoundReclaiming(gc); + EXPECT_EQ(rep.graduated, 1u); + EXPECT_EQ(rep.redeleted, 0u); // the delete lands on the NEXT pass, not this one + const auto e = currentEntryFor(*backend, store->layout(), blob); + ASSERT_TRUE(e.has_value()); + EXPECT_TRUE(e->delete_pending); + EXPECT_TRUE(blobExists(*backend, store->layout(), blob)); // pending: still present this pass + } + + // The pass AFTER the pending publish executes the exact-token delete; the blob becomes absent and the + // entry is dropped from the current retired list. report.redeleted counts the executed pending delete. + { + const RoundReport rep = runRegularRoundReclaiming(gc); + EXPECT_EQ(rep.redeleted, 1u); // the pending delete executed this round + EXPECT_FALSE(blobExists(*backend, store->layout(), blob)); + EXPECT_FALSE(currentEntryFor(*backend, store->layout(), blob).has_value()); + } +} + +/// End-to-end through the real round driver (`Gc::runRegularRound`) rather than +/// `foldDeltasIntoGeneration` directly: a cohort well past `gc_round_redelete_budget` still drains +/// completely, but no single round's `redeleted` count exceeds the cap — the same convergence the +/// merge-level `CASThreeCursorMerge` budget tests pin, proven through the production entry point. +TEST(CASGCAckFloor, RedeleteBudgetCapsRoundDrainAndConverges) +{ + auto backend = std::make_shared(); + constexpr uint64_t kCap = 5; + auto store = Pool::open(backend, + PoolConfig{.pool_prefix = "p", .server_root_id = "test", .gc_round_redelete_budget = kCap}); + const RootNamespace ns{"00/aa@cas@"}; + + constexpr uint64_t kCohort = 20; + std::vector blobs; + std::vector refs; + for (uint64_t i = 1; i <= kCohort; ++i) + { + const UInt128 blob(i); + const ManifestRef r = ref("srv-a:1", i, static_cast(i)); + blobs.push_back(blob); + refs.push_back(r); + writeBlobBody(*backend, store->layout(), blob); + writeManifestRaw(*backend, store->layout(), ns, r, {blobEntryFor("a", blob)}); + publishCommittedTransition(*backend, store->layout(), ns, "tbl" + std::to_string(i), std::nullopt, r); + } + + Gc gc(store, kGc); + runRegularRoundReclaiming(gc); // round 1: folds every publish, every blob referenced + + for (uint64_t i = 1; i <= kCohort; ++i) + dropRefTransition(*backend, store->layout(), ns, "tbl" + std::to_string(i), refs[i - 1]); + + { + const RoundReport rep = runRegularRoundReclaiming(gc); // condemning round + EXPECT_EQ(rep.condemned, kCohort); + EXPECT_EQ(rep.graduated, 0u); + EXPECT_EQ(rep.redeleted, 0u); + } + { + // Graduation has no budget configured in this test (default 0 = unbounded) — the whole + // cohort graduates together, isolating the redelete cap as the only thing under test. + const RoundReport rep = runRegularRoundReclaiming(gc); + EXPECT_EQ(rep.graduated, kCohort); + EXPECT_EQ(rep.redeleted, 0u); + } + + uint64_t total_redeleted = 0; + uint64_t rounds = 0; + while (total_redeleted < kCohort && rounds < 10) + { + const RoundReport rep = runRegularRoundReclaiming(gc); + EXPECT_LE(rep.redeleted, kCap) << "a round must never redelete past gc_round_redelete_budget"; + total_redeleted += rep.redeleted; + ++rounds; + } + EXPECT_EQ(total_redeleted, kCohort) << "no entry lost to the cap across the whole drain"; + EXPECT_EQ(rounds, kCohort / kCap) << "ceil(20 / 5) rounds to fully drain the cohort"; + for (const UInt128 & blob : blobs) + EXPECT_FALSE(blobExists(*backend, store->layout(), blob)); +} + +/// A publish re-referencing the condemned blob before graduation is folded and SPARES the entry: the entry +/// is dropped (recovery wins even past graduation) and the blob survives. +TEST(CASGCAckFloor, PublishBeforeGraduationSpares) +{ + auto backend = std::make_shared(); + auto store = openPoolForTest(backend); + const RootNamespace ns{"00/aa@cas@"}; + const ManifestRef r1 = ref("srv-a:1", 1, 0xA1); + const ManifestRef r2 = ref("srv-a:1", 2, 0xA2); + const UInt128 blob = DB::UInt128(1); + writeBlobBody(*backend, store->layout(), blob); + writeManifestRaw(*backend, store->layout(), ns, r1, {blobEntryFor("a", blob)}); + publishCommittedTransition(*backend, store->layout(), ns, "tbl", std::nullopt, r1); + Gc gc(store, kGc); + + gc.runRegularRound(); + store->renewWatermarkOnce(); + dropRefTransition(*backend, store->layout(), ns, "tbl", r1); + gc.runRegularRound(); // condemns blob 1 (in-degree 0) + store->renewWatermarkOnce(); + ASSERT_TRUE(currentEntryFor(*backend, store->layout(), blob).has_value()); + + // Re-publish a committed ref pointing at the same blob BEFORE it graduates: the next pass folds the +1, + // the merge sees in-degree 1, and the entry is spared (dropped from the retired list). + writeManifestRaw(*backend, store->layout(), ns, r2, {blobEntryFor("a", blob)}); + publishCommittedTransition(*backend, store->layout(), ns, "tbl", std::nullopt, r2); + gc.runRegularRound(); + store->renewWatermarkOnce(); + EXPECT_FALSE(currentEntryFor(*backend, store->layout(), blob).has_value()); // spared: entry dropped + EXPECT_TRUE(blobExists(*backend, store->layout(), blob)); + + // Keep running: the re-referenced blob must never be deleted. + for (int i = 0; i < 4; ++i) + { + gc.runRegularRound(); + store->renewWatermarkOnce(); + } + EXPECT_TRUE(blobExists(*backend, store->layout(), blob)); +} + +/// A dead mount is fenced out by the round's heartbeat step: gc_fenced is set on its body (a +/// token-guarded rewrite that bumps seq). The fence is pure liveness (re-arms the write fence so a +/// resumed sleeper can never mutate again); reclaim itself no longer depends on any mount's heartbeat — +/// graduation paces on GC rounds. The fenced mount's own subsequent renew then fails closed, because the +/// fence invalidated the token it held. +/// +/// Rev.6 §token-stability observation (Task 9): the fence-out no longer trusts a bare wall-clock stamp +/// (`expires_at_ms`) against the GC's own clock — it fences ONLY once GC has watched a mount's write +/// token hold unchanged for the full threshold on its OWN monotonic clock. That takes (at least) two +/// `computeHeartbeatFloor` calls spanning the threshold, so this test drives the GC leader's own +/// (persistent) `mono_ms_fn` across two rounds: round 1 seeds the observation for both mounts; the +/// STORE's own mount is then renewed (as a live leader would) before round 2 crosses the threshold — +/// srid2, never renewed again after its one-shot claim, is the one that gets fenced. +TEST(CASGCAckFloor, ExpiredMountFencedOutAndExcluded) +{ + auto backend = std::make_shared(); + std::vector events; /// declared BEFORE the Pool so it outlives the background syncer's emits (ASan 2026-07-09) + auto store = openPoolForTest(backend); + const Layout & layout = store->layout(); + + // srid2's keeper claims ONE lease via `start()` and is never renewed again — tests never enable + // the background renewal thread (`background_watermark` defaults to false), so this alone models a + // crashed process: a body that is live-shaped (not terminated, not fenced) but whose write token + // never changes again. + const String srid2 = "stale-server"; + MountLeaseKeeper srid2_keeper(backend, layout, srid2, DB::UInt128(0x2222), /*writer_epoch=*/1, + std::chrono::milliseconds(100), [] { return 1000u; }, [] { return 0u; }); + srid2_keeper.start(); + ASSERT_FALSE(decodeMountLease(backend->get(layout.mountKey(srid2))->bytes).gc_fenced); + + // The fence-out threshold on the GC leader's OWN monotonic clock — mirrors the production formula + // in `Gc::runRegularRound` (ttl + 5% drift allowance + one round's worth of renewal slack). + const uint64_t ttl_ms = static_cast(store->poolConfig().mount_lease_ttl_ms.count()); + const uint64_t threshold_ms = ttl_ms + ttl_ms / 20 + + static_cast(store->poolConfig().mount_renew_period.count()); + + uint64_t gc_now = 1'000'000; // audit-only wall clock; never gates the fence decision + uint64_t gc_mono = 0; + Gc gc(store, kGc, [&] { return gc_now; }, [&] { return gc_mono; }); + + // Capture the emitted events so we can assert the round emits exactly one GcFenceOut row for srid2. + store->setEventSink([&](const CasEvent & e) { events.push_back(e); }); + + const RootNamespace ns{"00/aa@cas@"}; + const ManifestRef r = ref("srv-a:1", 1, 0xAA); + const UInt128 blob = DB::UInt128(7); + writeBlobBody(*backend, layout, blob); + writeManifestRaw(*backend, layout, ns, r, {blobEntryFor("a", blob)}); + publishCommittedTransition(*backend, layout, ns, "tbl", std::nullopt, r); + + // Round 1 (mono 0): first sight of both mounts — observation starts, nothing fenced yet. + const RoundReport rep1 = gc.runRegularRound(); + EXPECT_EQ(rep1.fence_outs, 0u); + + // The store's OWN mount renews between rounds (as a live leader would); srid2 never does. + store->renewWatermarkOnce(); + gc_mono = threshold_ms; + + // Round 2 (mono == threshold): srid2's original token has held stable for the full threshold — + // fenced. The store's own (just-renewed) mount restarts its observation and stays live. + const RoundReport rep = gc.runRegularRound(); + + EXPECT_EQ(rep.fence_outs, 1u); // exactly one dead mount fenced-out this round + const MountLease fenced = decodeMountLease(backend->get(layout.mountKey(srid2))->bytes); + EXPECT_TRUE(fenced.gc_fenced); + + // Exactly one GcFenceOut audit row was emitted, naming srid2 in its detail. + size_t fence_out_rows = 0; + for (const CasEvent & e : events) + if (e.type == CasEventType::GcFenceOut) + { + ++fence_out_rows; + EXPECT_EQ(e.outcome, "fenced"); + EXPECT_FALSE(e.reason.empty()); + const auto it = e.detail.find("server_root_id"); + ASSERT_NE(it, e.detail.end()); + EXPECT_EQ(it->second, srid2); + } + EXPECT_EQ(fence_out_rows, 1u); + + // srid2's writer comes back and tries to renew: its held token was invalidated by the fence rewrite, + // so renewOnce fails closed. (It renews on its own clock; liveness is irrelevant — the token guard + // trips regardless.) + EXPECT_THROW(srid2_keeper.renewOnce(), DB::Exception); + + // The fence-out is pure liveness cleanup: reclaim proceeds through the normal (round-paced) pipeline + // regardless of srid2's fate — fencing one stale mount must never wedge the reclaim pipeline. + dropRefTransition(*backend, layout, ns, "tbl", r); + EXPECT_TRUE(runRoundsUntilAbsent(store, gc, *backend, layout, blob)); +} + +/// fix-round F6 (author-review: `Gc`'s own `mono_ms_fn` used to default to the RAW static `Pool:: +/// bootMs()`, bypassing the Pool's own injectable `config.boot_ms_fn` -- a time-controlled test can +/// desync the mount side's fake clock from the GC side's real one). This mirrors +/// `ExpiredMountFencedOutAndExcluded` exactly, except: the Pool is opened with an injected +/// `boot_ms_fn` driving a FAKE clock that barely advances in real time, and `Gc` is constructed WITHOUT +/// an explicit `mono_ms_fn` -- exercising the DEFAULT under test. If the default still read the real +/// wall clock, this round would see essentially zero elapsed mono time and never cross the fence-out +/// threshold; the fix makes it default to `store->bootMsNow()`, which tracks the SAME fake clock. +TEST(CASGCAckFloor, DefaultMonoClockTracksPoolsInjectedBootClockNotWallClock) +{ + auto backend = std::make_shared(); + uint64_t fake_boot = 0; + auto store = Pool::open(backend, PoolConfig{.pool_prefix = "p", .server_root_id = "test", + .boot_ms_fn = [&] { return fake_boot; }}); + const Layout & layout = store->layout(); + + // A stale mount, exactly as `ExpiredMountFencedOutAndExcluded`: one claim, never renewed again. + const String srid2 = "stale-server"; + MountLeaseKeeper srid2_keeper(backend, layout, srid2, DB::UInt128(0x2222), /*writer_epoch=*/1, + std::chrono::milliseconds(100), [] { return 1000u; }, [&] { return fake_boot; }); + srid2_keeper.start(); + ASSERT_FALSE(decodeMountLease(backend->get(layout.mountKey(srid2))->bytes).gc_fenced); + + const uint64_t ttl_ms = static_cast(store->poolConfig().mount_lease_ttl_ms.count()); + const uint64_t threshold_ms = ttl_ms + ttl_ms / 20 + + static_cast(store->poolConfig().mount_renew_period.count()); + + // `Gc` constructed with only `now_ms_fn` -- `mono_ms_fn` is left at its DEFAULT (the fix under test). + Gc gc(store, kGc, [] { return 1'000'000u; }); + + const RoundReport rep1 = gc.runRegularRound(); + EXPECT_EQ(rep1.fence_outs, 0u); + + store->renewWatermarkOnce(); + fake_boot = threshold_ms; // advance the FAKE clock only; this test runs in well under a millisecond + + const RoundReport rep2 = gc.runRegularRound(); + EXPECT_EQ(rep2.fence_outs, 1u) + << "Gc's default mono_ms_fn must track the Pool's injected boot clock, not the real wall clock"; + EXPECT_TRUE(decodeMountLease(backend->get(layout.mountKey(srid2))->bytes).gc_fenced); +} + +/// deleteExact against a blob the writer RECREATED (fresh incarnation, different token) between the pending +/// publish and the deleting pass lands TokenMismatch — a terminal-OK outcome recorded as a replace: the +/// fresh incarnation is a live object and survives. report.replaced counts it. +TEST(CASGCAckFloor, RecreatedBlobDeleteIsTokenMismatchOk) +{ + auto backend = std::make_shared(); + auto store = openPoolForTest(backend); + const RootNamespace ns{"00/aa@cas@"}; + const ManifestRef r = ref("srv-a:1", 1, 0xAA); + const UInt128 blob = DB::UInt128(1); + const BlobRef blob_id{BlobHashAlgo::CityHash128, BlobDigest::fromU128(blob)}; + writeBlobBody(*backend, store->layout(), blob); + writeManifestRaw(*backend, store->layout(), ns, r, {blobEntryFor("a", blob)}); + publishCommittedTransition(*backend, store->layout(), ns, "tbl", std::nullopt, r); + Gc gc(store, kGc); + + runRegularRoundReclaiming(gc); + store->renewWatermarkOnce(); + dropRefTransition(*backend, store->layout(), ns, "tbl", r); + runRegularRoundReclaiming(gc); // condemn (captures the ORIGINAL token) + store->renewWatermarkOnce(); + + // Drive rounds until the entry is delete_pending (the token it holds is the original observation). + bool pending = false; + for (int i = 0; i < 6 && !pending; ++i) + { + runRegularRoundReclaiming(gc); + store->renewWatermarkOnce(); + const auto e = currentEntryFor(*backend, store->layout(), blob); + pending = e && e->delete_pending; + } + ASSERT_TRUE(pending); + + // The writer recreates the blob with a FRESH incarnation before the deleting pass: the current token no + // longer matches the pending entry's captured token. + displaceBlobToken(*backend, store->layout(), blob_id); + + // The deleting pass issues deleteExact(entry.token) → TokenMismatch → Replaced. The fresh incarnation + // survives; the entry is dropped. + const RoundReport rep = runRegularRoundReclaiming(gc); + store->renewWatermarkOnce(); + EXPECT_EQ(rep.replaced, 1u); + EXPECT_TRUE(blobExists(*backend, store->layout(), blob)); // the recreated incarnation is live + EXPECT_FALSE(currentEntryFor(*backend, store->layout(), blob).has_value()); +} + +/// Idempotent replay of a crashed round: a fresh Gc instance (new lease seq = new attempt) re-runs a round +/// and completes; a delete that already landed under a prior pass replays onto NotFound (Absent outcome) +/// and the round still completes. We model the crash-after-delete-before-CAS replay by manually deleting +/// the pending blob (its exact token) BEFORE the deleting pass, then asserting the pass reports the delete +/// as absent (report.absent == 1) and completes (round advances). +TEST(CASGCAckFloor, ResumeAfterCrashBetweenRetiredPutAndStateCas) +{ + auto backend = std::make_shared(); + auto store = openPoolForTest(backend); + const RootNamespace ns{"00/aa@cas@"}; + const ManifestRef r = ref("srv-a:1", 1, 0xAA); + const UInt128 blob = DB::UInt128(1); + const BlobRef blob_id{BlobHashAlgo::CityHash128, BlobDigest::fromU128(blob)}; + writeBlobBody(*backend, store->layout(), blob); + writeManifestRaw(*backend, store->layout(), ns, r, {blobEntryFor("a", blob)}); + publishCommittedTransition(*backend, store->layout(), ns, "tbl", std::nullopt, r); + + // A fresh Gc per round (each acquires the lease, bumping lease.seq = a fresh attempt) — the replay + // property: no wedging, each round completes under its own fresh attempt. + { + Gc gc(store, kGc); + runRegularRoundReclaiming(gc); + store->renewWatermarkOnce(); + } + dropRefTransition(*backend, store->layout(), ns, "tbl", r); + + RetiredEntry pending_entry; + bool pending = false; + for (int i = 0; i < 6 && !pending; ++i) + { + Gc gc(store, kGc); + runRegularRoundReclaiming(gc); + store->renewWatermarkOnce(); + const auto e = currentEntryFor(*backend, store->layout(), blob); + if (e && e->delete_pending) + { + pending = true; + pending_entry = *e; + } + } + ASSERT_TRUE(pending); + + // Simulate a crashed deleting pass that DID land the exact-token delete but crashed before the gc/state + // CAS. The next (fresh-attempt) pass replays the delete → the object is already gone → NotFound → the + // pass records Absent and completes. + ASSERT_EQ(backend->deleteExact(store->layout().blobKey(blob_id), pending_entry.token).kind, + DeleteOutcome::Kind::Deleted); + + const uint64_t round_before = decodeGcState(backend->get(store->layout().gcStateKey())->bytes).round; + Gc gc2(store, kGc); + const RoundReport rep = runRegularRoundReclaiming(gc2); + store->renewWatermarkOnce(); + EXPECT_EQ(rep.absent, 1u); // the replayed delete found the object already gone + const uint64_t round_after = decodeGcState(backend->get(store->layout().gcStateKey())->bytes).round; + EXPECT_GT(round_after, round_before); // the round completed (no wedge) + EXPECT_FALSE(currentEntryFor(*backend, store->layout(), blob).has_value()); +} + +/// Backend-agnostic regression for the rustfs 412-on-absent quirk: a conditional exact-token delete +/// against an object that is ALREADY absent answers `TokenMismatch`, not `NotFound`, on this backend +/// (`TokenMismatchOnAbsentBackend` reproduces it deterministically). The redelete site must disambiguate +/// via a follow-up HEAD: the object is truly gone, so the outcome must settle as Absent (never Replaced) +/// and the `.meta` cleanup (gated on Deleted/NotFound) must still run. +TEST(CASGCAckFloor, TokenMismatchOnAbsentBlobSettlesAsAbsentAndDropsMeta) +{ + auto backend = std::make_shared(); + auto store = openPoolForTest(backend); + const RootNamespace ns{"00/aa@cas@"}; + const ManifestRef r = ref("srv-a:1", 1, 0xAA); + const UInt128 blob = DB::UInt128(1); + const BlobRef blob_id{BlobHashAlgo::CityHash128, BlobDigest::fromU128(blob)}; + writeBlobBody(*backend, store->layout(), blob); + writeManifestRaw(*backend, store->layout(), ns, r, {blobEntryFor("a", blob)}); + publishCommittedTransition(*backend, store->layout(), ns, "tbl", std::nullopt, r); + + Gc gc(store, kGc); + runRegularRoundReclaiming(gc); + store->renewWatermarkOnce(); + dropRefTransition(*backend, store->layout(), ns, "tbl", r); + runRegularRoundReclaiming(gc); // condemn + store->renewWatermarkOnce(); + + // Drive rounds until the entry is delete_pending, capturing its exact condemn-time token. + RetiredEntry pending_entry; + bool pending = false; + for (int i = 0; i < 6 && !pending; ++i) + { + runRegularRoundReclaiming(gc); + store->renewWatermarkOnce(); + const auto e = currentEntryFor(*backend, store->layout(), blob); + if (e && e->delete_pending) + { + pending = true; + pending_entry = *e; + } + } + ASSERT_TRUE(pending); + { + const auto lm = loadMetaForTest(*backend, store->layout(), blob); + ASSERT_TRUE(lm.has_value()) << "the blob must still carry its Condemned freshness meta pre-delete"; + ASSERT_EQ(lm->meta.state, MetaState::Condemned); + } + + // The object is genuinely gone already (as if a prior crashed pass landed the delete); confirm that, + // then arm the quirk so the NEXT conditional delete against this now-absent key answers TokenMismatch + // instead of NotFound (the rustfs 412-on-absent behavior). + const String blob_key = store->layout().blobKey(blob_id); + ASSERT_EQ(backend->deleteExact(blob_key, pending_entry.token).kind, DeleteOutcome::Kind::Deleted); + ASSERT_FALSE(backend->head(blob_key).exists); + backend->quirkOnAbsent(blob_key); + + // The deleting pass replays deleteExact(entry.token): the backend answers TokenMismatch (quirk), but + // the follow-up HEAD shows the object absent, so the fix disambiguates the outcome to Absent and still + // runs the `.meta` cleanup. + const RoundReport rep = runRegularRoundReclaiming(gc); + store->renewWatermarkOnce(); + EXPECT_EQ(rep.absent, 1u) << "the 412-on-absent quirk must settle as Absent, not Replaced"; + EXPECT_EQ(rep.replaced, 0u); + EXPECT_FALSE(currentEntryFor(*backend, store->layout(), blob).has_value()); + EXPECT_FALSE(loadMetaForTest(*backend, store->layout(), blob).has_value()) + << ".meta cleanup (gated on Deleted/NotFound) must still run on the disambiguated Absent outcome"; +} + +/// ---- condemn-marker gate suite ---- +/// +/// The per-hash condemn marker is LOAD-BEARING for the delete edge: the writer's adopt gate point-reads +/// the meta and an ABSENT meta reads as Clean, so a blob whose condemn-marker write was swallowed can be +/// same-token adopted by a writer landing in the [discovery-LIST, deleteExact] window — invisible to the +/// graduating fold — and the exact-token redelete then deletes a body under a live committed edge +/// (dangling manifest). Graduation to `delete_pending` therefore requires CONFIRMED durable `Condemned` +/// evidence for the entry; absent evidence CARRIES the entry to the next round (fail-safe delay, never a +/// fail-open delete) and retries the marker so a healed backend restores liveness. + +/// A condemned entry whose marker write was swallowed must be CARRIED round after round — never +/// graduated, never deleted — until durable `Condemned` evidence exists. Once the backend heals, the +/// carry-time marker retry lands and the normal two-phase pipeline reclaims the blob (delay, not a leak). +TEST(CASGCCondemnMarker, SwallowedMarkerWriteCarriesEntryInsteadOfDeleting) +{ + auto backend = std::make_shared(); + auto store = openPoolForTest(backend); + store->setCasRetrySleepForTest([](uint64_t) {}); + const RootNamespace ns{"00/aa@cas@"}; + const ManifestRef r = ref("srv-a:1", 1, 0xAA); + const UInt128 blob = DB::UInt128(1); + writeBlobBody(*backend, store->layout(), blob); + writeManifestRaw(*backend, store->layout(), ns, r, {blobEntryFor("a", blob)}); + publishCommittedTransition(*backend, store->layout(), ns, "tbl", std::nullopt, r); + Gc gc(store, kGc); + + runRegularRoundReclaiming(gc); // +1 folds; blob referenced + dropRefTransition(*backend, store->layout(), ns, "tbl", r); + runRegularRoundReclaiming(gc); // the condemning round; the controlled marker write exhausts as Unresolved + ASSERT_FALSE(loadMetaForTest(*backend, store->layout(), blob).has_value()) + << "precondition: the injected fault must have lost the condemn-marker write"; + ASSERT_TRUE(currentEntryFor(*backend, store->layout(), blob).has_value()) + << "precondition: the retired entry must have been committed despite the lost marker"; + + /// Rounds keep coming while the marker stays unwritable: without durable Condemned evidence the + /// entry must be CARRIED — a writer reading the absent meta may have adopted this exact token. + const auto carries_before = + ProfileEvents::global_counters[ProfileEvents::CASGCCondemnMarkerUnconfirmedCarry].load(); + for (int i = 0; i < 4; ++i) + { + runRegularRoundReclaiming(gc); + store->renewWatermarkOnce(); + EXPECT_TRUE(blobExists(*backend, store->layout(), blob)) + << "round " << i << " after condemn: deleted without a durable condemn marker"; + } + const auto e = currentEntryFor(*backend, store->layout(), blob); + ASSERT_TRUE(e.has_value()) << "the entry must remain retired (carried), not dropped"; + EXPECT_FALSE(e->delete_pending) << "graduation must be refused without a confirmed marker"; + EXPECT_FALSE(e->marker_confirmed); + EXPECT_GE(ProfileEvents::global_counters[ProfileEvents::CASGCCondemnMarkerUnconfirmedCarry].load() + - carries_before, 4u) + << "every refused graduation must count one unconfirmed carry"; + + /// Heal the backend: the carry-time retry publishes the marker, the entry confirms + graduates, and + /// the pipeline reclaims the blob and drops the meta. + backend->fail_meta_writes.store(false); + EXPECT_TRUE(runRoundsUntilAbsent(store, gc, *backend, store->layout(), blob)); + EXPECT_FALSE(currentEntryFor(*backend, store->layout(), blob).has_value()); + EXPECT_FALSE(loadMetaForTest(*backend, store->layout(), blob).has_value()); +} + +/// The healthy-path counterpart: with the condemn-time marker write landing normally, the gate must not +/// change the canonical schedule — condemned at round K, graduated (delete_pending) at K+1, deleted at +/// K+2 — and the durable Condemned marker exists from the condemning round on. +TEST(CASGCCondemnMarker, DurableMarkerKeepsCanonicalGraduationSchedule) +{ + auto backend = std::make_shared(); + auto store = openPoolForTest(backend); + const RootNamespace ns{"00/aa@cas@"}; + const ManifestRef r = ref("srv-a:1", 1, 0xAA); + const UInt128 blob = DB::UInt128(1); + writeBlobBody(*backend, store->layout(), blob); + writeManifestRaw(*backend, store->layout(), ns, r, {blobEntryFor("a", blob)}); + publishCommittedTransition(*backend, store->layout(), ns, "tbl", std::nullopt, r); + Gc gc(store, kGc); + + runRegularRoundReclaiming(gc); + dropRefTransition(*backend, store->layout(), ns, "tbl", r); + + { + const RoundReport rep = runRegularRoundReclaiming(gc); // condemn round K + EXPECT_EQ(rep.condemned, 1u); + const auto lm = loadMetaForTest(*backend, store->layout(), blob); + ASSERT_TRUE(lm.has_value()); + EXPECT_EQ(lm->meta.state, MetaState::Condemned); + } + { + const RoundReport rep = runRegularRoundReclaiming(gc); // K+1: confirmed marker => graduates on schedule + EXPECT_EQ(rep.graduated, 1u); + const auto e = currentEntryFor(*backend, store->layout(), blob); + ASSERT_TRUE(e.has_value()); + EXPECT_TRUE(e->delete_pending); + EXPECT_TRUE(e->marker_confirmed) << "a delete_pending row must carry the confirmation bit"; + EXPECT_TRUE(blobExists(*backend, store->layout(), blob)); + } + { + const RoundReport rep = runRegularRoundReclaiming(gc); // K+2: the pending delete executes + EXPECT_EQ(rep.redeleted, 1u); + EXPECT_FALSE(blobExists(*backend, store->layout(), blob)); + } +} + +/// The leader-restart path: `condemn_markers_confirmed` is a process-local registry on `Gc`, lost on a +/// GC leader restart. Same idiom as the crash-replay tests above (`Gc gc2(store, kGc)` -- a fresh `Gc` +/// object under the SAME identity models a process restart that resumes its own lease, not a steal by a +/// different owner). The fresh instance must still confirm graduation via the ONE synchronous `loadMeta` +/// re-check: the durable `Condemned` meta observed now is sufficient evidence on its own, with no +/// in-process (hash, token) confirmation available at all. This proves the fallback branch -- not just +/// the in-process registry -- authorizes the delete. +TEST(CASGCCondemnMarker, LoadMetaFallbackConfirmsGraduationAfterLeaderRestart) +{ + auto backend = std::make_shared(); + auto store = openPoolForTest(backend); + const RootNamespace ns{"00/aa@cas@"}; + const ManifestRef r = ref("srv-a:1", 1, 0xAA); + const UInt128 blob = DB::UInt128(1); + writeBlobBody(*backend, store->layout(), blob); + writeManifestRaw(*backend, store->layout(), ns, r, {blobEntryFor("a", blob)}); + publishCommittedTransition(*backend, store->layout(), ns, "tbl", std::nullopt, r); + + { + /// The first (soon-to-be-gone) leader: seeds the blob, condemns it, and lets the marker write + /// land on the healthy backend. Its `condemn_markers_confirmed` registry dies with it. + Gc gc(store, kGc); + runRegularRoundReclaiming(gc); + dropRefTransition(*backend, store->layout(), ns, "tbl", r); + const RoundReport rep = runRegularRoundReclaiming(gc); // condemn round + EXPECT_EQ(rep.condemned, 1u); + store->renewWatermarkOnce(); + } + const auto lm = loadMetaForTest(*backend, store->layout(), blob); + ASSERT_TRUE(lm.has_value()); + EXPECT_EQ(lm->meta.state, MetaState::Condemned) + << "precondition: the durable marker must be on disk before the simulated restart"; + + /// A brand-new `Gc` object under the SAME identity -- an empty `condemn_markers_confirmed`, exactly + /// as after a process restart that resumes its own lease. It never observed the condemn round above, + /// so the in-process confirmation path (`condemnMarkerConfirmedInProcess`) has nothing to return true + /// for; only the `loadMeta` fallback can authorize graduation. + Gc gc2(store, kGc); + const RoundReport rep = runRegularRoundReclaiming(gc2); + EXPECT_EQ(rep.graduated, 1u) + << "the loadMeta fallback (leader-restart path) must authorize graduation from durable evidence " + "alone"; + const auto e = currentEntryFor(*backend, store->layout(), blob); + ASSERT_TRUE(e.has_value()); + EXPECT_TRUE(e->delete_pending); + EXPECT_TRUE(e->marker_confirmed) << "a delete_pending row confirmed via loadMeta still carries the bit"; + EXPECT_TRUE(blobExists(*backend, store->layout(), blob)); +} diff --git a/src/Disks/tests/gtest_cas_gc_arithmetic_intake.cpp b/src/Disks/tests/gtest_cas_gc_arithmetic_intake.cpp new file mode 100644 index 000000000000..870bcdc20295 --- /dev/null +++ b/src/Disks/tests/gtest_cas_gc_arithmetic_intake.cpp @@ -0,0 +1,595 @@ +#include + +#include +#include +#include +#include +#include +#include "cas_test_helpers.h" + +#include +#include + +/// ARITHMETIC REF INTAKE (spec 2026-07-27 "ref chain complete cut" §5). +/// +/// The GC fold used to walk the ids the round's LIST returned. That made the LIST a source of TRUTH +/// about which records exist, and an object store that omits a durable key from a listing -- observed +/// in production as the `0x1430c`/`0x1430d` shape -- silently skipped those records' owner edges and +/// then sealed a cursor ABOVE them, so their blobs looked unreferenced forever after. +/// +/// Under INV-1 (per-namespace contiguous ids) the ids within one `(namespace, writer_epoch)` are dense +/// `1..T`, so the next record's id is COMPUTABLE: `cursor + 1`. The fold therefore steps by arithmetic +/// and reads each expected id by EXACT key (the per-record GET was always owed -- the round read every +/// record's body anyway). The listing is demoted to a HINT with two jobs: it says which namespaces +/// exist, and it supplies the witnesses that make an absent expected-next decidable: +/// +/// * absent at `expected`, no listed id above it => the namespace's frontier this round (normal end) +/// * absent at `expected`, a listed id above it => IMPOSSIBLE under contiguity: the store is lying +/// or a durable record was lost. Hold the namespace +/// (classification 4), cursor unmoved. +/// +/// Epochs are crossed ONLY by consuming the `EpochSeal` that closes an epoch (INV-2). The seal folds as +/// an applied no-op (probe B2: `produced=false`), and the next epoch's start is `{E', 1}` -- reached +/// through the `prev_epoch_seal` back-chain, never guessed from the hint, so an epoch the hint omits +/// entirely is still walked. +/// +/// These tests drive REAL rounds over an in-memory pool whose LIST omits keys that are genuinely +/// present (`HintHoleBackend`) -- the production shape, reproduced. + +using namespace DB::Cas; +using namespace DB::Cas::tests; + +namespace +{ + +const UInt128 kGc = hexToU128("00000000000000000000000000000001"); + +/// The lying store is `HintHoleBackend` from `cas_test_helpers.h`: LIST permanently omits keys that +/// stay readable by exact key, so these tests exercise the intake's arithmetic walk rather than any +/// one `list` call. + +/// The `RefCoverage` the newest fold seal recorded for `ns`'s opaque catalog life, or nullopt when the +/// seal has no entry for it. Scans downward from the adopted generation for the most recent fold seal, +/// mirroring `foldCursorOf`'s reasoning (a completed round's `gc/state` points at the recheck generation). +std::optional coverageOf(Backend & backend, const Layout & layout, const RootNamespace & ns) +{ + const uint64_t gen = currentGenerationOf(backend, layout); + const uint64_t attempt = currentAttemptOf(backend, layout); + const UInt128 life_id = catalogLifeIdForTest(backend, layout, ns); + for (uint64_t g = gen; ; --g) + { + if (const auto got = backend.get(layout.foldSealKey(g, attempt))) + { + const CasFoldSeal seal = decodeFoldSeal(got->bytes); + const auto it = seal.ref_lives.find(life_id); + if (it == seal.ref_lives.end()) + return std::nullopt; + return it->second.coverage; + } + if (g == 0) + return std::nullopt; + } +} + +RefTxnId cursorOf(Backend & backend, const Layout & layout, const RootNamespace & ns) +{ + const auto cov = coverageOf(backend, layout, ns); + return cov ? cov->last_folded_ref_id : RefTxnId{}; +} + +uint8_t classificationOf(Backend & backend, const Layout & layout, const RootNamespace & ns) +{ + const auto cov = coverageOf(backend, layout, ns); + return cov ? cov->classification : 0; +} + +/// The `fold_ref_intake` phase metrics of the round `sched` runs -- the only place probe B1's two +/// numbers are observable. +std::map runRoundAndReadIntakeMetrics(const PoolPtr & store) +{ + std::vector rows; + CasGcScheduler sched(store, std::chrono::seconds(1), "test::gc", "ca", + [&](const GcRoundLogRecord & r) { rows.push_back(r); }); + EXPECT_TRUE(sched.runOneRoundNow(GcRoundLogRecord::Trigger::Manual).acquired_lease); + for (const GcRoundLogRecord & r : rows) + if (r.event_type == GcRoundLogRecord::EventType::Phase && r.phase == "fold_ref_intake") + return r.phase_metrics; + return {}; +} + +} + +/// ===================== THE BLOCKER, AS A UNIT TEST ===================== +/// +/// Five records, all durable and all readable by exact key; the hint omits the two in the MIDDLE. +/// Arithmetic intake never consults the hint for what to read next, so the omission is a non-event: +/// every record folds, every blob keeps its owner edge, and the cursor lands on the true tail. +/// +/// Under listing-driven intake this test fails on the blobs, not on the cursor: the cursor still +/// reached `{1, 5}` (the last LISTED id) while the two hidden records' edges were never folded -- the +/// exact damage shape the production blocker caused, since a cursor sealed above an unfolded record can +/// never be re-read. +TEST(CASGCArithmeticIntake, HintOmittingMiddleRecordsFoldsThroughUnnoticed) +{ + auto backend = std::make_shared(); + auto store = openPoolForTest(backend); + const Layout & layout = store->layout(); + const RootNamespace ns{"00/aa@cas@"}; + fixture::admitLive(*backend, store->layout(), ns); /// Stage B (Task 4-C): pin to the sentinel before the first real touch + + for (uint64_t i = 1; i <= 5; ++i) + publishAt(*backend, layout, ns, RefTxnId{1, i}, "ref_" + std::to_string(i), i, + DB::UInt128(i), /*birth=*/i == 1); + writeRecoverableCkptForRawFixture(*backend, layout, ns, RefCkpt{ + .life_epoch = 1, + .committed_through = RefTxnId{1, 5}, + .checkpoint_snapshot_id = std::nullopt, + .last_epoch_seal = std::nullopt, + }); + + backend->hide(layout.refLogKey(fixture::fixtureLife(ns), RefTxnId{1, 3})); + backend->hide(layout.refLogKey(fixture::fixtureLife(ns), RefTxnId{1, 4})); + + Gc gc(store, kGc); + ASSERT_TRUE(gc.runRegularRound().acquired_lease); + ASSERT_GT(backend->holesServed(), 0u) << "the hint hole was never actually served"; + + EXPECT_EQ(cursorOf(*backend, layout, ns), (RefTxnId{1, 5})); + EXPECT_EQ(classificationOf(*backend, layout, ns), 2) << "a folded namespace is `changed`"; + for (uint64_t i = 1; i <= 5; ++i) + EXPECT_EQ(inDegreeOf(*backend, layout, DB::UInt128(i)), 1) + << "blob " << i << " lost its owner edge: its record was skipped because the hint omitted it"; +} + +/// A clean, hole-free namespace still ends its walk exactly where it should: at the first absent id, +/// with no witness above it and therefore no hold. +TEST(CASGCArithmeticIntake, WalkEndsAtFrontierWithoutHold) +{ + auto backend = std::make_shared(); + /// Fold every round: the default defer window would skip the second round entirely and leave the + /// first round's seal in place, so the assertion below would read a stale coverage record. + auto store = openPoolForTest(backend, /*gc_fold_max_defer_rounds=*/0); + const Layout & layout = store->layout(); + const RootNamespace ns{"00/aa@cas@"}; + + for (uint64_t i = 1; i <= 3; ++i) + publishAt(*backend, layout, ns, RefTxnId{1, i}, "ref_" + std::to_string(i), i, + DB::UInt128(i), /*birth=*/i == 1); + writeRecoverableCkptForRawFixture(*backend, layout, ns, RefCkpt{ + .life_epoch = 1, + .committed_through = RefTxnId{1, 3}, + .checkpoint_snapshot_id = std::nullopt, + .last_epoch_seal = std::nullopt, + }); + + Gc gc(store, kGc); + ASSERT_TRUE(gc.runRegularRound().acquired_lease); + + EXPECT_EQ(cursorOf(*backend, layout, ns), (RefTxnId{1, 3})); + EXPECT_EQ(classificationOf(*backend, layout, ns), 2); + + /// A second round over an unchanged namespace pays exactly one exact GET, finds the same frontier, + /// and neither advances nor holds. + ASSERT_TRUE(gc.runRegularRound().acquired_lease); + EXPECT_EQ(cursorOf(*backend, layout, ns), (RefTxnId{1, 3})); + EXPECT_EQ(classificationOf(*backend, layout, ns), 1) << "an unchanged namespace is `carried`"; +} + +/// ===================== EPOCHS ARE CROSSED ONLY BY CONSUMING A SEAL ===================== +/// +/// `{1,1} {1,2} seal{1,3} | {2,1} {2,2}`: the seal is applied as a table no-op, counted applied, and +/// the walk continues at `{2, 1}` -- whose `prev_epoch_seal` names the seal just consumed, which is what +/// makes the crossing provable rather than guessed. +TEST(CASGCArithmeticIntake, SealCrossesEpochAndIsAppliedAsNoOp) +{ + auto backend = std::make_shared(); + auto store = openPoolForTest(backend); + const Layout & layout = store->layout(); + const RootNamespace ns{"00/aa@cas@"}; + fixture::admitLive(*backend, store->layout(), ns); /// Stage B (Task 4-C): pin to the sentinel before the first real touch + + publishAt(*backend, layout, ns, RefTxnId{1, 1}, "ref_1", 1, DB::UInt128(1), /*birth=*/true); + publishAt(*backend, layout, ns, RefTxnId{1, 2}, "ref_2", 2, DB::UInt128(2)); + writeSealAt(*backend, layout, ns, RefTxnId{1, 3}); + publishAt(*backend, layout, ns, RefTxnId{2, 1}, "ref_3", 3, DB::UInt128(3), + /*birth=*/false, /*prev_epoch_seal=*/RefTxnId{1, 3}); + publishAt(*backend, layout, ns, RefTxnId{2, 2}, "ref_4", 4, DB::UInt128(4)); + writeRecoverableCkptForRawFixture(*backend, layout, ns, RefCkpt{ + .life_epoch = 1, + .committed_through = RefTxnId{2, 2}, + .checkpoint_snapshot_id = std::nullopt, + .last_epoch_seal = RefTxnId{1, 3}, + }); + + /// The hint hides the seal AND the new epoch's first record: neither the epoch boundary nor its + /// start may depend on the listing. + backend->hide(layout.refLogKey(fixture::fixtureLife(ns), RefTxnId{1, 3})); + backend->hide(layout.refLogKey(fixture::fixtureLife(ns), RefTxnId{2, 1})); + + Gc gc(store, kGc); + ASSERT_TRUE(gc.runRegularRound().acquired_lease); + ASSERT_GT(backend->holesServed(), 0u); + + EXPECT_EQ(cursorOf(*backend, layout, ns), (RefTxnId{2, 2})); + EXPECT_EQ(classificationOf(*backend, layout, ns), 2); + for (uint64_t i = 1; i <= 4; ++i) + EXPECT_EQ(inDegreeOf(*backend, layout, DB::UInt128(i)), 1) << "blob " << i; +} + +/// Two chained seals in ONE round, the middle epoch entirely EMPTY (its seal is its only record, at +/// sequence 1, carrying `prev_epoch_seal`). The hint omits that whole epoch, so the only way to reach it +/// is the back-chain: the record the hint DOES show names the seal that must be consumed first. +TEST(CASGCArithmeticIntake, ChainedEmptyEpochSealsBothConsumedInOneRound) +{ + auto backend = std::make_shared(); + auto store = openPoolForTest(backend, /*gc_fold_max_defer_rounds=*/0); + const Layout & layout = store->layout(); + const RootNamespace ns{"00/aa@cas@"}; + fixture::admitLive(*backend, store->layout(), ns); /// Stage B (Task 4-C): pin to the sentinel before the first real touch + + publishAt(*backend, layout, ns, RefTxnId{1, 1}, "ref_1", 1, DB::UInt128(1), /*birth=*/true); + writeSealAt(*backend, layout, ns, RefTxnId{1, 2}); + writeSealAt(*backend, layout, ns, RefTxnId{2, 1}, /*prev_epoch_seal=*/RefTxnId{1, 2}); + publishAt(*backend, layout, ns, RefTxnId{3, 1}, "ref_2", 2, DB::UInt128(2), + /*birth=*/false, /*prev_epoch_seal=*/RefTxnId{2, 1}); + writeRecoverableCkptForRawFixture(*backend, layout, ns, RefCkpt{ + .life_epoch = 1, + .committed_through = RefTxnId{3, 1}, + .checkpoint_snapshot_id = std::nullopt, + .last_epoch_seal = RefTxnId{2, 1}, + }); + + backend->hide(layout.refLogKey(fixture::fixtureLife(ns), RefTxnId{2, 1})); + + const auto intake = runRoundAndReadIntakeMetrics(store); + ASSERT_FALSE(intake.empty()) << "no fold_ref_intake row"; + ASSERT_GT(backend->holesServed(), 0u); + + EXPECT_EQ(cursorOf(*backend, layout, ns), (RefTxnId{3, 1})); + EXPECT_EQ(inDegreeOf(*backend, layout, DB::UInt128(1)), 1); + EXPECT_EQ(inDegreeOf(*backend, layout, DB::UInt128(2)), 1); + + /// THE ASSERTION THAT MAKES THIS TEST ABOUT THE BACK-CHAIN. Every check above is also satisfied by + /// an id-ordered walk over the listed keys — which is why this case passed before the change. Two + /// crossings can only happen by consuming `seal{1,2}` and then `seal{2,1}`, and `{2,1}` is reachable + /// only through `{3,1}`'s `prev_epoch_seal`, since the hint never mentions it. + EXPECT_EQ(intake.at("epoch_crossings"), 2u) + << "the walk must cross TWICE, through a hidden epoch it can only reach by the seal chain"; + EXPECT_EQ(intake.at("logs_accounted"), intake.at("logs_applied")); + EXPECT_EQ(intake.at("logs_applied"), 4u) << "two records and two seals, all applied"; + /// The checkpoint names the complete, authoritative frontier, so the bounded walk has no absent + /// probes to perform. The hidden epoch remains reachable only through the seal chain. + EXPECT_EQ(intake.at("absent_probes"), 0u); +} + +/// A round that ends ON a seal (nothing above it yet) leaves the cursor there. The NEXT round must +/// still cross into the epoch that appears later -- the cursor sitting on a closed epoch's seal is the +/// ordinary steady state after a writer-epoch change, not a wedge. +TEST(CASGCArithmeticIntake, CursorRestingOnSealCrossesInALaterRound) +{ + auto backend = std::make_shared(); + auto store = openPoolForTest(backend, /*gc_fold_max_defer_rounds=*/0); + const Layout & layout = store->layout(); + const RootNamespace ns{"00/aa@cas@"}; + + publishAt(*backend, layout, ns, RefTxnId{1, 1}, "ref_1", 1, DB::UInt128(1), /*birth=*/true); + writeSealAt(*backend, layout, ns, RefTxnId{1, 2}); + writeRecoverableCkptForRawFixture(*backend, layout, ns, RefCkpt{ + .life_epoch = 1, + .committed_through = RefTxnId{1, 2}, + .checkpoint_snapshot_id = std::nullopt, + .last_epoch_seal = RefTxnId{1, 2}, + }); + + Gc gc(store, kGc); + ASSERT_TRUE(gc.runRegularRound().acquired_lease); + ASSERT_EQ(cursorOf(*backend, layout, ns), (RefTxnId{1, 2})) << "the round consumed the seal"; + + publishAt(*backend, layout, ns, RefTxnId{2, 1}, "ref_2", 2, DB::UInt128(2), + /*birth=*/false, /*prev_epoch_seal=*/RefTxnId{1, 2}); + advanceRecoverableCkptForRawFixture(*backend, layout, ns, RefTxnId{2, 1}); + + ASSERT_TRUE(gc.runRegularRound().acquired_lease); + EXPECT_EQ(cursorOf(*backend, layout, ns), (RefTxnId{2, 1})); + EXPECT_EQ(classificationOf(*backend, layout, ns), 2); + EXPECT_EQ(inDegreeOf(*backend, layout, DB::UInt128(2)), 1); +} + +/// ===================== IMPOSSIBLE SHAPES HOLD THE NAMESPACE ===================== +/// +/// `{1,3}` is genuinely absent while `{1,4}` is present AND listed. Contiguity says that cannot happen, +/// so whatever sits behind the gap may be an acked `+1`: the namespace is held at classification 4 with +/// its cursor UNMOVED, rather than sealing past the gap. +/// +/// Listing-driven intake folded `{1,4}` and sealed the cursor at it -- permanently, since a record below +/// the cursor is never re-read. +TEST(CASGCArithmeticIntake, GapBelowWitnessHoldsNamespaceAtClassificationFour) +{ + auto backend = std::make_shared(); + auto store = openPoolForTest(backend); + const Layout & layout = store->layout(); + const RootNamespace ns{"00/aa@cas@"}; + + publishAt(*backend, layout, ns, RefTxnId{1, 1}, "ref_1", 1, DB::UInt128(1), /*birth=*/true); + publishAt(*backend, layout, ns, RefTxnId{1, 2}, "ref_2", 2, DB::UInt128(2)); + /// {1,3} is never written -- the record that vanished. + publishAt(*backend, layout, ns, RefTxnId{1, 4}, "ref_4", 4, DB::UInt128(4)); + writeRecoverableCkptForRawFixture(*backend, layout, ns, RefCkpt{ + .life_epoch = 1, + .committed_through = RefTxnId{1, 4}, + .checkpoint_snapshot_id = std::nullopt, + .last_epoch_seal = std::nullopt, + }); + + Gc gc(store, kGc); + ASSERT_TRUE(gc.runRegularRound().acquired_lease); + + EXPECT_EQ(cursorOf(*backend, layout, ns), (RefTxnId{1, 2})) + << "the cursor must not advance past a gap"; + EXPECT_EQ(classificationOf(*backend, layout, ns), 4); + EXPECT_EQ(inDegreeOf(*backend, layout, DB::UInt128(4)), 0) + << "the record above the gap was not folded"; +} + +/// A record of a LATER epoch reachable while the current epoch's seal was never consumed: the crossing +/// has no proof (the later epoch's `prev_epoch_seal` names a seal this cursor never reached), so the +/// namespace holds instead of jumping the boundary. +TEST(CASGCArithmeticIntake, UnconsumedSealCrossingHoldsNamespace) +{ + auto backend = std::make_shared(); + auto store = openPoolForTest(backend); + const Layout & layout = store->layout(); + const RootNamespace ns{"00/aa@cas@"}; + + publishAt(*backend, layout, ns, RefTxnId{1, 1}, "ref_1", 1, DB::UInt128(1), /*birth=*/true); + /// Epoch 1's missing `{1,2}` seal is the exact position epoch 2 claims to chain from. With no + /// same-epoch witness above it, the later epoch is the only witness and the crossing must hold. + publishAt(*backend, layout, ns, RefTxnId{2, 1}, "ref_2", 2, DB::UInt128(2), + /*birth=*/false, /*prev_epoch_seal=*/RefTxnId{1, 2}); + writeRecoverableCkptForRawFixture(*backend, layout, ns, RefCkpt{ + .life_epoch = 1, + .committed_through = RefTxnId{2, 1}, + .checkpoint_snapshot_id = std::nullopt, + .last_epoch_seal = RefTxnId{1, 2}, + }); + + Gc gc(store, kGc); + ASSERT_TRUE(gc.runRegularRound().acquired_lease); + + EXPECT_EQ(cursorOf(*backend, layout, ns), (RefTxnId{1, 1})); + EXPECT_EQ(classificationOf(*backend, layout, ns), 4); + const auto coverage = coverageOf(*backend, layout, ns); + ASSERT_TRUE(coverage && coverage->hold.has_value()); + EXPECT_EQ(coverage->hold->reason, HoldReason::UnconsumedSealCrossing); + EXPECT_EQ(coverage->hold->offending_position, (RefTxnId{1, 2})); + EXPECT_EQ(inDegreeOf(*backend, layout, DB::UInt128(2)), 0); +} + +/// The back-chain proves the IDENTITY of the position an epoch chains from; it does not, by itself, +/// prove that position is a SEAL. Here a writer names an ordinary record (`{1,2}`, epoch 1's last +/// record, never sealed) as `{2,1}`'s `prev_epoch_seal`. Identity matches, so a chain-only check would +/// grant the crossing and declare epoch 1 closed while its writer may still be appending -- any later +/// `{1,k}` would then land permanently below the cursor, which is exactly the damage the seal exists to +/// prevent. The walk applied that record itself this round, so it knows its kind for free: refuse. +TEST(CASGCArithmeticIntake, CrossingFromANonSealRecordIsRefusedEvenWhenTheChainMatches) +{ + auto backend = std::make_shared(); + auto store = openPoolForTest(backend, /*gc_fold_max_defer_rounds=*/0); + const Layout & layout = store->layout(); + const RootNamespace ns{"00/aa@cas@"}; + + publishAt(*backend, layout, ns, RefTxnId{1, 1}, "ref_1", 1, DB::UInt128(1), /*birth=*/true); + publishAt(*backend, layout, ns, RefTxnId{1, 2}, "ref_2", 2, DB::UInt128(2)); + /// `{1,2}` is an ordinary published record, NOT an `EpochSeal` -- and epoch 2 chains to it anyway. + publishAt(*backend, layout, ns, RefTxnId{2, 1}, "ref_3", 3, DB::UInt128(3), + /*birth=*/false, /*prev_epoch_seal=*/RefTxnId{1, 2}); + writeRecoverableCkptForRawFixture(*backend, layout, ns, RefCkpt{ + .life_epoch = 1, + .committed_through = RefTxnId{2, 1}, + .checkpoint_snapshot_id = std::nullopt, + .last_epoch_seal = RefTxnId{1, 2}, + }); + + Gc gc(store, kGc); + ASSERT_TRUE(gc.runRegularRound().acquired_lease); + + EXPECT_EQ(cursorOf(*backend, layout, ns), (RefTxnId{1, 2})) + << "epoch 1 was never sealed, so the cursor may not leave it"; + EXPECT_EQ(classificationOf(*backend, layout, ns), 4); + EXPECT_EQ(inDegreeOf(*backend, layout, DB::UInt128(1)), 1) << "epoch 1's records still fold"; + EXPECT_EQ(inDegreeOf(*backend, layout, DB::UInt128(2)), 1); + EXPECT_EQ(inDegreeOf(*backend, layout, DB::UInt128(3)), 0) + << "the record beyond the unsealed boundary must NOT be folded"; +} + +/// The crossing reads the epoch-start record to prove the chain, and the walk then reads it again to +/// fold it. A record that answers the first read and not the second would make the next iteration +/// re-derive the SAME crossing from the same unchanged cursor and resolve to the same position -- an +/// infinite spin inside one namespace's walk. The strict-progress guard turns that into a hold. +/// +/// The fixture is the only shape that reaches it: a key that alternates present/absent across reads, so +/// `crossFromSeal` keeps succeeding while the walk's own GET keeps failing. +TEST(CASGCArithmeticIntake, EpochStartThatAnswersOnlyEveryOtherReadHoldsInsteadOfSpinning) +{ + /// Answers `flaky` on odd-numbered reads and 404s on even ones. Nothing else is disturbed. + class AlternatingGetBackend : public InMemoryBackend + { + public: + using DB::Cas::Backend::get; + String flaky; + size_t reads = 0; + + std::optional get(const String & key, Range range) override + { + if (key == flaky && ++reads % 2 == 0) + return std::nullopt; + return InMemoryBackend::get(key, range); + } + }; + + auto backend = std::make_shared(); + auto store = openPoolForTest(backend, /*gc_fold_max_defer_rounds=*/0); + const Layout & layout = store->layout(); + const RootNamespace ns{"00/aa@cas@"}; + fixture::admitLive(*backend, store->layout(), ns); /// Stage B (Task 4-C): pin to the sentinel before the first real touch + + publishAt(*backend, layout, ns, RefTxnId{1, 1}, "ref_1", 1, DB::UInt128(1), /*birth=*/true); + writeSealAt(*backend, layout, ns, RefTxnId{1, 2}); + publishAt(*backend, layout, ns, RefTxnId{2, 1}, "ref_2", 2, DB::UInt128(2), + /*birth=*/false, /*prev_epoch_seal=*/RefTxnId{1, 2}); + /// A THIRD epoch is what keeps the unstable position from reading as a frontier: without a witness + /// strictly above it, an absent `{2,1}` is just "the namespace ends here" and the walk stops + /// normally. With `{3,1}` listed, the walk must keep trying to cross -- and the chain from `{3,1}` + /// leads back to `{2,1}` every time, which is the spin. + publishAt(*backend, layout, ns, RefTxnId{3, 1}, "ref_3", 3, DB::UInt128(3), + /*birth=*/false, /*prev_epoch_seal=*/RefTxnId{2, 1}); + writeRecoverableCkptForRawFixture(*backend, layout, ns, RefCkpt{ + .life_epoch = 1, + .committed_through = RefTxnId{3, 1}, + .checkpoint_snapshot_id = std::nullopt, + .last_epoch_seal = RefTxnId{2, 1}, + }); + + /// Arm only after seeding, so the fixture's own writes are undisturbed and the read counter starts + /// at the round's first read of this key (`crossFromSeal`'s, which must succeed). + backend->flaky = layout.refLogKey(fixture::fixtureLife(ns), RefTxnId{2, 1}); + + Gc gc(store, kGc); + ASSERT_TRUE(gc.runRegularRound().acquired_lease); /// must RETURN -- the spin is the failure mode + ASSERT_GE(backend->reads, 3u) + << "the crossing must have re-proved the same position after the walk failed to read it; " + "fewer reads means the guard was never reached"; + + EXPECT_EQ(cursorOf(*backend, layout, ns), (RefTxnId{1, 2})) + << "the cursor stops on the seal it consumed and never enters the unstable epoch"; + EXPECT_EQ(classificationOf(*backend, layout, ns), 4); + EXPECT_EQ(inDegreeOf(*backend, layout, DB::UInt128(2)), 0); + EXPECT_EQ(inDegreeOf(*backend, layout, DB::UInt128(3)), 0) + << "nothing above the unstable position may be folded either"; +} + +/// ===================== A PER-NAMESPACE FAILURE IS NOT A ROUND FAILURE ===================== +/// +/// Spec §5 narrows the whole-round abort to a key that cannot be attributed to any namespace. An +/// undecodable BODY belongs to exactly one namespace, so it clamps that namespace and nothing else: +/// `ns_a` holds at its last good record while `ns_b` folds and seals normally in the same round. +TEST(CASGCArithmeticIntake, CorruptBodyClampsOneNamespaceWhileAnotherFolds) +{ + auto backend = std::make_shared(); + auto store = openPoolForTest(backend); + const Layout & layout = store->layout(); + const RootNamespace ns_a{"00/aa@cas@"}; + fixture::admitLive(*backend, store->layout(), ns_a); /// Stage B (Task 4-C): pin to the sentinel before the first real touch + const RootNamespace ns_b{"00/bb@cas@"}; + + publishAt(*backend, layout, ns_a, RefTxnId{1, 1}, "ref_1", 1, DB::UInt128(1), /*birth=*/true); + backend->putIfAbsent(layout.refLogKey(fixture::fixtureLife(ns_a), RefTxnId{1, 2}), "this is not a cas_ref_log object"); + writeRecoverableCkptForRawFixture(*backend, layout, ns_a, RefCkpt{ + .life_epoch = 1, + .committed_through = RefTxnId{1, 2}, + .checkpoint_snapshot_id = std::nullopt, + .last_epoch_seal = std::nullopt, + }); + + publishAt(*backend, layout, ns_b, RefTxnId{1, 1}, "ref_1", 11, DB::UInt128(11), /*birth=*/true); + publishAt(*backend, layout, ns_b, RefTxnId{1, 2}, "ref_2", 12, DB::UInt128(12)); + writeSealAt(*backend, layout, ns_b, RefTxnId{1, 3}); + writeRecoverableCkptForRawFixture(*backend, layout, ns_b, RefCkpt{ + .life_epoch = 1, + .committed_through = RefTxnId{1, 3}, + .checkpoint_snapshot_id = std::nullopt, + .last_epoch_seal = RefTxnId{1, 3}, + }); + + Gc gc(store, kGc); + ASSERT_TRUE(gc.runRegularRound().acquired_lease); + + EXPECT_EQ(cursorOf(*backend, layout, ns_a), (RefTxnId{1, 1})); + EXPECT_EQ(classificationOf(*backend, layout, ns_a), 4); + + EXPECT_EQ(cursorOf(*backend, layout, ns_b), (RefTxnId{1, 3})) + << "a sibling namespace's corrupt body must not stop this one"; + EXPECT_EQ(classificationOf(*backend, layout, ns_b), 2); + EXPECT_EQ(inDegreeOf(*backend, layout, DB::UInt128(11)), 1); + EXPECT_EQ(inDegreeOf(*backend, layout, DB::UInt128(12)), 1); +} + +/// ===================== PROBE B1 OVER AN ARITHMETIC CUT ===================== +/// +/// `logs_accounted` is recomputed from the SEALED cut -- the arithmetic distance the round's cursors +/// claim to cover -- and compared with the count the walk incremented once per applied record. The two +/// can only differ if a cursor moved over a position nothing applied, which is precisely the damage +/// listing-driven intake used to do silently. The identity must survive both a hint hole (positions +/// applied that the listing never mentioned) and a seal crossing (an applied no-op, and a cut that +/// spans two epochs). +TEST(CASGCArithmeticIntake, B1IdentityHoldsOverAHoleyCutThatCrossesASeal) +{ + auto backend = std::make_shared(); + auto store = openPoolForTest(backend, /*gc_fold_max_defer_rounds=*/0); + const Layout & layout = store->layout(); + const RootNamespace ns{"00/aa@cas@"}; + fixture::admitLive(*backend, store->layout(), ns); /// Stage B (Task 4-C): pin to the sentinel before the first real touch + + publishAt(*backend, layout, ns, RefTxnId{1, 1}, "ref_1", 1, DB::UInt128(1), /*birth=*/true); + publishAt(*backend, layout, ns, RefTxnId{1, 2}, "ref_2", 2, DB::UInt128(2)); + writeSealAt(*backend, layout, ns, RefTxnId{1, 3}); + publishAt(*backend, layout, ns, RefTxnId{2, 1}, "ref_3", 3, DB::UInt128(3), + /*birth=*/false, /*prev_epoch_seal=*/RefTxnId{1, 3}); + publishAt(*backend, layout, ns, RefTxnId{2, 2}, "ref_4", 4, DB::UInt128(4)); + writeRecoverableCkptForRawFixture(*backend, layout, ns, RefCkpt{ + .life_epoch = 1, + .committed_through = RefTxnId{2, 2}, + .checkpoint_snapshot_id = std::nullopt, + .last_epoch_seal = RefTxnId{1, 3}, + }); + + backend->hide(layout.refLogKey(fixture::fixtureLife(ns), RefTxnId{1, 2})); + + const auto intake = runRoundAndReadIntakeMetrics(store); + ASSERT_FALSE(intake.empty()) << "no fold_ref_intake row"; + ASSERT_GT(backend->holesServed(), 0u); + + EXPECT_EQ(intake.at("logs_accounted"), intake.at("logs_applied")); + EXPECT_EQ(intake.at("logs_applied"), 5u) + << "four records and the seal: the seal is APPLIED, as a no-op"; + EXPECT_EQ(cursorOf(*backend, layout, ns), (RefTxnId{2, 2})); +} + +/// A namespace the hint omits ENTIRELY still folds through the checkpoint's authoritative frontier. +/// Every log key remains readable by exact key, and the cursor reaches `{1,3}` despite the empty hint. +/// When the hint reappears, it changes neither the cursor nor the owner edges. +TEST(CASGCArithmeticIntake, WhollyOmittedNamespaceFoldsThroughAuthoritativeCheckpoint) +{ + auto backend = std::make_shared(); + auto store = openPoolForTest(backend, /*gc_fold_max_defer_rounds=*/0); + const Layout & layout = store->layout(); + const RootNamespace ns{"00/aa@cas@"}; + + for (uint64_t i = 1; i <= 3; ++i) + publishAt(*backend, layout, ns, RefTxnId{1, i}, "ref_" + std::to_string(i), i, + DB::UInt128(i), /*birth=*/i == 1); + writeRecoverableCkptForRawFixture(*backend, layout, ns, RefCkpt{ + .life_epoch = 1, + .committed_through = RefTxnId{1, 3}, + .checkpoint_snapshot_id = std::nullopt, + .last_epoch_seal = std::nullopt, + }); + for (uint64_t i = 1; i <= 3; ++i) + backend->hide(layout.refLogKey(fixture::fixtureLife(ns), RefTxnId{1, i})); + + Gc gc(store, kGc); + ASSERT_TRUE(gc.runRegularRound().acquired_lease); + const auto hidden_cov = coverageOf(*backend, layout, ns); + ASSERT_TRUE(hidden_cov.has_value()) + << "the namespace is `Live` in the catalog, so it stays in the universe even fully hidden"; + EXPECT_EQ(hidden_cov->classification, 2) << "the checkpoint's frontier is folded by exact key"; + EXPECT_EQ(hidden_cov->last_folded_ref_id, (RefTxnId{1, 3})); + + /// The store stops lying: the already folded namespace reappears. + backend->revealAll(); + ASSERT_TRUE(gc.runRegularRound().acquired_lease); + + EXPECT_EQ(cursorOf(*backend, layout, ns), (RefTxnId{1, 3})); + for (uint64_t i = 1; i <= 3; ++i) + EXPECT_EQ(inDegreeOf(*backend, layout, DB::UInt128(i)), 1) << "blob " << i; +} diff --git a/src/Disks/tests/gtest_cas_gc_attempt.cpp b/src/Disks/tests/gtest_cas_gc_attempt.cpp new file mode 100644 index 000000000000..b919298ae24e --- /dev/null +++ b/src/Disks/tests/gtest_cas_gc_attempt.cpp @@ -0,0 +1,189 @@ +#include + +#include +#include +#include +#include +#include "cas_test_helpers.h" + +using namespace DB::Cas; +using namespace DB::Cas::tests; + +namespace DB::ErrorCodes +{ +extern const int ABORTED; +} + +/// Unit-level GC-CONCURRENT-LEADER-LEAK regression (the original bug the attempt-scoped-generation fix +/// closes), ported to the one-pass ack-floor round. +/// +/// The historical wedge: two GC leaders fold the same generation. A DEPOSED leader writes its +/// `fold_seal(G_f)` to a FINAL `gc/gen//fold_seal` key just before its lease-guarded `gc/state` CAS +/// fails (lease lost mid-round). That orphaned write-once seal then poisons every future round: each +/// honest round recomputes `G_f`, hits the orphan's divergent bytes, throws "concurrent leader" +/// (`ABORTED`) forever — GC wedged, nothing reclaimed. +/// +/// The fix: every per-round `gc/gen` artifact is ATTEMPT-scoped (keyed by the folding leader's +/// `lease.seq`). A deposed leader writes its fold seal under its OWN attempt `a1`, which the failed +/// `gc/state` CAS never adopts — so it is pure unadopted debris, invisible to every reader resolving +/// only the adopted `(snap_generation, snap_attempt)`. The next honest round renews the lease (a fresh +/// `lease.seq`), folds under a DIFFERENT attempt, never collides, and drains. +/// +/// In the ONE-PASS round there is a SINGLE `gc/state` CAS per round (fold/publish/deletes all precede it), +/// so the deposition point is simply that single round-commit CAS. This test denies it once (leaving the +/// deposed fold seal under `a1`), then runs an honest GC to a fixpoint and asserts it drains the +/// now-unreachable blob to zero without wedging. + +namespace +{ + +const UInt128 kGcA = hexToU128("00000000000000000000000000000001"); + +ManifestRef ref(const String &, uint64_t seq, uint64_t inst) +{ + return ManifestRef{.writer_epoch = 1, .build_sequence = seq, .manifest_ordinal = static_cast(inst)}; +} + +/// Whether a blob's body object is present in the backend (HEADs the object key directly). +bool blobExists(InMemoryBackend & b, const Layout & layout, const UInt128 & hash) +{ + return b.head(layout.blobKey(BlobRef{BlobHashAlgo::CityHash128, BlobDigest::fromU128(hash)})).exists; +} + +/// Whether the CURRENT retired list (any gc-shard) still holds an entry — the ack-floor deletion pipeline +/// is in flight while this is true. +bool anyRetiredPending(const PoolPtr & s) +{ + /// Retired-in-snapshot (T4): condemned state rides the adopted fold seal's kCondemned rows, not a + /// separate retired list — reconstruct the in-flight set from the seal. + return anyCondemnedInSeal(s->backend(), s->layout()); +} + +/// Drive regular GC to a fixpoint over the ACK-FLOOR round (advancing the store's own mount ack after each +/// round so the floor follows the committed round; stay alive while any work counter is nonzero OR the +/// current retired list still holds an in-flight entry). +size_t runGcToFixpoint(const PoolPtr & s, Gc & gc, size_t max_rounds = 64) +{ + size_t rounds = 0; + for (; rounds < max_rounds; ++rounds) + { + const RoundReport rep = runRegularRoundReclaiming(gc); + if (!rep.acquired_lease) + continue; + s->renewWatermarkOnce(); + const bool no_work = rep.candidates == 0 && rep.deleted == 0 && rep.absent == 0 + && rep.replaced == 0 && rep.spared == 0; + if (no_work && !anyRetiredPending(s)) + break; + } + return rounds; +} + +/// A backend that throws ONCE on the SINGLE round-commit `gc/state` CAS — the casPut that advances +/// snap_generation (the one-pass round has exactly one such CAS; the lease-acquire CAS does not advance +/// snap_generation, so "advances snap_generation" uniquely picks the round commit). +class InterruptRoundCasBackend : public InMemoryBackend +{ +public: + explicit InterruptRoundCasBackend(String gc_state_key_) : gc_state_key(std::move(gc_state_key_)) {} + + CasResult casPut(const String & key, const String & bytes, const std::optional & expected, + const ObjectMeta & meta) override + { + if (arm_interrupt && key == gc_state_key) + { + const auto stored = get(key); + const uint64_t stored_gen = stored ? decodeGcState(stored->bytes).snap_generation : 0; + const uint64_t next_gen = decodeGcState(bytes).snap_generation; + if (next_gen > stored_gen) + { + arm_interrupt = false; /// one-shot: only depose the first round-commit CAS + throw DB::Exception(DB::ErrorCodes::ABORTED, + "test-injected: round-commit gc/state CAS denied (leader deposed mid-round; lease lost)"); + } + } + return InMemoryBackend::casPut(key, bytes, expected, meta); + } + + bool arm_interrupt = false; + +private: + String gc_state_key; +}; + +} + +/// A leader whose round-commit CAS is denied (lease lost mid-round) leaves its fold seal ONLY under its +/// own attempt `a1`; it never occupies the adopted attempt, so a subsequent honest round is not wedged +/// and drains the now-unreachable blob to zero. +TEST(CASGCAttempt, DeposedFoldAttemptDoesNotWedge) +{ + auto backend = std::make_shared(/*gc_state_key*/ "p/gc/state"); + auto store = openPoolForTest(backend); + ASSERT_EQ(store->layout().gcStateKey(), "p/gc/state"); // guard the injected key against layout drift + + const RootNamespace ns{"00/aa@cas@"}; + const ManifestRef r = ref("srv-a:1", 1, 0xAA); + writeBlobBody(*backend, store->layout(), DB::UInt128(1)); + writeManifestRaw(*backend, store->layout(), ns, r, {blobEntryFor("a", DB::UInt128(1))}); + publishCommittedTransition(*backend, store->layout(), ns, "tbl", std::nullopt, r); + + Gc gc(store, kGcA); + + // Round 1 (honest): fold the +1 so the blob is pinned in the in-degree generation, and adopt the + // first (snap_generation, snap_attempt). + runRegularRoundReclaiming(gc); + EXPECT_EQ(inDegreeOf(*backend, store->layout(), DB::UInt128(1)), 1) << "blob pinned by the committed ref"; + const auto after_fold = decodeGcState(backend->get(store->layout().gcStateKey())->bytes); + ASSERT_EQ(after_fold.snap_attempt, after_fold.lease.seq); + ASSERT_GT(after_fold.snap_generation, 0u); + + // Drop the only ref and advance the watermark floor so the now-orphaned blob is not spared in-flight. + dropRefTransition(*backend, store->layout(), ns, "tbl", r); + store->renewWatermarkOnce(); + + // Round 2 (DEPOSED): the round folds the -1 and writes its fold seal under its own attempt `a1`, then + // its single round-commit CAS is DENIED (lease lost mid-round). The round must throw and must NOT + // advance the adopted (snap_generation, snap_attempt). + backend->arm_interrupt = true; + EXPECT_ANY_THROW(runRegularRoundReclaiming(gc)); // ABORTED: round-commit CAS denied + backend->arm_interrupt = false; + + const auto after_deposed = decodeGcState(backend->get(store->layout().gcStateKey())->bytes); + EXPECT_EQ(after_deposed.snap_generation, after_fold.snap_generation) + << "the denied round-commit CAS must NOT advance the adopted generation"; + EXPECT_EQ(after_deposed.snap_attempt, after_fold.snap_attempt) + << "the denied round-commit CAS must NOT advance the adopted attempt"; + + // The deposed leader DID write its fold seal under its OWN attempt `a1` (= the lease.seq it renewed + // for round 2, which is strictly past the still-adopted attempt) at its fold generation `G_f` + // (= snap_generation + 1; fold mints the next generation, the round-commit CAS adopts it). That orphan + // is pure debris: it is under an attempt that gc/state never adopted, so no reader resolving + // (snap_generation, snap_attempt) can see it. On a PRE-FIX tree this seal would instead sit at the + // FINAL `gc/gen//fold_seal` key and wedge every future round's fold at the same G_f. + const uint64_t a1 = after_fold.lease.seq + 1; // round 2 renewed the lease => seq bumped once + const uint64_t g_f = after_fold.snap_generation + 1; // the generation the deposed fold minted + EXPECT_NE(a1, after_deposed.snap_attempt) << "the deposed attempt must differ from the adopted one"; + EXPECT_TRUE(backend->head(store->layout().foldSealKey(g_f, a1)).exists) + << "the deposed leader's fold seal is durable under its own (unadopted) attempt a1"; + EXPECT_FALSE(backend->head(store->layout().foldSealKey(g_f, after_deposed.snap_attempt)).exists) + << "no fold seal exists under the still-adopted attempt at the deposed fold generation (orphan is invisible)"; + + // An HONEST GC to a fixpoint (CAS now allowed). The KEY property: with attempt-scoping this SUCCEEDS — + // the next honest fold mints a FRESH attempt (a different lease.seq), never collides with the deposed + // seal under a1, and drains the unreachable blob. On a pre-fix (final-key) tree, the next fold would + // adopt-collide with the deposed final-key seal's divergent bytes and throw forever (GC wedged). + EXPECT_NO_THROW(runGcToFixpoint(store, gc)); + + EXPECT_FALSE(blobExists(*backend, store->layout(), DB::UInt128(1))) + << "the dropped blob must be reclaimed (GC drained past the deposed attempt)"; + EXPECT_EQ(inDegreeOf(*backend, store->layout(), DB::UInt128(1)), 0) << "no stranded positive in-degree"; + EXPECT_EQ(runFsck(*store, /*detail=*/false).unreachable, 0u) + << "INV-NO-LEAK: the deposed fold attempt did not wedge GC; the pool fully drained"; + + // GC advanced past the deposed attempt: the adopted (snap_generation, snap_attempt) moved on, and the + // adopted attempt is a fresh one (never the deposed a1). + const auto after_drain = decodeGcState(backend->get(store->layout().gcStateKey())->bytes); + EXPECT_GT(after_drain.snap_generation, after_fold.snap_generation) << "completion advanced the generation"; + EXPECT_NE(after_drain.snap_attempt, a1) << "the drained round never adopted the deposed attempt a1"; +} diff --git a/src/Disks/tests/gtest_cas_gc_bounded_walk.cpp b/src/Disks/tests/gtest_cas_gc_bounded_walk.cpp new file mode 100644 index 000000000000..e9041dd7b223 --- /dev/null +++ b/src/Disks/tests/gtest_cas_gc_bounded_walk.cpp @@ -0,0 +1,570 @@ +#include + +#include +#include +#include +#include +#include "cas_test_helpers.h" + +/// THE BOUNDED FOLD WALK. +/// +/// Arithmetic ref intake reads the next record by exact key -- `cursor + 1` -- and stops when that read +/// comes back absent. That is exact and immune to a lying listing, and it is also, on its own, a walk +/// with no last record: a namespace whose writer keeps appending never produces the absent read, so a +/// round's duration stopped being `backlog / walker_rate` and became `backlog / (walker - writer)`. It +/// diverges the moment a writer keeps up. Measured on a hot pool: ZERO completed GC rounds in 42 +/// minutes, and with them nothing that paces on rounds -- fold seal, cursors, the sampled store-quality +/// detector, ref-object cleanup -- ever ran again. +/// +/// The bound is `_ckpt.committed_through`, snapshotted once per namespace before the walk and never +/// re-read within the round, so the work is finite and fixed before the round began however fast the +/// writer appends. It is the AUTHORITY ceiling too -- a record above it is durable but is not logical +/// history yet -- so ONE comparison both terminates the round and refuses to fold uncommitted work. +/// +/// IT DOES NOT BOUND WHAT THE ROUND READS, and that is not an oversight. The read at `cursor + 1` +/// produces the frontier proof, an unproven namespace suppresses all +/// destructive work, and suppression stops the ref-object cleanup that would have drained the listing -- +/// so a namespace that stops being read can never become provable again by any route. "Skip the quiet +/// namespace entirely" therefore is not a cheaper version of this design, it is a GC that permanently +/// reclaims nothing; the saving is one `GET` and that `GET` is the proof. +/// +/// So the properties these tests pin are: +/// * a round folds through its round-start committed frontier and no further, whatever lands meanwhile; +/// * a namespace whose tail did not move folds NOTHING and a matching CTE proves its carried cursor; +/// * a round where no tail moved is skipped outright by the existing defer machinery; +/// * a raw record ABOVE the committed frontier neither extends the fold nor suppresses destruction; +/// * a manifest edge fold costs one GET and never a HEAD; +/// * a namespace that folded nothing still keeps its sealed coverage row. + +using namespace DB::Cas; +using namespace DB::Cas::tests; + +namespace +{ + +const UInt128 kGc = hexToU128("00000000000000000000000000000001"); + +/// Composed over `CountingBackend` because these tests assert REQUEST COUNTS: "folds nothing and reads +/// once" is the claim, and only a counting backend can check it. +using CountingHintHoleBackend = DB::Cas::tests::HintHoleBackendOn; + +/// The `RefCoverage` the newest fold seal recorded for `ns`'s opaque catalog life. Scans downward from +/// the adopted generation for the most recent fold seal, mirroring `foldCursorOf`'s reasoning (a +/// completed round's `gc/state` points at the recheck generation, which writes a completion seal rather +/// than a fold seal). +std::optional coverageOf(Backend & backend, const Layout & layout, const RootNamespace & ns) +{ + const uint64_t gen = currentGenerationOf(backend, layout); + const uint64_t attempt = currentAttemptOf(backend, layout); + const UInt128 life_id = catalogLifeIdForTest(backend, layout, ns); + for (uint64_t g = gen; ; --g) + { + if (const auto got = backend.get(layout.foldSealKey(g, attempt))) + { + const CasFoldSeal seal = decodeFoldSeal(got->bytes); + const auto it = seal.ref_lives.find(life_id); + if (it == seal.ref_lives.end()) + return std::nullopt; + return it->second.coverage; + } + if (g == 0) + return std::nullopt; + } +} + +RefTxnId cursorOf(Backend & backend, const Layout & layout, const RootNamespace & ns) +{ + const auto cov = coverageOf(backend, layout, ns); + return cov ? cov->last_folded_ref_id : RefTxnId{}; +} + +/// A phase metric, or 0 when the row does not carry it. Reading it this way rather than through +/// `std::map::at` is deliberate: against the unbounded walk these columns do not exist yet, and a +/// missing column should fail the assertion that names it, not abort the test with an exception. +UInt64 metric(const std::map & row, const String & name) +{ + const auto it = row.find(name); + return it == row.end() ? 0 : it->second; +} + +/// Every key the backend was asked to delete, so a failing zero-delete assertion names the site that +/// leaked instead of only reporting a count. +String deletedKeysMessage(const CountingBackend & backend) +{ + String out; + for (const String & key : backend.deletedKeys()) + out += "\n " + key; + return out.empty() ? String{" (none)"} : out; +} + +/// Every `_log/` GET this round issued against `ns` over ids `first..last` -- the "read once, fold +/// nothing" claim, made against the store rather than against a counter the fold keeps about itself. +uint64_t refLogGetsFor(const CountingBackend & backend, const Layout & layout, const RootNamespace & ns, + uint64_t first, uint64_t last, uint64_t epoch = 1) +{ + uint64_t total = 0; + for (uint64_t i = first; i <= last; ++i) + total += backend.getCount(layout.refLogKey(fixture::fixtureLife(ns), RefTxnId{epoch, i})); + return total; +} + +/// One round driven directly on `Gc`, capturing the `fold_ref_intake` phase row. Driving `Gc` rather +/// than `CasGcScheduler` is what lets a caller choose the universe policy, which the suppression test +/// needs. An EMPTY row means the round deferred and folded nothing at all. +std::map runRoundCapturingIntake(Gc & gc, UniversePolicy policy = UniversePolicy::kDefault) +{ + std::map intake; + gc.setPhaseSink([&](const GcPhaseRecord & rec) + { + if (rec.phase == "fold_ref_intake") + intake = rec.metrics; + }); + const RoundReport report = gc.runRegularRound({}, /*allow_steal*/true, policy); + gc.setPhaseSink({}); + EXPECT_TRUE(report.acquired_lease) << "the round must have run at all"; + return intake; +} + +/// A store whose writer keeps pace with the walker EXACTLY: every time the fold reads the newest record +/// by exact key, one more record lands above it. +/// +/// This is a mid-round appender expressed as a synchronous hook rather than as a thread, and the +/// determinism is the point. The property under test is "the round stops at the tail it froze, however +/// much arrives afterwards", and a thread can only make appends arrive at times the scheduler chooses -- +/// including, on an unlucky run, entirely after the walk has gone past. The hook reproduces the WORST +/// case (writer rate == walker rate, the rate at which the unbounded walk provably never terminates) on +/// every run, and `max_appends` bounds it so that the UNPATCHED walk still finishes and can be measured +/// rather than hanging the suite. +class ChasingWriterBackend : public CountingBackend +{ +public: + using CountingBackend::get; + + /// Start appending above `published_through` (writer epoch 1) whenever the tail is read, up to + /// `max_appends` further records. + void arm(const Layout * layout_, const RootNamespace & ns_, uint64_t published_through, uint64_t max_appends) + { + layout = layout_; + ns = ns_; + published = published_through; + limit = published_through + max_appends; + } + + /// Stop appending; the tail stands still from here on. + void disarm() { layout = nullptr; } + + uint64_t publishedThrough() const { return published; } + + std::optional get(const String & key, DB::Cas::Range range) override + { + auto result = CountingBackend::get(key, range); + if (!layout || appending || published >= limit) + return result; + if (key != layout->refLogKey(fixture::fixtureLife(ns), RefTxnId{1, published})) + return result; + + /// The walk just consumed the tail; the writer answers with the next record. Guarded against + /// re-entry because publishing issues backend calls of its own. + appending = true; + const uint64_t next = published + 1; + publishAt(*this, *layout, ns, RefTxnId{1, next}, "ref_" + std::to_string(next), next, DB::UInt128(next)); + published = next; + appending = false; + return result; + } + +private: + const Layout * layout = nullptr; + RootNamespace ns{}; + uint64_t published = 0; + uint64_t limit = 0; + bool appending = false; +}; + +/// Publish ids `first .. last` of `ns` in writer epoch 1, each pinning its own blob. +void publishRange(Backend & backend, const Layout & layout, const RootNamespace & ns, uint64_t first, uint64_t last) +{ + for (uint64_t i = first; i <= last; ++i) + publishAt(backend, layout, ns, RefTxnId{1, i}, "ref_" + std::to_string(i), i, DB::UInt128(i), + /*birth=*/i == 1); +} + +} + +/// ===================== (a) THE ROUND FOLDS THROUGH THE TAIL IT FROZE ===================== +/// +/// `planted` records are durable when the round starts. While it walks, the writer keeps pace exactly -- +/// every record the fold reads is answered with another one above it. The round must fold through the +/// tail it saw at round start and stop, leaving the stragglers to the round that lists them. +/// +/// Against the unbounded walk this fails on the cursor: it chases the appends and seals a cursor far +/// above the round-start tail. On a real pool nothing bounds that chase at all; the appender here stops +/// after `appended_mid_round` so the unpatched behaviour is measurable rather than a hang. +TEST(CASGCBoundedWalk, ARoundFoldsThroughItsRoundStartTailAndLeavesTheStragglers) +{ + const uint64_t planted = 6; + const uint64_t appended_mid_round = 40; + + auto backend = std::make_shared(); + auto store = openPoolForTest(backend, /*gc_fold_max_defer_rounds=*/0); + const Layout & layout = store->layout(); + const RootNamespace ns{"00/hot@cas@"}; + + publishRange(*backend, layout, ns, 1, planted); + writeRecoverableCkptForRawFixture(*backend, layout, ns, RefCkpt{ + .life_epoch = 1, + .committed_through = RefTxnId{1, planted}, + .checkpoint_snapshot_id = std::nullopt, + .last_epoch_seal = std::nullopt, + }); + backend->arm(&layout, ns, planted, appended_mid_round); + + Gc gc(store, kGc); + const std::map intake = runRoundCapturingIntake(gc); + + ASSERT_GT(backend->publishedThrough(), planted) + << "the mid-round appender never fired, so this test proves nothing about a moving tail"; + + const auto cov = coverageOf(*backend, layout, ns); + ASSERT_TRUE(cov.has_value()) << "the round must seal a coverage row for the namespace it walked"; + EXPECT_EQ(cov->last_folded_ref_id, (RefTxnId{1, planted})) + << "the walk must fold through the round-start tail and no further -- it chased the writer"; + EXPECT_FALSE(cov->hold.has_value()) << "reaching the committed frontier is not a hold"; + EXPECT_NE(cov->classification, 4) << "reaching the committed frontier is not a clamp"; + EXPECT_EQ(metric(intake, "tails_advanced"), 1u); + EXPECT_EQ(metric(intake, "logs_applied"), planted) << "exactly the round-start backlog was folded"; + + /// The stragglers are not lost: the next round's listing has a higher tail and folds through it. + backend->disarm(); + const uint64_t total = backend->publishedThrough(); + ASSERT_GT(total, planted); + advanceRecoverableCkptForRawFixture(*backend, layout, ns, RefTxnId{1, total}); + const std::map second = runRoundCapturingIntake(gc); + EXPECT_EQ(cursorOf(*backend, layout, ns), (RefTxnId{1, total})) + << "the records that landed mid-round are folded by the round that lists them"; + EXPECT_EQ(metric(second, "tails_advanced"), 1u); +} + +/// ===================== (b) A CTE-AUTHORIZED UNCHANGED NAMESPACE FOLDS NOTHING ===================== +/// +/// Two namespaces; only one gets a new record. The unchanged one must fold NOTHING. Its CTE already +/// authorizes the sealed cursor as a frontier, so an exact probe at `cursor + 1` would be redundant. +TEST(CASGCBoundedWalk, ACTEAuthorizedUnchangedNamespaceFoldsNothingWithoutAProbe) +{ + auto backend = std::make_shared(); + auto store = openPoolForTest(backend, /*gc_fold_max_defer_rounds=*/0); + const Layout & layout = store->layout(); + const RootNamespace moved{"00/moved@cas@"}; + const RootNamespace still{"00/still@cas@"}; + + publishRange(*backend, layout, moved, 1, 2); + publishRange(*backend, layout, still, 1, 2); + writeRecoverableCkptForRawFixture(*backend, layout, moved, RefCkpt{ + .life_epoch = 1, + .committed_through = RefTxnId{1, 2}, + .checkpoint_snapshot_id = std::nullopt, + .last_epoch_seal = std::nullopt, + }); + writeRecoverableCkptForRawFixture(*backend, layout, still, RefCkpt{ + .life_epoch = 1, + .committed_through = RefTxnId{1, 2}, + .checkpoint_snapshot_id = std::nullopt, + .last_epoch_seal = std::nullopt, + }); + + Gc gc(store, kGc); + runRoundCapturingIntake(gc); + ASSERT_EQ(cursorOf(*backend, layout, still), (RefTxnId{1, 2})) << "the seeding round must fold both"; + ASSERT_EQ(cursorOf(*backend, layout, moved), (RefTxnId{1, 2})); + + /// Only `moved` advances. + publishAt(*backend, layout, moved, RefTxnId{1, 3}, "ref_3", 3, DB::UInt128(0x33)); + advanceRecoverableCkptForRawFixture(*backend, layout, moved, RefTxnId{1, 3}); + + backend->resetCounts(); + const std::map intake = runRoundCapturingIntake(gc); + + EXPECT_EQ(refLogGetsFor(*backend, layout, still, 1, 8), 0u) + << "the CTE already proves the unchanged namespace's sealed frontier"; + EXPECT_EQ(backend->getCount(layout.refLogKey(fixture::fixtureLife(still), RefTxnId{1, 3})), 0u) + << "a valid CTE needs no successor probe"; + EXPECT_EQ(metric(intake, "tails_unchanged"), 1u); + EXPECT_EQ(metric(intake, "tails_advanced"), 1u); + EXPECT_EQ(metric(intake, "logs_applied"), 1u) << "only the one new record was folded, pool-wide"; + EXPECT_EQ(cursorOf(*backend, layout, moved), (RefTxnId{1, 3})) << "the advanced namespace still folds"; +} + +/// ===================== (c) A ROUND WHERE NO TAIL MOVED IS SKIPPED OUTRIGHT ===================== +/// +/// The round-level skip is the EXISTING defer machinery, whose signal is already exactly this +/// comparison: `RefScanSummary::changed_shards` counts the namespaces whose greatest listed log sits +/// above their sealed cursor. So a round in which no tail moved folds nothing at all -- no intake phase, +/// no per-namespace walk, not even the probes -- and an append un-defers it. +TEST(CASGCBoundedWalk, ARoundWhereNoTailMovedIsDeferredAndAnAppendUnDefersIt) +{ + auto backend = std::make_shared(); + auto store = openPoolForTest(backend); /// the DEFAULT defer window: this test is about the skip + const Layout & layout = store->layout(); + const RootNamespace a{"00/aa@cas@"}; + const RootNamespace b{"00/bb@cas@"}; + + publishRange(*backend, layout, a, 1, 2); + publishRange(*backend, layout, b, 1, 2); + writeRecoverableCkptForRawFixture(*backend, layout, a, RefCkpt{ + .life_epoch = 1, + .committed_through = RefTxnId{1, 2}, + .checkpoint_snapshot_id = std::nullopt, + .last_epoch_seal = std::nullopt, + }); + writeRecoverableCkptForRawFixture(*backend, layout, b, RefCkpt{ + .life_epoch = 1, + .committed_through = RefTxnId{1, 2}, + .checkpoint_snapshot_id = std::nullopt, + .last_epoch_seal = std::nullopt, + }); + + Gc gc(store, kGc); + ASSERT_FALSE(runRoundCapturingIntake(gc).empty()) << "the seeding round must actually fold"; + ASSERT_EQ(cursorOf(*backend, layout, a), (RefTxnId{1, 2})); + ASSERT_EQ(cursorOf(*backend, layout, b), (RefTxnId{1, 2})); + + backend->resetCounts(); + EXPECT_TRUE(runRoundCapturingIntake(gc).empty()) + << "no tail moved, so the round has no fold to run"; + EXPECT_EQ(refLogGetsFor(*backend, layout, a, 1, 8), 0u) << "a deferred round reads no ref log at all"; + EXPECT_EQ(refLogGetsFor(*backend, layout, b, 1, 8), 0u); + + /// One append, in `a` only. + publishAt(*backend, layout, a, RefTxnId{1, 3}, "ref_3", 3, DB::UInt128(0xaa3)); + advanceRecoverableCkptForRawFixture(*backend, layout, a, RefTxnId{1, 3}); + + backend->resetCounts(); + const std::map woken = runRoundCapturingIntake(gc); + ASSERT_FALSE(woken.empty()) << "an append must un-defer the round"; + EXPECT_EQ(metric(woken, "tails_advanced"), 1u) << "the appended-to namespace is walked again"; + EXPECT_EQ(metric(woken, "tails_unchanged"), 1u) << "and only that one"; + EXPECT_EQ(cursorOf(*backend, layout, a), (RefTxnId{1, 3})); + EXPECT_EQ(refLogGetsFor(*backend, layout, b, 1, 8), 0u) + << "the still-quiet namespace's CTE proves its carried frontier without a probe"; +} + +/// ============ (d) A RAW RECORD ABOVE THE COMMITTED FRONTIER PROVES AND SUPPRESSES NOTHING ============ +/// +/// This is the safety argument. It is stated under an explicit `Authoritative` policy so that the claim +/// is about the frontier terms and not about which policy the caller happened to pass. +/// +/// The CTE fixes the namespace's committed frontier at `{1,3}`. A raw record ABOVE that frontier was +/// never committed, so it is neither a reason to extend the fold nor a reason to suppress destruction: +/// LIST may observe it, but it cannot manufacture a later authoritative frontier. The dropped blob is +/// therefore reclaimable after the normal condemn/graduation pipeline. +TEST(CASGCBoundedWalk, ARawRecordBeyondTheCommittedFrontierCannotSuppressDestruction) +{ + auto backend = std::make_shared(); + auto store = openPoolForTest(backend, /*gc_fold_max_defer_rounds=*/0); + const Layout & layout = store->layout(); + const RootNamespace ns{"00/hot@cas@"}; + const DB::UInt128 blob(0xd00d); + + /// Publish a blob and drop it, both within the round-start listing: its folded in-degree returns to + /// zero, so a round with a complete frontier would condemn it. + publishAt(*backend, layout, ns, RefTxnId{1, 1}, "kept", 1, DB::UInt128(0x1), /*birth=*/true); + const ManifestRef doomed{.writer_epoch = 1, .build_sequence = 2, .manifest_ordinal = 1}; + writeBlobBody(*backend, layout, blob); + writeManifestRaw(*backend, layout, ns, doomed, {blobEntryFor("data.bin", blob)}); + writeTxnAt(*backend, layout, ns, RefTxnId{1, 2}, publishCommittedOps("doomed", doomed)); + dropRefTransition(*backend, layout, ns, "doomed", doomed); + + /// One record lands mid-round, above the committed frontier. + backend->arm(&layout, ns, /*published_through*/ 3, /*max_appends*/ 1); + + /// From HERE the deletes are the ROUND's. Opening the pool runs a capability probe that writes and + /// deletes its own `_probe/` keys, and counting those against the round would make this assertion + /// fail on debris that has nothing to do with the destructive gate. + backend->resetCounts(); + + Gc gc(store, kGc); + const std::map intake = runRoundCapturingIntake(gc, UniversePolicy::Authoritative); + + ASSERT_EQ(backend->publishedThrough(), 4u) << "the mid-round appender never fired"; + ASSERT_EQ(metric(intake, "tails_advanced"), 1u) << "the namespace must have been walked"; + EXPECT_EQ(cursorOf(*backend, layout, ns), (RefTxnId{1, 3})) << "and folded exactly its committed frontier"; + EXPECT_EQ(metric(intake, "frontier_proven"), 1u) + << "the CTE, not the raw record beyond it, fixes the namespace frontier"; + EXPECT_EQ(metric(intake, "frontier_namespaces"), 1u) << "it is still in the round's universe"; + EXPECT_EQ(backend->deleteTotal(), 1u) + << "the committed frontier permits the round's immediate manifest cleanup. Deleted:" + << deletedKeysMessage(*backend); + EXPECT_TRUE(backend->head(layout.blobKey(legacyMetaTestRef(blob))).exists); + + /// The raw F+1 record remains outside the CTE; it cannot defer the normal destructive pipeline. + backend->disarm(); + EXPECT_TRUE(runRoundsUntilAbsent(store, gc, *backend, layout, blob, /*max_rounds*/ 8)) + << "the committed frontier must permit reclamation despite the raw record above it"; +} + +/// A store that hides a namespace's records from every LIST does not lose them: the namespace goes +/// QUIET, and a quiet namespace is exactly the shape the exact-key probe at `cursor + 1` exists for. It +/// has no listed tail at all, and no bound is taken from a listing anyway -- bounding a namespace by a +/// tail the liar refuses to admit to would hand it the omission it was hoping for. +TEST(CASGCBoundedWalk, AListHiddenTailIsCaughtAndFoldedByTheQuietProbePath) +{ + auto backend = std::make_shared(); + auto store = openPoolForTest(backend, /*gc_fold_max_defer_rounds=*/0); + const Layout & layout = store->layout(); + const RootNamespace ns{"00/liar@cas@"}; + + publishRange(*backend, layout, ns, 1, 2); + writeRecoverableCkptForRawFixture(*backend, layout, ns, RefCkpt{ + .life_epoch = 1, + .committed_through = RefTxnId{1, 2}, + .checkpoint_snapshot_id = std::nullopt, + .last_epoch_seal = std::nullopt, + }); + Gc gc(store, kGc); + runRoundCapturingIntake(gc); + ASSERT_EQ(cursorOf(*backend, layout, ns), (RefTxnId{1, 2})); + + /// A third record lands and the store stops listing the namespace at the same moment: its listed + /// tail is now nothing at all, while `{1, 3}` is durable and readable by exact key. + publishAt(*backend, layout, ns, RefTxnId{1, 3}, "ref_3", 3, DB::UInt128(0x1a3)); + advanceRecoverableCkptForRawFixture(*backend, layout, ns, RefTxnId{1, 3}); + backend->hidePrefix(layout.namespaceStreamPrefix(fixture::fixtureLife(ns))); + + const std::map intake = runRoundCapturingIntake(gc); + EXPECT_EQ(cursorOf(*backend, layout, ns), (RefTxnId{1, 3})) + << "the exact-key probe sees what LIST omits, so the hidden record is folded, not lost"; + EXPECT_EQ(metric(intake, "unhinted_quiet_walked"), 1u) << "it is the quiet path that caught it"; + EXPECT_EQ(inDegreeOf(*backend, layout, DB::UInt128(0x1a3)), 1) + << "the hidden record's owner edge must be folded, or its blob looks unreferenced"; +} + +/// ===================== (e) ONE ROUND TRIP PER MANIFEST EDGE ===================== +/// +/// The edge fold used to pay HEAD-then-GET: two serial round trips per manifest edge, on every folded +/// log, where the GET alone already answers "is it there". +TEST(CASGCBoundedWalk, ManifestEdgeFoldsPayAGetAndNeverAHead) +{ + auto backend = std::make_shared(); + auto store = openPoolForTest(backend, /*gc_fold_max_defer_rounds=*/0); + const Layout & layout = store->layout(); + const RootNamespace ns{"00/aa@cas@"}; + + const uint64_t records = 3; + publishRange(*backend, layout, ns, 1, records); + writeRecoverableCkptForRawFixture(*backend, layout, ns, RefCkpt{ + .life_epoch = 1, + .committed_through = RefTxnId{1, records}, + .checkpoint_snapshot_id = std::nullopt, + .last_epoch_seal = std::nullopt, + }); + + backend->resetCounts(); + Gc gc(store, kGc); + runRoundCapturingIntake(gc); + ASSERT_EQ(cursorOf(*backend, layout, ns), (RefTxnId{1, records})) << "the round must have folded them"; + + uint64_t manifest_heads = 0; + uint64_t manifest_gets = 0; + for (uint64_t i = 1; i <= records; ++i) + { + const ManifestId id{ns, ManifestRef{.writer_epoch = 1, .build_sequence = i, .manifest_ordinal = 1}}; + manifest_heads += backend->headCount(layout.manifestKey(id)); + manifest_gets += backend->getCount(layout.manifestKey(id)); + } + EXPECT_EQ(manifest_heads, 0u) << "the fold must not HEAD a manifest body it is about to GET"; + EXPECT_GT(manifest_gets, 0u) << "the bodies were read, so the counters really are watching these keys"; +} + +/// An absent manifest body still takes the record-and-continue path -- the GET's own absence is the +/// signal the HEAD used to carry -- and still raises the fold barrier without ever HEADing the key. +TEST(CASGCBoundedWalk, AnAbsentManifestBodyStillHoldsWithoutAHead) +{ + auto backend = std::make_shared(); + auto store = openPoolForTest(backend, /*gc_fold_max_defer_rounds=*/0); + const Layout & layout = store->layout(); + const RootNamespace ns{"00/aa@cas@"}; + + publishRange(*backend, layout, ns, 1, 3); + writeRecoverableCkptForRawFixture(*backend, layout, ns, RefCkpt{ + .life_epoch = 1, + .committed_through = RefTxnId{1, 3}, + .checkpoint_snapshot_id = std::nullopt, + .last_epoch_seal = std::nullopt, + }); + const ManifestId gone{ns, ManifestRef{.writer_epoch = 1, .build_sequence = 2, .manifest_ordinal = 1}}; + deleteManifestBody(*backend, layout, gone); + + backend->resetCounts(); + Gc gc(store, kGc); + runRoundCapturingIntake(gc); + + const auto cov = coverageOf(*backend, layout, ns); + ASSERT_TRUE(cov.has_value()); + EXPECT_EQ(cov->last_folded_ref_id, (RefTxnId{1, 1})) + << "the cursor stays BELOW the log whose manifest body is missing"; + ASSERT_TRUE(cov->hold.has_value()) << "an absent committed manifest body raises the fold barrier"; + EXPECT_EQ(cov->hold->reason, HoldReason::ManifestBodyMissing); + EXPECT_EQ(cov->hold->offending_position, (RefTxnId{1, 2})); + EXPECT_EQ(cov->classification, 4); + EXPECT_EQ(backend->headCount(layout.manifestKey(gone)), 0u) + << "absence is decided by the GET, so the missing body costs no HEAD either"; +} + +/// ===================== (f) A NAMESPACE THAT FOLDED NOTHING KEEPS ITS COVERAGE ROW =================== +/// +/// The fold seal writes a row only for the namespaces the intake loop visits, so any future shortcut +/// that stops visiting a namespace with nothing to fold would DROP its cursor -- and a dropped cursor is +/// not a lost optimisation, it is a re-fold from `{0, 0}`: every owner edge counted a second time, every +/// blob's in-degree inflated, and the eventual correction mass-condemning live data. This pins the row +/// against exactly that. +TEST(CASGCBoundedWalk, ANamespaceThatFoldedNothingKeepsItsSealedCursor) +{ + auto backend = std::make_shared(); + auto store = openPoolForTest(backend, /*gc_fold_max_defer_rounds=*/0); + const Layout & layout = store->layout(); + const RootNamespace quiet{"00/quiet@cas@"}; + const RootNamespace moved{"00/moved@cas@"}; + + publishRange(*backend, layout, quiet, 1, 3); + publishRange(*backend, layout, moved, 1, 1); + writeRecoverableCkptForRawFixture(*backend, layout, quiet, RefCkpt{ + .life_epoch = 1, + .committed_through = RefTxnId{1, 3}, + .checkpoint_snapshot_id = std::nullopt, + .last_epoch_seal = std::nullopt, + }); + writeRecoverableCkptForRawFixture(*backend, layout, moved, RefCkpt{ + .life_epoch = 1, + .committed_through = RefTxnId{1, 1}, + .checkpoint_snapshot_id = std::nullopt, + .last_epoch_seal = std::nullopt, + }); + + Gc gc(store, kGc); + runRoundCapturingIntake(gc); + const auto before = coverageOf(*backend, layout, quiet); + ASSERT_TRUE(before.has_value()); + ASSERT_EQ(before->last_folded_ref_id, (RefTxnId{1, 3})); + ASSERT_FALSE(before->hold.has_value()); + + /// A second round in which `quiet` folds nothing and `moved` does, so the round really does write a + /// new seal that could have dropped the row. + publishAt(*backend, layout, moved, RefTxnId{1, 2}, "ref_2", 2, DB::UInt128(0x22)); + advanceRecoverableCkptForRawFixture(*backend, layout, moved, RefTxnId{1, 2}); + const std::map intake = runRoundCapturingIntake(gc); + ASSERT_EQ(metric(intake, "tails_unchanged"), 1u) << "the fixture must actually exercise the quiet case"; + + const auto after = coverageOf(*backend, layout, quiet); + ASSERT_TRUE(after.has_value()) + << "the coverage row was DROPPED -- the next round would re-fold this namespace from {0,0}"; + /// The CURSOR and the HOLD are what the next round trusts, and both ride unchanged. + /// `classification` legitimately moves from 2 ("this round folded records") to 1 ("unchanged"), + /// because that is what the round did — it is the one field that may differ, so it is the one field + /// asserted loosely. + EXPECT_EQ(after->last_folded_ref_id, before->last_folded_ref_id) + << "a namespace that folded nothing must keep the cursor it had"; + EXPECT_EQ(after->hold, before->hold); + EXPECT_NE(after->classification, 4) << "folding nothing is not a clamp"; + EXPECT_EQ(metric(intake, "frontier_namespaces"), 2u) + << "it stays in the round's universe, so its proof is still owed"; +} diff --git a/src/Disks/tests/gtest_cas_gc_fold.cpp b/src/Disks/tests/gtest_cas_gc_fold.cpp new file mode 100644 index 000000000000..c764aa0ac0b7 --- /dev/null +++ b/src/Disks/tests/gtest_cas_gc_fold.cpp @@ -0,0 +1,624 @@ +#include + +#include +#include +#include +#include +#include "cas_test_helpers.h" + +using namespace DB::Cas; +using namespace DB::Cas::tests; + +namespace DB::ErrorCodes +{ + extern const int CORRUPTED_DATA; +} + +namespace +{ +const UInt128 kGc = hexToU128("00000000000000000000000000000001"); +ManifestRef ref(const String &, uint64_t seq, uint64_t inst) +{ + return ManifestRef{.writer_epoch = 1, .build_sequence = seq, .manifest_ordinal = static_cast(inst)}; +} +} + +/// Committed new_manifest => +1 per blob entry (BlobInDegreeMatchesActiveManifests). +/// After a fold, gc/state records snap_attempt == the folding leader's lease.seq, and the fold seal +/// lives under (snap_generation, snap_attempt). +TEST(CASGCFold, FoldAdoptsAttemptEqualsLeaseSeq) +{ + auto backend = std::make_shared(); + auto store = openPoolForTest(backend); + const RootNamespace ns{"00/aa@cas@"}; + const ManifestRef r = ref("srv-a:1", 1, 0xAA); + writeManifestRaw(*backend, store->layout(), ns, r, {blobEntryFor("a", DB::UInt128(1))}); + publishCommittedTransition(*backend, store->layout(), ns, "tbl", std::nullopt, r); + + Gc gc(store, kGc); + gc.runRegularRound(); + + const auto st = decodeGcState(backend->get(store->layout().gcStateKey())->bytes); + EXPECT_EQ(st.snap_attempt, st.lease.seq); + EXPECT_GT(st.snap_generation, 0u); + /// The one-pass round's fold seal is durable under (snap_generation, snap_attempt) — the adopted + /// attempt locates it (a seal under any other attempt would be unadopted debris). + EXPECT_TRUE(backend->head(store->layout().foldSealKey(st.snap_generation, st.snap_attempt)).exists); +} + +TEST(CASGCFold, CommittedAddEmitsPlusOnePerBlob) +{ + auto backend = std::make_shared(); + auto store = openPoolForTest(backend); + const RootNamespace ns{"00/aa@cas@"}; + const ManifestRef r = ref("srv-a:1", 1, 0xAA); + writeManifestRaw(*backend, store->layout(), ns, r, + {blobEntryFor("a", DB::UInt128(1)), blobEntryFor("b", DB::UInt128(2))}); + publishCommittedTransition(*backend, store->layout(), ns, "tbl", std::nullopt, r); + + Gc gc(store, kGc); + gc.runRegularRound(); + + EXPECT_EQ(inDegreeOf(*backend, store->layout(), DB::UInt128(1)), 1); + EXPECT_EQ(inDegreeOf(*backend, store->layout(), DB::UInt128(2)), 1); +} + +/// Owner removal => -1 per blob entry; in-degree returns to 0. +TEST(CASGCFold, RemovalEmitsMinusOne) +{ + auto backend = std::make_shared(); + auto store = openPoolForTest(backend); + const RootNamespace ns{"00/aa@cas@"}; + const ManifestRef r = ref("srv-a:1", 1, 0xAA); + writeManifestRaw(*backend, store->layout(), ns, r, {blobEntryFor("a", DB::UInt128(1))}); + publishCommittedTransition(*backend, store->layout(), ns, "tbl", std::nullopt, r); + Gc gc(store, kGc); + gc.runRegularRound(); + EXPECT_EQ(inDegreeOf(*backend, store->layout(), DB::UInt128(1)), 1); + dropRefTransition(*backend, store->layout(), ns, "tbl", r); + gc.runRegularRound(); + EXPECT_EQ(inDegreeOf(*backend, store->layout(), DB::UInt128(1)), 0); +} + +/// Precommit with a PRESENT, valid body => +1. +TEST(CASGCFold, PrecommitBodyPresentEmitsPlusOne) +{ + auto backend = std::make_shared(); + auto store = openPoolForTest(backend); + const RootNamespace ns{"00/aa@cas@"}; + const ManifestRef r = ref("srv-a:1", 1, 0xAA); + writeManifestRaw(*backend, store->layout(), ns, r, {blobEntryFor("a", DB::UInt128(1))}); + addPrecommitTransition(*backend, store->layout(), ns, DB::UInt128(7), "tbl", std::nullopt, r); + Gc gc(store, kGc); + gc.runRegularRound(); + EXPECT_EQ(inDegreeOf(*backend, store->layout(), DB::UInt128(1)), 1); +} + +/// Precommit whose body is ABSENT => NO delta (control #4); the 404 must NOT throw. +TEST(CASGCFold, PrecommitMissingBodyEmitsNoDelta) +{ + auto backend = std::make_shared(); + auto store = openPoolForTest(backend); + const RootNamespace ns{"00/aa@cas@"}; + const ManifestRef r = ref("srv-a:1", 1, 0xAA); + addPrecommitTransition(*backend, store->layout(), ns, DB::UInt128(7), "tbl", std::nullopt, r); + Gc gc(store, kGc); + EXPECT_NO_THROW(gc.runRegularRound()); + EXPECT_EQ(inDegreeOf(*backend, store->layout(), DB::UInt128(1)), 0); +} + +/// FOLD BARRIER (control #23): a LIVE precommit binding whose body is missing does NOT advance the +/// durable fold cursor past its activation event; when the body appears the cursor advances. +TEST(CASGCFold, FoldBarrierHaltsCursorAtLiveMissingBodyPrecommit) +{ + auto backend = std::make_shared(); + auto store = openPoolForTest(backend); + const RootNamespace ns{"00/aa@cas@"}; + const ManifestRef r = ref("srv-a:1", 1, 0xAA); + const uint64_t v = addPrecommitTransition(*backend, store->layout(), ns, DB::UInt128(7), "tbl", std::nullopt, r); + Gc gc(store, kGc); + EXPECT_NO_THROW(gc.runRegularRound()); + EXPECT_LT(foldCursorOf(*backend, store->layout(), ns, 0), v); // barrier: halted at the activation + + writeManifestRaw(*backend, store->layout(), ns, r, {blobEntryFor("a", DB::UInt128(1))}); + gc.runRegularRound(); + EXPECT_EQ(inDegreeOf(*backend, store->layout(), DB::UInt128(1)), 1); + EXPECT_GE(foldCursorOf(*backend, store->layout(), ns, 0), v); // barrier lifted by activation +} + +/// Promote of an already-activated precommit is a PURE OWNER MOVE: NO delta, body not condemned. +TEST(CASGCFold, PromoteOfActivatedPrecommitEmitsNoDelta) +{ + auto backend = std::make_shared(); + auto store = openPoolForTest(backend); + const RootNamespace ns{"00/aa@cas@"}; + const ManifestRef r = ref("srv-a:1", 1, 0xAA); + writeManifestRaw(*backend, store->layout(), ns, r, {blobEntryFor("a", DB::UInt128(1))}); + addPrecommitTransition(*backend, store->layout(), ns, DB::UInt128(7), "tbl", std::nullopt, r); + Gc gc(store, kGc); + gc.runRegularRound(); + EXPECT_EQ(inDegreeOf(*backend, store->layout(), DB::UInt128(1)), 1); + + promoteTransition(*backend, store->layout(), ns, DB::UInt128(7), "tbl", r); + gc.runRegularRound(); + EXPECT_EQ(inDegreeOf(*backend, store->layout(), DB::UInt128(1)), 1); // unchanged, still pinned + EXPECT_TRUE(backend->head(store->layout().manifestKey(ManifestId{ns, r})).exists); // not condemned +} + +/// Committed add naming a MISSING body (404) => clamp + anomaly, never a guessed +1, never a throw. +TEST(CASGCFold, CommittedMissingBodyClampsCursorAndRecordsAnomaly) +{ + auto backend = std::make_shared(); + auto store = openPoolForTest(backend); + const RootNamespace ns{"00/aa@cas@"}; + const ManifestRef r = ref("srv-a:1", 1, 0xAA); + const uint64_t v = publishCommittedTransition(*backend, store->layout(), ns, "tbl", std::nullopt, r); // no body + Gc gc(store, kGc); + RoundReport report; + EXPECT_NO_THROW(report = gc.runRegularRound()); + EXPECT_TRUE(report.hasAnomaly(ns, /*shard*/0)); + EXPECT_EQ(inDegreeOf(*backend, store->layout(), DB::UInt128(1)), 0); + EXPECT_LT(foldCursorOf(*backend, store->layout(), ns, 0), v); +} + +/// A body whose self-ref disagrees (PRESENT but INVALID) => hard fail closed (controls #19/#20). +TEST(CASGCFold, RefMismatchFailsClosed) +{ + auto backend = std::make_shared(); + auto store = openPoolForTest(backend); + const RootNamespace ns{"00/aa@cas@"}; + const ManifestRef r = ref("srv-a:1", 1, 0xAA); + PartManifest bad; + bad.ref = ref("srv-a:1", 1, 0xBB); // != r + bad.root_namespace_id = ns; + bad.entries = {blobEntryFor("a", DB::UInt128(1))}; + bad.payload_digest = computePayloadDigest(bad); + backend->putIfAbsent(store->layout().manifestKey(ManifestId{ns, r}), encodePartManifest(bad)); + publishCommittedTransition(*backend, store->layout(), ns, "tbl", std::nullopt, r); + Gc gc(store, kGc); + expectThrowsCode(DB::ErrorCodes::CORRUPTED_DATA, [&]{ gc.runRegularRound(); }); +} + +/// Owner-removal whose OLD committed body is gone at removal-fold => clamp + anomaly, no partial -1. +TEST(CASGCFold, RemovalWithMissingOldBodyClampsAndRecordsAnomaly) +{ + auto backend = std::make_shared(); + auto store = openPoolForTest(backend); + const RootNamespace ns{"00/aa@cas@"}; + const ManifestRef r = ref("srv-a:1", 1, 0xAA); + writeManifestRaw(*backend, store->layout(), ns, r, {blobEntryFor("a", DB::UInt128(1))}); + publishCommittedTransition(*backend, store->layout(), ns, "tbl", std::nullopt, r); + Gc gc(store, kGc); + gc.runRegularRound(); // +1; blob 1 in-degree 1 + + const uint64_t removal_version = dropRefTransition(*backend, store->layout(), ns, "tbl", r); + deleteManifestBody(*backend, store->layout(), ManifestId{ns, r}); // body gone before its decrement + + RoundReport report; + EXPECT_NO_THROW(report = gc.runRegularRound()); + EXPECT_EQ(inDegreeOf(*backend, store->layout(), DB::UInt128(1)), 1); // unchanged: no silent -1 + EXPECT_TRUE(report.hasAnomaly(ns, /*shard*/0)); + EXPECT_LT(foldCursorOf(*backend, store->layout(), ns, 0), removal_version); +} + +/// (The two `CASGCFold.IncarnationMismatchRestartsFoldAtZero*` tests were removed with the snapshot+log +/// ref model: they injected a stale per-shard fold cursor beyond the live mutable shard's version and +/// asserted the fold RESET the cursor to 0 on an incarnation mismatch. There is no mutable per-shard +/// cursor to stale-reset anymore -- the durable cursor is a strictly-increasing `RefTxnId`, and a +/// recreated namespace uses a GREATER `writer_epoch`, so the ABA hazard is impossible by construction. +/// The ref-model equivalent -- `remove_namespace` then a later `namespace_birth` with a greater id folds +/// normally -- is covered by `gtest_cas_gc_shard_incarnation.cpp` and `gtest_cas_ref_gc.cpp`.) + +/// T0 (2026-07-02 snapshot-streaming): an idle round — no journal changes, no retired entries — touches +/// ZERO run objects. After one populated round, reset the counters and run a no-op round; the fold must +/// carry the parent generation's `RunRef` verbatim into the new fold_seal (same key, same checksum, same +/// generation) and NOT read or write any `.../blob_target/...` object. +TEST(CASGCFold, EmptyDeltaShardCarriesParentRunRef) +{ + auto backend = std::make_shared(); + /// gc_fold_max_defer_rounds=0 forces fold-every-round: this test exercises the pure-ref-carry FOLD + /// path on an idle round; without it the round would DEFER (re-adopt the sealed generation) and never + /// mint the carried generation this test inspects. + auto store = openPoolForTest(backend, /*gc_fold_max_defer_rounds*/ 0); + const RootNamespace ns{"00/aa@cas@"}; + const ManifestRef r = ref("srv-a:1", 1, 0xAA); + writeManifestRaw(*backend, store->layout(), ns, r, {blobEntryFor("a", DB::UInt128(1))}); + publishCommittedTransition(*backend, store->layout(), ns, "tbl", std::nullopt, r); + + Gc gc(store, kGc); + gc.runRegularRound(); // round 1: folds the +1, seals the gen-1 blob_target run + + const auto st1 = decodeGcState(backend->get(store->layout().gcStateKey())->bytes); + const auto parent_seal = decodeFoldSeal( + backend->get(store->layout().foldSealKey(st1.snap_generation, st1.snap_attempt))->bytes); + ASSERT_EQ(parent_seal.blob_target_runs.size(), 1u); + const RunRef parent_ref = parent_seal.blob_target_runs.front(); + + backend->resetCounts(); + gc.runRegularRound(); // round 2: no changes => pure ref-carry, zero run I/O + + EXPECT_EQ(backend->ioCountForKeysContaining("/blob_target/"), 0u) + << "idle round must not GET/getStream/PUT any blob_target run object"; + + const auto st2 = decodeGcState(backend->get(store->layout().gcStateKey())->bytes); + EXPECT_GT(st2.snap_generation, st1.snap_generation); + const auto new_seal = decodeFoldSeal( + backend->get(store->layout().foldSealKey(st2.snap_generation, st2.snap_attempt))->bytes); + ASSERT_EQ(new_seal.blob_target_runs.size(), 1u); + const RunRef carried = new_seal.blob_target_runs.front(); + EXPECT_EQ(carried.key, parent_ref.key) << "carried ref points at the PARENT generation's run key"; + EXPECT_EQ(carried.checksum, parent_ref.checksum); + EXPECT_EQ(carried.shard, 0u); + EXPECT_EQ(carried.generation, st1.snap_generation) + << "the carried ref names the generation whose key namespace physically holds the object"; +} + +/// The round AFTER a ref-carry, with a real delta, folds THROUGH the carried ref: the new generation's +/// run is produced from the OLD-generation run (resolved via the carried ref, not by key construction) +/// merged with the delta, and the resulting in-degree is correct. +TEST(CASGCFold, FoldResolvesThroughCarriedRef) +{ + auto backend = std::make_shared(); + auto store = openPoolForTest(backend); + const RootNamespace ns{"00/aa@cas@"}; + const ManifestRef r1 = ref("srv-a:1", 1, 0xAA); + writeManifestRaw(*backend, store->layout(), ns, r1, {blobEntryFor("a", DB::UInt128(1))}); + publishCommittedTransition(*backend, store->layout(), ns, "tbl", std::nullopt, r1); + + Gc gc(store, kGc); + gc.runRegularRound(); // gen 1: blob 1 in-degree 1 + EXPECT_EQ(inDegreeOf(*backend, store->layout(), DB::UInt128(1)), 1); + + gc.runRegularRound(); // gen 2: no delta => carries the gen-1 ref + EXPECT_EQ(inDegreeOf(*backend, store->layout(), DB::UInt128(1)), 1) + << "in-degree resolves through the carried parent ref"; + + // A real delta on the NEXT round must fold through the carried ref and drop blob 1 to zero. + const ManifestRef r2 = ref("srv-a:2", 2, 0xBB); + writeManifestRaw(*backend, store->layout(), ns, r2, {blobEntryFor("b", DB::UInt128(2))}); + publishCommittedTransition(*backend, store->layout(), ns, "tbl", r1, r2); + + gc.runRegularRound(); // gen 3: -1 on blob 1 (old owner dropped), +1 on blob 2 + EXPECT_EQ(inDegreeOf(*backend, store->layout(), DB::UInt128(1)), 0) + << "fold through the carried ref applied the -1 correctly"; + EXPECT_EQ(inDegreeOf(*backend, store->layout(), DB::UInt128(2)), 1); +} + +/// previewDeletes resolves runs through the current seal's refs, not by key construction. After a +/// pure ref-carry round the current seal's `blob_target_runs` point at an OLDER generation's key; the +/// preview must open that physical object via the ref and report the correct in-degree — here blob 1 is +/// still referenced, so its carried-ref-resolved in-degree is 1 and it is NOT surfaced as a candidate. +/// (A carried ref that the preview failed to resolve would mis-open the run and either throw or spuriously +/// surface the still-referenced blob.) +TEST(CASGCFold, PreviewResolvesCarriedRef) +{ + auto backend = std::make_shared(); + /// gc_fold_max_defer_rounds=0 forces the idle second round to FOLD (pure ref-carry) rather than + /// DEFER, so the current seal's `blob_target_runs` point at the parent generation's key (the carried + /// ref this test resolves through). + auto store = openPoolForTest(backend, /*gc_fold_max_defer_rounds*/ 0); + const RootNamespace ns{"00/aa@cas@"}; + const ManifestRef r = ref("srv-a:1", 1, 0xAA); + const UInt128 blob = DB::UInt128(1); + writeBlobBody(*backend, store->layout(), blob); + writeManifestRaw(*backend, store->layout(), ns, r, {blobEntryFor("a", blob)}); + publishCommittedTransition(*backend, store->layout(), ns, "tbl", std::nullopt, r); + + Gc gc(store, kGc); + gc.runRegularRound(); // gen 1: blob referenced, in-degree 1 + const auto st1 = decodeGcState(backend->get(store->layout().gcStateKey())->bytes); + + gc.runRegularRound(); // gen 2: no delta, no retired => pure ref-carry (ref points back at gen 1) + const auto st2 = decodeGcState(backend->get(store->layout().gcStateKey())->bytes); + ASSERT_GT(st2.snap_generation, st1.snap_generation); + const auto seal2 = decodeFoldSeal( + backend->get(store->layout().foldSealKey(st2.snap_generation, st2.snap_attempt))->bytes); + ASSERT_EQ(seal2.blob_target_runs.size(), 1u); + ASSERT_EQ(seal2.blob_target_runs.front().generation, st1.snap_generation) + << "the current seal's ref physically lives at the parent generation (carried, not reconstructed)"; + + // The preview resolves the carried ref (a gen-1 physical key) and computes in-degree 1 => blob 1 is + // not a delete candidate. Resolution-by-ref is the property under test. + const auto preview = gc.previewDeletes(); + for (const auto & e : preview) + EXPECT_NE(e.ref, (DB::Cas::BlobRef{DB::Cas::BlobHashAlgo::CityHash128, DB::Cas::BlobDigest::fromU128(blob)})) << "still-referenced blob must not be surfaced (carried ref resolved to in-degree 1)"; + EXPECT_EQ(inDegreeOf(*backend, store->layout(), blob), 1) + << "in-degree through the carried parent ref is 1"; +} + +/// Per-consumer whole-file seal-checksum RED tests (codecs-v3 phase 5, Task 6) at the seal-driven +/// consumers. Setup: fold one referenced blob into a sealed generation, then corrupt the persisted +/// seal's blob_target_runs[0].checksum (the stored run bytes stay valid), so the abort comes from the +/// seal-checksum verify, not a row invariant. +namespace +{ +String corruptSealedRunChecksum(InMemoryBackend & backend, const Layout & layout, const GcState & st) +{ + const String sk = layout.foldSealKey(st.snap_generation, st.snap_attempt); + const auto existing = backend.get(sk); + auto seal = decodeFoldSeal(existing->bytes); + if (seal.blob_target_runs.empty()) + return {}; + const String run_key = seal.blob_target_runs.front().key; + seal.blob_target_runs.front().checksum = seal.blob_target_runs.front().checksum + 1; + backend.putOverwrite(sk, encodeFoldSeal(seal), existing->token); + return run_key; +} +} + +TEST(CASGCFold, PreviewDeletesSealChecksumMismatchFailsClosed) +{ + auto backend = std::make_shared(); + auto store = openPoolForTest(backend); + const RootNamespace ns{"00/aa@cas@"}; + const ManifestRef r = ref("srv-a:1", 1, 0xAA); + const UInt128 blob = DB::UInt128(1); + writeBlobBody(*backend, store->layout(), blob); + writeManifestRaw(*backend, store->layout(), ns, r, {blobEntryFor("a", blob)}); + publishCommittedTransition(*backend, store->layout(), ns, "tbl", std::nullopt, r); + + Gc gc(store, kGc); + gc.runRegularRound(); // seals gen-1 with one blob_target run + const auto st = decodeGcState(backend->get(store->layout().gcStateKey())->bytes); + ASSERT_FALSE(corruptSealedRunChecksum(*backend, store->layout(), st).empty()); + + // A deletion preview must never be derived from an unverified run: fail closed. + Gc gc2(store, kGc); // fresh read of the corrupted seal + EXPECT_THROW(gc2.previewDeletes(), DB::Exception); +} + +TEST(CASGCFold, FsckSealChecksumMismatchCataloguedAndAuditCompletes) +{ + auto backend = std::make_shared(); + auto store = openPoolForTest(backend); + const RootNamespace ns{"00/aa@cas@"}; + const ManifestRef r = ref("srv-a:1", 1, 0xAA); + const UInt128 blob = DB::UInt128(1); + writeBlobBody(*backend, store->layout(), blob); + writeManifestRaw(*backend, store->layout(), ns, r, {blobEntryFor("a", blob)}); + publishCommittedTransition(*backend, store->layout(), ns, "tbl", std::nullopt, r); + replaceRecoverableCkptForRawFixture( + *backend, store->layout(), ns, + RefCkpt{.life_epoch = 1, .committed_through = RefTxnId{1, 1}, + .checkpoint_snapshot_id = std::nullopt, .last_epoch_seal = std::nullopt}); + + Gc gc(store, kGc); + gc.runRegularRound(); + const auto st = decodeGcState(backend->get(store->layout().gcStateKey())->bytes); + + /// A present-but-unreferenced blob (written AFTER the round so GC never touches it) is what makes + /// fsck enter its GC-pipeline classification path (guarded by a non-empty unreferenced set), which + /// is where it streams + seal-checksum-verifies the snapshot runs. + writeBlobBody(*backend, store->layout(), DB::UInt128(2)); + + const String bad_run_key = corruptSealedRunChecksum(*backend, store->layout(), st); + ASSERT_FALSE(bad_run_key.empty()); + + // fsck is a read-only auditor: it must CATALOGUE the corrupt run and COMPLETE, not abort the scan. + FsckReport report; + EXPECT_NO_THROW(report = runFsck(*store, /*detail*/ true)); + EXPECT_GE(report.corrupted_runs, 1u); + bool catalogued = false; + for (const auto & o : report.objects) + if (o.cls == FsckClass::CorruptedRun && o.key == bad_run_key) + catalogued = true; + EXPECT_TRUE(catalogued) << "the corrupt run must be catalogued with its key"; +} + +/// A mid-log clamp must be RECOVERABLE (spec §Step 3 transaction atomicity). A single log carrying two +/// ops -- [drop committed A (a `-1` whose body is present at removal-fold), add precommit B (whose body is +/// transiently absent)] -- clamps on B. The `-1` on A must NOT be merged into the round's owner-removed +/// cleanup, because the post-CAS body delete would then reclaim A's body while A's edge stays unfolded +/// behind the clamp; the next re-fold of that same log would then find A's body missing and clamp forever +/// (a permanent pool-wide destructive freeze). With per-log staging, A's body survives the clamp round and +/// the log folds cleanly once B's body reappears. +TEST(CASGCFold, MidLogClampPreservesEarlierRemovalBodyAndRecovers) +{ + auto backend = std::make_shared(); + auto store = openPoolForTest(backend, /*gc_fold_max_defer_rounds*/ 0); + const RootNamespace ns{"00/aa@cas@"}; + const ManifestRef a = ref("srv-a:1", 1, 0xAA); + const ManifestRef b = ref("srv-a:2", 2, 0xBB); + + /// Round 0: commit A (references blob 1). A's body is present and folds a +1. + writeManifestRaw(*backend, store->layout(), ns, a, {blobEntryFor("a", DB::UInt128(1))}); + publishCommittedTransition(*backend, store->layout(), ns, "r1", std::nullopt, a); + Gc gc(store, kGc); + gc.runRegularRound(); + EXPECT_EQ(inDegreeOf(*backend, store->layout(), DB::UInt128(1)), 1); + + /// ONE log with two ops: drop committed A (`-1`, body present), then add precommit B (`+1`, body + /// staged then removed => a transient 404 clamps the log after A's `-1` already folded). + writeManifestRaw(*backend, store->layout(), ns, b, {blobEntryFor("b", DB::UInt128(2))}); + deleteManifestBody(*backend, store->layout(), ManifestId{ns, b}); // B's body absent => clamp + const uint64_t log_seq = appendRefLogSeed(*backend, store->layout(), ns, + {ownerTransitionOp(RefOwnerBinding{RefOwnerKind::Committed, "r1", a}, std::nullopt), + ownerTransitionOp(std::nullopt, RefOwnerBinding{RefOwnerKind::Precommit, "r2", b})}); + advanceRecoverableCkptForRawFixture(*backend, store->layout(), ns, RefTxnId{1, log_seq}); + + const RoundReport clamp_report = gc.runRegularRound(); + EXPECT_TRUE(clamp_report.hasAnomaly(ns, /*shard*/0)) << "the missing B body must clamp this log"; + EXPECT_LT(foldCursorOf(*backend, store->layout(), ns, 0), log_seq) << "the clamp halts the cursor below the log"; + EXPECT_TRUE(backend->head(store->layout().manifestKey(ManifestId{ns, a})).exists) + << "A's body must survive the clamp round: its `-1` was staged, not merged, so no post-CAS delete " + "reclaimed it -- otherwise the re-fold would clamp on A's missing body forever"; + EXPECT_EQ(inDegreeOf(*backend, store->layout(), DB::UInt128(1)), 1) << "A's `-1` was not adopted (clamp)"; + + /// The transient 404 heals: B's body reappears. The next round re-folds the SAME log cleanly. + writeManifestRaw(*backend, store->layout(), ns, b, {blobEntryFor("b", DB::UInt128(2))}); + const RoundReport clean_report = gc.runRegularRound(); + EXPECT_FALSE(clean_report.hasAnomaly(ns, /*shard*/0)) << "with both bodies present the log folds; no clamp"; + EXPECT_GE(foldCursorOf(*backend, store->layout(), ns, 0), log_seq) << "the cursor advanced past the recovered log"; + EXPECT_EQ(inDegreeOf(*backend, store->layout(), DB::UInt128(1)), 0) << "A's `-1` applied"; + EXPECT_EQ(inDegreeOf(*backend, store->layout(), DB::UInt128(2)), 1) << "B's `+1` applied"; +} + +/// A `+1` precommit whose body is PERMANENTLY absent and whose build is below the durable watermark floor +/// (provably dead -- the exact fact the orphan sweep uses to reclaim the body) must be SKIPPED, not held on +/// the fold barrier forever. Without a terminal rule this table clamps every round with no resolution (a +/// late-predecessor precommit whose body was already reclaimed). The watermark is seeded so the precommit's +/// build is dead; the fold must advance the cursor past the log and record no clamp anomaly. +TEST(CASGCFold, DeadPrecommitWithMissingBodyIsSkippedNotClampedForever) +{ + auto backend = std::make_shared(); + auto store = openPoolForTest(backend, /*gc_fold_max_defer_rounds*/ 0); + /// The namespace's server-root prefix is "srv"; seed its watermark floor so build_sequence 5 is retired. + const RootNamespace ns{"srv/tbl"}; + setWatermarkMinActive(*backend, store->layout(), "srv", /*writer_epoch*/1, /*min_active*/10); + + /// A precommit naming a build (writer_epoch 1, build_sequence 5) whose body is never written. + const ManifestRef dead = ManifestRef{.writer_epoch = 1, .build_sequence = 5, .manifest_ordinal = 1}; + const uint64_t log_seq = + addPrecommitTransition(*backend, store->layout(), ns, DB::UInt128(7), "r1", std::nullopt, dead); + + Gc gc(store, kGc); + const RoundReport report = gc.runRegularRound(); + EXPECT_FALSE(report.hasAnomaly(ns, /*shard*/0)) + << "a provably-dead precommit's missing body is skipped, not clamped"; + EXPECT_GE(foldCursorOf(*backend, store->layout(), ns, 0), log_seq) + << "the fold advanced past the log instead of holding the barrier forever"; + + /// A second identical round stays clean (terminal resolution, not a recurring clamp). + const RoundReport report2 = gc.runRegularRound(); + EXPECT_FALSE(report2.hasAnomaly(ns, /*shard*/0)) << "the resolution is terminal: no recurring clamp"; +} + +/// A10: a single clamp anomaly must suppress ALL destructive actions in the round — the merge-side +/// deletes AND the post-CAS ref/namespace cleanup — from ONE decision, not two independent recomputes +/// of !report.anomalies.empty() that a future edit could desync (over-delete class). This pins that a +/// clamped round reclaims nothing. +TEST(CASGCFold, SingleAnomalySuppressesEveryDestructiveActionInTheRound) +{ + auto backend = std::make_shared(); + auto store = openPoolForTest(backend, /*gc_fold_max_defer_rounds*/ 0); + const RootNamespace ns{"00/aa@cas@"}; + const ManifestRef a = ref("srv-a:1", 1, 0xAA); + const ManifestRef b = ref("srv-a:2", 2, 0xBB); + + /// Round 0: commit A (references blob 1); its body folds a +1. + writeManifestRaw(*backend, store->layout(), ns, a, {blobEntryFor("a", DB::UInt128(1))}); + publishCommittedTransition(*backend, store->layout(), ns, "r1", std::nullopt, a); + Gc gc(store, kGc); + gc.runRegularRound(); + ASSERT_EQ(inDegreeOf(*backend, store->layout(), DB::UInt128(1)), 1); + + /// One log: drop committed A (`-1`, body present) then add precommit B whose body is absent -> the + /// missing B body clamps the log AFTER A's `-1` folded. + writeManifestRaw(*backend, store->layout(), ns, b, {blobEntryFor("b", DB::UInt128(2))}); + deleteManifestBody(*backend, store->layout(), ManifestId{ns, b}); + const uint64_t log_seq = appendRefLogSeed(*backend, store->layout(), ns, + {ownerTransitionOp(RefOwnerBinding{RefOwnerKind::Committed, "r1", a}, std::nullopt), + ownerTransitionOp(std::nullopt, RefOwnerBinding{RefOwnerKind::Precommit, "r2", b})}); + advanceRecoverableCkptForRawFixture(*backend, store->layout(), ns, RefTxnId{1, log_seq}); + + const RoundReport rep = gc.runRegularRound(); + ASSERT_TRUE(rep.hasAnomaly(ns, /*shard*/0)) << "the missing B body must clamp this round"; + /// The clamp suppresses the WHOLE destructive pipeline this round: no deletes, no redeletes, and + /// A's `-1` stays unadopted (its body must survive, else the re-fold clamps on it forever). + EXPECT_EQ(rep.deleted, 0u); + EXPECT_EQ(rep.redeleted, 0u); + EXPECT_EQ(rep.graduated, 0u); + EXPECT_TRUE(backend->head(store->layout().manifestKey(ManifestId{ns, a})).exists); + EXPECT_EQ(inDegreeOf(*backend, store->layout(), DB::UInt128(1)), 1); +} + +/// A10 follow-up: the round-side destructive gates -- the perpetual dead-life janitor AND +/// `cleanupRefObjects`' covered ref-object deletion -- must ALSO honor the round's ONE +/// `suppress_destructive` decision, not just fold()'s merge-side reducers pinned above. A clamp anomaly in +/// one namespace must suppress destructive cleanup POOL-WIDE: dead-life physical debris must not be +/// swept, and an unrelated live +/// table's snapshot-covered ref-log must not be deleted, in the SAME clamped round. A clean round +/// afterward proves the setup really was cleanup-eligible, not vacuously untouched. +TEST(CASGCFold, RoundSideAnomalySuppressesRefLogCleanupWhileRemovalDebrisStaysJanitorWork) +{ + auto backend = std::make_shared(); + auto store = openPoolForTest(backend, /*gc_fold_max_defer_rounds*/ 0); + const Layout & layout = store->layout(); + Gc gc(store, kGc); + + /// Namespace 1: the clamp trigger (same construction as + /// SingleAnomalySuppressesEveryDestructiveActionInTheRound above). + const RootNamespace ns_clamp{"00/aa@cas@"}; + const ManifestRef a = ref("srv-a:1", 1, 0xAA); + const ManifestRef b = ref("srv-a:2", 2, 0xBB); + writeManifestRaw(*backend, layout, ns_clamp, a, {blobEntryFor("a", DB::UInt128(1))}); + publishCommittedTransition(*backend, layout, ns_clamp, "r1", std::nullopt, a); + runRegularRoundReclaiming(gc); /// folds A cleanly; establishes the baseline before the clamp + + /// Namespace 2: a namespace mid-removal with physical manifest and verbatim-file debris. Generation + /// 7 has no lifecycle-specific cleanup pass: terminal folding records evidence, while these bytes + /// remain inert work for the perpetual janitor and orphan-manifest sweep. + const RootNamespace ns_removed{"00/cc@cas@"}; + RefOp remove_op; + remove_op.kind = RefOpKind::RemoveNamespace; + const uint64_t removal_log_seq = appendRefLogSeed(*backend, layout, ns_removed, {remove_op}); + writeRecoverableCkptForRawFixture( + *backend, layout, ns_removed, + RefCkpt{.life_epoch = 1, .committed_through = RefTxnId{1, removal_log_seq}, + .checkpoint_snapshot_id = std::nullopt, .last_epoch_seal = std::nullopt}); + /// Keyed at the life the CATALOG names for this namespace (`appendRefLogSeed` admitted it above), + /// which is the physical life that owns the eventual janitor work. Spelling the sentinel here instead + /// would plant debris under the wrong life and make the retention assertion vacuous. + const String debris_key + = layout.namespaceFilesPrefix(CasRefCatalog::lifeIfCataloged(*backend, layout, ns_removed).value()) + + "leftover_verbatim_file"; + backend->putIfAbsent(debris_key, "debris"); + const ManifestRef removed_body = ref("srv-r:1", 1, 0xEE); + writeManifestRaw(*backend, layout, ns_removed, removed_body, {blobEntryFor("r", DB::UInt128(9))}); + const String debris_manifest_key = layout.manifestKey(ManifestId{ns_removed, removed_body}); + + /// Namespace 3: a live table with an exact checkpoint-named recovery triple -- exactly what a + /// clamp-free round's `cleanupRefObjects` may clean below that base. + const RootNamespace ns_covered{"00/dd@cas@"}; + const ManifestRef c1 = ref("srv-c:1", 1, 0xCC); + const ManifestRef c2 = ref("srv-c:2", 2, 0xDD); + writeManifestRaw(*backend, layout, ns_covered, c1, {blobEntryFor("c", DB::UInt128(3))}); + writeManifestRaw(*backend, layout, ns_covered, c2, {blobEntryFor("d", DB::UInt128(4))}); + const uint64_t cv1 = publishCommittedTransition(*backend, layout, ns_covered, "t1", std::nullopt, c1); + const uint64_t cv2 = publishCommittedTransition(*backend, layout, ns_covered, "t2", std::nullopt, c2); + writeRefSnapshotRaw(*backend, layout, minimalLiveSnapshot(ns_covered.string(), RefTxnId{1, cv2}, + {committedRow("t1", c1), committedRow("t2", c2)})); + replaceRecoverableCkptForRawFixture(*backend, layout, ns_covered, RefCkpt{ + .life_epoch = 1, + .committed_through = RefTxnId{1, cv2}, + .checkpoint_snapshot_id = RefTxnId{1, cv2}, + .last_epoch_seal = std::nullopt, + }); + const String covered_log_key = layout.refLogKey(fixture::fixtureLife(ns_covered), RefTxnId{1, cv1}); + ASSERT_TRUE(backend->head(covered_log_key).exists); + + /// Trigger the clamp in ns_clamp: drop committed A, add precommit B whose body is absent. + writeManifestRaw(*backend, layout, ns_clamp, b, {blobEntryFor("b", DB::UInt128(2))}); + deleteManifestBody(*backend, layout, ManifestId{ns_clamp, b}); + const uint64_t clamp_log_seq = appendRefLogSeed(*backend, layout, ns_clamp, + {ownerTransitionOp(RefOwnerBinding{RefOwnerKind::Committed, "r1", a}, std::nullopt), + ownerTransitionOp(std::nullopt, RefOwnerBinding{RefOwnerKind::Precommit, "r2", b})}); + advanceRecoverableCkptForRawFixture(*backend, layout, ns_clamp, RefTxnId{1, clamp_log_seq}); + + const RoundReport rep = runRegularRoundReclaiming(gc); + ASSERT_TRUE(rep.hasAnomaly(ns_clamp, /*shard*/0)) << "the missing B body must clamp this round"; + EXPECT_EQ(rep.deleted, 0u); + EXPECT_EQ(rep.redeleted, 0u); + EXPECT_EQ(rep.graduated, 0u); + + /// Removal folding never performs lifecycle-specific physical cleanup, with or without a clamp. + EXPECT_TRUE(backend->head(debris_manifest_key).exists) + << "removed manifest debris remains ordinary orphan-sweep work"; + EXPECT_TRUE(backend->head(debris_key).exists) + << "removed verbatim-file debris remains ordinary janitor work"; + + /// `cleanupRefObjects` must not have deleted anything anywhere this round. + EXPECT_TRUE(backend->head(covered_log_key).exists) + << "a clamp anywhere in the round must suppress ref-log cleanup pool-wide, even for an unrelated live table"; + + /// Heal the clamp and run a clean round. Ordinary ref-log cleanup resumes, while removal debris + /// remains physically untouched by the lifecycle path. + writeManifestRaw(*backend, layout, ns_clamp, b, {blobEntryFor("b", DB::UInt128(2))}); + const RoundReport clean_rep = runRegularRoundReclaiming(gc); + EXPECT_FALSE(clean_rep.hasAnomaly(ns_clamp, /*shard*/0)); + EXPECT_TRUE(backend->head(debris_manifest_key).exists) + << "a clamp-free fold still performs no lifecycle-specific manifest deletion"; + EXPECT_TRUE(backend->head(debris_key).exists) + << "a clamp-free fold still performs no lifecycle-specific verbatim-file deletion"; + EXPECT_FALSE(backend->head(covered_log_key).exists) << "a clamp-free round cleans the covered ref-log"; +} diff --git a/src/Disks/tests/gtest_cas_gc_frontier_gate.cpp b/src/Disks/tests/gtest_cas_gc_frontier_gate.cpp new file mode 100644 index 000000000000..56a793157b81 --- /dev/null +++ b/src/Disks/tests/gtest_cas_gc_frontier_gate.cpp @@ -0,0 +1,3481 @@ +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include "cas_test_helpers.h" + +#include + +#include +#include +#include +#include +#include +#include +#include + +namespace DB::ErrorCodes +{ + extern const int NETWORK_ERROR; + extern const int CORRUPTED_DATA; +} + +/// THE DESTRUCTIVE-ROUND FRONTIER PROOF (spec 2026-07-27 "ref chain complete cut" §5). +/// +/// Reachability is a property of the WHOLE POOL. A blob is unreferenced only if no namespace anywhere +/// owns an edge to it, so a round that deletes one is asserting something about every namespace at +/// once -- including the ones it never looked at. Task 7 made the per-namespace half of that assertion +/// cheap and exact: one `GET` at the cursor's arithmetic successor, absent means end-of-stream. Task 8 +/// made a namespace that could NOT be walked say so durably. What neither can supply is the SET those +/// proofs have to cover, and that is what this task is about. +/// +/// So the gate has three terms, and a round destroys only when all three are clear: +/// +/// suppress_destructive = any anomaly this round +/// OR any hold the seal carries +/// OR the frontier is incomplete +/// +/// The second term is STRUCTURAL. Every hold recorded today also records an anomaly, so the first term +/// happens to imply it -- but the invariant is the hold SET, not that coincidence, and the gate reads +/// the seal directly so that a future change to anomaly recording cannot quietly open it. +/// +/// The third term is the SET, and only the catalog supplies it. The scenario that makes that so is the +/// one these tests open with: a hidden acked `+1` in a namespace no listing mentions and no sealed cursor +/// names, while a visible `-1` elsewhere drives the shared blob's OBSERVABLE in-degree to zero. Every +/// proof the round holds comes back clean and the blob is still owned. It survives because the round's +/// universe is the catalog's `Live`/`Removing` set, so that namespace is a member the round owes a proof +/// for and cannot supply one -- neither the listing's silence nor the missing cursor can shrink the set. +/// +/// The tests here come in two shapes. Ones whose subject is the OPEN gate run on the production path, +/// with no policy argument at all. Ones whose subject is a SUPPRESSOR either pass `StageA_Suppressed` +/// explicitly or arrange the suppressing condition on the pool, and assert every delete family inert PER +/// FAMILY -- an aggregate zero can hide one family running while another did not. + +using namespace DB::Cas; +using namespace DB::Cas::tests; + +namespace ProfileEvents +{ +extern const Event CASGCRefWalkPlansBuilt; +extern const Event CASGCUnmatchedAdoptedParentLives; +extern const Event CASGCNamespaceCleanupLeaks; +} + +namespace +{ + +const UInt128 kGc = hexToU128("00000000000000000000000000000001"); + +/// The lying store, shared from `cas_test_helpers.h`: every key is served by exact GET while the +/// selected ones are HIDDEN from every LIST. That is the only way to build the cross-namespace +/// scenario -- the hidden namespace's records stay durable and readable, so a round that KNOWS to +/// look for them finds them, while a round that only enumerates never learns they exist. Composed +/// over `CountingBackend` because these tests also assert request counts. +using CountingHintHoleBackend = DB::Cas::tests::HintHoleBackendOn; + +class DrainRaceBackend final : public CountingBackend +{ +public: + using CountingBackend::casPut; + using CountingBackend::get; + using CountingBackend::putIfAbsent; + + void blockNextCatalogCas(const String & key) + { + std::lock_guard lock(control_mutex); + catalog_key = key; + block_next_catalog_cas = true; + } + + void loseNextCatalogCasResponse(const String & key) + { + std::lock_guard lock(control_mutex); + catalog_key = key; + lose_next_catalog_cas_response = true; + } + + void conflictNextCatalogCas(const String & key) + { + std::lock_guard lock(control_mutex); + catalog_key = key; + conflict_next_catalog_cas = true; + } + + void waitForBlockedCatalogCas() + { + std::unique_lock lock(control_mutex); + control_cv.wait(lock, [&] { return catalog_cas_blocked; }); + } + + void releaseBlockedCatalogCas() + { + std::lock_guard lock(control_mutex); + release_catalog_cas = true; + control_cv.notify_all(); + } + + void clearJournal() + { + std::lock_guard lock(journal_mutex); + journal.clear(); + } + + std::vector journalSnapshot() const + { + std::lock_guard lock(journal_mutex); + return journal; + } + + std::optional get(const String & key, Range range) override + { + record("get " + key); + return CountingBackend::get(key, range); + } + + ListPage list(const String & prefix, const String & cursor, size_t limit) override + { + record("list " + prefix); + return CountingBackend::list(prefix, cursor, limit); + } + + PutResult putIfAbsent(const String & key, const String & bytes, const ObjectMeta & meta) override + { + record("put_begin " + key); + const PutResult result = CountingBackend::putIfAbsent(key, bytes, meta); + record("put_end " + key); + return result; + } + + CasResult casPut( + const String & key, const String & bytes, const std::optional & expected, + const ObjectMeta & meta) override + { + record("cas_begin " + key); + bool lose_response = false; + bool force_conflict = false; + { + std::unique_lock lock(control_mutex); + if (key == catalog_key && block_next_catalog_cas) + { + block_next_catalog_cas = false; + catalog_cas_blocked = true; + control_cv.notify_all(); + control_cv.wait(lock, [&] { return release_catalog_cas; }); + } + if (key == catalog_key && lose_next_catalog_cas_response) + { + lose_next_catalog_cas_response = false; + lose_response = true; + } + if (key == catalog_key && conflict_next_catalog_cas) + { + conflict_next_catalog_cas = false; + force_conflict = true; + } + } + if (force_conflict) + { + record("cas_forced_conflict " + key); + return {.outcome = CasOutcome::Conflict, .token = {}}; + } + const CasResult result = CountingBackend::casPut(key, bytes, expected, meta); + record("cas_end " + key); + if (lose_response && result.outcome == CasOutcome::Committed) + { + record("cas_response_lost " + key); + throw std::runtime_error("injected lost catalog CAS response"); + } + return result; + } + +private: + void record(String entry) const + { + std::lock_guard lock(journal_mutex); + journal.push_back(std::move(entry)); + } + + mutable std::mutex journal_mutex; + mutable std::vector journal; + std::mutex control_mutex; + std::condition_variable control_cv; + String catalog_key; + bool block_next_catalog_cas = false; + bool catalog_cas_blocked = false; + bool release_catalog_cas = false; + bool lose_next_catalog_cas_response = false; + bool conflict_next_catalog_cas = false; +}; + +class PostFoldUnreadableTerminalBackend final : public CountingBackend +{ +public: + ListPage list(const String & prefix, const String & cursor, size_t limit) override + { + ListPage page = CountingBackend::list(prefix, cursor, limit); + if (prefix.ends_with("/cas/ns/")) + for (ListedKey & listed : page.keys) + listed.token.reset(); + return page; + } + + HeadResult head(const String & key) override + { + if (key == unreadable_key) + throw std::runtime_error("injected post-fold terminal read failure for " + key); + return CountingBackend::head(key); + } + + void makeUnreadable(String key) + { + unreadable_key = std::move(key); + } + + bool existsIgnoringFault(const String & key) + { + return CountingBackend::head(key).exists; + } + +private: + String unreadable_key; +}; + +class ScopedCasGcLogCapture +{ +public: + ScopedCasGcLogCapture() + : logger(getLogger("CasGc")) + , channel(new Poco::StreamChannel(stream)) + , old_channel(logger->getChannel()) + , old_level(logger->getLevel()) + { + logger->setChannel(channel.get()); + logger->setLevel("warning"); + } + + ~ScopedCasGcLogCapture() + { + logger->setChannel(old_channel); + logger->setLevel(old_level); + } + + String captured() const + { + return stream.str(); + } + +private: + LoggerPtr logger; + std::ostringstream stream; // STYLE_CHECK_ALLOW_STD_STRING_STREAM + Poco::AutoPtr channel; + Poco::Channel * old_channel; + int old_level; +}; + +struct CompletedRemovingFixture +{ + RootNamespace ns; + UInt128 life_id{}; + String checkpoint_key; + String checkpoint_bytes; +}; + +CompletedRemovingFixture seedCompletedRemoving( + DrainRaceBackend & backend, const PoolPtr & store, const UInt128 & lease_owner) +{ + const Layout & layout = store->layout(); + CompletedRemovingFixture fixture{ + .ns = RootNamespace{"00/drain-race@cas@"}, + .life_id = UInt128{177}, + .checkpoint_key = {}, + .checkpoint_bytes = {}}; + CasRefCatalog::casAdmitEntry(backend, layout, store->poolConfig().gc_shards, CatalogEntry{ + .ns = fixture.ns, .state = NsState::Live, .incarnation = fixture.life_id}); + fixture.checkpoint_key = layout.refCkptKey( + NamespaceLifeId::fromCatalogEntry(fixture.ns, fixture.life_id)); + fixture.checkpoint_bytes = encodeRefCkpt(RefCkpt{ + .life_epoch = 1, + .committed_through = std::nullopt, + .checkpoint_snapshot_id = std::nullopt, + .last_epoch_seal = std::nullopt, + }); + backend.putIfAbsent(fixture.checkpoint_key, fixture.checkpoint_bytes); + EXPECT_TRUE(store->namespaceFilesLifeIfReadable(fixture.ns)); + CasRefCatalog::casUpdate(backend, layout, [](const RefCatalog & current) + { + RefCatalog next = current; + next.entries[0].state = NsState::Removing; + next.entries[0].removal_started_round = 1; + return next; + }); + + CasFoldSeal parent; + parent.generation = 1; + parent.ref_lives.emplace(fixture.life_id, RefLifeFoldState{ + .coverage = RefCoverage{.classification = 2, .last_folded_ref_id = RefTxnId{1, 1}}, + .cleanup_evidence = RefCleanupEvidence{.remove_txn_id = RefTxnId{1, 1}}}); + for (uint64_t shard = 0; shard < store->poolConfig().gc_shards; ++shard) + parent.condemned_summary.emplace(shard, CondemnedSummary{}); + backend.putIfAbsent(layout.foldSealKey(1, 1), encodeFoldSeal(parent)); + + GcState state; + state.round = 1; + state.gc_shards = store->poolConfig().gc_shards; + state.snap_generation = 1; + state.snap_attempt = 1; + state.lease = GcLease{.owner = lease_owner, .seq = 1}; + backend.putIfAbsent(layout.gcStateKey(), encodeGcState(state)); + + return fixture; +} + +void seedCompletedRemovingBatch( + DrainRaceBackend & backend, const PoolPtr & store, const UInt128 & lease_owner, size_t count) +{ + const Layout & layout = store->layout(); + std::vector entries; + entries.reserve(count); + for (size_t i = 0; i < count; ++i) + { + CatalogEntry entry{ + .ns = RootNamespace{fmt::format("00/drain-batch-{}@cas@", i)}, + .state = NsState::Live, + .incarnation = UInt128{200 + i}}; + CasRefCatalog::casAdmitEntry(backend, layout, store->poolConfig().gc_shards, entry); + entries.push_back(std::move(entry)); + } + CasRefCatalog::casUpdate(backend, layout, [](const RefCatalog & current) + { + RefCatalog next = current; + for (CatalogEntry & entry : next.entries) + { + entry.state = NsState::Removing; + entry.removal_started_round = 1; + } + return next; + }); + + CasFoldSeal parent; + parent.generation = 1; + for (const CatalogEntry & entry : entries) + parent.ref_lives.emplace(entry.incarnation, RefLifeFoldState{ + .coverage = RefCoverage{.classification = 2, .last_folded_ref_id = RefTxnId{1, 1}}, + .cleanup_evidence = RefCleanupEvidence{.remove_txn_id = RefTxnId{1, 1}}}); + for (uint64_t shard = 0; shard < store->poolConfig().gc_shards; ++shard) + parent.condemned_summary.emplace(shard, CondemnedSummary{}); + ASSERT_EQ(backend.putIfAbsent(layout.foldSealKey(1, 1), encodeFoldSeal(parent)).outcome, + PutOutcome::Done); + + GcState state; + state.round = 1; + state.gc_shards = store->poolConfig().gc_shards; + state.snap_generation = 1; + state.snap_attempt = 1; + state.lease = GcLease{.owner = lease_owner, .seq = 1}; + ASSERT_EQ(backend.putIfAbsent(layout.gcStateKey(), encodeGcState(state)).outcome, PutOutcome::Done); +} + +enum class CompetingCatalogOutcome : uint8_t +{ + Absent, + Replacement, +}; + +class CASGCCompletedRemovalFenceRace : public testing::TestWithParam +{ +}; + +void transferGcLease(DrainRaceBackend & backend, const Layout & layout, const UInt128 & new_owner) +{ + const auto got = backend.get(layout.gcStateKey()); + ASSERT_TRUE(got); + GcState state = decodeGcState(got->bytes); + state.lease.owner = new_owner; + ++state.lease.seq; + ASSERT_EQ(backend.casPut(layout.gcStateKey(), encodeGcState(state), got->token).outcome, + CasOutcome::Committed); +} + +size_t findJournalAfter(const std::vector & journal, const String & entry, size_t after) +{ + const auto it = std::find(journal.begin() + static_cast(after), journal.end(), entry); + return it == journal.end() ? journal.size() : static_cast(it - journal.begin()); +} + +/// A pool whose GC frontier-probe budget is set explicitly. Everything else matches `openPoolForTest`. +PoolPtr openPoolWithProbeBudget(std::shared_ptr backend, uint64_t budget) +{ + return Pool::open(std::move(backend), + PoolConfig{.pool_prefix = "p", .server_root_id = "test", + .gc_frontier_probe_budget = budget, .gc_fold_max_defer_rounds = 0}); +} + +/// Publish `ref_name` in `ns` pinning `blob`, allocating the next ref-log id. Writes the blob body and +/// the manifest body too, so the published edge is one GC can actually fold. +ManifestRef publish(Backend & backend, const Layout & layout, const RootNamespace & ns, + const String & ref_name, uint64_t build_sequence, const DB::UInt128 & blob) +{ + const ManifestRef mref{.writer_epoch = 1, .build_sequence = build_sequence, .manifest_ordinal = 1}; + writeBlobBody(backend, layout, blob); + writeManifestRaw(backend, layout, ns, mref, {blobEntryFor("data.bin", blob)}); + publishCommittedTransition(backend, layout, ns, ref_name, std::nullopt, mref); + return mref; +} + +/// The blob key for a raw hash, as the tests spell it. +String blobKeyOf(const Layout & layout, const DB::UInt128 & hash) +{ + return layout.blobKey(legacyMetaTestRef(hash)); +} + +/// The sealed fold cursor for `ns` as a full `RefTxnId`. Every seed here allocates `writer_epoch = 1`, +/// which is what `foldCursorOf` (returning the sequence alone) assumes too. +RefTxnId sealedCursorOf(Backend & backend, const Layout & layout, const RootNamespace & ns) +{ + return RefTxnId{1, foldCursorOf(backend, layout, ns, /*shard*/ 0)}; +} + +/// Drive `rounds` GC rounds under the given policy, renewing the store's watermark between them the way +/// the production scheduler does. +void drive(const PoolPtr & store, Gc & gc, int rounds, UniversePolicy policy) +{ + for (int i = 0; i < rounds; ++i) + { + gc.runRegularRound({}, /*allow_steal*/true, policy); + store->renewWatermarkOnce(); + } +} + +/// Every key the backend was asked to delete, rendered for a failing assertion's message. +String deletedKeysMessage(const CountingBackend & backend) +{ + String out; + for (const String & key : backend.deletedKeys()) + out += "\n " + key; + return out.empty() ? String{" (none)"} : out; +} + +/// The gate's own verdict for one round, READ OFF THE PHASE ROWS rather than recomputed in the test. A +/// test that re-derived `frontier_complete` from the tally would agree with a wrong formula just as +/// readily as with the right one. +struct GateVerdict +{ + bool saw_fold = false; + bool frontier_complete = false; + bool suppress_destructive = false; + uint64_t frontier_namespaces = 0; + uint64_t frontier_proven = 0; + uint64_t frontier_unprobed_budget = 0; + uint64_t catalog_entries = 0; + bool catalog_proved_empty = false; +}; + +GateVerdict runRoundCapturingGate(const PoolPtr & store, Gc & gc, UniversePolicy policy) +{ + GateVerdict verdict; + gc.setPhaseSink([&](const GcPhaseRecord & rec) + { + const auto value = [&](const char * name) -> std::optional + { + const auto it = rec.metrics.find(name); + return it == rec.metrics.end() ? std::nullopt : std::optional{it->second}; + }; + if (rec.phase == "fold_reduce") + { + if (const auto complete = value("frontier_complete")) + { + verdict.saw_fold = true; + verdict.frontier_complete = *complete != 0; + } + if (const auto suppress = value("suppress_destructive")) + verdict.suppress_destructive = *suppress != 0; + } + else if (rec.phase == "fold_ref_intake") + { + if (const auto total = value("frontier_namespaces")) + verdict.frontier_namespaces = *total; + if (const auto proven = value("frontier_proven")) + verdict.frontier_proven = *proven; + if (const auto unprobed = value("frontier_unprobed_budget")) + verdict.frontier_unprobed_budget = *unprobed; + if (const auto entries = value("catalog_entries")) + verdict.catalog_entries = *entries; + if (const auto proved_empty = value("catalog_proved_empty")) + verdict.catalog_proved_empty = *proved_empty != 0; + } + }); + gc.runRegularRound({}, /*allow_steal*/true, policy); + gc.setPhaseSink({}); + store->renewWatermarkOnce(); + return verdict; +} + +/// Every delete family a round can reach, asserted PER FAMILY: an aggregate zero can hide one family +/// running while another did not. +void expectEveryDeleteFamilyInert(const CountingBackend & backend, const char * where) +{ + EXPECT_EQ(backend.deleteCountForKeysContaining("/blobs/"), 0u) << where << ": blob delete"; + EXPECT_EQ(backend.deleteCountForKeysContaining("/cas/manifests/"), 0u) + << where << ": manifest-body delete"; + EXPECT_EQ(backend.deleteCountForKeysContaining("/gc/gen/"), 0u) + << where << ": generation prune and hand-off reclaim"; + EXPECT_EQ(backend.deleteCountForKeysContaining("/cas/ns/stream/"), 0u) + << where << ": covered-log / superseded-snapshot cleanup"; + EXPECT_EQ(backend.deleteTotal(), 0u) + << where << ": a family not named above also ran. Deleted:" << deletedKeysMessage(backend); +} + +} + +/// ===================== A HIDDEN `+1` IN AN UNKNOWN NAMESPACE ===================== +/// +/// Two namespaces share one blob. `visible` publishes it and then drops it, so the round observes +/// `+1` then `-1` and reads the blob's in-degree as zero. `hidden` also owns it -- durably, acked, +/// readable by exact key -- but is absent from the round's LIST hint. Its own publish still carries a +/// real checkpoint, so the arithmetic walk finds and folds its `+1` by exact key regardless of what the +/// LIST omits. +/// +/// The three arms below are the whole argument: the exact-key probe finds `hidden`'s edge and saves the +/// blob on a complete frontier; the blob still drains once `hidden` also honestly folds its own removal; +/// and a namespace inside the universe with a sealed cursor has its hidden `+1` found by the exact-key +/// probe the same way. + +namespace +{ +/// Build the shared-blob scenario. `hidden` owns `blob` and is hidden from every LIST; `visible` +/// publishes and drops it. Returns the pool. +PoolPtr buildCrossNamespaceScenario(const std::shared_ptr & backend, + const RootNamespace & hidden, const RootNamespace & visible, + const DB::UInt128 & blob, bool fold_hidden_first) +{ + auto store = openPoolForTest(backend, /*gc_fold_max_defer_rounds=*/0); + const Layout & layout = store->layout(); + + if (fold_hidden_first) + { + /// Give the hidden namespace a sealed cursor, WITHOUT folding the edge under test. It publishes + /// an unrelated blob and one round folds that; from then on the namespace is in the universe via + /// its cursor even after the hint stops naming it. + /// + /// The unrelated blob is what makes this arm mean anything: if the shared blob's `+1` had + /// already been folded by the seeding round, the blob would survive on the DURABLE in-degree and + /// the test would pass whether or not the round probes anything. Publishing it only AFTER the + /// seal puts it strictly above the cursor, so the probe is the one and only thing that can find + /// it. + publish(*backend, layout, hidden, "seed_ref", 7, DB::UInt128(0x5eed)); + Gc seed(store, kGc); + seed.runRegularRound(); + store->renewWatermarkOnce(); + } + + publish(*backend, layout, hidden, "kept_ref", 1, blob); + backend->hidePrefix(layout.namespaceStreamPrefix(fixture::fixtureLife(hidden))); + + const ManifestRef dropped = publish(*backend, layout, visible, "dropped_ref", 2, blob); + dropRefTransition(*backend, layout, visible, "dropped_ref", dropped); + return store; +} +} + +/// Rounds on the PRODUCTION path -- no policy argument -- because that is what the claim is about. +/// `hidden`'s own publish left it a real checkpoint, so the arithmetic walk's exact-key probe finds and +/// folds its `+1` no matter what the LIST hides: the blob survives on its own complete, proven frontier, +/// not on the round declining to touch anything. +TEST(CASGCFrontierGate, AHiddenEdgeIsFoundByTheExactKeyProbeAndSavesTheBlobOnACompleteFrontier) +{ + auto backend = std::make_shared(); + const RootNamespace hidden{"00/hidden@cas@"}; + const RootNamespace visible{"00/visible@cas@"}; + const DB::UInt128 blob(0x5ade); + + auto store = buildCrossNamespaceScenario(backend, hidden, visible, blob, /*fold_hidden_first=*/false); + const Layout & layout = store->layout(); + + Gc gc(store, kGc); + backend->resetCounts(); + GateVerdict verdict; + for (int i = 0; i < 5; ++i) + { + const GateVerdict round = runRoundCapturingGate(store, gc, UniversePolicy::kDefault); + if (round.saw_fold) + verdict = round; + store->renewWatermarkOnce(); + } + + ASSERT_TRUE(verdict.saw_fold) << "no round folded, so none published a gate verdict"; + EXPECT_TRUE(backend->head(blobKeyOf(layout, blob)).exists) + << "the blob a hidden namespace still owns must survive"; + EXPECT_TRUE(verdict.frontier_complete) + << "the exact-key probe reads at `cursor + 1` and a LIST hole cannot hide an exact key, so the " + "catalog-named hidden namespace IS provable — if this is false the blob above survived on " + "suppression instead of on its own in-degree, which proves nothing about the edge"; + EXPECT_FALSE(verdict.suppress_destructive); +} + +/// The arm above asserts "nothing was deleted", which does not on its own distinguish the gate correctly +/// refusing from the round simply never deleting anything at all. This +/// is the positive control: `hidden` is genuinely folded through its OWN drop of the same +/// blob (an honest exact-key read of a record the LIST still hides -- the arithmetic-intake mechanism +/// this whole file is about), so its frontier is REALLY proven, not merely declared so, and the blob is +/// REALLY unreferenced by both namespaces. The round drains it -- the zero-deletion arm above would pass +/// identically if the round were simply incapable of ever deleting anything. +TEST(CASGCFrontierGate, TheSameBlobDrainsOnceHiddenGenuinelyProvesItsOwnFrontier) +{ + auto backend = std::make_shared(); + const RootNamespace hidden{"00/hidden@cas@"}; + const RootNamespace visible{"00/visible@cas@"}; + const DB::UInt128 blob(0x5ade); + + auto store = openPoolForTest(backend, /*gc_fold_max_defer_rounds=*/0); + const Layout & layout = store->layout(); + Gc gc(store, kGc); + + /// `hidden`'s BIRTH is folded (and its cursor SEALED) with everything still listed -- a namespace + /// with no `_ckpt` (the raw-fixture admission this file's helper uses never publishes one) has no + /// genesis signal EXCEPT a sealed cursor or a visible LIST, so a real fold first is what makes an + /// arithmetic (cursor-relative) genesis available at all for what follows. + const ManifestRef kept = publish(*backend, layout, hidden, "kept_ref", 1, blob); + ASSERT_TRUE(gc.runRegularRound().acquired_lease); + store->renewWatermarkOnce(); + + /// NOW `hidden` drops its own reference (written while still fully listed, so the raw fixture's own + /// LIST -- finding the greatest existing log id, to derive the next one -- sees the truth), and + /// ONLY THEN does its whole prefix vanish from every subsequent LIST. With a sealed cursor already + /// in hand the walk's genesis is arithmetic (`cursor + 1`), so this drop is found and folded by + /// exact key alone -- the arithmetic-intake mechanism this whole file is about, exercised honestly + /// rather than declared past by fiat. + dropRefTransition(*backend, layout, hidden, "kept_ref", kept); + backend->hidePrefix(layout.namespaceStreamPrefix(fixture::fixtureLife(hidden))); + + const ManifestRef dropped = publish(*backend, layout, visible, "dropped_ref", 2, blob); + dropRefTransition(*backend, layout, visible, "dropped_ref", dropped); + + drive(store, gc, /*rounds*/ 5, UniversePolicy::Authoritative); + + EXPECT_FALSE(backend->head(blobKeyOf(layout, blob)).exists) + << "both namespaces genuinely proved their frontier and the blob is genuinely unreferenced -- " + "the round must still be able to reclaim it"; +} + +/// AND THE PER-NAMESPACE LOGIC IS WHAT SAVES IT. Identical to the arm above except that the hidden +/// namespace was folded once first, so it carries a sealed cursor and is therefore IN the universe even +/// though the hint has gone silent about it. The round probes its expected-next by exact key, finds the +/// record the listing hid, folds the `+1`, and the blob is never condemned. +TEST(CASGCFrontierGate, AKnownNamespaceIsProbedByExactKeyAndItsHiddenEdgeSavesTheBlob) +{ + auto backend = std::make_shared(); + const RootNamespace hidden{"00/hidden@cas@"}; + const RootNamespace visible{"00/visible@cas@"}; + const DB::UInt128 blob(0x5ade); + + auto store = buildCrossNamespaceScenario(backend, hidden, visible, blob, /*fold_hidden_first=*/true); + const Layout & layout = store->layout(); + + Gc gc(store, kGc); + drive(store, gc, /*rounds*/ 5, UniversePolicy::Authoritative); + + EXPECT_TRUE(backend->head(blobKeyOf(layout, blob)).exists) + << "the cursor kept the namespace in the universe, so its frontier was probed and its edge folded"; +} + +/// ===================== THE GATE FORMULA, TERM BY TERM ===================== +/// +/// The healthy case first, because every suppressor arm below is only meaningful against it: a pool with +/// nothing hidden, nothing held, no anomaly, and every namespace walked to an honest end-of-stream OPENS +/// the gate and reclaims. The two booleans are read off the fold's own rows, so a formula that computed +/// them differently would fail here rather than agree with the test. +TEST(CASGCFrontierGate, AHealthyCatalogRoundOpensTheGateAndReclaims) +{ + auto backend = std::make_shared(); + auto store = openPoolForTest(backend, /*gc_fold_max_defer_rounds=*/0); + const Layout & layout = store->layout(); + const RootNamespace ns{"00/aa@cas@"}; + const DB::UInt128 blob(0xdead); + + const ManifestRef mref = publish(*backend, layout, ns, "ref_1", 1, blob); + dropRefTransition(*backend, layout, ns, "ref_1", mref); + + Gc gc(store, kGc); + backend->resetCounts(); + const GateVerdict verdict = runRoundCapturingGate(store, gc, UniversePolicy::kDefault); + + ASSERT_TRUE(verdict.saw_fold) << "the round did not fold, so it published no gate verdict"; + EXPECT_TRUE(verdict.frontier_complete) + << "every namespace in a healthy catalog universe reached a proven frontier"; + EXPECT_FALSE(verdict.suppress_destructive) + << "no anomaly, no hold, a complete frontier -- the gate has nothing left to refuse on"; + EXPECT_GT(verdict.frontier_namespaces, 0u) + << "a zero-namespace universe would satisfy the equality vacuously; this pool must not be one"; + EXPECT_EQ(verdict.frontier_proven, verdict.frontier_namespaces); + + /// And the gate being open is worth something: the condemned blob actually drains. + EXPECT_TRUE(runRoundsUntilAbsent(store, gc, *backend, layout, blob)) + << "an unsuppressed round must reclaim a blob no ref owns any more"; +} + +/// ===================== EVERY DESTRUCTIVE SITE, INDIVIDUALLY ===================== +/// +/// The inventory as an assertion. The pool below has real work waiting at every gated site: a +/// graduated blob to delete, an owner-removed manifest body to delete, aged generations to prune and +/// hand off, ref logs and snapshots covered by a durable snapshot, and a removed namespace with a +/// Pending cleanup item. A suppressed round issues ZERO deletes against ALL of them, and the per-site +/// assertions name which one leaked if any does. + +namespace +{ +/// A pool with destructive work pending at every site, plus a few completed rounds so generations have +/// aged past the retention floor. Returns the hash of a blob whose in-degree has dropped to zero. +DB::UInt128 buildPoolWithWorkAtEverySite(const std::shared_ptr & backend, + const PoolPtr & store, Gc & gc) +{ + const Layout & layout = store->layout(); + const RootNamespace live{"00/live@cas@"}; + const RootNamespace doomed{"00/doomed@cas@"}; + const DB::UInt128 blob(0xfeed); + + /// A long-lived namespace that keeps publishing, so snapshots and covered logs accumulate and + /// generations keep advancing past the retention floor. + for (uint64_t i = 1; i <= 4; ++i) + { + publish(*backend, layout, live, "ref_" + std::to_string(i), i, DB::UInt128(0x1000 + i)); + runRegularRoundReclaiming(gc); + store->renewWatermarkOnce(); + } + + /// The condemnable blob: published in `doomed`, then dropped. Its manifest body becomes + /// owner-removed cleanup work at the same time. + const ManifestRef mref = publish(*backend, layout, doomed, "doomed_ref", 9, blob); + runRegularRoundReclaiming(gc); + store->renewWatermarkOnce(); + dropRefTransition(*backend, layout, doomed, "doomed_ref", mref); + return blob; +} +} + +/// (3a) THE NEGATIVE-POLICY SEAM, and the per-site inventory at the same time. A caller that supplies no +/// universe suppresses on that term ALONE: this pool has no anomaly, no hold, and a frontier every +/// per-namespace probe proves -- the control at the end of the test is what says so, since the identical +/// pool drains on the production path. +TEST(CASGCFrontierGate, EveryInventoriedDestructiveSiteIsInertUnderSuppression) +{ + auto backend = std::make_shared(); + auto store = openPoolForTest(backend, /*gc_fold_max_defer_rounds=*/0); + const Layout & layout = store->layout(); + + Gc gc(store, kGc); + const DB::UInt128 blob = buildPoolWithWorkAtEverySite(backend, store, gc); + + /// From here on the rounds supply NO universe: every site has work queued and every site must + /// decline it. + backend->resetCounts(); + const GateVerdict verdict = runRoundCapturingGate(store, gc, UniversePolicy::StageA_Suppressed); + for (int i = 0; i < 5; ++i) + runRoundCapturingGate(store, gc, UniversePolicy::StageA_Suppressed); + + ASSERT_TRUE(verdict.saw_fold) << "the round did not fold, so it published no gate verdict"; + EXPECT_FALSE(verdict.frontier_complete) + << "with no universe supplied the frontier can never be complete, whatever the probes proved"; + EXPECT_TRUE(verdict.suppress_destructive); + expectEveryDeleteFamilyInert(*backend, "no universe supplied"); + EXPECT_TRUE(backend->head(blobKeyOf(layout, blob)).exists); + + /// The control: the identical pool DOES reclaim at those sites on the production path, so the zeros + /// above are the gate at work and not an empty work queue -- and it is also what makes the "on that + /// term alone" claim above true rather than assumed. + drive(store, gc, /*rounds*/ 4, UniversePolicy::kDefault); + EXPECT_GT(backend->deleteTotal(), 0u) + << "the work queue was real -- a round with a universe drains it"; + EXPECT_FALSE(backend->head(blobKeyOf(layout, blob)).exists); +} + +/// (1) ONE ANOMALY. A namespace whose `_ckpt` is present but undecodable records the "no usable +/// checkpoint" anomaly, and the round declines every site on that. It leaves the frontier incomplete too, +/// so what this arm pins is "an anomaly suppresses", not "only the anomaly does" -- which is why every +/// assertion below is about inertness and not about which term fired. +TEST(CASGCFrontierGate, AnUndecodableCheckpointAnomalySuppressesEveryDeleteFamily) +{ + auto backend = std::make_shared(); + auto store = openPoolForTest(backend, /*gc_fold_max_defer_rounds=*/0); + const Layout & layout = store->layout(); + + Gc gc(store, kGc); + const DB::UInt128 blob = buildPoolWithWorkAtEverySite(backend, store, gc); + + const RootNamespace damaged{"00/damaged@cas@"}; + publish(*backend, layout, damaged, "damaged_ref", 42, DB::UInt128(0xda43)); + /// Resolved through the catalog, not minted from the namespace name: the corruption has to land on + /// the very object the round's own life resolution will read, or the round folds normally and this + /// test measures nothing. + const std::optional damaged_life = + CasRefCatalog::lifeIfCataloged(*backend, layout, damaged); + ASSERT_TRUE(damaged_life.has_value()) << "the publish must have left a catalog entry to resolve"; + const std::optional damaged_ckpt = readCkpt(*backend, layout, *damaged_life); + ASSERT_TRUE(damaged_ckpt.has_value()) << "the publish must have left a `_ckpt` to damage"; + ASSERT_EQ(backend->casPut(layout.refCkptKey(*damaged_life), "not a checkpoint", + damaged_ckpt->token).outcome, CasOutcome::Committed); + + backend->resetCounts(); + std::vector anomaly_counts; + for (int i = 0; i < 6; ++i) + { + anomaly_counts.push_back(gc.runRegularRound().anomalies.size()); + store->renewWatermarkOnce(); + } + + EXPECT_GT(anomaly_counts.front(), 0u) + << "the undecodable `_ckpt` must be RECORDED, not silently absorbed -- a silent exit would make " + "this test pass for the wrong reason"; + expectEveryDeleteFamilyInert(*backend, "one anomaly"); + EXPECT_TRUE(backend->head(blobKeyOf(layout, blob)).exists); +} + +/// (2) ONE CARRIED HOLD. The gate's second term reads the SEAL, not this round's anomaly list, so the +/// round that matters here is a LATER one: the hold was detected earlier, rides forward because its +/// offending position is still unresolved, and must suppress on its own. +TEST(CASGCFrontierGate, ACarriedHoldSuppressesEveryDeleteFamily) +{ + auto backend = std::make_shared(); + auto store = openPoolForTest(backend, /*gc_fold_max_defer_rounds=*/0); + const Layout & layout = store->layout(); + + Gc gc(store, kGc); + const DB::UInt128 blob = buildPoolWithWorkAtEverySite(backend, store, gc); + + /// A committed gap: the checkpoint says `{1,2}` is committed while only `{1,1}` was ever written, so + /// the walk reads `{1,2}` absent BELOW its authority ceiling and holds there. Nothing repairs it, so + /// every later round re-detects the same position and carries the same hold. + const RootNamespace gapped{"00/gapped@cas@"}; + publish(*backend, layout, gapped, "gapped_ref", 44, DB::UInt128(0x6a9)); + replaceRecoverableCkptForRawFixture(*backend, layout, gapped, RefCkpt{ + .life_epoch = 1, + .committed_through = RefTxnId{1, 2}, + .checkpoint_snapshot_id = std::nullopt, + .last_epoch_seal = std::nullopt, + }); + + std::map intake; + gc.setPhaseSink([&](const GcPhaseRecord & rec) + { + if (rec.phase == "fold_ref_intake") + intake = rec.metrics; + }); + gc.runRegularRound(); + gc.setPhaseSink({}); + store->renewWatermarkOnce(); + ASSERT_FALSE(intake.empty()); + ASSERT_GT(intake.at("tables_held"), 0u) + << "the gap must be HELD, or the later rounds carry nothing and this test proves nothing"; + + backend->resetCounts(); + for (int i = 0; i < 5; ++i) + { + gc.runRegularRound(); + store->renewWatermarkOnce(); + } + expectEveryDeleteFamilyInert(*backend, "one carried hold"); + EXPECT_TRUE(backend->head(blobKeyOf(layout, blob)).exists); +} + +/// (3c) THE PROBE BUDGET. A namespace with a sealed cursor, no `_ckpt` and no listing left can be proven +/// only by a successor probe; a zero budget denies it one, so it counts toward the universe and not toward +/// the proofs, and the equality fails. +TEST(CASGCFrontierGate, AnExhaustedProbeBudgetSuppressesEveryDeleteFamily) +{ + auto backend = std::make_shared(); + auto store = openPoolWithProbeBudget(backend, /*budget*/ 0); + const Layout & layout = store->layout(); + + Gc gc(store, kGc); + const DB::UInt128 blob = buildPoolWithWorkAtEverySite(backend, store, gc); + + /// The budget is spent only on a namespace the round knows about and can reach NO other way. Three + /// conditions, and all three are load-bearing: a SEALED cursor (an unhinted namespace with no cursor + /// is a no-genesis shape the budget never reaches), NO listing (a hinted target is walked for free), + /// and NO readable `_ckpt` (a recoverable checkpoint proves the frontier without spending a probe -- + /// which is why publishing and hiding alone leaves the namespace provable and measures nothing). + const RootNamespace quiet{"00/quiet@cas@"}; + publish(*backend, layout, quiet, "quiet_ref", 43, DB::UInt128(0x9a1e)); + runRoundCapturingGate(store, gc, UniversePolicy::StageA_Suppressed); + ASSERT_NE(sealedCursorOf(*backend, layout, quiet), (RefTxnId{})) + << "without a sealed cursor the namespace never becomes a budget-spending probe target"; + + const std::optional quiet_life = + CasRefCatalog::lifeIfCataloged(*backend, layout, quiet); + ASSERT_TRUE(quiet_life.has_value()); + const std::optional quiet_ckpt = readCkpt(*backend, layout, *quiet_life); + ASSERT_TRUE(quiet_ckpt.has_value()) << "there must be a `_ckpt` to remove"; + ASSERT_EQ(backend->deleteExact(layout.refCkptKey(*quiet_life), quiet_ckpt->token).kind, + DeleteOutcome::Kind::Deleted); + backend->hidePrefix(layout.namespaceStreamPrefix(*quiet_life)); + + backend->resetCounts(); + const GateVerdict verdict = runRoundCapturingGate(store, gc, UniversePolicy::kDefault); + for (int i = 0; i < 5; ++i) + runRoundCapturingGate(store, gc, UniversePolicy::kDefault); + + ASSERT_TRUE(verdict.saw_fold); + EXPECT_GT(verdict.frontier_unprobed_budget, 0u) + << "this arm must suppress on the BUDGET term; a zero here means some other suppressor fired and " + "the test would pass without ever exhausting a budget"; + EXPECT_LT(verdict.frontier_proven, verdict.frontier_namespaces) + << "the unprobed namespace must count toward the universe and not toward the proofs"; + EXPECT_FALSE(verdict.frontier_complete); + EXPECT_TRUE(verdict.suppress_destructive); + expectEveryDeleteFamilyInert(*backend, "exhausted probe budget"); + EXPECT_TRUE(backend->head(blobKeyOf(layout, blob)).exists); +} + +/// `frontier_proven == frontier_namespaces` is `0 == 0` -- vacuously TRUE -- on an empty universe, which +/// is not by itself a proof of anything: a fresh pool, a damaged catalog, and a genuinely emptied pool +/// all produce the same zeros. The gate's non-vacuity term therefore has TWO ways to be satisfied: +/// `frontier_namespaces > 0` (an ordinary nonempty pool, everything proven), or the round's own hot-scan +/// catalog cut positively proving the universe empty (present, token-bearing, decoded, zero rows of +/// every lifecycle state). The next two tests are that positive/negative pair. +TEST(CASGCFrontierGate, ADecodedTokenBearingEmptyCatalogCompletesTheFrontierAndDrainsRetiredWork) +{ + auto backend = std::make_shared(); + auto store = openPoolForTest(backend, /*gc_fold_max_defer_rounds=*/0); + const Layout & layout = store->layout(); + const DB::UInt128 blob(0xbead); + + /// Built to make `frontier_namespaces` GENUINELY zero by every source that feeds it -- no catalog + /// entry (this pool never admitted a namespace), no sealed cursor, no ref-log hint -- while a + /// condemned blob with a real, present body and in-degree 0 sits queued exactly the way a real + /// round leaves one (`injectRetire`). + writeBlobBody(*backend, layout, blob); + const BlobRef blob_ref = legacyMetaTestRef(blob); + const Token blob_token = backend->head(layout.blobKey(blob_ref)).token; + injectRetire(*backend, layout, /*round*/ 1, /*shard*/ 0, + {RetiredEntry{.kind = ObjectKind::Blob, .ref = blob_ref, .token = blob_token, .size = 0}}); + store->renewWatermarkOnce(); + + ASSERT_TRUE(CasRefCatalog::read(*backend, layout).catalog.entries.empty()) + << "the scenario needs a genuinely, provably empty catalog, or this test measures nothing"; + + Gc gc(store, kGc); + backend->resetCounts(); + + /// `injectRetire` seeds the condemned entry WITHOUT the durable per-hash `Condemned` meta a real + /// condemn round writes (`fold`'s own `scheduleCondemnMarkerWrite` side effect) -- so this fixture's + /// round cadence is longer than the textbook condemn->graduate->delete: the lease is UNCLAIMED + /// (`injectRetire` writes `gc/state` directly, never through a real acquire), so the first round + /// only arms `acquireOrRenewLease`'s two-tick steal-safety window; the graduation gate's own + /// `confirm_condemned_marker` then fails its first sighting, schedules the meta write, and CARRIES + /// the entry (not yet delete_pending) while `meta_pool_wait` lands it durably by that round's end; + /// only the round after that sees the durable meta and actually graduates; and the physical delete + /// is the round after THAT. MEASURED (phase-sink instrumentation, not a guess): round 1 arms + /// (`saw_fold == false`), round 2 is the first to fold with the gate open while the blob is still + /// present (the carry round), round 3 graduates, round 4 executes the delete -- four rounds exactly. + /// Bound the drive at that plus one (5): enough slack for the fixture's own cadence to be measured + /// without hand-counting rounds against this gate, but tight enough that a real regression in the + /// confirm/retry cadence still fails loudly instead of silently absorbing into a generous loop. + ASSERT_TRUE(backend->head(layout.blobKey(blob_ref)).exists) + << "the scenario starts with the condemned blob present, or the loop below measures nothing"; + + constexpr int kMaxRounds = 5; /// measured cadence (4) + 1; see the comment above + /// Observe the TWO-PHASE PIPELINE explicitly: an open, unsuppressed gate while the blob was still + /// present (the graduate side), and only a STRICTLY LATER round removing it (the delete side). + /// Asserting only the final state and the last verdict would pass just as readily if the blob + /// vanished by some other path entirely. + int round_gate_opened_while_present = -1; /// the graduate side: FIRST round that folded, unsuppressed, + /// with the blob still present + int round_blob_vanished = -1; /// the delete side + GateVerdict last; + int rounds_run = 0; + for (int i = 0; i < kMaxRounds && backend->head(layout.blobKey(blob_ref)).exists; ++i) + { + last = runRoundCapturingGate(store, gc, UniversePolicy::Authoritative); + ++rounds_run; + const bool still_present = backend->head(layout.blobKey(blob_ref)).exists; + if (round_gate_opened_while_present < 0 && last.saw_fold && !last.suppress_destructive && still_present) + round_gate_opened_while_present = rounds_run; + if (round_blob_vanished < 0 && !still_present) + round_blob_vanished = rounds_run; + } + + ASSERT_GT(rounds_run, 0) << "the loop must actually run, or every assertion below is vacuous"; + ASSERT_LE(rounds_run, kMaxRounds) + << "the drain took more than the measured cadence -- this is a real regression in the " + "confirm/retry pacing, not something to hide by bumping the bound; re-derive the cadence"; + ASSERT_TRUE(last.saw_fold); + EXPECT_EQ(last.frontier_namespaces, 0u); + EXPECT_EQ(last.frontier_proven, 0u); + EXPECT_TRUE(last.catalog_proved_empty) + << "the catalog cut itself must be the proof, not the bare 0==0 equality"; + EXPECT_TRUE(last.frontier_complete); + EXPECT_FALSE(last.suppress_destructive); + ASSERT_GT(round_gate_opened_while_present, 0) + << "the gate must have opened (folded, unsuppressed) at least one round BEFORE the blob was " + "gone -- the graduate side of the two-phase pipeline -- not just at the round that deleted it"; + ASSERT_GT(round_blob_vanished, round_gate_opened_while_present) + << "the delete must be a round STRICTLY LATER than the one that opened the gate, never the same " + "round -- a round that both graduates and deletes in one step would hide the two-phase split"; + EXPECT_FALSE(backend->head(layout.blobKey(blob_ref)).exists) + << "a proved-empty universe is a COMPLETE frontier, not a suppressed one -- the condemned blob " + "must drain through the ordinary two-phase pipeline instead of leaking forever"; +} + +/// The negative half of the pair. A `Creating` row is a birth in progress (spec §3: no publication can +/// exist under it, so `live_incarnation`/`walk_plan.lives()` exclude it -- see the R10 comment above the +/// intake loop), not an empty universe -- but it produces the SAME `frontier_namespaces == +/// frontier_proven == 0` a genuinely empty catalog does. Only `catalog_cut_proved_empty` tells them +/// apart, because it reads `entries` (every lifecycle state), not the frontier counters. +TEST(CASGCFrontierGate, AZeroWalkableFrontierWithACreatingCatalogRowIsNotProvedEmpty) +{ + auto backend = std::make_shared(); + auto store = openPoolForTest(backend, /*gc_fold_max_defer_rounds=*/0); + const Layout & layout = store->layout(); + const DB::UInt128 blob(0xbead); + + writeBlobBody(*backend, layout, blob); + const BlobRef blob_ref = legacyMetaTestRef(blob); + const Token blob_token = backend->head(layout.blobKey(blob_ref)).token; + injectRetire(*backend, layout, /*round*/ 1, /*shard*/ 0, + {RetiredEntry{.kind = ObjectKind::Blob, .ref = blob_ref, .token = blob_token, .size = 0}}); + store->renewWatermarkOnce(); + + const RootNamespace stalled{"00/stalled@cas@"}; + CatalogEntry entry; + entry.ns = stalled; + entry.state = NsState::Creating; + entry.incarnation = hexToU128("00000000000000000000000000000042"); + entry.creator = CreatorFence{ + .server_root_id = "test-stalled-creator", .writer_epoch = 1, .fence_generation = 1}; + CasRefCatalog::casAdmitEntry(*backend, layout, /*gc_shards*/ 1, entry); + + Gc gc(store, kGc); + backend->resetCounts(); + /// `injectRetire` leaves `gc/state`'s lease unclaimed (owner 0); the first round only arms the + /// steal-safety window (see `ADecodedTokenBearingEmptyCatalogCompletesTheFrontierAndDrainsRetiredWork`) + /// and folds nothing, so it is spent here rather than counted among the assertions below. + const GateVerdict warm_up = runRoundCapturingGate(store, gc, UniversePolicy::Authoritative); + EXPECT_FALSE(warm_up.saw_fold); + for (int i = 0; i < 6; ++i) + { + const GateVerdict v = runRoundCapturingGate(store, gc, UniversePolicy::Authoritative); + ASSERT_TRUE(v.saw_fold); + EXPECT_EQ(v.frontier_namespaces, 0u); + EXPECT_EQ(v.frontier_proven, 0u); + EXPECT_FALSE(v.catalog_proved_empty) + << "a Creating-only catalog is a birth in progress, not proof of an empty universe"; + EXPECT_FALSE(v.frontier_complete); + EXPECT_TRUE(v.suppress_destructive); + } + expectEveryDeleteFamilyInert(*backend, "Creating-only catalog"); + EXPECT_TRUE(backend->head(layout.blobKey(blob_ref)).exists); +} + +/// The bootstrap-only absent-as-empty representation (`initializeEmptyForNewPool`) must never leak into +/// the operational round: an absent mandatory catalog is corruption, never an empty authority set. +TEST(CASGCFrontierGate, AnAbsentCatalogNeverReadsAsAnEmptyUniverse) +{ + auto backend = std::make_shared(); + auto store = openPoolForTest(backend, /*gc_fold_max_defer_rounds=*/0); + const Layout & layout = store->layout(); + + const Token catalog_token = backend->head(layout.refCatalogKey()).token; + ASSERT_EQ(backend->deleteExact(layout.refCatalogKey(), catalog_token).kind, DeleteOutcome::Kind::Deleted); + + Gc gc(store, kGc); + backend->resetCounts(); + bool saw_fold = false; + gc.setPhaseSink([&](const GcPhaseRecord & rec) { if (rec.phase == "fold_reduce") saw_fold = true; }); + try + { + gc.runRegularRound({}, /*allow_steal*/true, UniversePolicy::Authoritative); + FAIL() << "expected the missing mandatory catalog to throw before any fold gate verdict"; + } + catch (const DB::Exception & e) + { + EXPECT_EQ(e.code(), DB::ErrorCodes::CORRUPTED_DATA); + } + gc.setPhaseSink({}); + EXPECT_FALSE(saw_fold) << "an absent catalog must abort before the round computes any gate verdict"; + EXPECT_EQ(backend->deleteTotal(), 0u) << "no destructive work may run on an unauthorized round"; +} + +/// A malformed, truncated, wrong-typed, count-mismatched, or future-versioned catalog must not decode +/// into a `Snapshot` at all -- these are the replacement guards for R11's original "damaged catalog" +/// concern, now that the empty case has a positive proof to keep separate from a broken one. +/// One table-driven test: each row installs a different broken body at the mandatory key and expects +/// decode failure before any destructive work. +/// +/// NOT covered here: a header version BELOW `RefCatalog`'s own birth generation. `checkCompatibility` +/// today only rejects a version ABOVE `G_BUILD`; a version below a type's birth floor decodes as if it +/// were legal, and `decodeRefCatalog` discards the parsed header entirely, so "decoded successfully" +/// does not yet imply "legal version for this type". That gap is not load-bearing for THIS proof: the +/// proof is token-present + a full structural decode (type, complete records, matching count trailer, +/// no trailing bytes) + zero entries, and a well-formed-but-out-of-protocol empty catalog is already an +/// accepted residual under the trusted-store model (the token proves byte identity, not history) -- +/// closing the version floor would only shrink that residual, not remove it. Tracked separately as +/// `[cas-format-version-floor]` in `BACKLOG.md`; deliberately out of scope for this gate. +TEST(CASGCFrontierGate, AMalformedCatalogNeverDecodesIntoAnEmptyProof) +{ + /// A one-entry catalog's canonical bytes, the base every mutation below starts from. + RefCatalog one_entry; + CatalogEntry entry; + entry.ns = RootNamespace{"00/malformed-base@cas@"}; + entry.state = NsState::Live; + entry.incarnation = hexToU128("00000000000000000000000000000099"); + one_entry.entries.push_back(entry); + const String base = encodeRefCatalog(one_entry); + const String empty_base = encodeRefCatalog(RefCatalog{}); + + const String type_needle = fmt::format("\"type\":\"{}\"", traitsFor(FormatId::RefCatalog).type); + ASSERT_NE(empty_base.find(type_needle), String::npos); + const String version_needle = fmt::format("\"v\":{}", currentCompatibilityVersion()); + ASSERT_NE(empty_base.find(version_needle), String::npos); + ASSERT_NE(base.find("\"n\":1"), String::npos); + + const auto replaceOnce = [](const String & haystack, const String & needle, const String & replacement) -> String + { + const auto pos = haystack.find(needle); + EXPECT_NE(pos, String::npos) << "expected to find '" << needle << "'"; + String out = haystack; + out.replace(pos, needle.size(), replacement); + return out; + }; + + struct Case { const char * name; String bytes; }; + const std::vector cases = { + {"wrong-type", replaceOnce(empty_base, type_needle, "\"type\":\"cas_ref_ckpt\"")}, + /// The one version case the CURRENT (unmodified) gate actually enforces: a version ABOVE + /// `G_BUILD` is refused by `checkCompatibility` before decode proceeds. + {"future-version", replaceOnce(empty_base, version_needle, "\"v\":999999")}, + {"trailer-count-mismatch", replaceOnce(base, "\"n\":1", "\"n\":2")}, + /// The trailer line entirely gone: decode's post-entry loop expects another line and hits EOF. + {"missing-trailer", base.substr(0, base.rfind("{\"n\":1}\n"))}, + /// The trailer present but its own line has no terminator: EOF strictly inside a line. + {"truncated-mid-line", base.substr(0, base.size() - 2)}, + }; + + for (const Case & c : cases) + { + auto backend = std::make_shared(); + auto store = openPoolForTest(backend, /*gc_fold_max_defer_rounds=*/0); + const Layout & layout = store->layout(); + const Token bootstrap_token = backend->head(layout.refCatalogKey()).token; + ASSERT_EQ(backend->casPut(layout.refCatalogKey(), c.bytes, bootstrap_token).outcome, + CasOutcome::Committed) << c.name; + + Gc gc(store, kGc); + backend->resetCounts(); + bool saw_fold = false; + gc.setPhaseSink([&](const GcPhaseRecord & rec) { if (rec.phase == "fold_reduce") saw_fold = true; }); + EXPECT_THROW( + gc.runRegularRound({}, /*allow_steal*/true, UniversePolicy::Authoritative), DB::Exception) + << c.name; + gc.setPhaseSink({}); + EXPECT_FALSE(saw_fold) << c.name << ": a broken catalog must abort before any fold gate verdict"; + EXPECT_EQ(backend->deleteTotal(), 0u) << c.name << ": no destructive work may run on it"; + } +} + +/// `StageA_Suppressed` refuses outright regardless of what the catalog proves -- a proved-empty cut +/// satisfies the frontier term but is not the only term the gate reads. +TEST(CASGCFrontierGate, AProvedEmptyCatalogUnderStageASuppressedStaysSuppressed) +{ + auto backend = std::make_shared(); + auto store = openPoolForTest(backend, /*gc_fold_max_defer_rounds=*/0); + const Layout & layout = store->layout(); + const DB::UInt128 blob(0xbead); + + writeBlobBody(*backend, layout, blob); + const BlobRef blob_ref = legacyMetaTestRef(blob); + const Token blob_token = backend->head(layout.blobKey(blob_ref)).token; + injectRetire(*backend, layout, /*round*/ 1, /*shard*/ 0, + {RetiredEntry{.kind = ObjectKind::Blob, .ref = blob_ref, .token = blob_token, .size = 0}}); + store->renewWatermarkOnce(); + + Gc gc(store, kGc); + backend->resetCounts(); + /// `injectRetire` leaves `gc/state`'s lease unclaimed (owner 0); the first round only arms the + /// steal-safety window (see `ADecodedTokenBearingEmptyCatalogCompletesTheFrontierAndDrainsRetiredWork`) + /// and folds nothing, independent of policy -- lease acquisition precedes the destructive gate. + const GateVerdict warm_up = runRoundCapturingGate(store, gc, UniversePolicy::StageA_Suppressed); + EXPECT_FALSE(warm_up.saw_fold); + for (int i = 0; i < 6; ++i) + { + const GateVerdict v = runRoundCapturingGate(store, gc, UniversePolicy::StageA_Suppressed); + ASSERT_TRUE(v.saw_fold); + EXPECT_TRUE(v.catalog_proved_empty) + << "the catalog cut is still genuinely empty -- the fact does not depend on policy"; + EXPECT_FALSE(v.frontier_complete) + << "StageA_Suppressed refuses outright no matter what the catalog cut proves"; + EXPECT_TRUE(v.suppress_destructive); + } + expectEveryDeleteFamilyInert(*backend, "StageA_Suppressed over a proved-empty catalog"); + EXPECT_TRUE(backend->head(layout.blobKey(blob_ref)).exists); +} + +/// THE BIRTH-AFTER-EMPTY-CUT BLOB RACE. The proved-empty exception's soundness rests on one hard fact: +/// under this pool's protocol every live or live-precommit edge requires an exact `Live` catalog row +/// (INV-3), so a catalog cut with zero rows proves no namespace ANYWHERE holds one -- AT THAT INSTANT. +/// A namespace born strictly after the cut is invisible to the round that took it; safety for blob +/// CONTENT then rests entirely on the condemned-marker/resurrection protocol (EDGE-BEFORE-OBSERVE: +/// `ContentAddressedTransaction.cpp` persists the precommit edge before observing/uploading the pool +/// blob), never on the frontier proof, which by construction cannot see a birth postdating its own cut. +/// This test pins that: a real writer, through the production `createNamespace` lifecycle +/// (`precommitAdd` on a namespace that has never existed), lands a precommit edge to an +/// ALREADY-CONDEMNED blob strictly after round R's catalog cut but strictly before round R executes the +/// pending delete that cut licensed. +TEST(CASGCFrontierGate, ANamespaceBornAfterTheEmptyCutResurrectsTheCondemnedBlobInstead) +{ + ensureBlobUploadPoolForTest(); + + auto backend = std::make_shared(); + auto store = openPoolForTest(backend, /*gc_fold_max_defer_rounds=*/0); + const Layout & layout = store->layout(); + const RootNamespace doomed{"00/doomed@cas@"}; + + const String payload = "empty-cut-birth-race-payload"; + const DB::UInt128 hash = u128Of(payload); + const BlobRef id = idOf(payload); + const String key = layout.blobKey(id); + String raw_body(store->poolMeta().blob_header_len, '\0'); + raw_body += payload; + writeRawBlobBody(*backend, layout, hash, raw_body); + + const ManifestRef mref{.writer_epoch = 1, .build_sequence = 1, .manifest_ordinal = 1}; + writeManifestRaw(*backend, layout, doomed, mref, {blobEntryFor("data.bin", hash)}); + publishCommittedTransition(*backend, layout, doomed, "ref_1", std::nullopt, mref); + + Gc gc(store, kGc); + runRegularRoundReclaiming(gc); /// folds the +1 edge + store->renewWatermarkOnce(); + dropRefTransition(*backend, layout, doomed, "ref_1", mref); + runRegularRoundReclaiming(gc); /// condemns: durable Condemned meta + store->renewWatermarkOnce(); + const Token condemned_token = backend->head(key).token; + const auto condemned_meta = loadMetaForTest(*backend, layout, hash); + ASSERT_TRUE(condemned_meta.has_value()); + ASSERT_EQ(condemned_meta->meta.state, MetaState::Condemned) + << "the delete round R is about to execute must be backed by durable Condemned evidence"; + runRegularRoundReclaiming(gc); /// graduates: publishes delete_pending + store->renewWatermarkOnce(); + + /// Remove `doomed` entirely -- the ONLY way the catalog can become genuinely, provably empty. A raw + /// `RemoveNamespace` op plus the Removing-state CAS mirrors + /// `CleanupEvidenceLeavesRemovedNamespaceCheckpointForJanitor`'s own recipe exactly. + RefOp remove_op; + remove_op.kind = RefOpKind::RemoveNamespace; + const uint64_t remove_seq = appendRefLogSeed(*backend, layout, doomed, {remove_op}); + publishRecoverableCkptForSemanticWrapper(*backend, layout, doomed, RefTxnId{1, remove_seq}); + CasRefCatalog::casUpdate(*backend, layout, [&](const RefCatalog & current) -> RefCatalog + { + RefCatalog next = current; + const auto it = std::find_if(next.entries.begin(), next.entries.end(), + [&](const CatalogEntry & e) { return e.ns == doomed; }); + EXPECT_NE(it, next.entries.end()); + it->state = NsState::Removing; + it->removal_started_round = 1; + return next; + }); + + /// A SUPPRESSED round folds `doomed` through its removal terminal and records `cleanup_evidence` in + /// the seal, WITHOUT executing the delete_pending the graduate round above published -- suppressed + /// rounds carry pending deletes forward untouched. `StageA_Suppressed` here is the test's OWN + /// control over timing, not the scenario under test: it exists only to keep blob X's delete pending + /// until round R below, rather than letting it drain the ordinary way while `doomed` is still Live. + gc.runRegularRound({}, /*allow_steal*/true, UniversePolicy::StageA_Suppressed); + store->renewWatermarkOnce(); + EXPECT_TRUE(backend->head(key).exists) << "the pending delete must still be carried, not yet run"; + + /// Round R: its pre-fold drain (`drainCompletedRemoving`) reads the round just above's + /// `cleanup_evidence` and drops `doomed`'s catalog row BEFORE this round's own hot-scan `GET` -- + /// so round R's catalog cut is the first one that is genuinely, provably empty. The hook fires the + /// instant that cut is taken and races a real namespace birth into the window before round R's own + /// pre-CAS delete phase runs. + bool hook_fired = false; + Token fresh_token{}; + gc.setPostHotScanCatalogReadHookForTest([&]() + { + hook_fired = true; + ASSERT_TRUE(CasRefCatalog::read(*backend, layout).catalog.entries.empty()) + << "the race must land inside the window where the cut itself is already empty"; + + const RootNamespace newborn{"00/newborn@cas@"}; + auto build = store->beginPartWrite( + PartWriteInfo{.intended_ref = newborn.string() + "/ref_1", .intended_namespace = newborn}); + const ManifestId new_id = build->stageManifest({blobEntryFor("data.bin", hash)}); + build->precommitAdd(newborn, "ref_1", new_id); /// mints `newborn` via real createNamespace + const PutBlobResult uploaded = build->putBlob(id, BlobSource::fromString(payload)); + EXPECT_EQ(uploaded.ref, id); + fresh_token = backend->head(key).token; + EXPECT_NE(fresh_token, condemned_token) + << "the writer must have observed Condemned and resurrected -- a fresh token, not an adopt " + "of the dying incarnation"; + }); + + const GateVerdict verdict = runRoundCapturingGate(store, gc, UniversePolicy::Authoritative); + ASSERT_TRUE(hook_fired) << "the race hook never fired -- this test proves nothing about the race"; + ASSERT_TRUE(verdict.saw_fold); + EXPECT_TRUE(verdict.catalog_proved_empty); + EXPECT_TRUE(verdict.frontier_complete); + EXPECT_FALSE(verdict.suppress_destructive); + + EXPECT_TRUE(backend->head(key).exists) + << "the resurrected incarnation must survive round R's delete"; + EXPECT_EQ(backend->head(key).token, fresh_token) << "and it is still the writer's incarnation"; + EXPECT_EQ(backend->deleteExact(key, condemned_token).kind, DeleteOutcome::Kind::TokenMismatch) + << "the condemned token can never remove the fresh object (INV_NO_LOSS)"; + + /// A later round's own fresh catalog cut names `newborn`, folds its `+1`, and the blob's frontier is + /// intact going forward. + const GateVerdict later = runRoundCapturingGate(store, gc, UniversePolicy::Authoritative); + ASSERT_TRUE(later.saw_fold); + EXPECT_EQ(later.frontier_namespaces, 1u); + EXPECT_EQ(later.frontier_proven, 1u); + EXPECT_TRUE(backend->head(key).exists) << "the newly folded owner keeps the blob alive"; +} + +/// The generation prune's cursor must not move on a suppressed round either. It is a monotone +/// high-water mark that the wholesale prune never revisits, so a cursor that advanced past a generation +/// this round declined to delete would strand that generation's whole prefix with no reclaimer left. +TEST(CASGCFrontierGate, ASuppressedRoundDoesNotAdvanceTheGenerationPruneCursor) +{ + auto backend = std::make_shared(); + auto store = openPoolForTest(backend, /*gc_fold_max_defer_rounds=*/0); + const Layout & layout = store->layout(); + const RootNamespace ns{"00/aa@cas@"}; + + Gc gc(store, kGc); + for (uint64_t i = 1; i <= 6; ++i) + { + publish(*backend, layout, ns, "ref_" + std::to_string(i), i, DB::UInt128(0x2000 + i)); + runRegularRoundReclaiming(gc); + store->renewWatermarkOnce(); + } + const uint64_t pruned_through_before = + decodeGcState(backend->get(layout.gcStateKey())->bytes).snap_pruned_through; + + for (uint64_t i = 7; i <= 10; ++i) + { + publish(*backend, layout, ns, "ref_" + std::to_string(i), i, DB::UInt128(0x2000 + i)); + gc.runRegularRound({}, /*allow_steal*/true, UniversePolicy::StageA_Suppressed); + store->renewWatermarkOnce(); + } + + EXPECT_EQ(decodeGcState(backend->get(layout.gcStateKey())->bytes).snap_pruned_through, + pruned_through_before) + << "the retention cursor is a high-water mark; it may not pass a generation nothing deleted"; +} + +/// THE HAND-OFF RECLAIM, WHICH THE INVENTORY TEST ABOVE CANNOT REACH. This site only fires for a +/// generation the wholesale prune SKIPPED while a live ref still pinned it (so the retention cursor +/// moved past it and will never revisit it) and which a later round's ref then moves off. Building that +/// takes a deliberately idle shard and a retention cursor driven past it, which is why it gets its own +/// test rather than riding on the inventory pool. +/// +/// It is reachable under suppression precisely because FOLDING still happens on a suppressed round: the +/// ref moves off the old generation exactly as it would otherwise, and only the reclaim is withheld. +TEST(CASGCFrontierGate, TheHandOffReclaimIsInertUnderSuppression) +{ + auto backend = std::make_shared(); + auto store = Pool::open(backend, + PoolConfig{.pool_prefix = "p", .server_root_id = "test", + .gc_snapshot_generations_to_keep = 1, .gc_fold_max_defer_rounds = 0}); + const Layout & layout = store->layout(); + const RootNamespace ns{"00/aa@cas@"}; + + const ManifestRef r1 = publish(*backend, layout, ns, "tbl", 1, DB::UInt128(0xa1)); + + Gc gc(store, kGc); + runRegularRoundReclaiming(gc); + store->renewWatermarkOnce(); + const uint64_t old_gen = decodeGcState(backend->get(layout.gcStateKey())->bytes).snap_generation; + const String old_prefix = layout.gcGenPrefix(old_gen); + ASSERT_FALSE(backend->list(old_prefix, "", 1000).keys.empty()); + + /// Idle-carry the ref until the retention cursor is strictly PAST its generation. Until then an + /// ordinary prune could still reclaim it and the hand-off would not be the load-bearing path. + for (int i = 0; i < 6; ++i) + { + runRegularRoundReclaiming(gc); + store->renewWatermarkOnce(); + } + ASSERT_GT(decodeGcState(backend->get(layout.gcStateKey())->bytes).snap_pruned_through, old_gen) + << "the generation must be behind the retention cursor before the hand-off is exercised"; + ASSERT_FALSE(backend->list(old_prefix, "", 1000).keys.empty()) + << "and still retained, because a live ref pins it"; + + /// A real delta moves the shard's run off the old generation. This is the round the hand-off would + /// reclaim it on -- and it supplies no universe. + const ManifestRef r2{.writer_epoch = 1, .build_sequence = 2, .manifest_ordinal = 1}; + writeBlobBody(*backend, layout, DB::UInt128(0xb2)); + writeManifestRaw(*backend, layout, ns, r2, {blobEntryFor("data.bin", DB::UInt128(0xb2))}); + publishCommittedTransition(*backend, layout, ns, "tbl", r1, r2); + + backend->resetCounts(); + gc.runRegularRound({}, /*allow_steal*/true, UniversePolicy::StageA_Suppressed); + + EXPECT_EQ(backend->deleteCountForKeysContaining("/gc/gen/"), 0u) + << "a suppressed round hands nothing off. Deleted:" << deletedKeysMessage(*backend); + EXPECT_FALSE(backend->list(old_prefix, "", 1000).keys.empty()) + << "the superseded generation's prefix survives a suppressed round intact"; + + /// AND THE OPPORTUNITY IS CONSUMED, NOT DEFERRED -- the one place in this task where the gate + /// costs something permanent, so it is asserted here rather than left to be discovered later. + /// + /// The hand-off is a one-shot DIFFERENCE: it compares the PARENT seal's runs against the new + /// seal's, and the suppressed round above already folded the delta, so the next round's parent + /// seal no longer mentions the old generation. Nothing revisits it -- the retention cursor is + /// already past it and the prune never goes back. The prefix is left to `fsck`, which is exactly + /// the outcome the site's own doc comment already records for a crash in the same window ("the + /// cursor already advanced, so a plain retry will NOT re-attempt it; fsck is the backstop"). + /// Bounded (one small run per shard per occurrence) and not a correctness problem. + /// + /// The hand-off itself is not going untested: `CASGCRetention.HandOffDeletesSupersededRef` drives + /// the same transition on an authoritative round and asserts the prefix IS reclaimed. + runRegularRoundReclaiming(gc); + EXPECT_FALSE(backend->list(old_prefix, "", 1000).keys.empty()) + << "the hand-off is a one-shot difference: the suppressed round consumed it, so the prefix is " + "now fsck's problem rather than a later round's"; +} + +/// THE ORPHAN-MANIFEST SWEEP, which the inventory pool above also cannot reach: it only deletes bodies +/// that no ref names AND whose build is provably dead by the durable watermark floor, so it needs a +/// pool seeded with exactly that -- orphan bodies and a floor above them. +/// +/// It is gated with its CURSOR, not just its deletes. The cursor paces a cold-prefix enumeration and +/// nothing revisits a range it passed, so advancing it on a round that swept nothing would silently +/// skip that range forever. A suppressed round therefore declines the whole pass. +TEST(CASGCFrontierGate, TheOrphanManifestSweepAndItsCursorAreInertUnderSuppression) +{ + auto backend = std::make_shared(); + auto store = Pool::open(backend, + PoolConfig{.pool_prefix = "p", .server_root_id = "gc-runner", + .manifest_sweep_list_budget_keys = 1, .manifest_sweep_delete_budget_keys = 1, + .gc_fold_max_defer_rounds = 0}); + const Layout & layout = store->layout(); + const RootNamespace ns{"test/aa@cas@"}; + /// The control arm below needs a recoverable catalog life whose frontier is exactly the carried + /// cursor. An empty non-seal transaction is a valid genesis that recovers to an empty table while + /// leaving the manifest epoch below the cursor's epoch. + fixture::admitLive(*backend, layout, ns); + fixture::writeRefLogRaw(*backend, layout, RefLogTxn{ + .ns = ns.string(), + .txn_id = RefTxnId{6, 1}, + .ops = {}, + .prev_epoch_seal = std::nullopt, + }); + writeRecoverableCkptForRawFixture(*backend, layout, ns, RefCkpt{ + .life_epoch = 6, + .committed_through = RefTxnId{6, 1}, + .checkpoint_snapshot_id = std::nullopt, + .last_epoch_seal = std::nullopt, + }); + + /// Two manifest bodies no ref ever named, under a build the durable floor has already passed. + const ManifestRef r1{.writer_epoch = 5, .build_sequence = 0xCA01, .manifest_ordinal = 1}; + const ManifestRef r2{.writer_epoch = 5, .build_sequence = 0xCA02, .manifest_ordinal = 1}; + writeManifestRaw(*backend, layout, ns, r1, {blobEntryFor("a", DB::UInt128(0xa1))}); + writeManifestRaw(*backend, layout, ns, r2, {blobEntryFor("b", DB::UInt128(0xb2))}); + setWatermarkMinActive(*backend, layout, "test", r1.writer_epoch, /*min_active*/ 0xCA03); + /// The §6 deletion premise is a second precondition on the CONTROL arm below: a manifest of an + /// epoch-`E` build is deletable only once the namespace's sealed fold cursor sits in an epoch + /// strictly above `E`. Sealing that cursor here is what keeps this test about the GATE — without it + /// the control arm would stop deleting for the premise's reason, and a removed gate would no longer + /// show up as a difference between the two arms. A real round rewrites this row with the same cursor + /// (the namespace is known, quiet and unheld, so the walk probes `cursor+1`, finds the frontier and + /// carries the cursor), so the seeded fact survives every round below. + seedFoldCursorForTest(*backend, layout, ns, RefTxnId{r1.writer_epoch + 1, 1}); + + Gc gc(store, kGc); + backend->resetCounts(); + for (int i = 0; i < 4; ++i) + { + gc.runRegularRound({}, /*allow_steal*/true, UniversePolicy::StageA_Suppressed); + store->renewWatermarkOnce(); + } + + EXPECT_EQ(backend->deleteCountForKeysContaining("/cas/manifests/"), 0u) + << "a suppressed round sweeps nothing. Deleted:" << deletedKeysMessage(*backend); + EXPECT_TRUE(backend->head(layout.manifestKey(ManifestId{ns, r1})).exists); + EXPECT_TRUE(backend->head(layout.manifestKey(ManifestId{ns, r2})).exists); + EXPECT_TRUE(decodeGcState(backend->get(layout.gcStateKey())->bytes).manifest_sweep_cursor.empty()) + << "the sweep cursor must not advance over a range the round declined to sweep -- nothing " + "revisits it"; + + /// The control: the same orphans ARE swept once the universe is authoritative. + for (int i = 0; i < 4; ++i) + { + runRegularRoundReclaiming(gc); + store->renewWatermarkOnce(); + } + EXPECT_FALSE(backend->head(layout.manifestKey(ManifestId{ns, r1})).exists); + EXPECT_FALSE(backend->head(layout.manifestKey(ManifestId{ns, r2})).exists); +} + + +/// ===================== QUIET NAMESPACES AND THE PROBE BUDGET ===================== + +/// THE TALLY ARITHMETIC, at a PARTIAL budget — the case neither 0 nor the default reaches. +/// +/// `frontier_namespaces` is the denominator an operator reads as "the round's universe", and the +/// integration test reads it too. A valid checkpoint at every quiet namespace's carried cursor is +/// authoritative independently of LIST and the probe budget: all three lives are proven without +/// successor probes, so the budget leaves no namespace unprobed. +TEST(CASGCFrontierGate, APartialProbeBudgetPublishesATallyThatMatchesTheSealedSet) +{ + auto backend = std::make_shared(); + auto store = openPoolWithProbeBudget(backend, /*budget*/ 1); + const Layout & layout = store->layout(); + const RootNamespace a{"00/quiet_a@cas@"}; + const RootNamespace b{"00/quiet_b@cas@"}; + const RootNamespace c{"00/quiet_c@cas@"}; + + for (const RootNamespace & ns : {a, b, c}) + publish(*backend, layout, ns, "ref_1", 1, DB::UInt128(0x300 + ns.string().size())); + + Gc gc(store, kGc); + runRegularRoundReclaiming(gc); + store->renewWatermarkOnce(); + for (const RootNamespace & ns : {a, b, c}) + ASSERT_NE(sealedCursorOf(*backend, layout, ns), (RefTxnId{})) << ns.string(); + + /// All three go unhinted at once. Their valid checkpoint frontiers still prove their carried + /// cursors, so this does not consume the successor-probe budget. + for (const RootNamespace & ns : {a, b, c}) + backend->hidePrefix(layout.namespaceStreamPrefix(fixture::fixtureLife(ns))); + + std::map intake; + gc.setPhaseSink([&](const GcPhaseRecord & rec) + { + if (rec.phase == "fold_ref_intake") + intake = rec.metrics; + }); + runRegularRoundReclaiming(gc); + gc.setPhaseSink({}); + + ASSERT_FALSE(intake.empty()) << "the intake phase must have emitted its row"; + EXPECT_EQ(intake["unhinted_quiet_walked"], 3u) + << "a valid checkpoint frontier makes every quiet life eligible without a successor probe"; + EXPECT_EQ(intake["frontier_unprobed_budget"], 0u) + << "the CTE authority, not the probe budget, decides these quiet lives"; + EXPECT_EQ(intake["frontier_proven"], 3u) + << "each carried cursor equals its valid checkpoint frontier"; + EXPECT_EQ(intake["frontier_namespaces"], 3u) + << "the denominator is the complete authoritative set of sealed quiet lives"; + EXPECT_EQ(intake["frontier_proven"], intake["frontier_namespaces"]) + << "a valid CTE frontier remains authoritative even when LIST omits every namespace"; + + /// And the seal really does carry all three rows — the denominator's claim, checked against the + /// object it describes rather than against another counter. + for (const RootNamespace & ns : {a, b, c}) + { + EXPECT_NE(sealedCursorOf(*backend, layout, ns), (RefTxnId{})) + << "every namespace in the tally must have a sealed cursor: " << ns.string(); + const NamespaceLifeId life = *CasRefCatalog::lifeIfCataloged(*backend, layout, ns); + const auto checkpoint = readCkpt(*backend, layout, life); + ASSERT_TRUE(checkpoint.has_value()); + EXPECT_EQ(checkpoint->ckpt.committed_through, (RefTxnId{1, 1})) + << "LIST omission and the probe budget do not alter a valid CTE"; + } +} + +/// A checkpoint boundary already equal to the carried cursor proves a quiet catalog life complete; +/// GC must not manufacture a successor `GET` merely because its LIST is empty. +TEST(CASGCFrontierGate, AQuietKnownNamespaceAtItsCheckpointFrontierCostsNoSuccessorGet) +{ + auto backend = std::make_shared(); + auto store = openPoolForTest(backend, /*gc_fold_max_defer_rounds=*/0); + const Layout & layout = store->layout(); + const RootNamespace quiet{"00/quiet@cas@"}; + + publish(*backend, layout, quiet, "ref_1", 1, DB::UInt128(0x11)); + replaceRecoverableCkptForRawFixture(*backend, layout, quiet, RefCkpt{ + .life_epoch = 1, + .committed_through = RefTxnId{1, 1}, + .checkpoint_snapshot_id = std::nullopt, + .last_epoch_seal = std::nullopt, + }); + + Gc gc(store, kGc); + runRegularRoundReclaiming(gc); + store->renewWatermarkOnce(); + + const RefTxnId sealed = sealedCursorOf(*backend, layout, quiet); + ASSERT_NE(sealed, (RefTxnId{})) << "the seeding round must have sealed a cursor to carry"; + + /// Now the store stops listing the namespace entirely. + backend->hidePrefix(layout.namespaceStreamPrefix(fixture::fixtureLife(quiet))); + backend->resetCounts(); + std::map intake; + gc.setPhaseSink([&](const GcPhaseRecord & rec) + { + if (rec.phase == "fold_ref_intake") + intake = rec.metrics; + }); + const RoundReport report = runRegularRoundReclaiming(gc); + gc.setPhaseSink({}); + + const String expected_next = + layout.refLogKey(fixture::fixtureLife(quiet), RefTxnId{sealed.writer_epoch, sealed.ref_sequence + 1}); + EXPECT_EQ(backend->getCount(expected_next), 0u) + << "the inclusive checkpoint boundary proves this quiet life without a successor probe"; + EXPECT_TRUE(report.anomalies.empty()); + ASSERT_FALSE(intake.empty()); + EXPECT_EQ(intake["frontier_proven"], intake["frontier_namespaces"]) + << "the inherited cursor already at the checkpoint boundary is destructive-eligible"; +} + +/// A checkpoint must never retreat below a sealed cursor. Its inclusive frontier can prove a cursor +/// already at that point, but cannot explain one that has advanced beyond it. +TEST(CASGCFrontierGate, CheckpointFrontierBehindAnInheritedCursorFailsClosed) +{ + auto backend = std::make_shared(); + auto store = openPoolForTest(backend, /*gc_fold_max_defer_rounds=*/0); + const Layout & layout = store->layout(); + const RootNamespace ns{"00/checkpoint-behind-inherited-cursor@cas@"}; + + fixture::admitLive(*backend, layout, ns); + publish(*backend, layout, ns, "first", 1, DB::UInt128(0xfb)); + publish(*backend, layout, ns, "second", 2, DB::UInt128(0xfc)); + replaceRecoverableCkptForRawFixture(*backend, layout, ns, RefCkpt{ + .life_epoch = 1, + .committed_through = RefTxnId{1, 2}, + .checkpoint_snapshot_id = std::nullopt, + .last_epoch_seal = std::nullopt, + }); + + Gc gc(store, kGc); + ASSERT_TRUE(runRegularRoundReclaiming(gc).acquired_lease); + ASSERT_EQ(sealedCursorOf(*backend, layout, ns), (RefTxnId{1, 2})); + + const NamespaceLifeId life = *CasRefCatalog::lifeIfCataloged(*backend, layout, ns); + const String checkpoint_key = layout.refCkptKey(life); + const HeadResult checkpoint_head = backend->head(checkpoint_key); + ASSERT_TRUE(checkpoint_head.exists); + ASSERT_EQ(backend->putOverwrite(checkpoint_key, encodeRefCkpt(RefCkpt{ + .life_epoch = 1, + .committed_through = RefTxnId{1, 1}, + .checkpoint_snapshot_id = std::nullopt, + .last_epoch_seal = std::nullopt, + }), checkpoint_head.token).outcome, PutOutcome::Done); + + std::map intake; + gc.setPhaseSink([&](const GcPhaseRecord & rec) + { + if (rec.phase == "fold_ref_intake") + intake = rec.metrics; + }); + const RoundReport report = runRegularRoundReclaiming(gc); + gc.setPhaseSink({}); + + EXPECT_FALSE(report.anomalies.empty()); + ASSERT_FALSE(intake.empty()); + EXPECT_LT(intake["frontier_proven"], intake["frontier_namespaces"]); +} + +/// A carried `EpochSeal` may have its authoritative successor in the next epoch. The arithmetic +/// successor in the sealed epoch is absent by design, so the exact checkpoint frontier must nominate +/// the shared seal-chain crossing before that absence is classified as a same-epoch gap. +TEST(CASGCFrontierGate, CheckpointFrontierCrossesAnInheritedEpochSeal) +{ + auto backend = std::make_shared(); + auto store = openPoolForTest(backend, /*gc_fold_max_defer_rounds=*/0); + const Layout & layout = store->layout(); + const RootNamespace ns{"00/checkpoint-inherited-seal-crossing@cas@"}; + const DB::UInt128 crossed_blob(0xfd); + + fixture::admitLive(*backend, layout, ns); + publishAt(*backend, layout, ns, RefTxnId{1, 1}, "birth", 1, DB::UInt128(0xfe), /*birth=*/true); + writeSealAt(*backend, layout, ns, RefTxnId{1, 2}); + writeRecoverableCkptForRawFixture(*backend, layout, ns, RefCkpt{ + .life_epoch = 1, + .committed_through = RefTxnId{1, 2}, + .checkpoint_snapshot_id = std::nullopt, + .last_epoch_seal = RefTxnId{1, 2}, + }); + + Gc gc(store, kGc); + ASSERT_TRUE(runRegularRoundReclaiming(gc).acquired_lease); + ASSERT_EQ(sealedCursorOf(*backend, layout, ns), (RefTxnId{1, 2})); + + publishAt(*backend, layout, ns, RefTxnId{2, 1}, "crossed", 2, crossed_blob, + /*birth=*/false, /*prev_epoch_seal=*/RefTxnId{1, 2}); + const NamespaceLifeId life = *CasRefCatalog::lifeIfCataloged(*backend, layout, ns); + const String checkpoint_key = layout.refCkptKey(life); + const HeadResult checkpoint_head = backend->head(checkpoint_key); + ASSERT_TRUE(checkpoint_head.exists); + ASSERT_EQ(backend->putOverwrite(checkpoint_key, encodeRefCkpt(RefCkpt{ + .life_epoch = 1, + .committed_through = RefTxnId{2, 1}, + .checkpoint_snapshot_id = std::nullopt, + .last_epoch_seal = RefTxnId{1, 2}, + }), checkpoint_head.token).outcome, PutOutcome::Done); + + std::map intake; + gc.setPhaseSink([&](const GcPhaseRecord & rec) + { + if (rec.phase == "fold_ref_intake") + intake = rec.metrics; + }); + const RoundReport report = runRegularRoundReclaiming(gc); + gc.setPhaseSink({}); + + EXPECT_TRUE(report.anomalies.empty()); + EXPECT_GT(inDegreeOf(*backend, layout, crossed_blob), 0); + ASSERT_FALSE(intake.empty()); + EXPECT_EQ(intake["frontier_proven"], intake["frontier_namespaces"]); +} + +/// The exact checkpoint successor must chain to the seal just consumed. Merely being in the next epoch +/// is insufficient: an incorrect predecessor would skip an unclosed history segment forever. +TEST(CASGCFrontierGate, CheckpointFrontierRejectsWrongPredecessorAfterFreshEpochSeal) +{ + auto backend = std::make_shared(); + auto store = openPoolForTest(backend, /*gc_fold_max_defer_rounds=*/0); + const Layout & layout = store->layout(); + const RootNamespace ns{"00/checkpoint-wrong-fresh-seal-predecessor@cas@"}; + + fixture::admitLive(*backend, layout, ns); + publishAt(*backend, layout, ns, RefTxnId{1, 1}, "birth", 1, DB::UInt128(0xff), /*birth=*/true); + writeSealAt(*backend, layout, ns, RefTxnId{1, 2}); + publishAt(*backend, layout, ns, RefTxnId{2, 1}, "wrong_predecessor", 2, DB::UInt128(0x100), + /*birth=*/false, /*prev_epoch_seal=*/RefTxnId{1, 1}); + writeRecoverableCkptForRawFixture(*backend, layout, ns, RefCkpt{ + .life_epoch = 1, + .committed_through = RefTxnId{2, 1}, + .checkpoint_snapshot_id = std::nullopt, + .last_epoch_seal = RefTxnId{1, 2}, + }); + + std::map intake; + Gc gc(store, kGc); + gc.setPhaseSink([&](const GcPhaseRecord & rec) + { + if (rec.phase == "fold_ref_intake") + intake = rec.metrics; + }); + const RoundReport report = runRegularRoundReclaiming(gc); + gc.setPhaseSink({}); + + EXPECT_FALSE(report.anomalies.empty()); + EXPECT_EQ(sealedCursorOf(*backend, layout, ns), (RefTxnId{1, 2})); + ASSERT_FALSE(intake.empty()); + EXPECT_LT(intake["frontier_proven"], intake["frontier_namespaces"]); +} + +/// A namespace that was WRONGLY quiet -- the hint hid a record that is durably there -- is walked this +/// round, not next: the probe finds the record and the walk continues from it. +TEST(CASGCFrontierGate, AWronglyQuietNamespaceIsWalkedTheSameRound) +{ + auto backend = std::make_shared(); + auto store = openPoolForTest(backend, /*gc_fold_max_defer_rounds=*/0); + const Layout & layout = store->layout(); + const RootNamespace quiet{"00/quiet@cas@"}; + const DB::UInt128 late_blob(0x77); + + publish(*backend, layout, quiet, "ref_1", 1, DB::UInt128(0x11)); + replaceRecoverableCkptForRawFixture(*backend, layout, quiet, RefCkpt{ + .life_epoch = 1, + .committed_through = RefTxnId{1, 1}, + .checkpoint_snapshot_id = std::nullopt, + .last_epoch_seal = std::nullopt, + }); + + Gc gc(store, kGc); + runRegularRoundReclaiming(gc); + store->renewWatermarkOnce(); + const RefTxnId sealed_before = sealedCursorOf(*backend, layout, quiet); + + /// A second publish lands, and the store hides the namespace from every LIST at the same moment. + publish(*backend, layout, quiet, "ref_2", 2, late_blob); + const NamespaceLifeId life = *CasRefCatalog::lifeIfCataloged(*backend, layout, quiet); + const String checkpoint_key = layout.refCkptKey(life); + const HeadResult checkpoint_head = backend->head(checkpoint_key); + ASSERT_TRUE(checkpoint_head.exists); + ASSERT_EQ(backend->putOverwrite(checkpoint_key, encodeRefCkpt(RefCkpt{ + .life_epoch = 1, + .committed_through = RefTxnId{1, 2}, + .checkpoint_snapshot_id = std::nullopt, + .last_epoch_seal = std::nullopt, + }), checkpoint_head.token).outcome, PutOutcome::Done); + backend->hidePrefix(layout.namespaceStreamPrefix(fixture::fixtureLife(quiet))); + + runRegularRoundReclaiming(gc); + + EXPECT_LT(sealed_before, sealedCursorOf(*backend, layout, quiet)) + << "the probe found the hidden record, so the walk folded it and the cursor advanced"; + EXPECT_GT(inDegreeOf(*backend, layout, late_blob), 0) + << "the hidden publish's edge folded this round -- the hint never mentioned it"; +} + +/// The catalog life is grounded by its exact decoded `_ckpt`, not by the round's listing or a later +/// absent probe. A durable `F+1` is physically present but not committed history, so this fold may apply +/// only `F`; in particular it must not read `F+2`. Reaching `F` still proves the checkpoint-bounded +/// cut, so the physical successor cannot suppress otherwise eligible destructive work. +TEST(CASGCFrontierGate, CheckpointFrontierBoundsOrdinaryFoldBeforeDurableSuccessor) +{ + auto backend = std::make_shared(); + auto store = openPoolForTest(backend, /*gc_fold_max_defer_rounds=*/0); + const Layout & layout = store->layout(); + const RootNamespace ns{"00/checkpoint-bounds-fold@cas@"}; + const DB::UInt128 committed_blob(0xf1); + const DB::UInt128 beyond_frontier_blob(0xf2); + + fixture::admitLive(*backend, layout, ns); + publish(*backend, layout, ns, "committed", 1, committed_blob); + const ManifestRef uncommitted{.writer_epoch = 1, .build_sequence = 2, .manifest_ordinal = 1}; + writeBlobBody(*backend, layout, beyond_frontier_blob); + writeManifestRaw(*backend, layout, ns, uncommitted, {blobEntryFor("data.bin", beyond_frontier_blob)}); + fixture::writeRefLogRaw(*backend, layout, RefLogTxn{ + .ns = ns.string(), + .txn_id = RefTxnId{1, 2}, + .ops = publishCommittedOps("durable_but_uncommitted", uncommitted), + .prev_epoch_seal = std::nullopt, + }); + replaceRecoverableCkptForRawFixture(*backend, layout, ns, RefCkpt{ + .life_epoch = 1, + .committed_through = RefTxnId{1, 1}, + .checkpoint_snapshot_id = std::nullopt, + .last_epoch_seal = std::nullopt, + }); + + std::map intake; + Gc gc(store, kGc); + gc.setPhaseSink([&](const GcPhaseRecord & rec) + { + if (rec.phase == "fold_ref_intake") + intake = rec.metrics; + }); + backend->resetCounts(); + const RoundReport report = runRegularRoundReclaiming(gc); + ASSERT_TRUE(report.acquired_lease); + gc.setPhaseSink({}); + + const NamespaceLifeId life = *CasRefCatalog::lifeIfCataloged(*backend, layout, ns); + EXPECT_EQ(sealedCursorOf(*backend, layout, ns), (RefTxnId{1, 1})); + EXPECT_EQ(inDegreeOf(*backend, layout, beyond_frontier_blob), 0) + << "a durable log above `_ckpt.committed_through` is not foldable history"; + EXPECT_EQ(backend->getCount(layout.refLogKey(life, RefTxnId{1, 2})), 0u) + << "the checkpoint frontier stops the walk before `F+1`"; + EXPECT_EQ(backend->getCount(layout.refLogKey(life, RefTxnId{1, 3})), 0u) + << "a 404 above `F+1` must not authorize the destructive frontier"; + EXPECT_TRUE(report.anomalies.empty()); + ASSERT_FALSE(intake.empty()); + EXPECT_EQ(intake["frontier_proven"], intake["frontier_namespaces"]) + << "the consumed checkpoint frontier, not the physical successor, authorizes this cut"; +} + +/// With no physical successor at all, consuming the exact inclusive checkpoint frontier proves this +/// catalog life complete. This is the control for the same bounded-cut proof exercised with a durable +/// uncommitted successor above. +TEST(CASGCFrontierGate, ConsumedCheckpointFrontierProvesOrdinaryLifeWithoutSuccessor) +{ + auto backend = std::make_shared(); + auto store = openPoolForTest(backend, /*gc_fold_max_defer_rounds=*/0); + const Layout & layout = store->layout(); + const RootNamespace ns{"00/checkpoint-complete-fold@cas@"}; + + fixture::admitLive(*backend, layout, ns); + publish(*backend, layout, ns, "committed", 1, DB::UInt128(0xf3)); + replaceRecoverableCkptForRawFixture(*backend, layout, ns, RefCkpt{ + .life_epoch = 1, + .committed_through = RefTxnId{1, 1}, + .checkpoint_snapshot_id = std::nullopt, + .last_epoch_seal = std::nullopt, + }); + + std::map intake; + Gc gc(store, kGc); + gc.setPhaseSink([&](const GcPhaseRecord & rec) + { + if (rec.phase == "fold_ref_intake") + intake = rec.metrics; + }); + backend->resetCounts(); + const RoundReport report = runRegularRoundReclaiming(gc); + ASSERT_TRUE(report.acquired_lease); + gc.setPhaseSink({}); + + const NamespaceLifeId life = *CasRefCatalog::lifeIfCataloged(*backend, layout, ns); + EXPECT_EQ(sealedCursorOf(*backend, layout, ns), (RefTxnId{1, 1})); + EXPECT_EQ(backend->getCount(layout.refLogKey(life, RefTxnId{1, 2})), 0u) + << "the checkpoint boundary proves the cut without a post-frontier 404"; + EXPECT_TRUE(report.anomalies.empty()); + ASSERT_FALSE(intake.empty()); + EXPECT_EQ(intake["frontier_proven"], intake["frontier_namespaces"]); +} + +/// The same cut remains complete when the durable uncommitted successor is hidden from every LIST. +/// Exact reads still serve that successor, but the checkpoint ceiling must leave it untouched and must +/// not let the list omission suppress the checkpoint-bounded destructive path. +TEST(CASGCFrontierGate, CheckpointFrontierProvesLifeWithHiddenDurableSuccessor) +{ + auto backend = std::make_shared(); + auto store = openPoolForTest(backend, /*gc_fold_max_defer_rounds=*/0); + const Layout & layout = store->layout(); + const RootNamespace ns{"00/checkpoint-hidden-successor@cas@"}; + const DB::UInt128 beyond_frontier_blob(0xf4); + + fixture::admitLive(*backend, layout, ns); + publish(*backend, layout, ns, "committed", 1, DB::UInt128(0xf5)); + const ManifestRef uncommitted{.writer_epoch = 1, .build_sequence = 2, .manifest_ordinal = 1}; + writeBlobBody(*backend, layout, beyond_frontier_blob); + writeManifestRaw(*backend, layout, ns, uncommitted, {blobEntryFor("data.bin", beyond_frontier_blob)}); + fixture::writeRefLogRaw(*backend, layout, RefLogTxn{ + .ns = ns.string(), + .txn_id = RefTxnId{1, 2}, + .ops = publishCommittedOps("hidden_durable_but_uncommitted", uncommitted), + .prev_epoch_seal = std::nullopt, + }); + replaceRecoverableCkptForRawFixture(*backend, layout, ns, RefCkpt{ + .life_epoch = 1, + .committed_through = RefTxnId{1, 1}, + .checkpoint_snapshot_id = std::nullopt, + .last_epoch_seal = std::nullopt, + }); + + const NamespaceLifeId life = *CasRefCatalog::lifeIfCataloged(*backend, layout, ns); + backend->hide(layout.refLogKey(life, RefTxnId{1, 2})); + + std::map intake; + Gc gc(store, kGc); + gc.setPhaseSink([&](const GcPhaseRecord & rec) + { + if (rec.phase == "fold_ref_intake") + intake = rec.metrics; + }); + backend->resetCounts(); + const RoundReport report = runRegularRoundReclaiming(gc); + ASSERT_TRUE(report.acquired_lease); + gc.setPhaseSink({}); + + EXPECT_GT(backend->holesServed(), 0u) << "the F+1 log must really be hidden from LIST"; + EXPECT_EQ(sealedCursorOf(*backend, layout, ns), (RefTxnId{1, 1})); + EXPECT_EQ(inDegreeOf(*backend, layout, beyond_frontier_blob), 0); + EXPECT_EQ(backend->getCount(layout.refLogKey(life, RefTxnId{1, 2})), 0u) + << "the hidden durable successor is outside the checkpoint cut"; + EXPECT_TRUE(report.anomalies.empty()); + ASSERT_FALSE(intake.empty()); + EXPECT_EQ(intake["frontier_proven"], intake["frontier_namespaces"]); +} + +/// The checkpoint's inclusive endpoint is itself a durable witness. If that exact log is absent, +/// the namespace is corrupt rather than complete; a 404 at the endpoint must not authorize cleanup. +TEST(CASGCFrontierGate, MissingCommittedCheckpointLogHoldsInsteadOfProvingTheFrontier) +{ + auto backend = std::make_shared(); + auto store = openPoolForTest(backend, /*gc_fold_max_defer_rounds=*/0); + const Layout & layout = store->layout(); + const RootNamespace ns{"00/missing-committed-checkpoint-log@cas@"}; + + fixture::admitLive(*backend, layout, ns); + publish(*backend, layout, ns, "first", 1, DB::UInt128(0xf6)); + publish(*backend, layout, ns, "missing_but_committed", 2, DB::UInt128(0xf7)); + replaceRecoverableCkptForRawFixture(*backend, layout, ns, RefCkpt{ + .life_epoch = 1, + .committed_through = RefTxnId{1, 2}, + .checkpoint_snapshot_id = std::nullopt, + .last_epoch_seal = std::nullopt, + }); + + const NamespaceLifeId life = *CasRefCatalog::lifeIfCataloged(*backend, layout, ns); + const String missing_key = layout.refLogKey(life, RefTxnId{1, 2}); + const HeadResult missing_head = backend->head(missing_key); + ASSERT_TRUE(missing_head.exists); + ASSERT_EQ(backend->deleteExact(missing_key, missing_head.token).kind, DeleteOutcome::Kind::Deleted); + + std::map intake; + Gc gc(store, kGc); + gc.setPhaseSink([&](const GcPhaseRecord & rec) + { + if (rec.phase == "fold_ref_intake") + intake = rec.metrics; + }); + const RoundReport report = runRegularRoundReclaiming(gc); + ASSERT_TRUE(report.acquired_lease); + gc.setPhaseSink({}); + + EXPECT_EQ(sealedCursorOf(*backend, layout, ns), (RefTxnId{1, 1})); + EXPECT_FALSE(report.anomalies.empty()) << "the missing committed checkpoint record is corruption"; + ASSERT_FALSE(intake.empty()); + EXPECT_LT(intake["frontier_proven"], intake["frontier_namespaces"]); +} + +/// A checkpoint may name a durable record that the round's LIST omitted. Exact GETs must still fold +/// that committed record; the frozen list tail is only a scheduling hint, never a history boundary. +TEST(CASGCFrontierGate, HiddenCommittedCheckpointLogIsFoldedThroughTheAuthorityCeiling) +{ + auto backend = std::make_shared(); + auto store = openPoolForTest(backend, /*gc_fold_max_defer_rounds=*/0); + const Layout & layout = store->layout(); + const RootNamespace ns{"00/hidden-committed-checkpoint-log@cas@"}; + const DB::UInt128 hidden_blob(0xf8); + + fixture::admitLive(*backend, layout, ns); + publish(*backend, layout, ns, "first", 1, DB::UInt128(0xf9)); + publish(*backend, layout, ns, "hidden_but_committed", 2, hidden_blob); + replaceRecoverableCkptForRawFixture(*backend, layout, ns, RefCkpt{ + .life_epoch = 1, + .committed_through = RefTxnId{1, 2}, + .checkpoint_snapshot_id = std::nullopt, + .last_epoch_seal = std::nullopt, + }); + + const NamespaceLifeId life = *CasRefCatalog::lifeIfCataloged(*backend, layout, ns); + backend->hide(layout.refLogKey(life, RefTxnId{1, 2})); + + std::map intake; + Gc gc(store, kGc); + gc.setPhaseSink([&](const GcPhaseRecord & rec) + { + if (rec.phase == "fold_ref_intake") + intake = rec.metrics; + }); + const RoundReport report = runRegularRoundReclaiming(gc); + ASSERT_TRUE(report.acquired_lease); + gc.setPhaseSink({}); + + EXPECT_GT(backend->holesServed(), 0u) << "the committed endpoint must really be omitted from LIST"; + EXPECT_EQ(sealedCursorOf(*backend, layout, ns), (RefTxnId{1, 2})); + EXPECT_GT(inDegreeOf(*backend, layout, hidden_blob), 0); + EXPECT_TRUE(report.anomalies.empty()); + ASSERT_FALSE(intake.empty()); + EXPECT_EQ(intake["frontier_proven"], intake["frontier_namespaces"]); +} + +/// A valid checkpoint with no committed record is an authoritative empty history. It is complete for +/// a never-folded life without probing a fabricated first transaction. +TEST(CASGCFrontierGate, EmptyCheckpointFrontierProvesAnUnfoldedLife) +{ + auto backend = std::make_shared(); + auto store = openPoolForTest(backend, /*gc_fold_max_defer_rounds=*/0); + const Layout & layout = store->layout(); + const RootNamespace ns{"00/empty-checkpoint-frontier@cas@"}; + + casAdmitRecoverableEntry(*backend, layout, ns); + + std::map intake; + Gc gc(store, kGc); + gc.setPhaseSink([&](const GcPhaseRecord & rec) + { + if (rec.phase == "fold_ref_intake") + intake = rec.metrics; + }); + const RoundReport report = runRegularRoundReclaiming(gc); + ASSERT_TRUE(report.acquired_lease); + gc.setPhaseSink({}); + + EXPECT_TRUE(report.anomalies.empty()); + ASSERT_FALSE(intake.empty()); + EXPECT_EQ(intake["frontier_proven"], intake["frontier_namespaces"]); +} + +/// Empty history cannot explain an inherited cursor. An operator-corrupted checkpoint that erases its +/// own committed boundary must clamp the life rather than silently authorize destruction. +TEST(CASGCFrontierGate, EmptyCheckpointFrontierRejectsAnInheritedCursor) +{ + auto backend = std::make_shared(); + auto store = openPoolForTest(backend, /*gc_fold_max_defer_rounds=*/0); + const Layout & layout = store->layout(); + const RootNamespace ns{"00/empty-checkpoint-after-cursor@cas@"}; + + fixture::admitLive(*backend, layout, ns); + publish(*backend, layout, ns, "first", 1, DB::UInt128(0xfa)); + replaceRecoverableCkptForRawFixture(*backend, layout, ns, RefCkpt{ + .life_epoch = 1, + .committed_through = RefTxnId{1, 1}, + .checkpoint_snapshot_id = std::nullopt, + .last_epoch_seal = std::nullopt, + }); + + Gc gc(store, kGc); + ASSERT_TRUE(runRegularRoundReclaiming(gc).acquired_lease); + ASSERT_EQ(sealedCursorOf(*backend, layout, ns), (RefTxnId{1, 1})); + + const NamespaceLifeId life = *CasRefCatalog::lifeIfCataloged(*backend, layout, ns); + const String checkpoint_key = layout.refCkptKey(life); + const HeadResult checkpoint_head = backend->head(checkpoint_key); + ASSERT_TRUE(checkpoint_head.exists); + ASSERT_EQ(backend->putOverwrite(checkpoint_key, encodeRefCkpt(RefCkpt{ + .life_epoch = 1, + .committed_through = std::nullopt, + .checkpoint_snapshot_id = std::nullopt, + .last_epoch_seal = std::nullopt, + }), checkpoint_head.token).outcome, PutOutcome::Done); + + std::map intake; + gc.setPhaseSink([&](const GcPhaseRecord & rec) + { + if (rec.phase == "fold_ref_intake") + intake = rec.metrics; + }); + const RoundReport report = runRegularRoundReclaiming(gc); + ASSERT_TRUE(report.acquired_lease); + gc.setPhaseSink({}); + + EXPECT_FALSE(report.anomalies.empty()) << "an empty checkpoint cannot explain a nonzero cursor"; + ASSERT_FALSE(intake.empty()); + EXPECT_LT(intake["frontier_proven"], intake["frontier_namespaces"]); +} + +/// A catalog `Live` life without its exact checkpoint cannot derive either its genesis or a frontier +/// from the ref LIST. Even a durable listed first log must be retained until the authority is repaired. +TEST(CASGCFrontierGate, CatalogLifeWithoutCheckpointDefersWithoutUsingListedFrontier) +{ + auto backend = std::make_shared(); + auto store = openPoolForTest(backend, /*gc_fold_max_defer_rounds=*/0); + const Layout & layout = store->layout(); + const RootNamespace ns{"00/missing-checkpoint-fold@cas@"}; + const DB::UInt128 blob(0xc7); + + fixture::admitLive(*backend, layout, ns); + const ManifestRef manifest{.writer_epoch = 1, .build_sequence = 1, .manifest_ordinal = 1}; + writeBlobBody(*backend, layout, blob); + writeManifestRaw(*backend, layout, ns, manifest, {blobEntryFor("data.bin", blob)}); + appendRefLogSeed(*backend, layout, ns, publishCommittedOps("must_remain_unfolded", manifest)); + + const NamespaceLifeId life = *CasRefCatalog::lifeIfCataloged(*backend, layout, ns); + ASSERT_FALSE(readCkpt(*backend, layout, life).has_value()); + + std::map intake; + Gc gc(store, kGc); + gc.setPhaseSink([&](const GcPhaseRecord & rec) + { + if (rec.phase == "fold_ref_intake") + intake = rec.metrics; + }); + backend->resetCounts(); + ASSERT_TRUE(runRegularRoundReclaiming(gc).acquired_lease); + gc.setPhaseSink({}); + + EXPECT_EQ(foldCursorOf(*backend, layout, ns, /*shard=*/0), 0u); + EXPECT_EQ(inDegreeOf(*backend, layout, blob), 0) + << "a missing checkpoint must defer rather than fold the listed log"; + EXPECT_EQ(backend->getCount(layout.refLogKey(life, RefTxnId{1, 1})), 0u) + << "the listed log is not authority for a checkpoint-less catalog life"; + EXPECT_EQ(backend->getCount(layout.refLogKey(life, RefTxnId{1, 2})), 0u) + << "the next 404 is not authority for a checkpoint-less catalog life"; + EXPECT_EQ(backend->deleteTotal(), 0u) << deletedKeysMessage(*backend); + ASSERT_FALSE(intake.empty()); + EXPECT_LT(intake["frontier_proven"], intake["frontier_namespaces"]); +} + +/// A valid checkpoint frontier proves a quiet unhinted life without spending the successor-probe budget. +/// A zero budget therefore cannot suppress unrelated destructive work merely because this life is absent +/// from LIST. +TEST(CASGCFrontierGate, AnExhaustedProbeBudgetSealsCursorsAndDeletesNothing) +{ + auto backend = std::make_shared(); + auto store = openPoolWithProbeBudget(backend, /*budget*/ 0); + const Layout & layout = store->layout(); + const RootNamespace quiet{"00/quiet@cas@"}; + const RootNamespace busy{"00/busy@cas@"}; + const DB::UInt128 blob(0xbeef); + + publish(*backend, layout, quiet, "quiet_ref", 1, DB::UInt128(0x11)); + const ManifestRef mref = publish(*backend, layout, busy, "busy_ref", 2, blob); + + Gc gc(store, kGc); + runRegularRoundReclaiming(gc); + store->renewWatermarkOnce(); + const RefTxnId quiet_cursor = sealedCursorOf(*backend, layout, quiet); + ASSERT_NE(quiet_cursor, (RefTxnId{})); + + /// The quiet namespace goes unhinted and the budget is zero. Its CTE still proves the carried + /// cursor, while the busy namespace drops its ref and may proceed through reclamation. + backend->hidePrefix(layout.namespaceStreamPrefix(fixture::fixtureLife(quiet))); + dropRefTransition(*backend, layout, busy, "busy_ref", mref); + + backend->resetCounts(); + drive(store, gc, /*rounds*/ 5, UniversePolicy::Authoritative); + + EXPECT_GT(backend->deleteTotal(), 0u) + << "the quiet life's checkpoint authority leaves unrelated deletion eligible"; + EXPECT_FALSE(backend->head(blobKeyOf(layout, blob)).exists) + << "the busy life's removal remains reclaimable despite the quiet LIST omission"; + EXPECT_EQ(sealedCursorOf(*backend, layout, quiet), quiet_cursor) + << "the unprobed namespace's cursor rides verbatim -- it is never dropped"; + const NamespaceLifeId quiet_life = *CasRefCatalog::lifeIfCataloged(*backend, layout, quiet); + const auto quiet_checkpoint = readCkpt(*backend, layout, quiet_life); + ASSERT_TRUE(quiet_checkpoint.has_value()); + EXPECT_EQ(quiet_checkpoint->ckpt.committed_through, quiet_cursor) + << "the quiet life's valid CTE is unaffected by LIST omission and a zero probe budget"; + EXPECT_GT(decodeGcState(backend->get(layout.gcStateKey())->bytes).round, 1u) + << "the round still commits; only its destructive half is withheld"; +} + +/// ===================== A COMMITTED GAP IS REDETECTED UNTIL REPAIRED ===================== +/// +/// A hold's committed checkpoint frontier remains a durable witness of its own gap. Hiding the later +/// log from LIST cannot make that gap quiet: every retry exact-reads the missing position, redetects the +/// hold, and suppresses destructive work until an operator repairs the record stream. +TEST(CASGCFrontierGate, ACommittedGapIsRedetectedAndSuppressesEveryRound) +{ + auto backend = std::make_shared(); + auto store = openPoolForTest(backend, /*gc_fold_max_defer_rounds=*/0); + const Layout & layout = store->layout(); + const RootNamespace held{"00/held@cas@"}; + const RootNamespace busy{"00/busy@cas@"}; + const DB::UInt128 blob(0xbeef); + + /// {1,3} never existed while {1,4} is durable and listed. + publish(*backend, layout, held, "ref_1", 1, DB::UInt128(0x21)); + publish(*backend, layout, held, "ref_2", 2, DB::UInt128(0x22)); + const ManifestRef orphan_ref{.writer_epoch = 1, .build_sequence = 4, .manifest_ordinal = 1}; + writeBlobBody(*backend, layout, DB::UInt128(0x24)); + writeManifestRaw(*backend, layout, held, orphan_ref, {blobEntryFor("data.bin", DB::UInt128(0x24))}); + RefLogTxn txn; + txn.ns = held.string(); + txn.txn_id = RefTxnId{1, 4}; + txn.ops = publishCommittedOps("ref_4", orphan_ref); + fixture::writeRefLogRaw(*backend, layout, txn); + replaceRecoverableCkptForRawFixture(*backend, layout, held, RefCkpt{ + .life_epoch = 1, + .committed_through = RefTxnId{1, 4}, + .checkpoint_snapshot_id = std::nullopt, + .last_epoch_seal = std::nullopt, + }); + + Gc gc(store, kGc); + std::map first_intake; + gc.setPhaseSink([&](const GcPhaseRecord & rec) + { + if (rec.phase == "fold_ref_intake") + first_intake = rec.metrics; + }); + const RoundReport first_round = runRegularRoundReclaiming(gc); + gc.setPhaseSink({}); + store->renewWatermarkOnce(); + ASSERT_EQ(sealedCursorOf(*backend, layout, held), (RefTxnId{1, 2})) + << "round 1 must stop below the gap and hold there"; + ASSERT_FALSE(first_intake.empty()); + EXPECT_GT(first_intake["tables_clamped"], 0u); + EXPECT_GT(first_intake["tables_held"], 0u); + EXPECT_FALSE(first_round.anomalies.empty()); + + const NamespaceLifeId held_life = *CasRefCatalog::lifeIfCataloged(*backend, layout, held); + const auto held_checkpoint = readCkpt(*backend, layout, held_life); + ASSERT_TRUE(held_checkpoint.has_value()); + EXPECT_EQ(held_checkpoint->ckpt.committed_through, (RefTxnId{1, 4})); + + /// Hiding `{1,4}` from LIST does not hide the committed CTE frontier. The next round exact-reads + /// the missing `{1,3}`, re-detects the gap, and seals a fresh hold. + backend->hidePrefix(layout.refLogKey(fixture::fixtureLife(held), RefTxnId{1, 4})); + + /// Meanwhile a blob elsewhere becomes condemnable, so the round has real destructive work to decline. + const ManifestRef mref = publish(*backend, layout, busy, "busy_ref", 9, blob); + dropRefTransition(*backend, layout, busy, "busy_ref", mref); + + backend->resetCounts(); + std::map second_intake; + gc.setPhaseSink([&](const GcPhaseRecord & rec) + { + if (rec.phase == "fold_ref_intake") + second_intake = rec.metrics; + }); + const RoundReport second_round = runRegularRoundReclaiming(gc); + gc.setPhaseSink({}); + store->renewWatermarkOnce(); + + ASSERT_FALSE(second_intake.empty()); + EXPECT_GT(second_intake["tables_clamped"], 0u) + << "the committed `{1,4}` frontier is a durable witness that re-detects the missing `{1,3}`"; + EXPECT_GT(second_intake["tables_held"], 0u) + << "the fresh clamp preserves the unresolved hold in the next sealed coverage"; + EXPECT_FALSE(second_round.anomalies.empty()); + + drive(store, gc, /*rounds*/ 4, UniversePolicy::Authoritative); + + EXPECT_EQ(backend->deleteTotal(), 0u) + << "the re-detected committed gap suppresses each round's destructive work. " + "Deleted:" << deletedKeysMessage(*backend); + EXPECT_TRUE(backend->head(blobKeyOf(layout, blob)).exists); + EXPECT_EQ(sealedCursorOf(*backend, layout, held), (RefTxnId{1, 2})) + << "the committed gap remains unresolved and the cursor cannot advance through it"; + const auto final_checkpoint = readCkpt(*backend, layout, held_life); + ASSERT_TRUE(final_checkpoint.has_value()); + EXPECT_EQ(final_checkpoint->ckpt.committed_through, (RefTxnId{1, 4})); +} + +/// ===================== THE TEMPORAL LEMMA, ALL THREE ARMS ===================== +/// +/// The gate says WHEN a round may destroy. These say that even a round which may destroy cannot +/// destroy a blob some edge still owns, over the three interleavings that matter. + +/// ARM (a): a `+1` that lands after this round's probes and is followed by the SAME round's +/// condemnation. Round pacing makes it safe on its own: an entry condemned at round K cannot graduate +/// before K+1 and cannot be deleted before K+2, so the round that condemns never deletes. +TEST(CASGCFrontierGate, ABlobCondemnedThisRoundIsNeverDeletedThisRound) +{ + auto backend = std::make_shared(); + auto store = openPoolForTest(backend, /*gc_fold_max_defer_rounds=*/0); + const Layout & layout = store->layout(); + const RootNamespace ns{"00/aa@cas@"}; + const DB::UInt128 blob(0xc04d); + + const ManifestRef mref = publish(*backend, layout, ns, "ref_1", 1, blob); + Gc gc(store, kGc); + runRegularRoundReclaiming(gc); + store->renewWatermarkOnce(); + + dropRefTransition(*backend, layout, ns, "ref_1", mref); + backend->resetCounts(); + runRegularRoundReclaiming(gc); + + EXPECT_TRUE(backend->head(blobKeyOf(layout, blob)).exists) + << "the condemning round must not also delete"; + EXPECT_EQ(backend->deleteCount(blobKeyOf(layout, blob)), 0u) + << "not merely still present -- the delete was never attempted"; +} + +/// ARM (c) of the temporal lemma is the delete-site in-degree re-read, and it is NORMATIVE (spec §5, +/// third arm): an edge folded AFTER the condemnation but BEFORE the delete pass spares the blob +/// outright, `indeg > 0` winning over `delete_pending` past the floor. The other two arms bound WHEN +/// and WHAT a delete may remove; only this one asks whether the blob is still referenced at the moment +/// the pass decides. +TEST(CASGCFrontierGate, ALateEdgeSparesADeletePendingBlobAtTheDeleteSite) +{ + auto backend = std::make_shared(); + auto store = openPoolForTest(backend, /*gc_fold_max_defer_rounds=*/0); + const Layout & layout = store->layout(); + const RootNamespace ns{"00/aa@cas@"}; + const DB::UInt128 blob(0x1a7e); + + const ManifestRef mref = publish(*backend, layout, ns, "ref_1", 1, blob); + Gc gc(store, kGc); + runRegularRoundReclaiming(gc); + store->renewWatermarkOnce(); + + /// Condemn it, then graduate it to delete_pending. + dropRefTransition(*backend, layout, ns, "ref_1", mref); + runRegularRoundReclaiming(gc); /// condemn + store->renewWatermarkOnce(); + runRegularRoundReclaiming(gc); /// graduate: delete_pending published + store->renewWatermarkOnce(); + + /// A new owner appears BEFORE the delete pass. The pass recomputes the in-degree from the merge it + /// just ran and finds it nonzero. + const ManifestRef revived{.writer_epoch = 1, .build_sequence = 42, .manifest_ordinal = 1}; + writeManifestRaw(*backend, layout, ns, revived, {blobEntryFor("data.bin", blob)}); + publishCommittedTransition(*backend, layout, ns, "revived_ref", std::nullopt, revived); + + runRegularRoundReclaiming(gc); + store->renewWatermarkOnce(); + + EXPECT_TRUE(backend->head(blobKeyOf(layout, blob)).exists) + << "the delete-site in-degree re-read spares a blob a fresh edge re-referenced"; + EXPECT_GT(inDegreeOf(*backend, layout, blob), 0); +} + +/// ARM (b): a TOKENED adoption of an already-delete-pending blob. The writer's admit gate reads the +/// `Condemned` meta, refuses to adopt the dying incarnation, and rematerializes from its own source as +/// a FRESH incarnation -- so the delayed exact-token delete the previous round published finds a +/// different token and removes nothing. The blob's identity is preserved by re-upload, never by +/// reviving the condemned object. +TEST(CASGCFrontierGate, AResurrectedIncarnationSurvivesTheDelayedStaleTokenDelete) +{ + ensureBlobUploadPoolForTest(); + + auto backend = std::make_shared(); + auto store = openPoolForTest(backend, /*gc_fold_max_defer_rounds=*/0); + const Layout & layout = store->layout(); + const RootNamespace ns{"00/aa@cas@"}; + + /// A REAL content-addressed blob, so the writer path below addresses exactly the object GC condemns. + const String payload = "frontier-gate-resurrect-payload"; + const DB::UInt128 hash = u128Of(payload); + const BlobRef id = idOf(payload); + const String key = layout.blobKey(id); + String raw_body(store->poolMeta().blob_header_len, '\0'); + raw_body += payload; + writeRawBlobBody(*backend, layout, hash, raw_body); + + /// Publish and drop it so GC condemns and then graduates it to delete_pending. + const ManifestRef mref{.writer_epoch = 1, .build_sequence = 1, .manifest_ordinal = 1}; + writeManifestRaw(*backend, layout, ns, mref, {blobEntryFor("data.bin", hash)}); + publishCommittedTransition(*backend, layout, ns, "ref_1", std::nullopt, mref); + + Gc gc(store, kGc); + runRegularRoundReclaiming(gc); + store->renewWatermarkOnce(); + dropRefTransition(*backend, layout, ns, "ref_1", mref); + runRegularRoundReclaiming(gc); /// condemn: writes the durable Condemned meta + store->renewWatermarkOnce(); + runRegularRoundReclaiming(gc); /// graduate: publishes delete_pending against THIS token + store->renewWatermarkOnce(); + + const Token condemned_token = backend->head(key).token; + const auto condemned_meta = loadMetaForTest(*backend, layout, hash); + ASSERT_TRUE(condemned_meta.has_value()); + ASSERT_EQ(condemned_meta->meta.state, MetaState::Condemned) + << "the delete GC is about to execute must be backed by durable Condemned evidence"; + + /// A writer now adopts the blob through the REAL admit gate. It point-reads the Condemned meta, + /// refuses to adopt the dying incarnation, and rematerializes from its OWN source bytes -- never by + /// reading the condemned object. The key ends up holding a DIFFERENT incarnation. + auto build = store->beginPartWrite({}); + const PutBlobResult uploaded = build->putBlob(id, BlobSource::fromString(payload)); + EXPECT_EQ(uploaded.ref, id); + const Token fresh_token = backend->head(key).token; + ASSERT_NE(fresh_token, condemned_token) << "a resurrect must displace the condemned incarnation"; + + /// GC's delayed delete still names the OLD token. It cannot touch the new object. + drive(store, gc, /*rounds*/ 2, UniversePolicy::Authoritative); + + ASSERT_TRUE(backend->head(key).exists) + << "the resurrected incarnation survives the delete published against its predecessor"; + EXPECT_EQ(backend->head(key).token, fresh_token) << "and it is still the writer's incarnation"; + EXPECT_EQ(backend->deleteExact(key, condemned_token).kind, DeleteOutcome::Kind::TokenMismatch) + << "the condemned token can never remove the fresh object (INV-NO-RETURN)"; +} + +/// ARM (c): a TOKENLESS relink -- the receiver adopts by evidence, holding no token at all. Safety +/// then rests entirely on ORDER, so the operation journal has to show it: the receiver's `+1` is +/// durable BEFORE the source releases its own committed edge, and no point in the schedule leaves the +/// blob with zero durable owners. +TEST(CASGCFrontierGate, ATokenlessRelinkMakesTheReceiverEdgeDurableBeforeTheSourceReleases) +{ + auto backend = std::make_shared(); + auto store = openPoolForTest(backend, /*gc_fold_max_defer_rounds=*/0); + const Layout & layout = store->layout(); + const RootNamespace source{"00/source@cas@"}; + const RootNamespace receiver{"00/receiver@cas@"}; + const DB::UInt128 blob(0x8e11); + + const ManifestRef source_ref = publish(*backend, layout, source, "part_1", 1, blob); + Gc gc(store, kGc); + runRegularRoundReclaiming(gc); + store->renewWatermarkOnce(); + ASSERT_GT(inDegreeOf(*backend, layout, blob), 0); + + /// The relink, in the only order the protocol permits: the receiver's manifest body and its + /// committed edge first (tokenless -- it never HEADs the blob), and only afterwards the source's + /// removal. Between the two writes the blob has TWO durable owners; it never has zero. + const ManifestRef receiver_ref{.writer_epoch = 1, .build_sequence = 5, .manifest_ordinal = 1}; + writeManifestRaw(*backend, layout, receiver, receiver_ref, {blobEntryFor("data.bin", blob)}); + publishCommittedTransition(*backend, layout, receiver, "part_1", std::nullopt, receiver_ref); + + /// The round that observes ONLY the receiver's `+1` -- the exact midpoint of the schedule. + runRegularRoundReclaiming(gc); + store->renewWatermarkOnce(); + EXPECT_GE(inDegreeOf(*backend, layout, blob), 2) + << "at the midpoint both owners are durable; the handoff never dips to zero"; + + dropRefTransition(*backend, layout, source, "part_1", source_ref); + drive(store, gc, /*rounds*/ 4, UniversePolicy::Authoritative); + + EXPECT_TRUE(backend->head(blobKeyOf(layout, blob)).exists) + << "the source released its edge only after the receiver's was durable, so nothing may collect it"; + EXPECT_EQ(inDegreeOf(*backend, layout, blob), 1) + << "the receiver is the sole remaining owner"; +} + +/// ===================== CLEANUP RANGES ARE COMPUTED, NOT ENUMERATED ===================== +/// +/// `planRefCleanup` is pure, so the boundary arithmetic is pinned directly rather than inferred from a +/// round's side effects. Its sole coverage authority is the checkpoint-named base; a listed snapshot +/// is merely a physical observation until the same-id triple has been validated. + +TEST(CASGCFrontierGateCleanupRange, CoveredLogsStopAtTheMinimumOfCheckpointAndCursor) +{ + RefTableListing listing; + listing.logs = {{1, 1}, {1, 2}, {1, 3}, {1, 4}, {1, 5}}; + listing.snapshots = {{1, 5}}; + + /// No checkpoint means no recovery base at all. A snapshot PUT that has not reached the `_ckpt` + /// CAS must retain every listed object. + const RefCleanupPlan without = planRefCleanup(listing, RefTxnId{1, 4}); + EXPECT_TRUE(without.deletable_logs.empty()); + EXPECT_TRUE(without.deletable_snapshots.empty()); + + /// A checkpoint BELOW the cursor tightens it to {1,2}. Its exact `_log` witness must survive, + /// so cleanup may remove only the strictly older entry. + const RefCleanupPlan with = planRefCleanup(listing, RefTxnId{1, 4}, RefTxnId{1, 2}); + EXPECT_EQ(with.deletable_logs, (std::vector{{1, 1}})) + << "the checkpoint witness and everything above it must survive"; + + /// Once validation has established a later checkpoint base, its earlier covered history is + /// reclaimable even if the hot fold cursor has not yet reached that base. + const RefCleanupPlan ahead = planRefCleanup(listing, RefTxnId{1, 4}, RefTxnId{1, 9}); + EXPECT_EQ(ahead.deletable_logs, (std::vector{{1, 1}, {1, 2}, {1, 3}, {1, 4}})); + EXPECT_EQ(ahead.deletable_snapshots, (std::vector{{1, 5}})); +} + +TEST(CASGCFrontierGateCleanupRange, ASnapshotAtTheCheckpointSurvivesAndOnlyStrictlyOlderOnesGo) +{ + RefTableListing listing; + listing.logs = {{1, 1}, {1, 2}, {1, 3}}; + listing.snapshots = {{1, 1}, {1, 2}, {1, 3}}; + + /// A LIST-only newest snapshot is never a cleanup boundary. + const RefCleanupPlan without = planRefCleanup(listing, RefTxnId{1, 3}); + EXPECT_TRUE(without.deletable_snapshots.empty()); + + /// With the checkpoint AT {1,2}, only {1,1} is strictly below it. The snapshot the checkpoint names + /// is the one a recovering reader samples, so it must survive its own cleanup. + const RefCleanupPlan with = planRefCleanup(listing, RefTxnId{1, 3}, RefTxnId{1, 2}); + EXPECT_EQ(with.deletable_snapshots, (std::vector{{1, 1}})); + + /// The oldest checkpoint deletes nothing at all. + const RefCleanupPlan oldest = planRefCleanup(listing, RefTxnId{1, 3}, RefTxnId{1, 1}); + EXPECT_TRUE(oldest.deletable_snapshots.empty()); +} + +/// Cleanup shares recovery's validator rather than inferring its own authority from a LIST. The +/// missing-base case is the no-checkpoint range above; the three physical triple failures below must +/// each reject exactly the checkpoint-named candidate. +TEST(CASGCFrontierGateCleanupRange, CheckpointBaseValidatorRejectsMissingLogSnapshotAndSeal) +{ + auto backend = std::make_shared(); + const Layout layout{"p"}; + const RefTxnId base{1, 1}; + const RefCkpt checkpoint{ + .life_epoch = 1, + .committed_through = base, + .checkpoint_snapshot_id = base, + .last_epoch_seal = std::nullopt}; + CasRefCatalog::initializeEmptyForNewPool(*backend, layout); + + { + const RootNamespace ns{"00/cleanup-missing-base-log@cas@"}; + fixture::admitLive(*backend, layout, ns); + const NamespaceLifeId life = CasRefCatalog::lifeIfCataloged(*backend, layout, ns).value(); + writeRefSnapshotRaw(*backend, layout, minimalLiveSnapshot(ns.string(), base)); + EXPECT_THROW((void)readCheckpointSnapshotBase(*backend, layout, life, checkpoint), DB::Exception); + } + { + const RootNamespace ns{"00/cleanup-missing-base-snapshot@cas@"}; + fixture::writeRefLogRaw(*backend, layout, RefLogTxn{ + .ns = ns.string(), .txn_id = base, .ops = {namespaceBirthOp()}, .prev_epoch_seal = std::nullopt}); + const NamespaceLifeId life = CasRefCatalog::lifeIfCataloged(*backend, layout, ns).value(); + EXPECT_THROW((void)readCheckpointSnapshotBase(*backend, layout, life, checkpoint), DB::Exception); + } + { + const RootNamespace ns{"00/cleanup-seal-is-not-base@cas@"}; + writeSealAt(*backend, layout, ns, base); + const NamespaceLifeId life = CasRefCatalog::lifeIfCataloged(*backend, layout, ns).value(); + writeRefSnapshotRaw(*backend, layout, minimalLiveSnapshot(ns.string(), base)); + EXPECT_THROW((void)readCheckpointSnapshotBase(*backend, layout, life, checkpoint), DB::Exception); + } +} + +TEST(CASGCFrontierGateCleanupRange, LaterEpochBaseWithoutItsContextualBacklinkCannotLicenseDeletion) +{ + auto backend = std::make_shared(); + const Layout layout{"p"}; + CasRefCatalog::initializeEmptyForNewPool(*backend, layout); + const RefTxnId seal_id{1, 2}; + const RefTxnId base_id{2, 1}; + + const auto expect_no_deletion_authority = [&](const RootNamespace & ns, std::optional backlink) + { + fixture::writeRefLogRaw(*backend, layout, RefLogTxn{ + .ns = ns.string(), .txn_id = RefTxnId{1, 1}, .ops = {namespaceBirthOp()}, + .prev_epoch_seal = std::nullopt}); + writeSealAt(*backend, layout, ns, seal_id); + fixture::writeRefLogRaw(*backend, layout, RefLogTxn{ + .ns = ns.string(), .txn_id = base_id, .ops = {}, .prev_epoch_seal = backlink}); + writeRefSnapshotRaw(*backend, layout, minimalLiveSnapshot(ns.string(), base_id)); + const NamespaceLifeId life = CasRefCatalog::lifeIfCataloged(*backend, layout, ns).value(); + + std::optional validated_base; + try + { + (void)readCheckpointSnapshotBase(*backend, layout, life, RefCkpt{ + .life_epoch = 1, + .committed_through = base_id, + .checkpoint_snapshot_id = base_id, + .last_epoch_seal = seal_id}); + validated_base = base_id; + } + catch (const DB::Exception &) // NOLINT(bugprone-empty-catch): failure to validate is the tested case -- `validated_base` deliberately stays nullopt + { + } + + RefTableListing listing; + listing.logs = {{1, 1}, seal_id, base_id}; + listing.snapshots = {{1, 1}, base_id}; + const RefCleanupPlan plan = planRefCleanup(listing, base_id, validated_base); + EXPECT_TRUE(plan.deletable_logs.empty()); + EXPECT_TRUE(plan.deletable_snapshots.empty()); + }; + + expect_no_deletion_authority(RootNamespace{"00/cleanup-base-missing-backlink@cas@"}, std::nullopt); + expect_no_deletion_authority(RootNamespace{"00/cleanup-base-wrong-backlink@cas@"}, RefTxnId{1, 99}); +} + +/// Folding a namespace terminal records evidence but performs no lifecycle-specific physical cleanup. +/// The checkpoint is inert debris for the perpetual janitor, and no `_cleanup` marker is published. +TEST(CASGCFrontierGate, CleanupEvidenceLeavesRemovedNamespaceCheckpointForJanitor) +{ + auto backend = std::make_shared(); + auto store = openPoolForTest(backend, /*gc_fold_max_defer_rounds=*/0); + const Layout & layout = store->layout(); + const RootNamespace removed{"00/removed@cas@"}; + const RefOp birth_op = namespaceBirthOp(); + RefOp remove_op; + remove_op.kind = RefOpKind::RemoveNamespace; + fixture::writeRefLogRaw(*backend, layout, RefLogTxn{ + .ns = removed.string(), .txn_id = RefTxnId{1, 1}, .ops = {birth_op}, .prev_epoch_seal = std::nullopt}); + fixture::writeRefLogRaw(*backend, layout, RefLogTxn{ + .ns = removed.string(), .txn_id = RefTxnId{1, 2}, .ops = {remove_op}, .prev_epoch_seal = std::nullopt}); + const NamespaceLifeId life = CasRefCatalog::lifeIfCataloged(*backend, layout, removed).value(); + CasRefCatalog::casUpdate(*backend, layout, [&](const RefCatalog & current) + { + RefCatalog next = current; + const auto it = std::find_if(next.entries.begin(), next.entries.end(), [&](const CatalogEntry & entry) + { + return entry.ns == removed; + }); + EXPECT_NE(it, next.entries.end()); + it->state = NsState::Removing; + it->removal_started_round = 1; + return next; + }); + const String ckpt_key = layout.refCkptKey(life); + backend->putIfAbsent(ckpt_key, encodeRefCkpt(RefCkpt{ + .life_epoch = 1, + .committed_through = RefTxnId{1, 2}, + .checkpoint_snapshot_id = std::nullopt, + .last_epoch_seal = std::nullopt, + })); + + /// The removal evidence must arise from a replay-valid terminal lifecycle, rather than merely + /// from a raw terminal record that the recovery state machine refuses. + const RecoveredRefTable recovered = recoverRefTableDetailedAtCatalogCutForTest( + *backend, layout, CasRefCatalog::read(*backend, layout), removed); + EXPECT_EQ(recovered.state.getLifecycle(), RefLifecycle::Removed); + EXPECT_EQ(recovered.state.getRemoveTxnId(), (RefTxnId{1, 2})); + + Gc gc(store, kGc); + ASSERT_TRUE(runRegularRoundReclaiming(gc).acquired_lease); + + const GcState st = decodeGcState(backend->get(layout.gcStateKey())->bytes); + const CasFoldSeal seal = decodeFoldSeal( + backend->get(layout.foldSealKey(st.snap_generation, st.snap_attempt))->bytes); + const auto row_it = seal.ref_lives.find(life.incarnation); + ASSERT_NE(row_it, seal.ref_lives.end()); + ASSERT_TRUE(row_it->second.cleanup_evidence.has_value()); + EXPECT_EQ(row_it->second.cleanup_evidence->remove_txn_id, (RefTxnId{1, 2})); + EXPECT_TRUE(backend->head(ckpt_key).exists); + for (const String & key : backend->touchedKeys()) + EXPECT_EQ(key.find("/_cleanup/"), String::npos) << key; + + /// Round 2 drops `removed`'s catalog row (the pre-fold drain, using round 1's `cleanup_evidence`), + /// which makes THIS round's own hot-scan catalog cut genuinely, provably empty -- so its destructive + /// gate opens for the first time (`catalog_cut_proved_empty`), and the namespace janitor -- a + /// separate `namespace_cleanup` phase the SAME round call also runs -- reclaims the now-orphaned + /// checkpoint. Reclaiming a removed namespace's `_ckpt` once the pool empties is exactly the + /// standstill this gate exists to fix, so the janitor running here is the fix working, not a + /// regression. What this test still pins is the DISCRIMINATION the title promises: the FOLD stage + /// itself performs no lifecycle-specific physical cleanup (asserted above, unchanged), and the + /// janitor is attributed the delete via its OWN phase counters -- never inferred from end-state + /// absence, which would not distinguish "the janitor did it" from "something else did". + std::map janitor_metrics; + gc.setPhaseSink([&](const GcPhaseRecord & rec) + { + if (rec.phase == "namespace_cleanup") + janitor_metrics = rec.metrics; + }); + ASSERT_TRUE(runRegularRoundReclaiming(gc).acquired_lease); + gc.setPhaseSink({}); + + EXPECT_TRUE(CasRefCatalog::read(*backend, layout).catalog.entries.empty()); + EXPECT_FALSE(CasRefCatalog::lifeIfCataloged(*backend, layout, removed)); + ASSERT_FALSE(janitor_metrics.empty()) << "the namespace_cleanup phase must have run this round"; + EXPECT_GE(janitor_metrics.at("janitor_deleted"), 1u) + << "the janitor's OWN counter must show the delete -- now that the proved-empty gate has " + "opened, not because some other site happened to remove the key"; + EXPECT_FALSE(backend->head(ckpt_key).exists); + EXPECT_EQ(backend->deleteCount(ckpt_key), 1); +} + +/// Once a terminal has folded, a later physical read failure is janitor debt, not lifecycle evidence +/// loss. Removing this per-key leak handling would either make the signal disappear or let one dead +/// object prevent the janitor from considering the rest of its page. +TEST(CASGCFrontierGate, PostFoldUnreadableTerminalIsCountedWithoutSuppressingProgress) +{ + auto backend = std::make_shared(); + auto store = openPoolForTest(backend, /*gc_fold_max_defer_rounds=*/0); + const Layout & layout = store->layout(); + const RootNamespace removed{"00/post-fold-unreadable@cas@"}; + const RootNamespace progressing{"00/post-fold-progress@cas@"}; + + fixture::writeRefLogRaw(*backend, layout, RefLogTxn{ + .ns = removed.string(), .txn_id = RefTxnId{1, 1}, .ops = {namespaceBirthOp()}, + .prev_epoch_seal = std::nullopt}); + RefOp remove_op; + remove_op.kind = RefOpKind::RemoveNamespace; + fixture::writeRefLogRaw(*backend, layout, RefLogTxn{ + .ns = removed.string(), .txn_id = RefTxnId{1, 2}, .ops = {remove_op}, + .prev_epoch_seal = std::nullopt}); + const NamespaceLifeId removed_life = CasRefCatalog::lifeIfCataloged(*backend, layout, removed).value(); + CasRefCatalog::casUpdate(*backend, layout, [&](const RefCatalog & current) + { + RefCatalog next = current; + const auto it = std::find_if(next.entries.begin(), next.entries.end(), [&](const CatalogEntry & entry) + { + return entry.ns == removed; + }); + if (it == next.entries.end()) + throw std::runtime_error("test fixture lost removing catalog row"); + it->state = NsState::Removing; + it->removal_started_round = 1; + return next; + }); + ASSERT_EQ(backend->putIfAbsent(layout.refCkptKey(removed_life), encodeRefCkpt(RefCkpt{ + .life_epoch = 1, + .committed_through = RefTxnId{1, 2}, + .checkpoint_snapshot_id = std::nullopt, + .last_epoch_seal = std::nullopt, + })).outcome, PutOutcome::Done); + + const DB::UInt128 blob(0xfeed); + const ManifestRef manifest = publish(*backend, layout, progressing, "victim", 1, blob); + const ManifestId manifest_id{progressing, manifest}; + + Gc gc(store, kGc); + ASSERT_TRUE(runRegularRoundReclaiming(gc).acquired_lease); + const GcState folded_state = decodeGcState(backend->get(layout.gcStateKey())->bytes); + const CasFoldSeal folded_seal = decodeFoldSeal( + backend->get(layout.foldSealKey(folded_state.snap_generation, folded_state.snap_attempt))->bytes); + const auto folded_row = folded_seal.ref_lives.find(removed_life.incarnation); + ASSERT_NE(folded_row, folded_seal.ref_lives.end()); + ASSERT_TRUE(folded_row->second.cleanup_evidence.has_value()); + + dropRefTransition(*backend, layout, progressing, "victim", manifest); + const String terminal_key = layout.refLogKey(removed_life, RefTxnId{1, 2}); + const String later_dead_residue = layout.refLogKey(removed_life, RefTxnId{1, 3}); + ASSERT_EQ(backend->putIfAbsent(later_dead_residue, "dead residue after the folded terminal").outcome, + PutOutcome::Done); + backend->makeUnreadable(terminal_key); + + std::map namespace_cleanup; + const uint64_t leaks_before + = ProfileEvents::global_counters[ProfileEvents::CASGCNamespaceCleanupLeaks].load(); + gc.setPhaseSink([&](const GcPhaseRecord & record) + { + if (record.phase == "namespace_cleanup") + namespace_cleanup = record.metrics; + }); + ScopedCasGcLogCapture log_capture; + const RoundReport report = runRegularRoundReclaiming(gc); + gc.setPhaseSink({}); + + ASSERT_TRUE(report.acquired_lease); + EXPECT_FALSE(CasRefCatalog::lifeIfCataloged(*backend, layout, removed)) + << "post-fold physical cleanup cannot gate catalog removal"; + EXPECT_TRUE(CasRefCatalog::lifeIfCataloged(*backend, layout, progressing)); + EXPECT_EQ(report.manifests_deleted, 1u) + << "the janitor leak cannot promote itself into pool-wide destructive suppression"; + EXPECT_FALSE(backend->head(layout.manifestKey(manifest_id)).exists); + EXPECT_TRUE(backend->existsIgnoringFault(terminal_key)); + EXPECT_FALSE(backend->existsIgnoringFault(later_dead_residue)) + << "one unreadable key cannot stop the perpetual janitor from deciding the rest of its page"; + ASSERT_FALSE(namespace_cleanup.empty()); + EXPECT_EQ(namespace_cleanup["leaked"], 1u); + EXPECT_EQ( + ProfileEvents::global_counters[ProfileEvents::CASGCNamespaceCleanupLeaks].load() - leaks_before, + 1u); + const String captured = log_capture.captured(); + EXPECT_NE(captured.find(terminal_key), String::npos); + EXPECT_NE(captured.find("leak"), String::npos); +} + +TEST(CASGCFrontierGate, UnmatchedAdoptedParentLifeDoesNotSuppressAuthoritativeDeletion) +{ + auto backend = std::make_shared(); + auto store = openPoolForTest(backend, /*gc_fold_max_defer_rounds=*/0); + const Layout & layout = store->layout(); + const RootNamespace ns{"00/unmatched-parent@cas@"}; + const DB::UInt128 blob(0xcafe); + const ManifestRef mref = publish(*backend, layout, ns, "victim", 1, blob); + const ManifestId manifest_id{ns, mref}; + + Gc gc(store, kGc); + ASSERT_TRUE(runRegularRoundReclaiming(gc).acquired_lease); + ASSERT_TRUE(backend->head(layout.manifestKey(manifest_id)).exists); + + const GcState before = decodeGcState(backend->get(layout.gcStateKey())->bytes); + const String parent_seal_key = layout.foldSealKey(before.snap_generation, before.snap_attempt); + const auto parent_object = backend->get(parent_seal_key); + ASSERT_TRUE(parent_object); + CasFoldSeal parent = decodeFoldSeal(parent_object->bytes, before.snap_generation); + const UInt128 unmatched_life = hexToU128("fedcba98765432100123456789abcdef"); + ASSERT_FALSE(parent.ref_lives.contains(unmatched_life)); + parent.ref_lives.emplace(unmatched_life, RefLifeFoldState{ + .coverage = RefCoverage{.classification = 2, .last_folded_ref_id = RefTxnId{9, 9}}}); + ASSERT_EQ( + backend->putOverwrite(parent_seal_key, encodeFoldSeal(parent), parent_object->token).outcome, + PutOutcome::Done); + + dropRefTransition(*backend, layout, ns, "victim", mref); + const uint64_t events_before = + ProfileEvents::global_counters[ProfileEvents::CASGCUnmatchedAdoptedParentLives].load(); + const RoundReport report = runRegularRoundReclaiming(gc); + + ASSERT_TRUE(report.acquired_lease); + EXPECT_EQ( + ProfileEvents::global_counters[ProfileEvents::CASGCUnmatchedAdoptedParentLives].load() - events_before, + 1u); + EXPECT_EQ(report.manifests_deleted, 1u) + << "an unmatched adopted-parent row is observed and dropped, not promoted to pool-wide suppression"; + EXPECT_FALSE(backend->head(layout.manifestKey(manifest_id)).exists) + << "the valid manifest candidate must be physically deleted by the same authoritative round"; + + const GcState after = decodeGcState(backend->get(layout.gcStateKey())->bytes); + const CasFoldSeal successor = decodeFoldSeal( + backend->get(layout.foldSealKey(after.snap_generation, after.snap_attempt))->bytes, + after.snap_generation); + EXPECT_FALSE(successor.ref_lives.contains(unmatched_life)); +} + +TEST(CASCatalogLifecycleReconciler, EmptyCatalogReturnsAuthoritativeCompleteCut) +{ + auto backend = std::make_shared(); + auto store = openPoolForTest(backend, /*gc_fold_max_defer_rounds=*/0); + const Layout & layout = store->layout(); + ASSERT_TRUE(CasRefCatalog::initializeEmptyForNewPool(*backend, layout).catalog.entries.empty()); + + CasFoldSeal parent; + CatalogLifecycleReconciler reconciler( + *backend, + layout, + parent, + /*admitted_generation=*/1, + [](uint64_t) + { + return CasRefCatalog::LeaderFenceStatus::Held; + }); + const CatalogLifecycleReconcileResult result = reconciler.reconcile(); + + EXPECT_EQ(result.authority_status, AuthorityStatus::Authoritative); + EXPECT_EQ(result.catalog_resolution, CatalogResolution::DrainComplete); + ASSERT_TRUE(result.final_catalog_cut); + EXPECT_TRUE(result.final_catalog_cut->catalog.entries.empty()); + EXPECT_TRUE(result.retired_lives.empty()); + EXPECT_EQ(result.deleted, 0); +} + +TEST(CASCatalogLifecycleReconciler, DeletesEligibleRowsFromReturnedResolutionCuts) +{ + auto backend = std::make_shared(); + auto store = openPoolForTest(backend, /*gc_fold_max_defer_rounds=*/0); + const Layout & layout = store->layout(); + constexpr size_t deletes = 3; + seedCompletedRemovingBatch(*backend, store, kGc, deletes); + const auto parent_object = backend->get(layout.foldSealKey(1, 1)); + ASSERT_TRUE(parent_object); + const CasFoldSeal parent = decodeFoldSeal(parent_object->bytes); + backend->clearJournal(); + backend->resetCounts(); + + CatalogLifecycleReconciler reconciler( + *backend, + layout, + parent, + /*admitted_generation=*/1, + [](uint64_t) + { + return CasRefCatalog::LeaderFenceStatus::Held; + }); + const CatalogLifecycleReconcileResult result = reconciler.reconcile(); + + EXPECT_EQ(result.authority_status, AuthorityStatus::Authoritative); + EXPECT_EQ(result.catalog_resolution, CatalogResolution::DrainComplete); + EXPECT_EQ(result.deleted, deletes); + ASSERT_EQ(result.retired_lives.size(), deletes); + ASSERT_TRUE(result.final_catalog_cut); + EXPECT_TRUE(result.final_catalog_cut->catalog.entries.empty()); + const std::vector journal = backend->journalSnapshot(); + const String catalog_get = "get " + layout.refCatalogKey(); + EXPECT_EQ(std::count(journal.begin(), journal.end(), catalog_get), deletes + 1); +} + +TEST(CASCatalogLifecycleReconciler, ReturnsRetiredLifeWhenAuthorityMovesAfterResolution) +{ + auto backend = std::make_shared(); + auto store = openPoolForTest(backend, /*gc_fold_max_defer_rounds=*/0); + const Layout & layout = store->layout(); + const CompletedRemovingFixture fixture = seedCompletedRemoving(*backend, store, kGc); + const auto parent_object = backend->get(layout.foldSealKey(1, 1)); + ASSERT_TRUE(parent_object); + const CasFoldSeal parent = decodeFoldSeal(parent_object->bytes); + size_t fence_checks = 0; + + CatalogLifecycleReconciler reconciler( + *backend, + layout, + parent, + /*admitted_generation=*/1, + [&fence_checks](uint64_t) + { + ++fence_checks; + return fence_checks == 2 + ? CasRefCatalog::LeaderFenceStatus::Moved + : CasRefCatalog::LeaderFenceStatus::Held; + }); + const CatalogLifecycleReconcileResult result = reconciler.reconcile(); + + EXPECT_EQ(result.authority_status, AuthorityStatus::FencedOut); + EXPECT_EQ(result.catalog_resolution, CatalogResolution::ExactRowAbsent); + ASSERT_EQ(result.retired_lives.size(), 1); + EXPECT_EQ(result.retired_lives.front(), + NamespaceLifeId::fromCatalogEntry(fixture.ns, fixture.life_id)); + EXPECT_EQ(result.deleted, 0); + EXPECT_FALSE(result.final_catalog_cut); +} + +TEST(CASCatalogLifecycleReconciler, InitialFenceLossReportsEligibleRowStillPresent) +{ + auto backend = std::make_shared(); + auto store = openPoolForTest(backend, /*gc_fold_max_defer_rounds=*/0); + const Layout & layout = store->layout(); + const CompletedRemovingFixture fixture = seedCompletedRemoving(*backend, store, kGc); + const auto parent_object = backend->get(layout.foldSealKey(1, 1)); + ASSERT_TRUE(parent_object); + const CasFoldSeal parent = decodeFoldSeal(parent_object->bytes); + backend->resetCounts(); + + CatalogLifecycleReconciler reconciler( + *backend, + layout, + parent, + /*admitted_generation=*/1, + [](uint64_t) + { + return CasRefCatalog::LeaderFenceStatus::Moved; + }); + const CatalogLifecycleReconcileResult result = reconciler.reconcile(); + + EXPECT_EQ(result.authority_status, AuthorityStatus::FencedOut); + EXPECT_EQ(result.catalog_resolution, CatalogResolution::ExactRowStillPresent); + EXPECT_TRUE(result.retired_lives.empty()); + EXPECT_EQ(result.deleted, 0); + EXPECT_FALSE(result.final_catalog_cut); + EXPECT_EQ(backend->getCount(layout.refCatalogKey()), 2) + << "the initial selection and mandatory erase-resolution cuts are the only catalog reads"; + EXPECT_EQ(backend->casPutCount(layout.refCatalogKey()), 0); + EXPECT_EQ(CasRefCatalog::lifeIfCataloged(*backend, layout, fixture.ns), + NamespaceLifeId::fromCatalogEntry(fixture.ns, fixture.life_id)); +} + +TEST(CASCatalogLifecycleReconciler, RetriesFromTheMandatoryConflictResolutionCut) +{ + auto backend = std::make_shared(); + auto store = openPoolForTest(backend, /*gc_fold_max_defer_rounds=*/0); + const Layout & layout = store->layout(); + seedCompletedRemoving(*backend, store, kGc); + const auto parent_object = backend->get(layout.foldSealKey(1, 1)); + ASSERT_TRUE(parent_object); + const CasFoldSeal parent = decodeFoldSeal(parent_object->bytes); + backend->clearJournal(); + backend->resetCounts(); + backend->conflictNextCatalogCas(layout.refCatalogKey()); + + CatalogLifecycleReconciler reconciler( + *backend, + layout, + parent, + /*admitted_generation=*/1, + [](uint64_t) + { + return CasRefCatalog::LeaderFenceStatus::Held; + }); + const CatalogLifecycleReconcileResult result = reconciler.reconcile(); + + EXPECT_EQ(result.authority_status, AuthorityStatus::Authoritative); + EXPECT_EQ(result.catalog_resolution, CatalogResolution::DrainComplete); + EXPECT_EQ(result.deleted, 1); + const std::vector journal = backend->journalSnapshot(); + const String catalog_get = "get " + layout.refCatalogKey(); + EXPECT_EQ(std::count(journal.begin(), journal.end(), catalog_get), 3) + << "the token-conflict retry must reuse its mandatory resolution cut"; +} + +TEST(CASCatalogLifecycleReconciler, PropagatesAuthorityFailureBeforeEraseCas) +{ + auto backend = std::make_shared(); + auto store = openPoolForTest(backend, /*gc_fold_max_defer_rounds=*/0); + const Layout & layout = store->layout(); + seedCompletedRemoving(*backend, store, kGc); + const auto parent_object = backend->get(layout.foldSealKey(1, 1)); + ASSERT_TRUE(parent_object); + const CasFoldSeal parent = decodeFoldSeal(parent_object->bytes); + size_t fence_checks = 0; + + CatalogLifecycleReconciler reconciler( + *backend, + layout, + parent, + /*admitted_generation=*/1, + [&fence_checks](uint64_t) + { + if (++fence_checks == 2) + throw std::runtime_error("injected reconciler authority failure before CAS"); + return CasRefCatalog::LeaderFenceStatus::Held; + }); + try + { + (void)reconciler.reconcile(); + FAIL() << "the authority exception must propagate"; + } + catch (const std::runtime_error & e) + { + EXPECT_STREQ(e.what(), "injected reconciler authority failure before CAS"); + } +} + +TEST(CASCatalogLifecycleReconciler, PropagatesAuthorityFailureAfterMandatoryResolution) +{ + auto backend = std::make_shared(); + auto store = openPoolForTest(backend, /*gc_fold_max_defer_rounds=*/0); + const Layout & layout = store->layout(); + const CompletedRemovingFixture fixture = seedCompletedRemoving(*backend, store, kGc); + const auto parent_object = backend->get(layout.foldSealKey(1, 1)); + ASSERT_TRUE(parent_object); + const CasFoldSeal parent = decodeFoldSeal(parent_object->bytes); + size_t fence_checks = 0; + + CatalogLifecycleReconciler reconciler( + *backend, + layout, + parent, + /*admitted_generation=*/1, + [&fence_checks](uint64_t) + { + if (++fence_checks == 3) + throw std::runtime_error("injected reconciler authority failure after resolution"); + return CasRefCatalog::LeaderFenceStatus::Held; + }); + try + { + (void)reconciler.reconcile(); + FAIL() << "the authority exception must propagate"; + } + catch (const std::runtime_error & e) + { + EXPECT_STREQ(e.what(), "injected reconciler authority failure after resolution"); + EXPECT_FALSE(CasRefCatalog::lifeIfCataloged(*backend, layout, fixture.ns)); + } +} + +TEST(CASGCFrontierGate, HealthyRebuildUsesTheCatalogLifecycleReconciler) +{ + auto backend = std::make_shared(); + auto store = openPoolForTest(backend, /*gc_fold_max_defer_rounds=*/0); + const Layout & layout = store->layout(); + const CompletedRemovingFixture fixture = seedCompletedRemoving(*backend, store, kGc); + const uint64_t catalog_cas_before = backend->casPutCount(layout.refCatalogKey()); + + Gc gc(store, kGc); + const RebuildReport result = gc.rebuildBaseline(/*force=*/true); + + EXPECT_TRUE(result.performed); + EXPECT_FALSE(CasRefCatalog::lifeIfCataloged(*backend, layout, fixture.ns)); + EXPECT_EQ(backend->casPutCount(layout.refCatalogKey()), catalog_cas_before + 1); +} + +TEST(CASGCFrontierGate, DamagedStateRebuildDoesNotDeleteCompletedRemovingRows) +{ + auto backend = std::make_shared(); + auto store = openPoolForTest(backend, /*gc_fold_max_defer_rounds=*/0); + const Layout & layout = store->layout(); + const RootNamespace ns{"00/damaged-rebuild-removing@cas@"}; + CasRefCatalog::casAdmitEntry(*backend, layout, store->poolConfig().gc_shards, CatalogEntry{ + .ns = ns, .state = NsState::Live, .incarnation = UInt128{901}}); + CasRefCatalog::casUpdate(*backend, layout, [](const RefCatalog & current) + { + RefCatalog next = current; + next.entries.front().state = NsState::Removing; + next.entries.front().removal_started_round = 1; + return next; + }); + writeRecoverableCkptForRawFixture(*backend, layout, ns, RefCkpt{ + .life_epoch = 1, + .committed_through = std::nullopt, + .checkpoint_snapshot_id = std::nullopt, + .last_epoch_seal = std::nullopt, + }); + const uint64_t catalog_cas_before = backend->casPutCount(layout.refCatalogKey()); + + Gc gc(store, kGc); + const RebuildReport result = gc.rebuildBaseline(/*force=*/false); + + EXPECT_TRUE(result.performed); + EXPECT_TRUE(CasRefCatalog::lifeIfCataloged(*backend, layout, ns)); + EXPECT_EQ(backend->casPutCount(layout.refCatalogKey()), catalog_cas_before); +} + +TEST(CASGCFrontierGate, DeferredRoundDrainsCompletedRemovingBeforeReturning) +{ + auto backend = std::make_shared(); + auto store = openPoolForTest(backend, /*gc_fold_max_defer_rounds=*/100); + const Layout & layout = store->layout(); + const RootNamespace removed{"00/deferred-removed@cas@"}; + const UInt128 life_id{77}; + CasRefCatalog::casAdmitEntry(*backend, layout, store->poolConfig().gc_shards, CatalogEntry{ + .ns = removed, .state = NsState::Live, .incarnation = life_id}); + CasRefCatalog::casUpdate(*backend, layout, [&](const RefCatalog & current) + { + RefCatalog next = current; + next.entries[0].state = NsState::Removing; + next.entries[0].removal_started_round = 1; + return next; + }); + + CasFoldSeal parent; + parent.generation = 1; + parent.ref_lives.emplace(life_id, RefLifeFoldState{ + .coverage = RefCoverage{.classification = 2, .last_folded_ref_id = RefTxnId{1, 1}}, + .cleanup_evidence = RefCleanupEvidence{.remove_txn_id = RefTxnId{1, 1}}}); + for (uint64_t shard = 0; shard < store->poolConfig().gc_shards; ++shard) + parent.condemned_summary.emplace(shard, CondemnedSummary{}); + ASSERT_EQ(backend->putIfAbsent(layout.foldSealKey(1, 1), encodeFoldSeal(parent)).outcome, PutOutcome::Done); + GcState state; + state.round = 1; + state.gc_shards = store->poolConfig().gc_shards; + state.snap_generation = 1; + state.snap_attempt = 1; + state.lease = GcLease{.owner = kGc, .seq = 1}; + ASSERT_EQ(backend->putIfAbsent(layout.gcStateKey(), encodeGcState(state)).outcome, PutOutcome::Done); + + const String ckpt_key = layout.refCkptKey(NamespaceLifeId::fromCatalogEntry(removed, life_id)); + ASSERT_EQ(backend->putIfAbsent(ckpt_key, "inert checkpoint debris").outcome, PutOutcome::Done); + const uint64_t catalog_cas_before = backend->casPutCount(layout.refCatalogKey()); + + Gc gc(store, kGc); + const RoundReport report = runRegularRoundReclaiming(gc); + ASSERT_TRUE(report.acquired_lease); + EXPECT_TRUE(report.deferred); + EXPECT_TRUE(CasRefCatalog::read(*backend, layout).catalog.entries.empty()); + EXPECT_FALSE(CasRefCatalog::lifeIfCataloged(*backend, layout, removed)); + EXPECT_EQ(backend->casPutCount(layout.refCatalogKey()), catalog_cas_before + 1); + EXPECT_TRUE(backend->head(ckpt_key).exists); + EXPECT_EQ(backend->deleteCount(ckpt_key), 0); +} + +TEST(CASGCFrontierGate, StaleIssuedCatalogCasLosesAfterNewLeaderHelpsBeforeListing) +{ + auto backend = std::make_shared(); + auto store = openPoolForTest(backend, /*gc_fold_max_defer_rounds=*/0); + const Layout & layout = store->layout(); + const UInt128 leader_b = hexToU128("00000000000000000000000000000002"); + const CompletedRemovingFixture fixture = seedCompletedRemoving(*backend, store, kGc); + backend->clearJournal(); + backend->blockNextCatalogCas(layout.refCatalogKey()); + + std::exception_ptr leader_a_failure; + std::thread leader_a([&] + { + try + { + Gc gc_a(store, kGc); + (void)runRegularRoundReclaiming(gc_a); + } + catch (...) + { + leader_a_failure = std::current_exception(); + } + }); + backend->waitForBlockedCatalogCas(); + + transferGcLease(*backend, layout, leader_b); + RoundReport report_b; + std::exception_ptr leader_b_failure; + /// `fixture.ns` is Removing with a durable `_ckpt`, same shape as + /// `CleanupEvidenceLeavesRemovedNamespaceCheckpointForJanitor`: once leader_b's round drops its + /// catalog row, the resulting cut is genuinely, provably empty, the destructive gate opens, and the + /// namespace janitor -- a separate `namespace_cleanup` phase within this SAME round -- reclaims the + /// checkpoint. Captured so the assertions below can attribute the delete to the janitor rather than + /// assume survival. + std::map janitor_metrics_b; + try + { + Gc gc_b(store, leader_b); + gc_b.setPhaseSink([&](const GcPhaseRecord & rec) + { + if (rec.phase == "namespace_cleanup") + janitor_metrics_b = rec.metrics; + }); + report_b = runRegularRoundReclaiming(gc_b); + gc_b.setPhaseSink({}); + } + catch (...) + { + leader_b_failure = std::current_exception(); + } + + const std::vector before_a_release = backend->journalSnapshot(); + backend->releaseBlockedCatalogCas(); + leader_a.join(); + + ASSERT_FALSE(leader_b_failure); + ASSERT_TRUE(report_b.acquired_lease); + ASSERT_FALSE(report_b.deferred); + ASSERT_TRUE(CasRefCatalog::read(*backend, layout).catalog.entries.empty()); + + const size_t catalog_cas_end = findJournalAfter(before_a_release, "cas_end " + layout.refCatalogKey(), 0); + ASSERT_LT(catalog_cas_end, before_a_release.size()); + const size_t conclusive_rescan = findJournalAfter( + before_a_release, "get " + layout.refCatalogKey(), catalog_cas_end + 1); + ASSERT_LT(conclusive_rescan, before_a_release.size()); + const size_t stream_list = findJournalAfter( + before_a_release, "list " + layout.casRefsPrefix(), conclusive_rescan + 1); + ASSERT_LT(stream_list, before_a_release.size()); + const size_t fresh_catalog_cut = findJournalAfter( + before_a_release, "get " + layout.refCatalogKey(), stream_list + 1); + ASSERT_LT(fresh_catalog_cut, before_a_release.size()); + const GcState adopted = decodeGcState(backend->get(layout.gcStateKey())->bytes); + const String successor_seal_key = layout.foldSealKey(adopted.snap_generation, adopted.snap_attempt); + const size_t successor_seal_put = findJournalAfter( + before_a_release, "put_end " + successor_seal_key, fresh_catalog_cut + 1); + ASSERT_LT(successor_seal_put, before_a_release.size()); + const size_t successor_adoption = findJournalAfter( + before_a_release, "cas_end " + layout.gcStateKey(), successor_seal_put + 1); + ASSERT_LT(successor_adoption, before_a_release.size()); + EXPECT_LT(catalog_cas_end, conclusive_rescan); + EXPECT_LT(conclusive_rescan, stream_list); + EXPECT_LT(stream_list, fresh_catalog_cut); + /// The invariant this ordering must still prove: the fold's OWN walk plan is built from the single + /// hot-scan cut, taken immediately after the ref-object LIST, with no earlier catalog read sneaking + /// into that construction. `fresh_catalog_cut` is defined as the FIRST catalog `get` after + /// `stream_list` (the `findJournalAfter` search above), so that already holds by construction -- + /// the walk plan physically cannot have consumed an earlier one. + /// + /// What this test used to also assert -- no SECOND catalog read anywhere before the seal PUT -- is + /// no longer the right claim once the destructive gate can open on a proved-empty cut: other + /// destructive families this SAME round now also runs (the orphan-manifest sweep, the namespace + /// janitor) take their OWN separate catalog cuts by design, each after its own candidate listing, + /// to resolve authority against a fresh read rather than the fold's frozen one -- exactly the shape + /// measured here (`list p/cas/manifests/` immediately followed by a second `get + /// p/cas/ref_catalog`, before the seal PUT, from the orphan sweep). That is expected, not redundant, + /// so it is not asserted against; the fold's own single-cut plan construction is what remains pinned. + EXPECT_LT(fresh_catalog_cut, successor_seal_put); + EXPECT_LT(successor_seal_put, successor_adoption); + + ASSERT_TRUE(leader_a_failure); + EXPECT_FALSE(CasRefCatalog::lifeIfCataloged(*backend, layout, fixture.ns)); + /// Same discrimination as `CleanupEvidenceLeavesRemovedNamespaceCheckpointForJanitor`: leader_b's + /// round both drops `fixture.ns`'s catalog row AND, because the resulting cut is genuinely, + /// provably empty, opens the destructive gate -- so the namespace janitor reclaims the checkpoint + /// in this SAME round. Attribute the delete to the janitor's own counter rather than assume either + /// survival (the old expectation) or absence (which an unchecked dereference here cannot + /// distinguish from "never existed"). + ASSERT_FALSE(janitor_metrics_b.empty()) << "the namespace_cleanup phase must have run this round"; + EXPECT_GE(janitor_metrics_b.at("janitor_deleted"), 1u) + << "the janitor's OWN counter must show the delete, now that the proved-empty gate has opened"; + EXPECT_FALSE(backend->get(fixture.checkpoint_key).has_value()); + EXPECT_EQ(backend->deleteCount(fixture.checkpoint_key), 1); +} + +TEST(CASGCFrontierGate, LostCatalogCasResponseIsResolvedBeforeListing) +{ + auto backend = std::make_shared(); + auto store = openPoolForTest(backend, /*gc_fold_max_defer_rounds=*/0); + const Layout & layout = store->layout(); + const CompletedRemovingFixture fixture = seedCompletedRemoving(*backend, store, kGc); + backend->clearJournal(); + backend->loseNextCatalogCasResponse(layout.refCatalogKey()); + + /// Same shape as `StaleIssuedCatalogCasLosesAfterNewLeaderHelpsBeforeListing`: `fixture.ns` is + /// Removing with a durable `_ckpt`, so this round both drops its catalog row and, because the + /// resulting cut is genuinely, provably empty, opens the destructive gate -- the namespace janitor + /// (a separate `namespace_cleanup` phase within this SAME round) reclaims the checkpoint. Captured + /// so the assertions below attribute the delete to the janitor's own counter. + std::map janitor_metrics; + Gc gc(store, kGc); + gc.setPhaseSink([&](const GcPhaseRecord & rec) + { + if (rec.phase == "namespace_cleanup") + janitor_metrics = rec.metrics; + }); + const RoundReport report = runRegularRoundReclaiming(gc); + gc.setPhaseSink({}); + ASSERT_TRUE(report.acquired_lease); + ASSERT_FALSE(report.deferred); + + const std::vector journal = backend->journalSnapshot(); + const size_t response_lost = findJournalAfter( + journal, "cas_response_lost " + layout.refCatalogKey(), 0); + ASSERT_LT(response_lost, journal.size()); + const size_t conclusive_rescan = findJournalAfter( + journal, "get " + layout.refCatalogKey(), response_lost + 1); + ASSERT_LT(conclusive_rescan, journal.size()); + const size_t stream_list = findJournalAfter( + journal, "list " + layout.casRefsPrefix(), conclusive_rescan + 1); + ASSERT_LT(stream_list, journal.size()); + const size_t fresh_catalog_cut = findJournalAfter( + journal, "get " + layout.refCatalogKey(), stream_list + 1); + ASSERT_LT(fresh_catalog_cut, journal.size()); + EXPECT_LT(response_lost, conclusive_rescan); + EXPECT_LT(conclusive_rescan, stream_list); + EXPECT_LT(stream_list, fresh_catalog_cut); + + EXPECT_FALSE(CasRefCatalog::lifeIfCataloged(*backend, layout, fixture.ns)); + /// See the discrimination comment in `CleanupEvidenceLeavesRemovedNamespaceCheckpointForJanitor`: + /// attribute the delete to the janitor's own counter, never to end-state absence alone, and never + /// assume survival -- both would be indistinguishable from a bug on this exact line (the old + /// unchecked `->bytes` here is what aborted the whole binary once the janitor started reclaiming). + ASSERT_FALSE(janitor_metrics.empty()) << "the namespace_cleanup phase must have run this round"; + EXPECT_GE(janitor_metrics.at("janitor_deleted"), 1u) + << "the janitor's OWN counter must show the delete, now that the proved-empty gate has opened"; + EXPECT_FALSE(backend->get(fixture.checkpoint_key).has_value()); + EXPECT_EQ(backend->deleteCount(fixture.checkpoint_key), 1); +} + +/// A stale leader may learn from its mandatory resolution read that the old life is gone, and must +/// invalidate that exact runtime, but loss of the leader fence remains the control outcome. It must +/// abort before the hot LIST and cannot build or publish any successor generation. +TEST_P(CASGCCompletedRemovalFenceRace, FencedLeaderStopsAfterWinnerRemovesOrReplacesLife) +{ + auto backend = std::make_shared(); + auto store = openPoolForTest(backend, /*gc_fold_max_defer_rounds=*/0); + const Layout & layout = store->layout(); + const UInt128 leader_b = hexToU128("00000000000000000000000000000002"); + const CompletedRemovingFixture fixture = seedCompletedRemoving(*backend, store, kGc); + const NamespaceLifeId predecessor_life + = NamespaceLifeId::fromCatalogEntry(fixture.ns, fixture.life_id); + ASSERT_TRUE(store->refTableRecoveredForTest(fixture.ns)) + << "the fixture must retain a resident predecessor runtime before removal"; + ASSERT_EQ(store->refTableLifeForTest(fixture.ns), predecessor_life); + const uint64_t predecessor_runtime = store->refTableRuntimeIdentityForTest(fixture.ns); + ASSERT_NE(predecessor_runtime, 0u); + backend->clearJournal(); + backend->blockNextCatalogCas(layout.refCatalogKey()); + + std::exception_ptr leader_a_failure; + std::thread leader_a([&] + { + try + { + Gc gc_a(store, kGc); + (void)runRegularRoundReclaiming(gc_a); + } + catch (...) + { + leader_a_failure = std::current_exception(); + } + }); + backend->waitForBlockedCatalogCas(); + + transferGcLease(*backend, layout, leader_b); + const CasRefCatalog::Snapshot observed = CasRefCatalog::read(*backend, layout); + RefCatalog winner_catalog; + if (GetParam() == CompetingCatalogOutcome::Replacement) + { + winner_catalog.entries.push_back(CatalogEntry{ + .ns = fixture.ns, + .state = NsState::Live, + .incarnation = UInt128{178}}); + /// Mirror production's publish-then-flip order: the successor life needs a readable `_ckpt` + /// before its catalog row can read `Live`, or `chooseRecoveryGrounding` rejects it. + const NamespaceLifeId successor_life = NamespaceLifeId::fromCatalogEntry(fixture.ns, UInt128{178}); + backend->putIfAbsent(layout.refCkptKey(successor_life), encodeRefCkpt(RefCkpt{ + .life_epoch = 1, + .committed_through = std::nullopt, + .checkpoint_snapshot_id = std::nullopt, + .last_epoch_seal = std::nullopt, + })); + } + ASSERT_EQ(backend->casPut( + layout.refCatalogKey(), encodeRefCatalog(winner_catalog), observed.token).outcome, + CasOutcome::Committed); + + backend->clearJournal(); + const uint64_t plans_before /// NOLINT(clang-analyzer-deadcode.DeadStores) + = ProfileEvents::global_counters[ProfileEvents::CASGCRefWalkPlansBuilt].load(); + backend->releaseBlockedCatalogCas(); + leader_a.join(); + + const std::vector journal = backend->journalSnapshot(); + ASSERT_TRUE(leader_a_failure); + try + { + std::rethrow_exception(leader_a_failure); + FAIL() << "expected DB::Exception"; + } + catch (const DB::Exception & e) + { + EXPECT_EQ(e.code(), DB::ErrorCodes::NETWORK_ERROR); + EXPECT_NE(e.message().find("pre-fold drain lost authority"), String::npos) << e.message(); + } + EXPECT_EQ(ProfileEvents::global_counters[ProfileEvents::CASGCRefWalkPlansBuilt].load() - plans_before, 0u); + EXPECT_EQ(findJournalAfter(journal, "list " + layout.casRefsPrefix(), 0), journal.size()); + EXPECT_EQ(findJournalAfter(journal, "cas_begin " + layout.gcStateKey(), 0), journal.size()); + EXPECT_FALSE(std::any_of(journal.begin(), journal.end(), [](const String & entry) + { + return entry.starts_with("put_begin ") && entry.ends_with("/fold_seal"); + })); + EXPECT_LT(findJournalAfter(journal, "get " + layout.refCatalogKey(), 0), journal.size()) + << "the stale leader must still complete mandatory erase resolution"; + + (void)store->namespaceLife(fixture.ns); + EXPECT_NE(store->refTableRuntimeIdentityForTest(fixture.ns), 0u); + ASSERT_TRUE(store->refTableLifeForTest(fixture.ns)); + EXPECT_NE(store->refTableLifeForTest(fixture.ns), predecessor_life) + << "the next name-based resolution must not retain the retired predecessor life"; +} + +INSTANTIATE_TEST_SUITE_P( + CASWinnerShape, + CASGCCompletedRemovalFenceRace, + testing::Values(CompetingCatalogOutcome::Absent, CompetingCatalogOutcome::Replacement), + [](const testing::TestParamInfo & parameter) + { + return parameter.param == CompetingCatalogOutcome::Absent ? "Absent" : "Replacement"; + }); + +/// One initial full catalog read selects the first row; each successful erase's mandatory resolution +/// read becomes the next selection snapshot. Therefore N uncontended deletes cost N+1 reads before +/// the hot LIST. The round then takes one post-LIST walk-plan cut and, later in the separate +/// `namespace_cleanup` phase, one post-page janitor cut. +TEST(CASGCFrontierGate, CompletedRemovalDrainUsesNPlusOneCatalogReads) +{ + auto backend = std::make_shared(); + auto store = openPoolForTest(backend, /*gc_fold_max_defer_rounds=*/0); + const Layout & layout = store->layout(); + constexpr size_t deletes = 3; + seedCompletedRemovingBatch(*backend, store, kGc, deletes); + backend->clearJournal(); + backend->resetCounts(); + + Gc gc(store, kGc); + ASSERT_TRUE(runRegularRoundReclaiming(gc).acquired_lease); + + const std::vector journal = backend->journalSnapshot(); + const size_t stream_list = findJournalAfter(journal, "list " + layout.casRefsPrefix(), 0); + ASSERT_LT(stream_list, journal.size()); + const String catalog_get = "get " + layout.refCatalogKey(); + EXPECT_EQ(std::count(journal.begin(), journal.begin() + static_cast(stream_list), catalog_get), + deletes + 1); + const size_t walk_plan_cut = findJournalAfter(journal, catalog_get, stream_list); + ASSERT_LT(walk_plan_cut, journal.size()); + /// Between the hot walk-plan cut and the janitor's own page, the orphan-manifest sweep -- ANOTHER + /// destructive family the now-open gate also unlocks (the batch drain above empties the catalog, so + /// this round's frontier is proved empty and the sweep's own `!suppress_destructive` gate opens + /// too) -- lists its own manifest candidates and takes its OWN separate catalog cut to resolve + /// authority, exactly as the janitor does. Located explicitly so the final read count below states + /// what it counts rather than drifting silently the next time a family is unlocked. + const size_t orphan_sweep_list = findJournalAfter(journal, "list " + layout.casManifestsPrefix(), walk_plan_cut); + ASSERT_LT(orphan_sweep_list, journal.size()); + const size_t orphan_sweep_cut = findJournalAfter(journal, catalog_get, orphan_sweep_list); + ASSERT_LT(orphan_sweep_cut, journal.size()); + const size_t janitor_list + = findJournalAfter(journal, "list " + layout.namespaceRootPrefix(), orphan_sweep_cut); + ASSERT_LT(janitor_list, journal.size()); + const size_t janitor_cut = findJournalAfter(journal, catalog_get, janitor_list); + ASSERT_LT(janitor_cut, journal.size()); + EXPECT_EQ(findJournalAfter(journal, catalog_get, janitor_cut + 1), journal.size()) + << "one hot walk-plan cut, one orphan-sweep cut, and one janitor page cut are the only " + "post-drain catalog reads"; + EXPECT_EQ(backend->listCount(layout.namespaceStreamRootPrefix()), 1u); + EXPECT_EQ(backend->listCount(layout.namespaceRootPrefix()), 1u); + EXPECT_EQ(backend->getCount(layout.refCatalogKey()), deletes + 4) + << "N+1 drain reads, one post-hot-LIST walk-plan cut, one orphan-manifest-sweep cut (now that " + "the proved-empty gate has opened, unlocking that destructive family too), and one separate " + "post-janitor-page cut"; +} diff --git a/src/Disks/tests/gtest_cas_gc_hold_grammar.cpp b/src/Disks/tests/gtest_cas_gc_hold_grammar.cpp new file mode 100644 index 000000000000..19a63bc6842a --- /dev/null +++ b/src/Disks/tests/gtest_cas_gc_hold_grammar.cpp @@ -0,0 +1,1590 @@ +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include "cas_test_helpers.h" + +#include +#include +#include +#include +#include +#include +#include +#include + +/// DURABLE HOLDS (spec 2026-07-27 "ref chain complete cut" §5). +/// +/// A namespace whose ref-log walk meets an IMPOSSIBLE shape stops there, and that stop has to survive +/// the round. Before this task the stop was a single bit — `classification == 4` — and everything that +/// explained it (what went wrong, and exactly WHERE) lived in a log line and an in-memory anomaly, both +/// gone by the next round. That is not enough for three separate reasons: +/// +/// * the next round could not RETRY the exact position, so a hold only survived while the round's +/// hint happened to keep mentioning the namespace; +/// * the hold could be cleared by an ABSENT — precisely the observation a lying store produces, and +/// precisely the shape that made the hold necessary in the first place; +/// * REBUILD rewrote coverage from owner state and silently dropped every hold, handing back a +/// baseline that looked proven when it was not. +/// +/// So the hold is now DURABLE and STRICTLY GRAMMARED: `{reason, offending_position, retry_count, +/// next_retry_round}` present if and only if `classification == 4`, rejected in both directions +/// otherwise. It rides the seal across rounds — including rounds whose hint omits the namespace +/// entirely — and across REBUILD, and it clears by exactly ONE event: the fold resolving the offending +/// position and that result being adopted in `gc/state`. +/// +/// The carried hold is also a WITNESS, and a better one than the listing: it is durable proof that the +/// walk once reached that position, so an absent below it is a gap rather than a frontier no matter +/// what the hint says this round. That is what makes "retry the exact offending position" work for a +/// hold that sits above an epoch boundary. + +using namespace DB::Cas; +using namespace DB::Cas::tests; + +namespace DB::ErrorCodes +{ +extern const int CORRUPTED_DATA; +extern const int LIMIT_EXCEEDED; +extern const int LOGICAL_ERROR; +} + +namespace ProfileEvents +{ +extern const Event CASGCRebuildVirginByEnumeration; +} + +namespace +{ + +const UInt128 kGc = hexToU128("00000000000000000000000000000001"); + +/// ===================== FIXTURES ===================== + +/// A backend that hides keys from every LIST while serving them by exact key (the observed lying-store +/// shape) AND counts reads. The hold tests need both: the hint has to go quiet while the exact GET the +/// hold forces stays observable. +class HintHoleCountingBackend : public CountingBackend +{ +public: + void hide(const String & key) + { + std::lock_guard lock(m); + hidden.insert(key); + } + + size_t holesServed() const + { + std::lock_guard lock(m); + return served; + } + + ListPage list(const String & prefix, const String & cursor, size_t limit) override + { + ListPage page = CountingBackend::list(prefix, cursor, limit); + std::lock_guard lock(m); + if (hidden.empty()) + return page; + const size_t before = page.keys.size(); + std::erase_if(page.keys, [&](const ListedKey & k) { return hidden.contains(k.key); }); + if (page.keys.size() != before) + ++served; + return page; + } + +private: + mutable std::mutex m; + std::set hidden; + size_t served = 0; +}; + +/// Write the namespace's `_ckpt` naming `checkpoint` as its snapshot base, through the real codec — the +/// fold's second witness source is a decode of exactly these bytes, so a hand-rolled body would prove +/// nothing about the object the writers actually publish. +void writeCkptAt( + Backend & backend, const Layout & layout, const RootNamespace & ns, const RefTxnId & checkpoint) +{ + writeRecoverableCkptForRawFixture(backend, layout, ns, RefCkpt{ + .life_epoch = 1, + .committed_through = checkpoint, + .checkpoint_snapshot_id = checkpoint, + .last_epoch_seal = std::nullopt, + }); +} + +/// Establish only the immutable recovery frontier for a raw-log fixture. Unlike `writeCkptAt`, this +/// does not claim a snapshot exists: rebuild tests need to replay the log through this exact position. +void writeCommittedCkptAt( + Backend & backend, const Layout & layout, const RootNamespace & ns, const RefTxnId & committed_through) +{ + writeRecoverableCkptForRawFixture(backend, layout, ns, RefCkpt{ + .life_epoch = 1, + .committed_through = committed_through, + .checkpoint_snapshot_id = std::nullopt, + .last_epoch_seal = std::nullopt, + }); +} + +/// The newest fold seal, scanning downward from the adopted generation (a completed round's gc/state +/// points at the recheck generation). +std::optional newestSeal(Backend & backend, const Layout & layout) +{ + const uint64_t gen = currentGenerationOf(backend, layout); + const uint64_t attempt = currentAttemptOf(backend, layout); + for (uint64_t g = gen; ; --g) + { + if (const auto got = backend.get(layout.foldSealKey(g, attempt))) + return decodeFoldSeal(got->bytes); + if (g == 0) + return std::nullopt; + } +} + +std::optional coverageOf(Backend & backend, const Layout & layout, const RootNamespace & ns) +{ + const auto seal = newestSeal(backend, layout); + if (!seal) + return std::nullopt; + const auto it = seal->ref_lives.find(catalogLifeIdForTest(backend, layout, ns)); + if (it == seal->ref_lives.end()) + return std::nullopt; + return it->second.coverage; +} + +/// The cursor `ns` was sealed at, or `{0, 0}` when the round sealed NO row for it at all. It never +/// dereferences a disengaged optional: a test that aborts the process takes every test after it in the +/// binary down with it, and "there is no coverage row" is exactly the shape a regression in the hold +/// carry produces — so it has to read as a failed expectation, not as a crash that hides the rest. +RefTxnId sealedCursorOf(Backend & backend, const Layout & layout, const RootNamespace & ns) +{ + const auto cov = coverageOf(backend, layout, ns); + EXPECT_TRUE(cov.has_value()) << "no coverage row for " << ns.string(); + return cov ? cov->last_folded_ref_id : RefTxnId{}; +} + +/// The coverage row a round MUST have sealed for `ns`, held. Fails the test rather than returning an +/// empty optional, so every caller below reads a real hold. +RefHold holdOf(Backend & backend, const Layout & layout, const RootNamespace & ns) +{ + const auto cov = coverageOf(backend, layout, ns); + EXPECT_TRUE(cov.has_value()) << "no coverage row for " << ns.string(); + if (!cov) + return RefHold{}; + EXPECT_EQ(cov->classification, 4) << "a held namespace is classification 4"; + EXPECT_TRUE(cov->hold.has_value()) << "classification 4 without a hold is the forbidden shape"; + return cov->hold ? *cov->hold : RefHold{}; +} + +UInt128 fixtureLifeId(std::string_view key) +{ + return key.ends_with("/1") ? UInt128{2} : UInt128{1}; +} + +RefCoverage & fixtureCoverage(CasFoldSeal & seal, std::string_view key) +{ + return seal.ref_lives[fixtureLifeId(key)].coverage; +} + +const RefCoverage & fixtureCoverage(const CasFoldSeal & seal, std::string_view key) +{ + return seal.ref_lives.at(fixtureLifeId(key)).coverage; +} + +/// A seal carrying exactly one held coverage row with every numeric at its maximum and a coverage key +/// that needs escaping — the widest row the per-row line budget has to survive. The caller supplies the +/// key, which is what the line-cap tests below grow byte by byte. +CasFoldSeal maximalHoldSeal(const String & map_key) +{ + CasFoldSeal seal; + seal.generation = std::numeric_limits::max(); + seal.parent_generation = std::numeric_limits::max(); + RefCoverage cov; + cov.classification = 4; + cov.last_folded_ref_id = RefTxnId{std::numeric_limits::max(), + std::numeric_limits::max()}; + cov.hold = RefHold{.reason = HoldReason::UnconsumedSealCrossing, /// the longest reason word + .offending_position = RefTxnId{std::numeric_limits::max(), + std::numeric_limits::max()}, + .retry_count = std::numeric_limits::max(), + .next_retry_round = std::numeric_limits::max()}; + fixtureCoverage(seal, map_key) = cov; + return seal; +} + +/// The `cov` line of an encoded seal (line 3: header, meta, then the single record). +String covLineOf(const String & encoded) +{ + size_t begin = encoded.find('\n') + 1; /// past the header + begin = encoded.find('\n', begin) + 1; /// past the meta line + return encoded.substr(begin, encoded.find('\n', begin) - begin); +} + +/// A one-row seal whose coverage is ordinary and CLEAN: folded through its cursor, nothing held. +CasFoldSeal cleanSeal(const String & map_key) +{ + CasFoldSeal seal; + seal.generation = 3; + seal.parent_generation = 2; + RefCoverage cov; + cov.classification = 2; + cov.last_folded_ref_id = RefTxnId{4, 5}; + fixtureCoverage(seal, map_key) = cov; + return seal; +} + +/// A one-row seal whose coverage is HELD at an exact position — the row every erasure shape below is +/// trying to make disappear. +CasFoldSeal heldSeal(const String & map_key) +{ + CasFoldSeal seal = cleanSeal(map_key); + RefCoverage & cov = fixtureCoverage(seal, map_key); + cov.classification = 4; + cov.hold = RefHold{.reason = HoldReason::GapBelowWitness, .offending_position = RefTxnId{4, 6}, + .retry_count = 7, .next_retry_round = 99}; + return seal; +} + +/// The header and meta lines (1 and 2) of an encoded seal, terminators included. +String headerAndMetaOf(const String & encoded) +{ + const size_t past_meta = encoded.find('\n', encoded.find('\n') + 1) + 1; + EXPECT_NE(past_meta, 0u); + return encoded.substr(0, past_meta); +} + +/// Assemble a raw seal object from `records` (one record per element, no terminators), on `prototype`'s +/// header and meta lines, closed by the trailer count those records imply. This is the ONLY way to put a +/// repeated record key on the wire: `CasFoldSeal` stores keyed maps, so a duplicate is not a value any +/// producer can hold — it is a shape a forged, truncated, or mis-merged object has. +String sealTextWith(const String & prototype, const std::vector & records) +{ + String text = headerAndMetaOf(prototype); + for (const String & record : records) + text += record + "\n"; + return text + "{\"n\":" + std::to_string(records.size()) + "}\n"; +} + +/// Replace the coverage row's `cls` value with `raw`, VERBATIM. The point is to write integers no +/// `RefCoverage` can hold: the field is a byte in the struct, so a wide value exists only on the wire, +/// which is exactly where a reader has to catch it. `cls` is never the last field of a `cov` record, so +/// the value always ends at a comma. +String withRawClassification(const String & encoded, std::string_view raw) +{ + const size_t at = encoded.find("\"cls\":"); + EXPECT_NE(at, String::npos); + const size_t begin = at + strlen("\"cls\":"); + const size_t end = encoded.find(',', begin); + EXPECT_NE(end, String::npos); + return encoded.substr(0, begin) + String{raw} + encoded.substr(end); +} + +/// Replace the FIRST occurrence of `field` with `replacement` (both are whole `"key":value` fragments), +/// so a test states the exact wire shape it is feeding the decoder. +String withField(const String & encoded, const String & field, const String & replacement) +{ + const size_t at = encoded.find(field); + EXPECT_NE(at, String::npos) << "the encoder does not emit " << field; + return encoded.substr(0, at) + replacement + encoded.substr(at + field.size()); +} + +/// Every coverage row the ENCODER must refuse, each paired with why producing it would be a bug in our +/// own fold rather than corruption arriving from a store. Shared by the two builds' assertions below so +/// the release expectation and the sanitizer death expectation can never drift apart. +std::vector> illFormedSealsTheEncoderMustRefuse() +{ + std::vector> out; + + /// The pairing, both ways round. + CasFoldSeal hold_on_folded = heldSeal("ns/0"); + fixtureCoverage(hold_on_folded, "ns/0").classification = 2; + out.emplace_back("a hold on a folded (2) row claims a stop that did not happen", hold_on_folded); + + CasFoldSeal clamped_without_hold = heldSeal("ns/0"); + fixtureCoverage(clamped_without_hold, "ns/0").hold.reset(); + out.emplace_back("a clamped (4) row with no hold is indistinguishable from a clean cursor once " + "durable", clamped_without_hold); + + /// The closed set. 3 is the dangerous one: it passes the sweep's `== 4` and `== 0` refusals and + /// reaches the deletion premise, which is a refusal written in terms of the set. + CasFoldSeal classification_three = cleanSeal("ns/0"); + fixtureCoverage(classification_three, "ns/0").classification = 3; + out.emplace_back("classification 3 is not one of {0,1,2,4} and passes every refusal stated in terms " + "of them", classification_three); + + CasFoldSeal classification_max = cleanSeal("ns/0"); + fixtureCoverage(classification_max, "ns/0").classification = 255; + out.emplace_back("classification 255 is not one of {0,1,2,4}", classification_max); + + /// The self-erasing hold, and its half-zero sibling. + CasFoldSeal hold_at_zero = heldSeal("ns/0"); + fixtureCoverage(hold_at_zero, "ns/0").hold->offending_position = RefTxnId{}; + out.emplace_back("a hold at {0,0} is cleared by the first record the next round folds", hold_at_zero); + + CasFoldSeal hold_zero_sequence = heldSeal("ns/0"); + fixtureCoverage(hold_zero_sequence, "ns/0").hold->offending_position = RefTxnId{7, 0}; + out.emplace_back("a hold position with a zero component is not a renderable id", hold_zero_sequence); + + return out; +} + +} + +/// ===================== THE SHARED BYTE ARITHMETIC ===================== +/// +/// Two caps, two predicates, one place they are computed. Stage B's catalog reuses THESE functions for +/// its additive "does one more entry still fit" question, so their boundary behaviour is pinned here +/// rather than re-derived per format: a cap is the largest PERMITTED value (equality fits), and every +/// sum saturates, because a wrapped sum answers "fits" for an object that does not — turning an +/// overflow into a durable object nothing can read. +TEST(CASGCHoldGrammarBudget, BothPredicatesAcceptEqualityAndRefuseOneMore) +{ + static_assert(fitsLineCap(64, 64)); + static_assert(!fitsLineCap(65, 64)); + static_assert(fitsObjectCap(40, 24, 64)); + static_assert(!fitsObjectCap(40, 25, 64)); + + EXPECT_TRUE(fitsLineCap(64, 64)); + EXPECT_FALSE(fitsLineCap(65, 64)); + EXPECT_TRUE(fitsObjectCap(64, 0, 64)); + EXPECT_FALSE(fitsObjectCap(64, 1, 64)); + + /// A cap of 0 means the format declares none (a streamed object never materialized whole). + EXPECT_TRUE(fitsLineCap(std::numeric_limits::max(), 0)); + EXPECT_TRUE(fitsObjectCap(std::numeric_limits::max(), 1, 0)); +} + +TEST(CASGCHoldGrammarBudget, SumsSaturateInsteadOfWrapping) +{ + constexpr uint64_t kMax = std::numeric_limits::max(); + static_assert(addByteBudget(kMax, 1) == kMax); + static_assert(addByteBudget(kMax, kMax) == kMax); + static_assert(addByteBudget(3, 4) == 7); + + /// The predicate that matters: a reservation that would wrap must REFUSE, not report a tiny sum. + EXPECT_FALSE(fitsObjectCap(kMax, 2, 256 * 1024 * 1024)); +} + +/// ===================== THE STRICT CLASSIFICATION-4 GRAMMAR ===================== + +TEST(CASGCHoldGrammar, EveryHoldReasonRoundTrips) +{ + for (const HoldReason reason : {HoldReason::GapBelowWitness, HoldReason::UnconsumedSealCrossing, + HoldReason::WitnessDisappeared, HoldReason::BodyUndecodable, + HoldReason::ManifestBodyMissing, HoldReason::CheckpointUndecodable}) + { + CasFoldSeal seal; + seal.generation = 3; + seal.parent_generation = 2; + RefCoverage cov; + cov.classification = 4; + cov.last_folded_ref_id = RefTxnId{4, 5}; + cov.hold = RefHold{.reason = reason, .offending_position = RefTxnId{4, 6}, + .retry_count = 7, .next_retry_round = 99}; + fixtureCoverage(seal, "ns/0") = cov; + + const CasFoldSeal back = decodeFoldSeal(encodeFoldSeal(seal)); + EXPECT_EQ(back, seal) << "hold reason " << static_cast(reason); + ASSERT_TRUE(fixtureCoverage(back, "ns/0").hold.has_value()); + EXPECT_EQ(fixtureCoverage(back, "ns/0").hold->reason, reason); + } +} + +/// THE ENCODER'S HALF OF THE GRAMMAR, in one place. Every shape here is OUR OWN fold handing the codec +/// a row it must never make durable, so the refusal is `LOGICAL_ERROR` — the code `encodeGcState` raises +/// for the same category of impossible input — and not the `CORRUPTED_DATA` reserved for bytes that +/// arrived from a store. Under a debug or sanitizer build that code ABORTS at construction +/// (`handle_error_code`), so the same table is asserted as a death expectation there; the contract +/// ("these bytes are never produced") is what both forms pin. +#ifndef DEBUG_OR_SANITIZER_BUILD +TEST(CASGCHoldGrammar, TheEncoderRefusesEveryIllFormedCoverageRow) +{ + for (const auto & entry : illFormedSealsTheEncoderMustRefuse()) + { + SCOPED_TRACE(entry.first); + expectThrowsCode(DB::ErrorCodes::LOGICAL_ERROR, [&] { encodeFoldSeal(entry.second); }); + } +} +#endif + +#if defined(DEBUG_OR_SANITIZER_BUILD) +TEST(CASGCHoldGrammarDeathTest, TheEncoderRefusesEveryIllFormedCoverageRow) +{ + for (const auto & entry : illFormedSealsTheEncoderMustRefuse()) + { + SCOPED_TRACE(entry.first); + EXPECT_DEATH({ (void)encodeFoldSeal(entry.second); }, ""); + } +} +#endif + +TEST(CASGCHoldGrammar, AHoldOnAnyOtherClassificationIsRefusedByTheDecoder) +{ + CasFoldSeal seal; + seal.generation = 1; + RefCoverage cov; + + /// Bytes some other producer wrote. Built by demoting a legitimate held row's classification, so the + /// hold fields are exactly the ones the encoder emits. + cov.classification = 4; + cov.hold = RefHold{.reason = HoldReason::GapBelowWitness, .offending_position = RefTxnId{1, 2}, + .retry_count = 0, .next_retry_round = 1}; + fixtureCoverage(seal, "ns/0") = cov; + String text = encodeFoldSeal(seal); + const size_t at = text.find("\"cls\":4"); + ASSERT_NE(at, String::npos); + text[at + 6] = '2'; + expectThrowsCode(DB::ErrorCodes::CORRUPTED_DATA, [&] { decodeFoldSeal(text); }); +} + +TEST(CASGCHoldGrammar, ClassificationFourWithoutAHoldIsRefusedByTheDecoder) +{ + CasFoldSeal seal; + seal.generation = 1; + RefCoverage cov; + cov.classification = 4; + cov.last_folded_ref_id = RefTxnId{1, 1}; + + /// Every single hold field is REQUIRED: dropping any one of them is corruption, not a default. + cov.hold = RefHold{.reason = HoldReason::BodyUndecodable, .offending_position = RefTxnId{1, 2}, + .retry_count = 3, .next_retry_round = 4}; + fixtureCoverage(seal, "ns/0") = cov; + const String whole = encodeFoldSeal(seal); + for (const String & field : {String(R"("hr":"body_undecodable")"), String(R"("hpe":"1")"), + String(R"("hps":"2")"), String(R"("hrc":3)"), String(R"("hnr":"4")")}) + { + SCOPED_TRACE("without " + field); + const size_t at = whole.find(field); + ASSERT_NE(at, String::npos) << "the encoder does not emit " << field; + String without = whole; + without.erase(at - 1, field.size() + 1); /// the field and the ',' before it + expectThrowsCode(DB::ErrorCodes::CORRUPTED_DATA, [&] { decodeFoldSeal(without); }); + } +} + +TEST(CASGCHoldGrammar, DuplicateHoldKeyIsCorruptedData) +{ + CasFoldSeal seal; + seal.generation = 1; + RefCoverage cov; + cov.classification = 4; + cov.hold = RefHold{.reason = HoldReason::GapBelowWitness, .offending_position = RefTxnId{1, 2}, + .retry_count = 0, .next_retry_round = 5}; + fixtureCoverage(seal, "ns/0") = cov; + + const String whole = encodeFoldSeal(seal); + const String field = R"("hr":"gap_below_witness")"; + const size_t at = whole.find(field); + ASSERT_NE(at, String::npos); + /// The same key twice, with a DIFFERENT value: last-wins would silently rewrite the reason. + String doubled = whole; + doubled.insert(at, R"("hr":"witness_disappeared",)"); + expectThrowsCode(DB::ErrorCodes::CORRUPTED_DATA, [&] { decodeFoldSeal(doubled); }); +} + +TEST(CASGCHoldGrammar, UnknownHoldReasonWordIsCorruptedData) +{ + CasFoldSeal seal; + seal.generation = 1; + RefCoverage cov; + cov.classification = 4; + cov.hold = RefHold{.reason = HoldReason::GapBelowWitness, .offending_position = RefTxnId{1, 2}, + .retry_count = 0, .next_retry_round = 5}; + fixtureCoverage(seal, "ns/0") = cov; + + String text = encodeFoldSeal(seal); + const size_t at = text.find("gap_below_witness"); + ASSERT_NE(at, String::npos); + text.replace(at, strlen("gap_below_witness"), "gap_below_witnesX"); + expectThrowsCode(DB::ErrorCodes::CORRUPTED_DATA, [&] { decodeFoldSeal(text); }); +} + +/// ===================== THE THREE WAYS A SEAL CAN ERASE A HOLD ===================== +/// +/// The three shapes below are one finding, and it is about what a fold seal is FOR. The hold is the only +/// durable record that a namespace stopped and where; everything downstream reads the seal and nothing +/// re-derives the stop. So a seal that decodes into "no hold here" is not a lossy read, it is a licence +/// to delete: the sweep's §6 refusals are stated as `classification == 4` / `== 0` / `hold.has_value()`, +/// and a row that slips past all three reaches an irreversible delete of a manifest the fold never +/// accounted for. Each shape gets past a DIFFERENT one of the decoder's checks, which is why they are +/// pinned separately rather than as one "malformed seal" case. + +/// (1) The classification the reader never sees. `cls` is narrowed to a byte, so an integer on the wire +/// is truncated first and validated (if at all) afterwards: 258 becomes 2, "everything through the +/// cursor was folded". The value has to be judged WIDE, before the narrowing, or the wire can buy +/// coverage that no fold ever performed. +TEST(CASGCHoldGrammar, AClassificationOutsideTheGrammarIsCorruptedData) +{ + const String clean = encodeFoldSeal(cleanSeal("ns/0")); + ASSERT_EQ(fixtureCoverage(decodeFoldSeal(clean), "ns/0").classification, 2) + << "the unmodified row is the one every case below deviates from"; + + /// In-range bytes that are simply not classifications. 3 is the one the sweep's refusals miss. + for (const std::string_view raw : {"3", "5", "6", "255"}) + { + SCOPED_TRACE(String{"cls="} + String{raw}); + expectThrowsCode(DB::ErrorCodes::CORRUPTED_DATA, + [&] { decodeFoldSeal(withRawClassification(clean, raw)); }); + } + + /// Wide integers whose LOW BYTE lands inside the grammar: 258 -> 2 (fully folded), 256 -> 0 + /// (absent), 260 -> 4 (clamped). Each would decode as a row the fold never wrote. + for (const std::string_view raw : {"256", "258", "260", "18446744073709551615"}) + { + SCOPED_TRACE(String{"cls="} + String{raw}); + expectThrowsCode(DB::ErrorCodes::CORRUPTED_DATA, + [&] { decodeFoldSeal(withRawClassification(clean, raw)); }); + } +} + +/// And the field itself is required: an absent `cls` reads as 0, which is not "nothing was said about +/// this namespace" but the positive claim "no round folded it". +TEST(CASGCHoldGrammar, ACoverageRowWithoutAClassificationIsCorruptedData) +{ + const String clean = encodeFoldSeal(cleanSeal("ns/0")); + const size_t at = clean.find("\"cls\":2,"); + ASSERT_NE(at, String::npos); + String without = clean; + without.erase(at, strlen("\"cls\":2,")); + expectThrowsCode(DB::ErrorCodes::CORRUPTED_DATA, [&] { decodeFoldSeal(without); }); +} + +/// (2) The hold that clears itself. `{0,0}` passes the completeness check — every field is present — and +/// then the carry rule drops it on the next round, because a hold rides forward only while the walk +/// stops BELOW its position and nothing is below zero. The namespace advances with no record that it was +/// ever held. A zero in EITHER component is the same defect, and is additionally unnameable: the sweep +/// renders the position when it reports what it retained, and `renderRefTxnId` refuses a zero component. +TEST(CASGCHoldGrammar, AHoldWhoseOffendingPositionHasAZeroComponentIsCorruptedData) +{ + const String held = encodeFoldSeal(heldSeal("ns/0")); + ASSERT_TRUE(fixtureCoverage(decodeFoldSeal(held), "ns/0").hold.has_value()); + + expectThrowsCode(DB::ErrorCodes::CORRUPTED_DATA, [&] + { + decodeFoldSeal(withField(withField(held, R"("hpe":"4")", R"("hpe":"0")"), + R"("hps":"6")", R"("hps":"0")")); + }); + expectThrowsCode(DB::ErrorCodes::CORRUPTED_DATA, + [&] { decodeFoldSeal(withField(held, R"("hpe":"4")", R"("hpe":"0")")); }); + expectThrowsCode(DB::ErrorCodes::CORRUPTED_DATA, + [&] { decodeFoldSeal(withField(held, R"("hps":"6")", R"("hps":"0")")); }); +} + +/// (3) The duplicate row. Two `cov` records for the same (namespace, shard) — held first, clean second — +/// used to be accepted with last-wins, so a single appended line erased a hold without touching the one +/// that recorded it. There is exactly one row per key, and a second one is corruption. +TEST(CASGCHoldGrammar, ASecondCoverageRowForTheSameKeyIsCorruptedData) +{ + const String held_line = covLineOf(encodeFoldSeal(heldSeal("ns/0"))); + const String clean_line = covLineOf(encodeFoldSeal(cleanSeal("ns/0"))); + const String other_clean_line = covLineOf(encodeFoldSeal(cleanSeal("ns/1"))); + const String prototype = encodeFoldSeal(heldSeal("ns/0")); + + /// The CONTROL first: the same two-record assembly with DIFFERENT keys decodes, so the refusal below + /// is about the repeated key and not about the way these bytes are forged. + const CasFoldSeal two_keys = decodeFoldSeal(sealTextWith(prototype, {held_line, other_clean_line})); + ASSERT_EQ(two_keys.ref_lives.size(), 2u); + ASSERT_TRUE(fixtureCoverage(two_keys, "ns/0").hold.has_value()); + + expectThrowsCode(DB::ErrorCodes::CORRUPTED_DATA, + [&] { decodeFoldSeal(sealTextWith(prototype, {held_line, clean_line})); }); + /// Order does not redeem it: a clean row followed by a held one is the same broken object. + expectThrowsCode(DB::ErrorCodes::CORRUPTED_DATA, + [&] { decodeFoldSeal(sealTextWith(prototype, {clean_line, held_line})); }); + /// Nor does repeating the identical row. + expectThrowsCode(DB::ErrorCodes::CORRUPTED_DATA, + [&] { decodeFoldSeal(sealTextWith(prototype, {held_line, held_line})); }); + + /// The ENCODER needs no matching check, and this is why: the seal stores keyed maps, so a second row + /// for a key is not a value any producer can construct — assigning it replaces the first. + CasFoldSeal seal = heldSeal("ns/0"); + fixtureCoverage(seal, "ns/0") = fixtureCoverage(cleanSeal("ns/0"), "ns/0"); + EXPECT_EQ(seal.ref_lives.size(), 1u); +} + +/// The same one-record-per-key rule applies to `cnd`: a repeated row rewrites a shard's condemned +/// totals, which graduation paces on. +TEST(CASGCHoldGrammar, ASecondCondemnedSummaryRecordIsCorruptedData) +{ + CasFoldSeal seal = cleanSeal("ns/0"); + seal.condemned_summary[0] = CondemnedSummary{.condemned_total = 5, .pending_total = 1, + .oldest_nonpending_condemn_round = 3}; + const String encoded = encodeFoldSeal(seal); + + /// Lines 3..4 are `rfl`, `cnd` in the encoder's fixed order. + std::vector lines; + for (size_t begin = headerAndMetaOf(encoded).size(); begin < encoded.size();) + { + const size_t end = encoded.find('\n', begin); + ASSERT_NE(end, String::npos); + lines.push_back(encoded.substr(begin, end - begin)); + begin = end + 1; + } + ASSERT_EQ(lines.size(), 3u) << "rfl, cnd and the trailer"; + const String ref_life_line = lines[0]; + const String cnd_line = lines[1]; + + expectThrowsCode(DB::ErrorCodes::CORRUPTED_DATA, + [&] { decodeFoldSeal(sealTextWith(encoded, {ref_life_line, cnd_line, cnd_line})); }); + /// The unduplicated assembly is the control. + const std::vector one_of_each{ref_life_line, cnd_line}; + EXPECT_NO_THROW(decodeFoldSeal(sealTextWith(encoded, one_of_each))); +} + +/// Unified cleanup evidence still requires a canonical nonzero removal transaction id. Decoding +/// foreign bytes must fail this read, never the process. +TEST(CASGCHoldGrammar, CleanupEvidenceWithAZeroRemovalIdIsCorruptedData) +{ + CasFoldSeal seal = cleanSeal("ns/0"); + seal.ref_lives.at(fixtureLifeId("ns/0")).cleanup_evidence = + RefCleanupEvidence{.remove_txn_id = RefTxnId{2, 3}}; + const String encoded = encodeFoldSeal(seal); + + expectThrowsCode(DB::ErrorCodes::CORRUPTED_DATA, + [&] { decodeFoldSeal(withField(encoded, R"("rte":"2")", R"("rte":"0")")); }); + expectThrowsCode(DB::ErrorCodes::CORRUPTED_DATA, + [&] { decodeFoldSeal(withField(encoded, R"("rts":"3")", R"("rts":"0")")); }); + /// Omitted entirely is the same thing: the fields default to zero. + expectThrowsCode(DB::ErrorCodes::CORRUPTED_DATA, + [&] { decodeFoldSeal(withField(encoded, R"("rte":"2",)", "")); }); +} + +/// The OBJECT cap bounds the whole seal. Nothing on the fold-seal READ path enforces it (the seal +/// is read raw, never through `openObject`), so an oversized PUT would leave a durable seal that no +/// later round can decode — unrecoverable. The gate therefore sits before the bytes are handed out, and +/// equality is still accepted: the cap is the largest permitted size, not the first forbidden one. +TEST(CASGCHoldGrammar, ObjectCapAcceptsEqualityAndRefusesOneMoreByte) +{ + const uint64_t object_cap = foldSealCaps().object_cap; + ASSERT_EQ(object_cap, 256u * 1024 * 1024); + + EXPECT_NO_THROW(checkFoldSealObjectBytes(object_cap - 1)); + EXPECT_NO_THROW(checkFoldSealObjectBytes(object_cap)); + expectThrowsCode(DB::ErrorCodes::LIMIT_EXCEEDED, [&] { checkFoldSealObjectBytes(object_cap + 1); }); + + /// An ordinary seal is nowhere near it, so the gate costs a comparison and changes nothing. + EXPECT_NO_THROW(encodeFoldSeal(maximalHoldSeal("ns/0"))); +} + +/// ===================== HOLDS ARE CREATED WITH AN EXACT POSITION ===================== + +TEST(CASGCHoldGrammar, GapBelowWitnessNamesTheExactAbsentPosition) +{ + auto backend = std::make_shared(); + auto store = openPoolForTest(backend, /*gc_fold_max_defer_rounds=*/0); + const Layout & layout = store->layout(); + const RootNamespace ns{"00/aa@cas@"}; + + publishAt(*backend, layout, ns, RefTxnId{1, 1}, "ref_1", 1, DB::UInt128(1), /*birth=*/true); + publishAt(*backend, layout, ns, RefTxnId{1, 2}, "ref_2", 2, DB::UInt128(2)); + /// {1,3} never existed; {1,4} is durable AND listed, so the gap is impossible under contiguity. + publishAt(*backend, layout, ns, RefTxnId{1, 4}, "ref_4", 4, DB::UInt128(4)); + writeCommittedCkptAt(*backend, layout, ns, RefTxnId{1, 4}); + + Gc gc(store, kGc); + ASSERT_TRUE(gc.runRegularRound().acquired_lease); + + const RefHold hold = holdOf(*backend, layout, ns); + EXPECT_EQ(hold.reason, HoldReason::GapBelowWitness); + EXPECT_EQ(hold.offending_position, (RefTxnId{1, 3})); + EXPECT_EQ(hold.retry_count, 0u) << "the round that creates a hold has retried nothing yet"; + EXPECT_GT(hold.next_retry_round, 0u); + EXPECT_EQ(sealedCursorOf(*backend, layout, ns), (RefTxnId{1, 2})); +} + +TEST(CASGCHoldGrammar, UnconsumedSealCrossingNamesTheAbsentPosition) +{ + auto backend = std::make_shared(); + auto store = openPoolForTest(backend, /*gc_fold_max_defer_rounds=*/0); + const Layout & layout = store->layout(); + const RootNamespace ns{"00/aa@cas@"}; + + publishAt(*backend, layout, ns, RefTxnId{1, 1}, "ref_1", 1, DB::UInt128(1), /*birth=*/true); + /// Epoch 1 ends at {1,1} with NO seal, and epoch 2 chains to a seal at {1,3} that this cursor + /// never consumed (and that does not exist). The nearest witness above the absent {1,2} therefore + /// sits in another epoch, and the crossing has nothing to prove itself from. + publishAt(*backend, layout, ns, RefTxnId{2, 1}, "ref_2", 2, DB::UInt128(2), + /*birth=*/false, /*prev_epoch_seal=*/RefTxnId{1, 3}); + writeRecoverableCkptForRawFixture(*backend, layout, ns, RefCkpt{ + .life_epoch = 1, + .committed_through = RefTxnId{2, 1}, + .checkpoint_snapshot_id = std::nullopt, + .last_epoch_seal = RefTxnId{1, 3}, + }); + + Gc gc(store, kGc); + ASSERT_TRUE(gc.runRegularRound().acquired_lease); + + const RefHold hold = holdOf(*backend, layout, ns); + EXPECT_EQ(hold.reason, HoldReason::UnconsumedSealCrossing); + EXPECT_EQ(hold.offending_position, (RefTxnId{1, 2})) << "the hold names the position that read absent"; + EXPECT_EQ(sealedCursorOf(*backend, layout, ns), (RefTxnId{1, 1})); + EXPECT_EQ(inDegreeOf(*backend, layout, DB::UInt128(2)), 0) + << "nothing beyond the unproven boundary may fold"; +} + +TEST(CASGCHoldGrammar, UndecodableBodyNamesTheRecordItCouldNotRead) +{ + auto backend = std::make_shared(); + auto store = openPoolForTest(backend, /*gc_fold_max_defer_rounds=*/0); + const Layout & layout = store->layout(); + const RootNamespace ns{"00/aa@cas@"}; + fixture::admitLive(*backend, store->layout(), ns); /// Stage B (Task 4-C): pin to the sentinel before the first real touch + + publishAt(*backend, layout, ns, RefTxnId{1, 1}, "ref_1", 1, DB::UInt128(1), /*birth=*/true); + backend->putIfAbsent(layout.refLogKey(fixture::fixtureLife(ns), RefTxnId{1, 2}), "this is not a cas_ref_log object"); + writeCommittedCkptAt(*backend, layout, ns, RefTxnId{1, 2}); + + Gc gc(store, kGc); + ASSERT_TRUE(gc.runRegularRound().acquired_lease); + + const RefHold hold = holdOf(*backend, layout, ns); + EXPECT_EQ(hold.reason, HoldReason::BodyUndecodable); + EXPECT_EQ(hold.offending_position, (RefTxnId{1, 2})); +} + +/// The fold barrier is a hold too, and it is the ONE hold whose ordinary cause is benign: a writer that +/// has appended its precommit record but not yet finished uploading the manifest body. It gets the same +/// durable treatment as the corruption shapes because it stops the namespace the same way — and because +/// a barrier that is durably named is one an operator can distinguish from a wedge. +TEST(CASGCHoldGrammar, MissingManifestBodyBarrierIsADurableHold) +{ + auto backend = std::make_shared(); + auto store = openPoolForTest(backend, /*gc_fold_max_defer_rounds=*/0); + const Layout & layout = store->layout(); + const RootNamespace ns{"00/aa@cas@"}; + + publishAt(*backend, layout, ns, RefTxnId{1, 1}, "ref_1", 1, DB::UInt128(1), /*birth=*/true); + publishAt(*backend, layout, ns, RefTxnId{1, 2}, "ref_2", 2, DB::UInt128(2)); + deleteManifestBody(*backend, layout, + ManifestId{ns, ManifestRef{.writer_epoch = 1, .build_sequence = 2, .manifest_ordinal = 1}}); + writeCommittedCkptAt(*backend, layout, ns, RefTxnId{1, 2}); + + Gc gc(store, kGc); + ASSERT_TRUE(gc.runRegularRound().acquired_lease); + + const RefHold hold = holdOf(*backend, layout, ns); + EXPECT_EQ(hold.reason, HoldReason::ManifestBodyMissing); + EXPECT_EQ(hold.offending_position, (RefTxnId{1, 2})) << "the hold names the LOG whose edges could not fold"; + EXPECT_EQ(sealedCursorOf(*backend, layout, ns), (RefTxnId{1, 1})); +} + +/// An above-cursor record that answered one GET and then stopped answering is CORRUPTION, not a +/// frontier: nothing may legitimately remove an object above the fold cursor. It is the one hold shape +/// that no amount of waiting can clear, and naming it durably is what stops a later round from reading +/// the same namespace as quiet and granting it a frontier proof. +TEST(CASGCHoldGrammar, AWitnessThatStopsAnsweringIsWitnessDisappeared) +{ + /// Answers on odd-numbered reads and 404s on even ones: `crossFromSeal` proves the position, and + /// the walk's own GET of it then fails. + class AlternatingGetBackend : public InMemoryBackend + { + public: + using DB::Cas::Backend::get; + String flaky; + size_t reads = 0; + + std::optional get(const String & key, Range range) override + { + if (key == flaky && ++reads % 2 == 0) + return std::nullopt; + return InMemoryBackend::get(key, range); + } + }; + + auto backend = std::make_shared(); + auto store = openPoolForTest(backend, /*gc_fold_max_defer_rounds=*/0); + const Layout & layout = store->layout(); + const RootNamespace ns{"00/aa@cas@"}; + fixture::admitLive(*backend, store->layout(), ns); /// Stage B (Task 4-C): pin to the sentinel before the first real touch + + publishAt(*backend, layout, ns, RefTxnId{1, 1}, "ref_1", 1, DB::UInt128(1), /*birth=*/true); + writeSealAt(*backend, layout, ns, RefTxnId{1, 2}); + publishAt(*backend, layout, ns, RefTxnId{2, 1}, "ref_2", 2, DB::UInt128(2), + /*birth=*/false, /*prev_epoch_seal=*/RefTxnId{1, 2}); + /// A third epoch keeps the unstable position from reading as a frontier. + publishAt(*backend, layout, ns, RefTxnId{3, 1}, "ref_3", 3, DB::UInt128(3), + /*birth=*/false, /*prev_epoch_seal=*/RefTxnId{2, 1}); + writeRecoverableCkptForRawFixture(*backend, layout, ns, RefCkpt{ + .life_epoch = 1, + .committed_through = RefTxnId{3, 1}, + .checkpoint_snapshot_id = std::nullopt, + .last_epoch_seal = RefTxnId{2, 1}, + }); + backend->flaky = layout.refLogKey(fixture::fixtureLife(ns), RefTxnId{2, 1}); + + Gc gc(store, kGc); + ASSERT_TRUE(gc.runRegularRound().acquired_lease); + + const RefHold hold = holdOf(*backend, layout, ns); + EXPECT_EQ(hold.reason, HoldReason::WitnessDisappeared); + /// The walk crossed into epoch 2 on the record's first answer and then could not read it: the hold + /// names {2,1}, the position that stopped being readable, and the cursor stays on the seal below it. + EXPECT_EQ(hold.offending_position, (RefTxnId{2, 1})); + EXPECT_EQ(sealedCursorOf(*backend, layout, ns), (RefTxnId{1, 2})); +} + +/// ===================== THE SECOND WITNESS: `_ckpt.checkpoint` ===================== +/// +/// A listing is a SNAPSHOT: a record that became durable after the enumeration is invisible to that +/// round's probes, so an absent expected-next reads as a frontier when it is really a gap. The +/// namespace's own durable checkpoint decides the same question without asking the listing anything — +/// and this pair of pools is the proof, because they differ in nothing else. +TEST(CASGCHoldGrammar, CheckpointWitnessHoldsAGapTheHintIsSilentAbout) +{ + const RootNamespace ns{"00/aa@cas@"}; + /// Stage B (Task 4-C): no pin needed here -- `publishAt` below (draining into `writeRefLogTxnRaw`) + /// admits `ns` into the catalog itself, once per pool, inside each nested block's own `seed` call. + const auto seed = [&](HintHoleCountingBackend & backend, const Layout & layout) + { + publishAt(backend, layout, ns, RefTxnId{1, 1}, "ref_1", 1, DB::UInt128(1), /*birth=*/true); + publishAt(backend, layout, ns, RefTxnId{1, 2}, "ref_2", 2, DB::UInt128(2)); + /// {1,3} is missing and {1,4}, though durable, is invisible to every LIST. + publishAt(backend, layout, ns, RefTxnId{1, 4}, "ref_4", 4, DB::UInt128(4)); + backend.hide(layout.refLogKey(fixture::fixtureLife(ns), RefTxnId{1, 4})); + }; + + /// Hint-only: nothing above {1,2} is visible, so the walk honestly reads a frontier and does not hold. + { + auto backend = std::make_shared(); + auto store = openPoolForTest(backend, /*gc_fold_max_defer_rounds=*/0); + seed(*backend, store->layout()); + Gc gc(store, kGc); + ASSERT_TRUE(gc.runRegularRound().acquired_lease); + ASSERT_GT(backend->holesServed(), 0u); + const auto cov = coverageOf(*backend, store->layout(), ns); + ASSERT_TRUE(cov.has_value()); + EXPECT_FALSE(cov->hold.has_value()) << "without a witness an absent IS the frontier"; + } + + /// Same pool, same hint, plus the checkpoint: the gap becomes decidable and holds at the same + /// position, with the same reason, as if the hint had shown the witness itself. + /// + /// The `_ckpt` object is hidden from every LIST as well, so the two pools' listings are byte-for-byte + /// the same and the only difference between them is an object reachable by EXACT KEY alone. That is + /// what makes this a proof of hint-INDEPENDENCE rather than of a richer hint. + { + auto backend = std::make_shared(); + auto store = openPoolForTest(backend, /*gc_fold_max_defer_rounds=*/0); + const Layout & layout = store->layout(); + seed(*backend, layout); + writeCkptAt(*backend, layout, ns, RefTxnId{1, 4}); + backend->hide(layout.refCkptKey(fixture::fixtureLife(ns))); + + Gc gc(store, kGc); + ASSERT_TRUE(gc.runRegularRound().acquired_lease); + + const RefHold hold = holdOf(*backend, layout, ns); + EXPECT_EQ(hold.reason, HoldReason::GapBelowWitness); + EXPECT_EQ(hold.offending_position, (RefTxnId{1, 3})); + } +} + +/// The namespace whose second witness matters MOST: one the hint has stopped mentioning entirely, kept +/// in the round's universe by nothing but its CARRIED HOLD. Its checkpoint is why +/// `readCheckpointWitnesses` takes the parent cursors as well as the hint — the hold alone witnesses only +/// the position it stopped at, so a gap ABOVE that position, once the hold resolves, has no witness left. +TEST(CASGCHoldGrammar, CheckpointWitnessReachesAHeldNamespaceTheHintNoLongerNames) +{ + const RootNamespace ns{"00/aa@cas@"}; + /// Stage B (Task 4-C): no pin needed -- `publishAt` inside `seedPool` (draining into + /// `writeRefLogTxnRaw`) admits `ns` into each nested block's own pool. + + /// Round 1 in both pools: held at {1,3} by a gap below the listed witness {1,4}. Then the hint goes + /// silent about every one of the namespace's objects, {1,3} becomes readable (so the hold resolves and + /// the walk runs on), and a durable-but-unlisted {1,6} leaves a fresh gap at {1,5}. + const auto seedPool = [&](HintHoleCountingBackend & backend, const Layout & layout, Gc & gc) + { + publishAt(backend, layout, ns, RefTxnId{1, 1}, "ref_1", 1, DB::UInt128(1), /*birth=*/true); + publishAt(backend, layout, ns, RefTxnId{1, 2}, "ref_2", 2, DB::UInt128(2)); + publishAt(backend, layout, ns, RefTxnId{1, 4}, "ref_4", 4, DB::UInt128(4)); + writeCommittedCkptAt(backend, layout, ns, RefTxnId{1, 4}); + EXPECT_TRUE(gc.runRegularRound().acquired_lease); + EXPECT_EQ(holdOf(backend, layout, ns).offending_position, (RefTxnId{1, 3})); + + publishAt(backend, layout, ns, RefTxnId{1, 3}, "ref_3", 3, DB::UInt128(3)); + publishAt(backend, layout, ns, RefTxnId{1, 6}, "ref_6", 6, DB::UInt128(6)); + for (const RefTxnId & id : {RefTxnId{1, 1}, RefTxnId{1, 2}, RefTxnId{1, 3}, RefTxnId{1, 4}, + RefTxnId{1, 6}}) + backend.hide(layout.refLogKey(fixture::fixtureLife(ns), id)); + }; + + /// Hold-witness only: it witnesses {1,3}, which the walk has now passed, so the absent {1,5} above it + /// is an honest frontier and the namespace comes out clean. + { + auto backend = std::make_shared(); + auto store = openPoolForTest(backend, /*gc_fold_max_defer_rounds=*/0); + Gc gc(store, kGc); + seedPool(*backend, store->layout(), gc); + + ASSERT_TRUE(gc.runRegularRound().acquired_lease); + const auto cov = coverageOf(*backend, store->layout(), ns); + ASSERT_TRUE(cov.has_value()); + EXPECT_FALSE(cov->hold.has_value()) << "a resolved hold witnesses nothing above itself"; + EXPECT_EQ(cov->last_folded_ref_id, (RefTxnId{1, 4})); + } + + /// Same pool, plus the checkpoint — read by exact key for a namespace THIS round's hint never names. + { + auto backend = std::make_shared(); + auto store = openPoolForTest(backend, /*gc_fold_max_defer_rounds=*/0); + const Layout & layout = store->layout(); + Gc gc(store, kGc); + seedPool(*backend, layout, gc); + advanceRecoverableCkptForRawFixture(*backend, layout, ns, RefTxnId{1, 6}); + backend->hide(layout.refCkptKey(fixture::fixtureLife(ns))); + + ASSERT_TRUE(gc.runRegularRound().acquired_lease); + const RefHold hold = holdOf(*backend, layout, ns); + EXPECT_EQ(hold.reason, HoldReason::GapBelowWitness); + EXPECT_EQ(hold.offending_position, (RefTxnId{1, 5})); + EXPECT_EQ(sealedCursorOf(*backend, layout, ns), (RefTxnId{1, 4})); + } +} + +/// The second witness can also be UNREADABLE, and that is a different answer from absent. An absent +/// `_ckpt` says "this namespace published no checkpoint" and honestly contributes no witness; a present +/// one that will not decode says "this namespace HAS a checkpoint and we cannot read it", which no walk +/// may treat as no witness. +/// +/// It is still ONE NAMESPACE'S object. The fold used to fail the whole round closed on it — every +/// namespace's cursor, seal and cleanup stopped, every round, on one unreadable 4 KiB object, and the +/// exception named neither the namespace nor the key. The rule is the one §5 states for every other +/// per-namespace failure: hold the namespace that owns the object, fold everything else. +TEST(CASGCHoldGrammar, AnUndecodableCheckpointHoldsOnlyItsOwnNamespace) +{ + const RootNamespace bad{"00/aa@cas@"}; + /// Stage B (Task 4-C): no pin needed -- `publishAt(..., birth=true)` below (draining into + /// `writeRefLogTxnRaw`) admits `bad` into the catalog itself, pinned to the same sentinel this + /// test's own `fixture::fixtureLife(bad)` key computations already assume. + const RootNamespace good{"00/bb@cas@"}; + + auto backend = std::make_shared(); + auto store = openPoolForTest(backend, /*gc_fold_max_defer_rounds=*/0); + const Layout & layout = store->layout(); + Gc gc(store, kGc); + + publishAt(*backend, layout, bad, RefTxnId{1, 1}, "ref_1", 1, DB::UInt128(1), /*birth=*/true); + publishAt(*backend, layout, bad, RefTxnId{1, 2}, "ref_2", 2, DB::UInt128(2)); + publishAt(*backend, layout, good, RefTxnId{1, 1}, "ref_1", 1, DB::UInt128(11), /*birth=*/true); + writeCkptAt(*backend, layout, bad, RefTxnId{1, 2}); + writeCkptAt(*backend, layout, good, RefTxnId{1, 1}); + + /// Round 1 is the BASELINE both namespaces are measured against: each folds its whole stream and + /// seals a cursor, and neither holds. + ASSERT_TRUE(gc.runRegularRound().acquired_lease); + ASSERT_EQ(sealedCursorOf(*backend, layout, bad), (RefTxnId{1, 2})); + ASSERT_EQ(sealedCursorOf(*backend, layout, good), (RefTxnId{1, 1})); + ASSERT_FALSE(coverageOf(*backend, layout, bad)->hold.has_value()); + + /// Corrupt EXACTLY ONE OBJECT: the first namespace's `_ckpt` body. Nothing else in the pool changes, + /// so everything the next round does differently is attributable to this one object. + const String bad_ckpt_key = layout.refCkptKey(fixture::fixtureLife(bad)); + const HeadResult ckpt_head = backend->head(bad_ckpt_key); + ASSERT_TRUE(ckpt_head.exists); + ASSERT_EQ(backend->putOverwrite(bad_ckpt_key, "this is not a cas_ref_ckpt", ckpt_head.token).outcome, + PutOutcome::Done); + + /// Work only a round that COMPLETES can fold. + publishAt(*backend, layout, good, RefTxnId{1, 2}, "ref_2", 2, DB::UInt128(12)); + const String good_ckpt_key = layout.refCkptKey(fixture::fixtureLife(good)); + const HeadResult good_ckpt_head = backend->head(good_ckpt_key); + ASSERT_TRUE(good_ckpt_head.exists); + ASSERT_EQ(backend->putOverwrite(good_ckpt_key, encodeRefCkpt(RefCkpt{ + .life_epoch = 1, + .committed_through = RefTxnId{1, 2}, + .checkpoint_snapshot_id = RefTxnId{1, 2}, + .last_epoch_seal = std::nullopt, + }), good_ckpt_head.token).outcome, PutOutcome::Done); + + ASSERT_TRUE(gc.runRegularRound().acquired_lease); + + /// The namespace that owns the object is held, at the position its walk would have read next, and + /// its coverage row rides UNCHANGED — the cursor may not move while the hold stands. + const RefHold hold = holdOf(*backend, layout, bad); + EXPECT_EQ(hold.reason, HoldReason::CheckpointUndecodable); + EXPECT_EQ(hold.offending_position, (RefTxnId{1, 3})); + EXPECT_EQ(sealedCursorOf(*backend, layout, bad), (RefTxnId{1, 2})); + + /// The other namespace folded its new record. This is the whole point of the finding: one corrupt + /// object must not stop the pool. + const auto good_cov = coverageOf(*backend, layout, good); + ASSERT_TRUE(good_cov.has_value()); + EXPECT_FALSE(good_cov->hold.has_value()) << "the corrupt object belongs to the OTHER namespace"; + EXPECT_EQ(good_cov->last_folded_ref_id, (RefTxnId{1, 2})); + EXPECT_EQ(good_cov->classification, 2); + + /// And nothing was destroyed for the held namespace: a hold shuts the round's destructive gate, so + /// its ref objects — including the ones a cleanup range computed WITHOUT the unreadable checkpoint + /// would have widened onto — are all still there. + for (const RefTxnId & id : {RefTxnId{1, 1}, RefTxnId{1, 2}}) + EXPECT_TRUE(backend->head(layout.refLogKey(fixture::fixtureLife(bad), id)).exists) + << "ref log " << renderRefTxnId(id) << " of the held namespace was deleted"; +} + +/// THE OTHER ARM OF THE SAME RULE, and the one that must NOT mint a hold. +/// +/// A namespace can carry an undecodable `_ckpt` and offer the walk NO POSITION TO READ: never folded +/// (no sealed cursor) and no listed log. Two ways to get there, both real. A writer publishes the +/// object around its namespace's birth, so a `_ckpt` that lands before the birth log is durable is +/// exactly this shape. And `parseRefCkptKey` deliberately resolves anything of the form +/// `/_ckpt`, so a key with a stray segment names the checkpoint of a table that has no logs +/// and no snapshots and never will (`CasLayout.h`, "the phantom table it names ... the fold does +/// nothing for it") — which is precisely the object that used to halt GC for the entire pool. +/// +/// NO HOLD IS MINTED, and that is a positive design choice rather than a shortfall. A hold is not just +/// a stop flag: its `offending_position` is read by every later round as a DURABLE WITNESS that some +/// round once reached that position, which turns an absent below it into a gap rather than a frontier. +/// The walk here reached nothing, so any position would be invented — `{0, 0}` is rejected outright by +/// both codecs, and any canonical value would plant a permanent false witness under a namespace whose +/// records legitimately do not exist. The anomaly carries it instead, which is enough because it shuts +/// the same round-wide destructive gate a hold would, and because everything the checkpoint gates is a +/// no-op for a namespace the walk cannot even start on: nothing to fold, and an empty delete plan. +TEST(CASGCHoldGrammar, AnUndecodableCheckpointWithNoWalkPositionRecordsAnAnomalyAndMintsNoHold) +{ + const RootNamespace phantom{"00/aa@cas@"}; + const RootNamespace good{"00/bb@cas@"}; + + auto backend = std::make_shared(); + auto store = openPoolForTest(backend, /*gc_fold_max_defer_rounds=*/0); + const Layout & layout = store->layout(); + Gc gc(store, kGc); + + /// Stage B (Task 4-C): `phantom` gets no birth and no other production touch -- it is meant to + /// have no logs and no snapshots, ever. But `discoverUniverse` is now catalog-authoritative, so a + /// namespace absent from the catalog is invisible to the walk (R10 treats it as foreign-prefix-inert), + /// and this test's whole premise -- that GC still surfaces an anomaly for an uncataloged `_ckpt` -- + /// would be silently defeated. Admitting it here (still with no `_ckpt` of its own) is what keeps + /// `phantom` reachable by `readCheckpointWitnesses` without giving it the birth this test deliberately + /// withholds. + fixture::admitLive(*backend, layout, phantom); + + /// A lone `_ckpt` with an undecodable body, and NOTHING else under that namespace. + backend->putIfAbsent(layout.refCkptKey(fixture::fixtureLife(phantom)), "this is not a cas_ref_ckpt"); + publishAt(*backend, layout, good, RefTxnId{1, 1}, "ref_1", 1, DB::UInt128(11), /*birth=*/true); + writeCommittedCkptAt(*backend, layout, good, RefTxnId{1, 1}); + + const RoundReport report = gc.runRegularRound(); + ASSERT_TRUE(report.acquired_lease); + + /// The anomaly is the whole carrier here: it is what shuts the round's destructive gate, and the + /// gate is what keeps `cleanupRefObjects` from computing this namespace's delete range from an + /// ABSENT checkpoint — which is the WIDEST reading, not the safest. + /// Located by namespace and shard, then CHECKED ON ITS REASON. Asserting only that "some anomaly + /// exists for this namespace" is a pin any future unrelated anomaly would satisfy, and this test + /// would then stop testing anything; the reason check is what keeps it pinned to this arm. Note it + /// is also stricter than searching BY reason would be — it requires the FIRST anomaly recorded for + /// this namespace to be this one, not merely that one of them somewhere is. + const auto anomaly = std::find_if(report.anomalies.begin(), report.anomalies.end(), + [&](const RoundAnomaly & a) { return a.ns.string() == phantom.string() && a.shard == 0; }); + ASSERT_NE(anomaly, report.anomalies.end()) + << "an unreadable `_ckpt` must be surfaced even when there is no walk to stop"; + EXPECT_NE(anomaly->reason.find("_ckpt"), String::npos) + << "the anomaly must say WHAT stopped the namespace, not merely that something did"; + + const auto cov = coverageOf(*backend, layout, phantom); + ASSERT_TRUE(cov.has_value()); + EXPECT_FALSE(cov->hold.has_value()) << "a hold here could only name a position no round ever read"; + EXPECT_EQ(cov->classification, 1) << "nothing was folded, so the row is `unchanged`"; + EXPECT_EQ(cov->last_folded_ref_id, (RefTxnId{})); + + /// Same isolation as the held arm: the pool keeps working. + const auto good_cov = coverageOf(*backend, layout, good); + ASSERT_TRUE(good_cov.has_value()); + EXPECT_FALSE(good_cov->hold.has_value()); + EXPECT_EQ(good_cov->last_folded_ref_id, (RefTxnId{1, 1})); + + /// The unreadable object itself is never deleted as debris — repairing it is the operator's move, + /// and GC removing it would erase the only evidence of what stopped the namespace. + EXPECT_TRUE(backend->head(layout.refCkptKey(fixture::fixtureLife(phantom))).exists); +} + +/// ===================== THE HOLD IS DURABLE ===================== + +namespace +{ + +/// Seed a namespace held at {1,3} by a gap below the listed witness {1,4}, then make the hint forget +/// the namespace exists. Returns the round-1 hold. +RefHold seedHeldThenUnhinted( + const std::shared_ptr & backend, const PoolPtr & store, + const RootNamespace & ns, Gc & gc) +{ + const Layout & layout = store->layout(); + publishAt(*backend, layout, ns, RefTxnId{1, 1}, "ref_1", 1, DB::UInt128(1), /*birth=*/true); + publishAt(*backend, layout, ns, RefTxnId{1, 2}, "ref_2", 2, DB::UInt128(2)); + publishAt(*backend, layout, ns, RefTxnId{1, 4}, "ref_4", 4, DB::UInt128(4)); + writeCommittedCkptAt(*backend, layout, ns, RefTxnId{1, 4}); + + EXPECT_TRUE(gc.runRegularRound().acquired_lease); + const RefHold hold = holdOf(*backend, layout, ns); + + /// Every one of the namespace's objects vanishes from every LIST while staying readable by key: + /// the round that follows has no hint entry for this namespace at all. + for (const RefTxnId & id : {RefTxnId{1, 1}, RefTxnId{1, 2}, RefTxnId{1, 4}}) + backend->hide(layout.refLogKey(fixture::fixtureLife(ns), id)); + return hold; +} + +} + +TEST(CASGCHoldGrammar, HoldRidesARoundWhoseHintOmitsTheNamespace) +{ + auto backend = std::make_shared(); + auto store = openPoolForTest(backend, /*gc_fold_max_defer_rounds=*/0); + const RootNamespace ns{"00/aa@cas@"}; + Gc gc(store, kGc); + const RefHold first = seedHeldThenUnhinted(backend, store, ns, gc); + + ASSERT_TRUE(gc.runRegularRound().acquired_lease); + ASSERT_GT(backend->holesServed(), 0u); + + const RefHold second = holdOf(*backend, store->layout(), ns); + EXPECT_EQ(second.reason, first.reason) << "a quiet hint must not rewrite why the namespace is held"; + EXPECT_EQ(second.offending_position, first.offending_position); + EXPECT_EQ(sealedCursorOf(*backend, store->layout(), ns), (RefTxnId{1, 2})) + << "the cursor may not advance while the hold stands"; + /// The one field that moves, and the reason it exists: it counts the rounds that retried and failed. + EXPECT_EQ(second.retry_count, first.retry_count + 1); +} + +TEST(CASGCHoldGrammar, HoldForcesAnExactRetryOfItsOffendingPositionWhenUnhinted) +{ + auto backend = std::make_shared(); + auto store = openPoolForTest(backend, /*gc_fold_max_defer_rounds=*/0); + const RootNamespace ns{"00/aa@cas@"}; + fixture::admitLive(*backend, store->layout(), ns); /// Stage B (Task 4-C): pin to the sentinel before the first real touch + Gc gc(store, kGc); + seedHeldThenUnhinted(backend, store, ns, gc); + + const String offending = store->layout().refLogKey(fixture::fixtureLife(ns), RefTxnId{1, 3}); + const uint64_t before = backend->getCount(offending); + ASSERT_TRUE(gc.runRegularRound().acquired_lease); + + EXPECT_GT(backend->getCount(offending), before) + << "a carried hold must read its offending position by EXACT key; the hint cannot be asked, " + "because the hint no longer mentions the namespace at all"; +} + +/// The clearing rule, stated as a test: an absent proves nothing. The round below observes the +/// offending position absent AGAIN, with no witness anywhere — exactly the observation a lying store +/// produces — and the hold survives it. Only the record actually appearing, being folded, and the +/// result reaching `gc/state` clears it. +TEST(CASGCHoldGrammar, HoldClearsOnlyByFoldingThroughTheOffendingPosition) +{ + auto backend = std::make_shared(); + auto store = openPoolForTest(backend, /*gc_fold_max_defer_rounds=*/0); + const Layout & layout = store->layout(); + const RootNamespace ns{"00/aa@cas@"}; + fixture::admitLive(*backend, store->layout(), ns); /// Stage B (Task 4-C): pin to the sentinel before the first real touch + Gc gc(store, kGc); + seedHeldThenUnhinted(backend, store, ns, gc); + + /// Round 2: another absent, no witness. NOT a clearance. + ASSERT_TRUE(gc.runRegularRound().acquired_lease); + EXPECT_EQ(holdOf(*backend, layout, ns).offending_position, (RefTxnId{1, 3})); + + /// The record appears at last (still invisible to every LIST — the hold is the only thing that + /// knows to look there). + publishAt(*backend, layout, ns, RefTxnId{1, 3}, "ref_3", 3, DB::UInt128(3)); + backend->hide(layout.refLogKey(fixture::fixtureLife(ns), RefTxnId{1, 3})); + + ASSERT_TRUE(gc.runRegularRound().acquired_lease); + const auto cov = coverageOf(*backend, layout, ns); + ASSERT_TRUE(cov.has_value()); + EXPECT_FALSE(cov->hold.has_value()) << "folding through the offending position is what clears a hold"; + EXPECT_EQ(cov->classification, 2); + EXPECT_EQ(cov->last_folded_ref_id, (RefTxnId{1, 4})) << "the walk resumed past the resolved gap"; + EXPECT_EQ(inDegreeOf(*backend, layout, DB::UInt128(4)), 1) + << "the record above the gap finally contributed its owner edge"; +} + +/// ===================== REBUILD ===================== + +namespace +{ + +/// Rewrite the fold seal at an EXACT `(generation, attempt)`, applying `mutate` to it. Needed where +/// the seal under test is not the adopted one — a step-down test plants its hold in a generation the +/// pool has already moved past. +void mutateSealAt(Backend & backend, const Layout & layout, uint64_t generation, uint64_t attempt, + const std::function & mutate) +{ + const String key = layout.foldSealKey(generation, attempt); + CasFoldSeal seal = decodeFoldSeal(backend.get(key)->bytes); + mutate(seal); + backend.putOverwrite(key, encodeFoldSeal(seal), backend.head(key).token); +} + +/// Rewrite the adopted fold seal, applying `mutate` to it. Used to plant a hold that the rebuild must +/// then carry: planting it directly (rather than by holding a real round) keeps the REBUILD tests about +/// the carry, not about how the hold arose. +void mutateAdoptedSeal(Backend & backend, const Layout & layout, const std::function & mutate) +{ + const GcState st = decodeGcState(backend.get(layout.gcStateKey())->bytes); + const String key = layout.foldSealKey(st.snap_generation, st.snap_attempt); + CasFoldSeal seal = decodeFoldSeal(backend.get(key)->bytes); + mutate(seal); + backend.putOverwrite(key, encodeFoldSeal(seal), backend.head(key).token); +} + +RefHold plantedHold() +{ + return RefHold{.reason = HoldReason::WitnessDisappeared, .offending_position = RefTxnId{4, 9}, + .retry_count = 17, .next_retry_round = 23}; +} + +} + +/// A rebuild carries a hold only for the matching catalog life. A historical row whose id is absent +/// from the rebuild cut is dropped and cannot mint output work. +TEST(CASGCHoldGrammar, RebuildCarriesMatchingHoldAndDropsAbsentLife) +{ + auto backend = std::make_shared(); + auto store = openPoolForTest(backend, /*gc_fold_max_defer_rounds=*/0); + const Layout & layout = store->layout(); + const RootNamespace ns{"00/aa@cas@"}; + + publishAt(*backend, layout, ns, RefTxnId{1, 1}, "ref_1", 1, DB::UInt128(1), /*birth=*/true); + writeCommittedCkptAt(*backend, layout, ns, RefTxnId{1, 1}); + Gc gc(store, kGc); + ASSERT_TRUE(gc.runRegularRound().acquired_lease); + + const UInt128 life_id = catalogLifeIdForTest(*backend, layout, ns); + constexpr UInt128 absent_life_id{0xfeed}; + mutateAdoptedSeal(*backend, layout, [&](CasFoldSeal & seal) + { + RefCoverage & cov = seal.ref_lives.at(life_id).coverage; + cov.classification = 4; + cov.hold = plantedHold(); + RefCoverage gone; + gone.classification = 4; + gone.last_folded_ref_id = RefTxnId{2, 2}; + gone.hold = RefHold{.reason = HoldReason::GapBelowWitness, .offending_position = RefTxnId{2, 3}, + .retry_count = 1, .next_retry_round = 2}; + seal.ref_lives[absent_life_id].coverage = gone; + }); + + const RebuildReport rep = gc.rebuildBaseline(/*force=*/true); + ASSERT_TRUE(rep.performed) << rep.refusal; + + const auto rebuilt = newestSeal(*backend, layout); + ASSERT_TRUE(rebuilt.has_value()); + const auto rediscovered = rebuilt->ref_lives.find(life_id); + ASSERT_NE(rediscovered, rebuilt->ref_lives.end()); + EXPECT_EQ(rediscovered->second.coverage.classification, 4); + ASSERT_TRUE(rediscovered->second.coverage.hold.has_value()); + EXPECT_EQ(*rediscovered->second.coverage.hold, plantedHold()); + EXPECT_FALSE(rebuilt->ref_lives.contains(absent_life_id)); +} + +/// AN ORDINARY CRASH IS NOT A CORRUPT POOL. A round writes its runs during the reduce phase and its +/// fold seal only at phase 10/18, so a crash in between leaves the newest generation existing WITHOUT +/// a seal — the commonest shape there is. If discovery stopped at the listing's maximum it would find +/// no seal there, conclude it could enumerate nothing, and refuse — telling the operator to recreate a +/// pool whose holds are sitting readable one generation down. +/// +/// So discovery steps DOWN through the generations the listing itself reported until one carries a +/// seal. That spends no trust the maximum had not already been given. What it does NOT weaken is the +/// refusal above the maximum: that one stays terminal, because a seal found there is the listing +/// caught lying, not merely being incomplete about seals. +TEST(CASGCHoldGrammar, RebuildStepsDownPastACrashedNewestGenerationToTheSealBelowIt) +{ + auto backend = std::make_shared(); + auto store = openPoolForTest(backend, /*gc_fold_max_defer_rounds=*/0); + const Layout & layout = store->layout(); + const RootNamespace ns{"00/aa@cas@"}; + + publishAt(*backend, layout, ns, RefTxnId{1, 1}, "ref_1", 1, DB::UInt128(1), /*birth=*/true); + writeCommittedCkptAt(*backend, layout, ns, RefTxnId{1, 1}); + Gc gc(store, kGc); + ASSERT_TRUE(gc.runRegularRound().acquired_lease); + const GcState after_first = decodeGcState(backend->get(layout.gcStateKey())->bytes); + const uint64_t older_generation = after_first.snap_generation; + const uint64_t older_attempt = after_first.snap_attempt; + const UInt128 life_id = catalogLifeIdForTest(*backend, layout, ns); + + publishAt(*backend, layout, ns, RefTxnId{1, 2}, "ref_2", 2, DB::UInt128(2)); + advanceRecoverableCkptForRawFixture(*backend, layout, ns, RefTxnId{1, 2}); + ASSERT_TRUE(gc.runRegularRound().acquired_lease); + const GcState after_second = decodeGcState(backend->get(layout.gcStateKey())->bytes); + ASSERT_GT(after_second.snap_generation, older_generation) << "the fixture needs two generations"; + + /// The older generation is the one holding the pool's durable hold. + mutateSealAt(*backend, layout, older_generation, older_attempt, [&](CasFoldSeal & seal) + { + RefCoverage & cov = seal.ref_lives.at(life_id).coverage; + cov.classification = 4; + cov.hold = plantedHold(); + }); + + /// THE CRASH: the newest generation's run objects are there, its seal never got written. Then + /// `gc/state` is lost, which is this path's whole premise. + const String newest_seal = layout.foldSealKey(after_second.snap_generation, after_second.snap_attempt); + const HeadResult seal_head = backend->head(newest_seal); + ASSERT_TRUE(seal_head.exists); + ASSERT_EQ(backend->deleteExact(newest_seal, seal_head.token).kind, DeleteOutcome::Kind::Deleted); + ASSERT_FALSE(backend->list(layout.gcGenPrefix(after_second.snap_generation), "", 1).keys.empty()) + << "the crashed generation must still hold objects, or it is not the shape being modelled"; + const HeadResult sh = backend->head(layout.gcStateKey()); + ASSERT_EQ(backend->deleteExact(layout.gcStateKey(), sh.token).kind, DeleteOutcome::Kind::Deleted); + + Gc gc2(store, hexToU128("0000000000000000000000000000000c")); + const RebuildReport rep = gc2.rebuildBaseline(/*force=*/false); + ASSERT_TRUE(rep.performed) << rep.refusal; + EXPECT_FALSE(rep.virgin_by_enumeration) << "a pool with a readable seal is not virgin"; + EXPECT_EQ(rep.adopted_seal_generation, older_generation) + << "the report must name WHICH generation the holds came from, so a step-down is visible"; + + const auto rebuilt = newestSeal(*backend, layout); + ASSERT_TRUE(rebuilt.has_value()); + const auto it = rebuilt->ref_lives.find(life_id); + ASSERT_NE(it, rebuilt->ref_lives.end()); + ASSERT_TRUE(it->second.coverage.hold.has_value()) + << "a crash between the run writes and the seal write turned into 'recreate the pool', and the " + "hold readable one generation down was thrown away with it"; + EXPECT_EQ(*it->second.coverage.hold, plantedHold()); +} + +/// With no readable prior seal there is nothing to carry, and the holds it may have contained are +/// unknowable. The rebuild refuses rather than blessing a baseline whose provenance it cannot state — +/// a pool-wide hold is not representable (there is no offending position anyone could ever fold +/// through), so the honest answer is the refusal, and the recovery path is pool recreation. +TEST(CASGCHoldGrammar, RebuildRefusesWithAMissingPriorSeal) +{ + auto backend = std::make_shared(); + auto store = openPoolForTest(backend, /*gc_fold_max_defer_rounds=*/0); + const Layout & layout = store->layout(); + const RootNamespace ns{"00/aa@cas@"}; + + publishAt(*backend, layout, ns, RefTxnId{1, 1}, "ref_1", 1, DB::UInt128(1), /*birth=*/true); + Gc gc(store, kGc); + ASSERT_TRUE(gc.runRegularRound().acquired_lease); + + const GcState st = decodeGcState(backend->get(layout.gcStateKey())->bytes); + ASSERT_GT(st.snap_generation, 0u); + const String seal_key = layout.foldSealKey(st.snap_generation, st.snap_attempt); + const HeadResult sh = backend->head(seal_key); + ASSERT_TRUE(sh.exists); + ASSERT_EQ(backend->deleteExact(seal_key, sh.token).kind, DeleteOutcome::Kind::Deleted); + + /// FORCE does not buy past it either: force means "rebuild deliberately", never "drop the holds". + for (const bool force : {false, true}) + { + SCOPED_TRACE(force ? "force" : "plain"); + expectThrowsCode(DB::ErrorCodes::CORRUPTED_DATA, [&] { gc.rebuildBaseline(force); }); + } + + const GcState after = decodeGcState(backend->get(layout.gcStateKey())->bytes); + EXPECT_EQ(after.snap_generation, st.snap_generation) << "a refused rebuild adopts nothing"; +} + +TEST(CASGCHoldGrammar, RebuildRefusesWithAnUndecodablePriorSeal) +{ + auto backend = std::make_shared(); + auto store = openPoolForTest(backend, /*gc_fold_max_defer_rounds=*/0); + const Layout & layout = store->layout(); + const RootNamespace ns{"00/aa@cas@"}; + + publishAt(*backend, layout, ns, RefTxnId{1, 1}, "ref_1", 1, DB::UInt128(1), /*birth=*/true); + Gc gc(store, kGc); + ASSERT_TRUE(gc.runRegularRound().acquired_lease); + + const GcState st = decodeGcState(backend->get(layout.gcStateKey())->bytes); + const String seal_key = layout.foldSealKey(st.snap_generation, st.snap_attempt); + backend->putOverwrite(seal_key, "{\"type\":\"cas_fold_seal\",\"v\":4}\nthis is not a seal body\n", + backend->head(seal_key).token); + + expectThrowsCode(DB::ErrorCodes::CORRUPTED_DATA, [&] { gc.rebuildBaseline(/*force=*/true); }); +} + +/// LOSING THE POINTER IS NOT WEAKER THAN LOSING THE SEAL. `gc/state` names the adopted seal, and it is +/// the seal that carries the holds — so if the refusal only covered an unreadable seal, the *lesser* +/// corruption (the pointer is gone, every seal intact) would be treated more permissively than the +/// greater one, and the rebuild would write a baseline with no hold in it at all. +/// +/// That matters because holds are not re-derivable by the next walk. `WitnessDisappeared` names a +/// record that is *gone*: the next round reads a clean frontier and would hand the namespace exactly +/// the frontier proof the hold exists to deny. Same for any hold whose only witness was the checkpoint +/// or the hold itself. +/// +/// So with no adopted baseline named, the rebuild finds the newest fold seal OBJECT by enumeration and +/// carries its holds. This keeps the pool's disaster recovery intact — losing `gc/state` on a +/// lived-in pool is the scenario `REBUILD` exists for — while making it impossible to write a +/// hold-free baseline over a pool that had holds. +TEST(CASGCHoldGrammar, RebuildWithLostStateStillCarriesHoldsFromTheNewestSeal) +{ + auto backend = std::make_shared(); + auto store = openPoolForTest(backend, /*gc_fold_max_defer_rounds=*/0); + const Layout & layout = store->layout(); + const RootNamespace ns{"00/aa@cas@"}; + + publishAt(*backend, layout, ns, RefTxnId{1, 1}, "ref_1", 1, DB::UInt128(1), /*birth=*/true); + writeCommittedCkptAt(*backend, layout, ns, RefTxnId{1, 1}); + Gc gc(store, kGc); + ASSERT_TRUE(gc.runRegularRound().acquired_lease); + const UInt128 life_id = catalogLifeIdForTest(*backend, layout, ns); + mutateAdoptedSeal(*backend, layout, [&](CasFoldSeal & seal) + { + RefCoverage & cov = seal.ref_lives.at(life_id).coverage; + cov.classification = 4; + cov.hold = plantedHold(); + }); + + /// The pointer vanishes; every seal object survives. + const HeadResult sh = backend->head(layout.gcStateKey()); + ASSERT_TRUE(sh.exists); + ASSERT_EQ(backend->deleteExact(layout.gcStateKey(), sh.token).kind, DeleteOutcome::Kind::Deleted); + + Gc gc2(store, hexToU128("00000000000000000000000000000009")); + const RebuildReport rep = gc2.rebuildBaseline(/*force=*/false); + ASSERT_TRUE(rep.performed) << rep.refusal; + + const auto rebuilt = newestSeal(*backend, layout); + ASSERT_TRUE(rebuilt.has_value()); + const auto it = rebuilt->ref_lives.find(life_id); + ASSERT_NE(it, rebuilt->ref_lives.end()); + ASSERT_TRUE(it->second.coverage.hold.has_value()) + << "the rebuild blessed a baseline with no hold in it, having read no seal at all"; + EXPECT_EQ(*it->second.coverage.hold, plantedHold()); +} + +/// ...and when that newest seal cannot be read either, there is nothing left to carry and no way to +/// know what was lost, so the rebuild refuses exactly as it does for an unreadable adopted seal. +TEST(CASGCHoldGrammar, RebuildRefusesWhenTheNewestSealIsUnreadableAndTheStateIsLost) +{ + auto backend = std::make_shared(); + auto store = openPoolForTest(backend, /*gc_fold_max_defer_rounds=*/0); + const Layout & layout = store->layout(); + const RootNamespace ns{"00/aa@cas@"}; + + publishAt(*backend, layout, ns, RefTxnId{1, 1}, "ref_1", 1, DB::UInt128(1), /*birth=*/true); + Gc gc(store, kGc); + ASSERT_TRUE(gc.runRegularRound().acquired_lease); + + const GcState st = decodeGcState(backend->get(layout.gcStateKey())->bytes); + const String seal_key = layout.foldSealKey(st.snap_generation, st.snap_attempt); + backend->putOverwrite(seal_key, "{\"type\":\"cas_fold_seal\",\"v\":4}\nthis is not a seal body\n", + backend->head(seal_key).token); + const HeadResult sh = backend->head(layout.gcStateKey()); + ASSERT_EQ(backend->deleteExact(layout.gcStateKey(), sh.token).kind, DeleteOutcome::Kind::Deleted); + + Gc gc2(store, hexToU128("0000000000000000000000000000000a")); + for (const bool force : {false, true}) + { + SCOPED_TRACE(force ? "force" : "plain"); + expectThrowsCode(DB::ErrorCodes::CORRUPTED_DATA, [&] { gc2.rebuildBaseline(force); }); + } +} + +/// NEWEST-NESS IS NOT READ OFF A LISTING. Taking the newest seal from the pool-wide enumeration would +/// put the same hole one layer up: a listing that omits the true newest seal hands back an OLDER one, +/// and every hold detected since that older seal is silently lost. Two narrow single-generation probes +/// above the listing's maximum ask whether it lied. +/// +/// And when it did lie, the answer is REFUSAL, not adoption of the newer seal. A store that misreports +/// its own enumeration DURING DISASTER RECOVERY does not get a second guess: adopting whatever the +/// second query happened to return would move the same trust one query along and prove nothing. +/// +/// The fixture is the production shape rather than a contrivance: the broad `gc/gen/` enumeration +/// omits the newest generation's objects while a listing scoped to that generation still returns +/// them — the same class of lie the arithmetic ref walk was built for one layer down. +TEST(CASGCHoldGrammar, RebuildRefusesWhenANarrowProbeFindsASealAboveTheListingMaximum) +{ + /// Omits keys from ONE enumeration prefix only. Every other query — including a listing scoped to + /// the generation itself — answers truthfully. + class BroadListHoleBackend : public InMemoryBackend + { + public: + String hide_under_prefix; + String hidden_key_infix; + size_t holes_served = 0; + + ListPage list(const String & prefix, const String & cursor, size_t limit) override + { + ListPage page = InMemoryBackend::list(prefix, cursor, limit); + if (prefix != hide_under_prefix) + return page; + const size_t before = page.keys.size(); + std::erase_if(page.keys, + [&](const ListedKey & k) { return k.key.find(hidden_key_infix) != String::npos; }); + if (page.keys.size() != before) + ++holes_served; + return page; + } + }; + + auto backend = std::make_shared(); + auto store = openPoolForTest(backend, /*gc_fold_max_defer_rounds=*/0); + const Layout & layout = store->layout(); + const RootNamespace ns{"00/aa@cas@"}; + + publishAt(*backend, layout, ns, RefTxnId{1, 1}, "ref_1", 1, DB::UInt128(1), /*birth=*/true); + Gc gc(store, kGc); + ASSERT_TRUE(gc.runRegularRound().acquired_lease); + publishAt(*backend, layout, ns, RefTxnId{1, 2}, "ref_2", 2, DB::UInt128(2)); + ASSERT_TRUE(gc.runRegularRound().acquired_lease); + + const GcState st = decodeGcState(backend->get(layout.gcStateKey())->bytes); + ASSERT_GT(st.snap_generation, 1u) << "the fixture needs a newer generation to hide"; + const UInt128 life_id = catalogLifeIdForTest(*backend, layout, ns); + mutateAdoptedSeal(*backend, layout, [&](CasFoldSeal & seal) + { + RefCoverage & cov = seal.ref_lives.at(life_id).coverage; + cov.classification = 4; + cov.hold = plantedHold(); + }); + + /// The pool-wide enumeration loses the newest generation entirely; the pointer to it is deleted. + const String gen_prefix = layout.gcGenPrefix(0); + backend->hide_under_prefix = gen_prefix.substr(0, gen_prefix.size() - 2); /// ".../gc/gen/" + backend->hidden_key_infix = layout.gcGenPrefix(st.snap_generation); + const HeadResult sh = backend->head(layout.gcStateKey()); + ASSERT_EQ(backend->deleteExact(layout.gcStateKey(), sh.token).kind, DeleteOutcome::Kind::Deleted); + + Gc gc2(store, hexToU128("0000000000000000000000000000000b")); + for (const bool force : {false, true}) + { + SCOPED_TRACE(force ? "force" : "plain"); + expectThrowsCode(DB::ErrorCodes::CORRUPTED_DATA, [&] { gc2.rebuildBaseline(force); }); + } + ASSERT_GT(backend->holes_served, 0u) << "the broad listing never actually lied"; + + /// Nothing was adopted: the refusal fires before the lease, so the pool is exactly as it was. + EXPECT_FALSE(backend->head(layout.gcStateKey()).exists) + << "a refused rebuild must not mint a baseline, nor a bootstrap body"; +} + +/// The virgin verdict, pinned so the refusal can never grow to swallow a fresh pool — and pinned as +/// what it actually is. It rests on THREE pieces of enumeration evidence (wide LIST empty, narrow +/// generation-1 probe empty, no `gc/state`) and on no point read at all, so it is COUNTED: an operator +/// reading a disaster-recovery run needs to see that the clean slate came from enumeration rather than +/// from proof. `CASGCRebuildVirginByEnumeration` on a pool that has ever completed a round means the +/// enumeration lied. +TEST(CASGCHoldGrammar, RebuildProceedsOnAPoolThatNeverSealedABaselineAndCountsTheVerdict) +{ + auto backend = std::make_shared(); + auto store = openPoolForTest(backend, /*gc_fold_max_defer_rounds=*/0); + const Layout & layout = store->layout(); + const RootNamespace ns{"00/aa@cas@"}; + + /// No round has run, so there is no `gc/state` and no seal — only owner state to rebuild from. + publishAt(*backend, layout, ns, RefTxnId{1, 1}, "ref_1", 1, DB::UInt128(1), /*birth=*/true); + writeCommittedCkptAt(*backend, layout, ns, RefTxnId{1, 1}); + ASSERT_FALSE(backend->head(layout.gcStateKey()).exists); + + using ProfileEvents::global_counters; + const auto virgin_before = global_counters[ProfileEvents::CASGCRebuildVirginByEnumeration].load(); + + Gc gc(store, kGc); + const RebuildReport rep = gc.rebuildBaseline(/*force=*/false); + EXPECT_TRUE(rep.performed) << rep.refusal; + EXPECT_GT(global_counters[ProfileEvents::CASGCRebuildVirginByEnumeration].load(), virgin_before) + << "a clean slate granted from enumeration alone must be visible to whoever reads the run"; +} diff --git a/src/Disks/tests/gtest_cas_gc_leak.cpp b/src/Disks/tests/gtest_cas_gc_leak.cpp new file mode 100644 index 000000000000..a02deb07d0bc --- /dev/null +++ b/src/Disks/tests/gtest_cas_gc_leak.cpp @@ -0,0 +1,545 @@ +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include + +namespace DB::ErrorCodes +{ +extern const int FILE_DOESNT_EXIST; +extern const int ABORTED; +} + +/// NO-LEAK property suite (C++ verification of the R0 INV-NO-LEAK invariant for the root-local +/// part-manifest model). Every dropped/abandoned closure must be FULLY reclaimed: after GC reaches a +/// fixpoint, NO blob or manifest object may remain for the reclaimed part, and the in-degree generation +/// must hold no stranded positive counter for a now-unreferenced blob. +/// +/// The model has changed since the tree/snap era: a part is one immutable single-owner `ManifestId` +/// (only blobs stay content-addressed; manifests are NEVER shared across instances — backlog item B7). +/// The leak scenarios below therefore drive the REAL write flow (`stageManifest -> precommitAdd -> +/// putBlob -> promote`) and the real drop/abandon paths, then assert the reclaimed closure leaves no +/// debris. The old "adopt-by-tree relink" leak cases (B7) are REMOVED: there is no shared content id, +/// no subtree placement, `getPartTreeId` returns nullopt and `adoptPart` throws `NOT_IMPLEMENTED`; the +/// byte-stream-fallback relink is an ordinary publish covered by the no-leak displacement repros below. + +using namespace DB::Cas; +using DB::Cas::tests::idOf; +using DB::Cas::tests::u128Of; +using DB::Cas::tests::inDegreeOf; +using DB::Cas::tests::publishCommittedTransition; + +namespace +{ + +PoolPtr openTestPool(std::shared_ptr & out_backend) +{ + out_backend = std::make_shared(); + return Pool::open(out_backend, PoolConfig{.pool_prefix = "p", .server_root_id = "test"}); +} + +/// Whether the CURRENT retired list (any gc-shard) still holds an entry — the ack-floor deletion pipeline +/// (condemn -> graduate -> delete) is in flight while this is true. +bool anyRetiredPending(const PoolPtr & s) +{ + /// Retired-in-snapshot (T4): condemned state rides the adopted fold seal's kCondemned rows, not a + /// separate retired list — reconstruct the in-flight set from the seal. + return DB::Cas::tests::anyCondemnedInSeal(s->backend(), s->layout()); +} + +/// Drive regular GC to a fixpoint. A condemned blob is not deleted in the round that folds its removal: +/// it condemns, then graduates the round after (round-paced, unconditional), then the NEXT pass deletes +/// it. The loop renews the store's own heartbeat after each round (`renewWatermarkOnce`, unrelated to +/// graduation timing but keeping the build-watermark floor and lease current) and stays alive while ANY +/// work counter is nonzero OR the current retired list still holds an in-flight entry. +size_t runGcToFixpoint(const PoolPtr & s, Gc & gc, size_t max_rounds = 64) +{ + size_t rounds = 0; + for (; rounds < max_rounds; ++rounds) + { + const RoundReport rep = DB::Cas::tests::runRegularRoundReclaiming(gc); + if (!rep.acquired_lease) + continue; + s->renewWatermarkOnce(); + const bool no_work = rep.candidates == 0 && rep.deleted == 0 && rep.absent == 0 + && rep.replaced == 0 && rep.spared == 0; + if (no_work && !anyRetiredPending(s)) + break; + } + return rounds; +} + +/// A `ManifestEntry` for a Blob leaf at `path` referencing `payload`'s content hash. +ManifestEntry blobEntry(const String & path, const String & payload) +{ + ManifestEntry e; + e.path = path; + e.placement = EntryPlacement::Blob; + e.ref = DB::Cas::BlobRef{DB::Cas::BlobHashAlgo::CityHash128, DB::Cas::BlobDigest::fromU128(u128Of(payload))}; + + e.blob_size = payload.size(); + return e; +} + +/// Publish ONE ref naming a two-blob part through the REAL writer transaction sequence — the exact order +/// the wiring drives (EDGE-BEFORE-OBSERVE): `beginPartWrite -> stageManifest(entries) -> precommitAdd -> +/// putBlob(each body) -> promote`. The durable precommit closure names every blob hash before putBlob +/// makes the first backend observation. Returns the published `ManifestId` so a caller can later HEAD +/// its body / assert reclaim. +ManifestId publishTwoBlobPart( + const PoolPtr & s, const RootNamespace & ns, const String & ref, + const String & payload_a, const String & payload_b) +{ + PartWriteInfo info; + info.intended_ref = ns.string() + "/" + ref; + auto build = s->beginPartWrite(info); + + const ManifestId id = build->stageManifest({blobEntry("data.bin", payload_a), + blobEntry("data.cmrk3", payload_b)}); + build->precommitAdd(ns, ref, id); + build->putBlob(idOf(payload_a), BlobSource::fromString(payload_a)); + build->putBlob(idOf(payload_b), BlobSource::fromString(payload_b)); + build->promote(ns, ref, build->buildId(), id); + return id; +} + +/// Publish ONE ref naming a single-blob part through the real writer sequence. Returns its ManifestId. +ManifestId publishOneBlobPart( + const PoolPtr & s, const RootNamespace & ns, const String & ref, const String & payload) +{ + PartWriteInfo info; + info.intended_ref = ns.string() + "/" + ref; + auto build = s->beginPartWrite(info); + const ManifestId id = build->stageManifest({blobEntry("data.bin", payload)}); + build->precommitAdd(ns, ref, id); + build->putBlob(idOf(payload), BlobSource::fromString(payload)); + build->promote(ns, ref, build->buildId(), id); + return id; +} + +/// The semantic `publishCommittedTransition` wrapper advances its same-life checkpoint. Keep this +/// assertion explicit so this leak fixture cannot silently depend on a historical checkpoint lag. +void assertSemanticTransitionCheckpoint( + Backend & backend, const Layout & layout, const RootNamespace & ns, const RefTxnId & committed_through) +{ + const auto life = CasRefCatalog::lifeIfCataloged(backend, layout, ns); + ASSERT_TRUE(life); + const String key = layout.refCkptKey(*life); + const auto before = backend.get(key); + ASSERT_TRUE(before); + + RefCkpt ckpt = decodeRefCkpt(before->bytes); + ASSERT_TRUE(ckpt.committed_through); + EXPECT_EQ(*ckpt.committed_through, committed_through); +} + +/// Whether a blob's body object is present in the backend (HEADs blobKey directly — the GC retire path +/// HEADs the object key, never the Pool's manifest decode cache). +bool blobPresent(const std::shared_ptr & b, const Layout & layout, const String & payload) +{ + return b->head(layout.blobKey(BlobRef{BlobHashAlgo::CityHash128, BlobDigest::fromU128(u128Of(payload))})).exists; +} + +/// Whether a manifest body object is present in the backend. +bool manifestPresent(const std::shared_ptr & b, const Layout & layout, const ManifestId & id) +{ + return b->head(layout.manifestKey(id)).exists; +} + +/// Stage partB's full closure (its two distinct blob bodies + its manifest body) through the REAL +/// writer primitives WITHOUT publishing an owner — `beginPartWrite -> putBlob(each) -> stageManifest`. The +/// bytes are durable in the backend but no journal owner names them yet; the caller installs partB as +/// the new owner via a REPOINT (see displaceAndGc). Returns partB's ManifestId. +ManifestId stagePartBClosure( + const PoolPtr & s, const RootNamespace & ns, const String & ref, + const String & payload_a, const String & payload_b) +{ + PartWriteInfo info; + info.intended_ref = ns.string() + "/" + ref; + auto build = s->beginPartWrite(info); + build->putBlob(idOf(payload_a), BlobSource::fromString(payload_a)); + build->putBlob(idOf(payload_b), BlobSource::fromString(payload_b)); + const ManifestId id = build->stageManifest({blobEntry("data.bin", payload_a), + blobEntry("data.cmrk3", payload_b)}); + /// No precommitAdd / promote: the repoint below installs partB committed in ONE owner-move event. + return id; +} + +/// Reproduce displacement on the SAME (s, ns, ref) and run GC to a fixpoint. partB's distinct blobs +/// displace partA's via a REPOINT of the ref (one RootOwnerEvent old={Committed,ref,partA}/ +/// new={Committed,ref,partB}) — the real production shape of last-owner-wins, NOT a body delete. +/// +/// Crucially the test does NOT delete partA's manifest body. In the part-manifest model a true removal +/// (the repoint's -1) is derived by GC READING partA's body at removal-fold time; only GC may delete a +/// committed owner's body, and only AFTER the -1 is sealed (recheck cleanup, control #11). So GC folds +/// the repoint: -1 for partA's blobs (body present), +1 for partB's blobs, retires + deletes partA's +/// now-zero-in-degree blobs, and recheck cleanup deletes partA's owner-removed body. Returns the fsck +/// report so the caller can assert the no-leak end state (partA's blobs AND body gone, unreachable==0). +FsckReport displaceAndGc( + const PoolPtr & s, const std::shared_ptr & b, + const RootNamespace & ns, const String & ref, const ManifestId & part_a) +{ + /// Stage partB's full closure (blobs + body present), then repoint the ref from partA to partB. + const ManifestId part_b = stagePartBClosure(s, ns, ref, "data-B", "mark-B"); + + EXPECT_TRUE(b->head(s->layout().manifestKey(part_a)).exists) + << "partA manifest body must still be present so GC can read its -1 edges at removal-fold"; + + /// REPOINT: old={Committed,ref,partA} / new={Committed,ref,partB} in the single ordered journal. + const uint64_t repoint_sequence = publishCommittedTransition(*b, s->layout(), ns, ref, part_a.ref, part_b.ref); + assertSemanticTransitionCheckpoint(*b, s->layout(), ns, RefTxnId{1, repoint_sequence}); + + /// The repoint dropped partA's owner; advance the watermark floor so partA's now-orphaned blobs are + /// not spared as in-flight, then run GC to a fixpoint. + s->renewWatermarkOnce(); + Gc gc(s, hexToU128("00000000000000000000000000000001")); + runGcToFixpoint(s, gc); + return runFsck(*s, /*detail=*/false); +} + +} + +/// NO-LEAK (S1, fold interleaved): partA is published and folded ONCE (its body present, +1 per blob), +/// then partB REPOINTS the ref away from partA (partA's body stays present so GC reads its -1 edges at +/// removal-fold; only GC deletes the owner-removed body, after the -1 is sealed). GC must reclaim partA's +/// blobs to a fixpoint: no blob/manifest object remains for partA and the in-degree generation holds no +/// stranded positive counter for partA's blobs. +TEST(CASGCLeak, DisplacedPartBlobsReclaimedFoldBetween) +{ + std::shared_ptr b; + auto s = openTestPool(b); + const RootNamespace ns{"test/tbl"}; + const String ref = "all_0_0_0"; + + const ManifestId part_a = publishTwoBlobPart(s, ns, ref, "data-A", "mark-A"); + + /// A GC fold runs HERE, before any displacement — partA's body is present, so the fold records +1 for + /// each of partA's blobs into the durable in-degree generation. + { + Gc gc(s, hexToU128("00000000000000000000000000000001")); + runGcToFixpoint(s, gc); + } + EXPECT_EQ(inDegreeOf(*b, s->layout(), u128Of("data-A")), 1) << "partA's data blob is pinned (+1)"; + EXPECT_EQ(inDegreeOf(*b, s->layout(), u128Of("mark-A")), 1) << "partA's mark blob is pinned (+1)"; + + const FsckReport after = displaceAndGc(s, b, ns, ref, part_a); + + EXPECT_EQ(after.dangling, 0u) << "S1 INV-NO-LOSS: displacement must never lose a reachable object"; + EXPECT_GT(after.reachable, 0u) << "S1: the live ref points at partB; partB's closure is reachable"; + EXPECT_EQ(after.unreachable, 0u) + << "S1 INV-NO-LEAK: an interleaved fold recorded partA's edges; the removal -1 + retire must " + "reclaim partA's blobs (unreachable=" << after.unreachable << ")"; + + /// Backend-level no-debris: partA's blobs and body object are gone; the in-degree counters are 0. + EXPECT_FALSE(blobPresent(b, s->layout(), "data-A")) << "S1: partA data blob object must be deleted"; + EXPECT_FALSE(blobPresent(b, s->layout(), "mark-A")) << "S1: partA mark blob object must be deleted"; + EXPECT_FALSE(manifestPresent(b, s->layout(), part_a)) << "S1: partA manifest body must be gone"; + EXPECT_EQ(inDegreeOf(*b, s->layout(), u128Of("data-A")), 0) << "S1: no stranded positive in-degree"; + EXPECT_EQ(inDegreeOf(*b, s->layout(), u128Of("mark-A")), 0) << "S1: no stranded positive in-degree"; +} + +/// NO-LEAK (S2, NO fold interleaved — the decisive worst case): partA is published, then IMMEDIATELY +/// repointed to partB before ANY GC fold runs. The single fold therefore folds partA's activation (+1) +/// and its removal (-1, read from partA's still-present body) in one pass; the retire reclaims partA's +/// blobs and recheck cleanup deletes partA's owner-removed body. No debris may remain. +TEST(CASGCLeak, DisplacedPartBlobsReclaimedNoFoldBetween) +{ + std::shared_ptr b; + auto s = openTestPool(b); + const RootNamespace ns{"test/tbl"}; + const String ref = "all_0_0_0"; + + const ManifestId part_a = publishTwoBlobPart(s, ns, ref, "data-A", "mark-A"); + + const FsckReport after = displaceAndGc(s, b, ns, ref, part_a); + + EXPECT_EQ(after.dangling, 0u) << "S2 INV-NO-LOSS: displacement must never lose a reachable object"; + EXPECT_GT(after.reachable, 0u) << "S2: the live ref points at partB; partB's closure is reachable"; + EXPECT_EQ(after.unreachable, 0u) + << "S2 INV-NO-LEAK: partA's blobs must be reclaimed even with no interleaved fold — the recorded " + "owner edges drive the removal -1 + retire (unreachable=" << after.unreachable << ")"; + + EXPECT_FALSE(blobPresent(b, s->layout(), "data-A")) << "S2: partA data blob object must be deleted"; + EXPECT_FALSE(blobPresent(b, s->layout(), "mark-A")) << "S2: partA mark blob object must be deleted"; + EXPECT_FALSE(manifestPresent(b, s->layout(), part_a)) << "S2: partA manifest body must be gone"; + EXPECT_EQ(inDegreeOf(*b, s->layout(), u128Of("data-A")), 0) << "S2: no stranded positive in-degree"; + EXPECT_EQ(inDegreeOf(*b, s->layout(), u128Of("mark-A")), 0) << "S2: no stranded positive in-degree"; +} + +/// NO-LEAK (drop): a fully-committed part is published, folded (+1 per blob), then its ref is dropped. +/// GC must reclaim the WHOLE closure — both blobs and the manifest body — leaving no debris and no +/// stranded positive in-degree. +TEST(CASGCLeak, DroppedPartFullyReclaimed) +{ + std::shared_ptr b; + auto s = openTestPool(b); + const RootNamespace ns{"test/tbl"}; + const String ref = "all_1_1_0"; + + const ManifestId id = publishTwoBlobPart(s, ns, ref, "drop-data", "drop-mark"); + { + Gc gc(s, hexToU128("00000000000000000000000000000002")); + runGcToFixpoint(s, gc); + } + EXPECT_EQ(inDegreeOf(*b, s->layout(), u128Of("drop-data")), 1); + EXPECT_EQ(inDegreeOf(*b, s->layout(), u128Of("drop-mark")), 1); + + s->dropRef(ns, ref); + s->renewWatermarkOnce(); /// advance the floor so the now-unreferenced closure is not spared + + Gc gc(s, hexToU128("00000000000000000000000000000002")); + runGcToFixpoint(s, gc); + + const FsckReport after = runFsck(*s, /*detail=*/false); + EXPECT_EQ(after.dangling, 0u) << "drop INV-NO-LOSS: nothing reachable was lost"; + EXPECT_EQ(after.unreachable, 0u) + << "drop INV-NO-LEAK: the dropped closure's blobs + body must be fully reclaimed " + "(unreachable=" << after.unreachable << ")"; + EXPECT_FALSE(blobPresent(b, s->layout(), "drop-data")) << "dropped data blob must be deleted"; + EXPECT_FALSE(blobPresent(b, s->layout(), "drop-mark")) << "dropped mark blob must be deleted"; + EXPECT_FALSE(manifestPresent(b, s->layout(), id)) << "dropped manifest body must be gone"; + EXPECT_EQ(inDegreeOf(*b, s->layout(), u128Of("drop-data")), 0) << "no stranded positive in-degree"; + EXPECT_EQ(inDegreeOf(*b, s->layout(), u128Of("drop-mark")), 0) << "no stranded positive in-degree"; +} + +/// NO-LEAK (resurrect-reupload): a blob incarnation A is published, dropped, and condemned by ONE GC +/// round (retired, NOT yet deleted — it is still mid-pipeline). A fresh build then dedup-hits the SAME +/// content hash: `putBlob` HEADs A, sees it condemned via the per-hash freshness meta point-read, and — +/// per INV-1 (revival-from-source) — re-uploads a DISTINCT incarnation B at the same content-addressed key +/// (fresh `incarnation_tag`, never a GET of the dying object A). B is referenced by a second ref, then +/// that ref is dropped too. GC must fold B's own activation/removal exactly like any other incarnation +/// and reclaim it to a fixpoint: no blob object may remain for the content hash and the in-degree +/// generation must hold no stranded positive counter. +/// +/// This reproduces RESURRECT-REUPLOAD-ORPHAN: if GC's bookkeeping keys off the content hash rather than +/// the (hash, token) incarnation identity, it may treat the hash as "already handled" from A's retire +/// cycle and never open a fresh condemn cycle for B once B's in-degree drops to zero — B then orphans +/// forever (unreachable > 0, its body never deleted). +TEST(CASGCLeak, ResurrectReplacedIncarnationReclaimed) +{ + std::shared_ptr b; + auto s = openTestPool(b); + const RootNamespace ns{"test/tbl"}; + const String P = "resurrect-payload"; + + /// 1. Publish ref r1 -> token A referenced; capture A. + publishOneBlobPart(s, ns, "r1", P); + const HeadResult hA = b->head(s->layout().blobKey(idOf(P))); + ASSERT_TRUE(hA.exists); + + /// 2. Drop r1 -> A dereferenced. + s->dropRef(ns, "r1"); + s->renewWatermarkOnce(); /// advance the floor so A is not spared as in-flight + + /// 3. ONE GC round: A transitions to in-degree 0 and is condemned (retired), NOT yet deleted. + Gc gc(s, hexToU128("00000000000000000000000000000004")); + DB::Cas::tests::runRegularRoundReclaiming(gc); + { + const auto lm = DB::Cas::tests::loadMetaForTest(*b, s->layout(), u128Of(P)); + ASSERT_TRUE(lm.has_value() && lm->meta.state == MetaState::Condemned) + << "precondition: token A must be condemned before the resurrect"; + } + ASSERT_TRUE(blobPresent(b, s->layout(), P)) << "A not yet deleted (still in the pipeline)"; + + /// 4. RESURRECT: a fresh build dedup-hits P; putBlob sees A condemned -> re-uploads a DISTINCT + /// incarnation B at the same content-addressed key (INV-1 revival-from-source). + publishOneBlobPart(s, ns, "r2", P); + const HeadResult hB = b->head(s->layout().blobKey(idOf(P))); + ASSERT_TRUE(hB.exists); + ASSERT_NE(hB.token.value, hA.token.value) << "resurrect must mint a new incarnation token B"; + + /// 5. Drop r2 -> B dereferenced. + s->dropRef(ns, "r2"); + s->renewWatermarkOnce(); + + /// 6. Run GC to fixpoint. The replaced incarnation B MUST be reclaimed. + runGcToFixpoint(s, gc); + + const FsckReport after = runFsck(*s, /*detail=*/false); + EXPECT_EQ(after.dangling, 0u) << "resurrect INV-NO-LOSS: nothing reachable was lost"; + EXPECT_EQ(after.unreachable, 0u) + << "resurrect INV-NO-LEAK: the resurrect-replaced incarnation B must not orphan " + "(unreachable=" << after.unreachable << ")"; + EXPECT_FALSE(blobPresent(b, s->layout(), P)) << "B's object must be deleted"; + EXPECT_EQ(inDegreeOf(*b, s->layout(), u128Of(P)), 0) << "no stranded positive in-degree"; +} + +/// IDEMPOTENCY of the RESURRECT-REUPLOAD-ORPHAN fold: drives the exact same condemn-A / resurrect-B / +/// drop-B / reclaim sequence as `ResurrectReplacedIncarnationReclaimed` above, then keeps running the +/// regular round PAST the fixpoint. The re-condemn that reclaims the resurrect-replaced incarnation B +/// must fire exactly once: extra rounds on an already-reclaimed content hash must be no-ops (no +/// re-condemn churn, no duplicate retired entry) and must never manufacture fresh fsck debris. +TEST(CASGCLeak, ResurrectReplacedReclaimIsIdempotent) +{ + std::shared_ptr b; + auto s = openTestPool(b); + const RootNamespace ns{"test/tbl"}; + const String P = "resurrect-payload-idem"; + + /// 1. Publish ref r1 -> token A referenced, then drop it. + publishOneBlobPart(s, ns, "r1", P); + s->dropRef(ns, "r1"); + s->renewWatermarkOnce(); /// advance the floor so A is not spared as in-flight + + /// 2. ONE GC round: A transitions to in-degree 0 and is condemned (retired), NOT yet deleted. + Gc gc(s, hexToU128("00000000000000000000000000000005")); + DB::Cas::tests::runRegularRoundReclaiming(gc); + + /// 3. RESURRECT: r2 dedup-hits P while A is condemned -> mints a fresh incarnation B. + publishOneBlobPart(s, ns, "r2", P); + s->dropRef(ns, "r2"); + s->renewWatermarkOnce(); + + /// 4. Reclaim B to a fixpoint (the RESURRECT-REUPLOAD-ORPHAN fold under test). + runGcToFixpoint(s, gc); + ASSERT_FALSE(blobPresent(b, s->layout(), P)) << "B must be reclaimed before the idempotency check"; + + /// 5. Extra rounds past the fixpoint: nothing is left to do for this hash. The fold must not + /// re-condemn it (that would be the churn/duplicate-entry bug) and must not resurrect any debris. + for (int round = 0; round < 3; ++round) + { + const RoundReport r = DB::Cas::tests::runRegularRoundReclaiming(gc); + if (r.acquired_lease) + EXPECT_EQ(r.condemned, 0u) << "no re-condemn of an already-reclaimed hash on extra round " << round; + s->renewWatermarkOnce(); + } + + EXPECT_FALSE(blobPresent(b, s->layout(), P)) << "stays deleted across extra rounds"; + EXPECT_EQ(inDegreeOf(*b, s->layout(), u128Of(P)), 0) << "no stranded positive in-degree"; + + const FsckReport after = runFsck(*s, /*detail=*/false); + EXPECT_EQ(after.unreachable, 0u) << "re-condemn churn must not manufacture a fresh unreachable object"; + EXPECT_EQ(after.dangling, 0u) << "idempotent extra rounds must never lose a reachable object"; +} + +/// WRITER-SIDE half of the RESURRECT-REUPLOAD-ORPHAN fold: after the round that folds the resurrect- +/// replaced incarnation B's dereference re-condemns B, a fresh writer dedup-hitting the SAME content hash +/// must see B as condemned via the per-hash freshness meta point-read — never as an adoptable live token. +/// If GC's bookkeeping instead kept treating B as adopt-eligible (the pre-fix bug), a concurrent writer's +/// `putBlob` would adopt the being-reclaimed B rather than resurrect a fresh incarnation, racing the +/// delete pipeline. +/// +/// Depending on round timing, by the time the meta is checked B may be (a) still present and visibly +/// condemned, or (b) already physically deleted by the delete pipeline (meta dropped alongside it) — BOTH +/// outcomes prove B is never adoptable. The assertion only fails on the pre-fix shape: B present and NOT +/// condemned. +TEST(CASGCLeak, ResurrectReplacedTokenIsCondemnedInMeta) +{ + std::shared_ptr b; + auto s = openTestPool(b); + const RootNamespace ns{"test/tbl"}; + Gc gc(s, hexToU128("00000000000000000000000000000006")); + const String P = "resurrect-payload-view"; + + /// 1. Publish ref r1 -> token A referenced; capture A, then drop it and condemn via ONE GC round. + publishOneBlobPart(s, ns, "r1", P); + const HeadResult hA = b->head(s->layout().blobKey(idOf(P))); + ASSERT_TRUE(hA.exists); + s->dropRef(ns, "r1"); + s->renewWatermarkOnce(); /// advance the floor so A is not spared as in-flight + gc.runRegularRound(); + + /// 2. RESURRECT: r2 dedup-hits P while A is condemned -> mints a fresh incarnation B. + publishOneBlobPart(s, ns, "r2", P); + const HeadResult hB = b->head(s->layout().blobKey(idOf(P))); + ASSERT_TRUE(hB.exists); + ASSERT_NE(hB.token.value, hA.token.value) << "resurrect must mint a distinct incarnation"; + s->dropRef(ns, "r2"); + s->renewWatermarkOnce(); + + /// 3. The round that folds B's dereference re-condemns token B. + gc.runRegularRound(); + + const auto lm = DB::Cas::tests::loadMetaForTest(*b, s->layout(), u128Of(P)); + EXPECT_TRUE((lm.has_value() && lm->meta.state == MetaState::Condemned) || !blobPresent(b, s->layout(), P)) + << "the replaced incarnation B must be visible as condemned (or already reclaimed) so a " + "dedup-hitting writer resurrects, not adopts"; +} + +/// (The NO-LEAK-on-abandon test `CASGCLeak.AbandonedPrecommitReclaimsOwnBlobs` was removed with the +/// snapshot+log ref model: it asserted GC AUTOMATICALLY reclaims a crashed build's abandoned precommit and +/// collects its own unique blob. Per spec §Responsibility Boundary that reclaim is now the WRITER's job +/// (it appends the exact `owner_transition` removal on recovery); GC never scans for or removes precommit +/// bindings. The writer-side abandon/recovery cleanup is exercised by the writer tests.) + +/// REUSE-vs-GC race (no-LOSS half of the no-leak family): a build ADOPTS a committed blob B by tokenless +/// evidence (B present, not yet condemned), the committed ref pinning B is DROPPED, GC retires+deletes B +/// AND completes the round, and only THEN does the build try to publish a manifest naming B. +/// +/// The promote gate re-observes the loss (it re-HEADs every blob leaf and fails closed on a deleted dep, +/// throwing a retryable ABORTED) — it must NEVER silently commit a dangling ref. The assertion is the +/// no-LOSS guarantee: `dangling==0`. (A tokenless adopt has no body to re-upload, so a real caller would +/// re-derive B from source on retry; here we only confirm the gate fails closed.) +TEST(CASReuseGcRace, ReuseOfBlobDeletedBeforePublish) +{ + std::shared_ptr b; + auto s = openTestPool(b); + const RootNamespace ns{"test/tbl"}; + const String B = "shared-blob-payload"; + const String U = "build2-unique-blob"; + + /// build1: commit part_1 -> manifest -> blob B. + publishOneBlobPart(s, ns, "part_1", B); + + /// build2: adopt B by tokenless evidence (no HEAD) and upload its OWN unique blob U. It does NOT yet + /// stage a manifest or precommit — the scenario is that GC deletes B BEFORE build2 publishes a manifest + /// naming it. (Staging+precommitting BEFORE the drop would make the precommit's activating +1 PIN B — + /// B would never reach in-degree 0 and GC could not delete it, so the race could not be reproduced.) + PartWriteInfo info; + info.intended_ref = ns.string() + "/part_2"; + auto build2 = s->beginPartWrite(info); + + ManifestEntry eb; + eb.path = "data.bin"; + eb.placement = EntryPlacement::Blob; + eb.ref = DB::Cas::BlobRef{DB::Cas::BlobHashAlgo::CityHash128, DB::Cas::BlobDigest::fromU128(u128Of(B))}; + + eb.blob_size = B.size(); + build2->adoptEvidence(eb); /// tokenless dep (no HEAD) + build2->putBlob(idOf(U), BlobSource::fromString(U)); /// build2's own unique, protected blob + + /// Drop the committed pin on B and advance the watermark so B (owned by the finished build1) is not + /// spared. No owner names B now (build2 has not staged/precommitted), so GC folds B to in-degree 0 + /// once part_1 is dropped. + s->dropRef(ns, "part_1"); + s->renewWatermarkOnce(); + + /// GC reclaims build1's manifest and the now-unreferenced B to a fixpoint, completing the rounds. + { + Gc gc(s, u128Of("gc-reuse-race")); + runGcToFixpoint(s, gc); + } + ASSERT_FALSE(blobPresent(b, s->layout(), B)) + << "GC must have deleted the now-unreferenced reused blob B"; + + /// Only NOW does build2 publish a manifest naming the (just-deleted) B: stage the body + precommit. + const ManifestId id2 = build2->stageManifest({eb, blobEntry("uniq.bin", U)}); + build2->precommitAdd(ns, "part_2", id2); + + /// build2 promotes part_2 -> id2 -> {B, U}. §4 manifest-trust: B is a committed-source adopted leaf, + /// so the promote gate TRUSTS it (no HEAD/loadMeta probe) and commits — it does NOT re-observe the + /// deleted B. This is the accepted D4 trade-off. On the real reuse/relink path B CANNOT be deleted + /// while build2's precommit edge is live: precommitAdd durably appends the Precommit OwnerTransition + /// (CasPartWriteTxn.cpp precommitAdd) BEFORE promote, and promote re-proves that edge live (WPromote + /// owner==bld) BEFORE it trusts the leaf — so B has in-degree >= 1 and GC (the sole deleter) cannot + /// collect it. This test injects the loss DIRECTLY (raw GC-to-fixpoint after dropping EVERY owner, + /// with build2 not yet precommitted), which the live-precommit invariant excludes. So the dangle is + /// not prevented at promote under §4 — it is DETECTED by fsck (the backstop). + EXPECT_NO_THROW(build2->promote(ns, "part_2", build2->buildId(), id2)); + + /// THE BACKSTOP (INV-NO-DANGLE-via-fsck): fsck's reachable-but-absent scan reports the committed-yet- + /// deleted B as dangling. This is where an absent adopted blob surfaces under §4 — not at the promote + /// gate. Detection moved, it did not disappear. + const FsckReport rep = runFsck(*s, /*detail=*/true); + EXPECT_GE(rep.dangling, 1u) + << "§4 D4 backstop: promote trusts the adopted leaf and commits; the deleted B must surface as an " + "fsck dangling finding (dangling=" << rep.dangling << ", reachable=" << rep.reachable << ")"; +} diff --git a/src/Disks/tests/gtest_cas_gc_log.cpp b/src/Disks/tests/gtest_cas_gc_log.cpp new file mode 100644 index 000000000000..23aefcfb31d0 --- /dev/null +++ b/src/Disks/tests/gtest_cas_gc_log.cpp @@ -0,0 +1,489 @@ +#include +#include +#include +#include +#include +#include +#include + +#include + +#include +#include + +/// Unit coverage for the CA GC scheduler's logging sink (the source of +/// `system.cas_gc_log`). The scheduler emits a Start + Finish +/// `GcRoundLogRecord` per round through the injected `GcRoundLogger`; here we capture the records in +/// a vector and assert their shape over a real (in-memory) Pool driven through a dropped-then- +/// collectable object — the same Pool/Backend fixture the B140 reclaim test uses. +/// +/// NOTE on ProfileEvents: `runOneRoundNow` runs on THIS (bare gtest) thread, which has no attached +/// `ThreadStatus`, so the scheduler's `CurrentThread::isInitialized()` guard skips per-round +/// ProfileEvents capture. The `profile_events` map is therefore EXPECTED to be empty here and this +/// test does NOT assert it non-empty (the on-server paths are attached; the functional/soak coverage +/// asserts non-empty there). + +namespace DB::ErrorCodes +{ + extern const int BAD_ARGUMENTS; +} + +using namespace DB::Cas; +using DB::Cas::tests::idOf; +using DB::Cas::tests::u128Of; +using Rec = DB::Cas::GcRoundLogRecord; + +namespace +{ + +/// Publish one part `ref` with a single content blob whose payload is `payload`. Returns the manifest id. +ManifestId publishPart(const PoolPtr & s, const String & ns, const String & ref, const String & payload) +{ + const RootNamespace nsr{ns}; + PartWriteInfo info; + info.intended_ref = ns + "/" + ref; + auto build = s->beginPartWrite(info); + build->putBlob(idOf(payload), BlobSource::fromString(payload)); + + ManifestEntry e; + e.path = "data.bin"; + e.placement = EntryPlacement::Blob; + e.ref = DB::Cas::BlobRef{DB::Cas::BlobHashAlgo::CityHash128, DB::Cas::BlobDigest::fromU128(u128Of(payload))}; + + e.blob_size = payload.size(); + + const ManifestId id = build->stageManifest({e}); + build->precommitAdd(nsr, ref, id); + build->promote(nsr, ref, build->buildId(), id); + return id; +} + +} + +namespace +{ +/// One round emits a Start, then one Phase row per GC phase it reached, then a Finish. Tests that care +/// only about the round-outcome rows filter the phase rows out through this. +std::vector roundRowsOnly(const std::vector & rows) +{ + std::vector out; + for (const Rec & r : rows) + if (r.event_type != Rec::EventType::Phase) + out.push_back(r); + return out; +} +} + +/// The happy path: a marking round (candidates_marked > 0). Each `runOneRoundNow` must emit exactly one +/// Start, then its phase rows, then one Finish, with `disk_name`/`gc_id` set and `duration_ms` +/// populated on the Finish. +/// +/// It drives the PRODUCTION scheduler, so it covers both halves of the pipeline: a MARKING round +/// (candidates condemned, nothing deleted) and, some rounds later once the mount's ack floor graduates +/// them, a DELETING round whose Finish carries the count through. The ordering is asserted, because a +/// deletion reported before its marking would mean the row is not describing the round it names. +TEST(CASGCLog, EmitsStartFinishWithCounts) +{ + auto backend = std::make_shared(); + /// gc_fold_max_defer_rounds=0: this test drives up to 16 consecutive rounds through the scheduler + /// (no direct Gc handle to override per-instance) expecting each to fold; force fold-every-round + /// (Phase-4 Lever A would otherwise defer once the pool quiesces, stalling the mark-then-delete + /// pipeline within the round budget). + auto store = Pool::open(backend, + PoolConfig{.pool_prefix = "p", .server_root_id = "test", .gc_fold_max_defer_rounds = 0}); + const RootNamespace ns{"srv1/tbl"}; + + /// Publish a part, then drop it so its blob/tree become collectable. + publishPart(store, ns.string(), "all_0_0_0", "hello-cas-gc-log"); + store->dropRef(ns, "all_0_0_0"); + /// Advance the durable watermark floor past the build's seq so the build-watermark guard no + /// longer spares the now-dropped objects (the background renewer is off in this test). + store->renewWatermarkOnce(); + + std::vector rows; + DB::Cas::CasGcScheduler sched( + store, std::chrono::seconds(1), "test::gc", "ca", + [&](const Rec & r) { rows.push_back(r); }); + + /// Drive rounds until we observe both a marking round and a deletion round. Under the ack-floor + /// pipeline a candidate is marked (condemned) in one round and physically deleted a few rounds later, + /// once the mount's ack floor graduates it — so advance the store's own mount ack after each round + /// (renewWatermarkOnce runs the beat) and give the pipeline a generous round budget. Each + /// runOneRoundNow call appends a Start, the round's phase rows, and a Finish. + bool saw_marked = false; + bool saw_deleted = false; + size_t marking_finish_idx = 0; + size_t deleting_finish_idx = 0; + uint64_t total_deleted = 0; + constexpr size_t max_rounds = 16; + for (size_t round = 0; round < max_rounds; ++round) + { + const size_t before = rows.size(); + sched.runOneRoundNow(Rec::Trigger::Manual); + store->renewWatermarkOnce(); + + /// Each call emits exactly one Start (first) and one Finish (last), with the round's phase rows + /// in between. + ASSERT_GE(rows.size(), before + 2u) << "each round must emit at least a Start and a Finish"; + ASSERT_EQ(rows[before].event_type, Rec::EventType::Start); + ASSERT_EQ(rows.back().event_type, Rec::EventType::Finish); + for (size_t i = before + 1; i + 1 < rows.size(); ++i) + ASSERT_EQ(rows[i].event_type, Rec::EventType::Phase) + << "only Phase rows may sit between a round's Start and Finish"; + + const size_t finish_idx = rows.size() - 1; + const Rec & fin = rows[finish_idx]; + if (!saw_marked && fin.candidates_marked > 0) + { + saw_marked = true; + marking_finish_idx = finish_idx; + } + if (!saw_deleted && fin.objects_deleted > 0) + { + saw_deleted = true; + deleting_finish_idx = finish_idx; + } + total_deleted += fin.objects_deleted; + } + + ASSERT_TRUE(saw_marked) << "expected a round that marked at least one candidate"; + EXPECT_GT(rows[marking_finish_idx].candidates_marked, 0u); + EXPECT_GT(rows[marking_finish_idx].entries_condemned, 0u); + ASSERT_TRUE(saw_deleted) << "expected a round that physically deleted at least one object"; + EXPECT_GE(deleting_finish_idx, marking_finish_idx) + << "an object cannot be reported deleted before the round that condemned it"; + EXPECT_GT(total_deleted, 0u) + << "the deleted count must reach the Finish row, not stop inside the round"; + + /// Identity + timing fields are set on every record. + for (const Rec & r : rows) + { + EXPECT_EQ(r.disk_name, "ca"); + EXPECT_FALSE(r.gc_id.empty()); + EXPECT_EQ(r.trigger, Rec::Trigger::Manual); + } + /// The round-outcome rows alternate Start, Finish, Start, Finish, ... once the phase rows are + /// filtered out; `duration_ms` is meaningful on each Finish (populated unconditionally there). + const std::vector round_rows = roundRowsOnly(rows); + ASSERT_EQ(round_rows.size() % 2, 0u); + for (size_t i = 0; i < round_rows.size(); ++i) + EXPECT_EQ(round_rows[i].event_type, + i % 2 == 0 ? Rec::EventType::Start : Rec::EventType::Finish); +} + +namespace +{ + +/// A backend that throws on `list`, the first thing the GC round does (namespace discovery via the +/// roots registry / listing). Used to drive the Aborted-Finish path: the round throws, the scheduler +/// emits an Aborted Finish with the exception text, and `runOneRoundNow` rethrows. +class ThrowingBackend : public InMemoryBackend +{ +public: + ListPage list(const String & prefix, const String & cursor, size_t limit) override + { + if (arm) + throw DB::Exception(DB::ErrorCodes::BAD_ARGUMENTS, "injected backend list failure"); + return InMemoryBackend::list(prefix, cursor, limit); + } + + std::optional get(const String & key, Range range) override + { + if (arm) + throw DB::Exception(DB::ErrorCodes::BAD_ARGUMENTS, "injected backend get failure"); + return InMemoryBackend::get(key, range); + } + + HeadResult head(const String & key) override + { + if (arm) + throw DB::Exception(DB::ErrorCodes::BAD_ARGUMENTS, "injected backend head failure"); + return InMemoryBackend::head(key); + } + + /// Armed only after Pool::open, so opening (which reads/initialises gc state) succeeds. + std::atomic arm{false}; +}; + +} + +/// A7-HIGH-fix: the manual `SYSTEM ... GC` path (runOneRoundNow) reuses ONE stable Gc instance across +/// calls (A7 — the lease's observation-window steal protocol compares consecutive observations of the +/// SAME observer), but it must be OBSERVE-ONLY with respect to STEALING: the protocol's safety argument +/// requires the two observations that flag an incumbent "frozen" to be spaced by real wall time (>= the +/// heartbeat cadence H) so a live incumbent gets a chance to pulse in between — a guarantee only the +/// background loop's own interval-paced ticks provide. Two manual calls have no such guarantee (they +/// can land microseconds apart in a real query), so a manual round must NEVER execute the steal CAS, +/// no matter how many times it re-observes the same frozen tuple. Dead-incumbent recovery stays the +/// loop's job (bounded ~2*interval; covered by the CASGCLease loop-driven steal tests in +/// gtest_cas_gc_round.cpp, e.g. StealAfterObservedNonRenewalBumpsEpoch / FailoverStealOnceHeartbeatStops). +/// Deterministic: "time" is the order of runRegularRound calls; no sleep, no clock, no threads. +TEST(CASGCSchedulerSteal, ManualRoundNeverStealsEvenADeadIncumbent) +{ + auto backend = std::make_shared(); + auto store = Pool::open(backend, PoolConfig{.pool_prefix = "p", .server_root_id = "test"}); + + /// A foreign incumbent takes the lease and then DIES (never renews, never heartbeats). + const UInt128 kIncumbent = hexToU128("00000000000000000000000000000abc"); + Gc incumbent(store, kIncumbent); + ASSERT_TRUE(incumbent.runRegularRound().acquired_lease); + + DB::Cas::CasGcScheduler sched(store, std::chrono::seconds(1), "test::gc", "ca"); + + /// obs #1: records the incumbent's (owner, seq, hb=absent). + EXPECT_FALSE(sched.runOneRoundNow(Rec::Trigger::Manual).acquired_lease); + /// obs #2 and #3: the same frozen (owner, seq, hb) observed repeatedly would be steal-eligible on + /// the loop path (see the Core-level test this mirrors), but the manual path keeps backing off. + EXPECT_FALSE(sched.runOneRoundNow(Rec::Trigger::Manual).acquired_lease); + EXPECT_FALSE(sched.runOneRoundNow(Rec::Trigger::Manual).acquired_lease); +} + +/// Negative-control companion to the test above (reviewer-requested): with the incumbent visibly alive +/// (its heartbeat advancing between the manual round's observations, exactly like +/// CASGCLease.HeartbeatBlocksFalseStealOfAliveLeader at the Core level), the manual round must still +/// correctly back off — confirming the new observe-only branch didn't regress the PRE-EXISTING +/// incumbent_renewed/hb_alive liveness detection (this test would already pass on the protocol's own +/// terms even without the A7-HIGH-fix; it pins that the fix didn't break it). +TEST(CASGCSchedulerSteal, ManualRoundNeverStealsALiveHeartbeatingIncumbent) +{ + auto backend = std::make_shared(); + auto store = Pool::open(backend, PoolConfig{.pool_prefix = "p", .server_root_id = "test"}); + + const UInt128 kIncumbent = hexToU128("00000000000000000000000000000abc"); + Gc incumbent(store, kIncumbent); + ASSERT_TRUE(incumbent.runRegularRound().acquired_lease); + + DB::Cas::CasGcScheduler sched(store, std::chrono::seconds(1), "test::gc", "ca"); + + /// obs #1: records (owner=incumbent, seq, hb=absent). + EXPECT_FALSE(sched.runOneRoundNow(Rec::Trigger::Manual).acquired_lease); + Gc::pulseHeartbeat(*store, kIncumbent); /// the incumbent is alive and pulsing (hb 0->1) + /// obs #2: hb advanced since obs #1 => alive => no steal (never reaches the observe-only branch). + EXPECT_FALSE(sched.runOneRoundNow(Rec::Trigger::Manual).acquired_lease); + Gc::pulseHeartbeat(*store, kIncumbent); /// hb 1->2 + EXPECT_FALSE(sched.runOneRoundNow(Rec::Trigger::Manual).acquired_lease); +} + +/// A round whose backend throws must produce a Finish with `outcome == Aborted` and a non-empty +/// `error`, and `runOneRoundNow` must rethrow the exception (the round failure is observable, not +/// swallowed — the logging sink itself is best-effort, but the round error propagates). +TEST(CASGCLog, AbortedFinishOnThrowingRound) +{ + auto backend = std::make_shared(); + auto store = Pool::open(backend, PoolConfig{.pool_prefix = "p", .server_root_id = "test"}); + + std::vector rows; + DB::Cas::CasGcScheduler sched( + store, std::chrono::seconds(1), "test::gc", "ca", + [&](const Rec & r) { rows.push_back(r); }); + + backend->arm.store(true); + + EXPECT_THROW(sched.runOneRoundNow(Rec::Trigger::Manual), DB::Exception); + + /// A throwing round still emits a Start and an Aborted Finish. It also emits the phase row of the + /// phase it died in -- the timer is RAII, so it fires during unwinding, which is exactly the forensic + /// record a failed round needs. That is also why `round_id`, not `round`, is the correlator: this + /// round has no round number at all. + const std::vector round_rows = roundRowsOnly(rows); + ASSERT_EQ(round_rows.size(), 2u) << "a throwing round still emits a Start and a (Aborted) Finish"; + EXPECT_EQ(round_rows[0].event_type, Rec::EventType::Start); + EXPECT_EQ(round_rows[1].event_type, Rec::EventType::Finish); + EXPECT_EQ(round_rows[1].outcome, Rec::Outcome::Failed); + EXPECT_FALSE(round_rows[1].error.empty()) << "a failed Finish must carry the exception text"; + EXPECT_EQ(round_rows[1].disk_name, "ca"); + EXPECT_FALSE(round_rows[1].gc_id.empty()); + EXPECT_FALSE(round_rows[1].round_id.empty()); + for (const Rec & r : rows) + EXPECT_EQ(r.round_id, round_rows[0].round_id) + << "every row of a FAILED round must still correlate through round_id"; +} + +/// Every row of one round -- its Start, each of its Phase rows, and its Finish -- carries the SAME +/// non-empty `round_id`, and two rounds carry DIFFERENT ones. That is the property the column exists +/// for: `round` is 0 on Start, is only known after the round's single `gc/state` CAS, and is absent on a +/// round that never led, so it cannot serve as the correlator. +TEST(CASGCLog, EveryRowOfARoundSharesOneRoundId) +{ + auto backend = std::make_shared(); + auto store = Pool::open(backend, + PoolConfig{.pool_prefix = "p", .server_root_id = "test", .gc_fold_max_defer_rounds = 0}); + const RootNamespace ns{"srv1/tbl"}; + publishPart(store, ns.string(), "all_0_0_0", "hello-round-id"); + store->dropRef(ns, "all_0_0_0"); + store->renewWatermarkOnce(); + + std::vector rows; + DB::Cas::CasGcScheduler sched( + store, std::chrono::seconds(1), "test::gc", "ca", + [&](const Rec & r) { rows.push_back(r); }); + + sched.runOneRoundNow(Rec::Trigger::Manual); + const size_t after_first = rows.size(); + ASSERT_GE(after_first, 2u); + const String first_id = rows.front().round_id; + EXPECT_FALSE(first_id.empty()); + for (size_t i = 0; i < after_first; ++i) + EXPECT_EQ(rows[i].round_id, first_id) << "row " << i << " of the first round has a different round_id"; + + store->renewWatermarkOnce(); + sched.runOneRoundNow(Rec::Trigger::Manual); + ASSERT_GT(rows.size(), after_first); + const String second_id = rows[after_first].round_id; + EXPECT_FALSE(second_id.empty()); + EXPECT_NE(second_id, first_id) << "two rounds must not share a round_id"; + for (size_t i = after_first; i < rows.size(); ++i) + EXPECT_EQ(rows[i].round_id, second_id); +} + +namespace +{ +/// The phase names of one round, in emission order. +std::vector phaseNames(const std::vector & rows, size_t from) +{ + std::vector out; + for (size_t i = from; i < rows.size(); ++i) + if (rows[i].event_type == Rec::EventType::Phase) + out.push_back(rows[i].phase); + return out; +} + +/// The `phase_metrics` of the named phase of one round. Fails the caller's expectation if absent. +std::map metricsOf(const std::vector & rows, size_t from, const String & phase) +{ + for (size_t i = from; i < rows.size(); ++i) + if (rows[i].event_type == Rec::EventType::Phase && rows[i].phase == phase) + return rows[i].phase_metrics; + return {}; +} +} + +/// A FOLDING round emits every phase, in execution order, and each phase's row carries the semantic +/// counts only that phase can compute. This is the test that would catch an instrumentation site +/// silently dropping out of the round -- a phase that stops emitting reads exactly like a phase that +/// costs nothing, which is the failure mode this whole change exists to prevent. +/// +/// ProfileEvents are deliberately NOT asserted: `runOneRoundNow` runs on the bare gtest thread, which +/// has no attached `ThreadStatus`, so per-phase capture degrades to an empty map exactly as the +/// round-level capture already does (see the note at the top of this file). +TEST(CASGCLog, FoldingRoundEmitsEveryPhaseInOrder) +{ + auto backend = std::make_shared(); + auto store = Pool::open(backend, + PoolConfig{.pool_prefix = "p", .server_root_id = "test", .gc_fold_max_defer_rounds = 0}); + const RootNamespace ns{"srv1/tbl"}; + publishPart(store, ns.string(), "all_0_0_0", "hello-cas-gc-phases"); + store->dropRef(ns, "all_0_0_0"); + store->renewWatermarkOnce(); + + std::vector rows; + DB::Cas::CasGcScheduler sched( + store, std::chrono::seconds(1), "test::gc", "ca", + [&](const Rec & r) { rows.push_back(r); }); + + ASSERT_TRUE(sched.runOneRoundNow(Rec::Trigger::Manual).acquired_lease); + + const std::vector expected = { + "lease", "pre_fold_ref_drain", "heartbeat_floor", "defer_decision", "parent_seal_read", + "fold_ref_group", "fold_seal_read", "fold_ref_intake", + "fold_reduce", "fold_seal_write", + "pending_deletes", "meta_pool_wait", "round_commit", "handoff_reclaim", + "manifest_deletes", "namespace_cleanup", "ref_object_cleanup", "orphan_sweep"}; + EXPECT_EQ(phaseNames(rows, 0), expected); + + /// Every phase row is a Phase row of THIS round and carries a duration field (0 is a legitimate + /// microsecond reading for a phase that did nothing, so only the shape is asserted). + for (const Rec & r : rows) + if (r.event_type == Rec::EventType::Phase) + { + EXPECT_EQ(r.round_id, rows.front().round_id); + EXPECT_FALSE(r.phase.empty()); + EXPECT_TRUE(r.error.empty()); + } + + /// The defer decision reports the signal it decided on, and the two fold-seal reads it paid for. + const auto defer = metricsOf(rows, 0, "defer_decision"); + EXPECT_EQ(defer.at("deferred"), 0u) << "this round folded, so it cannot report itself deferred"; + EXPECT_EQ(defer.at("fold_seal_reads"), 2u); + EXPECT_GT(defer.at("namespaces_seen"), 0u); + + const auto ref_group = metricsOf(rows, 0, "fold_ref_group"); + EXPECT_EQ(ref_group.at("ref_folding_aborted"), 0u); + EXPECT_GT(ref_group.at("ref_keys_listed"), 0u); + + /// Probe B1's identity, as an OBSERVABLE property of the table rather than an assumption in a + /// comment: the round sealed coverage over exactly the logs it folded. + const auto intake = metricsOf(rows, 0, "fold_ref_intake"); + EXPECT_EQ(intake.at("logs_accounted"), intake.at("logs_applied")); + EXPECT_GT(intake.at("logs_applied"), 0u); + EXPECT_GT(intake.at("deltas_emitted"), 0u); + + /// Probe B2's verdict. Nonzero would have thrown, so the row can only ever read 0 on a round that + /// reached its Finish -- which is the point: the column is the round's own attestation. + EXPECT_EQ(metricsOf(rows, 0, "fold_reduce").at("transactions_unapplied"), 0u); + + /// The honest gap: the meta pool's work runs on other threads, so this row's ProfileEvents delta is + /// empty by construction and these two counts are its ONLY signal. They must be real numbers. + const auto meta = metricsOf(rows, 0, "meta_pool_wait"); + EXPECT_GT(meta.at("jobs_scheduled"), 0u) << "this round condemns, so it schedules condemn-marker writes"; + EXPECT_EQ(meta.at("jobs_completed"), meta.at("jobs_scheduled")) + << "every scheduled job must have finished by the time the wait returns"; +} + +/// A round that never leads emits ONLY the phase it reached. `round` does not exist for such a round, +/// so `round_id` is the only thing tying its rows together -- which is why it is the correlator. +TEST(CASGCLog, NotALeaderRoundEmitsOnlyTheLeasePhase) +{ + auto backend = std::make_shared(); + auto store = Pool::open(backend, PoolConfig{.pool_prefix = "p", .server_root_id = "test"}); + + /// A foreign incumbent holds the lease, so the scheduler's round backs off immediately. + Gc incumbent(store, hexToU128("00000000000000000000000000000abc")); + ASSERT_TRUE(incumbent.runRegularRound().acquired_lease); + + std::vector rows; + DB::Cas::CasGcScheduler sched( + store, std::chrono::seconds(1), "test::gc", "ca", + [&](const Rec & r) { rows.push_back(r); }); + EXPECT_FALSE(sched.runOneRoundNow(Rec::Trigger::Manual).acquired_lease); + + EXPECT_EQ(phaseNames(rows, 0), (std::vector{"lease"})); + EXPECT_EQ(metricsOf(rows, 0, "lease").at("acquired"), 0u); + ASSERT_EQ(rows.size(), 3u); + EXPECT_EQ(rows.back().outcome, Rec::Outcome::NotALeader); + for (const Rec & r : rows) + EXPECT_EQ(r.round_id, rows.front().round_id); +} + +/// B3: the scheduler exposes per-disk GC health for system.cas_mounts (the process- +/// global CurrentMetrics gauges were clobbered with >= 2 CAS disks). Drive one leader round and +/// assert the health snapshot reflects leadership, the pending-reclaim backlog and a fresh success. +TEST(CASGCHealth, ReflectsLeadershipAndPendingReclaim) +{ + auto backend = std::make_shared(); + auto store = Pool::open(backend, + PoolConfig{.pool_prefix = "p", .server_root_id = "test", .gc_fold_max_defer_rounds = 0}); + const RootNamespace ns{"srv1/tbl"}; + publishPart(store, ns.string(), "all_0_0_0", "hello-cas-gc-health"); + store->dropRef(ns, "all_0_0_0"); + store->renewWatermarkOnce(); + + DB::Cas::CasGcScheduler sched(store, std::chrono::seconds(1), "test::gc", "ca", {}); + + const auto h0 = sched.gcHealth(); + EXPECT_FALSE(h0.is_leader); + EXPECT_FALSE(h0.ever_succeeded); + EXPECT_EQ(h0.pending_reclaim, 0); + EXPECT_EQ(h0.wedged_namespace_count, 0u); + + const RoundReport rep = sched.runOneRoundNow(Rec::Trigger::Manual); + ASSERT_TRUE(rep.acquired_lease); + + const auto h1 = sched.gcHealth(); + EXPECT_TRUE(h1.is_leader); + EXPECT_TRUE(h1.ever_succeeded); + EXPECT_EQ(h1.pending_reclaim, + static_cast(rep.condemned) - static_cast(rep.redeleted)); + EXPECT_EQ(h1.wedged_namespace_count, 0u); + EXPECT_LT(h1.last_success_age_seconds, 60u); +} diff --git a/src/Disks/tests/gtest_cas_gc_maintenance_state_format.cpp b/src/Disks/tests/gtest_cas_gc_maintenance_state_format.cpp new file mode 100644 index 000000000000..158040be38c9 --- /dev/null +++ b/src/Disks/tests/gtest_cas_gc_maintenance_state_format.cpp @@ -0,0 +1,202 @@ +#include "cas_test_helpers.h" +#include +#include +#include + +using namespace DB::Cas; + +namespace DB::ErrorCodes +{ + extern const int CORRUPTED_DATA; + extern const int LIMIT_EXCEEDED; + extern const int UNKNOWN_FORMAT_VERSION; +} + +namespace +{ +class FailingMaintenanceReadBackend : public InMemoryBackend +{ +public: + std::optional get(const String &, Range) override + { + throw std::runtime_error("injected maintenance read failure"); + } +}; +} + +TEST(CASGCMaintenanceStateFormat, RegistryLayoutAndCanonicalCodec) +{ + EXPECT_EQ(static_cast(FormatId::GcMaintenanceState), 25); + const auto points = changePoints(FormatId::GcMaintenanceState); + ASSERT_EQ(points.size(), 1u); + EXPECT_EQ(points[0].generation, 7); + EXPECT_EQ(points[0].min_reader, 7); + const FormatTraits & traits = traitsFor(FormatId::GcMaintenanceState); + EXPECT_EQ(traits.type, "cas_gc_maintenance_state"); + EXPECT_EQ(traits.family, TextFamily::Control); + EXPECT_EQ(traits.strictness, KeyStrictness::Strict); + EXPECT_EQ(traits.compression, CompressionPolicy::Never); + EXPECT_EQ(traits.object_cap, 512 * 1024); + EXPECT_EQ(traits.line_cap, 512 * 1024); + EXPECT_EQ(storedSuffix(FormatId::GcMaintenanceState), ""); + EXPECT_EQ(traitsForType("cas_gc_maintenance_state"), &traits); + + const Layout layout("p"); + EXPECT_EQ(layout.gcMaintenanceStateKey(), "p/gc/maintenance_state"); + EXPECT_NE(layout.gcMaintenanceStateKey(), layout.gcStateKey()); + EXPECT_NE(layout.gcMaintenanceStateKey(), layout.gcHbKey()); + + const GcMaintenanceState empty; + EXPECT_EQ(encodeGcMaintenanceState(empty), fmt::format( + "{{\"type\":\"cas_gc_maintenance_state\",\"v\":{}}}\n{{\"cur\":\"\"}}\n", currentCompatibilityVersion())); + const GcMaintenanceState state{.janitor_cursor = R"(cas/ns/a/"quoted"\\next)"}; + EXPECT_EQ(decodeGcMaintenanceState(encodeGcMaintenanceState(state)), state); +} + +TEST(CASGCMaintenanceStateFormat, RejectsMalformedAndBoundsCursor) +{ + const auto bad = [](std::string_view body) + { + return "{\"type\":\"cas_gc_maintenance_state\",\"v\":7}\n" + String(body); + }; + DB::Cas::tests::expectThrowsCode(DB::ErrorCodes::CORRUPTED_DATA, + [&] { (void)decodeGcMaintenanceState(bad("{}\n")); }); + DB::Cas::tests::expectThrowsCode(DB::ErrorCodes::CORRUPTED_DATA, + [&] { (void)decodeGcMaintenanceState(bad("{\"cur\":\"a\",\"cur\":\"b\"}\n")); }); + DB::Cas::tests::expectThrowsCode(DB::ErrorCodes::CORRUPTED_DATA, + [&] { (void)decodeGcMaintenanceState(bad("{\"cur\":\"a\",\"extra\":1}\n")); }); + DB::Cas::tests::expectThrowsCode(DB::ErrorCodes::CORRUPTED_DATA, + [&] { (void)decodeGcMaintenanceState(bad("{\"cur\":\"a\"}\nx")); }); + + const GcMaintenanceState at_limit{.janitor_cursor = String(kMaxGcMaintenanceCursorBytes, 'x')}; + EXPECT_EQ(decodeGcMaintenanceState(encodeGcMaintenanceState(at_limit)), at_limit); + const GcMaintenanceState over_limit{.janitor_cursor = String(kMaxGcMaintenanceCursorBytes + 1, 'x')}; + DB::Cas::tests::expectThrowsCode(DB::ErrorCodes::LIMIT_EXCEEDED, + [&] { (void)encodeGcMaintenanceState(over_limit); }); + const String raw = "{\"type\":\"cas_gc_maintenance_state\",\"v\":7}\n{\"cur\":\"" + over_limit.janitor_cursor + "\"}\n"; + DB::Cas::tests::expectThrowsCode(DB::ErrorCodes::CORRUPTED_DATA, + [&] { (void)decodeGcMaintenanceState(raw); }); + String oversized = R"({"type":"cas_gc_maintenance_state","v":7,"pad":")"; + oversized.append(448 * 1024, 'x'); + oversized += "\"}\n{\"cur\":\""; + oversized.append(kMaxGcMaintenanceCursorBytes, 'y'); + oversized += "\"}\n"; + ASSERT_GT(oversized.size(), traitsFor(FormatId::GcMaintenanceState).object_cap); + DB::Cas::tests::expectThrowsCode(DB::ErrorCodes::CORRUPTED_DATA, + [&] { (void)decodeGcMaintenanceState(oversized); }); +} + +TEST(CASGCMaintenanceState, ReadsAndCasWithoutAdoptingConflicts) +{ + InMemoryBackend backend; + const Layout layout("p"); + const String key = layout.gcMaintenanceStateKey(); + const GcMaintenanceReadResult absent = readGcMaintenanceState(backend, layout); + EXPECT_EQ(absent.status, GcMaintenanceReadStatus::Absent); + EXPECT_FALSE(absent.state); + EXPECT_FALSE(absent.token); + + const GcMaintenanceState first{.janitor_cursor = "cas/ns/first"}; + const GcMaintenanceCasResult created = casGcMaintenanceState(backend, layout, std::nullopt, first); + EXPECT_EQ(created.outcome, GcMaintenanceCasOutcome::Committed); + const GcMaintenanceReadResult valid = readGcMaintenanceState(backend, layout); + ASSERT_EQ(valid.status, GcMaintenanceReadStatus::Valid); + ASSERT_TRUE(valid.token); + ASSERT_TRUE(valid.state); + EXPECT_EQ(*valid.state, first); + + const GcMaintenanceCasResult advanced = casGcMaintenanceState(backend, layout, valid.token, + GcMaintenanceState{.janitor_cursor = "cas/ns/advanced"}); + ASSERT_EQ(advanced.outcome, GcMaintenanceCasOutcome::Committed); + const auto advanced_body = backend.get(key); + ASSERT_TRUE(advanced_body); + + ASSERT_EQ(backend.casPut(key, encodeGcMaintenanceState({.janitor_cursor = "winner"}), advanced_body->token).outcome, + CasOutcome::Committed); + const GcMaintenanceCasResult conflict = casGcMaintenanceState(backend, layout, valid.token, + GcMaintenanceState{.janitor_cursor = "loser"}); + EXPECT_EQ(conflict.outcome, GcMaintenanceCasOutcome::Conflict); + EXPECT_EQ(decodeGcMaintenanceState(backend.get(key)->bytes).janitor_cursor, "winner"); +} + +TEST(CASGCMaintenanceState, ClassifiesCorruptionAndResetsOnlyExactToken) +{ + InMemoryBackend backend; + const Layout layout("p"); + const String key = layout.gcMaintenanceStateKey(); + ASSERT_EQ(backend.putIfAbsent(key, "malformed").outcome, PutOutcome::Done); + const GcMaintenanceReadResult corrupt = readGcMaintenanceState(backend, layout); + ASSERT_EQ(corrupt.status, GcMaintenanceReadStatus::Corrupt); + ASSERT_TRUE(corrupt.token); + EXPECT_FALSE(corrupt.state); + EXPECT_FALSE(corrupt.diagnostic.empty()); + ASSERT_EQ(casGcMaintenanceState(backend, layout, corrupt.token, {}).outcome, GcMaintenanceCasOutcome::Committed); + EXPECT_EQ(decodeGcMaintenanceState(backend.get(key)->bytes), GcMaintenanceState{}); +} + +TEST(CASGCMaintenanceState, UsesExactlyOneReadOrCasAttempt) +{ + DB::Cas::tests::CountingBackend backend; + const Layout layout("p"); + const String key = layout.gcMaintenanceStateKey(); + EXPECT_EQ(readGcMaintenanceState(backend, layout).status, GcMaintenanceReadStatus::Absent); + EXPECT_EQ(backend.getCount(key), 1u); + + backend.resetCounts(); + ASSERT_EQ(casGcMaintenanceState(backend, layout, std::nullopt, {}).outcome, + GcMaintenanceCasOutcome::Committed); + EXPECT_EQ(backend.casPutCount(key), 1u); + EXPECT_EQ(backend.getCount(key), 0u); + + backend.resetCounts(); + EXPECT_EQ(casGcMaintenanceState(backend, layout, std::nullopt, + GcMaintenanceState{.janitor_cursor = "loser"}).outcome, GcMaintenanceCasOutcome::Conflict); + EXPECT_EQ(backend.casPutCount(key), 1u); + EXPECT_EQ(backend.getCount(key), 0u); + + const auto current = backend.get(key); + ASSERT_TRUE(current); + ASSERT_EQ(backend.casPut(key, encodeGcMaintenanceState({.janitor_cursor = "winner"}), current->token).outcome, + CasOutcome::Committed); + backend.resetCounts(); + EXPECT_EQ(casGcMaintenanceState(backend, layout, current->token, + GcMaintenanceState{.janitor_cursor = "stale"}).outcome, GcMaintenanceCasOutcome::Conflict); + EXPECT_EQ(backend.casPutCount(key), 1u); + EXPECT_EQ(backend.getCount(key), 0u); + EXPECT_EQ(decodeGcMaintenanceState(backend.InMemoryBackend::get(key)->bytes).janitor_cursor, "winner"); +} + +TEST(CASGCMaintenanceState, FutureVersionPropagatesInsteadOfResetting) +{ + DB::Cas::tests::CountingBackend backend; + const Layout layout("p"); + const String key = layout.gcMaintenanceStateKey(); + ASSERT_EQ(backend.putIfAbsent(key, fmt::format( + "{{\"type\":\"cas_gc_maintenance_state\",\"v\":{}}}\n{{\"cur\":\"\"}}\n", currentCompatibilityVersion() + 1)).outcome, + PutOutcome::Done); + DB::Cas::tests::expectThrowsCode(DB::ErrorCodes::UNKNOWN_FORMAT_VERSION, + [&] { (void)readGcMaintenanceState(backend, layout); }); + EXPECT_EQ(backend.casPutCount(key), 0u); + + FailingMaintenanceReadBackend failing; + EXPECT_THROW((void)readGcMaintenanceState(failing, layout), std::runtime_error); +} + +TEST(CASGCMaintenanceState, LosingCorruptResetPreservesConcurrentWinner) +{ + DB::Cas::tests::CountingBackend backend; + const Layout layout("p"); + const String key = layout.gcMaintenanceStateKey(); + ASSERT_EQ(backend.putIfAbsent(key, "corrupt").outcome, PutOutcome::Done); + const auto corrupt = readGcMaintenanceState(backend, layout); + ASSERT_EQ(corrupt.status, GcMaintenanceReadStatus::Corrupt); + ASSERT_TRUE(corrupt.token); + ASSERT_EQ(backend.casPut(key, encodeGcMaintenanceState({.janitor_cursor = "winner"}), corrupt.token).outcome, + CasOutcome::Committed); + backend.resetCounts(); + EXPECT_EQ(casGcMaintenanceState(backend, layout, corrupt.token, {}).outcome, + GcMaintenanceCasOutcome::Conflict); + EXPECT_EQ(backend.casPutCount(key), 1u); + EXPECT_EQ(backend.getCount(key), 0u); + EXPECT_EQ(decodeGcMaintenanceState(backend.InMemoryBackend::get(key)->bytes).janitor_cursor, "winner"); +} diff --git a/src/Disks/tests/gtest_cas_gc_outcomes_format.cpp b/src/Disks/tests/gtest_cas_gc_outcomes_format.cpp new file mode 100644 index 000000000000..e6530f131299 --- /dev/null +++ b/src/Disks/tests/gtest_cas_gc_outcomes_format.cpp @@ -0,0 +1,101 @@ +#include "cas_format_test_battery.h" +#include +#include +#include + +using namespace DB::Cas; + +namespace +{ + +/// Same tiny inline copy as `gtest_cas_part_manifest_format.cpp`'s `expectThrowsCode`: stays clear +/// of `Disks/tests/cas_test_helpers.h`, which would drag in the whole CAS backend/store machinery +/// this file otherwise has no need for. +template +void expectThrowsCode(int expected_code, F && fn) +{ + try + { + fn(); + FAIL() << "expected DB::Exception"; + } + catch (const DB::Exception & e) + { + EXPECT_EQ(e.code(), expected_code); + } +} + +} + +TEST(CASFormatBattery, GcOutcomes) +{ + OutcomeLog log; + OutcomeEntry e; + e.kind = ObjectKind::Blob; + e.ref = BlobRef{BlobHashAlgo::CityHash128, BlobDigest::fromU128(hexToU128("00112233445566778899aabbccddeeff"))}; + e.token = Token{"e-1", TokenType::ETag}; + e.outcome = OutcomeKind::Deleted; + log.entries.push_back(e); + runFormatBattery({FormatId::GcOutcomes, + [&] { return sealObject(FormatId::GcOutcomes, encodeOutcomeLog(log)); }, + [](std::string_view d) { decodeOutcomeLog(std::string(openObject(FormatId::GcOutcomes, d))); }, + currentFormatHeader("cas_gc_outcomes") + + "{\"k\":\"blob\",\"ha\":\"ch128\",\"h\":\"00112233445566778899aabbccddeeff\"," + "\"tt\":\"etag\",\"tv\":\"e-1\",\"oc\":\"deleted\"}\n{\"n\":1}\n"}); +} + +TEST(CASGCOutcomesFormat, EmptyRoundTrips) +{ + EXPECT_EQ(decodeOutcomeLog(encodeOutcomeLog(OutcomeLog{})).entries.size(), 0u); +} + +TEST(CASGCOutcomesFormat, MultiEntryRoundTripAllOutcomes) +{ + OutcomeLog log; + log.entries.push_back({ObjectKind::Blob, BlobRef{BlobHashAlgo::CityHash128, BlobDigest::fromU128(hexToU128("aa00000000000000000000000000000a"))}, + Token{"etag-1", TokenType::ETag}, OutcomeKind::Deleted}); + log.entries.push_back({ObjectKind::Blob, BlobRef{BlobHashAlgo::CityHash128, BlobDigest::fromU128(hexToU128("bb00000000000000000000000000000b"))}, + Token{"7", TokenType::Emulated}, OutcomeKind::Spared}); + log.entries.push_back({ObjectKind::Blob, BlobRef{BlobHashAlgo::CityHash128, BlobDigest::fromU128(hexToU128("cc00000000000000000000000000000c"))}, + Token{"8", TokenType::Emulated}, OutcomeKind::Replaced}); + log.entries.push_back({ObjectKind::Blob, BlobRef{BlobHashAlgo::CityHash128, BlobDigest::fromU128(hexToU128("dd00000000000000000000000000000d"))}, + Token{"9", TokenType::Emulated}, OutcomeKind::Absent}); + const String text = encodeOutcomeLog(log); + const OutcomeLog d = decodeOutcomeLog(text); + ASSERT_EQ(d.entries.size(), 4u); + EXPECT_EQ(d.entries[0].ref, (BlobRef{BlobHashAlgo::CityHash128, BlobDigest::fromU128(hexToU128("aa00000000000000000000000000000a"))})); + EXPECT_EQ(d.entries[0].outcome, OutcomeKind::Deleted); + EXPECT_EQ(d.entries[1].outcome, OutcomeKind::Spared); + EXPECT_EQ(d.entries[2].outcome, OutcomeKind::Replaced); + EXPECT_EQ(d.entries[3].outcome, OutcomeKind::Absent); + EXPECT_EQ(d.entries[0].token.value, "etag-1"); + EXPECT_EQ(d.entries[0].token.type, TokenType::ETag); + EXPECT_EQ(d.entries[3].token.value, "9"); + /// Insertion order + byte-stable text (the encoder is a pure function of the log). + EXPECT_EQ(encodeOutcomeLog(d), text); +} + +TEST(CASGCOutcomesFormat, GarbageAndUnknownWordsFailClosed) +{ + EXPECT_THROW(decodeOutcomeLog(String("")), DB::Exception); + EXPECT_THROW(decodeOutcomeLog(String("not a cas object\n")), DB::Exception); + /// A record with an unknown outcome word fails closed. + const String bad = "{\"type\":\"cas_gc_outcomes\",\"v\":3}\n" + "{\"k\":\"blob\",\"ha\":\"ch128\",\"h\":\"00112233445566778899aabbccddeeff\"," + "\"tt\":\"etag\",\"tv\":\"x\",\"oc\":\"bogus\"}\n{\"n\":1}\n"; + EXPECT_THROW(decodeOutcomeLog(bad), DB::Exception); + /// A trailer count mismatch fails closed. + const String miscount = "{\"type\":\"cas_gc_outcomes\",\"v\":3}\n{\"n\":5}\n"; + EXPECT_THROW(decodeOutcomeLog(miscount), DB::Exception); +} + +TEST(CASGCOutcomesFormat, DigestWidthMismatchFailsClosedWithCorruptedData) +{ + /// `ch128` (CityHash128) digests are 16 bytes = 32 hex chars; here the "h" field is truncated + /// to 30 hex chars. Must surface as CORRUPTED_DATA (malformed serialized input), not + /// `fromHex`'s BAD_ARGUMENTS. + const String bad = "{\"type\":\"cas_gc_outcomes\",\"v\":3}\n" + "{\"k\":\"blob\",\"ha\":\"ch128\",\"h\":\"00112233445566778899aabbccddee\"," + "\"tt\":\"etag\",\"tv\":\"x\",\"oc\":\"deleted\"}\n{\"n\":1}\n"; + expectThrowsCode(DB::ErrorCodes::CORRUPTED_DATA, [&] { decodeOutcomeLog(bad); }); +} diff --git a/src/Disks/tests/gtest_cas_gc_rebuild.cpp b/src/Disks/tests/gtest_cas_gc_rebuild.cpp new file mode 100644 index 000000000000..aa896dbf68df --- /dev/null +++ b/src/Disks/tests/gtest_cas_gc_rebuild.cpp @@ -0,0 +1,681 @@ +#include + +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include "cas_test_helpers.h" + +using namespace DB::Cas; +using namespace DB::Cas::tests; + +namespace ProfileEvents +{ +extern const Event CASGCRefWalkPlansBuilt; +} + +namespace DB::ErrorCodes +{ +extern const int CORRUPTED_DATA; +} + +namespace +{ +const UInt128 kGc = hexToU128("00000000000000000000000000000001"); +ManifestRef ref(uint64_t seq, uint64_t inst) +{ + return ManifestRef{.writer_epoch = 1, .build_sequence = seq, .manifest_ordinal = static_cast(inst)}; +} +} + +/// (`CASGCBaselineGuard.FreshStateOverTrimmedJournalsFailsClosed` was removed with the snapshot+log ref +/// model. It asserted that a fresh GC over a MUTABLE shard journal whose folded history had been TRIMMED +/// must refuse, lest it fold only the surviving tails and mass-delete live data. Immutable `_log`/`_snap` +/// objects are never trimmed in place: a fresh GC always reconstructs the FULL ref state via the recovery +/// equation (newest snapshot + later log tail), so the "trimmed history" hazard cannot arise. The +/// vanished-`gc/state` disaster-recovery path is covered by `CASGCRebuild.RecoversLostStateAndConverges`, +/// and the corrupt-bookkeeping guard by `CASGCBaselineGuard.AbsentAdoptedSealFailsClosed`.) + +/// A genuinely fresh pool (journals start at version 1) passes the guard — rounds run as today. +TEST(CASGCBaselineGuard, GenuinelyFreshPoolIsUnaffected) +{ + auto backend = std::make_shared(); + auto store = openPoolForTest(backend); + const RootNamespace ns{"00/aa@cas@"}; + const ManifestRef r = ref(1, 0xAA); + writeBlobBody(*backend, store->layout(), DB::UInt128(1)); + writeManifestRaw(*backend, store->layout(), ns, r, {blobEntryFor("a", DB::UInt128(1))}); + publishCommittedTransition(*backend, store->layout(), ns, "tbl", std::nullopt, r); + Gc gc(store, kGc); + EXPECT_NO_THROW(gc.runRegularRound()); + EXPECT_NO_THROW(gc.runRegularRound()); +} + +/// (б) audit: snap_generation > 0 whose adopted fold seal is ABSENT must be CORRUPTED_DATA, +/// never silently treated as an empty baseline. +TEST(CASGCBaselineGuard, AbsentAdoptedSealFailsClosed) +{ + auto backend = std::make_shared(); + auto store = openPoolForTest(backend); + const RootNamespace ns{"00/aa@cas@"}; + const ManifestRef r = ref(1, 0xAA); + writeBlobBody(*backend, store->layout(), DB::UInt128(1)); + writeManifestRaw(*backend, store->layout(), ns, r, {blobEntryFor("a", DB::UInt128(1))}); + publishCommittedTransition(*backend, store->layout(), ns, "tbl", std::nullopt, r); + Gc gc(store, kGc); + gc.runRegularRound(); + + /// Corrupt (б): delete the adopted fold seal out from under a healthy gc/state. + const GcState st = decodeGcState(backend->get(store->layout().gcStateKey())->bytes); + ASSERT_GT(st.snap_generation, 0u); + const String seal_key = store->layout().foldSealKey(st.snap_generation, st.snap_attempt); + const HeadResult sh = backend->head(seal_key); + ASSERT_TRUE(sh.exists); + ASSERT_EQ(backend->deleteExact(seal_key, sh.token).kind, DeleteOutcome::Kind::Deleted); + + expectThrowsCode(DB::ErrorCodes::CORRUPTED_DATA, [&] { gc.runRegularRound(); }); +} + +/// (а): lose gc/state on a lived-in pool -> guard blocks rounds -> rebuild -> rounds converge: the +/// live blob intact, the round minted strictly above the last one seen, and the REBUILT baseline is a +/// working one — a ref dropped AFTER it is still reclaimed by ordinary rounds. +/// +/// A blob dropped BEFORE the rebuild is a different matter, and this test pins it: the rebuild derives +/// edges from owner state, so a blob no owner names gets no row at all, and a rebuild CONDEMNS NOTHING +/// (spec §7 — the condemnation that used to catch this case was the r5-finding-4 data-loss vector). +/// Such a blob is retained until register R4's build/upload registry can enumerate it safely. That is +/// the NAMED Stage-A residual, and it is asserted here rather than left to be discovered. +TEST(CASGCRebuild, RecoversLostStateAndConverges) +{ + auto backend = std::make_shared(); + /// gc_fold_max_defer_rounds=0: this test drives MANY consecutive rounds via runRoundsUntilAbsent + /// expecting every one to fold (Phase-4 Lever A would otherwise defer once the pool quiesces, + /// stalling the reclaim loop below the 8-round budget); force fold-every-round. + auto store = openPoolForTest(backend, /*gc_fold_max_defer_rounds*/ 0); + const RootNamespace ns{"00/aa@cas@"}; + const ManifestRef live_r = ref(1, 0xA1); + const ManifestRef dead_r = ref(2, 0xA2); + writeBlobBody(*backend, store->layout(), DB::UInt128(1)); + writeBlobBody(*backend, store->layout(), DB::UInt128(2)); + writeManifestRaw(*backend, store->layout(), ns, live_r, {blobEntryFor("a", DB::UInt128(1))}); + writeManifestRaw(*backend, store->layout(), ns, dead_r, {blobEntryFor("b", DB::UInt128(2))}); + publishCommittedTransition(*backend, store->layout(), ns, "tbl_live", std::nullopt, live_r); + publishCommittedTransition(*backend, store->layout(), ns, "tbl_dead", std::nullopt, dead_r); + Gc gc(store, kGc); + runRegularRoundReclaiming(gc); + dropRefTransition(*backend, store->layout(), ns, "tbl_dead", dead_r); + runRegularRoundReclaiming(gc); /// -1 folds; eager trim cuts the journal + store->renewWatermarkOnce(); /// renews the lease + build-watermark floor + + /// Capture the round reached before gc/state is destroyed (the rebuild must mint strictly above it). + const auto pre_rebuild_got = backend->get(store->layout().gcStateKey()); + ASSERT_TRUE(pre_rebuild_got.has_value()); + const uint64_t pre_rebuild_round = decodeGcState(pre_rebuild_got->bytes).round; + ASSERT_EQ(backend->deleteExact(store->layout().gcStateKey(), pre_rebuild_got->token).kind, DeleteOutcome::Kind::Deleted); + + Gc gc2(store, hexToU128("00000000000000000000000000000003")); + /// A fresh GC over the orphaned generation artifacts fails closed: re-folding from a fresh gc/state + /// collides with a leftover run object (divergent bytes) — the disaster is surfaced, never silently + /// double-applied. That first round also re-mints a superficially-healthy gc/state (snap_generation 0), + /// so the recovery is a DELIBERATE force-rebuild (the auto-rebuild correctly refuses to discard a + /// state that "looks healthy" without the operator's force). + expectThrowsCode(DB::ErrorCodes::CORRUPTED_DATA, [&] { runRegularRoundReclaiming(gc2); }); + + const RebuildReport rep = gc2.rebuildBaseline(/*force*/ true); + ASSERT_TRUE(rep.performed) << rep.refusal; + EXPECT_EQ(rep.committed_refs, 1u); + EXPECT_EQ(rep.namespaces, 1u); + + /// Round strictly above the fence/state/generation numbers seen so far. + EXPECT_GT(rep.round, pre_rebuild_round); + + /// The live blob is intact, and blob 2 — dropped BEFORE the rebuild, so invisible to a baseline + /// derived from owner state — is RETAINED. Retention, not loss: the named residual above. + for (int i = 0; i < 4; ++i) + { + runRegularRoundReclaiming(gc2); + store->renewWatermarkOnce(); + } + EXPECT_TRUE(backend->head(store->layout().blobKey(BlobRef{BlobHashAlgo::CityHash128, BlobDigest::fromU128(DB::UInt128(1))})).exists); + EXPECT_TRUE(backend->head(store->layout().blobKey(BlobRef{BlobHashAlgo::CityHash128, BlobDigest::fromU128(DB::UInt128(2))})).exists) + << "a rebuild condemns nothing, so a pre-rebuild drop is retained — never reclaimed by a " + "substitute pass, and never lost"; + + /// The rebuilt baseline is a WORKING one: a ref published over it and then dropped still folds to + /// zero and is reclaimed by ordinary rounds. Without this the test would prove only that the + /// pipeline stopped deleting. + const ManifestRef post_r = ref(3, 0xA3); + writeBlobBody(*backend, store->layout(), DB::UInt128(3)); + writeManifestRaw(*backend, store->layout(), ns, post_r, {blobEntryFor("c", DB::UInt128(3))}); + publishCommittedTransition(*backend, store->layout(), ns, "tbl_post", std::nullopt, post_r); + runRegularRoundReclaiming(gc2); + dropRefTransition(*backend, store->layout(), ns, "tbl_post", post_r); + EXPECT_TRUE(runRoundsUntilAbsent(store, gc2, *backend, store->layout(), DB::UInt128(3))) + << "the rebuilt baseline must still reclaim what it can actually see"; +} + +/// (б): a run object named by a healthy state is lost -> the regular round fails closed -> the +/// PLAIN rebuild (no FORCE) recovers, and rounds converge afterwards. +TEST(CASGCRebuild, RecoversLostGenerationArtifact) +{ + auto backend = std::make_shared(); + auto store = openPoolForTest(backend); + const RootNamespace ns{"00/aa@cas@"}; + const ManifestRef r = ref(1, 0xA1); + writeBlobBody(*backend, store->layout(), DB::UInt128(1)); + writeManifestRaw(*backend, store->layout(), ns, r, {blobEntryFor("a", DB::UInt128(1))}); + publishCommittedTransition(*backend, store->layout(), ns, "tbl", std::nullopt, r); + Gc gc(store, kGc); + gc.runRegularRound(); + + /// Lose one snapshot run object out from under the healthy state. + const GcState st = decodeGcState(backend->get(store->layout().gcStateKey())->bytes); + const auto seal = decodeFoldSeal(backend->get(store->layout().foldSealKey(st.snap_generation, st.snap_attempt))->bytes); + ASSERT_FALSE(seal.blob_target_runs.empty()); + const String run_key = seal.blob_target_runs.front().key; + const HeadResult rh = backend->head(run_key); + ASSERT_TRUE(rh.exists); + ASSERT_EQ(backend->deleteExact(run_key, rh.token).kind, DeleteOutcome::Kind::Deleted); + + /// A pure ref-carry round would not read the lost run; land a REAL delta so the fold's + /// three-cursor merge must stream the prior run — and fails closed on its absence. + const ManifestRef r2 = ref(2, 0xB7); + writeBlobBody(*backend, store->layout(), DB::UInt128(3)); + writeManifestRaw(*backend, store->layout(), ns, r2, {blobEntryFor("c", DB::UInt128(3))}); + publishCommittedTransition(*backend, store->layout(), ns, "tbl2", std::nullopt, r2); + expectThrowsCode(DB::ErrorCodes::CORRUPTED_DATA, [&] { gc.runRegularRound(); }); + + const RebuildReport rep = gc.rebuildBaseline(/*force*/ false); + ASSERT_TRUE(rep.performed) << rep.refusal; + EXPECT_NO_THROW(gc.runRegularRound()); + EXPECT_TRUE(backend->head(store->layout().blobKey(BlobRef{BlobHashAlgo::CityHash128, BlobDigest::fromU128(DB::UInt128(1))})).exists); +} + +/// FORCE: a healthy state refuses the plain rebuild; FORCE rebuilds; rounds run clean after. +TEST(CASGCRebuild, HealthyStateRequiresForce) +{ + auto backend = std::make_shared(); + auto store = openPoolForTest(backend); + const RootNamespace ns{"00/aa@cas@"}; + const ManifestRef r = ref(1, 0xA1); + writeBlobBody(*backend, store->layout(), DB::UInt128(1)); + writeManifestRaw(*backend, store->layout(), ns, r, {blobEntryFor("a", DB::UInt128(1))}); + publishCommittedTransition(*backend, store->layout(), ns, "tbl", std::nullopt, r); + Gc gc(store, kGc); + gc.runRegularRound(); + + const RebuildReport refused = gc.rebuildBaseline(/*force*/ false); + EXPECT_FALSE(refused.performed); + EXPECT_NE(refused.refusal.find("FORCE"), String::npos); + + backend->resetCounts(); + const uint64_t plans_before + = ProfileEvents::global_counters[ProfileEvents::CASGCRefWalkPlansBuilt].load(); + const RebuildReport forced = gc.rebuildBaseline(/*force*/ true); + ASSERT_TRUE(forced.performed) << forced.refusal; + EXPECT_EQ(ProfileEvents::global_counters[ProfileEvents::CASGCRefWalkPlansBuilt].load() - plans_before, 1u) + << "healthy FORCE REBUILD must share the same one-shot authoritative plan builder"; + EXPECT_EQ(backend->getCount(store->layout().refCatalogKey()), 2u) + << "healthy FORCE REBUILD may read the catalog for the conclusive drain and the one post-LIST cut only"; + EXPECT_EQ(backend->listCount(store->layout().namespaceStreamRootPrefix()), 1u); + EXPECT_NO_THROW(gc.runRegularRound()); +} + +/// The post-LIST catalog cut and its exact `_ckpt` are REBUILD's authority. A visible later log is +/// not admitted merely because a LIST would find it: it may be a durable-but-unfrontiered writer +/// attempt, and folding its missing manifest would turn a safe rebuild into a false refusal. +/// +/// This catches a regression back to the legacy `recoverRefTable` overload, whose full LIST-derived +/// replay consumes the second transaction and therefore refuses on `unfrontiered`'s missing body. +TEST(CASGCRebuild, FrozenCheckpointFrontierExcludesVisibleUnfrontieredTail) +{ + auto backend = std::make_shared(); + auto store = openPoolForTest(backend); + const Layout & layout = store->layout(); + const RootNamespace ns{"00/rebuild-frozen-frontier@cas@"}; + const UInt128 life_id{0xF001}; + const NamespaceLifeId life = NamespaceLifeId::fromCatalogEntry(ns, life_id); + CasRefCatalog::casAdmitEntry(*backend, layout, store->poolConfig().gc_shards, + CatalogEntry{.ns = ns, .state = NsState::Live, .incarnation = life_id}); + + const ManifestRef admitted = ref(1, 0xA1); + writeBlobBody(*backend, layout, DB::UInt128(1)); + writeManifestRaw(*backend, layout, ns, admitted, {blobEntryFor("a", DB::UInt128(1))}); + + std::vector first_ops{namespaceBirthOp()}; + const auto admitted_ops = publishCommittedOps("admitted", admitted); + first_ops.insert(first_ops.end(), admitted_ops.begin(), admitted_ops.end()); + fixture::writeRefLogRaw(*backend, layout, + RefLogTxn{.ns = ns.string(), .txn_id = RefTxnId{1, 1}, .ops = std::move(first_ops), .prev_epoch_seal = std::nullopt}); + + /// This record is real and listable, but the writer never published it through `_ckpt`. + const ManifestRef unfrontiered = ref(2, 0xB2); + fixture::writeRefLogRaw(*backend, layout, + RefLogTxn{.ns = ns.string(), .txn_id = RefTxnId{1, 2}, .ops = publishCommittedOps("unfrontiered", unfrontiered), + .prev_epoch_seal = std::nullopt}); + ASSERT_TRUE(backend->head(layout.refLogKey(life, RefTxnId{1, 2})).exists); + ASSERT_EQ(backend->putIfAbsent(layout.refCkptKey(life), encodeRefCkpt(RefCkpt{ + .life_epoch = 1, + .committed_through = RefTxnId{1, 1}, + .checkpoint_snapshot_id = std::nullopt, + .last_epoch_seal = std::nullopt})).outcome, PutOutcome::Done); + + Gc gc(store, kGc); + const RebuildReport report = gc.rebuildBaseline(/*force=*/false); + + ASSERT_TRUE(report.performed) << report.refusal; + EXPECT_EQ(report.committed_refs, 1u); +} + +/// A catalog-admitted life without its exact checkpoint has no bounded recovery frontier. REBUILD +/// must refuse rather than falling back to a list-derived history and publishing a baseline it cannot +/// prove complete. +TEST(CASGCRebuild, LiveCatalogLifeWithoutCheckpointFailsClosed) +{ + auto backend = std::make_shared(); + auto store = openPoolForTest(backend); + const Layout & layout = store->layout(); + const RootNamespace ns{"00/rebuild-missing-checkpoint@cas@"}; + const UInt128 life_id{0xF002}; + CasRefCatalog::casAdmitEntry(*backend, layout, store->poolConfig().gc_shards, + CatalogEntry{.ns = ns, .state = NsState::Live, .incarnation = life_id}); + + const ManifestRef admitted = ref(1, 0xA2); + writeBlobBody(*backend, layout, DB::UInt128(2)); + writeManifestRaw(*backend, layout, ns, admitted, {blobEntryFor("a", DB::UInt128(2))}); + std::vector ops{namespaceBirthOp()}; + const auto admitted_ops = publishCommittedOps("admitted", admitted); + ops.insert(ops.end(), admitted_ops.begin(), admitted_ops.end()); + fixture::writeRefLogRaw(*backend, layout, + RefLogTxn{.ns = ns.string(), .txn_id = RefTxnId{1, 1}, .ops = std::move(ops), .prev_epoch_seal = std::nullopt}); + + Gc gc(store, kGc); + expectThrowsCode(DB::ErrorCodes::CORRUPTED_DATA, [&] { (void)gc.rebuildBaseline(/*force=*/false); }); + + const auto state = backend->get(layout.gcStateKey()); + ASSERT_TRUE(state); + EXPECT_EQ(decodeGcState(state->bytes).snap_generation, 0u) + << "a rejected recovery must not adopt a new baseline"; +} + +/// A syntactically valid snapshot at an OLDER `EpochSeal` id must not let REBUILD synthesize a +/// baseline. The forged base differs from `last_epoch_seal`, so metadata equality cannot reject it; +/// REBUILD must use the retained same-id log witness. +TEST(CASGCRebuild, CheckpointSnapshotAtOlderEpochSealFailsClosed) +{ + auto backend = std::make_shared(); + auto store = openPoolForTest(backend); + const Layout & layout = store->layout(); + const RootNamespace ns{"00/rebuild-checkpoint-base-seal@cas@"}; + const UInt128 life_id{0xF003}; + CasRefCatalog::casAdmitEntry(*backend, layout, store->poolConfig().gc_shards, + CatalogEntry{.ns = ns, .state = NsState::Live, .incarnation = life_id}); + const NamespaceLifeId life = NamespaceLifeId::fromCatalogEntry(ns, life_id); + + const RefLogTxn birth{ + .ns = ns.string(), .txn_id = RefTxnId{1, 1}, .ops = {namespaceBirthOp()}, + .prev_epoch_seal = std::nullopt}; + fixture::writeRefLogRaw(*backend, layout, birth); + RefOp seal; + seal.kind = RefOpKind::EpochSeal; + const RefLogTxn seal_txn{ + .ns = ns.string(), .txn_id = RefTxnId{1, 2}, .ops = {seal}, + .prev_epoch_seal = std::nullopt}; + fixture::writeRefLogRaw(*backend, layout, seal_txn); + RefOp later_seal; + later_seal.kind = RefOpKind::EpochSeal; + fixture::writeRefLogRaw(*backend, layout, RefLogTxn{ + .ns = ns.string(), .txn_id = RefTxnId{2, 1}, .ops = {later_seal}, + .prev_epoch_seal = RefTxnId{1, 2}}); + RefTableState through_seal; + applyRefLogTxn(through_seal, birth); + applyRefLogTxn(through_seal, seal_txn); + writeRefSnapshotRaw(*backend, layout, snapshotOf(through_seal, ns.string())); + + ASSERT_EQ(backend->putIfAbsent(layout.refCkptKey(life), encodeRefCkpt(RefCkpt{ + .life_epoch = 1, + .committed_through = RefTxnId{2, 1}, + .checkpoint_snapshot_id = RefTxnId{1, 2}, + .last_epoch_seal = RefTxnId{2, 1}})).outcome, PutOutcome::Done); + + Gc gc(store, kGc); + expectThrowsCode(DB::ErrorCodes::CORRUPTED_DATA, [&] { (void)gc.rebuildBaseline(/*force=*/false); }); + + const auto state = backend->get(layout.gcStateKey()); + ASSERT_TRUE(state); + EXPECT_EQ(decodeGcState(state->bytes).snap_generation, 0u) + << "a rejected checkpoint base must not publish a REBUILD baseline"; +} + +TEST(CASGCRebuild, DamagedGenerationZeroStatePerformsNoCatalogDrainMutation) +{ + auto backend = std::make_shared(); + auto store = openPoolForTest(backend); + const Layout & layout = store->layout(); + const RootNamespace ns{"00/removing-without-parent@cas@"}; + const UInt128 life_id{91}; + CasRefCatalog::casAdmitEntry(*backend, layout, 1, CatalogEntry{ + .ns = ns, .state = NsState::Live, .incarnation = life_id}); + CasRefCatalog::casUpdate(*backend, layout, [](const RefCatalog & current) + { + RefCatalog next = current; + next.entries[0].state = NsState::Removing; + next.entries[0].removal_started_round = 1; + return next; + }); + ASSERT_EQ(backend->putIfAbsent(layout.refCkptKey(NamespaceLifeId::fromCatalogEntry(ns, life_id)), encodeRefCkpt(RefCkpt{ + .life_epoch = 1, + .committed_through = std::nullopt, + .checkpoint_snapshot_id = std::nullopt, + .last_epoch_seal = std::nullopt})).outcome, PutOutcome::Done); + const uint64_t catalog_cas_before = backend->casPutCount(layout.refCatalogKey()); + const uint64_t plans_before + = ProfileEvents::global_counters[ProfileEvents::CASGCRefWalkPlansBuilt].load(); + + Gc gc(store, kGc); + const RebuildReport report = gc.rebuildBaseline(/*force*/ false); + ASSERT_TRUE(report.performed) << report.refusal; + EXPECT_EQ(ProfileEvents::global_counters[ProfileEvents::CASGCRefWalkPlansBuilt].load() - plans_before, 1u); + EXPECT_EQ(backend->casPutCount(layout.refCatalogKey()), catalog_cas_before); + const CasRefCatalog::Snapshot catalog = CasRefCatalog::read(*backend, layout); + ASSERT_EQ(catalog.catalog.entries.size(), 1u); + EXPECT_EQ(catalog.catalog.entries[0].state, NsState::Removing); + EXPECT_EQ(catalog.catalog.entries[0].incarnation, life_id); +} + +/// Refusal: a committed owner with a MISSING manifest body is data loss — the rebuild refuses, +/// names the owner, and writes nothing (gc/state stays absent). +TEST(CASGCRebuild, MissingCommittedManifestRefuses) +{ + auto backend = std::make_shared(); + auto store = openPoolForTest(backend); + const RootNamespace ns{"00/aa@cas@"}; + const ManifestRef a = ref(1, 0xA1); + const ManifestRef b = ref(2, 0xB2); + writeBlobBody(*backend, store->layout(), DB::UInt128(1)); + writeManifestRaw(*backend, store->layout(), ns, a, {blobEntryFor("a", DB::UInt128(1))}); + writeManifestRaw(*backend, store->layout(), ns, b, {blobEntryFor("b", DB::UInt128(1))}); + publishCommittedTransition(*backend, store->layout(), ns, "tbl_a", std::nullopt, a); + publishCommittedTransition(*backend, store->layout(), ns, "tbl_b", std::nullopt, b); + Gc gc(store, kGc); + gc.runRegularRound(); + gc.runRegularRound(); /// trim + + /// Disaster pair: gc/state lost AND tbl_b's manifest body lost. + const HeadResult st = backend->head(store->layout().gcStateKey()); + backend->deleteExact(store->layout().gcStateKey(), st.token); + const String mkey = store->layout().manifestKey(ManifestId{ns, b}); + const HeadResult mh = backend->head(mkey); + ASSERT_TRUE(mh.exists); + backend->deleteExact(mkey, mh.token); + + Gc gc2(store, hexToU128("00000000000000000000000000000004")); + const RebuildReport rep = gc2.rebuildBaseline(/*force*/ false); + EXPECT_FALSE(rep.performed); + EXPECT_NE(rep.refusal.find("tbl_b"), String::npos) << rep.refusal; + /// The lease acquire minted a gen-0 bootstrap body (that is the acquire's contract, not the + /// rebuild's); the rebuild's own contract is that NO baseline was blessed by the refusal. + const auto post = backend->get(store->layout().gcStateKey()); + ASSERT_TRUE(post.has_value()); + const GcState post_state = decodeGcState(post->bytes); + EXPECT_EQ(post_state.snap_generation, 0u) << "a refused rebuild must not adopt a baseline"; +} + +/// A live precommit with a durable body contributes edges (no clamp); the rebuilt baseline +/// protects its blob from condemnation. +TEST(CASGCRebuild, LivePrecommitEdgesIncluded) +{ + auto backend = std::make_shared(); + auto store = openPoolForTest(backend); + const RootNamespace ns{"00/aa@cas@"}; + const ManifestRef pre = ref(7, 0xC1); + writeBlobBody(*backend, store->layout(), DB::UInt128(9)); + writeManifestRaw(*backend, store->layout(), ns, pre, {blobEntryFor("p", DB::UInt128(9))}); + addPrecommitTransition( + *backend, store->layout(), ns, /*build_id*/ DB::UInt128(0x77), "part_pre", std::nullopt, pre); + + /// No round before the rebuild: the journal still carries the create-precommit event (a round's + /// eager trim would cut it — the trimmed-but-live case is the next test). gc/state absent => + /// the plain rebuild is allowed. + Gc gc2(store, hexToU128("00000000000000000000000000000005")); + const RebuildReport rep = gc2.rebuildBaseline(/*force*/ false); + ASSERT_TRUE(rep.performed) << rep.refusal; + EXPECT_EQ(rep.live_precommits, 1u); + EXPECT_EQ(rep.clamped_shards, 0u); + + /// The precommit's blob is edge-protected: rounds never reclaim it while the precommit lives. + for (int i = 0; i < 4; ++i) + { + gc2.runRegularRound(); + store->renewWatermarkOnce(); + } + EXPECT_TRUE(backend->head(store->layout().blobKey(BlobRef{BlobHashAlgo::CityHash128, BlobDigest::fromU128(DB::UInt128(9))})).exists); +} + +/// O(budget) attempt iteration: a tiny edge budget forces multi-batch folding; the rebuilt +/// baseline still protects every committed blob (same convergence as the single-batch path). +TEST(CASGCRebuild, BatchedRebuildProtectsAllRefs) +{ + auto backend = std::make_shared(); + constexpr uint64_t gc_shards = 2; + auto store = Pool::open(backend, + PoolConfig{.pool_prefix = "p", .server_root_id = "test", .gc_shards = gc_shards}); + const RootNamespace ns{"00/aa@cas@"}; + constexpr uint64_t edge_budget = 2; + constexpr uint64_t refs_per_shard = edge_budget + 1; + std::vector blobs; + for (uint64_t shard = 0; shard < gc_shards; ++shard) + { + for (uint64_t i = 0; i < refs_per_shard; ++i) + { + const uint64_t sequence = blobs.size() + 1; + const UInt128 blob = (UInt128{shard + gc_shards * i} << 64) | UInt128{sequence}; + ASSERT_EQ(blobShard(legacyMetaTestRef(blob), gc_shards), shard); + blobs.push_back(blob); + writeBlobBody(*backend, store->layout(), blob); + const ManifestRef r = ref(sequence, 0xA0 + sequence); + writeManifestRaw(*backend, store->layout(), ns, r, {blobEntryFor("f", blob)}); + publishCommittedTransition( + *backend, store->layout(), ns, "tbl_" + std::to_string(sequence), std::nullopt, r); + } + } + Gc gc(store, kGc); + gc.runRegularRound(); + gc.runRegularRound(); + const HeadResult st = backend->head(store->layout().gcStateKey()); + backend->deleteExact(store->layout().gcStateKey(), st.token); + + Gc gc2(store, hexToU128("00000000000000000000000000000006")); + /// Every shard has `edge_budget + 1` live edges, so each independently crosses the flush budget; + /// a test with only a pool-wide excess would not prove multiple batches for every non-empty shard. + gc2.setRebuildEdgeBudgetForTest(edge_budget); + const RebuildReport rep = gc2.rebuildBaseline(/*force*/ false); + ASSERT_TRUE(rep.performed) << rep.refusal; + EXPECT_EQ(rep.committed_refs, blobs.size()); + + /// Multiple rebuild flushes still converge to one authoritative row domain: no more than one + /// canonical seq-0 `btr` per shard and exactly one `cnd` per shard. These are the cardinalities the + /// catalog admission reservation over-covers independently of catalog-entry count. + const GcState rebuilt_state = decodeGcState(backend->get(store->layout().gcStateKey())->bytes); + const CasFoldSeal rebuilt_seal = decodeFoldSeal( + backend->get(store->layout().foldSealKey( + rebuilt_state.snap_generation, rebuilt_state.snap_attempt))->bytes, + store->layout(), gc_shards); + ASSERT_EQ(rebuilt_seal.condemned_summary.size(), gc_shards); + bool run_seen[gc_shards] = {false, false}; + ASSERT_EQ(rebuilt_seal.blob_target_runs.size(), gc_shards); + for (const RunRef & run : rebuilt_seal.blob_target_runs) + { + ASSERT_LT(run.shard, gc_shards); + EXPECT_FALSE(run_seen[run.shard]); + run_seen[run.shard] = true; + const auto parsed = store->layout().parseBlobTargetRunKey(run.key); + ASSERT_TRUE(parsed.has_value()); + EXPECT_EQ(parsed->shard, run.shard); + EXPECT_EQ(parsed->generation, run.generation); + EXPECT_EQ(parsed->seq, 0u); + } + EXPECT_TRUE(run_seen[0]); + EXPECT_TRUE(run_seen[1]); + + for (int i = 0; i < 5; ++i) + { + gc2.runRegularRound(); + store->renewWatermarkOnce(); + } + for (const UInt128 blob : blobs) + EXPECT_TRUE(backend->head(store->layout().blobKey(legacyMetaTestRef(blob))).exists) + << "blob " << u128ToHex(blob); +} + +/// Trimmed-but-live (design delta 2): the precommit's journal evidence is gone (trim), the build +/// is NOT provably dead (a live build holds min_active down) — the unowned-alive sweep must +/// over-protect the manifest's edges. +TEST(CASGCRebuild, UnownedAliveManifestOverProtected) +{ + auto backend = std::make_shared(); + auto store = openPoolForTest(backend); + const RootNamespace ns{"00/aa@cas@"}; + + /// A LIVE build pins min_active at its build_seq, so higher build sequences are not provably dead. + auto live_build = store->beginPartWrite({}); + store->renewWatermarkOnce(); + + /// An unowned manifest from build_seq 7 (no journal events at all — the trimmed shape). + const ManifestRef pre = ref(7, 0xC1); + writeBlobBody(*backend, store->layout(), DB::UInt128(9)); + writeManifestRaw(*backend, store->layout(), ns, pre, {blobEntryFor("p", DB::UInt128(9))}); + /// The namespace must be discoverable: give it one committed ref on another manifest. + const ManifestRef anchor = ref(1, 0xA1); + writeBlobBody(*backend, store->layout(), DB::UInt128(1)); + writeManifestRaw(*backend, store->layout(), ns, anchor, {blobEntryFor("a", DB::UInt128(1))}); + publishCommittedTransition(*backend, store->layout(), ns, "tbl", std::nullopt, anchor); + + Gc gc(store, hexToU128("00000000000000000000000000000007")); + const RebuildReport rep = gc.rebuildBaseline(/*force*/ false); + ASSERT_TRUE(rep.performed) << rep.refusal; + EXPECT_EQ(rep.unowned_alive_manifests, 1u); + + /// Over-protected: rounds never reclaim the unowned-alive manifest's blob. + for (int i = 0; i < 4; ++i) + { + gc.runRegularRound(); + store->renewWatermarkOnce(); + } + EXPECT_TRUE(backend->head(store->layout().blobKey(BlobRef{BlobHashAlgo::CityHash128, BlobDigest::fromU128(DB::UInt128(9))})).exists); +} + +/// Task 4 (SYSTEM CAS GC REBUILD): a rebuild refuses when ANOTHER Gc instance holds +/// the lease, even under FORCE (FORCE bypasses the "healthy state" refusal, not the lease). Gc A's +/// runRegularRound freshly acquires/renews the lease; Gc B (a different gc_id) must see it as live. +TEST(CASGCRebuild, LeaseConflictRefuses) +{ + auto backend = std::make_shared(); + auto store = openPoolForTest(backend); + const RootNamespace ns{"00/aa@cas@"}; + const ManifestRef r = ref(1, 0xA1); + writeBlobBody(*backend, store->layout(), DB::UInt128(1)); + writeManifestRaw(*backend, store->layout(), ns, r, {blobEntryFor("a", DB::UInt128(1))}); + publishCommittedTransition(*backend, store->layout(), ns, "tbl", std::nullopt, r); + + Gc gc_a(store, kGc); + gc_a.runRegularRound(); /// Gc A acquires/renews the lease. + + Gc gc_b(store, hexToU128("00000000000000000000000000000002")); + const RebuildReport rep = gc_b.rebuildBaseline(/*force*/ true); + EXPECT_FALSE(rep.performed); + EXPECT_NE(rep.refusal.find("lease"), String::npos) << rep.refusal; + EXPECT_NE(rep.refusal.find("leader"), String::npos) << rep.refusal; +} + +/// A rebuild CONDEMNS NOTHING (spec §7). The zero-edge condemnation that used to live here — a +/// `blobs/` LIST whose every unreached body was condemned into the rebuilt run — was the +/// r5-finding-4 data-loss vector: the rebuild's own traversal is listing-driven, so a hidden +/// durable owner made this pass condemn acked data. Its removal, the NAMED residual it leaves +/// (manifest-less orphans are retained until register R4's build/upload registry), and the +/// hold-carry that had to survive the removal are all covered by +/// `gtest_cas_rebuild_condemn_nothing.cpp`. + +/// CLAMP SUPPRESSION regression (2026-07-03 night soak: 31 dangling blobs). A committed +1 for +/// blob X lands on a shard whose fold cursor is CLAMPED (behind a bodiless precommit — the fold +/// barrier), while X's only FOLDED edge (a committed ref on ANOTHER shard) drops. Without +/// suppression the pipeline condemns, graduates and DELETES X while its landed +1 sits unfolded +/// behind the clamp; the clamp release then folds the +1 into a DANGLING reference (the model's +/// SabotageSkipChangedShard, realized). With suppression a clamped pass neither graduates nor +/// redeletes; X survives until the clamp clears, after which the +1 folds and X is SPARED. +TEST(CASGCClampSuppression, LandedEdgeBehindClampNeverDeleted) +{ + auto backend = std::make_shared(); + std::vector seen; /// declared BEFORE the Pool so it outlives the background syncer's emits (ASan 2026-07-09) + auto store = openPoolForTest(backend); + const RootNamespace ns{"00/aa@cas@"}; + + /// Folded baseline: blob X referenced by committed tbl_a (manifest m1) on shard 1. + const ManifestRef m1 = ref(1, 0xA1); + writeBlobBody(*backend, store->layout(), DB::UInt128(1)); + writeManifestRaw(*backend, store->layout(), ns, m1, {blobEntryFor("a", DB::UInt128(1))}); + publishCommittedTransition(*backend, store->layout(), ns, "tbl_a", std::nullopt, m1, /*shard*/1); + Gc gc(store, kGc); + gc.runRegularRound(); + store->renewWatermarkOnce(); + + /// The CLAMP on shard 0: a bodiless precommit (fold barrier — its manifest body never written). + const ManifestRef pre = ref(9, 0xEE); + addPrecommitTransition(*backend, store->layout(), ns, /*build_id*/ DB::UInt128(0x99), "part_pre", + std::nullopt, pre, /*shard*/0); + + /// BEHIND the clamp: a committed +1 for X (manifest m2, tbl_b) on shard 0 — landed, unfoldable + /// until the barrier clears. Then tbl_a drops on shard 1 — X's only FOLDED edge disappears. + const ManifestRef m2 = ref(2, 0xB2); + writeManifestRaw(*backend, store->layout(), ns, m2, {blobEntryFor("b", DB::UInt128(1))}); + publishCommittedTransition(*backend, store->layout(), ns, "tbl_b", std::nullopt, m2, /*shard*/0); + dropRefTransition(*backend, store->layout(), ns, "tbl_a", m1, /*shard*/1); + + /// Rounds with acks current: X reaches folded in-degree 0 and is condemned, but every pass is + /// CLAMPED (the bodiless precommit persists), so nothing may graduate or delete. + /// Observability (2026-07-03): every clamp emits a gc_fold_clamp event with the reason. + store->setEventSink([&](const CasEvent & e){ if (e.type == CasEventType::GcFoldClamp) seen.push_back(e); }); + const String blob_key = store->layout().blobKey(BlobRef{BlobHashAlgo::CityHash128, BlobDigest::fromU128(DB::UInt128(1))}); + for (int i = 0; i < 6; ++i) + { + gc.runRegularRound(); + store->renewWatermarkOnce(); + ASSERT_TRUE(backend->head(blob_key).exists) + << "round " << i << ": X was deleted while its landed +1 sat unfolded behind the clamp"; + } + + ASSERT_FALSE(seen.empty()) << "each clamped pass must emit a gc_fold_clamp event"; + EXPECT_NE(seen.front().reason.find("fold barrier"), String::npos); + /// Snapshot+log ref model: the clamp is per-table (one ref-log stream per namespace, no ref shards), + /// so the event names the clamped `log` and the `resolved_through` cursor rather than a shard number. + EXPECT_TRUE(seen.front().detail.contains("log")) + << "clamp event must name the clamped log id"; + EXPECT_TRUE(seen.front().detail.contains("resolved_through")) + << "clamp event must name the cursor it resolved through"; + store->setEventSink(nullptr); + + /// Release the clamp: the precommit's body lands (the build finished staging). The next rounds + /// fold through the barrier, m2's +1 lands, and X is SPARED (entry dropped, blob intact). + writeManifestRaw(*backend, store->layout(), ns, pre, {blobEntryFor("p", DB::UInt128(1))}); + for (int i = 0; i < 4; ++i) + { + gc.runRegularRound(); + store->renewWatermarkOnce(); + } + EXPECT_TRUE(backend->head(blob_key).exists); + /// And the pipeline is unwedged: a genuinely-unreferenced blob still gets reclaimed. + const ManifestRef m3 = ref(3, 0xC3); + writeBlobBody(*backend, store->layout(), DB::UInt128(5)); + writeManifestRaw(*backend, store->layout(), ns, m3, {blobEntryFor("c", DB::UInt128(5))}); + publishCommittedTransition(*backend, store->layout(), ns, "tbl_c", std::nullopt, m3, /*shard*/1); + gc.runRegularRound(); + store->renewWatermarkOnce(); + dropRefTransition(*backend, store->layout(), ns, "tbl_c", m3, /*shard*/1); + EXPECT_TRUE(runRoundsUntilAbsent(store, gc, *backend, store->layout(), DB::UInt128(5))); +} diff --git a/src/Disks/tests/gtest_cas_gc_resume.cpp b/src/Disks/tests/gtest_cas_gc_resume.cpp new file mode 100644 index 000000000000..6c707bdd60cd --- /dev/null +++ b/src/Disks/tests/gtest_cas_gc_resume.cpp @@ -0,0 +1,184 @@ +#include + +#include +#include +#include +#include "cas_test_helpers.h" + +using namespace DB::Cas; +using namespace DB::Cas::tests; + +namespace DB::ErrorCodes +{ +extern const int ABORTED; +} + +namespace +{ +const UInt128 kGc = hexToU128("00000000000000000000000000000001"); +ManifestRef ref(uint64_t seq, uint64_t inst) +{ + return ManifestRef{.writer_epoch = 1, .build_sequence = seq, .manifest_ordinal = static_cast(inst)}; +} +bool blobExists(InMemoryBackend & b, const Layout & layout, const UInt128 & hash) +{ + return b.head(layout.blobKey(BlobRef{BlobHashAlgo::CityHash128, BlobDigest::fromU128(hash)})).exists; +} + +/// Whether the CURRENT retired list (any gc-shard) still holds an entry. +bool anyRetiredPending(const PoolPtr & s) +{ + /// Retired-in-snapshot (T4): condemned state rides the adopted fold seal's kCondemned rows, not a + /// separate retired list — reconstruct the in-flight set from the seal. + return anyCondemnedInSeal(s->backend(), s->layout()); +} + +/// Drive regular GC to a fixpoint over the ACK-FLOOR round (renew the store's mount ack after each round; +/// stay alive while any work counter is nonzero OR an in-flight retired entry remains). +size_t runGcToFixpoint(const PoolPtr & s, Gc & gc, size_t max_rounds = 64) +{ + size_t rounds = 0; + for (; rounds < max_rounds; ++rounds) + { + const RoundReport rep = runRegularRoundReclaiming(gc); + if (!rep.acquired_lease) + continue; + s->renewWatermarkOnce(); + const bool no_work = rep.candidates == 0 && rep.deleted == 0 && rep.absent == 0 + && rep.replaced == 0 && rep.spared == 0; + if (no_work && !anyRetiredPending(s)) + break; + } + return rounds; +} + +/// A backend that denies ONCE the SINGLE round-commit `gc/state` CAS — the casPut that advances +/// snap_generation (the one-pass round has exactly one such CAS; the lease-acquire CAS does not advance +/// snap_generation). A denied round leaves only never-adopted attempt-scoped debris (fold seal / retired +/// list under an attempt gc/state never adopted); a fresh-attempt rerun is idempotent. +class InterruptRoundCasBackend : public InMemoryBackend +{ +public: + explicit InterruptRoundCasBackend(String gc_state_key_) : gc_state_key(std::move(gc_state_key_)) {} + + CasResult casPut(const String & key, const String & bytes, const std::optional & expected, + const ObjectMeta & meta) override + { + if (arm_interrupt && key == gc_state_key) + { + const auto stored = get(key); + const uint64_t stored_gen = stored ? decodeGcState(stored->bytes).snap_generation : 0; + const uint64_t next_gen = decodeGcState(bytes).snap_generation; + if (next_gen > stored_gen) + { + arm_interrupt = false; /// one-shot: only depose the first round-commit CAS + throw DB::Exception(DB::ErrorCodes::ABORTED, + "test-injected: round-commit gc/state CAS denied (leader deposed mid-round)"); + } + } + return InMemoryBackend::casPut(key, bytes, expected, meta); + } + + bool arm_interrupt = false; + +private: + String gc_state_key; +}; +} + +/// (`CASGCRound.TrimDropsFoldedOwnerEvents` was removed with the snapshot+log ref model: it asserted GC +/// trims folded owner events out of a MUTABLE shard journal in place. Immutable `_log` objects are never +/// trimmed in place; the new-model equivalent -- ref-object cleanup deletes a covered `_log`/`_snap` key +/// once BOTH the durable cursor AND a checkpoint-named validated recovery triple cover it -- is exercised in +/// `gtest_cas_ref_gc.cpp` (`RefObjectCleanupRetainsCheckpointNamedTriple`).) + +/// A crashed round leaves only never-adopted attempt-scoped debris (there is no resume machinery in the +/// one-pass round). A fresh Gc simply re-runs the round under a fresh attempt and the deletion pipeline +/// converges idempotently — a delete that already landed replays onto NotFound. +TEST(CASGCReplay, FreshAttemptRerunCompletes) +{ + auto backend = std::make_shared(); + auto store = openPoolForTest(backend); + const RootNamespace ns{"00/aa@cas@"}; + const ManifestRef r = ref(1, 0xAA); + writeBlobBody(*backend, store->layout(), DB::UInt128(1)); + writeManifestRaw(*backend, store->layout(), ns, r, {blobEntryFor("a", DB::UInt128(1))}); + publishCommittedTransition(*backend, store->layout(), ns, "tbl", std::nullopt, r); + Gc gc(store, kGc); + runRegularRoundReclaiming(gc); + store->renewWatermarkOnce(); + dropRefTransition(*backend, store->layout(), ns, "tbl", r); + + // Drive the ack-floor pipeline to a fixpoint: the blob condemns, graduates, then is deleted. Every + // step is exact-token / write-once, so a replay is idempotent (the unit oracle for the crash-replay rule). + runGcToFixpoint(store, gc); + EXPECT_FALSE(blobExists(*backend, store->layout(), DB::UInt128(1))); + + // Re-running again is a clean no-op (idempotent): the blob stays gone, no throw. + EXPECT_NO_THROW(runRegularRoundReclaiming(gc)); + EXPECT_FALSE(blobExists(*backend, store->layout(), DB::UInt128(1))); +} + +/// Crash-replay idempotence: a round is deposed at its SINGLE round-commit CAS (lease lost mid-round), +/// leaving only never-adopted attempt-scoped debris (a fold seal + retired list under an attempt gc/state +/// never adopted). A SECOND leader (different id => a fresh lease.seq, hence a fresh attempt) re-runs the +/// round from scratch and completes: no wedge, no CORRUPTED_DATA, and the prior-round artifacts under the +/// old (unadopted) attempt are simply unreferenced. The pool drains to a fixpoint. +TEST(CASGCReplay, DeposedRoundRerunsUnderFreshAttempt) +{ + auto backend = std::make_shared(/*gc_state_key*/ "p/gc/state"); + auto store = openPoolForTest(backend); + ASSERT_EQ(store->layout().gcStateKey(), "p/gc/state"); // guard the injected key against layout drift + const RootNamespace ns{"00/aa@cas@"}; + const ManifestRef r = ref(1, 0xAA); + writeBlobBody(*backend, store->layout(), DB::UInt128(1)); + writeManifestRaw(*backend, store->layout(), ns, r, {blobEntryFor("a", DB::UInt128(1))}); + publishCommittedTransition(*backend, store->layout(), ns, "tbl", std::nullopt, r); + + // First leader folds + adopts the first (snap_generation, snap_attempt). + Gc gc1(store, hexToU128("00000000000000000000000000000001")); + runRegularRoundReclaiming(gc1); + store->renewWatermarkOnce(); + const auto after_fold = decodeGcState(backend->get(store->layout().gcStateKey())->bytes); + ASSERT_EQ(after_fold.snap_attempt, after_fold.lease.seq); + ASSERT_GT(after_fold.snap_generation, 0u); + + // Drop the only ref, then drive the round whose single commit CAS is DENIED (leader deposed mid-round). + // The round folded under a FRESH attempt and published its fold seal + retired list under that attempt, + // but the commit never adopted them — pure unadopted debris. gc/state is unchanged. + dropRefTransition(*backend, store->layout(), ns, "tbl", r); + backend->arm_interrupt = true; + EXPECT_THROW(runRegularRoundReclaiming(gc1), DB::Exception); + backend->arm_interrupt = false; + + const auto after_interrupt = decodeGcState(backend->get(store->layout().gcStateKey())->bytes); + EXPECT_EQ(after_interrupt.snap_generation, after_fold.snap_generation) + << "the denied round-commit CAS must NOT advance the adopted generation"; + EXPECT_EQ(after_interrupt.snap_attempt, after_fold.snap_attempt) + << "the denied round-commit CAS must NOT advance the adopted attempt"; + // The deposed round's fold seal is durable under its OWN (unadopted) attempt — unreferenced by gc/state. + const uint64_t deposed_attempt = after_fold.lease.seq + 1; // round 2 renewed the lease once + const uint64_t deposed_gen = after_fold.snap_generation + 1; + EXPECT_TRUE(backend->head(store->layout().foldSealKey(deposed_gen, deposed_attempt)).exists) + << "the deposed round's fold seal is durable under its own unadopted attempt (harmless debris)"; + + // A DIFFERENT leader takes over. The lease steal protocol observes the stalled lease twice before + // stealing: the first round only observes and defers; the second steals and re-runs the round from + // scratch under its own fresh attempt. + Gc gc2(store, hexToU128("00000000000000000000000000000002")); + EXPECT_NO_THROW(runRegularRoundReclaiming(gc2)); // observe-and-defer (lease not yet provably stalled) + store->renewWatermarkOnce(); + + // From here gc2 owns the lease; drive it to a fixpoint. It must drain the unreachable blob WITHOUT + // wedging on the deposed attempt's debris (attempt-scoping keeps that debris invisible). + EXPECT_NO_THROW(runGcToFixpoint(store, gc2)); + EXPECT_FALSE(blobExists(*backend, store->layout(), DB::UInt128(1))); + + const auto after_drain = decodeGcState(backend->get(store->layout().gcStateKey())->bytes); + EXPECT_GT(after_drain.snap_generation, after_fold.snap_generation) << "the round completed under gc2"; + EXPECT_NE(after_drain.snap_attempt, deposed_attempt) << "the drained round never adopted the deposed attempt"; + + // A further round is a clean no-op. + EXPECT_NO_THROW(runRegularRoundReclaiming(gc2)); + EXPECT_FALSE(blobExists(*backend, store->layout(), DB::UInt128(1))); +} diff --git a/src/Disks/tests/gtest_cas_gc_round.cpp b/src/Disks/tests/gtest_cas_gc_round.cpp new file mode 100644 index 000000000000..b9ed3278a95a --- /dev/null +++ b/src/Disks/tests/gtest_cas_gc_round.cpp @@ -0,0 +1,2000 @@ +#include + +#include + +#include +#include +#include +#include +#include +#include +#include "cas_test_helpers.h" + +namespace DB::ErrorCodes +{ +extern const int BAD_ARGUMENTS; +extern const int CORRUPTED_DATA; +extern const int ABORTED; +} + +namespace ProfileEvents +{ +extern const Event CASGCMetaOps; +extern const Event CASGCEnumerationPages; +extern const Event CASMountExclusivityViolation; +extern const Event CASGCRetiredSpared; +} + +using namespace DB::Cas; +using namespace DB::Cas::tests; + +/// ROUND-LEVEL end-to-end GC tests over the root-local part-manifest model (one-pass ack-floor round: +/// heartbeat floor -> fold with the three-cursor merge -> pre-CAS exact-token deletes -> single CAS -> trim). +/// +/// This file is the survivor of the old snap/cascade-based `gtest_cas_gc_round.cpp`. The per-STEP +/// behaviours it used to cover have moved to the dedicated GC-core suites and are intentionally NOT +/// re-tested here: +/// - fold edge dispatch (committed/precommit/promote/removal +/-1, 404 clamp/anomaly, fold barrier, +/// ref-mismatch fail-closed) -> gtest_cas_gc_fold.cpp +/// - condemn/graduate/delete + spare (manifest body deferred delete, publish racing the pass is spared, +/// unreferenced blob exact-token delete) -> gtest_cas_gc_ack_floor.cpp +/// - trim of folded owner events + idempotent crash replay -> gtest_cas_gc_resume.cpp +/// What remains here is what those step suites do NOT cover: the LEASE/leadership protocol (the round's +/// only stateful concurrency), the cursor-key codec, and the multi-round END-TO-END reclaim scenarios +/// driven to fixpoint (publish->drop->reclaim, multi-ref sharing, spare-on-recheck race, idempotent +/// fixpoint, split-brain duplicate-work-only). Every kept test keeps STRONG no-loss / no-dangle / no-leak +/// assertions. No test sleeps or reads a clock — "time" is the order of `runRegularRound` calls. + +namespace +{ + +const UInt128 kGc = hexToU128("00000000000000000000000000000001"); +const UInt128 kGcA = hexToU128("0000000000000000000000000000000a"); +const UInt128 kGcB = hexToU128("0000000000000000000000000000000b"); +const UInt128 kGcC = hexToU128("0000000000000000000000000000000c"); + +ManifestRef ref(uint64_t seq, uint64_t inst) +{ + return ManifestRef{.writer_epoch = 1, .build_sequence = seq, .manifest_ordinal = static_cast(inst)}; +} + +bool blobExists(InMemoryBackend & b, const Layout & layout, const UInt128 & hash) +{ + return b.head(layout.blobKey(BlobRef{BlobHashAlgo::CityHash128, BlobDigest::fromU128(hash)})).exists; +} + +bool manifestExists(InMemoryBackend & b, const Layout & layout, const ManifestId & id) +{ + return b.head(layout.manifestKey(id)).exists; +} + +PoolPtr openTestPool(std::shared_ptr & out_backend) +{ + out_backend = std::make_shared(); + return Pool::open(out_backend, PoolConfig{.pool_prefix = "p", .server_root_id = "test"}); +} + +PoolPtr openTestPoolWithConfig(std::shared_ptr & out_backend, PoolConfig config) +{ + out_backend = std::make_shared(); + return Pool::open(out_backend, std::move(config)); +} + +/// Fault decorator for triage #5's regression test (`CASGCRetention.LosingRoundNeverDestroysParentSealGeneration` +/// below): the `fail_at_call`-th `casPut` against `faulted_key` returns `Conflict` instead of committing — +/// deterministically and single-threaded reproducing "this round's own gc/state CAS lost the race to a +/// concurrent leader," which is the only condition under which the pre-CAS wholesale prune's choice of +/// `referenced_generations` is externally observable (a round whose own CAS SUCCEEDS reclaims the same +/// generation moments later via the existing, unrelated post-CAS hand-off delete regardless of this fix, +/// so faulting the CAS is required, not optional, to pin the production call site). A call count, not a +/// one-shot arm flag: `Gc::acquireOrRenewLease` issues its OWN earlier `casPut` on the very same gc/state +/// key to renew the lease BEFORE a round folds — that renewal must SUCCEED (so the round actually reaches +/// the fold/prune it's meant to exercise), and only the round's LATER, final round-commit `casPut` must +/// be the one that loses. `fail_at_call` is 1-indexed and lets the test target that specific call exactly, +/// computed from `calls_to_faulted_key` observed so far rather than hardcoded. +class GcStateCasFaultBackend : public InMemoryBackend +{ +public: + using Backend::get; + using Backend::getStream; + using Backend::putIfAbsent; + using Backend::putIfAbsentStream; + using Backend::putOverwrite; + using Backend::casPut; + + CasResult casPut(const String & key, const String & bytes, + const std::optional & expected, const ObjectMeta & meta) override + { + if (key == faulted_key) + { + ++calls_to_faulted_key; + if (fail_at_call != 0 && calls_to_faulted_key == fail_at_call) + return CasResult{CasOutcome::Conflict, {}}; + } + return InMemoryBackend::casPut(key, bytes, expected, meta); + } + + String faulted_key; + size_t calls_to_faulted_key = 0; + size_t fail_at_call = 0; /// 0 = never fault; else fault exactly the Nth casPut to `faulted_key` +}; + +GcState readState(InMemoryBackend & b, const Pool & s) +{ + const auto got = b.get(s.layout().gcStateKey()); + if (!got) + { + ADD_FAILURE() << "gc/state absent"; + return {}; + } + return decodeGcState(got->bytes); +} + +/// Whether ANY gc-shard's adopted-seal run still holds a `kCondemned` row (retired-in-snapshot T4: the +/// retired state rides the snapshot run, not a separate retired-list object) — the ack-floor deletion +/// pipeline is still in flight while this is true. +bool anyRetiredPending(InMemoryBackend & b, const Pool & s) +{ + return anyCondemnedInSeal(b, s.layout()); +} + +/// Drive a Gc to fixpoint over the round-paced retired-cursor pipeline: run rounds, renewing the store's +/// own heartbeat after each (`renewWatermarkOnce` — keeps the lease + build-watermark floor current; +/// graduation itself paces on rounds alone). A condemned blob traverses the multi-round condemn -> +/// graduate -> delete pipeline, so "fixpoint" is reached only when a round did NO work AND the current +/// retired list is empty (nothing still in flight). Returns the number of rounds that held the lease and +/// did work. Bounded so a non-converging core fails downstream assertions rather than hanging. +size_t driveToFixpoint(InMemoryBackend & backend, const PoolPtr & store, Gc & gc) +{ + size_t working_rounds = 0; + for (size_t r = 0; r < 64; ++r) + { + const RoundReport rep = runRegularRoundReclaiming(gc); + if (!rep.acquired_lease) + continue; + store->renewWatermarkOnce(); + const bool no_work = rep.candidates == 0 && rep.deleted == 0 && rep.absent == 0 + && rep.replaced == 0 && rep.spared == 0; + if (no_work && !anyRetiredPending(backend, *store)) + break; + if (!no_work) + ++working_rounds; + } + return working_rounds; +} + +/// A full key -> token snapshot of the backend, for the previewDeletes write-free invariant: any +/// put/casPut/overwrite mints a fresh token (or adds a key) and any delete removes one, so an unchanged +/// map across a call proves it performed NO writes. +std::map snapshotKeyTokens(InMemoryBackend & b) +{ + std::map out; + String cursor; + while (true) + { + const ListPage page = b.list("", cursor, 100000); + for (const ListedKey & k : page.keys) + out[k.key] = k.token ? k.token->value : String{}; + if (page.next_cursor.empty()) + break; + cursor = page.next_cursor; + } + return out; +} + +} + +/// ---- LEASE / leadership protocol (the round's only stateful concurrency) ---- +/// +/// The lease steal window is observation-based and deterministic (see CasGc.h): a contender becomes +/// steal-eligible when it observes the SAME (owner, seq) across two of its own consecutive round +/// attempts. The new model keeps gc/state {round, snap_generation, lease}, so these tests +/// are model-agnostic and were ported verbatim from the pre-redesign suite. + +TEST(CASGCLease, FreshPoolAcquiresAndRenews) +{ + std::shared_ptr b; + auto s = openTestPool(b); + Gc gc(s, kGc); + + EXPECT_TRUE(gc.runRegularRound().acquired_lease); + const GcState st1 = readState(*b, *s); + EXPECT_EQ(st1.lease.owner, kGc); + const uint64_t seq1 = st1.lease.seq; + EXPECT_GE(seq1, 1u); + + EXPECT_TRUE(gc.runRegularRound().acquired_lease); /// renew + const GcState st2 = readState(*b, *s); + EXPECT_EQ(st2.lease.owner, kGc); + EXPECT_GT(st2.lease.seq, seq1); /// seq strictly advanced +} + +TEST(CASGCLease, ContenderBacksOffWhileIncumbentRenews) +{ + std::shared_ptr b; + auto s = openTestPool(b); + Gc gc1(s, kGcA); + Gc gc2(s, kGcB); + + ASSERT_TRUE(gc1.runRegularRound().acquired_lease); + EXPECT_FALSE(gc2.runRegularRound().acquired_lease); /// first sight: record observation + ASSERT_TRUE(gc1.runRegularRound().acquired_lease); /// incumbent renews (seq advances) + EXPECT_FALSE(gc2.runRegularRound().acquired_lease); /// gc2 sees a NEW seq => incumbent alive + ASSERT_TRUE(gc1.runRegularRound().acquired_lease); + EXPECT_FALSE(gc2.runRegularRound().acquired_lease); /// alive again - never steals while renewing + EXPECT_EQ(readState(*b, *s).lease.owner, kGcA); +} + +TEST(CASGCLease, StealAfterObservedNonRenewalAdvancesLease) +{ + std::shared_ptr b; + auto s = openTestPool(b); + Gc gc1(s, kGcA); + Gc gc2(s, kGcB); + + ASSERT_TRUE(gc1.runRegularRound().acquired_lease); + const GcState st0 = readState(*b, *s); + EXPECT_FALSE(gc2.runRegularRound().acquired_lease); /// observation recorded; gc1 then DIES + EXPECT_TRUE(gc2.runRegularRound().acquired_lease); /// same (owner, seq) observed twice => steal + const GcState st = readState(*b, *s); + EXPECT_EQ(st.lease.owner, kGcB); + EXPECT_GT(st.lease.seq, st0.lease.seq); +} + +TEST(CASGCLease, HeartbeatBlocksFalseStealOfAliveLeader) +{ + /// B160: a slow-but-alive incumbent whose lease.seq is frozen for its (long) round must NOT be + /// stolen from, because its advisory heartbeat keeps advancing. + std::shared_ptr b; + auto s = openTestPool(b); + Gc gc1(s, kGcA); + Gc gc2(s, kGcB); + + ASSERT_TRUE(gc1.runRegularRound().acquired_lease); /// gc1 leads (seq frozen for its round) + EXPECT_FALSE(gc2.runRegularRound().acquired_lease); /// gc2 observes (gc/hb absent yet) + + Gc::pulseHeartbeat(*s, kGcA); /// gc1 mid-round but heartbeating (hb 0->1) + EXPECT_FALSE(gc2.runRegularRound().acquired_lease); /// hb advanced => alive => NO steal + Gc::pulseHeartbeat(*s, kGcA); /// hb 1->2 + EXPECT_FALSE(gc2.runRegularRound().acquired_lease); /// still no steal while heartbeating + EXPECT_EQ(readState(*b, *s).lease.owner, kGcA); /// gc1 still owns the lease +} + +/// A7-HIGH-fix follow-up (residual timing window): an allow_steal=false observation of a foreign +/// incumbent (the manual `SYSTEM ... GC` path) must NOT arm the frozen-tuple comparison that the loop's +/// very next (allow_steal=true) call uses to decide whether to steal. Without this, a manual round's +/// observation at time t, immediately followed by an unluckily-timed scheduled tick at t+epsilon (no +/// real chance for a live incumbent to heartbeat in between), would see the SAME (owner, seq, hb) twice +/// and steal a LIVE leader — exactly the hazard the allow_steal gate alone does not close, since it only +/// stops the MANUAL call itself from executing the steal CAS, not from contaminating the shared `Gc` +/// instance's observation state that the next allow_steal=true call reads. +TEST(CASGCLease, ManualObservationNeverArmsTheLoopsStealDecision) +{ + std::shared_ptr b; + auto s = openTestPool(b); + Gc gc1(s, kGcA); + Gc gc2(s, kGcB); /// plays the scheduler's ONE shared Gc, observed by both manual and loop calls + + ASSERT_TRUE(gc1.runRegularRound().acquired_lease); /// gc1 leads; never renews, never heartbeats + + /// Manual observation "at t": allow_steal=false. Would normally be obs #1, but must NOT record it. + EXPECT_FALSE(gc2.runRegularRound({}, /*allow_steal=*/false).acquired_lease); + + /// Loop-path call "immediately after" (allow_steal=true, the default): with the fix, gc2's + /// last_seen_* is UNTOUCHED by the manual call above, so this is still effectively obs #1 (first + /// sight) => must NOT steal. Pre-fix (manual observations armed the state), this would see the same + /// frozen tuple as "twice observed" and steal gc1's still-live lease. + EXPECT_FALSE(gc2.runRegularRound().acquired_lease); + EXPECT_EQ(readState(*b, *s).lease.owner, kGcA); /// gc1 keeps the lease + + /// The loop still recovers a genuinely dead incumbent across its OWN two spaced observations: the + /// call above was the loop's real obs #1 (now armed, since allow_steal=true); this one is obs #2 of + /// the same still-frozen tuple => steal-eligible => steals. Recovery is delayed, not disabled. + EXPECT_TRUE(gc2.runRegularRound().acquired_lease); + EXPECT_EQ(readState(*b, *s).lease.owner, kGcB); +} + +/// P3-B1 (2026-07-11 mid-switch soak wedge): CasGcScheduler used to flip its `i_am_leader` flag (which +/// gates the heartbeat thread's pulses) only AFTER `runRegularRound` RETURNS, while the lease is +/// acquired INSIDE the round, before the (potentially long) fold. A brand-new leader's FIRST round +/// therefore ran the whole fold with no heartbeat cover: a follower observing the frozen (owner, seq) +/// across two of its own ticks steals deterministically once that first round outlasts ~2 ticks - +/// mutual-steal livelock under a slow fold. The fix moves the "start heartbeating" action to the +/// INSTANT the lease is acquired (`Gc::runRegularRound`'s new `on_lease_acquired` hook), fired before +/// the fold begins. These two tests pin the protocol both ways at the `Gc` level (the scheduler itself +/// only wires `i_am_leader.store(true, ...)` + one `pulseHeartbeat` call into that hook - a thread-pacing +/// wire-up not practically unit-testable without sleeps; verified by code review + the full gtest run). + +TEST(CASGCLease, WithoutAcquireTimePulseFirstRoundStealsDeterministically) +{ + /// RED-before-the-fix scenario: gc1 acquires the lease and (simulating a long first round) never + /// pulses `gc/hb` and never renews - exactly what happened before `on_lease_acquired` existed. + /// gc2's SECOND observation of the same frozen (owner, seq, hb) steals, per the documented protocol. + std::shared_ptr b; + auto s = openTestPool(b); + Gc gc1(s, kGcA); + Gc gc2(s, kGcB); + + ASSERT_TRUE(gc1.runRegularRound().acquired_lease); /// gc1 becomes leader; NO pulse follows (the bug) + EXPECT_FALSE(gc2.runRegularRound().acquired_lease); /// obs #1: records (owner=A, seq, hb=absent) + EXPECT_TRUE(gc2.runRegularRound().acquired_lease); /// obs #2: unchanged => steal-eligible => STEALS + EXPECT_EQ(readState(*b, *s).lease.owner, kGcB); +} + +TEST(CASGCLease, AcquireTimePulseProtectsNewLeadersFirstRound) +{ + /// GREEN-after-the-fix scenario: with the fix, `i_am_leader` flips true and the FIRST pulse fires + /// the instant gc1 acquires the lease - before B's first observation even happens - and the + /// (separately-threaded, out of scope here) `heartbeatLoop` keeps landing further pulses on its own + /// cadence for as long as `i_am_leader` stays true, i.e. for the whole duration of gc1's first round. + /// The net effect proven here is the one that matters: SOME pulse lands between B's two + /// observations (not just before both, and not only after both), so B's second observation sees hb + /// advanced relative to its first and backs off instead of stealing - exactly what never happened + /// pre-fix, when `i_am_leader` (and hence every pulse) was gated on the round having already returned. + std::shared_ptr b; + auto s = openTestPool(b); + Gc gc1(s, kGcA); + Gc gc2(s, kGcB); + + ASSERT_TRUE(gc1.runRegularRound().acquired_lease); /// gc1 becomes leader (still mid-fold, seq frozen) + EXPECT_FALSE(gc2.runRegularRound().acquired_lease); /// obs #1: records (owner=A, seq, hb=absent) + Gc::pulseHeartbeat(*s, kGcA); /// a heartbeatLoop tick lands mid-round (hb 0->1) + EXPECT_FALSE(gc2.runRegularRound().acquired_lease); /// obs #2: hb advanced since obs #1 => alive => NO steal + + EXPECT_EQ(readState(*b, *s).lease.owner, kGcA); /// gc1 keeps the lease through its whole first round +} + +TEST(CASGCLease, StaleOwnerHeartbeatDoesNotEnableFalseSteal) +{ + /// A deposed leader's heartbeat thread keeps pulsing until its next round notices the lost lease + /// (`i_am_leader` is only reset there), and `pulseHeartbeat` stamps `owner = self` while a losing + /// CAS write silently vanishes — so a zombie old leader can keep `gc/hb.owner` pointing at ITSELF + /// even while the live new leader is pulsing too. The liveness gate must therefore treat ANY + /// movement of the observed (owner, hb_seq) pair between a follower's two ticks as "someone is + /// alive": comparing hb_seq is only meaningful against the SAME remembered hb owner. The old + /// predicate compared `hb.owner` with the LEASE owner instead, so a zombie-owned hb read as + /// "not the leader's heartbeat" on both ticks and a live, pulsing new leader got its lease stolen. + std::shared_ptr b; + auto s = openTestPool(b); + Gc gc1(s, kGcA); + Gc gc2(s, kGcB); + Gc gc3(s, kGcC); + + /// gc1 leads, beats, then dies mid-round; gc2 legitimately steals the lease. + ASSERT_TRUE(gc1.runRegularRound().acquired_lease); + Gc::pulseHeartbeat(*s, kGcA); + EXPECT_FALSE(gc2.runRegularRound().acquired_lease); /// obs #1 of gc1's frozen tuple + EXPECT_TRUE(gc2.runRegularRound().acquired_lease); /// obs #2: frozen lease + frozen hb => steal + ASSERT_EQ(readState(*b, *s).lease.owner, kGcB); + + /// gc2 is now mid-long-round (lease tuple frozen) and PULSING — but gc1's zombie heartbeat + /// thread interleaves after every gc2 pulse, so the follower gc3 only ever OBSERVES gc1-owned + /// heartbeats. The pair keeps moving, which is proof of life. + Gc::pulseHeartbeat(*s, kGcB); + Gc::pulseHeartbeat(*s, kGcA); /// zombie masks gc2's pulse + EXPECT_FALSE(gc3.runRegularRound().acquired_lease); /// obs #1: records (hb owner=A, seq) + Gc::pulseHeartbeat(*s, kGcB); + Gc::pulseHeartbeat(*s, kGcA); /// zombie masks again + EXPECT_FALSE(gc3.runRegularRound().acquired_lease); /// obs #2: hb pair MOVED => alive => NO steal + EXPECT_EQ(readState(*b, *s).lease.owner, kGcB); /// the live leader keeps its lease + + /// Liveness is preserved: once everything genuinely freezes (gc2 dead, zombie gone), the next + /// tick completes the window — obs #2 above already re-armed on the now-frozen (lease, hb) pair. + EXPECT_TRUE(gc3.runRegularRound().acquired_lease); /// still frozen a full tick later => steal + EXPECT_EQ(readState(*b, *s).lease.owner, kGcC); +} + +TEST(CASGCLease, FailoverStealOnceHeartbeatStops) +{ + /// B160: once the incumbent stops heartbeating (it died), a follower observing the now-frozen + /// heartbeat steals — automatic failover is preserved. + std::shared_ptr b; + auto s = openTestPool(b); + Gc gc1(s, kGcA); + Gc gc2(s, kGcB); + + ASSERT_TRUE(gc1.runRegularRound().acquired_lease); + EXPECT_FALSE(gc2.runRegularRound().acquired_lease); /// obs #1 + Gc::pulseHeartbeat(*s, kGcA); /// one last pulse (hb 0->1) + EXPECT_FALSE(gc2.runRegularRound().acquired_lease); /// hb advanced => no steal; records hb=1 + /// gc1 now DEAD: no renew, no further pulse. hb stays at 1 == gc2's last observation. + EXPECT_TRUE(gc2.runRegularRound().acquired_lease); /// hb frozen + seq frozen => STEAL + EXPECT_EQ(readState(*b, *s).lease.owner, kGcB); +} + +TEST(CASGCLease, DeadIncumbentThenRevivedIncumbentWinsRace) +{ + /// A stalled incumbent that revives and renews BEFORE the contender's second look resets the + /// contender's window: gc2's second observation sees a NEW seq => NOT steal-eligible => backs off. + std::shared_ptr b; + auto s = openTestPool(b); + Gc gc1(s, kGcA); + Gc gc2(s, kGcB); + + ASSERT_TRUE(gc1.runRegularRound().acquired_lease); + EXPECT_FALSE(gc2.runRegularRound().acquired_lease); /// obs #1 + ASSERT_TRUE(gc1.runRegularRound().acquired_lease); /// gc1 revives and renews + EXPECT_FALSE(gc2.runRegularRound().acquired_lease); /// new seq seen => window resets + EXPECT_EQ(readState(*b, *s).lease.owner, kGcA); +} + +TEST(CASGCLease, ConcurrentStealLosesCas) +{ + /// The CAS-race horn: gc2 is steal-eligible and goes for the CAS, but gc/state moved under it + /// (injected one-shot conflict). It must back off (never acquired=true off a lost CAS) and the + /// owner on storage must be unperturbed. The injected conflict left the object unchanged, so gc2's + /// NEXT round is steal-eligible again and succeeds. + std::shared_ptr b; + auto s = openTestPool(b); + Gc gc1(s, kGcA); + Gc gc2(s, kGcB); + + ASSERT_TRUE(gc1.runRegularRound().acquired_lease); + const GcState st0 = readState(*b, *s); + EXPECT_FALSE(gc2.runRegularRound().acquired_lease); /// obs #1; gc1 stalls now + b->failNextCasPut(s->layout().gcStateKey()); /// inject: gc2's steal CAS conflicts + EXPECT_FALSE(gc2.runRegularRound().acquired_lease); /// steal attempt loses the CAS => back off + const GcState st1 = readState(*b, *s); + EXPECT_EQ(st1.lease.owner, kGcA); /// unchanged + EXPECT_EQ(st1.lease.seq, st0.lease.seq); /// nothing clobbered + EXPECT_TRUE(gc2.runRegularRound().acquired_lease); /// still steal-eligible => succeeds now + EXPECT_EQ(readState(*b, *s).lease.owner, kGcB); +} + +TEST(CASGCLease, CreateConflictReReadsWithinTheBound) +{ + /// The create-Conflict branch: a fresh pool where the create-if-absent CAS conflicts (one-shot). + /// The contender re-reads and falls through within its bounded (2) CAS attempts — the re-read still + /// finds the key absent, so the second attempt creates and acquires. + std::shared_ptr b; + auto s = openTestPool(b); + Gc gc(s, hexToU128("0000000000000000000000000000000c")); + + b->failNextCasPut(s->layout().gcStateKey()); + EXPECT_TRUE(gc.runRegularRound().acquired_lease); + const GcState st = readState(*b, *s); + EXPECT_EQ(st.lease.owner, hexToU128("0000000000000000000000000000000c")); + EXPECT_EQ(st.lease.seq, 1u); +} + +TEST(CASGCLease, CtorFailsClosedOnBadArguments) +{ + /// Guards: a null store and gc_id == 0 (reserved for "lease never held") are caller bugs. + std::shared_ptr b; + auto s = openTestPool(b); + expectThrowsCode(DB::ErrorCodes::BAD_ARGUMENTS, [&] { Gc(nullptr, kGc); }); + expectThrowsCode(DB::ErrorCodes::BAD_ARGUMENTS, [&] { Gc(s, DB::UInt128(0)); }); +} + +TEST(CASGCLease, IncumbentRenewConflictRetriesOnceAndAcquires) +{ + /// The incumbent's own renew CAS conflicts (one-shot). Re-read sees our own ownership => the renew + /// is retried ONCE within the bounded (2) CAS attempts => acquired. Never acquired=true without a + /// Committed CAS — storage must carry the seq the SECOND (committed) attempt wrote. + std::shared_ptr b; + auto s = openTestPool(b); + Gc gc(s, hexToU128("0000000000000000000000000000000d")); + + ASSERT_TRUE(gc.runRegularRound().acquired_lease); /// create: seq 1 + b->failNextCasPut(s->layout().gcStateKey()); /// inject: the renew CAS conflicts + EXPECT_TRUE(gc.runRegularRound().acquired_lease); /// re-read (still us) => retried once + const GcState st = readState(*b, *s); + EXPECT_EQ(st.lease.owner, hexToU128("0000000000000000000000000000000d")); + EXPECT_EQ(st.lease.seq, 2u); /// the committed retry's seq +} + +TEST(CASGCLease, VanishedStateAfterObservationFailsClosed) +{ + /// gc/state is never legally deleted - absent AFTER a recorded observation proves an out-of-model + /// deletion. Recreating a default state would reset round/cursors; the lease protocol + /// must fail closed (CORRUPTED_DATA) instead. + std::shared_ptr b; + auto s = openTestPool(b); + Gc gc1(s, kGcA); + Gc gc2(s, kGcB); + + ASSERT_TRUE(gc1.runRegularRound().acquired_lease); + EXPECT_FALSE(gc2.runRegularRound().acquired_lease); /// gc2 records an observation + + const auto head = b->head(s->layout().gcStateKey()); /// out-of-model wipe (raw delete) + ASSERT_TRUE(head.exists); + ASSERT_EQ(b->deleteExact(s->layout().gcStateKey(), head.token).kind, DeleteOutcome::Kind::Deleted); + + expectThrowsCode(DB::ErrorCodes::CORRUPTED_DATA, [&] { gc2.runRegularRound(); }); +} + +/// ---- END-TO-END round scenarios driven to fixpoint (the headline value of this file) ---- + +/// publish -> drop -> GC-to-fixpoint reclaim: a committed ref names a blob; after the ref is dropped, +/// the round protocol collects the blob (exact-token delete) AND the owner-removed manifest body, and a +/// further round is a clean no-op. The strongest no-loss/no-leak oracle: while the ref is live the blob +/// is NEVER touched; once dropped, BOTH the blob and the manifest are gone and nothing dangles. +TEST(CASGCRound, PublishDropReclaimsBlobAndManifestToFixpoint) +{ + auto backend = std::make_shared(); + auto store = openPoolForTest(backend); + const RootNamespace ns{"00/aa@cas@"}; + const ManifestRef r = ref(1, 0xAA); + const ManifestId id{ns, r}; + + writeBlobBody(*backend, store->layout(), DB::UInt128(1)); + writeManifestRaw(*backend, store->layout(), ns, r, {blobEntryFor("a", DB::UInt128(1))}); + publishCommittedTransition(*backend, store->layout(), ns, "tbl", std::nullopt, r); + + Gc gc(store, kGc); + driveToFixpoint(*backend, store, gc); + /// While live: the blob's in-degree is 1 and NOTHING is collected (no-loss). + EXPECT_EQ(inDegreeOf(*backend, store->layout(), DB::UInt128(1)), 1); + EXPECT_TRUE(blobExists(*backend, store->layout(), DB::UInt128(1))); + EXPECT_TRUE(manifestExists(*backend, store->layout(), id)); + + dropRefTransition(*backend, store->layout(), ns, "tbl", r); + driveToFixpoint(*backend, store, gc); + /// After drop + fixpoint: the blob's only edge is gone, the blob is collected, the owner-removed + /// manifest body is collected, and the in-degree generation reflects zero (no-leak / no-dangle). + EXPECT_EQ(inDegreeOf(*backend, store->layout(), DB::UInt128(1)), 0); + EXPECT_FALSE(blobExists(*backend, store->layout(), DB::UInt128(1))); + EXPECT_FALSE(manifestExists(*backend, store->layout(), id)); + + /// Idempotent: re-running to fixpoint changes nothing and never throws. + EXPECT_NO_THROW(driveToFixpoint(*backend, store, gc)); + EXPECT_FALSE(blobExists(*backend, store->layout(), DB::UInt128(1))); +} + +/// retired-in-snapshot T4: after a round condemns one blob, the ADOPTED fold seal's per-shard +/// condemned_summary reflects it (condemned_total == 1, pending_total == 0) — distilled zero-I/O from the +/// kCondemned rows the fold sealed into the snapshot run. +TEST(CASGCRound, CondemnRoundSealSummaryCountsCondemned) +{ + auto backend = std::make_shared(); + auto store = openPoolForTest(backend); + const RootNamespace ns{"00/aa@cas@"}; + const ManifestRef r = ref(1, 0xAA); + writeBlobBody(*backend, store->layout(), DB::UInt128(1)); + writeManifestRaw(*backend, store->layout(), ns, r, {blobEntryFor("a", DB::UInt128(1))}); + publishCommittedTransition(*backend, store->layout(), ns, "tbl", std::nullopt, r); + + Gc gc(store, kGc); + gc.runRegularRound(); /// folds the +1 + store->renewWatermarkOnce(); + dropRefTransition(*backend, store->layout(), ns, "tbl", r); /// the -1 condemns it + + /// Drive rounds until the blob shows up condemned in the adopted-seal run; capture that seal. + bool condemned = false; + CasFoldSeal seal; + for (int i = 0; i < 6 && !condemned; ++i) + { + gc.runRegularRound(); + store->renewWatermarkOnce(); + const GcState st = readState(*backend, *store); + seal = decodeFoldSeal( + backend->get(store->layout().foldSealKey(st.snap_generation, st.snap_attempt))->bytes); + for (const RetiredEntry & e : currentRetiredSet(*backend, store->layout(), /*shard*/0)) + if (e.ref == DB::Cas::BlobRef{DB::Cas::BlobHashAlgo::CityHash128, DB::Cas::BlobDigest::fromU128(DB::UInt128(1))}) + condemned = true; + } + ASSERT_TRUE(condemned) << "blob never condemned into the snapshot run"; + ASSERT_TRUE(seal.condemned_summary.contains(0)) << "seal summary must be total over gc_shards"; + EXPECT_EQ(seal.condemned_summary.at(0).condemned_total, 1u); + EXPECT_EQ(seal.condemned_summary.at(0).pending_total, 0u) + << "a freshly condemned entry is not yet delete_pending"; + EXPECT_LT(seal.condemned_summary.at(0).oldest_nonpending_condemn_round, + std::numeric_limits::max()) + << "a non-pending condemned entry records its condemn round"; +} + +/// retired-in-snapshot T5: `previewDeletes` streams the adopted seal's `kCondemned` rows and reports each +/// with the STORED condemn-time token — `awaiting_graduation` while newly condemned, then `delete_pending` +/// once graduated, and NOTHING once the exact-token redelete has removed the blob. The preview performs no +/// HEAD on the condemned rows (the token is durable in-run) and is WRITE-FREE throughout (spec §5 req 1). +TEST(CASGCRound, PreviewReportsCondemnedRowsAndIsWriteFree) +{ + auto backend = std::make_shared(); + auto store = openPoolForTest(backend); + const RootNamespace ns{"00/aa@cas@"}; + const ManifestRef r = ref(1, 0xAA); + const UInt128 blob = DB::UInt128(1); + writeBlobBody(*backend, store->layout(), blob); + writeManifestRaw(*backend, store->layout(), ns, r, {blobEntryFor("a", blob)}); + publishCommittedTransition(*backend, store->layout(), ns, "tbl", std::nullopt, r); + + Gc gc(store, kGc); + runRegularRoundReclaiming(gc); /// round 1: folds the +1; blob referenced + EXPECT_TRUE(gc.previewDeletes().empty()) << "a live-referenced blob is never previewed for deletion"; + + dropRefTransition(*backend, store->layout(), ns, "tbl", r); + runRegularRoundReclaiming(gc); /// condemning round: -1 => in-degree 0 => kCondemned row (not pending) + + /// Write-free contract: a full key->token snapshot must be identical across the previewDeletes call. + const auto before = snapshotKeyTokens(*backend); + const std::vector awaiting = gc.previewDeletes(); + const auto after = snapshotKeyTokens(*backend); + EXPECT_EQ(before, after) << "previewDeletes must perform NO writes (put/casPut/overwrite/delete)"; + + ASSERT_EQ(awaiting.size(), 1u) << "exactly the one condemned blob is previewed"; + EXPECT_EQ(awaiting[0].ref, (DB::Cas::BlobRef{DB::Cas::BlobHashAlgo::CityHash128, DB::Cas::BlobDigest::fromU128(blob)})); + EXPECT_EQ(awaiting[0].key, store->layout().blobKey(DB::Cas::BlobRef{DB::Cas::BlobHashAlgo::CityHash128, DB::Cas::BlobDigest::fromU128(blob)})); + EXPECT_EQ(awaiting[0].reason, "awaiting_graduation"); + EXPECT_FALSE(awaiting[0].token.value.empty()) << "must carry the stored condemn-time token"; + EXPECT_GT(awaiting[0].condemn_round, 0u) << "must carry the stored condemn round"; + + runRegularRoundReclaiming(gc); /// graduation round: entry becomes delete_pending (blob still present) + const std::vector pending = gc.previewDeletes(); + ASSERT_EQ(pending.size(), 1u); + EXPECT_EQ(pending[0].ref, (DB::Cas::BlobRef{DB::Cas::BlobHashAlgo::CityHash128, DB::Cas::BlobDigest::fromU128(blob)})); + EXPECT_EQ(pending[0].reason, "delete_pending"); + EXPECT_FALSE(pending[0].token.value.empty()); + + runRegularRoundReclaiming(gc); /// redelete round: exact-token delete; entry dropped; blob gone + EXPECT_FALSE(blobExists(*backend, store->layout(), blob)); + EXPECT_TRUE(gc.previewDeletes().empty()) << "nothing to preview once the blob is redeleted"; +} + +/// A fully idle fold pure-carries every shard's authoritative rows verbatim. The parent is first made +/// non-vacuous with one live blob in each of two shards; the forced no-delta successor must preserve +/// both `btr` rows and the total `cnd` domain byte-for-byte. +TEST(CASGCRound, PureCarryRoundPreservesAuthoritativeShardRowsVerbatim) +{ + auto backend = std::make_shared(); + auto store = Pool::open(backend, + PoolConfig{.pool_prefix = "p", .server_root_id = "test", + .gc_shards = 2, .gc_fold_max_defer_rounds = 0}); + const RootNamespace ns{"00/aa@cas@"}; + + Gc gc(store, kGc); + + const UInt128 shard0_blob{1}; + const UInt128 shard1_blob = (UInt128{1} << 64) | UInt128{1}; + ASSERT_EQ(blobShard(legacyMetaTestRef(shard0_blob), 2), 0u); + ASSERT_EQ(blobShard(legacyMetaTestRef(shard1_blob), 2), 1u); + + const ManifestRef r0 = ref(1, 0xAA); + const ManifestRef r1 = ref(2, 0xBB); + writeBlobBody(*backend, store->layout(), shard0_blob); + writeBlobBody(*backend, store->layout(), shard1_blob); + writeManifestRaw(*backend, store->layout(), ns, r0, {blobEntryFor("a", shard0_blob)}); + writeManifestRaw(*backend, store->layout(), ns, r1, {blobEntryFor("b", shard1_blob)}); + publishCommittedTransition(*backend, store->layout(), ns, "tbl0", std::nullopt, r0); + publishCommittedTransition(*backend, store->layout(), ns, "tbl1", std::nullopt, r1); + gc.runRegularRound(); + const GcState st1 = readState(*backend, *store); + const CasFoldSeal seal1 = decodeFoldSeal( + backend->get(store->layout().foldSealKey(st1.snap_generation, st1.snap_attempt))->bytes, + store->layout(), /*gc_shards=*/2); + + /// No state changes after the parent. The zero defer bound forces an actual fold rather than DEFER, + /// so every shard takes the production pure-carry path. + gc.runRegularRound(); + const GcState st2 = readState(*backend, *store); + const CasFoldSeal seal2 = decodeFoldSeal( + backend->get(store->layout().foldSealKey(st2.snap_generation, st2.snap_attempt))->bytes, + store->layout(), /*gc_shards=*/2); + + /// TOTALITY: both seals carry a summary entry for every gc-shard. + ASSERT_EQ(seal1.condemned_summary.size(), 2u); + ASSERT_EQ(seal2.condemned_summary.size(), 2u); + EXPECT_TRUE(seal1.condemned_summary.contains(0) && seal1.condemned_summary.contains(1)); + EXPECT_TRUE(seal2.condemned_summary.contains(0) && seal2.condemned_summary.contains(1)); + + /// Capacity reserves one widest `btr` row per shard. Pin the production pure-carry seal to the + /// authoritative grammar that makes that bound sufficient: at most one in-range canonical seq-0 + /// run per shard, beside exactly one `cnd` row for every shard. + bool run_seen[2] = {false, false}; + ASSERT_EQ(seal1.blob_target_runs.size(), 2u); + ASSERT_EQ(seal2.blob_target_runs.size(), 2u); + for (const RunRef & run : seal2.blob_target_runs) + { + ASSERT_LT(run.shard, 2u); + EXPECT_FALSE(run_seen[run.shard]); + run_seen[run.shard] = true; + const auto parsed = store->layout().parseBlobTargetRunKey(run.key); + ASSERT_TRUE(parsed.has_value()); + EXPECT_EQ(parsed->shard, run.shard); + EXPECT_EQ(parsed->generation, run.generation); + EXPECT_EQ(parsed->seq, 0u); + } + EXPECT_TRUE(run_seen[0]); + EXPECT_TRUE(run_seen[1]); + for (uint64_t shard = 0; shard < 2; ++shard) + { + const auto parent_run = std::find_if( + seal1.blob_target_runs.begin(), seal1.blob_target_runs.end(), + [shard](const RunRef & run) { return run.shard == shard; }); + const auto carried_run = std::find_if( + seal2.blob_target_runs.begin(), seal2.blob_target_runs.end(), + [shard](const RunRef & run) { return run.shard == shard; }); + ASSERT_NE(parent_run, seal1.blob_target_runs.end()); + ASSERT_NE(carried_run, seal2.blob_target_runs.end()); + EXPECT_EQ(*carried_run, *parent_run); + } + + /// VERBATIM CARRY: nothing was ever condemned, so every shard's summary is the zero entry, carried + /// unchanged from parent to child across the fully idle fold. + for (uint64_t shard = 0; shard < 2; ++shard) + { + EXPECT_EQ(seal2.condemned_summary.at(shard), seal1.condemned_summary.at(shard)) + << "shard " << shard << " summary must be carried verbatim from the parent seal"; + EXPECT_EQ(seal2.condemned_summary.at(shard).condemned_total, 0u); + } +} + +/// Attempt-scoping (B2): a fold seal planted under a NON-adopted attempt at the adopted generation +/// must be INVISIBLE to every reader. A deposed leader writes its fold seal under its own (unadopted) +/// `lease.seq`; that artifact lives at `foldSealKey(snap_generation, snap_attempt + k)` and no decision +/// path may resolve it. `previewDeletes` reads the in-degree generation strictly at the adopted +/// `(snap_generation, snap_attempt)`, so the decoy must not change its output and must not throw. This +/// is the implementation-level complement to the TLA+ `INV_ONLY_ADOPTED_VIEWABLE` gate. +TEST(CASGCRound, NonAdoptedAttemptSealIgnored) +{ + auto backend = std::make_shared(); + auto store = openPoolForTest(backend); + const RootNamespace ns{"00/aa@cas@"}; + const ManifestRef r = ref(1, 0xAA); + writeBlobBody(*backend, store->layout(), DB::UInt128(1)); + writeManifestRaw(*backend, store->layout(), ns, r, {blobEntryFor("a", DB::UInt128(1))}); + publishCommittedTransition(*backend, store->layout(), ns, "tbl", std::nullopt, r); + + Gc gc(store, kGc); + gc.runRegularRound(); + const GcState st = readState(*backend, *store); + ASSERT_GT(st.snap_generation, 0u); + + /// Control preview BEFORE the decoy (previewDeletes is write-free, so the result is deterministic). + const auto control = gc.previewDeletes(); + + /// Plant a decoy fold seal under a DIFFERENT attempt at the SAME generation (a deposed leader's + /// unadopted artifact). It must be invisible to the adopted-attempt readers. + backend->putIfAbsent(store->layout().foldSealKey(st.snap_generation, st.snap_attempt + 999), + "decoy-seal-bytes"); + + /// No reader resolves the non-adopted attempt: no throw, and the preview is unchanged by the decoy. + std::vector after; + EXPECT_NO_THROW(after = gc.previewDeletes()); + EXPECT_EQ(after.size(), control.size()) + << "a non-adopted attempt's fold seal must not influence previewDeletes"; + + /// A further full round must still proceed without throwing and without the decoy wedging it. + EXPECT_NO_THROW(gc.runRegularRound()); +} + +/// B11: the round summary must count manifest-body (tree) deletes separately from blob deletes. A drop +/// that reclaims one manifest body must report manifests_deleted >= 1 in the RoundReport of the +/// reclaiming round, while blobs and manifests remain separately countable. +TEST(CASGCRound, RoundSummaryCountsManifestBodyDeletes) +{ + auto backend = std::make_shared(); + auto store = openPoolForTest(backend); + const RootNamespace ns{"00/aa@cas@"}; + const ManifestRef r = ref(1, 0xCC); + const ManifestId id{ns, r}; + + writeBlobBody(*backend, store->layout(), DB::UInt128(3)); + writeManifestRaw(*backend, store->layout(), ns, r, {blobEntryFor("a", DB::UInt128(3))}); + publishCommittedTransition(*backend, store->layout(), ns, "tbl", std::nullopt, r); + + Gc gc(store, kGc); + driveToFixpoint(*backend, store, gc); /// fold the publish; no delete yet + + dropRefTransition(*backend, store->layout(), ns, "tbl", r); + + /// §0 introspection: both counters are captured BEFORE the condemn+delete pipeline below, which + /// drives the round's meta pool (condemn/spare/delete) and its own orphan-sweep cursor pass. + const auto meta_ops_before = ProfileEvents::global_counters[ProfileEvents::CASGCMetaOps].load(); + const auto pages_before = ProfileEvents::global_counters[ProfileEvents::CASGCEnumerationPages].load(); + + /// Ack-floor drift: the owner-removed manifest body is deleted in the CONDEMNING round (post-CAS, + /// after its -1 is adopted), while the blob's exact-token delete happens a few rounds later once the + /// ack floor graduates its retired entry. So the two deletes fall in DIFFERENT reports now — accumulate + /// across the pipeline (renewing the ack each round so the floor advances) and assert both were counted. + uint64_t total_manifests_deleted = 0; + uint64_t total_blob_deleted = 0; + for (size_t i = 0; i < 64; ++i) + { + const RoundReport rep = runRegularRoundReclaiming(gc); + if (!rep.acquired_lease) + continue; + store->renewWatermarkOnce(); + total_manifests_deleted += rep.manifests_deleted; + total_blob_deleted += rep.deleted; + if (total_manifests_deleted > 0 && total_blob_deleted > 0) + break; + } + + /// B11: the manifest-body delete must be counted separately from the blob delete. + EXPECT_GE(total_manifests_deleted, 1u) + << "round summary must count the owner-removed manifest body delete (B11 — manifests_deleted)"; + /// Blobs and manifests are separately countable: the blob delete (deleted >= 1) is independent. + EXPECT_GE(total_blob_deleted, 1u) + << "the blob exact-token delete must still be counted in deleted"; + /// The manifest body is gone and the blob is gone — no-leak / no-dangle. + EXPECT_FALSE(manifestExists(*backend, store->layout(), id)); + EXPECT_FALSE(blobExists(*backend, store->layout(), DB::UInt128(3))); + + /// §0 introspection: the exact-token blob delete above scheduled at least one per-hash freshness-meta + /// op on the round's bounded meta pool, and every round ran its own orphan-manifest-sweep cursor pass + /// (default `manifest_sweep_list_budget_keys` is nonzero), fetching at least one LIST page directly. + EXPECT_GE(ProfileEvents::global_counters[ProfileEvents::CASGCMetaOps].load() - meta_ops_before, 1); + EXPECT_GE(ProfileEvents::global_counters[ProfileEvents::CASGCEnumerationPages].load() - pages_before, 1); +} + +/// Manifest-body cleanup (post-CAS `manifest_deletes` phase) has no cap: the ref-log intake cursor that +/// discovers each owner-removed manifest commits in the SAME round's CAS that produces `mf_cleanup`, so an +/// entry a cap declined would never be re-derived by this pipeline -- a bounded burst would become a +/// permanent leak. Five tables' manifests are all owner-removed in one fold; the round must delete all +/// five bodies in the same round, with nothing left un-deleted. +TEST(CASGCRound, ManifestCleanupDrainsEntireRoundWithNoSkips) +{ + auto backend = std::make_shared(); + auto store = Pool::open(backend, PoolConfig{ + .pool_prefix = "p", .server_root_id = "gc-runner", + .gc_fold_max_defer_rounds = 0}); + const RootNamespace ns{"00/aa@cas@"}; + constexpr int kManifests = 5; + + std::vector ids; + for (int i = 0; i < kManifests; ++i) + { + const ManifestRef r = ref(1, 0xD0 + i); + writeBlobBody(*backend, store->layout(), DB::UInt128(100 + i)); + writeManifestRaw(*backend, store->layout(), ns, r, {blobEntryFor("a", DB::UInt128(100 + i))}); + publishCommittedTransition(*backend, store->layout(), ns, "tbl" + std::to_string(i), std::nullopt, r); + ids.push_back(ManifestId{ns, r}); + } + + Gc gc(store, kGc); + driveToFixpoint(*backend, store, gc); /// fold all five +1s; no manifest owner-removed yet + for (const ManifestId & id : ids) + ASSERT_TRUE(manifestExists(*backend, store->layout(), id)); + + /// Remove all five owners in one window; the next fold's intake sees all five `-1` edges together. + for (int i = 0; i < kManifests; ++i) + dropRefTransition(*backend, store->layout(), ns, "tbl" + std::to_string(i), ref(1, 0xD0 + i)); + + const RoundReport rep = runRegularRoundReclaiming(gc); + ASSERT_TRUE(rep.acquired_lease); + EXPECT_EQ(rep.manifests_deleted, kManifests) + << "manifest_deletes must drain the entire mf_cleanup vector in one round, not cap it"; + + for (const ManifestId & id : ids) + EXPECT_FALSE(manifestExists(*backend, store->layout(), id)) + << "an unbudgeted cleanup must leave nothing surviving the round it was discovered in"; +} + +/// §0 introspection follow-up: `CASGCEnumerationPages` must not depend on the orphan-manifest sweep alone +/// (`manifest_sweep_list_budget_keys` zeroed below disables that pass entirely). The mandatory per-round +/// `cas/ns/stream/` scan -- `listRefPrefix`'s pre-fold DEFER signal and the fold share its result -- +/// must still land at least one page each round. +TEST(CASGCRound, EnumerationPagesCountedEvenWithSweepBudgetZeroed) +{ + std::shared_ptr backend; + PoolConfig config; + config.pool_prefix = "p"; + config.server_root_id = "test"; + config.manifest_sweep_list_budget_keys = 0; /// disables the orphan sweep's own LIST entirely + config.gc_fold_max_defer_rounds = 0; /// force fold-every-round (Phase-4 Lever A would defer) + auto store = openTestPoolWithConfig(backend, config); + + Gc gc(store, kGc); + const auto pages_before = ProfileEvents::global_counters[ProfileEvents::CASGCEnumerationPages].load(); + ASSERT_TRUE(gc.runRegularRound().acquired_lease); + EXPECT_GE(ProfileEvents::global_counters[ProfileEvents::CASGCEnumerationPages].load() - pages_before, 1) + << "the round's own cas/ns/stream/ enumeration must count pages independent of " + "the orphan sweep"; +} + +/// M1 REGRESSION (cross-round fold cursor must survive independent of trim): a folded-but-untrimmed owner +/// event must NOT be re-folded by the next round. With eager trim the folded event is removed so the bug +/// (sealedCursorOf resetting to 0 after a completed round, because snap_generation points at the COMPLETION +/// generation whose fold_seal lives at the parent) is MASKED. Disable trim to expose it: the publish event +/// stays in the journal, so a round that re-folds from 0 emits a SECOND +1 and drives the blob's in-degree +/// to 2 (a silent over-pin => leak). The fix carries the per-shard fold cursor into the completion seal so +/// the next round recovers the exact cursor. Asserts in-degree stays EXACTLY 1 across >= 2 re-folds. +TEST(CASGCRound, FoldCursorSurvivesAcrossRoundsWithoutTrim) +{ + auto backend = std::make_shared(); + auto store = openPoolForTest(backend); + const RootNamespace ns{"00/aa@cas@"}; + const ManifestRef r = ref(1, 0xAA); + + writeBlobBody(*backend, store->layout(), DB::UInt128(1)); + writeManifestRaw(*backend, store->layout(), ns, r, {blobEntryFor("a", DB::UInt128(1))}); + publishCommittedTransition(*backend, store->layout(), ns, "tbl", std::nullopt, r); + + Gc gc(store, kGc); + gc.setTrimEnabledForTest(false); /// keep the folded publish event in the journal across rounds + + /// Round 1 folds the +1 edge: in-degree 1, blob pinned. + ASSERT_TRUE(gc.runRegularRound().acquired_lease); + EXPECT_EQ(inDegreeOf(*backend, store->layout(), DB::UInt128(1)), 1); + + /// Several more rounds. The publish event is STILL in the journal (trim off). Each round must + /// recover the exact sealed cursor and re-fold NOTHING for this shard — in-degree stays exactly 1. + for (int round = 0; round < 3; ++round) + { + ASSERT_TRUE(gc.runRegularRound().acquired_lease); + EXPECT_EQ(inDegreeOf(*backend, store->layout(), DB::UInt128(1)), 1) + << "round " << round << ": a folded-but-untrimmed event was re-folded => blob in-degree double-counted"; + } + + /// No-loss throughout: the live blob and its owner body are intact. + EXPECT_TRUE(blobExists(*backend, store->layout(), DB::UInt128(1))); + EXPECT_TRUE(manifestExists(*backend, store->layout(), ManifestId{ns, r})); +} + +/// Multi-ref sharing (INV-NO-LOSS): one blob referenced by TWO committed refs is spared until BOTH +/// drop. Dropping the first ref must NOT collect the blob (the second ref still pins it); only after the +/// second ref drops does the round collect it. +TEST(CASGCRound, SharedBlobSparedUntilBothRefsDrop) +{ + auto backend = std::make_shared(); + auto store = openPoolForTest(backend); + const RootNamespace ns{"00/aa@cas@"}; + const ManifestRef r1 = ref(1, 0xA1); + const ManifestRef r2 = ref(2, 0xA2); + + writeBlobBody(*backend, store->layout(), DB::UInt128(1)); + /// Two distinct manifests at two distinct refs, BOTH referencing the same shared blob 1. + writeManifestRaw(*backend, store->layout(), ns, r1, {blobEntryFor("a", DB::UInt128(1))}); + writeManifestRaw(*backend, store->layout(), ns, r2, {blobEntryFor("a", DB::UInt128(1))}); + publishCommittedTransition(*backend, store->layout(), ns, "tbl1", std::nullopt, r1); + publishCommittedTransition(*backend, store->layout(), ns, "tbl2", std::nullopt, r2); + + Gc gc(store, kGc); + driveToFixpoint(*backend, store, gc); + EXPECT_EQ(inDegreeOf(*backend, store->layout(), DB::UInt128(1)), 2); /// two source edges + EXPECT_TRUE(blobExists(*backend, store->layout(), DB::UInt128(1))); + + /// Drop the FIRST ref: in-degree falls to 1, blob STILL pinned by tbl2 (spared). + dropRefTransition(*backend, store->layout(), ns, "tbl1", r1); + driveToFixpoint(*backend, store, gc); + EXPECT_EQ(inDegreeOf(*backend, store->layout(), DB::UInt128(1)), 1); + EXPECT_TRUE(blobExists(*backend, store->layout(), DB::UInt128(1))) + << "shared blob must survive while a second ref still names it"; + + /// Drop the SECOND ref: in-degree reaches 0, blob is finally collected. + dropRefTransition(*backend, store->layout(), ns, "tbl2", r2); + driveToFixpoint(*backend, store, gc); + EXPECT_EQ(inDegreeOf(*backend, store->layout(), DB::UInt128(1)), 0); + EXPECT_FALSE(blobExists(*backend, store->layout(), DB::UInt128(1))); +} + +/// `gc_round_outcome_entry_budget` bounds only the `GcOutcomes` AUDIT row per spared decision, never the +/// decision itself. Five blobs are condemned (owner dropped, indegree 0, durable retired rows), then -- +/// BEFORE graduation -- a fresh manifest re-references all five (the `CASThreeCursorMerge.RecoverySpares` +/// shape, scaled up and driven through the real round path): recovery wins unconditionally for every one +/// of them. `CASGCRetiredSpared` and blob survival prove all five decisions happened regardless of the +/// budget, but with a budget of 2, only 2 of the 5 get a row in the round's `GcOutcomes` log, so +/// `RoundReport::spared` (tallied from that log) reports 2, not 5. +TEST(CASGCRound, OutcomeEntryBudgetCapsSparedLogRowsWithoutRecondemning) +{ + auto backend = std::make_shared(); + auto store = Pool::open(backend, PoolConfig{ + .pool_prefix = "p", .server_root_id = "test", + .gc_round_outcome_entry_budget = 2, + .gc_fold_max_defer_rounds = 0}); + const RootNamespace ns{"00/aa@cas@"}; + const ManifestRef r1 = ref(1, 0xC1); + const ManifestRef r2 = ref(2, 0xC2); + constexpr int kBlobs = 5; + + std::vector entries; + for (int i = 0; i < kBlobs; ++i) + { + writeBlobBody(*backend, store->layout(), DB::UInt128(i + 1)); + entries.push_back(blobEntryFor("p" + std::to_string(i), DB::UInt128(i + 1))); + } + writeManifestRaw(*backend, store->layout(), ns, r1, entries); + publishCommittedTransition(*backend, store->layout(), ns, "tbl", std::nullopt, r1); + + Gc gc(store, kGc); + driveToFixpoint(*backend, store, gc); + for (int i = 0; i < kBlobs; ++i) + ASSERT_EQ(inDegreeOf(*backend, store->layout(), DB::UInt128(i + 1)), 1); + + /// Drop the only ref: one round later all five blobs are condemned (indegree 0, durable retired rows). + dropRefTransition(*backend, store->layout(), ns, "tbl", r1); + ASSERT_TRUE(runRegularRoundReclaiming(gc).acquired_lease); + for (int i = 0; i < kBlobs; ++i) + ASSERT_EQ(inDegreeOf(*backend, store->layout(), DB::UInt128(i + 1)), 0); + store->renewWatermarkOnce(); + + /// BEFORE graduation, a fresh manifest re-references all five: the NEXT fold recomputes indegree 1 for + /// every one of them -- recovery wins over graduation for every entry, unconditionally. + writeManifestRaw(*backend, store->layout(), ns, r2, entries); + publishCommittedTransition(*backend, store->layout(), ns, "tbl2", std::nullopt, r2); + + const auto spared_events_before = ProfileEvents::global_counters[ProfileEvents::CASGCRetiredSpared].load(); + const RoundReport rep = runRegularRoundReclaiming(gc); + ASSERT_TRUE(rep.acquired_lease); + const uint64_t total_spared_reported = rep.spared; + + /// THE LOAD-BEARING ASSERTION: the audit log under-reports (capped at the budget) while every + /// decision it under-reports still happened correctly. + EXPECT_EQ(total_spared_reported, 2u) + << "GcOutcomes rows must be capped at gc_round_outcome_entry_budget, not one per spared entry"; + EXPECT_EQ(ProfileEvents::global_counters[ProfileEvents::CASGCRetiredSpared].load() - spared_events_before, kBlobs) + << "every spared decision must still happen even when its audit row is capped"; + for (int i = 0; i < kBlobs; ++i) + { + EXPECT_TRUE(blobExists(*backend, store->layout(), DB::UInt128(i + 1))) + << "blob " << i << " must survive -- the cap must never re-condemn a spared entry"; + EXPECT_EQ(inDegreeOf(*backend, store->layout(), DB::UInt128(i + 1)), 1); + } +} + +/// Spare-during-the-pass, multi-blob discrimination: a drop condemns two blobs; in the SAME window +/// between rounds (before the next pass folds), one of them is re-referenced under a fresh ref. The pass +/// folds the racing publish and SPARES the re-referenced blob (recovery wins in the pass merge, dropping +/// its retired entry), while the genuinely-unreferenced blob proceeds through the condemn -> graduate -> +/// delete pipeline. The discriminating assertion: at fixpoint, one is spared (kept) and the other gone. +TEST(CASGCRound, RepublishDuringFenceWindowSparesOnlyReReferencedBlob) +{ + auto backend = std::make_shared(); + auto store = openPoolForTest(backend); + const RootNamespace ns{"00/aa@cas@"}; + const ManifestRef r1 = ref(1, 0xB1); + const ManifestRef r2 = ref(2, 0xB2); + + writeBlobBody(*backend, store->layout(), DB::UInt128(1)); /// kept (will be re-referenced) + writeBlobBody(*backend, store->layout(), DB::UInt128(2)); /// genuinely dropped + writeManifestRaw(*backend, store->layout(), ns, r1, + {blobEntryFor("a", DB::UInt128(1)), blobEntryFor("b", DB::UInt128(2))}); + publishCommittedTransition(*backend, store->layout(), ns, "tbl", std::nullopt, r1); + + Gc gc(store, kGc); + driveToFixpoint(*backend, store, gc); + EXPECT_EQ(inDegreeOf(*backend, store->layout(), DB::UInt128(1)), 1); + EXPECT_EQ(inDegreeOf(*backend, store->layout(), DB::UInt128(2)), 1); + + /// Repoint the ref from r1 to r2 between rounds: ONE event {old=committed(r1), new=committed(r2)}. + /// The -1 (r1's body: blobs 1 AND 2) and +1 (r2's body: blob 1 only) net to in-degree 1 for blob 1 + /// (re-referenced => SPARED in the pass merge) and 0 for blob 2 (genuinely unreferenced => condemned, + /// then reclaimed by the ack-floor pipeline). (A separate drop THEN repoint would double-count the -1 + /// on r1's blobs and drive blob 2 to -1 — an undercount the in-degree fold fails closed on.) + writeManifestRaw(*backend, store->layout(), ns, r2, {blobEntryFor("a", DB::UInt128(1))}); + publishCommittedTransition(*backend, store->layout(), ns, "tbl", r1, r2); + + driveToFixpoint(*backend, store, gc); + /// Blob 1 is re-referenced (net in-degree 1) => SPARED; blob 2 is genuinely unreferenced => GONE. + EXPECT_TRUE(blobExists(*backend, store->layout(), DB::UInt128(1))) + << "the racing republish must spare blob 1"; + EXPECT_EQ(inDegreeOf(*backend, store->layout(), DB::UInt128(1)), 1); + EXPECT_FALSE(blobExists(*backend, store->layout(), DB::UInt128(2))) + << "the genuinely-unreferenced blob 2 must be collected"; + EXPECT_EQ(inDegreeOf(*backend, store->layout(), DB::UInt128(2)), 0); +} + +/// Idempotent fixpoint: once a pool is quiescent (all live refs folded, nothing to collect), repeated +/// rounds are pure no-ops — no blob is collected, no manifest disappears, the in-degree generation is +/// stable, and no round throws. The split-brain-safety bedrock: every step is idempotent. +TEST(CASGCRound, IdempotentRerunAtFixpointIsNoOp) +{ + auto backend = std::make_shared(); + auto store = openPoolForTest(backend); + const RootNamespace ns{"00/aa@cas@"}; + const ManifestRef r = ref(1, 0xAA); + const ManifestId id{ns, r}; + + writeBlobBody(*backend, store->layout(), DB::UInt128(1)); + writeManifestRaw(*backend, store->layout(), ns, r, {blobEntryFor("a", DB::UInt128(1))}); + publishCommittedTransition(*backend, store->layout(), ns, "tbl", std::nullopt, r); + + Gc gc(store, kGc); + driveToFixpoint(*backend, store, gc); + const uint64_t gen0 = currentGenerationOf(*backend, store->layout()); + + /// At quiescence: a fresh round does NO work (no candidates/deletes/spares) and changes nothing. + const RoundReport quiescent = gc.runRegularRound(); + EXPECT_TRUE(quiescent.acquired_lease); + EXPECT_EQ(quiescent.candidates, 0u); + EXPECT_EQ(quiescent.deleted, 0u); + EXPECT_EQ(quiescent.spared, 0u); + + EXPECT_NO_THROW(driveToFixpoint(*backend, store, gc)); + EXPECT_TRUE(blobExists(*backend, store->layout(), DB::UInt128(1))); /// no-loss + EXPECT_TRUE(manifestExists(*backend, store->layout(), id)); /// no-loss + /// The CONTENT no-op invariant: the live blob's durable in-degree is unchanged (still pinned). The + /// generation POINTER advances every round by design (each fold seals a fresh generation for durable + /// cursor coverage, and recheck seals the completion generation), even when no edges change — so the + /// quiescence guarantee is "no candidates/deletes/spares + nothing lost", NOT a frozen generation. + EXPECT_EQ(inDegreeOf(*backend, store->layout(), DB::UInt128(1)), 1); + EXPECT_GE(currentGenerationOf(*backend, store->layout()), gen0) + << "the generation pointer is monotone; a quiescent round never moves it backward"; +} + +/// Split-brain: two leaders racing the same pool only DUPLICATE WORK, never double-delete or lose data. +/// gc1 leads and folds the live publish; the ref is then dropped; gc2 steals the lease (stale leader) +/// and both contend to collect the now-unreferenced blob. The exact-token delete is the only destructive +/// authority, so the blob is removed exactly once and a losing/duplicate attempt is a harmless 404/412 — +/// no exception escapes, and the blob ends up gone exactly once with no dangling owner. +TEST(CASGCRound, SplitBrainLeadersOnlyDuplicateWork) +{ + auto backend = std::make_shared(); + auto store = openPoolForTest(backend); + const RootNamespace ns{"00/aa@cas@"}; + const ManifestRef r = ref(1, 0xAA); + const ManifestId id{ns, r}; + + writeBlobBody(*backend, store->layout(), DB::UInt128(1)); + writeManifestRaw(*backend, store->layout(), ns, r, {blobEntryFor("a", DB::UInt128(1))}); + publishCommittedTransition(*backend, store->layout(), ns, "tbl", std::nullopt, r); + + Gc gc1(store, kGcA); + Gc gc2(store, kGcB); + + /// gc1 leads; fold the publish edge. + ASSERT_TRUE(runRegularRoundReclaiming(gc1).acquired_lease); + EXPECT_EQ(inDegreeOf(*backend, store->layout(), DB::UInt128(1)), 1); + + /// The ref is dropped; gc1 stalls. gc2 observes the frozen lease twice and STEALS (new epoch). + dropRefTransition(*backend, store->layout(), ns, "tbl", r); + EXPECT_FALSE(runRegularRoundReclaiming(gc2).acquired_lease); /// obs #1 + ASSERT_TRUE(runRegularRoundReclaiming(gc2).acquired_lease); /// obs #2 => steal + + /// Both leaders now drive rounds. The blob is collected exactly once; duplicate attempts are + /// harmless. No round throws. + EXPECT_NO_THROW(driveToFixpoint(*backend, store, gc2)); + EXPECT_NO_THROW(driveToFixpoint(*backend, store, gc1)); /// the revived stale leader backs off / duplicates harmlessly + + EXPECT_FALSE(blobExists(*backend, store->layout(), DB::UInt128(1))) + << "the dropped blob must be collected exactly once across both leaders"; + EXPECT_FALSE(manifestExists(*backend, store->layout(), id)); + EXPECT_EQ(inDegreeOf(*backend, store->layout(), DB::UInt128(1)), 0); +} + +/// (`CASGCRound.TrimOnlyBelowSealedCoverage` and the B12 lazy/batched-trim tests +/// `LazyTrimSkipsSmallJournalAndKeepsTokenStable`, `LazyTrimCompactsAtThresholdOrSoftLimit`, +/// `MaintenanceTrimCompactsEverythingOnce` were removed with the snapshot+log ref model. They asserted +/// GC compacts a MUTABLE shard journal in place (INV-JOURNAL-COVERAGE / `gc_trim_min_events` gates). +/// Immutable `_log` objects are never trimmed in place: covered `_log`/`_snap` keys are DELETED by +/// ref-object cleanup once BOTH the durable cursor AND a checkpoint-named validated recovery triple +/// cover them -- exercised in `gtest_cas_ref_gc.cpp` (`RefObjectCleanupRetainsCheckpointNamedTriple`).) + +/// ---- INTENTIONALLY NOT PORTED (covered elsewhere or obsolete in the manifest model) ---- +/// +/// The removed snap/cascade/tree cases and where their behaviour now lives: +/// - CasGcFold.{FreshUploadsAreNeverCandidates, DropZeroesTreeButChildStaysPinned, +/// RepublishSameRefIsLastOpWins, ExpansionIsOncePerTree, IncrementalSecondFoldOnlyNewRecords, +/// DurableSnapBeforeCursorAdvance, ForeignDivergentGenerationIsProbedPast, +/// GenerationProbeRecoversAfterLostCursorCas, SnapShardsOtherThanOneIsNotImplemented, +/// AbsentTree*, NoChurnRound*} — fold-step behaviour now in gtest_cas_gc_fold.cpp +/// (CommittedAdd/Removal/Precommit/FoldBarrier/Clamp+anomaly/RefMismatch). +/// - CasGcCorruptCommittedTree.MissingTreeOfLiveRefDoesNotHaltGc — now +/// CASGCFold.CommittedMissingBodyClampsCursorAndRecordsAnomaly. +/// - CASGCRetire.{Observes*, AbsentCandidate*, DeletedCandidate*, DeleteTimePrune*, BlobOnlyPrune*, +/// RetireForgets*, RetireSetsDurable*, Diverged*, BlobHeaderUnderflow*, RetireUsesFoldCommitted*, +/// RetireReplayAdoptsOwnCrashedAttempt} — retire-step behaviour now split between +/// gtest_cas_gc_ack_floor.cpp and the retire-view suite. +/// - CASGCRecheck.{SparedWhenPublishRacesTheFence, ReplacedWhenResurrectionWins, AbsentWhenAlreadyGone} +/// — now CASGCRecheck.{PublishRacingFenceSparesBlob, UnreferencedBlobDeletedExactToken}. +/// - CasGcFence.* / CasGcDiscovery.UsesRegistryNotList — the fence machinery is retired; the equivalent +/// no-op-round-does-not-mutate-ref-shards property is gtest_cas_gc_ack_floor.cpp:: +/// CASGCAckFloor.NoOpRoundDoesNotMutateRefShards (+ helper registerNamespaceRaw discovery is +/// exercised by every fold test). +/// - CasGcCascade.* — the cascade/closure model is REMOVED; in-degree is per-blob, so a shared +/// child surviving one parent's deletion is now CASGCRound.SharedBlobSparedUntilBothRefsDrop above, +/// and "never cascades on replaced" is CASGCRound.RepublishDuringFenceWindowSparesOnlyReReferencedBlob. +/// - CasGcTrim.* — now gtest_cas_gc_resume.cpp::CASGCRound.TrimDropsFoldedOwnerEvents. +/// - CasGcResume.{CompletesRoundAfterCrashBeforeFencePersist, AdoptsOutcomesAfterCrashBeforeCascadePersist} +/// — now gtest_cas_gc_resume.cpp::CasGcResume.ResumeFromDurableFoldSealCompletesRound. +/// - CasGcScenario.ZombieDeleteAfterResurrectIs412 — relied on the snap/tree publish path + held +/// in-flight deletes; the in-degree-spare equivalent is RepublishDuringFenceWindowSparesOnly... +/// above (exact-token delete is the sole authority; a zombie carrying a stale token 412s). +/// - CASGCRound.PreviewDeletesIsWriteFreeAndSubsetOfUnreachable — previewDeletes survives, but it is +/// covered by the fsck/preview suite; not duplicated here. +/// - CasGcWatermark.LiveBuildPrecommitHonoredAcrossGcRounds / +/// CASGCRetire.ReclaimsAbandonedPrecommitWhenFloorPasses — precommit removal is now the WRITER's job +/// (an exact `owner_transition` on abandon, or a fenced successor's stale-precommit sweep); GC no +/// longer reclaims abandoned precommits. Exercised by the orphan-manifest-sweep / build-root suites. + +/// B9 snap-generation retention, reimplemented over the run/generation model: after a generation is +/// adopted the GC prunes the per-generation seal/run/cleanup objects of generations at or below the +/// retention floor (snap_generation - gc_snapshot_generations_to_keep), advancing snap_pruned_through. This +/// test drives enough rounds to accumulate several generations, then asserts that everything at or below +/// the floor is GONE while the last `keep` generations (and the live current one) remain. +TEST(CASGCSnapRetention, PrunesOldGenerationsKeepingLastThree) +{ + auto backend = std::make_shared(); + /// keep the default 3 generations; one root shard so cursor keys are "ns/0". + auto store = Pool::open(backend, PoolConfig{.pool_prefix = "p", .server_root_id = "test", .gc_snapshot_generations_to_keep = 3, .gc_fold_max_defer_rounds = 0}); + const RootNamespace ns{"00/aa@cas@"}; + const ManifestRef r = ref(1, 0xAA); + + writeBlobBody(*backend, store->layout(), DB::UInt128(1)); + writeManifestRaw(*backend, store->layout(), ns, r, {blobEntryFor("a", DB::UInt128(1))}); + publishCommittedTransition(*backend, store->layout(), ns, "tbl", std::nullopt, r); + + Gc gc(store, kGc); + /// Several quiescent rounds, each advancing the generation pointer (fold + completion). Enough to + /// push generations below the floor. + for (int i = 0; i < 8; ++i) + ASSERT_TRUE(runRegularRoundReclaiming(gc).acquired_lease); + + const GcState st = readState(*backend, *store); + const uint64_t keep = 3; + ASSERT_GT(st.snap_generation, keep); + const uint64_t floor = st.snap_generation - keep; + + /// snap_pruned_through reached the floor (bounded burst is large enough for this generation count). + EXPECT_EQ(st.snap_pruned_through, floor) + << "retention cursor must reach the floor (snap_generation - keep)"; + + /// Every generation at or below the floor is fully gone (fold seal absent). + for (uint64_t g = 1; g <= floor; ++g) + { + EXPECT_FALSE(backend->head(store->layout().foldSealKey(g, st.snap_attempt)).exists) + << "fold seal of pruned generation " << g << " must be gone"; + EXPECT_FALSE(backend->head(store->layout().blobTargetRunKey(g, st.snap_attempt, /*shard*/0, /*seq*/0)).exists) + << "blob-target run of pruned generation " << g << " must be gone"; + } + + /// The fold seal at the current generation survives (the live in-degree view). + EXPECT_TRUE(backend->head(store->layout().foldSealKey(st.snap_generation, st.snap_attempt)).exists) + << "the current generation's seal must NOT be pruned"; + + /// No-loss: the live blob and owner body are intact throughout retention pruning. + EXPECT_TRUE(blobExists(*backend, store->layout(), DB::UInt128(1))); + EXPECT_TRUE(manifestExists(*backend, store->layout(), ManifestId{ns, r})); +} + +/// Task 9 (wholesale generation-retention): a generation may hold artifacts under MULTIPLE attempts +/// (each round mints a fresh `lease.seq`, and a deposed leader may have written debris under its own +/// unadopted attempt). When a generation ages past the retention floor it must be reclaimed WHOLESALE +/// — every attempt's artifacts (incl. the attempt-scoped `retired/` and `outcomes/` sets that now live +/// under `gc/gen//attempt//`), not just the final adopted attempt's. This test plants a retired +/// set AND a decoy fold seal under a NON-adopted attempt at an old generation, ages that generation out, +/// and asserts the whole `gc/gen//` subtree is gone (the per-key single-attempt prune leaked it). +TEST(CASGCSnapRetention, WholesalePruneReclaimsAllAttemptsIncludingRetiredOutcomes) +{ + auto backend = std::make_shared(); + auto store = Pool::open(backend, PoolConfig{.pool_prefix = "p", .server_root_id = "test", .gc_snapshot_generations_to_keep = 3, .gc_fold_max_defer_rounds = 0}); + const RootNamespace ns{"00/aa@cas@"}; + const ManifestRef r = ref(1, 0xAA); + + writeBlobBody(*backend, store->layout(), DB::UInt128(1)); + writeManifestRaw(*backend, store->layout(), ns, r, {blobEntryFor("a", DB::UInt128(1))}); + publishCommittedTransition(*backend, store->layout(), ns, "tbl", std::nullopt, r); + + Gc gc(store, kGc); + /// One round to establish the first completed generation and learn its adopted attempt (derive both + /// from gc/state — never hardcode a generation; the round folds and completes, so it is > 1). + ASSERT_TRUE(runRegularRoundReclaiming(gc).acquired_lease); + const GcState st1 = readState(*backend, *store); + ASSERT_GT(st1.snap_generation, 0u); + const uint64_t old_gen = st1.snap_generation; + const uint64_t adopted_attempt_g1 = st1.snap_attempt; + + /// Plant debris under a NON-adopted attempt of generation 1: a retired set, an outcomes log, a fold + /// seal, and a blob-target run — exactly the families a deposed leader would have written before its + /// CAS failed. The per-key single-attempt prune (keyed on the FINAL snap_attempt) never touches them. + const uint64_t decoy_attempt = adopted_attempt_g1 + 777; + const String decoy_outcomes = store->layout().outcomesKey(old_gen, decoy_attempt, /*round*/0, /*shard*/0); + const String decoy_seal = store->layout().foldSealKey(old_gen, decoy_attempt); + const String decoy_run = store->layout().blobTargetRunKey(old_gen, decoy_attempt, /*shard*/0, /*seq*/0); + backend->putIfAbsent(decoy_outcomes, "decoy-outcomes"); + backend->putIfAbsent(decoy_seal, "decoy-seal"); + backend->putIfAbsent(decoy_run, "decoy-run"); + + /// Drop the ref so the next fold writes a FRESH run under a newer generation and the adopted seal's + /// blob_target ref moves OFF `old_gen`. Under T0 reference-parent carry, a still-referenced generation + /// is deliberately retained (its run is live), so `old_gen` can only age out once nothing references + /// its run anymore — which the drop guarantees. + dropRefTransition(*backend, store->layout(), ns, "tbl", r); + + /// Age generation 1 well past the retention floor (keep=3): several more quiescent rounds. + for (int i = 0; i < 8; ++i) + ASSERT_TRUE(runRegularRoundReclaiming(gc).acquired_lease); + + const GcState st = readState(*backend, *store); + ASSERT_GT(st.snap_generation, old_gen + 3) << "generation 1 must be below the retention floor"; + + /// The ENTIRE gc/gen// subtree — across ALL attempts — must be reclaimed. + EXPECT_FALSE(backend->head(decoy_outcomes).exists) << "non-adopted outcomes log leaked past retention"; + EXPECT_FALSE(backend->head(decoy_seal).exists) << "non-adopted fold seal leaked past retention"; + EXPECT_FALSE(backend->head(decoy_run).exists) << "non-adopted blob-target run leaked past retention"; + + /// Nothing remains under the old generation prefix at all. + const ListPage residue = backend->list(store->layout().gcGenPrefix(old_gen), "", 1000); + EXPECT_TRUE(residue.keys.empty()) << "old generation prefix must be fully reclaimed; left " + << residue.keys.size() << " objects"; + + /// The drop was necessary to move the seal's blob_target ref off `old_gen` so it could age out (under + /// T0 a still-referenced generation is deliberately retained — see the comment above). The blob is + /// condemned by the drop and, being round-paced, graduates and is physically deleted well within the + /// 8 quiescent rounds above — retire drain is not the property under test here (generation retention + /// is), so this only asserts the reclaim pipeline is not itself broken by the retention plumbing. + EXPECT_FALSE(blobExists(*backend, store->layout(), DB::UInt128(1))); + /// The owner-removed manifest body IS reclaimed by the part-manifest cleanup pass over the aging rounds. + EXPECT_FALSE(manifestExists(*backend, store->layout(), ManifestId{ns, r})); +} + +/// `deletePrefixWholesale`'s callers now draw from the round's shared +/// object-count budget instead of `UINT64_MAX`, and `snap_pruned_through` must advance only past a +/// FULLY drained generation -- never past one the budget cut short, or its undeleted remainder would +/// be stranded behind a cursor this loop never revisits. A tiny budget (2 objects/round) against a +/// generation carrying far more debris than that forces multiple rounds to fully drain it; the +/// invariant under test is that AT EVERY ROUND, `snap_pruned_through >= old_gen` implies the old +/// generation's prefix is already empty -- the cursor never claims completion it has not earned. +TEST(CASGCSnapRetention, PruneRespectsPrefixWholesaleBudgetAndNeverStrandsAPartialGeneration) +{ + auto backend = std::make_shared(); + auto store = Pool::open(backend, PoolConfig{ + .pool_prefix = "p", .server_root_id = "test", + .gc_snapshot_generations_to_keep = 3, + .gc_round_prefix_wholesale_budget = 2, + .gc_fold_max_defer_rounds = 0}); + const RootNamespace ns{"00/aa@cas@"}; + const ManifestRef r = ref(1, 0xAA); + + writeBlobBody(*backend, store->layout(), DB::UInt128(1)); + writeManifestRaw(*backend, store->layout(), ns, r, {blobEntryFor("a", DB::UInt128(1))}); + publishCommittedTransition(*backend, store->layout(), ns, "tbl", std::nullopt, r); + + Gc gc(store, kGc); + ASSERT_TRUE(runRegularRoundReclaiming(gc).acquired_lease); + const GcState st1 = readState(*backend, *store); + const uint64_t old_gen = st1.snap_generation; + + /// Ten extra debris objects under generation 1's prefix -- far more than the 2-object round budget + /// can wholesale-delete in a single pass, regardless of whatever real fold artifacts already live + /// there. + for (int i = 0; i < 10; ++i) + backend->putIfAbsent(store->layout().gcGenPrefix(old_gen) + "debris" + std::to_string(i), "x"); + + /// Move the ref off `old_gen`'s run (as `WholesalePruneReclaimsAllAttemptsIncludingRetiredOutcomes` + /// does) so the WHOLESALE RETENTION PRUNE -- not the one-shot post-CAS hand-off -- is what + /// eventually processes this generation once the cursor reaches it. + dropRefTransition(*backend, store->layout(), ns, "tbl", r); + + size_t previous_residue = backend->list(store->layout().gcGenPrefix(old_gen), "", 1000).keys.size(); + std::optional drain_start_round; /// first round the residue count actually DROPS + std::optional drain_done_round; /// first round the residue reaches zero + for (int i = 0; i < 40 && !drain_done_round; ++i) + { + ASSERT_TRUE(runRegularRoundReclaiming(gc).acquired_lease); + const GcState st = readState(*backend, *store); + const ListPage residue = backend->list(store->layout().gcGenPrefix(old_gen), "", 1000); + + if (st.snap_pruned_through >= old_gen) + EXPECT_TRUE(residue.keys.empty()) + << "round " << i << ": snap_pruned_through (" << st.snap_pruned_through + << ") claims generation " << old_gen << " is behind it, but " << residue.keys.size() + << " object(s) remain -- the cursor advanced past a partially-drained prefix"; + + if (!drain_start_round && residue.keys.size() < previous_residue) + drain_start_round = i; + if (residue.keys.empty()) + drain_done_round = i; + previous_residue = residue.keys.size(); + } + + ASSERT_TRUE(drain_start_round.has_value()) << "the round loop never even started draining the debris"; + ASSERT_TRUE(drain_done_round.has_value()) + << "the budget-limited generation must eventually fully drain within a generous round bound"; + /// THE LOAD-BEARING ASSERTION: with a 2-object budget against 10+ debris objects, draining cannot + /// finish the SAME round it starts -- it must take several rounds. An unbounded + /// `deletePrefixWholesale` call (the mutation this pins) drains everything the round it starts, + /// collapsing this gap to zero. + EXPECT_GT(*drain_done_round, *drain_start_round) + << "draining finished the same round it started -- the per-round budget is not load-bearing"; +} + +/// Reclaim-VIA-RETENTION of a non-adopted current-generation attempt orphan (KISS prune model). A +/// deposed leader can write its fold seal under an attempt that lost CAS #1 to a higher-seq adopter — +/// debris at the FOLD generation under a NON-adopted attempt. There is NO per-round current-generation +/// attempt-sweep anymore (it cost a per-round LIST for a rare collision); the wholesale +/// generation-retention prune is the SOLE reclaimer. So such an orphan is NOT reclaimed within one +/// round; instead it waits until its generation ages past `keep` and the wholesale prefix-delete +/// reclaims the whole `gc/gen//` subtree — every attempt at once, including this orphan. This test +/// plants the orphan at a fold generation, ages that generation out, and asserts retention reclaims it. +TEST(CASGCSnapRetention, ReclaimsNonAdoptedCurrentGenAttemptViaRetention) +{ + auto backend = std::make_shared(); + /// keep=3 retention floor (matches WholesalePruneReclaimsAllAttemptsIncludingRetiredOutcomes). + auto store = Pool::open(backend, PoolConfig{.pool_prefix = "p", .server_root_id = "test", .gc_snapshot_generations_to_keep = 3, .gc_fold_max_defer_rounds = 0}); + const RootNamespace ns{"00/aa@cas@"}; + const ManifestRef r = ref(1, 0xAA); + writeBlobBody(*backend, store->layout(), DB::UInt128(1)); + writeManifestRaw(*backend, store->layout(), ns, r, {blobEntryFor("a", DB::UInt128(1))}); + publishCommittedTransition(*backend, store->layout(), ns, "tbl", std::nullopt, r); + + Gc gc(store, kGc); + /// Drive a couple of rounds so snap_attempt is comfortably above 0 (a low orphan seq exists below it). + ASSERT_TRUE(runRegularRoundReclaiming(gc).acquired_lease); + ASSERT_TRUE(runRegularRoundReclaiming(gc).acquired_lease); + const GcState st = readState(*backend, *store); + ASSERT_GT(st.snap_attempt, 0u) << "need snap_attempt > 0 so a strictly-older orphan attempt exists"; + + /// Plant a deposed competitor's debris at the next round's FOLD generation under an attempt strictly + /// older than that round's adopted attempt — exactly the orphan the old per-round sweep targeted. + const uint64_t orphan_gen = st.snap_generation + 1; + const uint64_t orphan_attempt = st.snap_attempt - 1; + const String orphan_seal = store->layout().foldSealKey(orphan_gen, orphan_attempt); + const String orphan_run = store->layout().blobTargetRunKey(orphan_gen, orphan_attempt, 0, 0); + backend->putIfAbsent(orphan_seal, "orphan-seal"); + backend->putIfAbsent(orphan_run, "orphan-run"); + + /// One more round folds into `orphan_gen` and completes. The orphan must SURVIVE this round — there + /// is no current-generation sweep; retention has not yet reached `orphan_gen`. + ASSERT_TRUE(runRegularRoundReclaiming(gc).acquired_lease); + EXPECT_TRUE(backend->head(orphan_seal).exists) + << "orphan must survive its own round — there is no per-round current-gen sweep"; + + /// Age `orphan_gen` well past the retention floor (keep=3): several more quiescent rounds. The + /// wholesale generation-retention prune then reclaims the WHOLE `gc/gen//` subtree, + /// including this non-adopted attempt's debris. + for (int i = 0; i < 8; ++i) + ASSERT_TRUE(runRegularRoundReclaiming(gc).acquired_lease); + + const GcState st_after = readState(*backend, *store); + ASSERT_GT(st_after.snap_generation, orphan_gen + 3) << "orphan_gen must be below the retention floor"; + + EXPECT_FALSE(backend->head(orphan_seal).exists) + << "non-adopted attempt orphan must be reclaimed by wholesale retention once its generation ages out"; + EXPECT_FALSE(backend->head(orphan_run).exists) + << "the whole orphan subtree must be reclaimed by wholesale retention"; + + /// No-loss: the live data is intact throughout. + EXPECT_TRUE(blobExists(*backend, store->layout(), DB::UInt128(1))); + EXPECT_TRUE(manifestExists(*backend, store->layout(), ManifestId{ns, r})); +} + +/// ---- Task 7 (2026-07-02 snapshot-streaming): ref-aware retention + post-CAS hand-off delete ---- + +/// Retention must NOT reclaim a generation whose run the live seal still references, EVEN once the +/// retention cursor (`snap_pruned_through`) has advanced past that generation. With `keep=1` and a live +/// ref that idle-carries across generations, `pruneSupersededGenerations` SKIPS gen-1's prefix every +/// round while advancing the cursor over it. The gen-1 run object (physically holding the seal's ref) +/// must survive, and folding/in-degree resolution THROUGH the carried ref must keep working. +TEST(CASGCRetention, PruneRetainsLiveReferencedRun) +{ + auto backend = std::make_shared(); + /// keep=1: the retention floor is aggressive so the cursor reaches gen-1's neighbourhood fast. + auto store = Pool::open(backend, PoolConfig{.pool_prefix = "p", .server_root_id = "test", .gc_snapshot_generations_to_keep = 1, .gc_fold_max_defer_rounds = 0}); + const RootNamespace ns{"00/aa@cas@"}; + const ManifestRef r = ref(1, 0xAA); + + writeBlobBody(*backend, store->layout(), DB::UInt128(1)); + writeManifestRaw(*backend, store->layout(), ns, r, {blobEntryFor("a", DB::UInt128(1))}); + publishCommittedTransition(*backend, store->layout(), ns, "tbl", std::nullopt, r); + + Gc gc(store, kGc); + runRegularRoundReclaiming(gc); // gen 1: the blob's run is sealed under gen-1's key namespace + const GcState st1 = readState(*backend, *store); + const uint64_t ref_gen = st1.snap_generation; + + /// The gen-1 seal's ref names gen-1's physical run key — capture it so we can assert the OBJECT + /// (not just the generation number) survives retention. + const auto seal1 = decodeFoldSeal( + backend->get(store->layout().foldSealKey(st1.snap_generation, st1.snap_attempt))->bytes); + ASSERT_EQ(seal1.blob_target_runs.size(), 1u); + const String referenced_run_key = seal1.blob_target_runs.front().key; + ASSERT_EQ(seal1.blob_target_runs.front().generation, ref_gen); + ASSERT_TRUE(backend->head(referenced_run_key).exists); + + /// Several idle rounds: no delta, no retired => pure ref-carry. Each round advances the generation + /// and, once adopted_generation > keep, drives the retention prune forward. gen-1 is referenced every + /// round, so it is SKIPPED (retained) even as `snap_pruned_through` climbs past it. + for (int i = 0; i < 6; ++i) + ASSERT_TRUE(runRegularRoundReclaiming(gc).acquired_lease); + + const GcState st = readState(*backend, *store); + /// The cursor has advanced strictly past the referenced generation (the retention prune SKIPPED it + /// but still moved the high-water cursor forward) — this is the exact window Task 7 guards. + ASSERT_GT(st.snap_pruned_through, ref_gen) + << "the retention cursor must have advanced past the still-referenced generation"; + + /// The referenced run object is STILL ALIVE despite the cursor passing its generation. + EXPECT_TRUE(backend->head(referenced_run_key).exists) + << "a run referenced by the live seal must be retained even after the cursor passes its generation"; + + /// The current seal still references that same physical gen-1 object (carried, not reconstructed), + /// and in-degree resolution THROUGH the carried ref still works. + const auto seal_now = decodeFoldSeal( + backend->get(store->layout().foldSealKey(st.snap_generation, st.snap_attempt))->bytes); + ASSERT_EQ(seal_now.blob_target_runs.size(), 1u); + EXPECT_EQ(seal_now.blob_target_runs.front().key, referenced_run_key); + EXPECT_EQ(seal_now.blob_target_runs.front().generation, ref_gen); + EXPECT_EQ(inDegreeOf(*backend, store->layout(), DB::UInt128(1)), 1) + << "folding still resolves in-degree through the retained, carried parent ref"; + + /// No-loss end-to-end: the live blob is intact. + EXPECT_TRUE(blobExists(*backend, store->layout(), DB::UInt128(1))); +} + +/// When a later delta finally REPLACES the carried ref with a fresh run, the superseded old-generation +/// run — whose generation the retention cursor already passed while it was retained — is reclaimed by the +/// post-CAS HAND-OFF delete in `runRegularRound` (the wholesale prune never revisits a generation behind +/// its cursor, so the ordinary prune would leak it). The whole `gc/gen//` prefix must be gone. +TEST(CASGCRetention, HandOffDeletesSupersededRef) +{ + auto backend = std::make_shared(); + auto store = Pool::open(backend, PoolConfig{.pool_prefix = "p", .server_root_id = "test", .gc_snapshot_generations_to_keep = 1, .gc_fold_max_defer_rounds = 0}); + const RootNamespace ns{"00/aa@cas@"}; + const ManifestRef r1 = ref(1, 0xAA); + + writeBlobBody(*backend, store->layout(), DB::UInt128(1)); + writeManifestRaw(*backend, store->layout(), ns, r1, {blobEntryFor("a", DB::UInt128(1))}); + publishCommittedTransition(*backend, store->layout(), ns, "tbl", std::nullopt, r1); + + Gc gc(store, kGc); + runRegularRoundReclaiming(gc); // gen 1: run sealed under gen-1 + const GcState st1 = readState(*backend, *store); + const uint64_t old_gen = st1.snap_generation; + const String old_prefix = store->layout().gcGenPrefix(old_gen); + ASSERT_FALSE(backend->list(old_prefix, "", 1000).keys.empty()) << "gen-1 prefix must be populated"; + + /// Idle-carry the gen-1 ref until the retention cursor has advanced strictly PAST gen-1. Until it + /// does, a normal prune could still reclaim gen-1 when the ref moves — the hand-off is only load- + /// bearing once gen-1 is BEHIND the cursor. + for (int i = 0; i < 6; ++i) + ASSERT_TRUE(runRegularRoundReclaiming(gc).acquired_lease); + ASSERT_GT(readState(*backend, *store).snap_pruned_through, old_gen) + << "gen-1 must be behind the retention cursor before the hand-off is exercised"; + /// gen-1 is retained (referenced) even though the cursor passed it. + ASSERT_FALSE(backend->list(old_prefix, "", 1000).keys.empty()) + << "the referenced gen-1 prefix must still exist before the ref moves off it"; + + /// A real delta: swap the ref to a new manifest naming a different blob. The next fold writes a FRESH + /// run under the new generation and the seal's shard-0 ref moves OFF gen-1. + const ManifestRef r2 = ref(2, 0xBB); + writeBlobBody(*backend, store->layout(), DB::UInt128(2)); + writeManifestRaw(*backend, store->layout(), ns, r2, {blobEntryFor("b", DB::UInt128(2))}); + publishCommittedTransition(*backend, store->layout(), ns, "tbl", r1, r2); + + ASSERT_TRUE(runRegularRoundReclaiming(gc).acquired_lease); // folds through the carried ref; ref leaves gen-1 + + /// The seal no longer references gen-1 ... + const GcState st_after = readState(*backend, *store); + const auto seal_after = decodeFoldSeal( + backend->get(store->layout().foldSealKey(st_after.snap_generation, st_after.snap_attempt))->bytes); + for (const RunRef & rr : seal_after.blob_target_runs) + EXPECT_NE(rr.generation, old_gen) << "the live seal must have moved its ref off gen-1"; + + /// ... and the post-CAS hand-off delete reclaimed gen-1's WHOLE prefix (not just the single run + /// object): seal, attempt subtree, run — all gone. The ordinary prune would have leaked it because its + /// cursor is already past gen-1. + const ListPage residue = backend->list(old_prefix, "", 1000); + EXPECT_TRUE(residue.keys.empty()) + << "the superseded gen-1 prefix must be hand-off deleted; left " << residue.keys.size() << " objects"; + + /// The now-referenced blob 2 is intact; folding through the fresh run resolves it. + EXPECT_TRUE(blobExists(*backend, store->layout(), DB::UInt128(2))); + EXPECT_EQ(inDegreeOf(*backend, store->layout(), DB::UInt128(2)), 1); +} + +/// The post-CAS hand-off draws from its OWN reserve, so a prune that spends its ENTIRE (separate, tiny) +/// budget in a round can never leave the hand-off with zero. Combines the two existing shapes: a +/// debris-heavy generation only the ordinary PRUNE ever touches (like +/// `PruneRespectsPrefixWholesaleBudgetAndNeverStrandsAPartialGeneration`, mid-drain over several rounds on +/// a starvation-small prune budget), running CONCURRENTLY with an idle-carried ref that finally moves off +/// its generation (like `HandOffDeletesSupersededRef`) in one of those very same mid-drain rounds. +TEST(CASGCRetention, HandoffOwnBudgetSurvivesAPruneHeavyRound) +{ + auto backend = std::make_shared(); + /// `gc_shards = 2` with the "keep" and "debris" blobs routed to DIFFERENT shards is load-bearing: with + /// the default single shard, ANY delta anywhere rewrites the pool's one shared run object every round, + /// which would drag the "keep" ref's physical run forward the moment the debris table is touched -- + /// destroying the idle-carry this test depends on. Two independent shards keep debris activity from + /// disturbing the kept ref's generation at all until its ref is explicitly moved. + /// `keep=5` (not the more aggressive `keep=1` other hand-off tests use) is ALSO load-bearing: the + /// debris generation must still be numerically AHEAD of the cursor at the moment its own drop folds, + /// or that fold's post-CAS hand-off phase -- not the ordinary prune -- would claim it (the same + /// one-round "parent-seal protects, then hand-off claims" shape `HandOffDeletesSupersededRef` relies + /// on, which this test must deliberately avoid for the debris generation). + auto store = Pool::open(backend, PoolConfig{ + .pool_prefix = "p", .server_root_id = "test", + .gc_snapshot_generations_to_keep = 5, + .gc_shards = 2, + .gc_round_prefix_wholesale_budget = 2, /// prune: starvation-small, shared by nothing else + .gc_round_handoff_prefix_wholesale_budget = 5, /// hand-off: its own separate reserve + .gc_fold_max_defer_rounds = 0}); + const RootNamespace ns{"00/aa@cas@"}; + Gc gc(store, kGc); + /// `blobShard` uses only the digest's high 64 bits, so a small integer's `UInt128` (high bits zero) + /// always routes to shard 0 regardless of `gc_shards` -- these two differ in the high half so they + /// land in different shards of a 2-shard pool. + const DB::UInt128 blob_keep_1 = hexToU128("00000000000000010000000000000000"); + const DB::UInt128 blob_keep_2 = hexToU128("00000000000000010000000000000001"); + const DB::UInt128 blob_debris = hexToU128("00000000000000020000000000000000"); + + /// The HAND-OFF generation: ns "keep" idle-carries this ref for several rounds until the cursor has + /// advanced strictly past it (referenced generations are skipped for free -- no budget spent). + const ManifestRef r_keep_1 = ref(1, 0xE1); + writeBlobBody(*backend, store->layout(), blob_keep_1); + writeManifestRaw(*backend, store->layout(), ns, r_keep_1, {blobEntryFor("a", blob_keep_1)}); + publishCommittedTransition(*backend, store->layout(), ns, "keep", std::nullopt, r_keep_1); + ASSERT_TRUE(runRegularRoundReclaiming(gc).acquired_lease); + const uint64_t handoff_gen = readState(*backend, *store).snap_generation; + const String handoff_prefix = store->layout().gcGenPrefix(handoff_gen); + ASSERT_FALSE(backend->list(handoff_prefix, "", 1000).keys.empty()); + + for (int i = 0; i < 20; ++i) + ASSERT_TRUE(runRegularRoundReclaiming(gc).acquired_lease); + ASSERT_GT(readState(*backend, *store).snap_pruned_through, handoff_gen) + << "the hand-off generation must be behind the cursor before this test is meaningful"; + ASSERT_FALSE(backend->list(handoff_prefix, "", 1000).keys.empty()) + << "still referenced -- must survive despite the cursor having passed it"; + + /// The PRUNE-DEBRIS generation: a second table, on the OTHER shard, unreferenced from the start, + /// carrying far more debris than the tiny prune budget can drain in one round. Minted well AHEAD of + /// the current cursor (see the `keep=5` note above), so its own drop-fold is NOT immediately + /// hand-off-eligible. + const ManifestRef r_debris = ref(1, 0xE2); + writeBlobBody(*backend, store->layout(), blob_debris); + writeManifestRaw(*backend, store->layout(), ns, r_debris, {blobEntryFor("b", blob_debris)}); + publishCommittedTransition(*backend, store->layout(), ns, "debris", std::nullopt, r_debris); + ASSERT_TRUE(runRegularRoundReclaiming(gc).acquired_lease); + const uint64_t debris_gen = readState(*backend, *store).snap_generation; + ASSERT_GT(debris_gen, readState(*backend, *store).snap_pruned_through) + << "the debris generation must still be ahead of the cursor when its drop folds, or the hand-off " + "(not the prune) would claim it"; + for (int i = 0; i < 10; ++i) + backend->putIfAbsent(store->layout().gcGenPrefix(debris_gen) + "debris" + std::to_string(i), "x"); + dropRefTransition(*backend, store->layout(), ns, "debris", r_debris); + + /// Drive rounds until the debris generation is MID-DRAIN (prune has started but not yet finished it -- + /// the round-budget of 2 against 10+ objects guarantees several such rounds exist). + bool mid_drain = false; + for (int i = 0; i < 20 && !mid_drain; ++i) + { + ASSERT_TRUE(runRegularRoundReclaiming(gc).acquired_lease); + const size_t residue = backend->list(store->layout().gcGenPrefix(debris_gen), "", 1000).keys.size(); + mid_drain = residue > 0 && residue < 10; + } + ASSERT_TRUE(mid_drain) << "the debris generation never reached a partially-drained state to test against"; + ASSERT_FALSE(backend->list(handoff_prefix, "", 1000).keys.empty()) + << "the hand-off generation must still be intact (untouched) going into the contended round"; + + /// NOW, in a round where the prune is busy mid-drain on the debris generation (spending its entire + /// small budget there), move the kept ref off the hand-off generation -- a fresh manifest replaces it. + const ManifestRef r_keep_2 = ref(2, 0xE3); + writeBlobBody(*backend, store->layout(), blob_keep_2); + writeManifestRaw(*backend, store->layout(), ns, r_keep_2, {blobEntryFor("a", blob_keep_2)}); + publishCommittedTransition(*backend, store->layout(), ns, "keep", r_keep_1, r_keep_2); + const size_t debris_residue_before = backend->list(store->layout().gcGenPrefix(debris_gen), "", 1000).keys.size(); + ASSERT_TRUE(runRegularRoundReclaiming(gc).acquired_lease); + + /// THE LOAD-BEARING ASSERTIONS: the prune spent its whole (separate) budget on the debris generation + /// this very round (proving the two really contended for I/O in the same round) ... + const size_t debris_residue_after = backend->list(store->layout().gcGenPrefix(debris_gen), "", 1000).keys.size(); + EXPECT_EQ(debris_residue_before - debris_residue_after, 2u) + << "the prune must have spent its entire per-round budget on the debris generation this round"; + /// ... and the hand-off, drawing from its OWN reserve, still fully reclaimed the generation the ref + /// just moved off -- zero, not starved to zero by the prune's consumption. + EXPECT_TRUE(backend->list(handoff_prefix, "", 1000).keys.empty()) + << "the hand-off must not be starved by a prune-heavy round that exhausted a SEPARATE budget"; +} + +/// triage #5, driven through the REAL call site (`Gc::runRegularRound`, not a test seam): a losing +/// leader's pre-CAS wholesale generation-retention prune must never destroy a generation the PARENT +/// (currently-adopted, pre-fold) seal still references, even when the round's own PROPOSED seal has +/// already moved off it and the round's own `gc/state` CAS then loses. `GcStateCasFaultBackend` makes +/// this round's own round-commit CAS return `Conflict` — deterministically and single-threaded standing +/// in for a concurrent leader winning first — which is the only condition under which the fix is +/// externally observable: a round whose own CAS SUCCEEDS reclaims the same generation moments later via +/// the existing (unrelated, unchanged) post-CAS hand-off delete regardless of this fix, so a plain +/// successful round cannot tell bug from fix apart. +TEST(CASGCRetention, LosingRoundNeverDestroysParentSealGeneration) +{ + auto backend = std::make_shared(); + auto store = Pool::open(backend, PoolConfig{.pool_prefix = "p", .server_root_id = "test", .gc_snapshot_generations_to_keep = 1}); + const Layout & layout = store->layout(); + backend->faulted_key = layout.gcStateKey(); + const RootNamespace ns{"00/aa@cas@"}; + const ManifestRef r1 = ref(1, 0xAA); + + writeBlobBody(*backend, layout, DB::UInt128(1)); + writeManifestRaw(*backend, layout, ns, r1, {blobEntryFor("a", DB::UInt128(1))}); + publishCommittedTransition(*backend, layout, ns, "tbl", std::nullopt, r1); + + Gc gc(store, kGc); + ASSERT_TRUE(gc.runRegularRound().acquired_lease); // round 1: gen=1 adopted, referencing blob 1's run. + const GcState st1 = readState(*backend, *store); + const uint64_t g_parent = st1.snap_generation; + + const auto seal1 = decodeFoldSeal(backend->get(layout.foldSealKey(st1.snap_generation, st1.snap_attempt))->bytes); + ASSERT_EQ(seal1.blob_target_runs.size(), 1u); + const String parent_run_key = seal1.blob_target_runs.front().key; + const String parent_gen_prefix = layout.gcGenPrefix(g_parent); + ASSERT_FALSE(backend->list(parent_gen_prefix, "", 1000).keys.empty()); + + /// A real delta: swap the ref to a new manifest naming a different blob. The next fold will move + /// shard 0's run OFF `g_parent` onto a fresh generation. + const ManifestRef r2 = ref(2, 0xBB); + writeBlobBody(*backend, layout, DB::UInt128(2)); + writeManifestRaw(*backend, layout, ns, r2, {blobEntryFor("b", DB::UInt128(2))}); + publishCommittedTransition(*backend, layout, ns, "tbl", r1, r2); + + /// Arm the fault for the NEXT round's SECOND casPut on gc/state, not its first: the first is + /// `acquireOrRenewLease`'s own lease-renewal CAS (must SUCCEED, so the round actually folds), and the + /// second is the round's final round-commit CAS (the one that must LOSE, exactly as if a concurrent + /// leader had already committed a different seal first). + const size_t calls_before = backend->calls_to_faulted_key; + backend->fail_at_call = calls_before + 2; + bool threw_aborted = false; + try + { + gc.runRegularRound(); + } + catch (const DB::Exception & e) + { + threw_aborted = (e.code() == DB::ErrorCodes::ABORTED); + if (!threw_aborted) + throw; + } + ASSERT_TRUE(threw_aborted) << "the losing round's own gc/state CAS must fail and propagate ABORTED"; + EXPECT_EQ(backend->calls_to_faulted_key, calls_before + 2) + << "the round must have made exactly the expected two gc/state casPut attempts (renew + commit)"; + + /// GREEN evidence: the losing round's pre-CAS prune must NOT have destroyed `g_parent` — it is still + /// exactly what the (unreplaced, still-adopted) parent seal references. + EXPECT_FALSE(backend->list(parent_gen_prefix, "", 1000).keys.empty()) + << "a losing round must never destroy the generation the still-adopted parent seal references"; + EXPECT_TRUE(backend->head(parent_run_key).exists) + << "the parent seal's exact run object must survive a losing round's pre-CAS prune"; + + /// GC is NOT wedged: gc/state is unchanged (the CAS never committed) and the original blob still + /// resolves cleanly through the surviving parent run — no `CORRUPTED_DATA` from a dangling reference. + EXPECT_EQ(readState(*backend, *store).snap_generation, g_parent); + EXPECT_TRUE(blobExists(*backend, layout, DB::UInt128(1))); + EXPECT_EQ(inDegreeOf(*backend, layout, DB::UInt128(1)), 1); + + /// A subsequent round (fault already disarmed) must succeed normally, AND must still reclaim the + /// losing round's own abandoned attempt debris — a generation referenced by NEITHER the parent nor + /// the new proposed seal — proving the fix does not turn pruning off altogether. + ASSERT_TRUE(gc.runRegularRound().acquired_lease); + const uint64_t g_after = readState(*backend, *store).snap_generation; + ASSERT_GT(g_after, g_parent); + for (uint64_t g = g_parent + 1; g < g_after; ++g) + EXPECT_TRUE(backend->list(layout.gcGenPrefix(g), "", 1000).keys.empty()) + << "generation " << g << " (the losing round's own abandoned attempt debris, referenced by " + "neither the parent nor the new proposed seal) must still be reclaimed on a successful " + "round — the fix must not disable pruning"; + + EXPECT_TRUE(blobExists(*backend, layout, DB::UInt128(2))); + EXPECT_EQ(inDegreeOf(*backend, layout, DB::UInt128(2)), 1); +} + +/// keep == 0 is the forensics "keep ALL" mode: NO generation is pruned, snap_pruned_through stays 0. +TEST(CASGCSnapRetention, KeepZeroPrunesNothing) +{ + auto backend = std::make_shared(); + auto store = Pool::open(backend, PoolConfig{.pool_prefix = "p", .server_root_id = "test", .gc_snapshot_generations_to_keep = 0}); + const RootNamespace ns{"00/aa@cas@"}; + const ManifestRef r = ref(1, 0xAA); + + writeBlobBody(*backend, store->layout(), DB::UInt128(1)); + writeManifestRaw(*backend, store->layout(), ns, r, {blobEntryFor("a", DB::UInt128(1))}); + publishCommittedTransition(*backend, store->layout(), ns, "tbl", std::nullopt, r); + + Gc gc(store, kGc); + for (int i = 0; i < 6; ++i) + ASSERT_TRUE(gc.runRegularRound().acquired_lease); + + const GcState st = readState(*backend, *store); + EXPECT_EQ(st.snap_pruned_through, 0u) << "keep==0 must prune nothing"; + + /// Every seal from generation 1 up to the current one remains. Each generation was sealed under the + /// attempt of the round that produced it (attempt == that round's lease.seq, which bumps every round), + /// so a historical generation's seal lives under an earlier attempt than the final snap_attempt — scan + /// all attempts up to snap_attempt and require the seal to survive under one of them. + for (uint64_t g = 1; g <= st.snap_generation; ++g) + { + bool seal_present = false; + for (uint64_t a = 0; a <= st.snap_attempt && !seal_present; ++a) + seal_present = backend->head(store->layout().foldSealKey(g, a)).exists; + EXPECT_TRUE(seal_present) << "keep==0: seal of generation " << g << " must remain"; + } +} + +TEST(CASGCRound, OrphanManifestCursorSweepDeletesAndPersistsCursor) +{ + std::shared_ptr backend; + PoolConfig config; + config.pool_prefix = "p"; + /// The GC runner owns a different mount from the synthetic `test` watermark below. This keeps the + /// cursor-sweep assertions in the parent process without replacing its live keeper incarnation. + config.server_root_id = "gc-runner"; + config.manifest_sweep_list_budget_keys = 1; + config.manifest_sweep_delete_budget_keys = 1; + /// This test drives MANY consecutive rounds expecting each to sweep + persist the cursor; force + /// fold-every-round (Phase-4 Lever A would otherwise defer once the pool quiesces). + config.gc_fold_max_defer_rounds = 0; + auto store = openTestPoolWithConfig(backend, config); + + const RootNamespace ns{"test/aa@cas@"}; + registerNamespaceRaw(*backend, store->layout(), ns); + const ManifestRef r1 = ref(5, 0xCA01); + const ManifestRef r2 = ref(5, 0xCA02); + writeManifestRaw(*backend, store->layout(), ns, r1, {blobEntryFor("a", DB::UInt128(1))}); + writeManifestRaw(*backend, store->layout(), ns, r2, {blobEntryFor("b", DB::UInt128(2))}); + setWatermarkMinActive(*backend, store->layout(), "test", r1.writer_epoch, /*min_active*/6); + + /// The §6 deletion premise is a second precondition on every sweep deletion: a manifest of an + /// epoch-`E` build is deletable only once the namespace's sealed fold cursor sits in an epoch + /// STRICTLY above `E`. The debris above is epoch 1, so the namespace's own ref log has to cross + /// into epoch 2 and the ROUND has to fold that crossing. + /// + /// The crossing is written record by record and folded by the round's own arithmetic intake, which + /// makes this the composition of the whole chain in one test: a real `EpochSeal` is minted at + /// `{1,2}`, `RefTableState::apply` consumes it as INV-2's chain link when the epoch-2 record names + /// it in `prev_epoch_seal`, the walk CROSSES on that back-chain, the round seals a cursor in epoch + /// 2, and the premise then admits a deletion for the crossed epoch. Nothing here is seeded: an + /// injected cursor would prove only that the premise reads a number, not that the number can be + /// produced. + /// + /// The live publications use build sequences ABOVE the watermark's `min_active`, so the only + /// sweep-ELIGIBLE manifests in the namespace remain the two debris bodies -- the premise, not the + /// watermark, is what this test varies. + publishAt(*backend, store->layout(), ns, RefTxnId{1, 1}, "tbl", /*build_sequence=*/7, + DB::UInt128(0xB10B1), /*birth=*/true); + writeRecoverableCkptForRawFixture(*backend, store->layout(), ns, RefCkpt{ + .life_epoch = 1, + .committed_through = RefTxnId{1, 1}, + .checkpoint_snapshot_id = std::nullopt, + .last_epoch_seal = std::nullopt, + }); + + Gc gc(store, kGc); + ASSERT_TRUE(runRegularRoundReclaiming(gc).acquired_lease); + EXPECT_TRUE(manifestExists(*backend, store->layout(), ManifestId{ns, r1})) + << "a cursor still INSIDE epoch 1 proves nothing about epoch 1's closing seal: the premise retains"; + EXPECT_TRUE(manifestExists(*backend, store->layout(), ManifestId{ns, r2})); + + /// The round above already persisted a mid-circuit cursor while deleting nothing, which is the + /// cursor half of this test's subject: the sweep examined a key, retained it, and durably recorded + /// where it got to. + EXPECT_FALSE(readState(*backend, *store).manifest_sweep_cursor.empty()) + << "the sweep persisted the cursor it examined to, even having deleted nothing"; + + /// Close epoch 1 and open epoch 2 over the seal it consumed. + writeSealAt(*backend, store->layout(), ns, RefTxnId{1, 2}); + publishAt(*backend, store->layout(), ns, RefTxnId{2, 1}, "tbl2", /*build_sequence=*/7, + DB::UInt128(0xB10B2), /*birth=*/false, /*prev_epoch_seal=*/RefTxnId{1, 2}); + const std::optional life = CasRefCatalog::lifeIfCataloged(*backend, store->layout(), ns); + ASSERT_TRUE(life.has_value()); + const String ckpt_key = store->layout().refCkptKey(*life); + const auto old_ckpt = backend->get(ckpt_key); + ASSERT_TRUE(old_ckpt.has_value()); + ASSERT_EQ(backend->putOverwrite(ckpt_key, encodeRefCkpt(RefCkpt{ + .life_epoch = 1, + .committed_through = RefTxnId{2, 1}, + .checkpoint_snapshot_id = std::nullopt, + .last_epoch_seal = RefTxnId{1, 2}, + }), old_ckpt->token).outcome, PutOutcome::Done); + + /// The list budget is one key per round, so reclaiming both debris bodies takes a circuit. + for (int round = 0; round < 12; ++round) + { + ASSERT_TRUE(runRegularRoundReclaiming(gc).acquired_lease) << "round " << round; + if (!manifestExists(*backend, store->layout(), ManifestId{ns, r1}) + && !manifestExists(*backend, store->layout(), ManifestId{ns, r2})) + break; + } + EXPECT_FALSE(manifestExists(*backend, store->layout(), ManifestId{ns, r1})); + EXPECT_FALSE(manifestExists(*backend, store->layout(), ManifestId{ns, r2})); + + /// What ADMITTED those deletions, stated rather than inferred: the round folded the crossing itself + /// and sealed a cursor in the epoch above the debris. Without this the two expectations above would + /// still pass if the premise ever stopped consulting the cursor at all. + { + const GcState st = readState(*backend, *store); + const CasFoldSeal seal = decodeFoldSeal( + backend->get(store->layout().foldSealKey(st.snap_generation, st.snap_attempt))->bytes); + const auto it = seal.ref_lives.find(catalogLifeIdForTest(*backend, store->layout(), ns)); + ASSERT_NE(it, seal.ref_lives.end()) << "the round must have sealed a coverage row"; + EXPECT_FALSE(it->second.coverage.hold.has_value()) << "a held namespace can never reach the premise"; + EXPECT_EQ(it->second.coverage.last_folded_ref_id, (RefTxnId{2, 1})) + << "the cursor must sit in the epoch ABOVE the debris, reached by folding the seal at {1,2}"; + } + + /// Replacing a live Pool's own mount with a synthetic foreign watermark must make release fail + /// CLOSED. This was an `EXPECT_DEATH` pinning a `LOGICAL_ERROR` abort; the abort was the defect + /// (it fires from `~Pool`, defeating `finishTeardown`'s own catch by aborting at exception + /// construction, and it fires in ASan builds on any deposed writer's shutdown). What it was really + /// protecting is asserted directly now: the runtime never had a failed renewal, so it still + /// believed it owned the mount, which makes this the exclusivity-violation arm — refuse, leave the + /// occupant byte-for-byte untouched, and SURVIVE the teardown. + std::shared_ptr foreign_backend; + PoolConfig foreign_config = config; + foreign_config.server_root_id = "test"; + auto invalid_store = openTestPoolWithConfig(foreign_backend, std::move(foreign_config)); + const String foreign_mount_key = invalid_store->layout().mountKey("test"); + setWatermarkMinActive(*foreign_backend, invalid_store->layout(), "test", r1.writer_epoch, /*min_active*/6); + const auto occupant_before = foreign_backend->get(foreign_mount_key); + ASSERT_TRUE(occupant_before.has_value()); + const uint64_t violations_before + = ProfileEvents::global_counters[ProfileEvents::CASMountExclusivityViolation].load(); + + invalid_store.reset(); /// must not abort, must not terminate + + EXPECT_EQ(ProfileEvents::global_counters[ProfileEvents::CASMountExclusivityViolation].load(), + violations_before + 1) + << "a runtime that never observed a deposition must report the foreign occupant as a broken " + "single-writer guarantee"; + const auto occupant_after = foreign_backend->get(foreign_mount_key); + ASSERT_TRUE(occupant_after.has_value()) << "the release must never delete another incarnation's lease"; + EXPECT_EQ(occupant_after->bytes, occupant_before->bytes) + << "the release must leave the slot byte-for-byte untouched, never stamp our farewell over it"; +} + +/// Source-edge idempotency: re-folding the same blob activation does not double-count. +/// A blob activated twice from the SAME source edge (same ManifestId + path) has in-degree 1, not 2. +TEST(CASGCRound, FoldManifestEdgesEmitsOnePlusEdgePerBlob) +{ + auto backend = std::make_shared(); + auto store = openPoolForTest(backend); + const RootNamespace ns{"00/aa@cas@"}; + + const ManifestRef r = ref(1, 0xAA); + writeBlobBody(*backend, store->layout(), DB::UInt128(1)); + writeManifestRaw(*backend, store->layout(), ns, r, {blobEntryFor("a", DB::UInt128(1))}); + publishCommittedTransition(*backend, store->layout(), ns, "tbl", std::nullopt, r); + + Gc gc(store, kGc); + driveToFixpoint(*backend, store, gc); + + EXPECT_EQ(inDegreeOf(*backend, store->layout(), DB::UInt128(1)), 1) + << "a single published manifest must contribute exactly one source edge per blob"; + EXPECT_TRUE(blobExists(*backend, store->layout(), DB::UInt128(1))) + << "the blob must still exist (in-degree > 0)"; +} + +/// Re-fold of a removal is idempotent: the fold barrier + source-edge set model ensure that +/// folding the same removal twice (the H1b scenario) does NOT drive the in-degree below zero. +TEST(CASGCRound, ReFoldOfRemovalIsIdempotent) +{ + auto backend = std::make_shared(); + auto store = openPoolForTest(backend); + const RootNamespace ns{"00/aa@cas@"}; + + const ManifestRef r = ref(1, 0xAA); + writeBlobBody(*backend, store->layout(), DB::UInt128(1)); + writeManifestRaw(*backend, store->layout(), ns, r, {blobEntryFor("a", DB::UInt128(1))}); + publishCommittedTransition(*backend, store->layout(), ns, "tbl", std::nullopt, r); + + Gc gc(store, kGc); + driveToFixpoint(*backend, store, gc); + EXPECT_EQ(inDegreeOf(*backend, store->layout(), DB::UInt128(1)), 1); + + /// Drop the ref and run to fixpoint. The blob should be reclaimed. + dropRefTransition(*backend, store->layout(), ns, "tbl", r); + EXPECT_NO_THROW(driveToFixpoint(*backend, store, gc)) + << "re-fold of a removal must be idempotent (source-edge set, never underflows)"; + + EXPECT_FALSE(blobExists(*backend, store->layout(), DB::UInt128(1))) + << "the blob must be reclaimed after the only reference is dropped"; + EXPECT_EQ(inDegreeOf(*backend, store->layout(), DB::UInt128(1)), 0); +} + +/// Two distinct manifests referencing the same blob contribute TWO independent source edges. +/// Dropping one manifest leaves the other's edge intact (in-degree stays 1, blob is spared). +TEST(CASGCRound, TwoManifestsTwoSourceEdgesDropOneSpares) +{ + auto backend = std::make_shared(); + auto store = openPoolForTest(backend); + const RootNamespace ns{"00/aa@cas@"}; + + const ManifestRef r1 = ref(1, 0xAA); + const ManifestRef r2 = ref(2, 0xBB); + writeBlobBody(*backend, store->layout(), DB::UInt128(1)); + writeManifestRaw(*backend, store->layout(), ns, r1, {blobEntryFor("a", DB::UInt128(1))}); + writeManifestRaw(*backend, store->layout(), ns, r2, {blobEntryFor("a", DB::UInt128(1))}); + publishCommittedTransition(*backend, store->layout(), ns, "tbl1", std::nullopt, r1); + publishCommittedTransition(*backend, store->layout(), ns, "tbl2", std::nullopt, r2); + + Gc gc(store, kGc); + driveToFixpoint(*backend, store, gc); + EXPECT_EQ(inDegreeOf(*backend, store->layout(), DB::UInt128(1)), 2) + << "two distinct manifests referencing the same blob must each contribute one source edge"; + EXPECT_TRUE(blobExists(*backend, store->layout(), DB::UInt128(1))); + + /// Drop one of the two references; the other still pins the blob. + dropRefTransition(*backend, store->layout(), ns, "tbl1", r1); + driveToFixpoint(*backend, store, gc); + EXPECT_EQ(inDegreeOf(*backend, store->layout(), DB::UInt128(1)), 1) + << "after dropping one of two references the in-degree must be 1"; + EXPECT_TRUE(blobExists(*backend, store->layout(), DB::UInt128(1))) + << "the blob must survive — the second reference still pins it"; +} diff --git a/src/Disks/tests/gtest_cas_gc_round_defer.cpp b/src/Disks/tests/gtest_cas_gc_round_defer.cpp new file mode 100644 index 000000000000..a33816a8bac2 --- /dev/null +++ b/src/Disks/tests/gtest_cas_gc_round_defer.cpp @@ -0,0 +1,640 @@ +#include + +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include "cas_test_helpers.h" + +using namespace DB::Cas; +using namespace DB::Cas::tests; + +namespace ProfileEvents +{ +extern const Event CASGCRefWalkPlansBuilt; +} + +namespace +{ +const UInt128 kGc = UInt128(0xAB); +} + +TEST(CASGCRoundDefer, PredicateTruthTable) +{ + /// threshold=1 (default): defer ONLY when zero shards changed AND no graduation due AND within bound. + EXPECT_TRUE (shouldDeferRound(/*changed*/0, /*grad_due*/false, /*since*/0, /*threshold*/1, /*max*/8)); + EXPECT_FALSE(shouldDeferRound(1, false, 0, 1, 8)); // a shard changed => fold + EXPECT_FALSE(shouldDeferRound(0, true, 0, 1, 8)); // graduation due => force fold + EXPECT_FALSE(shouldDeferRound(0, false, 8, 1, 8)); // defer bound reached => force fold + + /// threshold=3 (batching): defer while accumulated changed shards < threshold, no grad, within bound. + EXPECT_TRUE (shouldDeferRound(2, false, 0, 3, 8)); + EXPECT_FALSE(shouldDeferRound(3, false, 0, 3, 8)); // reached threshold => fold + EXPECT_FALSE(shouldDeferRound(2, true, 0, 3, 8)); // graduation due => force fold regardless of size + EXPECT_FALSE(shouldDeferRound(2, false, 8, 3, 8)); // bound reached => force fold +} + +/// graduationDue (retired-in-snapshot T4): read ZERO-I/O from the adopted seal's condemned_summary. An +/// entry whose oldest non-pending condemn round crosses current_round forces it true; a delete_pending +/// entry forces it true regardless of the round; otherwise false. +TEST(CASGCRoundDefer, GraduationDueDetectsDuePendingAndRoundCrossing) +{ + auto backend = std::make_shared(); + auto store = openPoolForTest(backend); + const Layout & layout = store->layout(); + + /// Adopt a seal whose shard-0 summary holds one condemned-but-not-yet-graduated entry (round 2). + injectCondemnedSummarySeal(*backend, layout, /*generation*/1, /*attempt*/1, /*gc_shards*/1, + {{0, CondemnedSummary{.condemned_total = 1, .pending_total = 0, + .oldest_nonpending_condemn_round = 2}}}); + + Gc gc(store, kGc); + const GcState state = decodeGcState(backend->get(layout.gcStateKey())->bytes); + + EXPECT_FALSE(gc.graduationDueForTest(state, /*current_round=*/2)) + << "oldest non-pending condemn round (2) is not < current_round (2); not yet due to graduate"; + EXPECT_TRUE(gc.graduationDueForTest(state, /*current_round=*/3)) + << "oldest non-pending condemn round (2) < current_round (3) => due to graduate"; + + /// Re-adopt a seal whose summary entry is delete_pending: due regardless of the round. + injectCondemnedSummarySeal(*backend, layout, /*generation*/1, /*attempt*/1, /*gc_shards*/1, + {{0, CondemnedSummary{.condemned_total = 1, .pending_total = 1, + .oldest_nonpending_condemn_round = std::numeric_limits::max()}}}); + const GcState state_pending = decodeGcState(backend->get(layout.gcStateKey())->bytes); + + EXPECT_TRUE(gc.graduationDueForTest(state_pending, /*current_round=*/0)) + << "a delete_pending entry must force graduationDue true regardless of current_round"; +} + +/// graduationDue fail-closed: when the adopted seal OBJECT is deleted out from under gc/state, the signal +/// must be TRUE (forces the fold so the round's own fail-closed path surfaces the corrupt bookkeeping), +/// never a silent defer. +TEST(CASGCRoundDefer, GraduationDueFailsClosedWhenSealMissing) +{ + auto backend = std::make_shared(); + auto store = openPoolForTest(backend); + const Layout & layout = store->layout(); + + injectCondemnedSummarySeal(*backend, layout, /*generation*/1, /*attempt*/1, /*gc_shards*/1, + {{0, CondemnedSummary{}}}); + const GcState state = decodeGcState(backend->get(layout.gcStateKey())->bytes); + + /// Delete the adopted seal object (corrupt destructive bookkeeping). + const String seal_key = layout.foldSealKey(state.snap_generation, state.snap_attempt); + const HeadResult h = backend->head(seal_key); + ASSERT_TRUE(h.exists); + ASSERT_EQ(backend->deleteExact(seal_key, h.token).kind, DeleteOutcome::Kind::Deleted); + + Gc gc(store, kGc); + EXPECT_TRUE(gc.graduationDueForTest(state, /*current_round=*/5)) + << "a missing adopted seal must fail-closed to a forced fold"; +} + +/// graduationDue is FALSE on a TOTAL all-zero summary: nothing condemned in any shard => nothing due. +TEST(CASGCRoundDefer, GraduationDueFalseOnAllZeroSummary) +{ + auto backend = std::make_shared(); + auto store = Pool::open(backend, + PoolConfig{.pool_prefix = "p", .server_root_id = "test", .gc_shards = 2}); + const Layout & layout = store->layout(); + + injectCondemnedSummarySeal(*backend, layout, /*generation*/1, /*attempt*/1, /*gc_shards*/2, + {{0, CondemnedSummary{}}, {1, CondemnedSummary{}}}); + const GcState state = decodeGcState(backend->get(layout.gcStateKey())->bytes); + + Gc gc(store, kGc); + EXPECT_FALSE(gc.graduationDueForTest(state, /*current_round=*/9)) + << "an all-zero total summary means nothing is due to graduate"; + + /// Fail-closed if the summary is NOT total over gc_shards (shard 1 missing). + injectCondemnedSummarySeal(*backend, layout, /*generation*/1, /*attempt*/1, /*gc_shards*/2, + {{0, CondemnedSummary{}}}); + const GcState partial = decodeGcState(backend->get(layout.gcStateKey())->bytes); + EXPECT_TRUE(gc.graduationDueForTest(partial, /*current_round=*/9)) + << "a summary not total over gc_shards is corrupt => fail-closed force-fold"; +} + +/// `listRefPrefix`'s `changed_shards`: with the fold seal covering shard s at its current token, a quiescent pool reports +/// 0; after one publish to a ref in shard s, it reports 1. +TEST(CASGCRoundDefer, ChangedShardCountIsZeroWhenQuiescent) +{ + auto backend = std::make_shared(); + auto store = openPoolForTest(backend); + const Layout & layout = store->layout(); + const RootNamespace ns{"00/aa@cas@"}; + const ManifestRef r1{.writer_epoch = 1, .build_sequence = 1, .manifest_ordinal = 0xAA}; + + writeBlobBody(*backend, layout, UInt128(1)); + writeManifestRaw(*backend, layout, ns, r1, {blobEntryFor("a", UInt128(1))}); + publishCommittedTransition(*backend, layout, ns, "tbl", std::nullopt, r1); + + Gc gc(store, kGc); + ASSERT_TRUE(gc.runRegularRound().acquired_lease); /// fold; the round's own trim then rewrites the + /// shard (compacting the just-folded event), so + /// its sealed token is the PRE-trim snapshot. + ASSERT_TRUE(gc.runRegularRound().acquired_lease); /// a second, work-free round: nothing left to + /// trim, so THIS round's fold seal finally + /// captures the shard's actual current token. + + const GcState quiescent_state = decodeGcState(backend->get(layout.gcStateKey())->bytes); + EXPECT_EQ(gc.listRefPrefixForTest(quiescent_state).changed_shards, 0u) + << "a quiescent shard (listed token == sealed token) must not count as changed"; + + /// Publish a second ref into the SAME shard: its LISTED token now differs from what + /// `quiescent_state`'s adopted fold seal recorded. + const ManifestRef r2{.writer_epoch = 1, .build_sequence = 2, .manifest_ordinal = 0xBB}; + writeBlobBody(*backend, layout, UInt128(2)); + writeManifestRaw(*backend, layout, ns, r2, {blobEntryFor("b", UInt128(2))}); + publishCommittedTransition(*backend, layout, ns, "tbl2", std::nullopt, r2); + + EXPECT_EQ(gc.listRefPrefixForTest(quiescent_state).changed_shards, 1u) + << "one shard whose token advanced since the sealed generation must count as changed"; +} + +/// Mutation caught: widening the hot LIST from `cas/ns/stream/` to `cas/ns/` would offer `_ckpt` and +/// `_files` state objects to the fold. The backend-observed result set must contain both immutable +/// stream kinds and neither state kind. +TEST(CASGCRoundDefer, HotEnumerationOffersLogsAndSnapshotsButNeverCheckpointOrFiles) +{ + auto backend = std::make_shared(); + auto store = openPoolForTest(backend); + const Layout & layout = store->layout(); + const NamespaceLifeId life = NamespaceLifeId::fromCatalogEntry(RootNamespace{"name-must-not-appear"}, UInt128{0x123}); + const RefTxnId id{1, 1}; + const String log_key = layout.refLogKey(life, id); + const String snap_key = layout.refSnapshotKey(life, id); + const String ckpt_key = layout.refCkptKey(life); + const String file_key = layout.namespaceFileKey(life, "f"); + ASSERT_EQ(backend->putIfAbsent(log_key, "log").outcome, PutOutcome::Done); + ASSERT_EQ(backend->putIfAbsent(snap_key, "snap").outcome, PutOutcome::Done); + ASSERT_EQ(backend->putIfAbsent(ckpt_key, "ckpt").outcome, PutOutcome::Done); + ASSERT_EQ(backend->putIfAbsent(file_key, "file").outcome, PutOutcome::Done); + backend->resetCounts(); + + Gc gc(store, kGc); + const RefScanSummary scan = gc.listRefPrefixForTest(GcState{}); + const std::set offered(scan.keys.begin(), scan.keys.end()); + EXPECT_EQ(offered, (std::set{log_key, snap_key})); + EXPECT_EQ(backend->listCount(layout.namespaceStreamRootPrefix()), 1u); + EXPECT_EQ(backend->listCount(layout.namespaceRootPrefix()), 0u); + EXPECT_EQ(backend->listCount(layout.namespaceStateRootPrefix()), 0u); +} + +/// The authoritative cut follows the completed hot LIST. A listed life absent from that later cut is +/// inert dead-life debris: it is not admitted and does not defer the round or read the body. +TEST(CASGCRoundDefer, ListedLifeAbsentFromThePostListCatalogCutIsInertDebris) +{ + auto backend = std::make_shared(); + auto store = openPoolForTest(backend, /*gc_fold_max_defer_rounds=*/0); + const Layout & layout = store->layout(); + const NamespaceLifeId unknown = NamespaceLifeId::fromCatalogEntry(RootNamespace{"cannot-authorize"}, UInt128{0x456}); + const String log_key = layout.refLogKey(unknown, RefTxnId{1, 1}); + ASSERT_EQ(backend->putIfAbsent(log_key, "not-read-on-defer").outcome, PutOutcome::Done); + backend->resetCounts(); + + Gc gc(store, kGc); + const RoundReport report = gc.runRegularRound({}, /*allow_steal=*/true, UniversePolicy::Authoritative); + EXPECT_FALSE(report.deferred); + EXPECT_EQ(backend->getCount(log_key), 0u) + << "inert means the body is never read: the life is absent from the authoritative cut, so no " + "admission and no fold intake can touch it"; + /// The debris IS reclaimed in this round, and that is the janitor's designed job, not the fold's: + /// a life id absent from the catalog cut is a dead life, and the namespace janitor deletes its + /// objects by exact token behind the same fence. A round over a proved-empty catalog completes its + /// frontier, so nothing suppresses that reclaim any more -- the object is dropped without ever + /// being read or admitted, which is exactly what "inert debris" means here. + EXPECT_EQ(backend->deleteCount(log_key), 1u); +} + +/// The post-LIST cut classifies every immutable stream kind, not only logs. A snapshot belonging to a +/// life absent from that later cut is inert debris and its body is not read. +TEST(CASGCRoundDefer, SnapshotLifeAbsentFromThePostListCatalogCutIsInertDebris) +{ + auto backend = std::make_shared(); + auto store = openPoolForTest(backend, /*gc_fold_max_defer_rounds=*/0); + const Layout & layout = store->layout(); + const NamespaceLifeId unknown = NamespaceLifeId::fromCatalogEntry(RootNamespace{"cannot-authorize"}, UInt128{0x457}); + const String snapshot_key = layout.refSnapshotKey(unknown, RefTxnId{1, 1}); + ASSERT_EQ(backend->putIfAbsent(snapshot_key, "not-read-on-defer").outcome, PutOutcome::Done); + backend->resetCounts(); + + Gc gc(store, kGc); + const RoundReport report = gc.runRegularRound({}, /*allow_steal=*/true, UniversePolicy::Authoritative); + EXPECT_FALSE(report.deferred); + EXPECT_EQ(backend->getCount(snapshot_key), 0u) + << "inert means the body is never read, whatever immutable stream kind it is"; + /// As for the log above: the dead life's snapshot is reclaimed by the janitor by exact token, + /// never read and never admitted. + EXPECT_EQ(backend->deleteCount(snapshot_key), 1u); +} + +/// ---- Task 4: the DEFER short-circuit wired into runRegularRound ---- + +/// Idle round re-adopts: after a settled round, a subsequent round with zero changed shards and no +/// graduation due sets report.deferred=true and performs dramatically less generation-run I/O than a +/// real fold round (no `blob_target` run object touched at all -- the fold never runs). Snap +/// generation/attempt are untouched (the snapshot is not rebuilt). +/// +/// SETTLING NOTE: immutable `_log` objects are never trimmed in place (unlike the legacy mutable shard +/// journal, whose fold-then-trim token rewrite forced a second settling round), so the pool quiesces the +/// round AFTER the folding round -- the very next round defers. +TEST(CASGCRoundDefer, IdleRoundDefersAndReadsNoGeneration) +{ + auto backend = std::make_shared(); + auto store = openPoolForTest(backend); + const RootNamespace ns{"00/aa@cas@"}; + const ManifestRef r{.writer_epoch = 1, .build_sequence = 1, .manifest_ordinal = 0xAA}; + writeBlobBody(*backend, store->layout(), UInt128(1)); + writeManifestRaw(*backend, store->layout(), ns, r, {blobEntryFor("a", UInt128(1))}); + publishCommittedTransition(*backend, store->layout(), ns, "tbl", std::nullopt, r); + + Gc gc(store, kGc); + backend->resetCounts(); + const RoundReport fold_rep = gc.runRegularRound(); /// round 1: folds the +1 (no trim-lag, quiesces at once) + ASSERT_FALSE(fold_rep.deferred); + const uint64_t fold_round_gets = backend->getTotal(); + EXPECT_GT(fold_round_gets, 0u) << "sanity: a real fold round performs some GETs"; + + const auto st_before = decodeGcState(backend->get(store->layout().gcStateKey())->bytes); + + backend->resetCounts(); + const RoundReport rep = gc.runRegularRound(); /// round 2: genuinely quiesced now => must defer + const uint64_t defer_round_gets = backend->getTotal(); + + EXPECT_TRUE(rep.deferred) << "a settled idle round must re-adopt the sealed generation, not fold"; + /// A deferred round mints no new round (CasGc.cpp:runRegularRound's defer branch), so the honest + /// `report.round` is the round that was ALREADY adopted before this round started -- the same round + /// the preceding fold round committed. Guards against the bug where the defer path returned WITHOUT + /// ever assigning `report.round`, leaving it at its zero-initialized default and making every + /// deferred round print `CA GC round 0` regardless of how far GC had actually progressed. + EXPECT_NE(rep.round, 0u) << "a deferred round must report a truthful, nonzero round number"; + EXPECT_EQ(rep.round, fold_rep.round) + << "a deferred round re-adopts the already-committed round, not a fabricated new one"; + + const auto st_after = decodeGcState(backend->get(store->layout().gcStateKey())->bytes); + EXPECT_EQ(st_after.snap_generation, st_before.snap_generation) + << "a deferred round must not mint a new generation (snapshot rebuild elided)"; + EXPECT_EQ(st_after.snap_attempt, st_before.snap_attempt); + + /// SECONDARY (not over-fit to "exactly 0 gets" -- the decision itself pays a bounded retired-list + + /// discovery-LIST cost that may share the same get counter): the deferred round touches NO + /// blob_target run object at all (fold never runs, so foldDeltasIntoGeneration never executes), and + /// its total get volume sits far below a genuine fold round's. + EXPECT_EQ(backend->ioCountForKeysContaining("/blob_target/"), 0u) + << "a deferred round must never GET/getStream/PUT any blob_target run object"; + EXPECT_LT(defer_round_gets, fold_round_gets) + << "a deferred round's read volume must sit far below a real fold round's"; +} + +/// Every ordinary round constructs one complete catalog-authoritative walk plan after the hot LIST, +/// before deciding DEFER. A fold consumes that exact frozen plan; it must not build another one. +TEST(CASGCRoundDefer, FoldAndDeferEachBuildExactlyOneCompletePostListWalkPlan) +{ + auto backend = std::make_shared(); + auto store = openPoolForTest(backend); + const Layout & layout = store->layout(); + const RootNamespace ns{"00/one-walk-plan@cas@"}; + const ManifestRef ref{.writer_epoch = 1, .build_sequence = 1, .manifest_ordinal = 1}; + writeBlobBody(*backend, layout, UInt128{1}); + writeManifestRaw(*backend, layout, ns, ref, {blobEntryFor("a", UInt128{1})}); + publishCommittedTransition(*backend, layout, ns, "tbl", std::nullopt, ref); + + Gc gc(store, kGc); + std::vector phases; + gc.setPhaseSink([&](const GcPhaseRecord & phase) { phases.push_back(phase); }); + + backend->resetCounts(); + const uint64_t fold_builds_before + = ProfileEvents::global_counters[ProfileEvents::CASGCRefWalkPlansBuilt].load(); + ASSERT_FALSE(gc.runRegularRound().deferred); + EXPECT_EQ(ProfileEvents::global_counters[ProfileEvents::CASGCRefWalkPlansBuilt].load() - fold_builds_before, 1u); + EXPECT_EQ(backend->listCount(layout.namespaceStreamRootPrefix()), 1u) + << "the hot walk must enumerate the stream tree exactly once"; + EXPECT_EQ(backend->listCount(layout.namespaceRootPrefix()), 1u) + << "the bounded janitor page is a distinct ownership-tree enumeration"; + EXPECT_EQ(backend->getCount(layout.refCatalogKey()), 3u) + << "generation zero has no drain read: one cut builds the hot walk plan, one follows the janitor " + "page, and `planManifestCursorPage` takes its own"; + const auto fold_decision = std::find_if(phases.begin(), phases.end(), [](const GcPhaseRecord & phase) + { + return phase.phase == "defer_decision"; + }); + ASSERT_NE(fold_decision, phases.end()); + EXPECT_EQ(fold_decision->metrics.at("walk_plan_builds"), 1u); + EXPECT_EQ(fold_decision->metrics.at("walk_plan_rows"), 1u); + const auto fold_cleanup = std::find_if(phases.begin(), phases.end(), [](const GcPhaseRecord & phase) + { + return phase.phase == "namespace_cleanup"; + }); + ASSERT_NE(fold_cleanup, phases.end()); + EXPECT_EQ(fold_cleanup->metrics.at("janitor_pages"), 1u); + + phases.clear(); + backend->resetCounts(); + const uint64_t defer_builds_before + = ProfileEvents::global_counters[ProfileEvents::CASGCRefWalkPlansBuilt].load(); + ASSERT_TRUE(gc.runRegularRound().deferred); + EXPECT_EQ(ProfileEvents::global_counters[ProfileEvents::CASGCRefWalkPlansBuilt].load() - defer_builds_before, 1u); + EXPECT_EQ(backend->listCount(layout.namespaceStreamRootPrefix()), 1u) + << "a deferred round still builds exactly one complete hot walk plan"; + EXPECT_EQ(backend->listCount(layout.namespaceRootPrefix()), 1u) + << "the janitor remains one separately paced ownership-tree page"; + EXPECT_EQ(backend->getCount(layout.refCatalogKey()), 3u) + << "one adopted-parent drain cut, one post-hot-LIST cut, and one post-janitor-page cut"; + const auto defer_decision = std::find_if(phases.begin(), phases.end(), [](const GcPhaseRecord & phase) + { + return phase.phase == "defer_decision"; + }); + ASSERT_NE(defer_decision, phases.end()); + EXPECT_EQ(defer_decision->metrics.at("walk_plan_builds"), 1u); + EXPECT_EQ(defer_decision->metrics.at("walk_plan_rows"), 1u); + EXPECT_EQ(defer_decision->metrics.at("walk_plan_dropped_parent_rows"), 0u); + EXPECT_EQ(defer_decision->metrics.at("walk_plan_dropped_listed_lives"), 0u); + EXPECT_EQ(defer_decision->metrics.at("walk_plan_dropped_tails"), 0u); + const auto defer_cleanup = std::find_if(phases.begin(), phases.end(), [](const GcPhaseRecord & phase) + { + return phase.phase == "namespace_cleanup"; + }); + ASSERT_NE(defer_cleanup, phases.end()); + EXPECT_EQ(defer_cleanup->metrics.at("janitor_pages"), 1u); +} + +/// A maintenance cursor can be left between pages while the correctness state is already quiescent. +/// The next acquired round may DEFER its fold, but it has no authoritative destructive verdict. It +/// must therefore inspect exactly one janitor page without deleting OR advancing past it; the bounded +/// forced fold then retries the same page under its computed global gate and reclaims the debris. +TEST(CASGCRoundDefer, DeferredRoundRetriesPartialJanitorPageAtForcedFoldWithoutPublishingSuccessor) +{ + auto backend = std::make_shared(); + auto store = openPoolForTest(backend, /*gc_fold_max_defer_rounds=*/1); + const Layout & layout = store->layout(); + const NamespaceLifeId dead_a + = NamespaceLifeId::fromCatalogEntry(RootNamespace{"dead/a"}, UInt128{0xDA}); + const NamespaceLifeId dead_b + = NamespaceLifeId::fromCatalogEntry(RootNamespace{"dead/b"}, UInt128{0xDB}); + const String key_a = layout.refCkptKey(dead_a); + const String key_b = layout.refCkptKey(dead_b); + ASSERT_EQ(backend->putIfAbsent(key_a, "dead-a").outcome, PutOutcome::Done); + ASSERT_EQ(backend->putIfAbsent(key_b, "dead-b").outcome, PutOutcome::Done); + + /// Establish real opaque backend progress rather than fabricating a cursor value. One key remains + /// after this page and the durable cursor must be non-empty. + const NamespaceJanitorResult first_page + = NamespaceJanitor(*backend, layout, 1).runOnePage(false, [] { return true; }); + ASSERT_EQ(first_page.pages, 1u); + ASSERT_EQ(first_page.deleted, 1u); + const GcMaintenanceReadResult partial = readGcMaintenanceState(*backend, layout); + ASSERT_EQ(partial.status, GcMaintenanceReadStatus::Valid); + ASSERT_TRUE(partial.state); + ASSERT_FALSE(partial.state->janitor_cursor.empty()); + ASSERT_EQ(static_cast(backend->head(key_a).exists) + static_cast(backend->head(key_b).exists), 1u); + + /// Give the forced fold a nonempty, fully proved authoritative universe. The R11 floor correctly + /// refuses to open the destructive gate for an empty 0-of-0 universe even in the test-only policy. + const RootNamespace live_namespace{"live/frontier@cas@"}; + fixture::admitLive(*backend, layout, live_namespace); + ASSERT_EQ(backend->putIfAbsent( + layout.refCkptKey(fixture::fixtureLife(live_namespace)), + encodeRefCkpt(RefCkpt{ + .life_epoch = std::optional{1}, + .checkpoint_snapshot_id = std::nullopt, + .last_epoch_seal = std::nullopt})).outcome, + PutOutcome::Done); + + backend->resetCounts(); + std::vector phases; + Gc gc(store, kGc); + gc.setPhaseSink([&](const GcPhaseRecord & phase) { phases.push_back(phase); }); + const uint64_t plans_before + = ProfileEvents::global_counters[ProfileEvents::CASGCRefWalkPlansBuilt].load(); + + const RoundReport report = gc.runRegularRound(); + + ASSERT_TRUE(report.acquired_lease); + ASSERT_TRUE(report.deferred); + EXPECT_EQ(ProfileEvents::global_counters[ProfileEvents::CASGCRefWalkPlansBuilt].load() - plans_before, 1u) + << "DEFER still constructs its one immutable hot walk plan, never a second janitor-derived plan"; + EXPECT_EQ(backend->listCount(layout.namespaceStreamRootPrefix()), 1u); + EXPECT_EQ(backend->listCount(layout.namespaceRootPrefix()), 1u) + << "the deferred round must inspect exactly one separately paced janitor page"; + EXPECT_EQ(backend->getCount(layout.refCatalogKey()), 2u) + << "generation zero pays one hot walk-plan cut and one post-janitor-page cut"; + const auto cleanup = std::find_if(phases.begin(), phases.end(), [](const GcPhaseRecord & phase) + { + return phase.phase == "namespace_cleanup"; + }); + ASSERT_NE(cleanup, phases.end()); + EXPECT_EQ(cleanup->metrics.at("janitor_pages"), 1u); + EXPECT_GE(cleanup->metrics.at("janitor_keys"), 1u); + EXPECT_EQ(cleanup->metrics.at("janitor_deleted"), 0u); + + const GcMaintenanceReadResult deferred_progress = readGcMaintenanceState(*backend, layout); + ASSERT_EQ(deferred_progress.status, GcMaintenanceReadStatus::Valid); + ASSERT_TRUE(deferred_progress.state); + EXPECT_EQ(deferred_progress.state->janitor_cursor, partial.state->janitor_cursor) + << "a suppressed DEFER page is undecided and must remain selected for the authoritative fold"; + EXPECT_EQ(static_cast(backend->head(key_a).exists) + static_cast(backend->head(key_b).exists), 1u); + + const auto gc_state = backend->get(layout.gcStateKey()); + ASSERT_TRUE(gc_state); + const GcState state = decodeGcState(gc_state->bytes); + EXPECT_EQ(state.snap_generation, 0u); + EXPECT_EQ(state.snap_attempt, 0u); + EXPECT_FALSE(backend->head(layout.foldSealKey(1, 1)).exists) + << "maintenance on DEFER must not publish a fold successor"; + + backend->resetCounts(); + phases.clear(); + const RoundReport folded = gc.runRegularRound({}, true, UniversePolicy::Authoritative); + ASSERT_TRUE(folded.acquired_lease); + ASSERT_FALSE(folded.deferred) + << "gc_fold_max_defer_rounds=1 forces the round immediately following one DEFER to fold"; + EXPECT_EQ(backend->listCount(layout.namespaceRootPrefix()), 1u) + << "the authoritative fold must run the janitor exactly once, not once per call site"; + const auto folded_cleanup = std::find_if(phases.begin(), phases.end(), [](const GcPhaseRecord & phase) + { + return phase.phase == "namespace_cleanup"; + }); + ASSERT_NE(folded_cleanup, phases.end()); + EXPECT_EQ(folded_cleanup->metrics.at("janitor_pages"), 1u); + EXPECT_GE(folded_cleanup->metrics.at("janitor_keys"), 1u); + EXPECT_EQ(folded_cleanup->metrics.at("janitor_deleted"), 1u); + EXPECT_EQ(static_cast(backend->head(key_a).exists) + static_cast(backend->head(key_b).exists), 0u) + << "the fold must retry and delete the exact page that DEFER left undecided"; + const GcMaintenanceReadResult completed = readGcMaintenanceState(*backend, layout); + ASSERT_EQ(completed.status, GcMaintenanceReadStatus::Valid); + ASSERT_TRUE(completed.state); + EXPECT_TRUE(completed.state->janitor_cursor.empty()); +} + +/// The same idle-defer property under a sharded blob-target GC (gc_shards=2): graduationDue's loop +/// over state.retired_refs and `listRefPrefix`'s discovery must both settle to "nothing due" once +/// quiesced, regardless of how many gc-shards partition the retired bookkeeping. +TEST(CASGCRoundDefer, IdleRoundDefersUnderShardedGc) +{ + auto backend = std::make_shared(); + auto store = Pool::open(backend, + PoolConfig{.pool_prefix = "p", .server_root_id = "test", + .gc_shards = 2}); + const RootNamespace ns{"00/aa@cas@"}; + const ManifestRef r{.writer_epoch = 1, .build_sequence = 1, .manifest_ordinal = 0xAA}; + writeBlobBody(*backend, store->layout(), UInt128(1)); + writeManifestRaw(*backend, store->layout(), ns, r, {blobEntryFor("a", UInt128(1))}); + publishCommittedTransition(*backend, store->layout(), ns, "tbl", std::nullopt, r); + + Gc gc(store, kGc); + ASSERT_FALSE(gc.runRegularRound().deferred); /// round 1: folds the publish + + /// Immutable `_log` objects are never trimmed in place, so there is no fold-then-trim token-rewrite + /// lag: the pool quiesces after the folding round, and the very next round defers. + const RoundReport rep = gc.runRegularRound(); /// round 2: quiesced + EXPECT_TRUE(rep.deferred) << "idle pool under gc_shards=2 must defer once settled"; +} + +/// The +1 guard (mirror of the 2026-06-27 leak): a blob condemned + published delete_pending, then +/// re-referenced WHILE it is pending, must NOT be over-deleted -- the due graduation forces a fold +/// (never a defer) that sees the +1 and spares the blob. +TEST(CASGCRoundDefer, DueGraduationForcesFoldAndSparesReReferencedBlob) +{ + auto backend = std::make_shared(); + auto store = openPoolForTest(backend); + const RootNamespace ns{"00/aa@cas@"}; + const UInt128 blob(1); + const ManifestRef r1{.writer_epoch = 1, .build_sequence = 1, .manifest_ordinal = 0xAA}; + writeBlobBody(*backend, store->layout(), blob); + writeManifestRaw(*backend, store->layout(), ns, r1, {blobEntryFor("a", blob)}); + publishCommittedTransition(*backend, store->layout(), ns, "tbl", std::nullopt, r1); + Gc gc(store, kGc); + + runRegularRoundReclaiming(gc); /// folds the +1; blob referenced + store->renewWatermarkOnce(); + dropRefTransition(*backend, store->layout(), ns, "tbl", r1); /// the -1 condemns it + + runRegularRoundReclaiming(gc); /// the condemning round + store->renewWatermarkOnce(); + + /// Drive rounds until the entry graduates (published delete_pending) -- mirrors + /// CASGCAckFloor.CondemnThenDeleteNextRoundAfterAcks. It is still PRESENT at that pass, and the + /// ack floor is by construction already past its condemn_round (that is what graduated it). + bool saw_pending = false; + for (int i = 0; i < 6 && !saw_pending; ++i) + { + runRegularRoundReclaiming(gc); + store->renewWatermarkOnce(); + for (const RetiredEntry & e : currentRetiredSet(*backend, store->layout(), /*shard*/0)) + if (e.ref == DB::Cas::BlobRef{DB::Cas::BlobHashAlgo::CityHash128, DB::Cas::BlobDigest::fromU128(blob)} && e.delete_pending) + saw_pending = true; + } + ASSERT_TRUE(saw_pending) << "entry never reached delete_pending"; + ASSERT_FALSE(blobAbsent(*backend, store->layout(), blob)) << "pending: still present this pass"; + + /// While B sits delete_pending, a NEW manifest re-references it -- a genuine +1 racing the + /// already-published pending delete. + const ManifestRef r2{.writer_epoch = 1, .build_sequence = 2, .manifest_ordinal = 0xBB}; + writeManifestRaw(*backend, store->layout(), ns, r2, {blobEntryFor("b", blob)}); + publishCommittedTransition(*backend, store->layout(), ns, "tbl2", std::nullopt, r2); + + /// The next pass would otherwise execute B's pending exact-token delete; graduationDue must force + /// a FOLD (never a DEFER) so the +1 is folded in and the blob is spared, not deleted. + const RoundReport rep = runRegularRoundReclaiming(gc); + EXPECT_FALSE(rep.deferred) << "a due graduation must force a fold, never defer"; + EXPECT_FALSE(blobAbsent(*backend, store->layout(), blob)) << "the re-referenced blob must survive"; + + const FsckReport fsck = runFsck(*store, /*detail*/true); + EXPECT_EQ(fsck.dangling, 0u); +} + +/// Companion to the test above: it proves `graduationDue` is the SOLE fold trigger at the assertion +/// round. `DueGraduationForcesFoldAndSparesReReferencedBlob` opens its store at the DEFAULT +/// `gc_fold_threshold` (1), so at its assertion round the +1 re-reference ALSO makes +/// `changed_shards (>= 1) >= fold_threshold (1)` true -- that branch of `shouldDeferRound` would force +/// the very same fold even if `graduationDue` were deleted or hard-wired false. Here `gc_fold_threshold` +/// and `gc_fold_max_defer_rounds` are both set to 1000, so neither the changed-shards branch (one +/// changed shard is nowhere near 1000) nor the liveness-bound branch (this is round 1) can fire -- +/// `graduationDue` is the ONLY thing in `shouldDeferRound` that can force this round's fold, making +/// `EXPECT_FALSE(rep.deferred)` below load-bearing for `graduationDue` specifically. +TEST(CASGCRoundDefer, DueGraduationIsSoleFoldTriggerAtHighThreshold) +{ + auto backend = std::make_shared(); + auto store = Pool::open(backend, + PoolConfig{.pool_prefix = "p", .server_root_id = "test", + .gc_fold_threshold = 1000, .gc_fold_max_defer_rounds = 1000}); + const Layout & layout = store->layout(); + const RootNamespace ns{"00/aa@cas@"}; + const UInt128 blob(1); + + Gc gc(store, kGc); + /// Warm-up round on the still-empty pool: `gc/state` does not exist yet, so lease acquisition takes + /// the create-fresh path and succeeds immediately (`gc_id` becomes the owner in storage). This + /// matters because the `injectCondemnedSummarySeal` seeding below writes `gc/state` directly, and a fresh `Gc` + /// object's FIRST-EVER `acquireOrRenewLease` call against a PRE-EXISTING lease it has never observed + /// refuses to steal it (two-observation safety against stealing from a live incumbent) -- it would + /// return `acquired_lease=false` and the round would bail out BEFORE the fold-decision code, making + /// `EXPECT_FALSE(rep.deferred)` below vacuously true regardless of `graduationDue`. Running this + /// warm-up round FIRST makes `gc_id` the observed incumbent, so the assertion round's lease RENEWAL + /// (not a steal) succeeds unconditionally and the round actually reaches the decision it's testing. + gc.runRegularRound(); + + writeBlobBody(*backend, layout, blob); + + /// Seed the adopted fold seal's condemned_summary with B already `delete_pending` (pending_total = 1), + /// mirroring `CASGCRoundDefer.GraduationDueDetectsDuePendingAndRoundCrossing`. Retired-in-snapshot + /// (T4): graduationDue reads this summary ZERO-I/O off the adopted seal — a delete_pending entry forces + /// it true regardless of the round. At `gc_fold_threshold = 1000` a real condemn -> graduate pipeline of + /// `runRegularRound` calls is not usable to set this up: every round before graduation would ITSELF + /// defer (nothing due yet, and changed_shards never nears 1000), so the due-pending summary is injected + /// directly instead of driven through real rounds. + injectCondemnedSummarySeal(*backend, layout, /*generation*/1, /*attempt*/1, /*gc_shards*/1, + {{0, CondemnedSummary{.condemned_total = 1, .pending_total = 1, + .oldest_nonpending_condemn_round = std::numeric_limits::max()}}}); + + /// The +1: a fresh manifest re-references B while it sits `delete_pending` -- one changed shard, + /// far below the threshold of 1000. + const ManifestRef r{.writer_epoch = 1, .build_sequence = 1, .manifest_ordinal = 0xBB}; + writeManifestRaw(*backend, layout, ns, r, {blobEntryFor("a", blob)}); + publishCommittedTransition(*backend, layout, ns, "tbl", std::nullopt, r); + + const RoundReport rep = gc.runRegularRound(); + + /// DISCRIMINATING (load-bearing): with graduationDue intact, the due delete_pending entry forces + /// the fold. If graduationDue were broken/hard-wired false, changed_shards (1) < threshold (1000) + /// and the defer bound (1000) is nowhere near reached, so `shouldDeferRound` would return true and + /// this round would DEFER instead. + EXPECT_FALSE(rep.deferred) << "a due graduation must be the SOLE fold trigger at a high fold threshold"; + EXPECT_FALSE(blobAbsent(*backend, layout, blob)) << "the re-referenced blob must survive the forced fold"; + + const FsckReport fsck = runFsck(*store, /*detail*/true); + EXPECT_EQ(fsck.dangling, 0u); +} + +/// Bounded deferral: with a large fold_threshold and a small standing delta (one shard changed, +/// forever, since deferring never resolves it), at most gc_fold_max_defer_rounds consecutive rounds +/// defer, then one round forces a fold (the liveness bound). +TEST(CASGCRoundDefer, BoundedDeferralForcesFoldWithinWindow) +{ + auto backend = std::make_shared(); + auto store = Pool::open(backend, + PoolConfig{.pool_prefix = "p", .server_root_id = "test", + .gc_fold_threshold = 100, .gc_fold_max_defer_rounds = 3}); + const RootNamespace ns{"00/aa@cas@"}; + const ManifestRef r{.writer_epoch = 1, .build_sequence = 1, .manifest_ordinal = 0xAA}; + writeBlobBody(*backend, store->layout(), UInt128(1)); + writeManifestRaw(*backend, store->layout(), ns, r, {blobEntryFor("a", UInt128(1))}); + publishCommittedTransition(*backend, store->layout(), ns, "tbl", std::nullopt, r); + + Gc gc(store, kGc); + for (int i = 0; i < 3; ++i) + { + const RoundReport rep = gc.runRegularRound(); + EXPECT_TRUE(rep.deferred) << "round " << (i + 1) << " is within the defer bound"; + } + const RoundReport rep4 = gc.runRegularRound(); + EXPECT_FALSE(rep4.deferred) << "the 4th round hits the defer bound and must force-fold"; +} diff --git a/src/Disks/tests/gtest_cas_gc_shard_incarnation.cpp b/src/Disks/tests/gtest_cas_gc_shard_incarnation.cpp new file mode 100644 index 000000000000..a487a51a8532 --- /dev/null +++ b/src/Disks/tests/gtest_cas_gc_shard_incarnation.cpp @@ -0,0 +1,521 @@ +#include + +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include "cas_test_helpers.h" + +namespace DB::ErrorCodes +{ +extern const int ABORTED; +} + +using namespace DB::Cas; +using namespace DB::Cas::tests; +using DB::Cas::tests::injectRetire; + +namespace +{ + +PoolPtr makePoolWithShards(std::shared_ptr & out_backend, uint64_t gc_shards = 1) +{ + out_backend = std::make_shared(); + return Pool::open(out_backend, + PoolConfig{.pool_prefix = "p", .server_root_id = "test", .gc_shards = gc_shards}); +} + +ManifestRef testRef(uint64_t seq) +{ + return ManifestRef{.writer_epoch = 1, .build_sequence = seq, .manifest_ordinal = 1}; +} + +} + +/// Review I5: `discoverUniverse` is catalog-authoritative (Task 4-C), and this test used to survive +/// the switch from LIST-based discovery unchanged -- `publishCommittedTransition` admits a catalog +/// entry as its own side effect, so LIST-based and catalog-based discovery were indistinguishable to +/// it. Pins the three shapes that actually distinguish the two sources directly: +/// (a) a `Live` catalog entry with ZERO ref objects IS in the universe -- the catalog alone decides; +/// (b) a `Creating` entry is EXCLUDED -- spec §3, no publication can exist yet; +/// (c) a namespace with ref OBJECTS but NO catalog entry is EXCLUDED -- the C1 shape: the catalog is +/// the authority, so its absence is authoritative too, however much debris LIST would still find. +TEST(CASGCShardIncarnation, DiscoveryEqualsPresentShards) +{ + for (const uint64_t gc_shards : {1u, 4u}) + { + std::shared_ptr backend; + auto store = makePoolWithShards(backend, gc_shards); + Gc gc(store, hexToU128("0000000000000000000000000000000a")); + const Layout & layout = store->layout(); + + const RootNamespace ns_live_empty{"srv1/tblLiveEmpty"}; + const RootNamespace ns_creating{"srv1/tblCreating"}; + const RootNamespace ns_uncataloged{"srv1/tblUncataloged"}; + + /// (a) Admitted Live, nothing else ever written under it. + fixture::admitLive(*backend, layout, ns_live_empty); + + /// (b) A genuinely Creating entry, admitted directly (step 1 alone -- never completed to Live). + CasRefCatalog::casAdmitEntry(*backend, layout, store->poolConfig().gc_shards, CatalogEntry{.ns = ns_creating, .state = NsState::Creating, + .incarnation = UInt128(1), .creator = CreatorFence{.server_root_id = "test", .writer_epoch = 1, .fence_generation = 1}}); + + /// (c) Ref objects present, but the catalog was never told (or has since forgotten): write + /// through the real path, which self-admits, then strip the entry back out to simulate "the + /// catalog does not name it" without touching the ref objects it left behind. + writeManifestRaw(*backend, layout, ns_uncataloged, testRef(1), {}); + publishCommittedTransition(*backend, layout, ns_uncataloged, "part_1", std::nullopt, testRef(1), /*shard=*/0); + { + CasRefCatalog::Snapshot snap = CasRefCatalog::read(*backend, layout); + std::erase_if(snap.catalog.entries, [&](const CatalogEntry & e) { return e.ns.string() == ns_uncataloged.string(); }); + const HeadResult h = backend->head(layout.refCatalogKey()); + ASSERT_TRUE(h.exists); + ASSERT_EQ(backend->putOverwrite(layout.refCatalogKey(), encodeRefCatalog(snap.catalog), h.token).outcome, + PutOutcome::Done); + } + + const auto universe = gc.discoverUniverseForTest(); + + /// Stage B (Task 4-C): the universe is catalog-authoritative now, so it is a life per namespace + /// (there are no numeric shards to destructure -- see `NamespaceLifeId`), never a + /// `(namespace, shard)` pair. + bool found_live_empty = false; + for (const NamespaceLifeId & life : universe) + { + if (life.ns.string() == ns_live_empty.string()) + found_live_empty = true; + EXPECT_NE(life.ns.string(), ns_creating.string()) << "a Creating entry must never be discovered"; + EXPECT_NE(life.ns.string(), ns_uncataloged.string()) + << "ref objects with no catalog entry must not be discovered, however much debris LIST would find"; + } + EXPECT_TRUE(found_live_empty) << "a Live catalog entry with zero ref objects must still be discovered"; + + /// Confirm (b) really is still Creating (not merely absent from a differently-shaped universe). + const CasRefCatalog::Snapshot final_snap = CasRefCatalog::read(*backend, layout); + const auto creating_it = std::find_if(final_snap.catalog.entries.begin(), final_snap.catalog.entries.end(), + [&](const CatalogEntry & e) { return e.ns.string() == ns_creating.string(); }); + ASSERT_NE(creating_it, final_snap.catalog.entries.end()); + EXPECT_EQ(creating_it->state, NsState::Creating); + } +} + +/// Catalog ambiguity stops destructive GC and REBUILD before either can derive authority from a +/// first row. No attempted delete is allowed on the rejected regular round. +TEST(CASGCShardIncarnation, DuplicateLifeIdStopsDestructiveRoundAndRebuild) +{ + auto backend = std::make_shared(); + auto store = Pool::open(backend, PoolConfig{.pool_prefix = "p", .server_root_id = "test", .gc_shards = 1}); + const Layout & layout = store->layout(); + RefCatalog catalog; + catalog.entries = { + CatalogEntry{.ns = RootNamespace{"a"}, .state = NsState::Live, .incarnation = UInt128{77}}, + CatalogEntry{ + .ns = RootNamespace{"b"}, + .state = NsState::Removing, + .incarnation = UInt128{77}, + .removal_started_round = 1}, + }; + const auto empty_catalog = backend->get(layout.refCatalogKey()); + ASSERT_TRUE(empty_catalog); + ASSERT_EQ(backend->putOverwrite( + layout.refCatalogKey(), encodeRefCatalog(catalog), empty_catalog->token).outcome, PutOutcome::Done); + backend->resetCounts(); + + Gc gc(store, hexToU128("0000000000000000000000000000000a")); + EXPECT_THROW(gc.runRegularRound({}, /*allow_steal=*/true, UniversePolicy::Authoritative), DB::Exception); + EXPECT_EQ(backend->deleteTotal(), 0u); + EXPECT_THROW(gc.rebuildBaseline(/*force=*/true), DB::Exception); +} + +/// A physical life id carries no reversible logical namespace component. Once the catalog moves a +/// logical name to a new life, the former stream is opaque debris: it cannot redirect GC to that name +/// or contribute an edge to the current-life fold. The separately paced janitor may reclaim its +/// unowned physical objects after that fold. +TEST(CASGCShardIncarnation, DeadLifeStreamIsOpaqueInertDebris) +{ + std::shared_ptr backend; + auto store = makePoolWithShards(backend, /*gc_shards=*/1); + Gc gc(store, hexToU128("0000000000000000000000000000000a")); + const Layout & layout = store->layout(); + const RootNamespace ns{"srv1/tblIncarnationSwap"}; + + CasRefCatalog::casAdmitEntry(*backend, layout, store->poolConfig().gc_shards, CatalogEntry{.ns = ns, .state = NsState::Live, + .incarnation = UInt128(11), .creator = std::nullopt}); // Live forbids a creator fence + const ManifestRef dead_ref = testRef(1); + writeBlobBody(*backend, layout, UInt128(11)); + writeManifestRaw(*backend, layout, ns, dead_ref, {blobEntryFor("dead", UInt128(11))}); + std::vector dead_ops{namespaceBirthOp()}; + const auto dead_committed_ops = publishCommittedOps("part_dead", dead_ref); + dead_ops.insert(dead_ops.end(), dead_committed_ops.begin(), dead_committed_ops.end()); + appendRefLogSeed(*backend, layout, ns, std::move(dead_ops)); // real but unacknowledged record at incarnation 11 + const NamespaceLifeId dead_life = NamespaceLifeId::fromCatalogEntry(ns, UInt128(11)); + + { + CasRefCatalog::Snapshot snap = CasRefCatalog::read(*backend, layout); + const auto it = std::find_if(snap.catalog.entries.begin(), snap.catalog.entries.end(), + [&](const CatalogEntry & e) { return e.ns.string() == ns.string(); }); + ASSERT_NE(it, snap.catalog.entries.end()); + it->incarnation = UInt128(22); // "recreated" -- same name, different (empty) key space + const HeadResult h = backend->head(layout.refCatalogKey()); + ASSERT_TRUE(h.exists); + ASSERT_EQ(backend->putOverwrite(layout.refCatalogKey(), encodeRefCatalog(snap.catalog), h.token).outcome, + PutOutcome::Done); + } + + const NamespaceLifeId current_life = NamespaceLifeId::fromCatalogEntry(ns, UInt128(22)); + ASSERT_EQ(backend->putIfAbsent(layout.refCkptKey(current_life), encodeRefCkpt(RefCkpt{ + .life_epoch = std::optional{1}, + .checkpoint_snapshot_id = std::nullopt, + .last_epoch_seal = std::nullopt})).outcome, PutOutcome::Done); + const ManifestRef current_ref = testRef(2); + writeBlobBody(*backend, layout, UInt128(22)); + writeManifestRaw(*backend, layout, ns, current_ref, {blobEntryFor("current", UInt128(22))}); + std::vector current_ops{namespaceBirthOp()}; + const auto committed_ops = publishCommittedOps("part_current", current_ref); + current_ops.insert(current_ops.end(), committed_ops.begin(), committed_ops.end()); + fixture::writeRefLogRaw(*backend, layout, RefLogTxn{ + .ns = ns.string(), .txn_id = RefTxnId{1, 1}, .ops = std::move(current_ops), .prev_epoch_seal = std::nullopt}); + replaceRecoverableCkptForRawFixture(*backend, layout, ns, RefCkpt{ + .life_epoch = std::optional{1}, + .committed_through = RefTxnId{1, 1}, + .checkpoint_snapshot_id = std::nullopt, + .last_epoch_seal = std::nullopt}); + + const RoundReport report = gc.runRegularRound({}, /*allow_steal=*/true, UniversePolicy::Authoritative); + EXPECT_TRUE(report.anomalies.empty()); + EXPECT_FALSE(report.deferred); + EXPECT_EQ(inDegreeOf(*backend, layout, UInt128(22)), 1); + EXPECT_EQ(inDegreeOf(*backend, layout, UInt128(11)), 0) + << "the unmatched old life must not contribute its unacknowledged edge to the current-life fold"; +} + +/// Checkpoints live in the state tree and are read by exact key from the catalog cut. They are never +/// discovered through the hot stream LIST, so hiding one from LIST must not affect the round. +TEST(CASGCShardIncarnation, CurrentLifeCheckpointIsReadByExactKeyOutsideHotList) +{ + auto backend = std::make_shared>(); + auto store = Pool::open(backend, PoolConfig{ + .pool_prefix = "p", .server_root_id = "test", .gc_shards = 1, + .gc_fold_max_defer_rounds = 0}); + Gc gc(store, hexToU128("0000000000000000000000000000000a")); + const Layout & layout = store->layout(); + const RootNamespace ns{"srv1/tblOrdinaryRebirth"}; + + CasRefCatalog::casAdmitEntry(*backend, layout, store->poolConfig().gc_shards, CatalogEntry{.ns = ns, .state = NsState::Live, + .incarnation = UInt128(11), .creator = std::nullopt}); + + CatalogEntry after_rebirth{.ns = ns, .state = NsState::Live, .incarnation = UInt128(22), .creator = std::nullopt}; + { + CasRefCatalog::Snapshot snap = CasRefCatalog::read(*backend, layout); + const auto it = std::find_if(snap.catalog.entries.begin(), snap.catalog.entries.end(), + [&](const CatalogEntry & e) { return e.ns.string() == ns.string(); }); + ASSERT_NE(it, snap.catalog.entries.end()); + *it = after_rebirth; // "recreated" -- same name, new (current) incarnation 22 + const HeadResult h = backend->head(layout.refCatalogKey()); + ASSERT_TRUE(h.exists); + ASSERT_EQ(backend->putOverwrite(layout.refCatalogKey(), encodeRefCatalog(snap.catalog), h.token).outcome, + PutOutcome::Done); + } + + /// The successor's own genesis `_ckpt`, published for the current physical life. Hiding it from + /// LIST must be irrelevant because the walk obtains state only through exact GETs. + const NamespaceLifeId current_life = NamespaceLifeId::fromCatalogEntry(ns, UInt128(22)); + ASSERT_EQ(backend->putIfAbsent(layout.refCkptKey(current_life), + encodeRefCkpt(RefCkpt{.life_epoch = std::optional{1}, .checkpoint_snapshot_id = std::nullopt, + .last_epoch_seal = std::nullopt})).outcome, PutOutcome::Done); + backend->hide(layout.refCkptKey(current_life)); + backend->resetCounts(); + std::vector phases; + gc.setPhaseSink([&](const GcPhaseRecord & phase) { phases.push_back(phase); }); + + const RoundReport report = gc.runRegularRound({}, /*allow_steal=*/true, UniversePolicy::Authoritative); + EXPECT_FALSE(report.deferred) << "the forced catalog-only fold must reach checkpoint intake"; + EXPECT_GT(backend->getCount(layout.refCkptKey(current_life)), 0u) + << "the catalog-derived current life must drive an exact checkpoint GET"; + EXPECT_EQ(backend->listCount(layout.namespaceStreamRootPrefix()), 1u) + << "the round must build exactly one hot stream plan"; + EXPECT_EQ(backend->listCount(layout.namespaceStateRootPrefix()), 0u) + << "checkpoint state must never receive its own hot LIST"; + EXPECT_EQ(backend->listCount(layout.namespaceRootPrefix()), 1u) + << "the only broader LIST is the separately paced janitor page"; + EXPECT_EQ(backend->holesServed(), 1u) + << "the hidden checkpoint is omitted only from the janitor's broad page, never from the hot stream LIST"; + EXPECT_TRUE(backend->head(layout.refCkptKey(current_life)).exists) + << "the post-page catalog cut retains the current life even when LIST omitted its checkpoint"; + const auto cleanup = std::find_if(phases.begin(), phases.end(), [](const GcPhaseRecord & phase) + { + return phase.phase == "namespace_cleanup"; + }); + ASSERT_NE(cleanup, phases.end()); + EXPECT_EQ(cleanup->metrics.at("janitor_pages"), 1u); + EXPECT_EQ(cleanup->metrics.at("janitor_deleted"), 0u); + bool saw_anomaly_for_ns = false; + for (const RoundAnomaly & a : report.anomalies) + if (a.ns.string() == ns.string()) + saw_anomaly_for_ns = true; + EXPECT_FALSE(saw_anomaly_for_ns) + << "the current life's `_ckpt` is real and readable by its exact catalog-derived key"; +} + +/// A stream life absent from the immutable catalog cut cannot be attributed to any logical namespace. +/// It remains inert debris rather than producing a made-up name or a round anomaly. +TEST(CASGCShardIncarnation, UncatalogedStreamLifeDefersWithoutInventingNamespace) +{ + std::shared_ptr backend; + auto store = makePoolWithShards(backend, /*gc_shards=*/1); + Gc gc(store, hexToU128("0000000000000000000000000000000a")); + const Layout & layout = store->layout(); + const RootNamespace ns{"srv1/tblForgotten"}; + + writeManifestRaw(*backend, layout, ns, testRef(1), {}); + publishCommittedTransition(*backend, layout, ns, "part_1", std::nullopt, testRef(1), /*shard=*/0); + const NamespaceLifeId forgotten_life = store->namespaceLife(ns); + + { + CasRefCatalog::Snapshot snap = CasRefCatalog::read(*backend, layout); + std::erase_if(snap.catalog.entries, [&](const CatalogEntry & e) { return e.ns.string() == ns.string(); }); + const HeadResult h = backend->head(layout.refCatalogKey()); + ASSERT_TRUE(h.exists); + ASSERT_EQ(backend->putOverwrite(layout.refCatalogKey(), encodeRefCatalog(snap.catalog), h.token).outcome, + PutOutcome::Done); + } + + const RoundReport report = gc.runRegularRound({}, /*allow_steal=*/true, UniversePolicy::Authoritative); + EXPECT_TRUE(report.anomalies.empty()); + EXPECT_FALSE(backend->list(layout.namespaceStreamPrefix(forgotten_life), "", 100).keys.empty()); +} + +/// State-tree objects are point-addressed only. A stalled creator's checkpoint and an unowned opaque +/// checkpoint are both outside the hot stream scan and cannot manufacture logical namespace anomalies. +TEST(CASGCShardIncarnation, StateCheckpointsOutsideCatalogAreInertToHotWalk) +{ + auto backend = std::make_shared(); + auto store = Pool::open(backend, + PoolConfig{.pool_prefix = "p", .server_root_id = "test", .gc_shards = 1}); + Gc gc(store, hexToU128("0000000000000000000000000000000a")); + const Layout & layout = store->layout(); + const RootNamespace creating_ns{"srv1/tblStalledBirth"}; + const RootNamespace unrelated_gone_ns{"srv1/tblGenuinelyGone"}; + + /// Step 1 of createNamespace: insert the Creating entry with a live creator fence. + CasRefCatalog::casAdmitEntry(*backend, layout, store->poolConfig().gc_shards, CatalogEntry{.ns = creating_ns, .state = NsState::Creating, + .incarnation = UInt128(33), + .creator = CreatorFence{.server_root_id = "test", .writer_epoch = 1, .fence_generation = 1}}); + /// Step 2, without step 3: publish the genesis `_ckpt` directly, at the SAME incarnation the + /// Creating entry names -- exactly what `completeCreation` durably leaves behind if the creator + /// crashes between its own steps 2 and 3. + const NamespaceLifeId creating_life = NamespaceLifeId::fromCatalogEntry(creating_ns, UInt128(33)); + ASSERT_EQ(backend->putIfAbsent(layout.refCkptKey(creating_life), + encodeRefCkpt(RefCkpt{.life_epoch = std::optional{1}, .checkpoint_snapshot_id = std::nullopt, + .last_epoch_seal = std::nullopt})).outcome, PutOutcome::Done); + + /// Opaque state debris with no corresponding catalog entry. + const NamespaceLifeId gone_life = NamespaceLifeId::fromCatalogEntry(unrelated_gone_ns, UInt128(44)); + ASSERT_EQ(backend->putIfAbsent(layout.refCkptKey(gone_life), + encodeRefCkpt(RefCkpt{.life_epoch = std::optional{1}, .checkpoint_snapshot_id = std::nullopt, + .last_epoch_seal = std::nullopt})).outcome, PutOutcome::Done); + + /// Add one fully current stream so the round performs a fold rather than stopping at an empty + /// walk. Catalog and checkpoint admission keep this traffic out of the janitor's dead-life set, + /// isolating the one deliberately unowned checkpoint below. + const RootNamespace ordinary_ns{"srv1/tblOrdinaryTraffic"}; + fixture::admitLive(*backend, layout, ordinary_ns); + const NamespaceLifeId ordinary_life = fixture::fixtureLife(ordinary_ns); + ASSERT_EQ(backend->putIfAbsent(layout.refCkptKey(ordinary_life), + encodeRefCkpt(RefCkpt{.life_epoch = std::optional{1}, .checkpoint_snapshot_id = std::nullopt, + .last_epoch_seal = std::nullopt})).outcome, PutOutcome::Done); + appendRefLogSeed(*backend, layout, ordinary_ns, {}); + + backend->resetCounts(); + std::vector phases; + gc.setPhaseSink([&](const GcPhaseRecord & phase) { phases.push_back(phase); }); + + const RoundReport report = gc.runRegularRound({}, /*allow_steal=*/true, UniversePolicy::Authoritative); + bool saw_stalled_birth_anomaly = false; + bool saw_genuinely_gone_anomaly = false; + for (const RoundAnomaly & a : report.anomalies) + { + if (a.ns.string() == creating_ns.string()) + saw_stalled_birth_anomaly = true; + if (a.ns.string() == unrelated_gone_ns.string()) + saw_genuinely_gone_anomaly = true; + } + EXPECT_FALSE(saw_stalled_birth_anomaly); + EXPECT_FALSE(saw_genuinely_gone_anomaly); + EXPECT_EQ(backend->listCount(layout.namespaceStreamRootPrefix()), 1u) + << "all hot intake must consume one immutable stream listing"; + EXPECT_EQ(backend->listCount(layout.namespaceStateRootPrefix()), 0u) + << "state checkpoints are never a hot discovery source"; + EXPECT_EQ(backend->listCount(layout.namespaceRootPrefix()), 1u) + << "the only ownership-tree listing belongs to the independently paced janitor"; + EXPECT_GT(backend->getCount(layout.refCkptKey(ordinary_life)), 0u) + << "the cataloged Live life is read by its exact checkpoint key"; + EXPECT_EQ(backend->getCount(layout.refCkptKey(creating_life)), 0u) + << "Creating is retained by the janitor cut but excluded from hot checkpoint intake"; + EXPECT_EQ(backend->getCount(layout.refCkptKey(gone_life)), 0u) + << "uncataloged state debris is classified by the janitor page, never exact-read by the hot walk"; + EXPECT_TRUE(backend->head(layout.refCkptKey(creating_life)).exists); + EXPECT_TRUE(backend->head(layout.refCkptKey(ordinary_life)).exists); + EXPECT_FALSE(backend->head(layout.refCkptKey(gone_life)).exists) + << "catalog absence is inert to the hot walk but authorizes the later janitor exact-token delete"; + const auto cleanup = std::find_if(phases.begin(), phases.end(), [](const GcPhaseRecord & phase) + { + return phase.phase == "namespace_cleanup"; + }); + ASSERT_NE(cleanup, phases.end()); + EXPECT_EQ(cleanup->metrics.at("janitor_pages"), 1u); + EXPECT_EQ(cleanup->metrics.at("janitor_deleted"), 1u); +} + +/// `listNamespaces` projects the authoritative catalog; physical streams never contribute names. +TEST(CASGCShardIncarnation, ListNamespacesFromCatalog) +{ + for (const uint64_t gc_shards : {1u, 4u}) + { + std::shared_ptr backend; + auto store = makePoolWithShards(backend, gc_shards); + + const RootNamespace ns_a{"srv1/tblA"}; + + EXPECT_TRUE(store->listNamespaces("").namespaces.empty()); + + /// The real writer path admits the catalog row for namespace A. + writeManifestRaw(*backend, store->layout(), ns_a, testRef(1), {}); + publishCommittedTransition(*backend, store->layout(), ns_a, "part_1", std::nullopt, testRef(1), /*shard=*/0); + + const auto nss = store->listNamespaces("").namespaces; + ASSERT_EQ(nss.size(), 1u); + EXPECT_EQ(nss[0], "srv1/tblA"); + + /// Prefix filter: no match. + EXPECT_TRUE(store->listNamespaces("srv2/").namespaces.empty()); + /// Prefix filter: match. + const auto filtered = store->listNamespaces("srv1/").namespaces; + ASSERT_EQ(filtered.size(), 1u); + EXPECT_EQ(filtered[0], "srv1/tblA"); + } +} + +/// Task 5: THM-NO-RETURN create-race. A NEWBORN ref-shard is born fenced to the current GC round +/// (self-floor: `fence_round` self-floors to `currentGcRound()` on the create-if-absent branch). +/// +/// Scenario (registry-free create-race): +/// 1. Open a Pool (gc/state absent). +/// 2. Write blob b1's body directly to the backend (present, not yet condemned). +/// 3. Inject gc/state at round 1 with b1 condemned (its current token in the retired set). +/// b1's body is still PRESENT — this simulates GC having fenced+retired b1 but not yet +/// deleted it (the retired-but-body-present window). +/// 4. A writer for NEWBORN ns B calls `precommitAdd` → reads `currentGcRound() = 1` → +/// the NEWBORN shard is born with `fence_round = 1` (self-floor). +/// 5. `promote` binds the condemned-but-present tokenless leaf AS IS (spec +/// 2026-07-09-cas-writer-gc-simplification D5: there is no writer-side view refresh at promote +/// any more). This is safe because the precommit closure's edge is journal-durable BEFORE +/// promote returns (EDGE-BEFORE-OBSERVE): the NEXT GC fold sees net in-degree >= 1 for b1 and +/// SPARES the entry, regardless of when it would otherwise graduate — the condemnation is +/// doomed, never the blob. INV-NO-DANGLE holds (dangling=0 in fsck). +/// +/// Both gc_shards=1 and gc_shards>1 are exercised. The self-floor and promote gate are independent +/// of the blob-hash-prefix sharding axis (fence_round lives in the ROOT shard). +TEST(CASGCShardIncarnation, NewbornPrecommitProtectsDedupBlobAgainstConcurrentDrop) +{ + for (const uint64_t gc_shards : {1u, 4u}) + { + std::shared_ptr backend; + auto store = makePoolWithShards(backend, gc_shards); + const RootNamespace ns_b{"srv1/tblB"}; + + /// --- Phase 1: Write b1's body directly (before any GC). --- + /// Mint b1 under the POOL streaming-hash id (via a throwaway build's putBlob) so the in-closure + /// copy-forward verifier accepts its payload — the plain CityHash test id would be refused. + const String b1_payload = "shared-blob-b1"; + const String b1_hex = streamingHexOf(b1_payload); + const BlobRef b1_ref{BlobHashAlgo::CityHash128, BlobDigest::fromU128(hexToU128(b1_hex))}; + { + auto seed = store->beginPartWrite({}); + seed->putBlob(b1_ref, BlobSource::fromString(b1_payload)); + } + const String b1_key = store->layout().blobKey(b1_ref); + ASSERT_TRUE(backend->head(b1_key).exists) + << "b1 body must be present after the seed putBlob"; + const Token b1_token = backend->head(b1_key).token; + + /// --- Phase 2: Inject gc/state at round 1 with b1 CONDEMNED (body still present). --- + /// This simulates GC having advanced to round 1 and retired b1 (condemned token recorded + /// in the retired set) but not yet deleted b1's body object. + injectRetire(*backend, store->layout(), /*round*/ 1, /*shard*/ 0, + {RetiredEntry{.kind = ObjectKind::Blob, .ref = b1_ref, + .token = b1_token, .size = static_cast(b1_payload.size())}}); + + /// Sanity: currentGcRound() reads gc/state fresh and returns 1. + ASSERT_EQ(store->currentGcRound(), 1u) + << "currentGcRound() must return the injected round"; + + /// --- Phase 3: Writer for NEWBORN ns B — b1 condemned but body present --- + PartWriteInfo info_b; + info_b.intended_ref = ns_b.string() + "/part_b1"; + auto build_b = store->beginPartWrite(info_b); + + /// Adopt b1 by tokenless evidence (simulating the dedup case: the writer observed b1 + /// present BEFORE the GC round — no HEAD here, just evidence). + ManifestEntry dep_b1; + dep_b1.path = "data.bin"; + dep_b1.placement = EntryPlacement::Blob; + dep_b1.ref = DB::Cas::BlobRef{DB::Cas::BlobHashAlgo::CityHash128, DB::Cas::BlobDigest::fromU128(hexToU128(b1_hex))}; + + dep_b1.blob_size = b1_payload.size(); + build_b->adoptEvidence(dep_b1); + + const ManifestId id_b = build_b->stageManifest({dep_b1}); + + /// precommitAdd: NEWBORN shard does not exist yet. Reads currentGcRound() = 1 → stamps + /// fence_round = 1 (self-floor). An existing shard would keep its old fence_round. + build_b->precommitAdd(ns_b, "part_b1", id_b); + + /// --- Phase 4: promote — the safety assertion (Phase-A contract) --- + /// Spec 2026-07-09-cas-writer-gc-simplification D5: there is NO writer-side view refresh at + /// promote any more — the K3 gate binds the condemned-but-present token AS IS. This is SAFE + /// because the precommit closure's edge has been journal-durable since precommitAdd (BEFORE + /// promote returns), so the NEXT GC fold sees net in-degree >= 1 for b1 and SPARES the entry + /// (EDGE-BEFORE-OBSERVE) regardless of round-paced graduation timing — the condemnation is + /// doomed, never the blob. (No GC round runs in this test at all; the argument is what makes + /// deferring the round safe, not something this test drives to completion.) + /// The former behavior (self-floor-forced refresh → in-closure copy-forward → fresh incarnation) + /// was TLA+-Gate-A-verified redundant; the shard's fence_round stamp itself (THM-NO-RETURN birth + /// floor) remains and is asserted by the sibling shard-incarnation tests. + EXPECT_NO_THROW(build_b->promote(ns_b, "part_b1", build_b->buildId(), id_b)) + << "gc_shards=" << gc_shards << ": promote must commit — the durable edge protects the " + "condemned-but-present tokenless leaf without any refresh or copy-forward"; + EXPECT_TRUE(store->resolveRef(ns_b, "part_b1").has_value()) + << "gc_shards=" << gc_shards << ": the ref must commit"; + /// The condemned token is bound UNCHANGED — no displacement happens (and none is needed). + EXPECT_EQ(backend->head(b1_key).token, b1_token) + << "gc_shards=" << gc_shards << ": no copy-forward under the Phase-A contract — the token " + "stays; the folded edge will spare it at the next fold (no round runs here to delete it)"; + + /// INV-NO-DANGLE: the body is present and no GC round ever runs in this test to fold the + /// precommit/committed edge; a real deployment's next fold would see net in-degree >= 1 and + /// spare the entry. A regression that let the delete pipeline race a live durable edge would + /// produce dangling=1 here. + const FsckReport rep = runFsck(*store, /*detail=*/false); + EXPECT_EQ(rep.dangling, 0u) + << "gc_shards=" << gc_shards << ": INV-NO-DANGLE violated — a committed ref names a " + "missing blob (dangling=" << rep.dangling << ", reachable=" << rep.reachable << ")"; + } +} + +/// The five shard-OBJECT-reclaim tests that used to follow (`DroppedShardObjectIsReclaimed`, +/// `IdleButLiveShardNotReclaimed`, `RecreateAfterReclaimFoldsFromZero`, `ActivatedPrecommitBlocksShardReclaim`, +/// `ReviveRacesReclaimAborts`) were removed with the snapshot+log ref model. They asserted GC reclaims / +/// token-guards a MUTABLE per-namespace ref-shard object at `rootShardKey(ns, shard)`. There is no such +/// mutable object anymore: a namespace's ref state is its immutable `_log`/`_snap` objects, physical +/// reclamation belongs to the perpetual namespace janitor, and ABA safety is structural -- a recreated +/// namespace uses a different opaque life id. The still-meaningful reincarnation case (a terminal old +/// life followed by a new life folds without inheriting the old cursor) is covered by +/// `gtest_cas_ref_gc.cpp`; lifecycle completion itself requires only folded terminal evidence and the +/// exact catalog-row mutation. diff --git a/src/Disks/tests/gtest_cas_gc_shard_plan.cpp b/src/Disks/tests/gtest_cas_gc_shard_plan.cpp new file mode 100644 index 000000000000..c45cd3ff772d --- /dev/null +++ b/src/Disks/tests/gtest_cas_gc_shard_plan.cpp @@ -0,0 +1,637 @@ +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include "cas_test_helpers.h" + +using namespace DB::Cas; +using namespace DB::Cas::tests; + +TEST(CASGCShardConfig, DefaultIsSingleShard) +{ + PoolConfig cfg; + EXPECT_EQ(cfg.gc_shards, 1u); + EXPECT_EQ(cfg.manifest_sweep_list_budget_keys, 1000u); + EXPECT_EQ(cfg.manifest_sweep_delete_budget_keys, 100u); +} + +TEST(CASGCShardConfig, GcStateRoundTripPreservesShardCount) +{ + GcState s; + s.gc_shards = 4; + s.round = 7; + const GcState d = decodeGcState(encodeGcState(s)); + EXPECT_EQ(d.gc_shards, 4u); + EXPECT_EQ(d.round, 7u); +} + +/// ---- blobShard tests (Phase 4, Task 3) ---- + +TEST(CASGCShardScatter, DeterministicAndStable) +{ + /// A fixed hash — the same bytes every run. blobShard must return the same value twice, + /// must be strictly less than gc_shards=4, and must be 0 when gc_shards=1. + const UInt128 h = hexToU128("0102030405060708090a0b0c0d0e0f10"); + const BlobRef hd{BlobHashAlgo::CityHash128, BlobDigest::fromU128(h)}; + + const uint64_t s4a = blobShard(hd, 4); + const uint64_t s4b = blobShard(hd, 4); + + EXPECT_EQ(s4a, s4b) << "blobShard must be deterministic"; + EXPECT_LT(s4a, 4u) << "blobShard result must be < gc_shards"; + EXPECT_EQ(blobShard(hd, 1), 0u) << "gc_shards==1 must route every hash to shard 0"; +} + +TEST(CASGCShardScatter, DisjointCoverageOverManyHashes) +{ + /// Over 4096 spread-out hashes with gc_shards=4: every result in [0,4) and every shard + /// gets at least one hash (no dead shard). + constexpr uint64_t kNumHashes = 4096; + constexpr uint64_t kShards = 4; + + std::vector seen(kShards, false); + for (uint64_t i = 0; i < kNumHashes; ++i) + { + /// Spread: use i in the high and low halves to avoid clustering. + const UInt128 h = (static_cast(i * 0x9e3779b97f4a7c15ULL) << 64) + | static_cast(i * 0x6c62272e07bb0142ULL); + const uint64_t s = blobShard(BlobRef{BlobHashAlgo::CityHash128, BlobDigest::fromU128(h)}, kShards); + ASSERT_LT(s, kShards) << "blobShard out of range at i=" << i; + seen[s] = true; + } + + for (uint64_t s = 0; s < kShards; ++s) + EXPECT_TRUE(seen[s]) << "shard " << s << " received no hashes (dead shard)"; +} + +/// ---- ShardReducer tests (Phase 4, Task 4) ---- + +/// Build two blob hashes that route to DIFFERENT shards under gc_shards=2. +/// Returns {hash_for_shard0, hash_for_shard1}. +static std::pair makeTwoShardHashes() +{ + /// Scan pairs (i, j): find hash_a -> shard 0, hash_b -> shard 1 under gc_shards=2. + /// We construct candidates by setting the high 64 bits and leaving the low 64 bits zero + /// so blobShard = high64 % 2. i=0 => shard 0, i=1 => shard 1. + const UInt128 h0 = static_cast(0ULL) << 64; /// high64=0 => shard 0 + const UInt128 h1 = static_cast(1ULL) << 64; /// high64=1 => shard 1 + return {BlobRef{BlobHashAlgo::CityHash128, BlobDigest::fromU128(h0)}, + BlobRef{BlobHashAlgo::CityHash128, BlobDigest::fromU128(h1)}}; +} + +/// `ShardReducer::reduce` merges deltas into the correct per-shard in-degree run. +/// +/// Scenario: scatter (+1 b1, +1 b1, -1 b1, +1 b2) across two shards. +/// - b1 routes to shard 0; net = +2 - 1 = 1; in-degree after reduce = 1. +/// - b2 routes to shard 1; net = +1; in-degree after reduce = 1. +/// - Each reducer touches ONLY its own shard's key space. +TEST(CASGCShardReducer, MergesDeltasToInDegree) +{ + const auto [b1, b2] = makeTwoShardHashes(); + ASSERT_EQ(blobShard(b1, 2), 0u) << "b1 must route to shard 0"; + ASSERT_EQ(blobShard(b2, 2), 1u) << "b2 must route to shard 1"; + + /// Construct source-edge deltas directly (the production fold produces these via + /// `foldManifestEdges`, bucketed by `blobShard`): + /// b1 shard=0: source 1 activates, source 2 activates, source 1 removes => 1 active edge + /// b2 shard=1: source 3 activates => 1 active edge + std::vector> buckets(2); + buckets[0] = { + BlobDelta{.ref = b1, .source_id = UInt128(1), .remove = false}, + BlobDelta{.ref = b1, .source_id = UInt128(2), .remove = false}, + BlobDelta{.ref = b1, .source_id = UInt128(1), .remove = true}, + }; + buckets[1] = { + BlobDelta{.ref = b2, .source_id = UInt128(3), .remove = false}, + }; + + /// Verify bucket net effects (source 2 survives for b1; source 3 survives for b2). + ASSERT_EQ(buckets.size(), 2u); + { + int64_t net_b1 = 0; + for (const auto & d : buckets[0]) + if (d.ref == b1) + net_b1 += d.remove ? -1 : +1; + EXPECT_EQ(net_b1, 1) << "shard-0 bucket net delta for b1 must be +1"; + } + { + int64_t net_b2 = 0; + for (const auto & d : buckets[1]) + if (d.ref == b2) + net_b2 += d.remove ? -1 : +1; + EXPECT_EQ(net_b2, 1) << "shard-1 bucket net delta for b2 must be +1"; + } + + /// Reduce: each reducer merges its shard's deltas into generation 1 (prior = 0 = fresh). + auto backend = std::make_shared(); + const Layout layout("p"); + + ShardReducer r0(0, 2); + ShardReducer r1(1, 2); + + EXPECT_TRUE(r0.owns(b1)) << "r0 must own b1"; + EXPECT_FALSE(r0.owns(b2)) << "r0 must not own b2"; + EXPECT_TRUE(r1.owns(b2)) << "r1 must own b2"; + EXPECT_FALSE(r1.owns(b1)) << "r1 must not own b1"; + + const auto runs0 = r0.reduce(*backend, layout, /*prior_runs=*/{}, /*new_generation=*/1, /*attempt=*/0, + std::move(buckets[0])); + const auto runs1 = r1.reduce(*backend, layout, /*prior_runs=*/{}, /*new_generation=*/1, /*attempt=*/0, + std::move(buckets[1])); + + ASSERT_EQ(runs0.size(), 1u) << "shard-0 reduce must produce exactly one RunRef"; + ASSERT_EQ(runs1.size(), 1u) << "shard-1 reduce must produce exactly one RunRef"; + + /// The keys must be distinct (disjoint shard namespaces). + EXPECT_NE(runs0[0].key, runs1[0].key) << "shard-0 and shard-1 run keys must be distinct"; + + /// Read back in-degree from the sealed runs (resolved via each reduce's returned refs). + const int64_t indeg_b1 = inDegreeInRuns(*backend, runs0, b1); + const int64_t indeg_b2 = inDegreeInRuns(*backend, runs1, b2); + EXPECT_EQ(indeg_b1, 1) << "b1 in-degree after reduce must be 1"; + EXPECT_EQ(indeg_b2, 1) << "b2 in-degree after reduce must be 1"; + + /// Cross-shard reads: shard-0's run must not contain b2; shard-1's run must not contain b1. + EXPECT_EQ(inDegreeInRuns(*backend, runs0, b2), 0) + << "shard-0 run must not mention b2"; + EXPECT_EQ(inDegreeInRuns(*backend, runs1, b1), 0) + << "shard-1 run must not mention b1"; +} + +/// `ShardReducer::owns` partitions the blob hash space: for any hash, exactly ONE reducer among +/// {r0, r1} owns it (union == all, intersection == empty). +TEST(CASGCShardReducer, TwoReducersCoverDisjointShards) +{ + constexpr uint64_t kNumHashes = 4096; + constexpr uint64_t kGcShards = 2; + + ShardReducer r0(0, kGcShards); + ShardReducer r1(1, kGcShards); + + for (uint64_t i = 0; i < kNumHashes; ++i) + { + const UInt128 h = (static_cast(i * 0x9e3779b97f4a7c15ULL) << 64) + | static_cast(i * 0x6c62272e07bb0142ULL); + const BlobRef href{BlobHashAlgo::CityHash128, BlobDigest::fromU128(h)}; + const bool o0 = r0.owns(href); + const bool o1 = r1.owns(href); + + /// Exactly one of the two reducers must own every hash. + ASSERT_TRUE(o0 || o1) + << "hash " << i << " is owned by neither shard (gap in coverage)"; + ASSERT_FALSE(o0 && o1) + << "hash " << i << " is owned by BOTH shards (overlap in coverage)"; + } +} + +/// ---- manifestCleanupShard tests (Phase 4, Task 5) ---- + +/// Two `ManifestId`s with the SAME `ManifestRef` but DIFFERENT namespaces must be unequal (proving +/// qualified identity), and `manifestCleanupShard` must depend on the namespace — not just the ref. +/// +/// Phase 0 `SabotageKeyByRefNotId`: if routing used only the `ManifestRef`, two namespaces sharing +/// the same ref would land on the same worker, merging cleanup work that belongs to distinct objects. +TEST(CASGCShardCleanup, RoutesByQualifiedManifestIdNotRef) +{ + /// Shared ManifestRef: identical across both ManifestIds. + const ManifestRef shared_ref{ + .writer_epoch = 1, + .build_sequence = 7, + .manifest_ordinal = 1, + }; + + const ManifestId id_a{RootNamespace("ns_alpha"), shared_ref}; + const ManifestId id_b{RootNamespace("ns_beta"), shared_ref}; + + /// The two ids are unequal (different namespace => different qualified identity). + EXPECT_NE(id_a, id_b) << "ManifestIds with different namespaces must be unequal"; + + /// Both results must be in range. + constexpr uint64_t kShards = 4; + const uint64_t shard_a = manifestCleanupShard(id_a, kShards); + const uint64_t shard_b = manifestCleanupShard(id_b, kShards); + EXPECT_LT(shard_a, kShards) << "shard for id_a must be < gc_shards"; + EXPECT_LT(shard_b, kShards) << "shard for id_b must be < gc_shards"; + + /// Deterministic: same id always routes to the same shard. + EXPECT_EQ(manifestCleanupShard(id_a, kShards), shard_a) << "manifestCleanupShard must be deterministic"; + EXPECT_EQ(manifestCleanupShard(id_b, kShards), shard_b) << "manifestCleanupShard must be deterministic"; + + /// Single-shard equivalence: gc_shards==1 routes everything to shard 0. + EXPECT_EQ(manifestCleanupShard(id_a, 1), 0u) << "gc_shards==1 must route to shard 0"; + EXPECT_EQ(manifestCleanupShard(id_b, 1), 0u) << "gc_shards==1 must route to shard 0"; + + /// KEY ASSERTION: routing depends on the namespace, not the ref alone. + /// Scan namespace-pair candidates (varying only the namespace string) until we find two that + /// route to different shards under gc_shards=8. This directly demonstrates that + /// `manifestCleanupShard` is NOT a function of `ManifestRef` alone. + bool found_namespace_split = false; + for (uint64_t i = 0; i < 256 && !found_namespace_split; ++i) + { + const ManifestId probe_a{RootNamespace("namespace_probe_" + std::to_string(i)), shared_ref}; + for (uint64_t j = i + 1; j < 256 && !found_namespace_split; ++j) + { + const ManifestId probe_b{RootNamespace("namespace_probe_" + std::to_string(j)), shared_ref}; + if (manifestCleanupShard(probe_a, 8) != manifestCleanupShard(probe_b, 8)) + found_namespace_split = true; + } + } + EXPECT_TRUE(found_namespace_split) + << "could not find two namespace variants of the same ManifestRef that route to different " + "shards — routing is not namespace-sensitive (SabotageKeyByRefNotId hazard)"; +} + +/// Over many `ManifestId`s with `gc_shards=4`: every owner shard is covered, and each id lands in +/// exactly one shard (total, disjoint coverage). +TEST(CASGCShardCleanup, DisjointWorkerCoverage) +{ + constexpr uint64_t kNumIds = 4096; + constexpr uint64_t kShards = 4; + + std::vector seen(kShards, false); + for (uint64_t i = 0; i < kNumIds; ++i) + { + /// Vary both namespace and ManifestRef fields to spread the distribution. + const ManifestId id{ + RootNamespace("ns_" + std::to_string(i % 16)), + ManifestRef{ + .writer_epoch = 1 + i / 16, + .build_sequence = i, + .manifest_ordinal = static_cast(i % kMaxManifestOrdinal + 1), + }, + }; + + const uint64_t s = manifestCleanupShard(id, kShards); + ASSERT_LT(s, kShards) << "manifestCleanupShard out of range at i=" << i; + seen[s] = true; + } + + for (uint64_t s = 0; s < kShards; ++s) + EXPECT_TRUE(seen[s]) << "owner shard " << s << " received no ManifestIds (dead shard)"; +} + +/// The sharded fold (gc_shards > 1) partitions a flat `BlobDelta` stream by `blobShard` and folds +/// each bucket via its own `ShardReducer`, exactly as `Gc::fold` does. This test replicates that +/// partition-and-reduce step over `gc_shards = 2` and asserts each blob's in-degree lands in its +/// owning shard's run and nowhere else. (The full two-replica round is covered by Task 8.) +TEST(CASGCShardCoordinator, ShardedFoldRoutesDeltasToOwningShards) +{ + constexpr uint64_t kGcShards = 2; + const auto [b0, b1] = makeTwoShardHashes(); + ASSERT_EQ(blobShard(b0, kGcShards), 0u); + ASSERT_EQ(blobShard(b1, kGcShards), 1u); + + /// A flat delta stream as produced by `foldManifestEdges`: b0 net +1 (two +1, one -1), b1 net +1. + std::vector deltas{ + BlobDelta{.ref = b0, .source_id = UInt128(1), .remove = false}, + BlobDelta{.ref = b1, .source_id = UInt128(2), .remove = false}, + BlobDelta{.ref = b0, .source_id = UInt128(3), .remove = false}, + BlobDelta{.ref = b0, .source_id = UInt128(1), .remove = true}, + }; + + /// Partition by blobShard — the exact step the sharded fold runs before reducing. + std::vector> buckets(kGcShards); + for (BlobDelta & d : deltas) + buckets[blobShard(d.ref, kGcShards)].push_back(d); + + auto backend = std::make_shared(); + const Layout layout("p"); + + std::vector> shard_runs(kGcShards); + for (uint64_t shard = 0; shard < kGcShards; ++shard) + { + ShardReducer reducer{shard, kGcShards}; + shard_runs[shard] = reducer.reduce(*backend, layout, /*prior_runs=*/{}, /*new_generation=*/1, /*attempt=*/0, + std::move(buckets[shard])); + } + + EXPECT_EQ(inDegreeInRuns(*backend, shard_runs[0], b0), 1) + << "b0 must fold into shard-0 with in-degree 1"; + EXPECT_EQ(inDegreeInRuns(*backend, shard_runs[1], b1), 1) + << "b1 must fold into shard-1 with in-degree 1"; + EXPECT_EQ(inDegreeInRuns(*backend, shard_runs[1], b0), 0) + << "b0 must NOT appear in shard-1's run"; + EXPECT_EQ(inDegreeInRuns(*backend, shard_runs[0], b1), 0) + << "b1 must NOT appear in shard-0's run"; +} + +/// ---- Phase 4, Task 7: single-shard equivalence ---- +/// +/// Prove that the sharded partition+reduce path (gc_shards=2, all blobs routing to shard 0) produces +/// the SAME per-blob in-degrees as the single-shard (gc_shards=1, Phase 1d) fold over an IDENTICAL +/// journal. This is approach (a) from the spec: choose blob hashes whose high64 % 2 == 0 so shard 1's +/// bucket is always empty; the sharded path's shard-0 reducer and the single-shard path both call +/// `foldDeltasIntoGeneration` with the same delta stream (one routing into shard 0 of 2, the other +/// into shard 0 of 1). +/// +/// NOTE ON SEAL-BYTE EQUALITY: byte-for-byte equality of the `CasFoldSeal` is NOT asserted here. The +/// fold seal records the `blobTargetRunKey(gen, shard, seq)` path, which embeds the shard number. The +/// single-shard path writes `blobTargetRunKey(g, 0, 0)` for gc_shards=1, while the sharded path writes +/// `blobTargetRunKey(g, 0, 0)` for the shard-0 run AND `blobTargetRunKey(g, 1, 0)` for the (empty) +/// shard-1 run. The per-blob in-degree (the load-bearing property — it drives the spare/delete +/// decision) is identical; the seal's key-set legitimately differs by shard count. +TEST(CASGCShardEquivalence, SingleShardMatchesPhase1dInDegree) +{ + /// Build three blob hashes that ALL route to shard 0 under gc_shards=2 (high64 % 2 == 0). + /// high64=0 => shard 0, high64=2 => shard 0, high64=4 => shard 0. + const UInt128 hA = static_cast(0ULL) << 64; /// high64=0, routes to shard 0 + const UInt128 hB = static_cast(2ULL) << 64; /// high64=2, routes to shard 0 + const UInt128 hC = static_cast(4ULL) << 64; /// high64=4, routes to shard 0 + + const BlobRef refA{BlobHashAlgo::CityHash128, BlobDigest::fromU128(hA)}; + const BlobRef refB{BlobHashAlgo::CityHash128, BlobDigest::fromU128(hB)}; + const BlobRef refC{BlobHashAlgo::CityHash128, BlobDigest::fromU128(hC)}; + ASSERT_EQ(blobShard(refA, 2), 0u) << "hA must route to shard 0 under gc_shards=2"; + ASSERT_EQ(blobShard(refB, 2), 0u) << "hB must route to shard 0 under gc_shards=2"; + ASSERT_EQ(blobShard(refC, 2), 0u) << "hC must route to shard 0 under gc_shards=2"; + ASSERT_EQ(blobShard(refA, 1), 0u) << "hA must route to shard 0 under gc_shards=1"; + ASSERT_EQ(blobShard(refB, 1), 0u) << "hB must route to shard 0 under gc_shards=1"; + ASSERT_EQ(blobShard(refC, 1), 0u) << "hC must route to shard 0 under gc_shards=1"; + + /// Construct the journal: hA gets net +2 (published twice), hB gets net +1, hC gets net 0 (publish + /// then drop => transitions to zero). This exercises all three outcomes (>1, =1, =0) for the + /// equivalence proof. + /// + /// Note: net +2 is unrealistic for production (two DISTINCT manifests can share a blob, each + /// contributing +1 independently) but is valid for the fold math test. It directly verifies that + /// accumulators sum correctly under both paths. + const RootNamespace ns{"ns-equiv"}; + const ManifestRef rA1{.writer_epoch = 1, .build_sequence = 1, .manifest_ordinal = static_cast(0x1)}; + const ManifestRef rA2{.writer_epoch = 1, .build_sequence = 2, .manifest_ordinal = static_cast(0x2)}; + const ManifestRef rB{.writer_epoch = 1, .build_sequence = 3, .manifest_ordinal = static_cast(0x3)}; + const ManifestRef rC{.writer_epoch = 1, .build_sequence = 4, .manifest_ordinal = static_cast(0x4)}; + + /// Helper lambda that sets up a fresh backend + store with the shared scripted journal, runs one GC + /// round with the given gc_shards, and returns the per-blob in-degrees in the sealed generation. + /// Returns {indeg_A, indeg_B, indeg_C}. + auto runJournalAndGetInDegrees = [&](uint64_t gc_shards) -> std::tuple + { + auto backend = std::make_shared(); + const Layout layout("p"); + /// Raw journal fixtures model an already-created pool and therefore establish both mandatory + /// controls before writing residual data. + seedPoolMetaForRestart(*backend); + + /// Write blob bodies so HEAD returns a token (GC retires zero-in-degree blobs only if present). + writeBlobBody(*backend, layout, hA); + writeBlobBody(*backend, layout, hB); + writeBlobBody(*backend, layout, hC); + + /// Write manifests: rA1 references hA once; rA2 also references hA once; rB references hB; + /// rC references hC. Each publication contributes +1 per referenced blob. + writeManifestRaw(*backend, layout, ns, rA1, {blobEntryFor("a", hA)}); + writeManifestRaw(*backend, layout, ns, rA2, {blobEntryFor("a", hA)}); + writeManifestRaw(*backend, layout, ns, rB, {blobEntryFor("b", hB)}); + writeManifestRaw(*backend, layout, ns, rC, {blobEntryFor("c", hC)}); + + /// Publish all four refs (tbl1=rA1, tbl2=rA2, tbl3=rB, tbl4=rC). + publishCommittedTransition(*backend, layout, ns, "tbl1", std::nullopt, rA1); + publishCommittedTransition(*backend, layout, ns, "tbl2", std::nullopt, rA2); + publishCommittedTransition(*backend, layout, ns, "tbl3", std::nullopt, rB); + publishCommittedTransition(*backend, layout, ns, "tbl4", std::nullopt, rC); + /// Drop tbl4 (hC net = 0): rC removed from the live set. + dropRefTransition(*backend, layout, ns, "tbl4", rC); + + /// Open a store with the given `gc_shards` over the pre-seeded restart state. + auto store = Pool::open(backend, PoolConfig{.pool_prefix = "p", .server_root_id = "test", .gc_shards = gc_shards}); + const UInt128 gc_id = UInt128(0xDEADBEEF42ULL); + Gc gc(store, gc_id); + EXPECT_TRUE(gc.runRegularRound().acquired_lease); + + /// The fold seal for new_generation (== snap_generation after fold) holds the in-degree runs. + /// After runRegularRound the snap_generation points at the COMPLETION generation; the fold + /// generation is snap_generation - 1 for the first full round. Use inDegreeOf (which reads + /// currentGenerationOf = completion generation) for the final in-degrees. + const std::vector shard0 = runsForShard(*backend, layout, /*shard=*/0); + const int64_t iA = inDegreeInRuns(*backend, shard0, refA); + const int64_t iB = inDegreeInRuns(*backend, shard0, refB); + const int64_t iC = inDegreeInRuns(*backend, shard0, refC); + return {iA, iB, iC}; + }; + + const auto [a1, b1_indeg, c1] = runJournalAndGetInDegrees(/*gc_shards=*/1); + const auto [a2, b2_indeg, c2] = runJournalAndGetInDegrees(/*gc_shards=*/2); + + /// The in-degree values must match exactly between the two runs. + EXPECT_EQ(a1, a2) + << "hA in-degree must match: gc_shards=1 gives " << a1 << ", gc_shards=2 gives " << a2; + EXPECT_EQ(b1_indeg, b2_indeg) + << "hB in-degree must match: gc_shards=1 gives " << b1_indeg << ", gc_shards=2 gives " << b2_indeg; + EXPECT_EQ(c1, c2) + << "hC in-degree must match: gc_shards=1 gives " << c1 << ", gc_shards=2 gives " << c2; + + /// Cross-check the known correct values (derivable from the scripted journal). + /// hA: +1 (tbl1/rA1) + 1 (tbl2/rA2) = 2. + EXPECT_EQ(a1, 2) << "hA in-degree must be 2 (two distinct live refs both citing hA)"; + /// hB: +1 (tbl3/rB) = 1. + EXPECT_EQ(b1_indeg, 1) << "hB in-degree must be 1"; + /// hC: +1 (tbl4/rC publish) - 1 (tbl4 drop) = 0. + EXPECT_EQ(c1, 0) << "hC in-degree must be 0 (publish then drop; net zero)"; +} + +/// ---- Phase 4, Task 8: two-replica disjoint-shard concurrency ---- +/// +/// With gc_shards=2 over a shared `InMemoryBackend`: +/// (a) DISJOINTNESS: a shard-0 reducer's product covers only hashes routing to shard 0; shard-1 +/// covers only hashes routing to shard 1 (`owns` check). +/// (b) PER-SHARD RUNS: each reducer writes its own write-once blob-target run; the runs for the two +/// shards are disjoint object keys and durably present after each `ShardReducer::reduce`. +/// (c) MERGED IN-DEGREE: the merged in-degrees across both shards equal the expected edge multiset, +/// and each blob is absent from the other shard's run (cross-shard disjointness). +/// +/// Interleaving: driven entirely from the test thread (no threads, no sleeps). The two reducers are +/// constructed and called sequentially from the test thread. This proves the protocol is correct even +/// when reducer work interleaves arbitrarily — the key-space disjointness is static. +TEST(CASGCShardTwoReplica, DisjointShardsConcurrentPerShardRuns) +{ + constexpr uint64_t kGcShards = 2; + constexpr uint64_t kNewGen = 1; + constexpr uint64_t kAttempt = 0; + + /// b0 routes to shard 0, b1 routes to shard 1 (from makeTwoShardHashes). + const auto [b0, b1] = makeTwoShardHashes(); + ASSERT_EQ(blobShard(b0, kGcShards), 0u) << "b0 must route to shard 0"; + ASSERT_EQ(blobShard(b1, kGcShards), 1u) << "b1 must route to shard 1"; + + auto backend = std::make_shared(); + const Layout layout("p"); + + /// (a) DISJOINTNESS — verify `owns` predicate before any reduce. + ShardReducer r0(0, kGcShards); + ShardReducer r1(1, kGcShards); + + EXPECT_TRUE(r0.owns(b0)) << "shard-0 reducer must own b0"; + EXPECT_FALSE(r0.owns(b1)) << "shard-0 reducer must NOT own b1"; + EXPECT_TRUE(r1.owns(b1)) << "shard-1 reducer must own b1"; + EXPECT_FALSE(r1.owns(b1) && r0.owns(b1)) << "no hash may be owned by both reducers"; + + /// Construct disjoint delta streams: b0 gets net +2 in shard 0; b1 gets net +1 in shard 1. + /// In production these buckets are produced by `foldManifestEdges` and partitioned by `blobShard` + /// (two distinct manifests both referencing b0 contribute two source edges; one manifest + /// referencing b1 contributes one source edge). + std::vector bucket0 = { + BlobDelta{.ref = b0, .source_id = UInt128(1), .remove = false}, + BlobDelta{.ref = b0, .source_id = UInt128(2), .remove = false}, + }; + std::vector bucket1 = { + BlobDelta{.ref = b1, .source_id = UInt128(3), .remove = false}, + }; + + /// (b) PER-SHARD RUNS — drive both reducers. + /// + /// Run shard-0 reducer (simulates the shard-0 replica's work). + const auto runs0 = r0.reduce(*backend, layout, /*prior_runs=*/{}, kNewGen, kAttempt, std::move(bucket0)); + ASSERT_FALSE(runs0.empty()) << "shard-0 reducer must produce at least one RunRef"; + + /// Run shard-1 reducer (simulates the shard-1 replica's work, interleaved from the test thread). + const auto runs1 = r1.reduce(*backend, layout, /*prior_runs=*/{}, kNewGen, kAttempt, std::move(bucket1)); + ASSERT_FALSE(runs1.empty()) << "shard-1 reducer must produce at least one RunRef"; + + /// The blob-target runs for both shards are durably present (the reducer's write-once `putIfAbsent`), + /// at disjoint object keys. + EXPECT_TRUE(backend->head(layout.blobTargetRunKey(kNewGen, kAttempt, /*shard=*/0, /*seq=*/0)).exists) + << "shard-0 blob-target run must be durably written by r0.reduce"; + EXPECT_TRUE(backend->head(layout.blobTargetRunKey(kNewGen, kAttempt, /*shard=*/1, /*seq=*/0)).exists) + << "shard-1 blob-target run must be durably written by r1.reduce"; + + /// (c) MERGED IN-DEGREE — the merged in-degrees across both shards equal the expected edge multiset. + EXPECT_EQ(inDegreeInRuns(*backend, runs0, b0), 2) + << "b0 in-degree must be 2 in shard-0 run"; + EXPECT_EQ(inDegreeInRuns(*backend, runs1, b1), 1) + << "b1 in-degree must be 1 in shard-1 run"; + /// Cross-shard: each blob must be absent from the other shard's run. + EXPECT_EQ(inDegreeInRuns(*backend, runs0, b1), 0) + << "b1 must NOT appear in shard-0's run (cross-shard disjointness)"; + EXPECT_EQ(inDegreeInRuns(*backend, runs1, b0), 0) + << "b0 must NOT appear in shard-1's run (cross-shard disjointness)"; +} + +/// ---- Phase 4 regression: gc_shards>1 retire-drain (High #1) ---- +/// +/// A FULL round-protocol regression that drives publish -> drop -> reclaim end-to-end under +/// `gc_shards = 2` with a droppable blob owned by a NON-zero shard. The fold/`ShardReducer` write one +/// in-degree run PER shard, so a zero-in-degree blob owned by shard 1..N is only ever retired (and +/// then exact-token deleted) if `retire`/`previewDeletes` scan EVERY blob-target shard. Before +/// `5f5fa5f7906` both hardcoded shard 0: a shard-1 candidate was never scanned, never retired, and +/// leaked forever. After the fix both shards are drained. +/// +/// The test plants TWO droppable blobs in the SAME round — one owned by shard 0, one owned by shard 1 +/// (verified via `blobShard(hash, 2)`) — and asserts BOTH are reclaimed. The shard-0 blob proves the +/// round works at all; the shard-1 blob is the regression's teeth (it would leak pre-fix while shard-0 +/// still drained, so a single-blob test could pass even with the bug). +/// +/// HOW IT WOULD LEAK PRE-FIX: under the old shard-0-only `retire`, the round folds the drop (shard-1 +/// blob's in-degree -> 0 in shard 1's run) but `retire` only reads shard 0's in-degree run and only +/// writes shard 0's retired set, so the shard-1 zero-in-degree blob is never proposed for retirement. +/// `previewDeletes` (also shard-0-only pre-fix) never lists it, the recheck never spares-or-deletes it, +/// and `blobExists(b1)` stays true at fixpoint. The shard-0 blob would still be reclaimed — which is +/// exactly why the existing in-degree-equivalence tests (all blobs route to shard 0) did not catch it. +TEST(CASGCShardRetireDrain, ReclaimsDroppableBlobOwnedByNonZeroShard) +{ + constexpr uint64_t kGcShards = 2; + + /// Two blob hashes routing to DIFFERENT shards under gc_shards=2. blobShard = high64 % 2. + /// high64=0 => shard 0; high64=1 => shard 1. + const UInt128 blob_shard0 = (static_cast(0ULL) << 64) | static_cast(7ULL); /// high64=0 => shard 0 + const UInt128 blob_shard1 = (static_cast(1ULL) << 64) | static_cast(7ULL); /// high64=1 => shard 1 + ASSERT_EQ(blobShard(BlobRef{BlobHashAlgo::CityHash128, BlobDigest::fromU128(blob_shard0)}, kGcShards), 0u) << "blob_shard0 must route to shard 0"; + ASSERT_EQ(blobShard(BlobRef{BlobHashAlgo::CityHash128, BlobDigest::fromU128(blob_shard1)}, kGcShards), 1u) << "blob_shard1 must route to shard 1 (regression teeth)"; + + auto backend = std::make_shared(); + auto store = Pool::open(backend, PoolConfig{.pool_prefix = "p", .server_root_id = "test", + .gc_shards = kGcShards}); + const Layout & layout = store->layout(); + + const RootNamespace ns{"00/aa@cas@"}; + const ManifestRef r0{.writer_epoch = 1, .build_sequence = 1, .manifest_ordinal = static_cast(0xA0)}; + const ManifestRef r1{.writer_epoch = 1, .build_sequence = 2, .manifest_ordinal = static_cast(0xA1)}; + const ManifestId id0{ns, r0}; + const ManifestId id1{ns, r1}; + + /// Local blobExists (the round-level helper is file-local to gtest_cas_gc_round.cpp). + auto blobExists = [&](const UInt128 & hash) + { + return backend->head(layout.blobKey(BlobRef{BlobHashAlgo::CityHash128, BlobDigest::fromU128(hash)})).exists; + }; + auto manifestExists = [&](const ManifestId & id) + { + return backend->head(layout.manifestKey(id)).exists; + }; + /// Whether ANY gc-shard still holds an in-flight condemned entry (the ack-floor deletion pipeline is + /// in flight while this is true). Retired-in-snapshot (T4): reconstructed from the adopted fold seal's + /// kCondemned rows across all shards, not a separate retired list. + auto anyRetiredPending = [&] + { + return anyCondemnedInSeal(*backend, layout); + }; + /// Drive to a fixpoint over the ACK-FLOOR round: advance the store's mount ack each round (so the floor + /// follows the committed round) and stay alive while any work counter is nonzero OR an in-flight + /// retired entry remains in ANY shard. + auto driveToFixpoint = [&](Gc & gc) + { + for (size_t r = 0; r < 64; ++r) + { + const RoundReport rep = runRegularRoundReclaiming(gc); + if (!rep.acquired_lease) + continue; + store->renewWatermarkOnce(); + const bool no_work = rep.candidates == 0 && rep.deleted == 0 && rep.absent == 0 + && rep.replaced == 0 && rep.spared == 0; + if (no_work && !anyRetiredPending()) + break; + } + }; + + /// Publish: ref r0 names the shard-0 blob, ref r1 names the shard-1 blob (distinct refs => distinct + /// edges, each contributing +1 to its blob's in-degree in its OWNING shard's run). + writeBlobBody(*backend, layout, blob_shard0); + writeBlobBody(*backend, layout, blob_shard1); + writeManifestRaw(*backend, layout, ns, r0, {blobEntryFor("a", blob_shard0)}); + writeManifestRaw(*backend, layout, ns, r1, {blobEntryFor("b", blob_shard1)}); + publishCommittedTransition(*backend, layout, ns, "tbl0", std::nullopt, r0); + publishCommittedTransition(*backend, layout, ns, "tbl1", std::nullopt, r1); + + const UInt128 gc_id = UInt128(0xDEADBEEF42ULL); + Gc gc(store, gc_id); + driveToFixpoint(gc); + + /// While both refs are live: each blob's in-degree is 1 in its OWNING shard's run, and nothing is + /// collected (no-loss). Derive generation/attempt from gc/state — never hardcode. + const GcState live = decodeGcState(backend->get(layout.gcStateKey())->bytes); + ASSERT_GT(live.snap_generation, 0u); + ASSERT_EQ(live.gc_shards, kGcShards) << "the pool must be running with gc_shards=2"; + EXPECT_EQ(inDegreeInRuns(*backend, runsForShard(*backend, layout, /*shard=*/0), BlobRef{BlobHashAlgo::CityHash128, BlobDigest::fromU128(blob_shard0)}), 1) + << "shard-0 blob in-degree must be 1 while live"; + EXPECT_EQ(inDegreeInRuns(*backend, runsForShard(*backend, layout, /*shard=*/1), BlobRef{BlobHashAlgo::CityHash128, BlobDigest::fromU128(blob_shard1)}), 1) + << "shard-1 blob in-degree must be 1 while live"; + EXPECT_TRUE(blobExists(blob_shard0)); + EXPECT_TRUE(blobExists(blob_shard1)); + + /// Drop BOTH refs: each blob's only edge goes away (in-degree -> 0 in its owning shard's run). + dropRefTransition(*backend, layout, ns, "tbl0", r0); + dropRefTransition(*backend, layout, ns, "tbl1", r1); + driveToFixpoint(gc); + + /// After drop + fixpoint: BOTH blobs are retired and exact-token deleted, and BOTH owner-removed + /// manifest bodies are collected. The shard-1 blob is the regression's teeth — pre-`5f5fa5f` it + /// would still exist here because retire/previewDeletes never scanned shard 1. + EXPECT_EQ(inDegreeInRuns(*backend, runsForShard(*backend, layout, /*shard=*/0), BlobRef{BlobHashAlgo::CityHash128, BlobDigest::fromU128(blob_shard0)}), 0) + << "shard-0 blob in-degree must be 0 after drop"; + EXPECT_EQ(inDegreeInRuns(*backend, runsForShard(*backend, layout, /*shard=*/1), BlobRef{BlobHashAlgo::CityHash128, BlobDigest::fromU128(blob_shard1)}), 0) + << "shard-1 blob in-degree must be 0 after drop"; + EXPECT_FALSE(blobExists(blob_shard0)) << "shard-0 droppable blob must be reclaimed"; + EXPECT_FALSE(blobExists(blob_shard1)) + << "shard-1 droppable blob must be reclaimed (High #1: retire must scan ALL shards, not just shard 0)"; + EXPECT_FALSE(manifestExists(id0)) << "shard-0 owner-removed manifest body must be reclaimed"; + EXPECT_FALSE(manifestExists(id1)) << "shard-1 owner-removed manifest body must be reclaimed"; + + /// Idempotent: another fixpoint changes nothing and never throws. + EXPECT_NO_THROW(driveToFixpoint(gc)); + EXPECT_FALSE(blobExists(blob_shard0)); + EXPECT_FALSE(blobExists(blob_shard1)); +} diff --git a/src/Disks/tests/gtest_cas_gc_source_edge.cpp b/src/Disks/tests/gtest_cas_gc_source_edge.cpp new file mode 100644 index 000000000000..5fffe7ad2a97 --- /dev/null +++ b/src/Disks/tests/gtest_cas_gc_source_edge.cpp @@ -0,0 +1,84 @@ +#include +#include +#include +#include +#include + +using namespace DB::Cas; + +TEST(CASSourceEdge, IdIsDeterministicAndPathSensitive) +{ + const ManifestId id{RootNamespace{"00/aa@cas@"}, ManifestRef{.writer_epoch = 1, .build_sequence = 15, .manifest_ordinal = 1}}; + EXPECT_EQ(sourceEdgeId(id, "a.bin"), sourceEdgeId(id, "a.bin")); // deterministic + EXPECT_NE(sourceEdgeId(id, "a.bin"), sourceEdgeId(id, "b.bin")); // path-sensitive + const ManifestId id2{id.root_namespace, ManifestRef{.writer_epoch = 1, .build_sequence = 31, .manifest_ordinal = 1}}; + EXPECT_NE(sourceEdgeId(id, "a.bin"), sourceEdgeId(id2, "a.bin")); // ref-sensitive +} + +TEST(CASSourceEdge, RunKeyRoundTripsAndOrdersByBlobThenSource) +{ + const BlobRef b1{BlobHashAlgo::CityHash128, BlobDigest::fromU128(UInt128(1))}; + const BlobRef b2{BlobHashAlgo::CityHash128, BlobDigest::fromU128(UInt128(2))}; + const UInt128 s1(10); + const UInt128 s2(20); + + BlobRef gb; + UInt128 gs; + SourceEdgeKeyCodec::parse(SourceEdgeKeyCodec::key(b1, s1), gb, gs); + EXPECT_EQ(gb, b1); + EXPECT_EQ(gs, s1); + EXPECT_LT(SourceEdgeKeyCodec::key(b1, s2), SourceEdgeKeyCodec::key(b2, s1)); // ref is the primary sort + EXPECT_LT(SourceEdgeKeyCodec::key(b1, s1), SourceEdgeKeyCodec::key(b1, s2)); // source_id is the secondary sort +} + +TEST(CASSourceEdge, KeyCodecSha256RoundTripAndRejectsBadSizes) +{ + /// sha256 (32-byte digest) round trip: key is 1 + 32 + 16 = 49 bytes, parse recovers the full ref. + BlobDigest d32{}; + for (size_t i = 0; i < d32.bytes.size(); ++i) + d32.bytes[i] = static_cast(i + 1); + const BlobRef sha_ref{BlobHashAlgo::Sha256, d32}; + const UInt128 sid(0xABCDu); + const String key32 = SourceEdgeKeyCodec::key(sha_ref, sid); + ASSERT_EQ(key32.size(), 49u); + BlobRef gb; + UInt128 gs; + SourceEdgeKeyCodec::parse(key32, gb, gs); + EXPECT_EQ(gb, sha_ref); + EXPECT_EQ(gs, sid); + + /// ch128 (16-byte digest): key is 1 + 16 + 16 = 33 bytes. + const BlobRef ch_ref{BlobHashAlgo::CityHash128, BlobDigest::fromU128(UInt128(0x0102030405060708ULL))}; + const String key16 = SourceEdgeKeyCodec::key(ch_ref, sid); + ASSERT_EQ(key16.size(), 33u); + EXPECT_EQ(key16.substr(1), String(reinterpret_cast(ch_ref.digest.bytes.data()), 16) + u128ToBytesBE(sid)); + + /// Fail-close: a wrong-size key throws CORRUPTED_DATA, never a silent false. `key16` truncated by + /// one byte still declares algo=ch128 (33-byte width expected) but is only 32 bytes. + EXPECT_THROW(SourceEdgeKeyCodec::parse(key16.substr(0, key16.size() - 1), gb, gs), DB::Exception); + EXPECT_THROW(SourceEdgeKeyCodec::parse(String(20, '\0'), gb, gs), DB::Exception); + + /// Unknown algo byte -> NOT_IMPLEMENTED (fail closed). + String bad_key = key32; + bad_key[0] = static_cast(99); + EXPECT_THROW(SourceEdgeKeyCodec::parse(bad_key, gb, gs), DB::Exception); +} + +TEST(CASSourceEdge, KeyOrderSentinelFirstAtLen32) +{ + /// At sha256 width, the sentinel (source_id 0) sorts before any nonzero source_id for the same + /// digest, and digest magnitude order is preserved (big-endian raw-byte lexicographic order == + /// numeric magnitude order for a width-homogeneous run — the consult's load-bearing fact). + BlobDigest d{}; + d.bytes[0] = 0x10; + const BlobRef ref{BlobHashAlgo::Sha256, d}; + EXPECT_LT(SourceEdgeKeyCodec::key(ref, UInt128(0)), SourceEdgeKeyCodec::key(ref, UInt128(1))); + + BlobDigest d_small{}; + d_small.bytes[0] = 0x01; + BlobDigest d_large{}; + d_large.bytes[0] = 0x02; + const BlobRef ref_small{BlobHashAlgo::Sha256, d_small}; + const BlobRef ref_large{BlobHashAlgo::Sha256, d_large}; + EXPECT_LT(SourceEdgeKeyCodec::key(ref_small, UInt128(5)), SourceEdgeKeyCodec::key(ref_large, UInt128(5))); +} diff --git a/src/Disks/tests/gtest_cas_gc_state_format.cpp b/src/Disks/tests/gtest_cas_gc_state_format.cpp new file mode 100644 index 000000000000..aa661429f813 --- /dev/null +++ b/src/Disks/tests/gtest_cas_gc_state_format.cpp @@ -0,0 +1,179 @@ +#include "cas_format_test_battery.h" +#include +#include + +using namespace DB::Cas; + +namespace DB::ErrorCodes +{ + extern const int CORRUPTED_DATA; + extern const int LOGICAL_ERROR; +} + +TEST(CASFormatBattery, GcState) +{ + GcState s; + s.round = 4; + s.gc_shards = 1; + s.snap_generation = 9; + s.snap_pruned_through = 7; + s.snap_attempt = 3; + s.manifest_sweep_cursor = ""; + s.lease = GcLease{UInt128(1), 12}; + runFormatBattery({FormatId::GcState, + [&] { return sealObject(FormatId::GcState, encodeGcState(s)); }, + [](std::string_view d) { decodeGcState(std::string(openObject(FormatId::GcState, d))); }, + currentFormatHeader("cas_gc_state") + + "{\"rnd\":\"4\",\"gcs\":1,\"sg\":\"9\",\"spt\":\"7\",\"sa\":\"3\",\"msc\":\"\"," + "\"lo\":\"00000000000000000000000000000001\",\"ls\":\"12\"}\n"}); +} + +TEST(CASFormatBattery, GcHeartbeat) +{ + GcHeartbeat hb{UInt128(1), 1741}; + runFormatBattery({FormatId::GcHeartbeat, + [&] { return sealObject(FormatId::GcHeartbeat, encodeGcHeartbeat(hb)); }, + [](std::string_view d) { decodeGcHeartbeat(std::string(openObject(FormatId::GcHeartbeat, d))); }, + currentFormatHeader("cas_gc_hb") + + "{\"by\":\"00000000000000000000000000000001\",\"seq\":\"1741\"}\n"}); +} + +/// ---------- field round-trips (migrated from gtest_cas_gc_formats.cpp, re-pointed at the text codec) ---------- + +TEST(CASGCStateFormat, RoundTripsCoreFields) +{ + GcState s; + s.round = 7; + s.gc_shards = 1; + s.snap_generation = 12; + s.lease.owner = hexToU128("00000000000000000000000000000005"); + s.lease.seq = 5; + auto d = decodeGcState(encodeGcState(s)); + EXPECT_EQ(d.round, 7u); + EXPECT_EQ(d.gc_shards, 1u); + EXPECT_EQ(d.snap_generation, 12u); + EXPECT_EQ(d.lease.owner, hexToU128("00000000000000000000000000000005")); + EXPECT_EQ(d.lease.seq, 5u); +} + +TEST(CASGCStateFormat, SnapPrunedThroughAndAttemptAndCursorRoundTrip) +{ + GcState s; + s.gc_shards = 2; + s.snap_generation = 42; + s.snap_pruned_through = 38; + s.snap_attempt = 7; + s.manifest_sweep_cursor = "p/cas/manifests/server/store/abc/table@cas@/writer/42/aa/id"; + auto d = decodeGcState(encodeGcState(s)); + EXPECT_EQ(d.snap_pruned_through, 38u); + EXPECT_EQ(d.snap_attempt, 7u); + EXPECT_EQ(d.manifest_sweep_cursor, s.manifest_sweep_cursor); +} + +TEST(CASGCStateFormat, DefaultsRoundTrip) +{ + GcState s; /// gc_shards defaults to 1 + EXPECT_EQ(s.gc_shards, 1u); + auto d = decodeGcState(encodeGcState(s)); + EXPECT_EQ(d.round, 0u); + EXPECT_EQ(d.snap_attempt, 0u); + EXPECT_TRUE(d.manifest_sweep_cursor.empty()); + EXPECT_EQ(d.lease.owner, UInt128{}); +} + +TEST(CASGCStateFormat, RejectsZeroGcShards) +{ + /// `v:3` is deliberate and must NOT follow a future `G_BUILD` bump: any version <= G_BUILD passes + /// the header gate, which is the point — the BODY is what has to fail here. + const String bad = "{\"type\":\"cas_gc_state\",\"v\":3}\n" + "{\"rnd\":\"0\",\"gcs\":0,\"sg\":\"0\",\"spt\":\"0\",\"sa\":\"0\",\"msc\":\"\"," + "\"lo\":\"00000000000000000000000000000000\",\"ls\":\"0\"}\n"; + EXPECT_THROW(decodeGcState(bad), DB::Exception); +} + +#ifndef DEBUG_OR_SANITIZER_BUILD +/// encodeGcState(gc_shards=0) throws LOGICAL_ERROR, which aborts the whole process in debug/sanitizer +/// builds instead of behaving like a catchable exception -- CASGCStateFormatDeathTest below proves the +/// abort positively in those builds instead. +TEST(CASGCStateFormat, RejectsZeroGcShardsOnEncode) +{ + GcState state; + state.gc_shards = 0; + + try + { + encodeGcState(state); + FAIL() << "expected exception code " << DB::ErrorCodes::LOGICAL_ERROR; + } + catch (const DB::Exception & e) + { + EXPECT_EQ(e.code(), DB::ErrorCodes::LOGICAL_ERROR); + } +} +#endif + +#if defined(DEBUG_OR_SANITIZER_BUILD) +TEST(CASGCStateFormatDeathTest, RejectsZeroGcShardsOnEncodeAborts) +{ + GcState state; + state.gc_shards = 0; + EXPECT_DEATH({ (void)encodeGcState(state); }, ""); +} +#endif + +TEST(CASGCStateFormat, RejectsAbsentGcShards) +{ + /// An absent gcs key must fail closed (the writer always emits it) rather than silently defaulting + /// to the struct's gc_shards = 1 — a missing shard count means a corrupt object, not "use the floor". + /// `v:3` is deliberate and must NOT follow a future `G_BUILD` bump: any version <= G_BUILD passes + /// the header gate, which is the point — the BODY is what has to fail here. + const String bad = "{\"type\":\"cas_gc_state\",\"v\":3}\n" + "{\"rnd\":\"0\",\"sg\":\"0\",\"spt\":\"0\",\"sa\":\"0\",\"msc\":\"\"," + "\"lo\":\"00000000000000000000000000000000\",\"ls\":\"0\"}\n"; + EXPECT_THROW(decodeGcState(bad), DB::Exception); +} + +TEST(CASGCStateFormat, GarbageFailsClosed) +{ + EXPECT_THROW(decodeGcState(String("")), DB::Exception); + EXPECT_THROW(decodeGcState(String("not a cas object\n")), DB::Exception); +} + +TEST(CASGCHeartbeatFormat, RoundTripAndBoundaries) +{ + GcHeartbeat hb; + hb.owner = hexToU128("0123456789abcdeffedcba9876543210"); + hb.hb_seq = 12345; + GcHeartbeat d = decodeGcHeartbeat(encodeGcHeartbeat(hb)); + EXPECT_EQ(d.owner, hb.owner); + EXPECT_EQ(d.hb_seq, 12345u); + + GcHeartbeat z; + z.owner = hexToU128("ffffffffffffffffffffffffffffffff"); + z.hb_seq = 0; + EXPECT_EQ(decodeGcHeartbeat(encodeGcHeartbeat(z)).owner, z.owner); + EXPECT_THROW(decodeGcHeartbeat(String("short")), DB::Exception); +} + +TEST(CASGCHeartbeatFormat, RejectsMissingIdentityFields) +{ + /// `v:3` is deliberate and must NOT follow a future `G_BUILD` bump: any version <= G_BUILD passes + /// the header gate, which is the point — the BODY is what has to fail here. + const String header = "{\"type\":\"cas_gc_hb\",\"v\":3}\n"; + + const auto expectCorrupted = [](const String & data) + { + try + { + decodeGcHeartbeat(data); + FAIL() << "expected CORRUPTED_DATA"; + } + catch (const DB::Exception & e) + { + EXPECT_EQ(e.code(), DB::ErrorCodes::CORRUPTED_DATA); + } + }; + + expectCorrupted(header + "{\"seq\":\"1741\"}\n"); + expectCorrupted(header + "{\"by\":\"00000000000000000000000000000001\"}\n"); +} diff --git a/src/Disks/tests/gtest_cas_gc_stop_start.cpp b/src/Disks/tests/gtest_cas_gc_stop_start.cpp new file mode 100644 index 000000000000..fa4c864cc8de --- /dev/null +++ b/src/Disks/tests/gtest_cas_gc_stop_start.cpp @@ -0,0 +1,492 @@ +#include + +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +/// Task 11 (rev.7 spec §6): `SYSTEM CAS GC STOP` / `GC START` -- granular operator control +/// of ONLY the background GC scheduler. STOP is STOP-IN-PLACE: it joins the worker + heartbeat threads and +/// clears the in-process leadership hint, but RETAINS the scheduler object so a later START restarts the +/// SAME instance (its `gc_id` + lease-observation history preserved). The disk stays fully usable (reads/ +/// writes unaffected) while GC is stopped. START refuses on a decommissioned/uncertain pool (typed 668). +/// +/// These tests exercise the scheduler-level behavior directly (`CasGcScheduler::stop`/`start`) and the +/// end-to-end verbs through a real `ContentAddressedMetadataStorage`. Harness patterns follow +/// gtest_cas_forget.cpp and gtest_cas_gc_log.cpp. + +namespace DB::ErrorCodes +{ +extern const int INVALID_STATE; +} + +using namespace DB; +using DB::Cas::CasGcScheduler; +using DB::Cas::GcRoundLogRecord; +using DB::Cas::InMemoryBackend; +using DB::Cas::PoolLifecycle; +using DB::Cas::RoundReport; +using DB::Cas::tests::openPoolForTest; + +namespace +{ + +/// A live table dir + committed part reused by the "reads/writes unaffected while stopped" test (the shape +/// gtest_cas_forget.cpp / gtest_cas_operation_gate.cpp use). +const std::string kTableDir = "gg0/gg0gg0g0-0808-4808-8808-080808080808"; +const std::string kPartDir = kTableDir + "/all_1_1_0"; +const std::string kPartFile = kPartDir + "/data.bin"; + +/// The Pool-level `server_root_id` `openPoolForTest` mints (mirrors gtest_cas_lifecycle_condition.cpp). +const std::string kSrid = "test"; + +/// GC's fence-out applied directly to the mount lease (preserve the body, set `gc_fenced`, bump `seq`) so a +/// subsequent `tryRemountOnce` verdicts `Recover` and reclaims a FRESH incarnation immediately (no +/// lease-expiry wait), driving a transient-not-live pool back to `Live`. Mirrors +/// gtest_cas_lifecycle_condition.cpp's helper — used by the operator-STOP-persistence test below. +void fenceOutMount(DB::Cas::Backend & backend, const String & mount_key) +{ + const auto got = backend.get(mount_key); + ASSERT_TRUE(got.has_value()); + DB::Cas::MountLease m = DB::Cas::decodeMountLease(got->bytes); + m.gc_fenced = true; + m.seq += 1; + ASSERT_EQ(backend.putOverwrite(mount_key, DB::Cas::encodeMountLease(m), got->token).outcome, + DB::Cas::PutOutcome::Done); +} + +/// A real `ContentAddressedMetadataStorage` over a fresh, unique local object storage. `context == nullptr` +/// (a unit-test mount), so `startup()` creates NO GC scheduler -- the GC entry points, and `gcStart`, create +/// one lazily. GC is enabled by default (`gc_enabled == true`, `gc_interval_sec == 60`), so no background +/// round fires during the sub-second test window. Mirrors gtest_cas_forget.cpp's `openForgetStorage`. +std::shared_ptr openGcStorage() +{ + static std::atomic counter{0}; + const auto scratch = std::filesystem::temp_directory_path() + / ("ca_gc_stopstart_scratch_" + std::to_string(::getpid()) + "_" + std::to_string(counter.fetch_add(1))); + auto settings = Cas::tests::makeSettingsForTest("test", scratch); + auto storage = std::make_shared( + Cas::tests::makeLocalObjectStorageForTest(), "pool", "srv1", "", nullptr, settings); + storage->startup(); + return storage; +} + +void commitOnePart(ContentAddressedMetadataStorage & storage) +{ + auto tx = storage.createTransaction(); + auto & ca_tx = dynamic_cast(*tx); + auto buf = ca_tx.writeFile(kTableDir + "/tmp_insert_all_1_1_0/data.bin", 65536, WriteMode::Rewrite, {}); + const std::string bytes = "content-of-the-part"; + buf->write(bytes.data(), bytes.size()); + buf->finalize(); + tx->moveDirectory(kTableDir + "/tmp_insert_all_1_1_0", kPartDir); + tx->commit(NoCommitOptions{}); +} + +/// A thread-safe sink for the scheduler's per-round log records, with a condition variable so a test can +/// WAIT (never sleep) for a background round to land. `waitForSuccessFinish` blocks until a Finish record +/// with `outcome == Success` (the round acquired/renewed the GC lease) appears at index >= `from`, or the +/// timeout trips (only on a genuine hang/regression -- the round is sub-millisecond on an in-memory pool). +class RoundLogSink +{ +public: + Cas::GcRoundLogger logger() + { + return [this](const GcRoundLogRecord & r) + { + std::lock_guard lock(mutex); + records.push_back(r); + cv.notify_all(); + }; + } + + /// Index one past the current end of the record log -- the "from" watermark for a subsequent wait. + size_t mark() + { + std::lock_guard lock(mutex); + return records.size(); + } + + /// The first Success Finish record at index >= `from`, waiting up to `timeout`. Returns nullopt on + /// timeout so the caller asserts with a clear message rather than hanging. + std::optional waitForSuccessFinish(size_t from, std::chrono::milliseconds timeout) + { + std::unique_lock lock(mutex); + const bool ok = cv.wait_for(lock, timeout, [&] + { + for (size_t i = from; i < records.size(); ++i) + if (records[i].event_type == GcRoundLogRecord::EventType::Finish + && records[i].outcome == GcRoundLogRecord::Outcome::Success) + return true; + return false; + }); + if (!ok) + return std::nullopt; + for (size_t i = from; i < records.size(); ++i) + if (records[i].event_type == GcRoundLogRecord::EventType::Finish + && records[i].outcome == GcRoundLogRecord::Outcome::Success) + return records[i]; + return std::nullopt; + } + +private: + std::mutex mutex; + std::condition_variable cv; + std::vector records; +}; + +/// A generous wait bound for a background round to land -- trips only on a real deadlock/regression. +constexpr std::chrono::milliseconds kRoundWait{60000}; + +/// Bound for the [C1] self-exit observation: comfortably above the 1s pacing interval (so a slow CI box +/// still sees the loop tick + observe the terminal state) yet short enough that the RED demo (self-exit +/// removed) fails fast rather than hanging for `kRoundWait`. +constexpr std::chrono::milliseconds kSelfExitWait{15000}; + +/// A bounded OBSERVATION window (not a sleep-to-fix-a-race) for the "stays stopped across recovery" test: +/// comfortably above the 1s pacing interval so a running scheduler would have filled it with several +/// rounds, yet short enough to keep the negative assertion cheap. Its meaning is anchored by a positive +/// control (an explicit START right after DOES produce a round through the same sink). +constexpr std::chrono::milliseconds kStayStoppedWindow{3000}; + +} + +/// (C1) A NATURAL terminal transition (`VanishedReplaced`, or here `VanishedForgotten` forced via the test +/// seam) is never accompanied by a `stop()` on this scheduler — only `~Pool`/FORGET join it. The scheduler's +/// OWN loops must observe the terminal lifecycle at their next tick and self-exit, +/// so the pacing loop stops spamming Failed rounds (the G2 zombie) and the steal-capable loop can never +/// fold/condemn a foreign pool's prefix. Drive it while RUNNING, then vanish it, then prove BOTH loops +/// self-exit (bounded cv wait, no sleep) and that no further round-log rows appear. +TEST(CASGCStopStart, SchedulerSelfExitsOnNaturalVanished) +{ + auto backend = std::make_shared(); + auto store = openPoolForTest(backend); + + RoundLogSink sink; + /// 1s interval: the loop ticks ~1s; the cv wait below (never a sleep) synchronizes on real records. + CasGcScheduler sched(store, std::chrono::seconds(1), "CasGcSelfExitTest", "ca-disk", sink.logger()); + sched.start(); + + /// Prove the loop is genuinely RUNNING first: a background round must land and acquire the lease. + ASSERT_TRUE(sink.waitForSuccessFinish(/*from=*/0, kRoundWait).has_value()) + << "the scheduler must be pacing rounds before we drive it terminal"; + + /// A natural terminal transition (forced here via the seam; in production `VanishedReplaced` and + /// `IdentityLost` arrive identically, WITHOUT anyone calling stop() on this scheduler). + store->setLifecycleForTest(PoolLifecycle::VanishedForgotten); + + ASSERT_TRUE(sched.waitForTerminalSelfExitForTest(kSelfExitWait)) + << "both the pacing and heartbeat loops must self-exit once the pool is Vanished"; + + /// No further round-log rows appear after the self-exit: both loops have returned, so capture the + /// count, reap them with stop() (a hang/double-terminate here would fail the test), and assert stable. + const size_t count_at_exit = sink.mark(); + sched.stop(); + EXPECT_EQ(sink.mark(), count_at_exit) << "a self-exited pacing loop must emit no further round records"; + EXPECT_FALSE(sched.gcHealth().is_leader); +} + +/// (C1, rev.8 §9 item 8) `IdentityLost` is now a fail-loud TERMINAL state, so the scheduler must self-exit +/// there exactly as it does on `Vanished` — a scheduler ticking against a half-erased pool is a pure zombie +/// (eternal `CORRUPTED_DATA` retries against the vanished `gc/state`). Prove BOTH loops self-exit and that no +/// further round-log rows appear, and that leadership is cleared. +TEST(CASGCStopStart, SchedulerSelfExitsOnIdentityLost) +{ + auto backend = std::make_shared(); + auto store = openPoolForTest(backend); + + RoundLogSink sink; + CasGcScheduler sched(store, std::chrono::seconds(1), "CasGcIdentityLostTest", "ca-disk", sink.logger()); + sched.start(); + + ASSERT_TRUE(sink.waitForSuccessFinish(/*from=*/0, kRoundWait).has_value()); + + store->setLifecycleForTest(PoolLifecycle::IdentityLost); + + ASSERT_TRUE(sched.waitForTerminalSelfExitForTest(kSelfExitWait)) + << "IdentityLost is terminal (rev.8): both the pacing and heartbeat loops must self-exit"; + + const size_t count_at_exit = sink.mark(); + sched.stop(); + EXPECT_EQ(sink.mark(), count_at_exit) << "a self-exited pacing loop must emit no further round records"; + EXPECT_FALSE(sched.gcHealth().is_leader) << "a self-exited scheduler must report it no longer leads"; +} + +/// (C1 cleanup hygiene) After BOTH loops self-exit on a terminal transition, `stop()` must cleanly reap +/// the already-finished (joinable) threads, a second `stop()` is a safe no-op, and destruction (scope exit +/// → ~CasGcScheduler → stop()) runs clean — the ThreadFromGlobalPool join/reset contract holds for a +/// self-exited thread exactly as for a stop()-signalled one. +TEST(CASGCStopStart, StopAndDestroyCleanAfterSelfExit) +{ + auto backend = std::make_shared(); + auto store = openPoolForTest(backend); + { + RoundLogSink sink; + CasGcScheduler sched(store, std::chrono::seconds(1), "CasGcSelfExitCleanupTest", "ca-disk", sink.logger()); + sched.start(); + ASSERT_TRUE(sink.waitForSuccessFinish(/*from=*/0, kRoundWait).has_value()); + + store->setLifecycleForTest(PoolLifecycle::VanishedReplaced); + ASSERT_TRUE(sched.waitForTerminalSelfExitForTest(kSelfExitWait)); + + EXPECT_NO_THROW(sched.stop()) << "stop() must cleanly join the self-exited threads"; + EXPECT_NO_THROW(sched.stop()) << "a second stop() after self-exit is a safe no-op"; + /// Destruction at scope exit runs stop() a third time — also clean (test completing proves it). + } + SUCCEED(); +} + +/// (a + e) STOP joins the worker + heartbeat threads and clears the in-process leadership hint. The T10 +/// lesson: make the assertion REAL -- acquire leadership via a manual round FIRST, so `is_leader` is +/// genuinely true before STOP for the clear to prove anything (otherwise `EXPECT_FALSE` would be vacuous). +TEST(CASGCStopStart, StopJoinsWorkersAndClearsLeadershipHint) +{ + auto backend = std::make_shared(); + auto store = openPoolForTest(backend); + + /// A long interval keeps any BACKGROUND round from firing; the manual round below is what leads. + CasGcScheduler sched(store, std::chrono::seconds(3600), "CasGcStopStartTest", "ca-disk"); + sched.start(); + + /// Acquire REAL leadership: a manual round on a free lease acquires it. + const RoundReport rep = sched.runOneRoundNow(); + ASSERT_TRUE(rep.acquired_lease) << "a manual round on a fresh pool must acquire the free GC lease"; + ASSERT_TRUE(sched.gcHealth().is_leader) << "leadership must be true BEFORE stop for the clear to prove anything"; + ASSERT_TRUE(sched.isQuiescent()) << "the manual round completed; nothing is in flight"; + + sched.stop(); /// joins loop + heartbeat threads (the test completing without hanging proves the join) + + EXPECT_TRUE(sched.isQuiescent()) << "no GC round may be in flight after stop joined the workers"; + EXPECT_FALSE(sched.gcHealth().is_leader) + << "stop must clear the in-process leadership hint (the disk no longer leads GC)"; +} + +/// (b) START after STOP restarts the SAME scheduler: background rounds resume, they carry the SAME gc_id +/// (identity preserved across the restart), and leadership is re-entered via the next round's NORMAL +/// acquisition (is_leader becomes true only after the restarted background round re-acquires the lease). +/// Deterministic and sleep-free: a condition variable fed by the round logger waits for each background +/// Finish. This also exercises `start()`'s post-join re-entrancy -- a bug there would hang the wait. +TEST(CASGCStopStart, StartAfterStopResumesBackgroundRoundsWithSameGcId) +{ + auto backend = std::make_shared(); + auto store = openPoolForTest(backend); + + RoundLogSink sink; + /// 1s interval: the background loop's first round fires ~1s after start(); the cv wait (not a sleep) + /// synchronizes on the actual Finish record. + CasGcScheduler sched(store, std::chrono::seconds(1), "CasGcStopStartTest", "ca-disk", sink.logger()); + + /// First run: background rounds start and one acquires the lease. + sched.start(); + const auto first = sink.waitForSuccessFinish(/*from=*/0, kRoundWait); + ASSERT_TRUE(first.has_value()) << "the background scheduler must run a round and acquire the lease after start()"; + EXPECT_TRUE(sched.gcHealth().is_leader) << "leadership is held after the first background round"; + const std::string gc_id_before = first->gc_id; + EXPECT_FALSE(gc_id_before.empty()); + + /// Stop: leadership hint cleared, threads joined. + sched.stop(); + EXPECT_FALSE(sched.gcHealth().is_leader) << "stop clears the leadership hint"; + const size_t after_stop = sink.mark(); + + /// Restart the SAME instance: a NEW background round must land, re-acquiring the lease, and it must + /// carry the SAME gc_id (proving the instance -- and its lease observer -- survived the restart). + sched.start(); + const auto second = sink.waitForSuccessFinish(/*from=*/after_stop, kRoundWait); + ASSERT_TRUE(second.has_value()) << "background rounds must resume after START (start() is re-enterable post-join)"; + EXPECT_EQ(second->gc_id, gc_id_before) << "the restarted scheduler must preserve its gc_id (same instance)"; + EXPECT_TRUE(sched.gcHealth().is_leader) + << "leadership is re-entered via the restarted round's normal lease acquisition"; + + sched.stop(); +} + +/// (c) STOP and START are both idempotent: a second STOP on an already-stopped scheduler is a safe no-op, +/// and a second START on a running one is a no-op that leaves it running (a manual round still works). +TEST(CASGCStopStart, StopAndStartAreIdempotent) +{ + auto backend = std::make_shared(); + auto store = openPoolForTest(backend); + CasGcScheduler sched(store, std::chrono::seconds(3600), "CasGcStopStartTest", "ca-disk"); + + sched.start(); + EXPECT_NO_THROW(sched.start()) << "a second START on a running scheduler is a no-op"; + + sched.stop(); + EXPECT_NO_THROW(sched.stop()) << "a second STOP on a stopped scheduler is a safe no-op"; + EXPECT_TRUE(sched.isQuiescent()); + EXPECT_FALSE(sched.gcHealth().is_leader); + + /// After the double-stop, START still restarts the same instance and it runs a round. + sched.start(); + const RoundReport rep = sched.runOneRoundNow(); + EXPECT_TRUE(rep.acquired_lease) << "the restarted scheduler still runs rounds after idempotent stop/start"; + sched.stop(); +} + +/// (d) START refuses on a Vanished disk with the typed 668 (`INVALID_STATE`) error -- restarting GC on a +/// decommissioned pool is meaningless and would only spin failing rounds -- while STOP on the SAME +/// Vanished disk (with a live scheduler present) SUCCEEDS: stopping the reclaimer on a sick disk is a +/// legitimate operator action, so STOP never consults the operation gate. +TEST(CASGCStopStart, StartRefusesOnVanishedButStopSucceeds) +{ + /// START on a Vanished disk -> typed 668. No scheduler needed: the gate refuses before touching it. + { + auto storage = openGcStorage(); + auto pool = storage->store(); /// captured while Live (store() throws once Vanished) + pool->setLifecycleForTest(PoolLifecycle::VanishedForgotten); + Cas::tests::expectThrowsCode(ErrorCodes::INVALID_STATE, [&] { storage->gcStart(); }); + } + + /// STOP on a Vanished disk WITH a live scheduler -> succeeds. + { + auto storage = openGcStorage(); + storage->gcStart(); /// Live: lazily creates + starts a scheduler + ASSERT_TRUE(storage->gcHealth().has_value()) << "gcStart must have created a scheduler on a Live disk"; + + auto pool = storage->store(); + pool->setLifecycleForTest(PoolLifecycle::VanishedForgotten); + + EXPECT_NO_THROW(storage->gcStop()) << "stopping GC on a Vanished disk is legitimate operator action"; + } +} + +/// (f) The disk stays fully usable while its GC scheduler is stopped: a store()-path write + read succeed +/// after `gcStop`. STOP controls ONLY the GC pacer, not the disk's data plane. +TEST(CASGCStopStart, DiskReadsWritesUnaffectedWhileGcStopped) +{ + auto storage = openGcStorage(); + storage->gcStart(); /// create + start the scheduler + storage->gcStop(); /// stop it in place (scheduler retained, threads joined) + + /// A write (commit a part) and a read (existsFile) both succeed with GC stopped. + EXPECT_NO_THROW(commitOnePart(*storage)); + EXPECT_TRUE(storage->existsFile(kPartFile)) << "reads/writes must be unaffected while the GC scheduler is stopped"; + + /// And START brings the scheduler back (idempotent, re-enterable) without disturbing the data. + EXPECT_NO_THROW(storage->gcStart()); + EXPECT_TRUE(storage->existsFile(kPartFile)); + storage->gcStop(); +} + +/// (T11 M3, acceptance matrix) Two threads hammering `gcStop`/`gcStart` on the SAME storage concurrently. +/// The verbs serialize on `lifecycle_mutex` (then `gc_scheduler_mutex`, always in that order — so there is +/// no lock-order inversion and hence no deadlock), so each call is atomic: the barrage interleaves in any +/// order but never tears the retained scheduler pointer or its worker-thread set. We bound each worker with +/// a `std::future` timeout (never a sleep) so a deadlock regression fails FAST instead of hanging the suite, +/// and — since the final serialized call determines the resting state — a single quiet STOP then START at +/// the end lands the object in a well-defined, usable state (last call wins). ASan/TSan running this proves +/// the racing start()/stop() thread spawns+joins never race the shared members. +TEST(CASGCStopStart, ConcurrentStopStartFromTwoThreadsStaysConsistent) +{ + auto storage = openGcStorage(); + + /// 200 iterations each, opposite phase, so the two threads spend the whole run contending on the + /// lifecycle mutex with one about to START while the other is about to STOP. + constexpr int kIters = 200; + auto worker = [&](bool start_first) + { + for (int i = 0; i < kIters; ++i) + { + if (start_first) { storage->gcStart(); storage->gcStop(); } + else { storage->gcStop(); storage->gcStart(); } + } + }; + + auto a = std::async(std::launch::async, worker, true); + auto b = std::async(std::launch::async, worker, false); + ASSERT_EQ(a.wait_for(std::chrono::seconds(60)), std::future_status::ready) + << "two-thread GC stop/start must not deadlock (both verbs lock lifecycle_mutex then gc_scheduler_mutex)"; + ASSERT_EQ(b.wait_for(std::chrono::seconds(60)), std::future_status::ready) + << "two-thread GC stop/start must not deadlock"; + a.get(); + b.get(); + + /// No torn state: a scheduler exists (both workers created/re-entered one) and its health snapshot is + /// coherently queryable rather than reading a half-published pointer. + ASSERT_TRUE(storage->gcHealth().has_value()) << "the scheduler must exist and report coherent health after the barrage"; + + /// Last call wins: once contention ends, one serialized STOP lands it stopped (leadership cleared, + /// quiescent), and one serialized START lands it running again — each observed deterministically. + storage->gcStop(); + ASSERT_TRUE(storage->gcHealth().has_value()); + EXPECT_FALSE(storage->gcHealth()->is_leader) << "a final serialized STOP clears leadership -- last call wins"; + + storage->gcStart(); + EXPECT_TRUE(storage->gcHealth().has_value()) << "a final serialized START leaves the scheduler present"; + + /// The data plane is unharmed by the whole barrage: a write + read still succeed. + EXPECT_NO_THROW(commitOnePart(*storage)); + EXPECT_TRUE(storage->existsFile(kPartFile)); + storage->gcStop(); +} + +/// (T11 cannot-verify, acceptance matrix) Operator intent PERSISTS across a transient recovery: after the +/// operator STOPs GC, the disk loses its mount lease (transient-not-live) and self-remounts back to Live — +/// and NOTHING restarts the GC scheduler. Recovery is a Pool-internal operation with no reference to the +/// scheduler; only an explicit START (`SYSTEM CAS GC START`) resumes it. We prove the scheduler +/// was genuinely running+leading first, STOP it, drive a real transient→Live recovery on the pool, then show +/// it stays stopped across a bounded observation window (a running 1s-paced scheduler would have produced +/// several rounds), and finally that an explicit START — the ONLY resumption path — brings rounds back on the +/// SAME instance (`gc_id` preserved). The positive control makes the negative meaningful: the sink IS live. +TEST(CASGCStopStart, OperatorStopPersistsAcrossTransientRecovery) +{ + auto backend = std::make_shared(); + auto store = openPoolForTest(backend); + + RoundLogSink sink; + /// 1s interval so a RUNNING scheduler would pace rounds within the observation window below. + CasGcScheduler sched(store, std::chrono::seconds(1), "CasGcStopPersistTest", "ca-disk", sink.logger()); + + /// The operator has GC running and leading. + sched.start(); + ASSERT_TRUE(sink.waitForSuccessFinish(/*from=*/0, kRoundWait).has_value()) + << "the scheduler must be pacing rounds and leading before the operator stops it"; + ASSERT_TRUE(sched.gcHealth().is_leader); + + /// The operator STOPs GC (stop-in-place: threads joined, leadership hint cleared). + sched.stop(); + ASSERT_FALSE(sched.gcHealth().is_leader); + const size_t after_stop = sink.mark(); + + /// The disk now suffers a transient mount-lease loss and self-remounts back to Live (a fresh + /// incarnation), WITHOUT any operator action — exactly the recovery §4 describes. + store->tripMountLost(); + ASSERT_EQ(store->lifecycle(), PoolLifecycle::TransientNotLive); + fenceOutMount(*backend, store->layout().mountKey(kSrid)); + ASSERT_TRUE(store->tryRemountOnce()) << "the self-remount must reclaim a fresh incarnation"; + ASSERT_EQ(store->lifecycle(), PoolLifecycle::Live) << "the pool must auto-recover to Live"; + + /// The operator's STOP persists: recovery restarted NOTHING. The scheduler is still not leading and + /// still quiescent, and NO background round appears across a window a running scheduler would have + /// filled many times over. + EXPECT_FALSE(sched.gcHealth().is_leader); + EXPECT_TRUE(sched.isQuiescent()); + EXPECT_FALSE(sink.waitForSuccessFinish(after_stop, kStayStoppedWindow).has_value()) + << "a self-remount recovery must NOT restart an operator-STOPped GC scheduler"; + + /// Positive control: only an explicit START resumes rounds, on the SAME instance (gc_id preserved). + /// This also proves the sink WOULD have caught a round, so the negative above is meaningful. + sched.start(); + const auto resumed = sink.waitForSuccessFinish(after_stop, kRoundWait); + ASSERT_TRUE(resumed.has_value()) << "an explicit START must resume background rounds after the recovery"; + EXPECT_TRUE(sched.gcHealth().is_leader); + sched.stop(); +} diff --git a/src/Disks/tests/gtest_cas_gc_undercount_repro.cpp b/src/Disks/tests/gtest_cas_gc_undercount_repro.cpp new file mode 100644 index 000000000000..430e2faa9293 --- /dev/null +++ b/src/Disks/tests/gtest_cas_gc_undercount_repro.cpp @@ -0,0 +1,416 @@ +#include + +#include +#include +#include +#include +#include "cas_test_helpers.h" + +#include + +/// Regression suite for the soak S04 / S04b undercount failures: +/// Code: 246 CORRUPTED_DATA: CAS blob in-degree: merged in-degree -1 < 0 for a blob ... +/// +/// H1 (DeposedFoldAdopt) and H1b (FenceWindowReRemoval) guard against the fence-window re-fold +/// undercount. Fixed STRUCTURALLY by replacing the persisted integer in-degree with an idempotent +/// source-edge SET: re-folding a fence-window removal across generations is a set-difference no-op, +/// so the underflow cannot occur (NOT by patching the sealed cursor — that approach was rejected). +/// +/// H2 (DuplicateRemovalIdempotent) guards against the duplicate-remove undercount that existed when +/// in-degree was a persisted integer: two events both carrying `old=committed(r1)` subtracted -1 +/// twice from a blob's count, driving it to -1. Same fix — the second removal of an already-absent +/// edge is a no-op. (Formerly staged the second event as a `{old=committed(r1), +/// new=committed(r2)}` "repoint" -- a single op naming DIFFERENT manifests in its old/new bindings. +/// Post-classifier (see Pool/CasRefProtocol.cpp's `classifyOwnerTransitionShape`) that single-op shape +/// is not representable at all: `manifestEdgesOfTxn` now throws `CORRUPTED_DATA` on it, same as the +/// state machine always has. The ACTUALLY representable duplicate-removal hazard -- two SEPARATE +/// remove-committed events both naming `old=committed(r1)`, which the GC fold extracts blindly without +/// replaying the state machine -- is what this test exercises instead.) + +using namespace DB::Cas; +using namespace DB::Cas::tests; + +namespace DB::ErrorCodes +{ +extern const int CORRUPTED_DATA; +extern const int ABORTED; +} + +namespace +{ + +const UInt128 kGc = hexToU128("00000000000000000000000000000001"); +const UInt128 kGcA = hexToU128("0000000000000000000000000000000a"); +const UInt128 kGcB = hexToU128("0000000000000000000000000000000b"); + +ManifestRef ref(uint64_t seq, uint64_t inst) +{ + return ManifestRef{.writer_epoch = 1, .build_sequence = seq, .manifest_ordinal = static_cast(inst)}; +} + +bool blobExists(InMemoryBackend & b, const Layout & layout, const UInt128 & hash) +{ + return b.head(layout.blobKey(BlobRef{BlobHashAlgo::CityHash128, BlobDigest::fromU128(hash)})).exists; +} + +/// A committed `RefOwnerBinding` for a raw `owner_transition` op. The raw appender is now +/// `tests::appendOwnerEvent`, which writes ONE `owner_transition` ref-log transaction via +/// `writeRefLogTxnRaw` at the next `RefTxnId` -- the GC fold EXTRACTS edges from each log +/// (`manifestEdgesOfTxn`) and never replays them through the state machine, so a SHAPE-legal +/// `old_binding` (an exact `remove committed` op) that no longer names the table's current committed +/// owner is still folded -- it is not caught until (and unless) the full state machine replays the +/// log. That is exactly the "duplicate removal of an already-removed committed ref" hazard H2 below +/// exercises: two SEPARATE remove-committed events for the same `(ref_name, manifest_ref)`, each +/// individually shape-legal (`classifyOwnerTransitionShape` accepts every one), but the second is a +/// stale repeat the idempotent source-edge set must absorb rather than double-subtract. +RefOwnerBinding committed(const String & ref_name, const ManifestRef & r) +{ + return RefOwnerBinding{RefOwnerKind::Committed, ref_name, r}; +} + +} + +/// ============================ H2: DUPLICATE COMMITTED REMOVAL IS IDEMPOTENT (REGRESSION GUARD) ======== +/// +/// Two SEPARATE journal events both carry the EXACT same `old = committed(r1)` removal: +/// v2: DROP r1 {old=committed(r1), new=none} => removes r1's source-edge to {1,2} +/// v3: DUPLICATE DROP r1 {old=committed(r1), new=none} => removes r1's source-edge to {1,2} AGAIN +/// +/// Each event is individually SHAPE-legal (`classifyOwnerTransitionShape` accepts a bare +/// `old=Committed, new=none` removal unconditionally; it has no state to check that the removal is +/// still live). The GC fold extracts edges from each log directly, without replaying the state machine +/// (which alone would notice the second removal names an owner that is no longer bound), so both +/// events fold. Under the OLD integer in-degree model this drove blob 2 to prior(1) + (-1) + (-1) = -1 +/// and threw CORRUPTED_DATA. Under the FIXED idempotent source-edge SET model the second removal of +/// r1's edge to a blob is a no-op: each source edge is present or absent, and removing an +/// already-absent edge is silent. +/// +/// Correct post-fix behaviour: GC must NOT throw, and both blobs -- owned only by r1, which has no +/// live owner after the (idempotent) drop -- become collectible (in-degree 0, keys gone). +TEST(CASGCUndercount, H2DuplicateCommittedRemovalIsIdempotentNoUnderflow) +{ + auto backend = std::make_shared(); + auto store = openPoolForTest(backend); + const RootNamespace ns{"00/aa@cas@"}; + const ManifestRef r1 = ref(1, 0xB1); + + /// r1 pins blobs {1,2}. + writeBlobBody(*backend, store->layout(), DB::UInt128(1)); + writeBlobBody(*backend, store->layout(), DB::UInt128(2)); + writeManifestRaw(*backend, store->layout(), ns, r1, + {blobEntryFor("a", DB::UInt128(1)), blobEntryFor("b", DB::UInt128(2))}); + + /// v1: publish r1 (owner: none -> committed(r1)). Fold it so blobs 1 and 2 are each pinned at 1. + publishCommittedTransition(*backend, store->layout(), ns, "tbl", std::nullopt, r1); + Gc gc(store, kGc); + ASSERT_TRUE(runRegularRoundReclaiming(gc).acquired_lease); + ASSERT_EQ(inDegreeOf(*backend, store->layout(), DB::UInt128(1)), 1); + ASSERT_EQ(inDegreeOf(*backend, store->layout(), DB::UInt128(2)), 1); + + /// Stage TWO distinct transactions, each carrying the SAME removal event for r1, in ONE fold window + /// (r1's body is NOT deleted until recheck, so both events are resolved at fold time): + /// v2: DROP r1 {old=committed(r1), new=none} + /// v3: DUPLICATE DROP r1 {old=committed(r1), new=none} + /// The second removal of r1's edges is a no-op under the idempotent set model. + appendOwnerEvent(*backend, store->layout(), ns, 0, committed("tbl", r1), std::nullopt); + const uint64_t duplicate_removal_sequence + = appendOwnerEvent(*backend, store->layout(), ns, 0, committed("tbl", r1), std::nullopt); + advanceRecoverableCkptForRawFixture( + *backend, store->layout(), ns, RefTxnId{1, duplicate_removal_sequence}); + + /// Drive GC to fixpoint (advancing the mount ack each round so the ack floor graduates the condemned + /// blobs): must complete without throwing and collect both blobs. + ASSERT_NO_THROW({ + for (int i = 0; i < 12; ++i) + { + runRegularRoundReclaiming(gc); + store->renewWatermarkOnce(); + } + }) << "H2 regression: duplicate removal of an already-removed committed ref must NOT underflow; " + << "the idempotent edge set absorbs the duplicate removal"; + + EXPECT_EQ(inDegreeOf(*backend, store->layout(), DB::UInt128(1)), 0) + << "blob 1 is unreferenced (r1 dropped) and must be collected"; + EXPECT_FALSE(blobExists(*backend, store->layout(), DB::UInt128(1))) + << "blob 1 must be physically removed from the store"; + + EXPECT_EQ(inDegreeOf(*backend, store->layout(), DB::UInt128(2)), 0) + << "blob 2 is unreferenced (r1 dropped) and must be collected"; + EXPECT_FALSE(blobExists(*backend, store->layout(), DB::UInt128(2))) + << "blob 2 must be physically removed from the store"; +} + +/// ============================ H1: CURSOR RE-FOLD UNDER ABORT ============================ +/// +/// Hypothesis H1: a removal `-1` is folded, but the SINGLE round-commit CAS that would durably advance the +/// cursor past that removal LOSES to a concurrent leader (ABORTED). The cursor is NOT advanced, so a later +/// honest round RE-FOLDS the same removal against a parent generation whose in-degree for that blob has +/// already reached 0 => -1. +/// +/// We reproduce the deposed-round-commit injection from gtest_cas_gc_attempt.cpp +/// (DeposedFoldAttemptDoesNotWedge): deny the SINGLE round-commit gc/state CAS (the one that advances +/// snap_generation) of the round that folds the drop's -1. The deposed round left only never-adopted +/// attempt-scoped debris, so the retry re-folds the -1 against the still-adopted parent (in-degree 1), +/// producing a clean 0 — never a double-applied -1. +class InterruptRoundCasBackend : public InMemoryBackend +{ +public: + CasResult casPut(const String & key, const String & bytes, const std::optional & expected, + const ObjectMeta & meta) override + { + if (arm_interrupt && key == gc_state_key) + { + const auto stored = get(key); + const uint64_t stored_gen = stored ? decodeGcState(stored->bytes).snap_generation : 0; + const uint64_t next_gen = decodeGcState(bytes).snap_generation; + if (next_gen > stored_gen) + { + arm_interrupt = false; + throw DB::Exception(DB::ErrorCodes::ABORTED, + "test-injected: round-commit gc/state CAS denied (leader deposed mid-round; lease lost)"); + } + } + return InMemoryBackend::casPut(key, bytes, expected, meta); + } + + bool arm_interrupt = false; + String gc_state_key = "p/gc/state"; +}; + +TEST(CASGCUndercount, H1DrainAfterDeposedRemovalFoldDoesNotUnderflow) +{ + auto backend = std::make_shared(); + auto store = openPoolForTest(backend); + ASSERT_EQ(store->layout().gcStateKey(), "p/gc/state"); + + const RootNamespace ns{"00/aa@cas@"}; + const ManifestRef r = ref(1, 0xAA); + writeBlobBody(*backend, store->layout(), DB::UInt128(1)); + writeManifestRaw(*backend, store->layout(), ns, r, {blobEntryFor("a", DB::UInt128(1))}); + publishCommittedTransition(*backend, store->layout(), ns, "tbl", std::nullopt, r); + + Gc gc(store, kGc); + /// Round 1 (honest): fold +1, pin blob 1. + ASSERT_TRUE(runRegularRoundReclaiming(gc).acquired_lease); + store->renewWatermarkOnce(); + ASSERT_EQ(inDegreeOf(*backend, store->layout(), DB::UInt128(1)), 1); + + /// Drop the only ref and advance the watermark floor. + dropRefTransition(*backend, store->layout(), ns, "tbl", r); + store->renewWatermarkOnce(); + + /// Round 2 (DEPOSED): fold the -1, then the round-commit CAS is denied (ABORTED). The adopted + /// (snap_generation, snap_attempt) must NOT advance. + backend->arm_interrupt = true; + EXPECT_ANY_THROW(runRegularRoundReclaiming(gc)); + backend->arm_interrupt = false; + + /// Honest drive to fixpoint (advancing the mount ack each round). H1 predicts the re-fold of the -1 + /// underflows; the current code predicts a clean drain. Capture whichever happens. + bool threw_undercount = false; + try + { + for (int i = 0; i < 32; ++i) + { + runRegularRoundReclaiming(gc); + store->renewWatermarkOnce(); + } + } + catch (const DB::Exception & e) + { + threw_undercount = (e.code() == DB::ErrorCodes::CORRUPTED_DATA + && e.message().find("merged in-degree -1 < 0") != String::npos); + if (!threw_undercount) + throw; + std::cerr << "H1 captured exception: " << e.message() << "\n"; + } + + if (threw_undercount) + { + FAIL() << "H1 REPRODUCED: the deposed removal fold underflowed on re-fold"; + } + else + { + /// H1 did NOT reproduce with a single deposed round: the drain is clean. + EXPECT_FALSE(blobExists(*backend, store->layout(), DB::UInt128(1))) + << "H1-not-reproduced: the pool drained cleanly (single deposed round is idempotent)"; + EXPECT_EQ(inDegreeOf(*backend, store->layout(), DB::UInt128(1)), 0); + } +} + +/// ==================== H1b: A CONCURRENT-DROP REMOVAL IS FOLDED ONCE (IDEMPOTENCE) ==================== +/// +/// The idempotence claim that survives the redesign, without the (retired) fence-window framing: a removal +/// that lands AFTER a round's fold sealed its cursor but BEFORE that round's single commit CAS must be +/// folded EXACTLY ONCE by a later round — never re-folded to drive the blob in-degree below zero. +/// +/// In the one-pass round there is a single gc/state CAS (fold -> publish -> commit). We inject the drop +/// just before that commit CAS lands, so the event (v2) is above the fold's sealed cursor (v1) this round. +/// The committed round adopts the fold seal at cursor v1; the next round folds (v1, v2] as an ordinary -1 +/// against the still-live parent (blob 1 at in-degree 1 => 0). The source-edge SET model makes a re-fold +/// of the same removal a set-difference no-op, so the in-degree never underflows. +class DropAtCommitBackend : public InMemoryBackend +{ +public: + CasResult casPut(const String & key, const String & bytes, const std::optional & expected, + const ObjectMeta & meta) override + { + /// The one-pass round has a SINGLE gc/state CAS that advances snap_generation. Fire the injected + /// drop ONCE, just before that CAS commits — so the drop event is above this round's sealed cursor. + if (arm_drop && key == gc_state_key) + { + const auto stored = get(key); + if (stored) + { + const GcState prev = decodeGcState(stored->bytes); + const GcState next = decodeGcState(bytes); + if (next.snap_generation > prev.snap_generation) + { + arm_drop = false; + if (on_commit) + on_commit(); + } + } + } + return InMemoryBackend::casPut(key, bytes, expected, meta); + } + + bool arm_drop = false; + String gc_state_key = "p/gc/state"; + std::function on_commit; +}; + +TEST(CASGCUndercount, H1bFenceWindowRemovalReFoldedNextRoundUnderflows) +{ + auto backend = std::make_shared(); + /// gc_fold_max_defer_rounds=0 forces fold-every-round: the injected drop fires from `on_commit`, + /// which only runs on the round-commit CAS that ADVANCES snap_generation. With immutable logs an idle + /// round DEFERS (never advancing the generation), so a default store would never fire the injection -- + /// forcing a fold each round keeps the fence-window injection point (and its re-fold) reachable. + auto store = openPoolForTest(backend, /*gc_fold_max_defer_rounds*/ 0); + ASSERT_EQ(store->layout().gcStateKey(), "p/gc/state"); + + const RootNamespace ns{"00/aa@cas@"}; + const ManifestRef r = ref(1, 0xAA); + writeBlobBody(*backend, store->layout(), DB::UInt128(1)); + writeManifestRaw(*backend, store->layout(), ns, r, {blobEntryFor("a", DB::UInt128(1))}); + publishCommittedTransition(*backend, store->layout(), ns, "tbl", std::nullopt, r); + + Gc gc(store, kGc); + /// Round 1 (honest): fold +1, pin blob 1 at in-degree 1. Cursor sealed at v1. + ASSERT_TRUE(runRegularRoundReclaiming(gc).acquired_lease); + store->renewWatermarkOnce(); + ASSERT_EQ(inDegreeOf(*backend, store->layout(), DB::UInt128(1)), 1); + + /// Round 2: just before the round-commit CAS lands (after the fold sealed its cursor at v1), inject the + /// DROP as v2. The fold this round saw only up to v1 (no change), so the sealed cursor stays v1; the + /// drop event v2 is above it and survives trim. The NEXT round folds (v1, v2] => -1 on blob 1 against + /// the still-live parent (in-degree 1 => 0). It must NOT be re-folded a second time. + backend->arm_drop = true; + backend->on_commit = [&] + { + dropRefTransition(*backend, store->layout(), ns, "tbl", r); + }; + ASSERT_TRUE(runRegularRoundReclaiming(gc).acquired_lease); + backend->arm_drop = false; + + /// CORRECT behaviour: the concurrently-dropped blob is reclaimed exactly once and GC stays quiescent — + /// the removal folds ONCE (idempotent source-edge set), NEVER driving the in-degree below zero. Advance + /// the mount ack each round so the ack floor graduates and deletes the condemned blob. + EXPECT_NO_THROW({ + for (int i = 0; i < 12; ++i) + { + runRegularRoundReclaiming(gc); + store->renewWatermarkOnce(); + } + }) << "undercount: a concurrent-drop removal was re-folded and drove the blob in-degree < 0"; + + EXPECT_FALSE(blobExists(*backend, store->layout(), DB::UInt128(1))) + << "the concurrently-dropped blob must be reclaimed"; + EXPECT_EQ(inDegreeOf(*backend, store->layout(), DB::UInt128(1)), 0); +} + +/// ============== UNRECOGNIZED owner_transition SHAPE ABORTS THE ROUND, NEVER DELETES ================= +/// +/// A decodable ref log whose `owner_transition` op is SHAPE-illegal (here: neither `old_binding` nor +/// `new_binding` set) is exactly what `classifyOwnerTransitionShape` (Pool/CasRefProtocol.cpp) throws +/// `CORRUPTED_DATA` on. `writeRefLogTxnRaw` -- the same codec real writers use -- never checks op-shape +/// legality at encode/decode time, so this body is perfectly decodable; only `manifestEdgesOfTxn`'s +/// shape classification rejects it, at GC fold time. +/// +/// `Gc::fold` extracts edges inside the SAME try-block as `decodeRefLogTxn` +/// (Gc/CasGc.cpp), so the throw gets the identical "ref log body invalid: ref folding aborted this +/// round" treatment as an undecodable body: no cursor advance for ANY table (not just the corrupt +/// one), no ref delta lands, and the recorded anomaly drives `suppress_destructive`, which gates OFF +/// every graduated/pending blob delete for the WHOLE round -- including a blob in a namespace the +/// corrupt log never touched. +TEST(CASGCUndercount, UnrecognizedOwnerTransitionShapeAbortsRoundNeverDeletes) +{ + auto backend = std::make_shared(); + auto store = openPoolForTest(backend); + const RootNamespace ns{"00/aa@cas@"}; + const RootNamespace corrupt_ns{"00/bb@cas@"}; + const ManifestRef r1 = ref(1, 0xC1); + + writeBlobBody(*backend, store->layout(), DB::UInt128(9)); + writeManifestRaw(*backend, store->layout(), ns, r1, {blobEntryFor("a", DB::UInt128(9))}); + + /// Publish r1 (pins blob 9) and drop it again -- an ordinary, LEGAL removal that, absent + /// corruption, condemns blob 9 and (over a few more rounds, matching H1/H2 above) physically + /// deletes it. + publishCommittedTransition(*backend, store->layout(), ns, "tbl", std::nullopt, r1); + Gc gc(store, kGc); + ASSERT_TRUE(runRegularRoundReclaiming(gc).acquired_lease); + store->renewWatermarkOnce(); + ASSERT_EQ(inDegreeOf(*backend, store->layout(), DB::UInt128(9)), 1); + dropRefTransition(*backend, store->layout(), ns, "tbl", r1); + + /// Drive rounds until blob 9 first reaches in-degree 0 (condemned) -- still physically present: + /// deletion is two-phase (a later round graduates it to `delete_pending`, a later round still + /// executes the delete), so a freshly condemned blob is never deleted in the same round. + bool condemned = false; + for (int i = 0; i < 12 && !condemned; ++i) + { + ASSERT_TRUE(runRegularRoundReclaiming(gc).acquired_lease); + store->renewWatermarkOnce(); + condemned = (inDegreeOf(*backend, store->layout(), DB::UInt128(9)) == 0); + } + ASSERT_TRUE(condemned) << "setup: blob 9 must reach in-degree 0 (condemned) before injecting corruption"; + ASSERT_TRUE(blobExists(*backend, store->layout(), DB::UInt128(9))) + << "setup: a freshly condemned blob must still be physically present"; + + const uint64_t cursor_before = foldCursorOf(*backend, store->layout(), ns, /*shard*/0); + + /// A LEGAL, foldable log in `ns` ITSELF, staged AFTER capturing `cursor_before`. Without it, `ns` + /// has nothing new to fold, so the "ns cursor did not advance" assertion below is vacuous -- it + /// would pass even if the abort were per-table rather than round-wide. A duplicate remove-committed + /// of r1 is shape-legal and foldable (idempotent on the source-edge set, so it does not disturb blob + /// 9's already-condemned state), so absent the round-wide abort, folding `ns` WOULD advance its + /// cursor past this log -- making the pin below load-bearing. + appendOwnerEvent(*backend, store->layout(), ns, 0, committed("tbl", r1), std::nullopt); + + /// A decodable but SHAPE-illegal owner_transition (neither binding) in an UNRELATED table. + appendRefLogSeed(*backend, store->layout(), corrupt_ns, {ownerTransitionOp(std::nullopt, std::nullopt)}); + + /// Drive MANY more rounds with the corrupt log present. Absent corruption blob 9 -- already + /// condemned -- would graduate and be physically deleted within a handful more rounds (exactly + /// what H1/H2 above demonstrate for an equivalent drop). Every round here must instead: not throw, + /// leave every table's cursor exactly where it was (including `ns`, which the corrupt log never + /// touched -- ref-folding abort is round-wide, never per-table), record the anomaly, and never + /// physically delete blob 9. + for (int i = 0; i < 20; ++i) + { + RoundReport rep; + ASSERT_NO_THROW(rep = runRegularRoundReclaiming(gc)) + << "round " << i << ": an unrecognized owner_transition shape must abort ref folding, " + "never throw out of the round"; + store->renewWatermarkOnce(); + EXPECT_FALSE(rep.anomalies.empty()) + << "round " << i << ": the round must record the unrecognized-shape anomaly"; + EXPECT_EQ(foldCursorOf(*backend, store->layout(), ns, /*shard*/0), cursor_before) + << "round " << i << ": ns's cursor must not advance on a round whose ref folding aborted"; + ASSERT_TRUE(blobExists(*backend, store->layout(), DB::UInt128(9))) + << "round " << i << ": a previously-eligible (condemned) blob must NOT be deleted while " + "ref folding is aborted -- destructive work is suppressed for the whole round"; + } +} diff --git a/src/Disks/tests/gtest_cas_heartbeat.cpp b/src/Disks/tests/gtest_cas_heartbeat.cpp new file mode 100644 index 000000000000..1b26b2a9a635 --- /dev/null +++ b/src/Disks/tests/gtest_cas_heartbeat.cpp @@ -0,0 +1,760 @@ +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include + +namespace DB::ErrorCodes +{ + extern const int NETWORK_ERROR; + extern const int ABORTED; +} + +using namespace DB::Cas; + + +/// MountLeaseKeeper behavior: the per-server mount lease and the merged build-watermark floor ride the +/// SAME slot, renewed by one beat. The keeper anchors durably before return, adopts a slot already +/// written by `claimMount` (same uuid+epoch), re-reads the callback on each renew and bumps `seq`, +/// stamps the farewell sentinel (`min_active = UINT64_MAX`, `expires_at_ms <= now`) on `stop`, and +/// fails closed on any foreign touch (`renewOnce` throws). + +namespace +{ +/// The normal steady-state flow: `claimMount` writes the live (uuid, epoch) mount, THEN the keeper +/// adopts it. Seed that claim so `start` adopts instead of self-tripping the double-start guard. +void seedOwnClaim(Backend & b, const Layout & l, const String & srid, UInt128 uuid, uint64_t epoch, + uint64_t now_ms, uint64_t ttl_ms) +{ + ASSERT_EQ(claimMount(b, l, srid, uuid, epoch, now_ms, ttl_ms).kind, MountClaimResult::Claimed); +} + +/// Fix #37 phase 1: `shouldFenceOnTransientRenewFailure` is `protected` on `MountLeaseKeeper` (it is an +/// internal decision hook, not part of the public keeper API) -- promote it to `public` here so these +/// tests can drive it directly, without needing a real background thread. +class TestableMountLeaseKeeper : public MountLeaseKeeper +{ +public: + using MountLeaseKeeper::MountLeaseKeeper; + using MountLeaseKeeper::shouldFenceOnTransientRenewFailure; + using MountLeaseKeeper::onRenewSucceeded; +}; +} + +TEST(CASHeartbeat, AnchorCarriesFloor) +{ + auto backend = std::make_shared(); + Layout layout("pool"); + const String srid = "test"; + const UInt128 uuid(0x1234); + uint64_t now_ms = 1000; + uint64_t min_active_now = 5; + seedOwnClaim(*backend, layout, srid, uuid, /*epoch=*/9, now_ms, /*ttl_ms=*/100); + + MountLeaseKeeper keeper(backend, layout, srid, uuid, /*writer_epoch=*/9, std::chrono::milliseconds(100), + [&] { return now_ms; }, [&] { return min_active_now; }); + keeper.start(); + + auto hr = backend->head(layout.mountKey(srid)); + ASSERT_TRUE(hr.exists); + auto m = decodeMountLease(backend->get(layout.mountKey(srid))->bytes); + EXPECT_EQ(m.writer_epoch, 9u); + EXPECT_EQ(m.min_active, 5u); + EXPECT_EQ(m.seq, 1u); + EXPECT_FALSE(m.gc_fenced); +} + +TEST(CASHeartbeat, RenewRereadsCallbackAndBumpsSeq) +{ + auto backend = std::make_shared(); + Layout layout("pool"); + const String srid = "test"; + const UInt128 uuid(0x1234); + uint64_t now_ms = 1000; + uint64_t min_active_now = 5; + seedOwnClaim(*backend, layout, srid, uuid, /*epoch=*/9, now_ms, /*ttl_ms=*/100); + + MountLeaseKeeper keeper(backend, layout, srid, uuid, /*writer_epoch=*/9, std::chrono::milliseconds(100), + [&] { return now_ms; }, [&] { return min_active_now; }); + keeper.start(); + + /// The dynamic field moves; the renewal re-reads it off the callback and bumps seq. + now_ms = 1500; + min_active_now = 8; + keeper.renewOnce(); + + auto m = decodeMountLease(backend->get(layout.mountKey(srid))->bytes); + EXPECT_EQ(m.min_active, 8u); + EXPECT_EQ(m.seq, 2u); + EXPECT_EQ(m.expires_at_ms, 1500u + 100u); +} + +TEST(CASHeartbeat, StopStampsExpiredAndFarewellSentinel) +{ + auto backend = std::make_shared(); + Layout layout("pool"); + const String srid = "test"; + const UInt128 uuid(0x1234); + uint64_t now_ms = 1000; + seedOwnClaim(*backend, layout, srid, uuid, /*epoch=*/9, now_ms, /*ttl_ms=*/100); + + MountLeaseKeeper keeper(backend, layout, srid, uuid, /*writer_epoch=*/9, std::chrono::milliseconds(100), + [&] { return now_ms; }, [] { return uint64_t{5}; }); + keeper.start(); + + now_ms = 2000; + keeper.stop(); + + auto m = decodeMountLease(backend->get(layout.mountKey(srid))->bytes); + /// Terminal body stamps the lease already-expired (so a same-server reopen reclaims immediately) + /// AND folds the watermark farewell into it (min_active = UINT64_MAX). + EXPECT_LE(m.expires_at_ms, now_ms); + EXPECT_EQ(m.min_active, std::numeric_limits::max()); +} + +/// Phase A (spec rev.4 2026-07-24): a confirmed renewal mismatch whose re-read shows OUR OWN +/// (uuid, epoch), unfenced, is state UNCERTAINTY (an ambiguous landed renewal of ours, or a +/// same-pair twin after epoch-state loss) — fail closed via fence + self-remount, never an +/// exception that aborts debug/ASan builds at construction. +TEST(CASHeartbeat, SameEpochUnfencedTouchIsUncertainNotFatal) +{ + auto backend = std::make_shared(); + Layout layout("pool"); + const String srid = "test"; + const UInt128 uuid(0x1234); + uint64_t now_ms = 1000; + seedOwnClaim(*backend, layout, srid, uuid, /*epoch=*/9, now_ms, /*ttl_ms=*/100); + + MountLeaseKeeper keeper(backend, layout, srid, uuid, /*writer_epoch=*/9, std::chrono::milliseconds(100), + [&] { return now_ms; }, [] { return uint64_t{5}; }); + keeper.start(); + + /// The slot advances past our held token under our own pair (the ambiguous-landed-renewal shape). + const HeadResult h = backend->head(layout.mountKey(srid)); + ASSERT_TRUE(h.exists); + MountLease advanced; + advanced.server_uuid = uuid; + advanced.writer_epoch = 9; + advanced.seq = 99; + backend->putOverwrite(layout.mountKey(srid), encodeMountLease(advanced), h.token); + + try + { + keeper.renewOnce(); + FAIL() << "renewOnce must throw on a confirmed mismatch"; + } + catch (const DB::Exception & e) + { + EXPECT_EQ(e.code(), DB::ErrorCodes::ABORTED) << e.message(); + EXPECT_NE(e.message().find("state uncertain"), String::npos) << e.message(); + /// Forensics must ride in the message: the observed seq and our local seq. + EXPECT_NE(e.message().find("seq=99"), String::npos) << e.message(); + /// The local-seq fragment specifically -- not just any "seq=99" substring (which the + /// OBSERVED holder's own describeMountHolder text could also satisfy on its own). + EXPECT_NE(e.message().find("vs our seq="), String::npos) << e.message(); + } +} + +/// A body under our own uuid but a NEWER writer_epoch is proven supersession — a normal fencing +/// outcome (the TLA model's localLost), fail closed but never an abort. +TEST(CASHeartbeat, SupersededTouchIsFailClosedNotFatal) +{ + auto backend = std::make_shared(); + Layout layout("pool"); + const String srid = "test"; + const UInt128 uuid(0x1234); + uint64_t now_ms = 1000; + seedOwnClaim(*backend, layout, srid, uuid, /*epoch=*/9, now_ms, /*ttl_ms=*/100); + + MountLeaseKeeper keeper(backend, layout, srid, uuid, /*writer_epoch=*/9, std::chrono::milliseconds(100), + [&] { return now_ms; }, [] { return uint64_t{5}; }); + keeper.start(); + + const HeadResult h = backend->head(layout.mountKey(srid)); + ASSERT_TRUE(h.exists); + MountLease successor; + successor.server_uuid = uuid; + successor.writer_epoch = 10; + successor.seq = 1; + backend->putOverwrite(layout.mountKey(srid), encodeMountLease(successor), h.token); + + try + { + keeper.renewOnce(); + FAIL() << "renewOnce must throw on a confirmed mismatch"; + } + catch (const DB::Exception & e) + { + EXPECT_EQ(e.code(), DB::ErrorCodes::ABORTED) << e.message(); + EXPECT_NE(e.message().find("superseded by a newer incarnation"), String::npos) << e.message(); + } +} + +/// A foreign server holding our mount slot must FAIL CLOSED — and must not take the process with it. +/// +/// This test used to be `ForeignUuidTouchStillDies`, an `EXPECT_DEATH` that pinned the abort. The abort +/// was the defect: the arm raised `LOGICAL_ERROR`, which aborts at CONSTRUCTION in debug/ASan builds, +/// and it does so on the keeper's BACKGROUND thread — so an environment-reachable condition (clear the +/// prefix, recreate under a different server id, and the survivor's next renewal lands there; see +/// `CASRefContiguousAlloc.SurvivingWriterIsFencedByTheRecreatedPoolsMount`, which drives exactly that) +/// took the whole server down, and took the ASan gate down with it. +/// +/// What must NOT change is the outcome, which is what this test now pins: `renewOnce` throws, the throw +/// carries the foreign holder's identity, and it is classified `ABORTED` — the same mount-lost class the +/// sibling fencing arms use, which the background loop turns into a latched write fence. The +/// `abort_on_logical_error` arming is deliberately kept: with it ON, a `LOGICAL_ERROR` would still abort, +/// so reaching the `EXPECT_THROW` at all is the proof that this condition is no longer classified as one. +TEST(CASHeartbeat, ForeignUuidTouchFailsClosedWithoutAborting) +{ + auto backend = std::make_shared(); + Layout layout("pool"); + const String srid = "test"; + const UInt128 uuid(0x1234); + uint64_t now_ms = 1000; + seedOwnClaim(*backend, layout, srid, uuid, /*epoch=*/9, now_ms, /*ttl_ms=*/100); + + MountLeaseKeeper keeper(backend, layout, srid, uuid, /*writer_epoch=*/9, std::chrono::milliseconds(100), + [&] { return now_ms; }, [] { return uint64_t{5}; }); + keeper.start(); + + const HeadResult h = backend->head(layout.mountKey(srid)); + ASSERT_TRUE(h.exists); + MountLease foreign; + foreign.server_uuid = UInt128(0x9999); + foreign.writer_epoch = 1; + foreign.seq = 1; + backend->putOverwrite(layout.mountKey(srid), encodeMountLease(foreign), h.token); + + /// Restored on every exit: this flag is process-global and every later test in this binary would + /// inherit it. + const bool armed_before = DB::abort_on_logical_error.load(std::memory_order_relaxed); + DB::abort_on_logical_error.store(true, std::memory_order_relaxed); + SCOPE_EXIT({ DB::abort_on_logical_error.store(armed_before, std::memory_order_relaxed); }); + + String message; + int code = 0; + try + { + keeper.renewOnce(); + FAIL() << "a foreign holder must fail the renewal closed, not be silently taken over"; + } + catch (const DB::Exception & e) + { + message = e.message(); + code = e.code(); + } + EXPECT_NE(message.find("held by a foreign server"), String::npos) << message; + EXPECT_EQ(code, DB::ErrorCodes::ABORTED) + << "the mount-lost class the background loop latches the write fence on -- and, critically, not " + "LOGICAL_ERROR, which would abort this keeper thread and the whole process with it"; +} + +/// Mount-slot writer audit (the P1 "foreign writer" instrument): every mount-slot WRITE and every +/// OBSERVED foreign/conflicting body becomes an event, carrying the conflicting body's identity — +/// the payload the chronic "touched by a foreign writer" collisions need to be diagnosable. +TEST(CASMountAudit, ClaimReleaseAndForeignConflictEmitEvents) +{ + auto backend = std::make_shared(); + Layout layout("pool"); + std::vector seen; + CasEventSink sink = [&](const CasEvent & e) { seen.push_back(e); }; + + const uint64_t now_ms = 1'000'000; + /// mint for uuid 1 -> one mount_claim + ASSERT_EQ(claimMount(*backend, layout, "a", UInt128{1}, 1, now_ms, /*ttl_ms=*/10'000, {}, sink).kind, + MountClaimResult::Claimed); + ASSERT_EQ(seen.size(), 1u); + EXPECT_EQ(seen[0].type, CasEventType::MountClaim); + EXPECT_EQ(seen[0].detail.at("server_root_id"), "a"); + EXPECT_EQ(seen[0].detail.at("branch"), "mint"); + + /// a FOREIGN uuid claiming a live slot -> mount_conflict carrying the current holder's identity + seen.clear(); + (void)claimMount(*backend, layout, "a", UInt128{2}, 1, now_ms, /*ttl_ms=*/10'000, {}, sink); + ASSERT_FALSE(seen.empty()); + EXPECT_EQ(seen.back().type, CasEventType::MountConflict); + EXPECT_EQ(seen.back().detail.at("server_root_id"), "a"); + /// The conflict must carry the ORIGINAL holder's identity (uuid 1, the minter) — not the + /// foreign claimer's (uuid 2). + EXPECT_EQ(seen.back().detail.at("holder_uuid"), u128ToHex(UInt128{1})); + EXPECT_NE(seen.back().detail.at("holder_uuid"), u128ToHex(UInt128{2})); +} + +/// The MountLeaseKeeper wiring: `start` adopting an already-claimed slot emits mount_claim, `stop` +/// (the farewell write) emits mount_release. +TEST(CASMountAudit, KeeperAdoptEmitsClaimAndTerminateEmitsRelease) +{ + auto backend = std::make_shared(); + Layout layout("pool"); + const String srid = "test"; + const UInt128 uuid(0x1234); + uint64_t now_ms = 1000; + seedOwnClaim(*backend, layout, srid, uuid, /*epoch=*/9, now_ms, /*ttl_ms=*/100); + + std::vector seen; + CasEventSink sink = [&](const CasEvent & e) { seen.push_back(e); }; + MountLeaseKeeper keeper(backend, layout, srid, uuid, /*writer_epoch=*/9, std::chrono::milliseconds(100), + [&] { return now_ms; }, [] { return uint64_t{5}; }, sink); + keeper.start(); + + ASSERT_EQ(seen.size(), 1u); + EXPECT_EQ(seen[0].type, CasEventType::MountClaim); + EXPECT_EQ(seen[0].detail.at("branch"), "adopt"); + + seen.clear(); + now_ms = 2000; + keeper.stop(); + + ASSERT_EQ(seen.size(), 1u); + EXPECT_EQ(seen[0].type, CasEventType::MountRelease); + EXPECT_EQ(seen[0].detail.at("branch"), "farewell"); +} + +/// Keeper-level foreign-conflict refusal: the mount slot is already held by a FOREIGN uuid (X) when +/// a keeper for a DIFFERENT uuid (Y) tries to claim it. This must fail closed and — since the +/// mount-audit sink is not yet installed at first-open — name X in the exception's message text +/// (the only identity carrier in err.log at that point). MountConflict payload coverage is above. +TEST(CASMountAudit, KeeperForeignConflictRefusesAndNamesHolder) +{ + auto backend = std::make_shared(); + Layout layout("pool"); + const String srid = "test"; + const UInt128 uuid_x(0x1111); + const UInt128 uuid_y(0x2222); + uint64_t now_ms = 1000; + + /// Foreign holder X claims the slot first. + ASSERT_EQ(claimMount(*backend, layout, srid, uuid_x, /*our_epoch=*/1, now_ms, /*ttl_ms=*/100).kind, + MountClaimResult::Claimed); + + MountLeaseKeeper keeper(backend, layout, srid, uuid_y, /*writer_epoch=*/1, std::chrono::milliseconds(100), + [&] { return now_ms; }, [] { return uint64_t{5}; }); + + /// The enriched refusal message must name the OBSERVED holder (X), not the caller (Y). + const String holder_uuid = u128ToHex(uuid_x); + EXPECT_DEATH( + { + DB::abort_on_logical_error.store(true, std::memory_order_relaxed); + keeper.start(); + }, + holder_uuid); +} + +/// `Pool::open` can fail before/inside `doStart` (e.g. a foreign-conflict refusal, see +/// `KeeperForeignConflictRefusesAndNamesHolder` above) — the keeper is destroyed without ever having +/// claimed anything. Teardown must not throw "release before start"; there is nothing to release. A +/// stop AFTER a successful start still performs the farewell (covered by +/// `StopStampsExpiredAndFarewellSentinel` above); a genuinely-started DOUBLE terminate stays loud. +TEST(CASHeartbeat, StopBeforeStartIsQuietNoOp) +{ + auto backend = std::make_shared(); + Layout layout("pool"); + uint64_t now_ms = 1000; + MountLeaseKeeper keeper(backend, layout, "a", UInt128{1}, /*writer_epoch=*/1, std::chrono::milliseconds(10'000), + [&] { return now_ms; }, [] { return uint64_t{0}; }); + /// start() never called. + EXPECT_NO_THROW(keeper.stop()); + EXPECT_NO_THROW(keeper.stop()); +} + +/// "A fence costs an epoch" at the keeper layer: the GC fenced our fresh lease before we adopted it +/// (the lease expired mid-open — e.g. a slow first beat). This must fail closed with a TYPED, +/// recoverable `MountFencedException`, distinct from the generic "touched by a foreign writer" +/// `LOGICAL_ERROR` — the open path (Task 4) tells "re-open with a fresh epoch" apart from "fail hard" +/// by this code, not by parsing message text. +TEST(CASMountAudit, KeeperAdoptRefusesFencedSelfWithTypedError) +{ + auto backend = std::make_shared(); + Layout layout("pool"); + const String srid = "test"; + const UInt128 uuid(0x1234); + uint64_t now_ms = 1000; + + /// mint (uuid, epoch 9), then fence it in place (what computeHeartbeatFloor does on expiry): + seedOwnClaim(*backend, layout, srid, uuid, /*epoch=*/9, now_ms, /*ttl_ms=*/100); + { + auto got = backend->get(layout.mountKey(srid)); + MountLease fenced = decodeMountLease(got->bytes); + fenced.gc_fenced = true; + fenced.seq += 1; + ASSERT_EQ(backend->putOverwrite(layout.mountKey(srid), encodeMountLease(fenced), got->token).outcome, + PutOutcome::Done); + } + + std::vector seen; + CasEventSink sink = [&](const CasEvent & e) { seen.push_back(e); }; + /// A keeper for the SAME (uuid, epoch) tries to adopt the now-fenced slot. + MountLeaseKeeper keeper(backend, layout, srid, uuid, /*writer_epoch=*/9, std::chrono::milliseconds(100), + [&] { return now_ms; }, [] { return uint64_t{5}; }, sink); + + bool threw = false; + try + { + keeper.start(); + } + catch (const MountFencedException & e) + { + threw = true; + EXPECT_NE(e.message().find("fenced by GC"), String::npos) << e.message(); + EXPECT_EQ(e.message().find("foreign writer"), String::npos) << e.message(); + } + EXPECT_TRUE(threw); + + ASSERT_FALSE(seen.empty()); + EXPECT_EQ(seen.back().type, CasEventType::MountConflict); + EXPECT_EQ(seen.back().detail.at("branch"), "fenced_by_gc"); +} + +/// A renew mismatch is classified by BODY, not blamed on "a foreign writer" by default: the GC can +/// fence our OWN (uuid, epoch) mount slot after our lease expires (a late renewal beat racing the +/// GC's fence-out). The keeper must re-read and recognize this as its OWN incarnation being fenced — +/// a recoverable `MountFencedException`, not the generic single-writer-violation text. +TEST(CASHeartbeat, RenewOverFencedOwnSlotIsClassifiedNotForeign) +{ + auto backend = std::make_shared(); + Layout layout("pool"); + const String srid = "test"; + const UInt128 uuid(0x1234); + uint64_t now_ms = 1000; + seedOwnClaim(*backend, layout, srid, uuid, /*epoch=*/9, now_ms, /*ttl_ms=*/100); + + std::vector seen; + CasEventSink sink = [&](const CasEvent & e) { seen.push_back(e); }; + MountLeaseKeeper keeper(backend, layout, srid, uuid, /*writer_epoch=*/9, std::chrono::milliseconds(100), + [&] { return now_ms; }, [] { return uint64_t{5}; }, sink); + keeper.start(); + seen.clear(); + + /// Mid-run: the GC fences our own (uuid, epoch) mount slot in place (as `computeHeartbeatFloor` + /// does on an expired lease), preserving the whole body — a token-guarded putOverwrite, exactly + /// as the GC's own fence-out does it. + { + const auto got = backend->get(layout.mountKey(srid)); + MountLease fenced = decodeMountLease(got->bytes); + fenced.gc_fenced = true; + fenced.seq += 1; + ASSERT_EQ(backend->putOverwrite(layout.mountKey(srid), encodeMountLease(fenced), got->token).outcome, + PutOutcome::Done); + } + + /// The renewal must classify the fence honestly — not "foreign writer": + try + { + keeper.renewOnce(); + FAIL() << "renewOnce over a fenced slot must throw"; + } + catch (const MountFencedException & e) + { + EXPECT_TRUE(e.message().find("fenced by GC") != String::npos); + EXPECT_TRUE(e.message().find("foreign writer") == String::npos); + } + /// and the capture sink saw mount_conflict branch=fenced_by_gc with the fenced body's identity. + ASSERT_FALSE(seen.empty()); + EXPECT_EQ(seen.back().type, CasEventType::MountConflict); + EXPECT_EQ(seen.back().detail.at("branch"), "fenced_by_gc"); + EXPECT_EQ(seen.back().detail.at("holder_uuid"), u128ToHex(uuid)); +} + +/// Fix #37 phase 1: a TRANSIENT renewal failure (the background loop's `renewOnce` threw, but NOT via a +/// confirmed `onRenewMismatch`) must not fence while the last confirmed lease still has more than +/// `lease_safety_margin` left before it would expire -- the mount-lease protocol guarantees no other +/// writer can claim the slot before that deadline, so riding it out is safe. +TEST(CASHeartbeat, TransientRetryStaysWithinLeaseDeadline) +{ + auto backend = std::make_shared(); + Layout layout("pool"); + const String srid = "test"; + const UInt128 uuid(0x1234); + uint64_t now_ms = 1000; + seedOwnClaim(*backend, layout, srid, uuid, /*epoch=*/9, now_ms, /*ttl_ms=*/1000); + + TestableMountLeaseKeeper keeper(backend, layout, srid, uuid, /*writer_epoch=*/9, + std::chrono::milliseconds(1000), [&] { return now_ms; }, + [] { return uint64_t{0}; }, CasEventSink{}, + /*lease_safety_margin=*/std::chrono::milliseconds(100)); + keeper.start(); /// claim() anchors confirmed_deadline_ms = 1000 (now) + 1000 (ttl) = 2000 + + /// Well before the deadline's safety margin (2000 - 100 = 1900): must NOT fence. + now_ms = 1500; + EXPECT_FALSE(keeper.shouldFenceOnTransientRenewFailure()); + + /// At/after the safety-margin boundary: must fence. + now_ms = 1900; + EXPECT_TRUE(keeper.shouldFenceOnTransientRenewFailure()); + now_ms = 2000; + EXPECT_TRUE(keeper.shouldFenceOnTransientRenewFailure()); +} + +/// A successful renew extends the confirmed deadline -- the boundary that WOULD have tripped against +/// the OLD deadline no longer does against the refreshed one. `confirmed_deadline_ms` is refreshed by +/// `onRenewSucceeded` (the hook the real background loop calls after a successful beat -- see +/// `CasPool.cpp`'s note that unit tests drive `renewOnce` directly and never through the loop, so this +/// test calls the promoted `onRenewSucceeded` itself to model exactly what one successful real beat +/// does), not by a bare `renewOnce` call in isolation. +TEST(CASHeartbeat, SuccessfulRenewExtendsTransientRetryDeadline) +{ + auto backend = std::make_shared(); + Layout layout("pool"); + const String srid = "test"; + const UInt128 uuid(0x1234); + uint64_t now_ms = 1000; + seedOwnClaim(*backend, layout, srid, uuid, /*epoch=*/9, now_ms, /*ttl_ms=*/1000); + + TestableMountLeaseKeeper keeper(backend, layout, srid, uuid, /*writer_epoch=*/9, + std::chrono::milliseconds(1000), [&] { return now_ms; }, + [] { return uint64_t{0}; }, CasEventSink{}, + /*lease_safety_margin=*/std::chrono::milliseconds(100)); + keeper.start(); /// confirmed_deadline_ms = 2000 + + now_ms = 1900; + ASSERT_TRUE(keeper.shouldFenceOnTransientRenewFailure()) << "sanity: 1900 trips the OLD deadline"; + + /// A renew at now_ms=1900 succeeds; onRenewSucceeded (as the background loop would call it) + /// refreshes confirmed_deadline_ms to 1900 + 1000 = 2900. + keeper.renewOnce(); + keeper.onRenewSucceeded(); + EXPECT_FALSE(keeper.shouldFenceOnTransientRenewFailure()) + << "the refreshed deadline (2900, margin 100) must not trip at now_ms=1900 any more"; +} + +/// Fence-not-rescue round follow-up #1: the redo site in `CasPool.cpp`'s `mountWritable` (the +/// claim-consumed-the-TTL branch) calls `renewOnce` DIRECTLY -- never through +/// `onRenewSucceeded` (that hook is only ever invoked by `backgroundLoop`). Before this fix, only +/// `onRenewSucceeded` refreshed `confirmed_deadline_ms`, so a direct `renewOnce` call left the wall +/// deadline stale at the pre-redo anchor. A successful renewal must refresh the deadline regardless +/// of who called `renewOnce` -- background loop or a direct caller alike. +TEST(CASHeartbeat, DirectRenewOnceRefreshesConfirmedDeadlineWithoutOnRenewSucceeded) +{ + auto backend = std::make_shared(); + Layout layout("pool"); + const String srid = "test"; + const UInt128 uuid(0x1234); + uint64_t now_ms = 1000; + seedOwnClaim(*backend, layout, srid, uuid, /*epoch=*/9, now_ms, /*ttl_ms=*/1000); + + TestableMountLeaseKeeper keeper(backend, layout, srid, uuid, /*writer_epoch=*/9, + std::chrono::milliseconds(1000), [&] { return now_ms; }, + [] { return uint64_t{0}; }, CasEventSink{}, + /*lease_safety_margin=*/std::chrono::milliseconds(100)); + keeper.start(); /// confirmed_deadline_ms = 2000 + + now_ms = 1900; + ASSERT_TRUE(keeper.shouldFenceOnTransientRenewFailure()) << "sanity: 1900 trips the OLD deadline"; + + /// A DIRECT renewOnce -- exactly what the redo site calls, with `onRenewSucceeded` never invoked + /// anywhere near it -- must ALSO refresh confirmed_deadline_ms to 1900 + 1000 = 2900. + keeper.renewOnce(); + EXPECT_FALSE(keeper.shouldFenceOnTransientRenewFailure()) + << "a direct renewOnce (with no onRenewSucceeded call at all) must refresh the confirmed " + "deadline too -- the redo's wall anchor must not go stale"; +} + +namespace +{ +/// Wraps an `InMemoryBackend` so `putOverwrite` throws a TRANSIENT (non-mismatch) exception for the +/// first `fault_count` calls, then delegates normally. Models a `putOverwrite` that fails before any +/// outcome is observed (timeout / 5xx / connection reset) -- exactly the case fix #37 phase 1 targets, +/// as opposed to a `PreconditionFailed` (a CONFIRMED, backend-observed mismatch). +class TransientPutOverwriteFaultBackend final : public InMemoryBackend +{ +public: + int fault_count = 0; + + PutResult putOverwrite(const String & k, const String & b, const Token & e, const ObjectMeta & m) override + { + if (fault_count > 0) + { + --fault_count; + throw DB::Exception(DB::ErrorCodes::NETWORK_ERROR, "injected transient putOverwrite fault"); + } + return InMemoryBackend::putOverwrite(k, b, e, m); + } +}; +} + +/// Real background thread: two transient faults, then the third beat lands. The loop must NOT stop and +/// must NOT fence (on_lost never fires) -- it just keeps retrying at the normal period. +TEST(CASHeartbeat, BackgroundLoopRetriesTransientFailureWithoutFencingOrStopping) +{ + auto backend = std::make_shared(); + Layout layout("pool"); + const String srid = "test"; + const UInt128 uuid(0x1234); + uint64_t now_ms = 1000; + seedOwnClaim(*backend, layout, srid, uuid, /*epoch=*/9, now_ms, /*ttl_ms=*/30000); + + std::atomic lost{false}; + MountLeaseKeeper keeper(backend, layout, srid, uuid, /*writer_epoch=*/9, std::chrono::milliseconds(30000), + [&] { return now_ms; }, [] { return uint64_t{0}; }, CasEventSink{}, + std::chrono::milliseconds(2000)); + keeper.setFenceCallbacks([](uint64_t) {}, [&] { lost = true; }); + keeper.start(); /// the adopt-path putOverwrite must land BEFORE the faults are armed below. + + /// Arm the faults only for the BACKGROUND renewals under test -- `start`'s own adopt-path + /// putOverwrite above must not be faulted, or it throws straight out of this test body instead of + /// exercising the loop's transient-retry path. + backend->fault_count = 2; + keeper.startBackground(std::chrono::milliseconds(20)); + + /// Bounded poll (not a blind sleep): waits for the REAL background thread to land a renewal past + /// the two faults. Generous 5s timeout; a background-thread test cannot be made synchronous without + /// a dedicated test seam this codebase does not have (see gtest_cas_pool.cpp's preference for + /// synchronous renewOnce-driven tests elsewhere -- not applicable here, since the loop-continuation + /// behavior under test only exists inside backgroundLoop itself). + const auto deadline = std::chrono::steady_clock::now() + std::chrono::seconds(5); + uint64_t seq = 1; + while (std::chrono::steady_clock::now() < deadline) + { + seq = decodeMountLease(backend->get(layout.mountKey(srid))->bytes).seq; + if (seq >= 2) + break; + std::this_thread::sleep_for(std::chrono::milliseconds(5)); + } + keeper.stopBackground(); + + EXPECT_GE(seq, 2u) << "background loop never recovered from the transient faults"; + EXPECT_FALSE(lost.load()) << "a transient putOverwrite failure must not trip the fence"; +} + +/// A CONFIRMED mismatch (a same-(uuid, epoch) unfenced touch lands on the slot -- state +/// uncertainty, spec rev.4) must fence immediately, even with the deadline nowhere near expiry -- +/// the other half of fix #37 phase 1's distinction. Phase A repartition: this shape is no longer +/// fatal (it throws `ABORTED`, not `LOGICAL_ERROR`), so the loop must recover via `on_lost` instead +/// of dying -- observe the fence the same way `BackgroundLoopRetriesTransientFailureWithoutFencingOrStopping` +/// observes non-fencing. +TEST(CASHeartbeat, BackgroundLoopFencesImmediatelyOnConfirmedMismatch) +{ + auto backend = std::make_shared(); + Layout layout("pool"); + const String srid = "test"; + const UInt128 uuid(0x1234); + uint64_t now_ms = 1000; + seedOwnClaim(*backend, layout, srid, uuid, /*epoch=*/9, now_ms, /*ttl_ms=*/30000); + + std::atomic lost{false}; + MountLeaseKeeper keeper( + backend, layout, srid, uuid, /*writer_epoch=*/9, std::chrono::milliseconds(30000), + [&] { return now_ms; }, [] { return uint64_t{0}; }, CasEventSink{}, std::chrono::milliseconds(2000)); + keeper.setFenceCallbacks([](uint64_t) {}, [&] { lost = true; }); + keeper.start(); + + /// A same-(uuid, epoch) unfenced touch overwrites the slot BEFORE the first background beat. + const HeadResult h = backend->head(layout.mountKey(srid)); + MountLease advanced; + advanced.server_uuid = uuid; + advanced.writer_epoch = 9; + advanced.seq = 99; + ASSERT_EQ(backend->putOverwrite(layout.mountKey(srid), encodeMountLease(advanced), h.token).outcome, + PutOutcome::Done); + + keeper.startBackground(std::chrono::milliseconds(20)); + const auto deadline = std::chrono::steady_clock::now() + std::chrono::seconds(5); + while (!lost.load() && std::chrono::steady_clock::now() < deadline) + std::this_thread::sleep_for(std::chrono::milliseconds(5)); + keeper.stopBackground(); + + EXPECT_TRUE(lost.load()) << "background loop must fence immediately on a confirmed same-epoch mismatch"; +} + +namespace +{ +/// The landed-but-unacked case: the putOverwrite APPLIES to the in-memory state, THEN throws a +/// transient exception — the exact CI shape (a client-side timeout whose PUT landed server-side). +/// The existing TransientPutOverwriteFaultBackend throws BEFORE applying and cannot model this. +class ApplyThenThrowPutOverwriteFaultBackend final : public InMemoryBackend +{ +public: + int fault_count = 0; + + PutResult putOverwrite(const String & k, const String & b, const Token & e, const ObjectMeta & m) override + { + if (fault_count > 0) + { + --fault_count; + InMemoryBackend::putOverwrite(k, b, e, m); /// the write LANDS... + throw DB::Exception(DB::ErrorCodes::NETWORK_ERROR, + "injected ambiguous fault: applied, ack lost"); /// ...the ack does not. + } + return InMemoryBackend::putOverwrite(k, b, e, m); + } +}; +} + +/// End-to-end reproduction of the CI crash (Altinity PR#2073, asan CAS-s3 stateless): beat 1's +/// renewal lands but its ack is lost (transient -> the loop retries, deadline permitting); beat 2 +/// renews with the now-stale token, gets a CONFIRMED mismatch, re-reads our own advanced body -> +/// the Phase A uncertain branch -> the loop stops, on_lost fires (fence latches; in production the +/// Pool self-remounts from there). No process death anywhere. +TEST(CASHeartbeat, BackgroundLoopSurvivesAmbiguousLandedRenewal) +{ + auto backend = std::make_shared(); + Layout layout("pool"); + const String srid = "test"; + const UInt128 uuid(0x1234); + uint64_t now_ms = 1000; + seedOwnClaim(*backend, layout, srid, uuid, /*epoch=*/9, now_ms, /*ttl_ms=*/30000); + + std::atomic lost{false}; + MountLeaseKeeper keeper(backend, layout, srid, uuid, /*writer_epoch=*/9, std::chrono::milliseconds(30000), + [&] { return now_ms; }, [] { return uint64_t{0}; }, CasEventSink{}, + std::chrono::milliseconds(2000)); + keeper.setFenceCallbacks([](uint64_t) {}, [&] { lost = true; }); + keeper.start(); /// the adopt-path putOverwrite must land unfaulted. + + backend->fault_count = 1; /// beat 1: lands + throws (ambiguous); beat 2: confirmed mismatch. + keeper.startBackground(std::chrono::milliseconds(20)); + + /// Bounded poll for on_lost (never a blind sleep): the deadline is generous; the loop needs + /// two ~20ms beats. abort_on_logical_error stays ON to prove no branch constructs one. + DB::abort_on_logical_error.store(true, std::memory_order_relaxed); + const auto deadline = std::chrono::steady_clock::now() + std::chrono::seconds(5); + while (!lost.load() && std::chrono::steady_clock::now() < deadline) + std::this_thread::sleep_for(std::chrono::milliseconds(5)); + /// Restore the default before returning: unlike the EXPECT_DEATH-wrapped uses above (which flip + /// this flag only inside a forked child), this test sets it in the actual test process — leaving + /// it ON would make every LOGICAL_ERROR raised by any LATER test in this binary abort instead of + /// throwing, regardless of that test's own intent. + DB::abort_on_logical_error.store(false, std::memory_order_relaxed); + EXPECT_TRUE(lost.load()) << "the confirmed mismatch after an ambiguous landed renewal must " + "latch the fence via on_lost (and must not abort the process)"; + keeper.stopBackground(); +} + +/// Phase B: the confirmed-lease deadline anchors at the ATTEMPT-START instant, not the response +/// instant — a slow ack must not extend the local fence past what the durable body authorizes. +TEST(CASHeartbeat, RenewDeadlineAnchorsAtAttemptStartNotResponseTime) +{ + auto backend = std::make_shared(); + Layout layout("pool"); + const String srid = "test"; + const UInt128 uuid(0x1234); + uint64_t now_ms = 1000; + seedOwnClaim(*backend, layout, srid, uuid, /*epoch=*/9, now_ms, /*ttl_ms=*/1000); + + TestableMountLeaseKeeper keeper(backend, layout, srid, uuid, /*writer_epoch=*/9, + std::chrono::milliseconds(1000), + [&] { return now_ms; }, [] { return uint64_t{0}; }, CasEventSink{}, + std::chrono::milliseconds(100)); + keeper.start(); /// claim at now=1000 -> anchored confirmed deadline 2000 + + /// Beat at now=1500; the "ack" (onRenewSucceeded) arrives late, at now=2400 — after the + /// durable expiry stamped by THIS beat's payload (1500+1000=2500 durable; anchor 1500). + now_ms = 1500; + keeper.renewOnce(); + now_ms = 2400; + keeper.onRenewSucceeded(); + + /// Anchored: deadline = 1500 + 1000 = 2500. At now=2401 with margin 100 the boundary check is + /// 2401 + 100 >= 2500 -> must fence. (Response-time behavior — the bug — would give + /// 2400 + 1000 = 3400 and NOT fence.) + now_ms = 2401; + EXPECT_TRUE(keeper.shouldFenceOnTransientRenewFailure()) + << "a late ack must not extend the confirmed deadline past attempt-start + TTL"; +} diff --git a/src/Disks/tests/gtest_cas_holey_list_detector.cpp b/src/Disks/tests/gtest_cas_holey_list_detector.cpp new file mode 100644 index 000000000000..6c1728d3ba31 --- /dev/null +++ b/src/Disks/tests/gtest_cas_holey_list_detector.cpp @@ -0,0 +1,306 @@ +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include + +/// SKIPPED-TRANSACTION suite. The defect class: a GC round's fold cursor advances past a ref +/// transaction the round never applied. Once the cursor is sealed above a record, that record can +/// never be folded again, so BOTH directions of the damage are permanent: +/// +/// - RETENTION: a skipped `-1` leaves a residual `+1`, so the blob is never reclaimed (a leak); +/// - DELETION: a skipped `+1` hides a live owner, so GC deletes a blob a committed manifest still +/// references (data loss). +/// +/// The suspected MECHANISM (a `LIST` page that omits a durable key) is UNCONFIRMED — a holey page was +/// never directly observed, it survives by elimination, and `CaRelinkConfirmCore.tla` `_sab_holeylist` +/// proves the mechanism is SUFFICIENT, not that it is what happened. These tests therefore use the +/// holey listing only as the cheapest way to make the EFFECT executable; nothing here may key on how +/// the hole was produced. + +using namespace DB::Cas; +using DB::Cas::tests::idOf; +using DB::Cas::tests::u128Of; + +namespace +{ + +/// A backend that drops ONE chosen key from ONE chosen `list` call, while leaving exact `get`/`head` +/// of that key working. This is the minimal realisation of "the store returned an incomplete answer": +/// the record is durable and readable, it is simply absent from one enumeration. The mechanism is +/// deliberately NOT modelled (no page split, no cursor games) — the arithmetic intake under test must +/// not depend on how the hole was produced. +/// +/// WHICH call is explicit and load-bearing. A GC round enumerates the ref prefix ONCE, in +/// `Gc::listRefPrefix`, and the fold regroups that same enumeration -- so `nth = 0` is the walk whose +/// hole the fold would have to survive, and it is the one every test here arms. `nth` counts, from the +/// moment `omitFromNthListCall` is called, only those `list` calls that WOULD have returned the key — so +/// unrelated prefix enumerations do not shift it. +/// Arm the sabotage AFTER every seeding write: the writer's own sequence allocation lists the +/// namespace prefix and would otherwise consume a qualifying call. +/// +/// Erasing a key from the page never disturbs pagination: `ListPage::next_cursor` is the LAST key the +/// underlying backend returned and is computed before the erase, so the next page still resumes +/// strictly after it. +class HoleyListBackend : public InMemoryBackend +{ +public: + /// Omit `key` from the `nth` (0-based) subsequent qualifying `list` call. Resets the counter. + void omitFromNthListCall(const String & key, size_t nth) + { + std::lock_guard lock(m); + omitted = key; + target_call = nth; + seen_calls = 0; + served = false; + } + + /// Whether the hole was actually served. Every test asserts this, so a mis-typed key or a + /// miscounted `nth` cannot let a test pass vacuously. + bool holeServed() const + { + std::lock_guard lock(m); + return served; + } + + ListPage list(const String & prefix, const String & cursor, size_t limit) override + { + ListPage page = InMemoryBackend::list(prefix, cursor, limit); + std::lock_guard lock(m); + if (omitted.empty()) + return page; + auto it = std::find_if(page.keys.begin(), page.keys.end(), + [&](const ListedKey & k) { return k.key == omitted; }); + if (it == page.keys.end()) + return page; /// not a qualifying call — do not count it + if (seen_calls++ != target_call) + return page; + page.keys.erase(it); + served = true; + omitted.clear(); /// one hole only + return page; + } + +private: + mutable std::mutex m; + String omitted; + size_t target_call = 0; + size_t seen_calls = 0; + bool served = false; +}; + +PoolPtr openHoleyPool(std::shared_ptr & out_backend) +{ + out_backend = std::make_shared(); + return Pool::open(out_backend, PoolConfig{.pool_prefix = "p", .server_root_id = "test"}); +} + +/// A `ManifestEntry` for a Blob leaf at `path` referencing `payload`'s content hash. +ManifestEntry blobEntry(const String & path, const String & payload) +{ + ManifestEntry e; + e.path = path; + e.placement = EntryPlacement::Blob; + e.ref = BlobRef{BlobHashAlgo::CityHash128, BlobDigest::fromU128(u128Of(payload))}; + e.blob_size = payload.size(); + return e; +} + +/// Publish one single-blob part through the REAL writer sequence and return its `ManifestId`. +ManifestId publishOneBlobPart(const PoolPtr & s, const RootNamespace & ns, const String & ref, + const String & payload) +{ + PartWriteInfo info; + info.intended_ref = ns.string() + "/" + ref; + auto build = s->beginPartWrite(info); + const ManifestId id = build->stageManifest({blobEntry("data.bin", payload)}); + build->precommitAdd(ns, ref, id); + build->putBlob(idOf(payload), BlobSource::fromString(payload)); + build->promote(ns, ref, build->buildId(), id); + return id; +} + +bool blobPresent(const std::shared_ptr & b, const Layout & layout, const String & payload) +{ + return b->head(layout.blobKey(BlobRef{BlobHashAlgo::CityHash128, + BlobDigest::fromU128(u128Of(payload))})).exists; +} + +/// Every ref object key of one namespace. Used to identify WHICH objects a publish appended, rather +/// than guessing a sequence number. +std::set listRefKeys(Backend & b, const Layout & layout, const RootNamespace & ns) +{ + /// Stage B (Task 4-C): `ns` is born through the REAL append lane here, so its objects sit at a + /// real catalog-minted incarnation, not the Stage-A sentinel. + const NamespaceLifeId life = CasRefCatalog::lifeIfCataloged(b, layout, ns).value(); + std::set keys; + forEachListedKey(b, layout.namespaceStreamPrefix(life), [&](const ListedKey & k) { keys.insert(k.key); }); + return keys; +} + +/// The keys present in `after` and not in `before`. +std::vector addedKeys(const std::set & before, const std::set & after) +{ + std::vector added; + std::set_difference(after.begin(), after.end(), before.begin(), before.end(), + std::back_inserter(added)); + return added; +} + +/// Among `candidates`, the ONE ref-log key whose transaction emits an edge of sign `change` naming +/// `manifest_id`. +/// +/// Selecting the key by DECODING is load-bearing. One logical publish appends SEVERAL ref-log +/// transactions (`precommitAdd`, then `promote`), and it is the `precommitAdd` that carries the `+1` +/// activation — a promote is an owner move at the same `manifest_ref` and emits no edge at all. Picking +/// "the greatest new key" would therefore omit the wrong object and the sabotage would be a no-op that +/// still let the test pass. +String refLogKeyEmittingEdge(Backend & b, const Layout & layout, const RootNamespace & ns, + const std::vector & candidates, const ManifestId & manifest_id, + int change) +{ + std::vector hits; + for (const String & key : candidates) + { + const auto parsed = layout.parseRefObjectKey(key); + if (!parsed || parsed->kind != RefObjectKind::Log) + continue; + const auto got = b.get(key); + if (!got) + continue; + const RefLogTxn txn = + decodeRefLogTxn(openObject(FormatId::RefLog, got->bytes), ns.string(), parsed->txn_id); + for (const RefManifestEdge & e : manifestEdgesOfTxn(txn)) + if (e.change == change && e.manifest_id == manifest_id) + { + hits.push_back(key); + break; + } + } + EXPECT_EQ(hits.size(), 1u) << "expected exactly one ref log emitting a " << change + << " edge for the manifest, found " << hits.size(); + return hits.empty() ? String{} : hits.front(); +} + +void runRounds(const PoolPtr & s, Gc & gc, int rounds) +{ + for (int i = 0; i < rounds; ++i) + { + DB::Cas::tests::runRegularRoundReclaiming(gc); + s->renewWatermarkOnce(); + } +} + +} + +/// RETENTION DIRECTION (the RCA's primary reproduction). A ref-log record omitted from a listing used to +/// sort at or below the cursor forever, so restoring the listing could not recover it: the blob's `-1` +/// never folded and the blob was retained permanently. Under arithmetic intake the omitted record is +/// reached by exact key on the very round that was lied to, so the removal folds and the blob dies on +/// the normal schedule — no abort, and no waiting for the store to become honest again. +TEST(CASHoleyListDetector, OmittedRemoveRecordIsSkippedForever) +{ + std::shared_ptr b; + auto s = openHoleyPool(b); + const Layout & layout = s->layout(); + const RootNamespace ns{"test/tbl"}; + const String payload = "holey-payload"; + + /// A: publish the part (its `+1` edges). Folded by the rounds below. + const ManifestId part = publishOneBlobPart(s, ns, "part_a", payload); + Gc gc(s, hexToU128("00000000000000000000000000000001")); + runRounds(s, gc, 2); + ASSERT_TRUE(blobPresent(b, layout, payload)); + + /// R: drop the ref (the `-1`). Through the REAL writer API, never the raw ref-log helper: the raw + /// helper allocates a sequence by listing, which collides with the ledger's own in-memory sequence + /// as soon as the same namespace is written through the writer again (`part_h` below). + const std::set before_drop = listRefKeys(*b, layout, ns); + s->dropRef(ns, "part_a"); + const std::set after_drop = listRefKeys(*b, layout, ns); + const String remove_key = + refLogKeyEmittingEdge(*b, layout, ns, addedKeys(before_drop, after_drop), part, -1); + ASSERT_FALSE(remove_key.empty()); + + /// H: a later, unrelated record so the cursor has a reason to advance past R even when R is not + /// returned. + publishOneBlobPart(s, ns, "part_h", "harmless-payload"); + s->renewWatermarkOnce(); /// advance the floor so the dropped closure is not spared as in-flight + + /// nth = 0: the round's own enumeration of the ref prefix — the one the fold regroups, and the only + /// walk whose hole the intake has to survive. Armed LAST, after every seeding write, so no + /// writer-side namespace listing consumes a qualifying call. + b->omitFromNthListCall(remove_key, /*nth=*/0); + + runRounds(s, gc, 1); + ASSERT_TRUE(b->holeServed()) << "the sabotage never fired — the omitted key was never listed"; + + /// Drive to the reclaim. The point is that the FIRST of these rounds — the one served the hole — + /// already folded the removal; the rest are the condemn/graduate/delete pacing. + runRounds(s, gc, 12); + + EXPECT_FALSE(blobPresent(b, layout, payload)) + << "the removal was hidden from one enumeration and never folded — the cursor advanced past a " + "record the round never applied, which is the skipped-transaction defect itself"; +} + +/// DELETION DIRECTION (the mirror safety test from the RCA). Two owners share ONE deduplicated blob. +/// The SECOND owner's `+1` is omitted from one listing while the FIRST owner's `-1` folds normally, +/// so GC sees zero edges for a blob a live manifest still references. THIS MUST NEVER DELETE THE BLOB. +TEST(CASHoleyListDetector, OmittedActivationNeverPermitsDeletingALiveBlob) +{ + std::shared_ptr b; + auto s = openHoleyPool(b); + const Layout & layout = s->layout(); + const RootNamespace ns{"test/tbl"}; + const String payload = "shared-payload"; + + /// M1 owns the token. Fold it so its `+1` is durable in the in-degree generation. + const ManifestId m1 = publishOneBlobPart(s, ns, "part_1", payload); + Gc gc(s, hexToU128("00000000000000000000000000000001")); + runRounds(s, gc, 2); + ASSERT_TRUE(blobPresent(b, layout, payload)); + ASSERT_TRUE(b->head(layout.manifestKey(m1)).exists) + << "M1's body must still be present so its `-1` edges are readable at removal-fold"; + + /// M2 adopts the SAME deduplicated blob (`putBlob` of an identical payload dedups). Learn WHICH + /// ref-log object carries M2's ACTIVATION by diffing the namespace's ref prefix around the publish + /// and decoding the new objects — do NOT guess a sequence number and do not append a probe + /// transaction (that would perturb the very stream under test). + const std::set before = listRefKeys(*b, layout, ns); + const ManifestId m2 = publishOneBlobPart(s, ns, "part_2", payload); + const std::set after = listRefKeys(*b, layout, ns); + const String m2_key = refLogKeyEmittingEdge(*b, layout, ns, addedKeys(before, after), m2, +1); + ASSERT_FALSE(m2_key.empty()); + + /// M1's removal folds normally. Through the REAL writer API (see the retention test's note). + s->dropRef(ns, "part_1"); + s->renewWatermarkOnce(); /// advance the floor so the removed closure is not spared as in-flight + + /// nth = 0: the round's own walk (see the note in the retention test). Armed LAST so the writer's + /// own namespace listings cannot shift the count. + b->omitFromNthListCall(m2_key, /*nth=*/0); + + runRounds(s, gc, 12); /// condemn -> graduate -> delete needs several rounds + /// The anti-vacuity check, and it is the RIGHT one now: a run that merely happened not to delete the + /// blob must not pass for the wrong reason, and what makes this run non-trivial is that the hole was + /// actually SERVED to the enumeration the fold works from. (It used to be "and the detector fired", + /// which was only ever a proxy for that — and is now a property of a different, sampled mechanism, + /// pinned in `CASRetirementSweep`.) + ASSERT_TRUE(b->holeServed()) << "the sabotage never fired — the omitted key was never listed"; + + EXPECT_TRUE(blobPresent(b, layout, payload)) + << "GC deleted a blob that manifest " << manifestRefDebugString(m2.ref) + << " still references — the skipped-transaction DATA-LOSS class, reproduced"; +} diff --git a/src/Disks/tests/gtest_cas_ids.cpp b/src/Disks/tests/gtest_cas_ids.cpp new file mode 100644 index 000000000000..046696124bd3 --- /dev/null +++ b/src/Disks/tests/gtest_cas_ids.cpp @@ -0,0 +1,41 @@ +#include +#include +#include + +using namespace DB::Cas; + +TEST(CASIds, StrongTypingAndContainers) +{ + /// Test the strong-typed-string class `RootNamespace`. + /// (`BlobId` was deleted in the mixed-algo-pools refactor; `TreeId` was part of the + /// standalone-tree layer excised in the rev. 15 `PartManifest` redesign.) + RootNamespace ns1{"srv1"}; + RootNamespace ns2{"srv1"}; + RootNamespace ns3{"srv2"}; + EXPECT_EQ(ns1, ns2); + EXPECT_NE(ns1, ns3); + std::unordered_set s{ns1, ns3}; + EXPECT_EQ(s.size(), 2u); +} + +TEST(CASIds, HexU128RoundTrip) +{ + // UInt128 is a global typedef (wide::integer<128,unsigned>), not in DB:: namespace. + const UInt128 v = (UInt128(0x0123456789abcdefULL) << 64) | 0xfedcba9876543210ULL; + const auto hex = u128ToHex(v); + EXPECT_EQ(hex.size(), 32u); + EXPECT_EQ(hexToU128(hex), v); + EXPECT_THROW(hexToU128("zz"), DB::Exception); // not hex + EXPECT_THROW(hexToU128("0123"), DB::Exception); // wrong length +} + +TEST(CASToken, Basics) +{ + Token a{"etag-1", TokenType::ETag}; + Token b{"etag-1", TokenType::ETag}; + Token c{"etag-2", TokenType::ETag}; + EXPECT_EQ(a, b); + EXPECT_NE(a, c); + EXPECT_TRUE(Token{}.empty()); + EXPECT_FALSE(a.empty()); +} diff --git a/src/Disks/tests/gtest_cas_inline_placement.cpp b/src/Disks/tests/gtest_cas_inline_placement.cpp new file mode 100644 index 000000000000..5b4000636fa0 --- /dev/null +++ b/src/Disks/tests/gtest_cas_inline_placement.cpp @@ -0,0 +1,28 @@ +#include +#include + +using DB::Cas::partFileMustStayBlob; + +TEST(CASInlinePlacement, ColumnAndMarkFilesStayBlob) +{ + EXPECT_TRUE(partFileMustStayBlob("data.bin")); + EXPECT_TRUE(partFileMustStayBlob("data.mrk")); + EXPECT_TRUE(partFileMustStayBlob("data.mrk2")); + EXPECT_TRUE(partFileMustStayBlob("data.mrk3")); + EXPECT_TRUE(partFileMustStayBlob("data.cmrk")); + EXPECT_TRUE(partFileMustStayBlob("data.cmrk2")); + EXPECT_TRUE(partFileMustStayBlob("data.cmrk3")); + EXPECT_TRUE(partFileMustStayBlob("primary.idx")); // potentially large; stays blob (follow-up tuning) +} + +TEST(CASInlinePlacement, EagerMetadataFilesAreInlineCandidates) +{ + EXPECT_FALSE(partFileMustStayBlob("checksums.txt")); + EXPECT_FALSE(partFileMustStayBlob("columns.txt")); + EXPECT_FALSE(partFileMustStayBlob("count.txt")); + EXPECT_FALSE(partFileMustStayBlob("serialization.json")); + EXPECT_FALSE(partFileMustStayBlob("metadata_version.txt")); + EXPECT_FALSE(partFileMustStayBlob("partition.dat")); + EXPECT_FALSE(partFileMustStayBlob("minmax_date.idx")); + EXPECT_FALSE(partFileMustStayBlob("default_compression_codec.txt")); +} diff --git a/src/Disks/tests/gtest_cas_inspect.cpp b/src/Disks/tests/gtest_cas_inspect.cpp new file mode 100644 index 000000000000..d807bec3f931 --- /dev/null +++ b/src/Disks/tests/gtest_cas_inspect.cpp @@ -0,0 +1,222 @@ +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include "cas_test_helpers.h" + +using namespace DB::Cas; + +namespace +{ + +ManifestRef manifestRef(uint64_t epoch, uint64_t seq, uint32_t ordinal) +{ + return ManifestRef{epoch, seq, ordinal}; +} + +BlobRef bh(uint64_t n) +{ + return BlobRef{BlobHashAlgo::CityHash128, BlobDigest::fromU128(UInt128(n))}; +} + +} + +/// Stage-1 T12 (spec §4 "RefOp payload removal"): `cas-inspect` renders the renamed `SetPublishedAt` +/// op kind, and neither the ref-log nor the ref-snapshot rendering carries a `payload_size` key -- +/// `RefOp`/`RefCommittedRow` no longer have a `payload` field to size. + +TEST(CASInspect, RendersSetPublishedAtOpWithNoPayloadSizeKey) +{ + const Layout layout("p"); + const RootNamespace ns{"srv1/db/tbl"}; + const RefTxnId id{7, 9}; + + RefLogTxn txn; + txn.ns = ns.string(); + txn.txn_id = id; + RefOp op; + op.kind = RefOpKind::SetPublishedAt; + op.ref_name = "all_1_1_0"; + op.expected_manifest_ref = manifestRef(1, 1, 1); + op.published_at_ms = 42; + txn.ops.push_back(op); + + const String key = layout.refLogKey(DB::Cas::tests::fixture::fixtureLife(ns), id); + const String bytes = sealObject(FormatId::RefLog, encodeRefLogTxn(txn)); + + const String json = caInspectToJson(layout, key, bytes, DB::Cas::tests::fixture::fixtureLife(ns)); + EXPECT_NE(json.find(R"("kind":"SetPublishedAt")"), String::npos) << json; + EXPECT_EQ(json.find("payload"), String::npos) << json; +} + +/// Task-1 review finding M5: `cas inspect` renders the new `EpochSeal` op kind and the txn-level +/// `prev_epoch_seal` chain field, needed to debug INV-2 seal chains without a raw byte dump. +TEST(CASInspect, RendersEpochSealTxnWithPrevEpochSeal) +{ + const Layout layout("p"); + const RootNamespace ns{"srv1/db/tbl"}; + const RefTxnId id{3, 1}; + + RefLogTxn txn; + txn.ns = ns.string(); + txn.txn_id = id; + txn.prev_epoch_seal = RefTxnId{2, 9}; + RefOp op; + op.kind = RefOpKind::EpochSeal; + txn.ops.push_back(op); + + const String key = layout.refLogKey(DB::Cas::tests::fixture::fixtureLife(ns), id); + const String bytes = sealObject(FormatId::RefLog, encodeRefLogTxn(txn)); + + const String json = caInspectToJson(layout, key, bytes, DB::Cas::tests::fixture::fixtureLife(ns)); + EXPECT_NE(json.find(R"("kind":"EpochSeal")"), String::npos) << json; + EXPECT_NE(json.find(R"("prev_epoch_seal":{"writer_epoch":2,"ref_sequence":9})"), String::npos) << json; +} + +TEST(CASInspect, RendersCommittedRowWithNoPayloadSizeKey) +{ + const Layout layout("p"); + const RootNamespace ns{"srv1/db/tbl"}; + const RefTxnId id{7, 9}; + + RefTableSnapshot snap; + snap.ns = ns.string(); + snap.snapshot_id = id; + RefCommittedRow row; + row.ref_name = "all_1_1_0"; + row.manifest_ref = manifestRef(1, 1, 1); + row.published_at_ms = 42; + snap.committed.push_back(row); + + const String key = layout.refSnapshotKey(DB::Cas::tests::fixture::fixtureLife(ns), id); + const String bytes = sealObject(FormatId::RefSnapshot, encodeRefTableSnapshot(snap)); + + const String json = caInspectToJson(layout, key, bytes, DB::Cas::tests::fixture::fixtureLife(ns)); + EXPECT_EQ(json.find("payload"), String::npos) << json; + EXPECT_EQ(json.find("lifecycle"), String::npos) << json; + EXPECT_EQ(json.find("remove_txn_id"), String::npos) << json; + EXPECT_NE(json.find(R"("published_at_ms":42)"), String::npos) << json; +} + +/// A blob-target source-edge run segment (`Layout::blobTargetRunKey`) is the ground truth for every +/// in-degree question; `cas-inspect` decodes it with the typed `SourceEdgeRunView` reader (not by hand) +/// and must distinguish an active edge from a condemned sentinel row, decoding the latter's fields. +TEST(CASInspect, RendersBlobTargetRunEdgeAndCondemnedRows) +{ + const Layout layout("p"); + + /// `SourceEdgeRunWriter::append` requires non-decreasing `(ref, source_id)` order; the condemned + /// sentinel sorts first for its blob (source_id 0), and `bh(1) < bh(2)`, so appending in this + /// order already satisfies it. + SourceEdgeRecord condemned_rec; + condemned_rec.ref = bh(1); + condemned_rec.source_id = UInt128{0}; + condemned_rec.marker = kCondemned; + condemned_rec.delete_pending = true; + condemned_rec.token = Token{.value = "etag-1", .type = TokenType::Emulated}; + condemned_rec.size = 123; + condemned_rec.condemn_round = 7; + condemned_rec.marker_confirmed = true; + + SourceEdgeRecord edge_rec; + edge_rec.ref = bh(2); + edge_rec.source_id = UInt128(9); + edge_rec.marker = kEdgeActive; + + DB::WriteBufferFromOwnString out; + SourceEdgeRunWriter writer(out); + writer.append(condemned_rec); + writer.append(edge_rec); + writer.finish(); + out.finalize(); + const String bytes = out.str(); + + const String key = layout.blobTargetRunKey(/*generation*/2, /*attempt*/0, /*shard*/0, /*seq*/0); + + const String json = caInspectToJson(layout, key, bytes); + EXPECT_NE(json.find(R"("object":"blob_target_run")"), String::npos) << json; + EXPECT_NE(json.find(R"("generation":2)"), String::npos) << json; + EXPECT_NE(json.find(R"("kind":"edge")"), String::npos) << json; + EXPECT_NE(json.find(R"("kind":"condemned")"), String::npos) << json; + EXPECT_NE(json.find(R"("delete_pending":true)"), String::npos) << json; + EXPECT_NE(json.find(R"("condemn_round":7)"), String::npos) << json; + EXPECT_NE(json.find(R"("value":"etag-1")"), String::npos) << json; + EXPECT_NE(json.find(R"("rows":2)"), String::npos) << json; + EXPECT_NE(json.find(R"("distinct_blobs":2)"), String::npos) << json; + EXPECT_NE(json.find(R"("edges":1)"), String::npos) << json; + EXPECT_NE(json.find(R"("condemned":1)"), String::npos) << json; + EXPECT_NE(json.find(R"("zero_markers":0)"), String::npos) << json; +} + +/// Stage A task 5 (spec INV-4): the `_ckpt` renders as its own object kind. It is point-addressed in +/// `cas/ns/state/` with no transaction id, so it has a separate dispatch from stream objects and once +/// fell through to +/// `BAD_ARGUMENTS` for it -- and it is precisely the object an operator reaches for when asking "what +/// is recovery's base" or "why is cleanup not reclaiming anything". +TEST(CASInspect, RendersRefCkptWithEveryFieldPresent) +{ + const Layout layout("p"); + const RootNamespace ns{"srv1/db/tbl"}; + + const RefCkpt ckpt{.life_epoch = std::optional{7}, + .committed_through = RefTxnId{7, 9}, + .checkpoint_snapshot_id = RefTxnId{7, 9}, + .last_epoch_seal = RefTxnId{6, 4}}; + + const String json = caInspectToJson( + layout, layout.refCkptKey(DB::Cas::tests::fixture::fixtureLife(ns)), encodeRefCkpt(ckpt), + DB::Cas::tests::fixture::fixtureLife(ns)); + EXPECT_NE(json.find(R"("object":"ref_ckpt")"), String::npos) << json; + /// The namespace comes from the KEY: a `_ckpt` body does not name it. + EXPECT_NE(json.find(R"("namespace":"srv1/db/tbl")"), String::npos) << json; + EXPECT_NE(json.find(R"("life_epoch":7)"), String::npos) << json; + EXPECT_NE(json.find(R"("committed_through":{"writer_epoch":7,"ref_sequence":9})"), String::npos) << json; + EXPECT_NE(json.find(R"("writer_epoch":7,"ref_sequence":9)"), String::npos) << json; + EXPECT_NE(json.find(R"("writer_epoch":6,"ref_sequence":4)"), String::npos) << json; +} + +/// The absences are the interesting readings, so they render as explicit `null`s rather than missing +/// keys: no checkpoint means recovery has no base AND nothing is deletable, which is a very different +/// report from "the key is there and I could not tell you what is in it". +TEST(CASInspect, RendersRefCkptAbsencesAsExplicitNulls) +{ + const Layout layout("p"); + const RootNamespace ns{"srv1/db/fresh"}; + + const String json = caInspectToJson( + layout, layout.refCkptKey(DB::Cas::tests::fixture::fixtureLife(ns)), encodeRefCkpt(RefCkpt{}), + DB::Cas::tests::fixture::fixtureLife(ns)); + EXPECT_NE(json.find(R"("object":"ref_ckpt")"), String::npos) << json; + EXPECT_NE(json.find(R"("life_epoch":null)"), String::npos) << json; + EXPECT_NE(json.find(R"("checkpoint_snapshot_id":null)"), String::npos) << json; + EXPECT_NE(json.find(R"("last_epoch_seal":null)"), String::npos) << json; +} + +/// A listed physical id cannot supply a namespace. Inspect must receive the unique catalog join, and +/// a different logical spelling at the same id is rejected by the decoded object's own namespace. +TEST(CASInspect, RefObjectRequiresTheExactCatalogResolution) +{ + const Layout layout("p"); + const RootNamespace ns{"srv1/db/tbl"}; + const NamespaceLifeId life = NamespaceLifeId::fromCatalogEntry(ns, UInt128{91}); + RefLogTxn txn; + txn.ns = ns.string(); + txn.txn_id = RefTxnId{1, 1}; + RefOp birth; + birth.kind = RefOpKind::NamespaceBirth; + txn.ops = {birth}; + const String key = layout.refLogKey(life, txn.txn_id); + const String bytes = sealObject(FormatId::RefLog, encodeRefLogTxn(txn)); + + EXPECT_THROW(caInspectToJson(layout, key, bytes), DB::Exception); + EXPECT_THROW(caInspectToJson( + layout, key, bytes, NamespaceLifeId::fromCatalogEntry(RootNamespace{"redirected"}, life.incarnation)), + DB::Exception); + EXPECT_NO_THROW(caInspectToJson(layout, key, bytes, life)); +} diff --git a/src/Disks/tests/gtest_cas_json_writer.cpp b/src/Disks/tests/gtest_cas_json_writer.cpp new file mode 100644 index 000000000000..f04b89255818 --- /dev/null +++ b/src/Disks/tests/gtest_cas_json_writer.cpp @@ -0,0 +1,214 @@ +#include +#include +#include +#include +#include +#include +#include +#include +#include + +using namespace DB; +using namespace DB::Cas; + +TEST(CASJsonWriter, KeyValueSequenceMatchesCanonicalShape) +{ + CasJsonWriter w; + bool first = true; + w.key("we", first); + w.u64StringValue(7); + w.key("mo", first); + w.u64Number(3); + w.key("ok", first); + w.boolValue(true); + w.key("o", "me", first); + w.u64StringValue(1); + w.closeObject(first); + w.newline(); + EXPECT_EQ(std::move(w).take(), "{\"we\":\"7\",\"mo\":3,\"ok\":true,\"ome\":\"1\"}\n"); +} + +TEST(CASJsonWriter, EmptyObjectAndClear) +{ + CasJsonWriter w; + bool first = true; + w.closeObject(first); + EXPECT_EQ(w.view(), "{}"); + w.clear(); + EXPECT_EQ(w.size(), 0u); +} + +TEST(CASJsonWriter, Hex128MatchesU128ToHex) +{ + const UInt128 v = (UInt128(0x0123456789abcdefULL) << 64) | UInt128(0xfedcba9876543210ULL); + CasJsonWriter w; + w.hex128Value(v); + EXPECT_EQ(std::move(w).take(), "\"" + u128ToHex(v) + "\""); +} + +TEST(CASJsonWriter, U64Extremes) +{ + CasJsonWriter w; + w.u64Number(0); + w.appendChar(' '); + w.u64Number(UINT64_MAX); + EXPECT_EQ(std::move(w).take(), "0 18446744073709551615"); +} + +namespace +{ +String referenceJson(std::string_view s) +{ + DB::FormatSettings settings; + settings.json.escape_forward_slashes = false; /// the pinned CAS canon + DB::WriteBufferFromOwnString out; + DB::writeJSONString(s, out, settings); + out.finalize(); + return out.str(); +} + +String writerJson(std::string_view s) +{ + DB::Cas::CasJsonWriter w; + w.stringValue(s); + return std::move(w).take(); +} +} + +TEST(CASJsonWriterEscaping, TargetedCorpusMatchesWriteJSONString) +{ + const std::vector corpus = { + "", + "plain_safe_ref_name_20260101_0_1_1_1", + "roots/pin", /// '/' must stay UNESCAPED + "quote\"inside", "back\\slash", "both\\\"x", + String("\b\f\n\r\t"), + String(1, '\0'), String("a") + '\0' + "b", + String("\x01\x02\x03\x1e\x1f"), + "\xE2\x80\xA8", "\xE2\x80\xA9", /// U+2028 / U+2029 -> / + "x\xE2\x80\xA8" "y", // NOLINT(bugprone-suspicious-missing-comma): deliberate adjacent-literal concatenation, testing a U+2028 sequence split across two source literals + "\xE2", /// truncated lead byte at end + "\xE2\x80", /// truncated pair at end + "\xE2\x21\x21", /// 0xE2 + non-continuation bytes + "\xE2\x80\x21", + "\xE2\xE2\x80\xA8", /// lead byte immediately before a real sequence + "\xC3\xA9\xF0\x9F\x98\x80", /// ordinary multi-byte UTF-8 passes through + "\xff\xfe invalid utf8 \x80", + String(1000, 'a'), /// long safe run (vector path) + String(1000, '"'), /// special-dense + }; + for (const String & s : corpus) + EXPECT_EQ(writerJson(s), referenceJson(s)) << "input bytes: " << s.size(); +} + +TEST(CASJsonWriterEscaping, FuzzMatchesWriteJSONString) +{ + std::mt19937 rng(20260720); // NOLINT(cert-msc32-c, cert-msc51-cpp) + for (int iter = 0; iter < 5000; ++iter) + { + const size_t len = rng() % 200; + String s(len, '\0'); + const int mode = iter % 3; + for (auto & c : s) + { + if (mode == 0) + c = static_cast(rng() % 256); /// full byte range + else if (mode == 1) + c = static_cast('a' + rng() % 26); /// safe-only + else + { + static constexpr char specials[] = {'"', '\\', '\n', '\x01', '\xE2', '\x80', '\xA8', 'z'}; + c = specials[rng() % (sizeof(specials))]; /// special-dense + } + } + ASSERT_EQ(writerJson(s), referenceJson(s)) << "iter " << iter; + } +} + +/// ---- CasJsonWriter overloads of the shared vocabulary (Task 4) ---- +/// +/// The production WriteBuffer vocabulary was retired in Task 9 (CasJsonWriter is now the only CAS +/// text writer). `reference_vocab` below is a verbatim copy of the retired implementation, kept +/// test-local so these differential tests keep an independent oracle instead of comparing +/// CasJsonWriter against itself. +namespace reference_vocab +{ +namespace +{ +/// Verbatim copy of the retired WriteBuffer-based CAS vocabulary (CasTextFormat.cpp pre-CasJsonWriter), +/// kept as the differential reference. jsonWriteSettings is inlined: escape_forward_slashes=false. +const DB::FormatSettings & settings() +{ + static const DB::FormatSettings s = [] + { + DB::FormatSettings fs; + fs.json.escape_forward_slashes = false; + return fs; + }(); + return s; +} + +void writeKey(DB::WriteBuffer & out, std::string_view key, bool & first) +{ + DB::writeChar(first ? '{' : ',', out); + first = false; + DB::writeChar('"', out); + out.write(key.data(), key.size()); + DB::writeChar('"', out); + DB::writeChar(':', out); +} + +void writeStringValue(DB::WriteBuffer & out, std::string_view s) { DB::writeJSONString(s, out, settings()); } + +void writeHex128Value(DB::WriteBuffer & out, const UInt128 & v) +{ + DB::writeChar('"', out); + const String hex = DB::Cas::u128ToHex(v); + out.write(hex.data(), hex.size()); + DB::writeChar('"', out); +} + +void writeU64StringValue(DB::WriteBuffer & out, uint64_t v) +{ + DB::writeChar('"', out); + DB::writeIntText(v, out); + DB::writeChar('"', out); +} + +void writeBoolValue(DB::WriteBuffer & out, bool v) { writeCString(v ? "true" : "false", out); } + +void closeObject(DB::WriteBuffer & out, bool & first) +{ + if (first) + DB::writeChar('{', out); + first = false; + DB::writeChar('}', out); +} +} +} + +TEST(CASJsonWriterVocab, MatchesReferenceVocabulary) +{ + using namespace DB::Cas; + const UInt128 h = (UInt128(0xdeadbeefULL) << 64) | UInt128(42); + + DB::WriteBufferFromOwnString ref; + CasJsonWriter w; + bool rf = true; + bool wf = true; + + reference_vocab::writeKey(ref, "a", rf); writeKey(w, "a", wf); + reference_vocab::writeStringValue(ref, "x/\"y"); writeStringValue(w, "x/\"y"); + reference_vocab::writeKey(ref, "h", rf); writeKey(w, "h", wf); + reference_vocab::writeHex128Value(ref, h); writeHex128Value(w, h); + reference_vocab::writeKey(ref, "u", rf); writeKey(w, "u", wf); + reference_vocab::writeU64StringValue(ref, UINT64_MAX); writeU64StringValue(w, UINT64_MAX); + reference_vocab::writeKey(ref, "b", rf); writeKey(w, "b", wf); + reference_vocab::writeBoolValue(ref, false); writeBoolValue(w, false); + reference_vocab::writeKey(ref, "n", rf); writeKey(w, "n", wf); + DB::writeIntText(uint64_t(12345), ref); writeIntText(uint64_t(12345), w); + reference_vocab::closeObject(ref, rf); closeObject(w, wf); + DB::writeChar('\n', ref); writeChar('\n', w); + ref.finalize(); + EXPECT_EQ(std::move(w).take(), ref.str()); +} diff --git a/src/Disks/tests/gtest_cas_layout.cpp b/src/Disks/tests/gtest_cas_layout.cpp new file mode 100644 index 000000000000..1006e87188a2 --- /dev/null +++ b/src/Disks/tests/gtest_cas_layout.cpp @@ -0,0 +1,382 @@ +#include +#include +#include +#include +#include "cas_test_helpers.h" + +using namespace DB::Cas; + +namespace +{ +/// A `BlobRef` at `algo` whose first bytes are `0x00, 0xaa, 0xbb` (the rest zero) -- for key-shape +/// tests that need a stable, recognizable hex prefix. `Layout` no longer captures an algo (Phase 3 +/// T2/T3): every blob key is built from a `BlobRef` alone, so key-shape tests construct one directly. +BlobRef prefixedRef(BlobHashAlgo algo) +{ + BlobDigest d{}; + d.bytes[0] = 0x00; d.bytes[1] = 0xaa; d.bytes[2] = 0xbb; + return BlobRef{algo, d}; +} +} + +TEST(CASLayout, KeyShapes) +{ + /// Per design §10 EVERY algo carries an explicit path segment: `blobs/ch128/...`, not the legacy + /// `blobs/...`. + Layout l{"p"}; + const BlobRef ref = prefixedRef(BlobHashAlgo::CityHash128); + const String hex = codecFor(BlobHashAlgo::CityHash128).toHex(ref.digest); + EXPECT_EQ(l.blobKey(ref), "p/blobs/ch128/" + hex.substr(0, 2) + "/" + hex); + EXPECT_EQ(l.gcStateKey(), "p/gc/state"); + EXPECT_EQ(l.outcomesKey(4, 42, 7, 1), "p/gc/gen/4/attempt/42/outcomes/7/1.zst"); + EXPECT_EQ(l.poolMetaKey(), "p/_pool_meta"); +} + +TEST(CASLayout, BlobKeyCarriesAlgoSegment) +{ + /// Every algo gets its own segment (design §3/§10), so two algos can never collide in the key + /// space even after a config change on a fresh pool. `Layout` itself carries no algo anymore -- + /// the segment comes from the `BlobRef` passed to `blobKey`/`blobMetaKey`. + const Layout l("p"); + + const BlobRef ch128_ref = prefixedRef(BlobHashAlgo::CityHash128); + const String ch128_hex = codecFor(BlobHashAlgo::CityHash128).toHex(ch128_ref.digest); + EXPECT_EQ(l.blobKey(ch128_ref), "p/blobs/ch128/" + ch128_hex.substr(0, 2) + "/" + ch128_hex); + EXPECT_EQ(l.blobMetaKey(ch128_ref), l.blobKey(ch128_ref) + ".meta"); + + const BlobRef xxh3_ref = prefixedRef(BlobHashAlgo::XXH3_128); + const String xxh3_hex = codecFor(BlobHashAlgo::XXH3_128).toHex(xxh3_ref.digest); + EXPECT_EQ(l.blobKey(xxh3_ref), "p/blobs/xxh3/" + xxh3_hex.substr(0, 2) + "/" + xxh3_hex); + EXPECT_EQ(l.blobMetaKey(xxh3_ref), l.blobKey(xxh3_ref) + ".meta"); + + const BlobRef sha256_ref = prefixedRef(BlobHashAlgo::Sha256); + const String sha256_hex = codecFor(BlobHashAlgo::Sha256).toHex(sha256_ref.digest); + EXPECT_EQ(l.blobKey(sha256_ref), "p/blobs/sha256/" + sha256_hex.substr(0, 2) + "/" + sha256_hex); + + /// Trees/manifests/refs are UNCHANGED -- only blob-body keys gain the algo segment. + EXPECT_EQ(l.blobsPrefix(), "p/blobs/"); +} + +TEST(CASLayout, RootNamespaceKeys) +{ + Layout l("p"); + RootNamespace ns{"srv1/3f2e-uuid"}; + const NamespaceLifeId ns_id = DB::Cas::tests::fixture::fixtureLife(ns); + EXPECT_EQ(l.namespaceStreamPrefix(ns_id), + "p/cas/ns/stream/" + renderIncarnation(ns_id.incarnation) + "/"); + EXPECT_EQ(l.namespaceFileKey(ns_id, "format_version.txt"), + "p/cas/ns/state/" + renderIncarnation(ns_id.incarnation) + "/_files/format_version.txt"); + EXPECT_EQ(l.namespaceFilesPrefix(ns_id), + "p/cas/ns/state/" + renderIncarnation(ns_id.incarnation) + "/_files/"); +} + +TEST(CASLayout, OpaqueLifeIdSeparatesStreamFromState) +{ + /// This catches a builder that accidentally puts the logical namespace back into a life-owned + /// key. The two different names deliberately share one physical id: object identity is the id, + /// while the name remains catalog-only. + Layout l("p"); + const UInt128 life_id = UInt128(0x1234); + const NamespaceLifeId first = NamespaceLifeId::fromCatalogEntry(RootNamespace{"root/first"}, life_id); + const NamespaceLifeId second = NamespaceLifeId::fromCatalogEntry(RootNamespace{"root/second"}, life_id); + const RefTxnId txn{7, 9}; + + EXPECT_EQ(l.namespaceStreamPrefix(first), "p/cas/ns/stream/00000000000000000000000000001234/"); + EXPECT_EQ(l.namespaceStatePrefix(first), "p/cas/ns/state/00000000000000000000000000001234/"); + EXPECT_EQ(l.refLogKey(first, txn), "p/cas/ns/stream/00000000000000000000000000001234/_log/0000000000000007-0000000000000009.zst"); + EXPECT_EQ(l.refSnapshotKey(first, txn), "p/cas/ns/stream/00000000000000000000000000001234/_snap/0000000000000007-0000000000000009.zst"); + EXPECT_EQ(l.refCkptKey(first), "p/cas/ns/state/00000000000000000000000000001234/_ckpt"); + EXPECT_EQ(l.namespaceFileKey(first, "nested/file"), "p/cas/ns/state/00000000000000000000000000001234/_files/nested/file"); + + EXPECT_EQ(l.refLogKey(second, txn), l.refLogKey(first, txn)); + EXPECT_EQ(l.namespaceFileKey(second, "nested/file"), l.namespaceFileKey(first, "nested/file")); +} + +TEST(CASLayout, RelocatedRefAndManifestKeys) +{ + Layout l("p"); + const RootNamespace ns{"srid/store/ab/uuid@cas@"}; + const NamespaceLifeId ns_id = DB::Cas::tests::fixture::fixtureLife(ns); + EXPECT_EQ(l.namespaceStreamPrefix(ns_id), + "p/cas/ns/stream/" + renderIncarnation(ns_id.incarnation) + "/"); + EXPECT_EQ(l.casRefsPrefix(), "p/cas/ns/stream/"); + /// All manifests of a namespace: cas/manifests// (replaces roots//_manifests/). + EXPECT_EQ(l.manifestNamespacePrefix(ns), "p/cas/manifests/srid/store/ab/uuid@cas@/"); + + /// manifestKey: canonical hex build directory, under cas/manifests// (no /_manifests/ infix). + ManifestId id; + id.root_namespace = ns; + id.ref.writer_epoch = 1; + id.ref.build_sequence = 1042; + id.ref.manifest_ordinal = 1; + const String key = l.manifestKey(id); + EXPECT_EQ(key, "p/cas/manifests/srid/store/ab/uuid@cas@/" + "0000000000000001-0000000000000412/000001.zst"); + EXPECT_EQ(key.find("/_manifests/"), String::npos) << key; +} + +TEST(CASLayout, RootNamespaceValidation) +{ + Layout l("p"); + /// Opaque physical life keys deliberately do not inspect the logical namespace. Namespace-bearing + /// families such as manifests remain responsible for validating it. + EXPECT_THROW(l.manifestNamespacePrefix(RootNamespace{""}), DB::Exception); + EXPECT_THROW(l.manifestNamespacePrefix(RootNamespace{"/lead"}), DB::Exception); + EXPECT_THROW(l.manifestNamespacePrefix(RootNamespace{"trail/"}), DB::Exception); + /// File names may be NESTED relative paths (M-W T2: deduplication_logs/...); only unclean + /// shapes are rejected (empty, leading/trailing '/', empty segments, '..' escapes). + const NamespaceLifeId ok_id = DB::Cas::tests::fixture::fixtureLife(RootNamespace{"ok"}); + EXPECT_NO_THROW(l.namespaceFileKey(ok_id, "a/b")); + EXPECT_THROW(l.namespaceFileKey(ok_id, ""), DB::Exception); + EXPECT_THROW(l.namespaceFileKey(ok_id, "/lead"), DB::Exception); + EXPECT_THROW(l.namespaceFileKey(ok_id, "trail/"), DB::Exception); + EXPECT_THROW(l.namespaceFileKey(ok_id, "a//b"), DB::Exception); + EXPECT_THROW(l.namespaceFileKey(ok_id, "../up"), DB::Exception); + EXPECT_THROW(l.namespaceFileKey(ok_id, "a/../b"), DB::Exception); + + EXPECT_THROW(l.manifestNamespacePrefix(RootNamespace{"a//b"}), DB::Exception); + EXPECT_THROW(l.manifestNamespacePrefix(RootNamespace{"srv1/_files/x"}), DB::Exception); + EXPECT_NO_THROW(l.manifestNamespacePrefix(RootNamespace{"my_files/tbl"})); +} + +TEST(CASLayout, GenerationAndRootsKeys) +{ + Layout l("p"); + /// rev. 15: gc/snap is gone; generations carry write-once seals + blob-target / cleanup runs. + /// rev. 16: every per-round artifact is attempt-scoped under gc/gen//attempt//. + EXPECT_EQ(l.foldSealKey(12, 0), "p/gc/gen/12/attempt/0/fold_seal"); + EXPECT_EQ(l.blobTargetRunKey(12, 0, 0, 0), "p/gc/gen/12/attempt/0/blob_target/0/0"); + EXPECT_EQ(l.namespaceRootPrefix(), "p/cas/ns/"); + EXPECT_EQ(l.rootsPrefix(), "p/roots/"); +} + +TEST(CASLayout, AttemptScopedGenKeys) +{ + DB::Cas::Layout layout("p"); + EXPECT_EQ(layout.foldSealKey(4, 42), "p/gc/gen/4/attempt/42/fold_seal"); + EXPECT_EQ(layout.blobTargetRunKey(4, 42, 3, 0), "p/gc/gen/4/attempt/42/blob_target/3/0"); + EXPECT_EQ(layout.outcomesKey(5, 42, 7, 3), "p/gc/gen/5/attempt/42/outcomes/7/3.zst"); + EXPECT_EQ(layout.gcGenPrefix(4), "p/gc/gen/4/"); + EXPECT_EQ(layout.gcGenAttemptPrefix(4, 42), "p/gc/gen/4/attempt/42/"); +} + +TEST(CASLayout, RegistryDeletedGcDiscoveryViaList) +{ + /// Task 4: the namespace registry (`gc/registry`) is deleted; discovery authority moved to LIST. + /// The `_registry` namespace segment is not reserved (it was only reserved while the registry lived + /// under `roots/_registry`, which was already relocated to `gc/registry` before being deleted). + Layout l("p"); + EXPECT_NO_THROW(l.namespaceStreamPrefix(DB::Cas::tests::fixture::fixtureLife(RootNamespace{"a/_registry@cas@"}))); + /// Opaque stream keys are independent of namespace-segment reservations. + EXPECT_NO_THROW(l.namespaceStreamPrefix(DB::Cas::tests::fixture::fixtureLife(RootNamespace{"a/_files"}))); +} + +TEST(CASLayout, CasArchiveSuffixConstant) +{ + EXPECT_EQ(DB::Cas::kCasArchiveSuffix, "@cas@"); +} + +TEST(CASVfsPaths, MirroredArchiveNamespace) +{ + using DB::Cas::mirroredArchiveNamespace; + /// Atomic: bare uuid -> store//@cas@ + EXPECT_EQ(mirroredArchiveNamespace("3f2a0000-0000-0000-0000-000000000001"), + "store/3f2/3f2a0000-0000-0000-0000-000000000001@cas@"); + /// Non-Atomic: a full data/db/tbl path is used verbatim, @cas@ appended to the last segment. + EXPECT_EQ(mirroredArchiveNamespace("data/mydb/events"), + "data/mydb/events@cas@"); +} + +TEST(CASLayout, ManifestKeyShape) +{ + Layout l("p"); + ManifestId id; + id.root_namespace = RootNamespace("srv-a/3f2e-uuid@cas@"); + id.ref.writer_epoch = 7; + id.ref.build_sequence = 1042; + id.ref.manifest_ordinal = 1; + const String key = l.manifestKey(id); + EXPECT_EQ(key, + "p/cas/manifests/srv-a/3f2e-uuid@cas@/" + "0000000000000007-0000000000000412/000001.zst"); +} + +TEST(CASLayout, ManifestsSegmentReserved) +{ + Layout l("p"); + ManifestId bad; + bad.root_namespace = RootNamespace("srv-a/_manifests/x"); + EXPECT_THROW(l.manifestKey(bad), DB::Exception); + /// Opaque life prefixes ignore the logical spelling; manifests still enforce the reservation. + EXPECT_NO_THROW(l.namespaceStreamPrefix(DB::Cas::tests::fixture::fixtureLife(RootNamespace{"srv-a/_manifests/tbl"}))); + EXPECT_NO_THROW(l.namespaceStreamPrefix(DB::Cas::tests::fixture::fixtureLife(RootNamespace{"my_manifests/tbl"}))); +} + +TEST(CASLayout, ManifestKeyHexRoundTrip) +{ + Layout l("p"); + ManifestId id; + id.root_namespace = RootNamespace("srv-a/3f2e-uuid@cas@"); + id.ref.writer_epoch = 7; + id.ref.build_sequence = 0x8e; + id.ref.manifest_ordinal = 42; + const String key = l.manifestKey(id); + EXPECT_EQ(key, + "p/cas/manifests/srv-a/3f2e-uuid@cas@/" + "0000000000000007-000000000000008e/000042.zst"); + + const auto parsed = l.parseManifestKey(key); + ASSERT_TRUE(parsed.has_value()); + EXPECT_EQ(parsed->root_namespace, id.root_namespace); + EXPECT_EQ(parsed->ref, id.ref); + + /// The old two-directory decimal shape (`//.zst`) is no + /// longer canonical: the segment right before the file is a plain decimal number, not two + /// fixed-width hex fields joined by '-', so `parseRefTxnId` rejects it. + EXPECT_FALSE(l.parseManifestKey("p/cas/manifests/srv-a/3f2e-uuid@cas@/7/142/000042.zst").has_value()); + /// Foreign prefix, missing build segment, non-registered-suffix file, and out-of-range ordinal + /// are all rejected. + EXPECT_FALSE(l.parseManifestKey("p/cas/refs/srv-a/3f2e-uuid@cas@/" + "0000000000000007-000000000000008e/000042.zst").has_value()); + EXPECT_FALSE(l.parseManifestKey("p/cas/manifests/0000000000000007-000000000000008e/000042.zst").has_value()); + EXPECT_FALSE(l.parseManifestKey("p/cas/manifests/srv-a/3f2e-uuid@cas@/" + "0000000000000007-000000000000008e/000042.bin").has_value()); + EXPECT_FALSE(l.parseManifestKey("p/cas/manifests/srv-a/3f2e-uuid@cas@/" + "0000000000000007-000000000000008e/000000.zst").has_value()); + EXPECT_FALSE(l.parseManifestKey("p/cas/manifests/srv-a/3f2e-uuid@cas@/" + "0000000000000007-000000000000008E/000042.zst").has_value()); /// uppercase hex +} + +TEST(CASLayout, RefObjectKeyRoundTrips) +{ + Layout l("p"); + const RootNamespace ns{"srv1/tbl@cas@"}; + const NamespaceLifeId ns_id = DB::Cas::tests::fixture::fixtureLife(ns); + const RefTxnId id{7, 0x8e}; + const String life = "p/cas/ns/stream/" + renderIncarnation(ns_id.incarnation) + "/"; + + const String log_key = l.refLogKey(ns_id, id); + EXPECT_EQ(log_key, life + "_log/0000000000000007-000000000000008e.zst"); + const auto parsed_log = l.parseRefObjectKey(log_key); + ASSERT_TRUE(parsed_log.has_value()); + EXPECT_EQ(parsed_log->life_id, ns_id.incarnation); + EXPECT_EQ(parsed_log->kind, RefObjectKind::Log); + EXPECT_EQ(parsed_log->txn_id, id); + + const String snap_key = l.refSnapshotKey(ns_id, id); + EXPECT_EQ(snap_key, life + "_snap/0000000000000007-000000000000008e.zst"); + const auto parsed_snap = l.parseRefObjectKey(snap_key); + ASSERT_TRUE(parsed_snap.has_value()); + EXPECT_EQ(parsed_snap->life_id, ns_id.incarnation); + EXPECT_EQ(parsed_snap->kind, RefObjectKind::Snap); + EXPECT_EQ(parsed_snap->txn_id, id); + +} + +TEST(CASLayout, RefObjectKeyLexicalOrder) +{ + Layout l("p"); + const NamespaceLifeId ns_id = DB::Cas::tests::fixture::fixtureLife(RootNamespace{"srv1/tbl@cas@"}); + const RefTxnId id{7, 0x8e}; + EXPECT_LT(l.refLogKey(ns_id, id), l.refSnapshotKey(ns_id, id)); +} + +TEST(CASLayout, ParseRefObjectKeyRejections) +{ + Layout l("p"); + const RootNamespace ns{"srv1/tbl@cas@"}; + const NamespaceLifeId ns_id = DB::Cas::tests::fixture::fixtureLife(ns); + const RefTxnId id{7, 0x8e}; + const String log_key = l.refLogKey(ns_id, id); + const String snap_key = l.refSnapshotKey(ns_id, id); + + /// Foreign top-level prefix. + EXPECT_FALSE(l.parseRefObjectKey("p/cas/manifests/srv1/tbl@cas@/_log/" + renderRefTxnId(id)).has_value()); + /// Unknown kind directory (also covers the removed numeric-shard ref-key shape, which has no kind dir). + EXPECT_FALSE(l.parseRefObjectKey("p/cas/ns/stream/00000000000000000000000000000001/_bogus/" + renderRefTxnId(id)).has_value()); + EXPECT_FALSE(l.parseRefObjectKey(l.namespaceStreamPrefix(ns_id) + "3").has_value()); + /// Uppercase hex and a short id are non-canonical RefTxnId renders. The id is judged BEFORE the + /// life segment, so these stay "not ours" rather than becoming an incarnation refusal. + EXPECT_FALSE(l.parseRefObjectKey(l.namespaceStreamPrefix(ns_id) + "_log/" + "0000000000000007-000000000000008E").has_value()); + EXPECT_FALSE(l.parseRefObjectKey(l.namespaceStreamPrefix(ns_id) + "_log/7-8e").has_value()); + /// `_snap` without its stored suffix, and WITH a stray one, are both rejected. The suffix is taken + /// from the registry rather than spelled out: it was `.proto` when this test was written and is + /// `.zst` today, and stripping the wrong number of characters would have tested nothing. + const String snap_suffix{storedSuffix(FormatId::RefSnapshot)}; + EXPECT_FALSE(l.parseRefObjectKey(snap_key.substr(0, snap_key.size() - snap_suffix.size())).has_value()); + EXPECT_FALSE(l.parseRefObjectKey(log_key + ".proto").has_value()); + /// Trailing garbage after the id. + EXPECT_FALSE(l.parseRefObjectKey(log_key + "/extra").has_value()); + EXPECT_FALSE(l.parseRefObjectKey(snap_key + "/extra").has_value()); + /// Missing namespace segment entirely. + EXPECT_FALSE(l.parseRefObjectKey("p/cas/ns/stream/_log/" + renderRefTxnId(id)).has_value()); + /// The `_ckpt` (spec INV-4) has no kind directory and no transaction id, so the id-bearing parser + /// must not claim it. Every sweep over the ref prefix has to consult `parseRefCkptKey` as well -- + /// `groupRefKeys` treats a key neither parser recognizes as corruption that aborts ref folding. + EXPECT_FALSE(l.parseRefObjectKey(l.refCkptKey(ns_id)).has_value()); +} + +/// Stage A task 5 (spec INV-4): `refCkptKey` and `parseRefCkptKey` are inverses, and the `_ckpt` +/// parser is exactly as strict as its id-bearing sibling -- it claims OUR checkpoint keys and nothing +/// else. A key that is not one of ours at all still yields `std::nullopt` rather than an exception, +/// for the same reason `parseRefObjectKey` does: classifying an untrusted listed key is an ordinary +/// "is this ours" question. Refusal is reserved for a key that IS ours but names no life -- +/// `gtest_cas_ref_namespace_id.cpp` owns that half. +TEST(CASLayout, RefCkptKeyRoundTripsAndRejectsEverythingElse) +{ + Layout l("p"); + const RootNamespace ns{"srv1/tbl@cas@"}; + const NamespaceLifeId ns_id = DB::Cas::tests::fixture::fixtureLife(ns); + const RefTxnId id{7, 0x8e}; + + /// The state prefix plus the bare leaf, with no compression suffix (the format is raw), so the key + /// is exactly `cas/ns/state//_ckpt`. + EXPECT_EQ(l.refCkptKey(ns_id), l.namespaceStatePrefix(ns_id) + "_ckpt"); + EXPECT_EQ(l.parseRefCkptKey(l.refCkptKey(ns_id)), ns_id.incarnation); + const NamespaceLifeId deep = DB::Cas::tests::fixture::fixtureLife(RootNamespace{"a/b/c"}); + EXPECT_EQ(l.parseRefCkptKey(l.refCkptKey(deep)), deep.incarnation); + + /// Foreign pool prefix. + EXPECT_FALSE(l.parseRefCkptKey("q/cas/ns/state/00000000000000000000000000000001/_ckpt").has_value()); + /// The two id-bearing kinds are not checkpoints. + EXPECT_FALSE(l.parseRefCkptKey(l.refLogKey(ns_id, id)).has_value()); + EXPECT_FALSE(l.parseRefCkptKey(l.refSnapshotKey(ns_id, id)).has_value()); + /// A suffix the registry does not put there, and trailing garbage. + EXPECT_FALSE(l.parseRefCkptKey(l.refCkptKey(ns_id) + ".zst").has_value()); + EXPECT_FALSE(l.parseRefCkptKey(l.refCkptKey(ns_id) + "/extra").has_value()); + /// A near-miss leaf name. + EXPECT_FALSE(l.parseRefCkptKey(l.namespaceStatePrefix(ns_id) + "_ckp").has_value()); + EXPECT_FALSE(l.parseRefCkptKey(l.namespaceStatePrefix(ns_id) + "_ckpt2").has_value()); + /// Missing namespace segment entirely. + EXPECT_FALSE(l.parseRefCkptKey("p/cas/ns/state/_ckpt").has_value()); + /// The mirror of the rejection above: `_ckpt` is not a canonical `RefTxnId` render, so a key that + /// puts it inside a kind directory is claimed by NEITHER parser. + EXPECT_FALSE(l.parseRefObjectKey(l.namespaceStreamPrefix(ns_id) + "_log/_ckpt").has_value()); + /// The same key used to be READ by this parser as the checkpoint of a phantom namespace named + /// `srv1/tbl@cas@//_log`, because a namespace is an OPAQUE multi-segment string and nothing + /// distinguished a deeper real namespace from a shallower one with a stray segment. The life + /// segment closes that: `_log` is not a canonical incarnation, so the key is now REFUSED instead + /// of quietly naming a table that cannot exist. + EXPECT_FALSE(l.parseRefCkptKey(l.namespaceStatePrefix(ns_id) + "_log/_ckpt").has_value()); +} + +/// C3: blobKey/parseBlobKey are inverses; pins the grammar before relocating the definitions +/// from CasPartWriteTxn.cpp to CasLayout.cpp (relocation must not change a single byte of output). +TEST(CASLayout, BlobKeyRoundTripsThroughParse) +{ + DB::Cas::Layout layout("pool0"); + const DB::Cas::BlobRef ref{DB::Cas::BlobHashAlgo::XXH3_128, + DB::Cas::codecFor(DB::Cas::BlobHashAlgo::XXH3_128).fromHex(std::string(32, 'a'))}; + const String body = layout.blobKey(ref); + const String meta = layout.blobMetaKey(ref); + EXPECT_EQ(meta, body + ".meta"); + + auto parsed_body = layout.parseBlobKey(body); + auto parsed_meta = layout.parseBlobKey(meta); /// body and .meta parse to the SAME BlobRef + ASSERT_TRUE(parsed_body.has_value()); + ASSERT_TRUE(parsed_meta.has_value()); + EXPECT_EQ(*parsed_body, ref); + EXPECT_EQ(*parsed_meta, ref); + EXPECT_FALSE(layout.parseBlobKey("pool0/blobs/unknown-algo/aa/aa00").has_value()); /// foreign => nullopt +} diff --git a/src/Disks/tests/gtest_cas_lifecycle_condition.cpp b/src/Disks/tests/gtest_cas_lifecycle_condition.cpp new file mode 100644 index 000000000000..9ab0a9bf4ee4 --- /dev/null +++ b/src/Disks/tests/gtest_cas_lifecycle_condition.cpp @@ -0,0 +1,259 @@ +#include + +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include + +/// Task 5 (spec §§1-3): the pool lifecycle condition + the identity gate at step 0 of `tryRemountOnce`. +/// These tests open a real writable `Pool` over the in-memory ("Emulated"-style) backend, manipulate the +/// pool sentinels behind the pool's back, then drive the gate through the synchronous `tryRemountOnce` +/// seam and assert the resulting lifecycle condition + the store()-class refusal. They follow +/// gtest_cas_sentinel_probe.cpp's harness patterns; the op counter is `tests::CountingBackend`. + +namespace DB::ErrorCodes +{ +extern const int INVALID_STATE; +} + +using namespace DB::Cas; +using DB::Cas::tests::CountingBackend; + +namespace +{ + +const String kSrid = "test"; + +/// Delete an existing key exactly (its current token comes from the same GET). Returns the deleted body +/// so a test can restore it verbatim later (scenario d). +String deleteKeyReturningBody(Backend & backend, const String & key) +{ + const auto got = backend.get(key); + EXPECT_TRUE(got.has_value()) << "expected '" << key << "' to exist before deletion"; + if (!got) + return {}; + backend.deleteExact(key, got->token); + return got->bytes; +} + +/// GC's fence-out applied directly to the mount lease: preserve the body, set `gc_fenced`, bump `seq` +/// (token-guarded). A subsequent `tryRemountOnce` whose identity gate verdicts `Recover` then reclaims a +/// fresh incarnation and returns true. Mirrors gtest_cas_pool.cpp's `fenceOutMount`. +void fenceOutMount(Backend & backend, const String & mount_key) +{ + const auto got = backend.get(mount_key); + ASSERT_TRUE(got.has_value()); + MountLease m = decodeMountLease(got->bytes); + m.gc_fenced = true; + m.seq += 1; + ASSERT_EQ(backend.putOverwrite(mount_key, encodeMountLease(m), got->token).outcome, PutOutcome::Done); +} + +/// A Backend decorator whose head/get/list throw an untyped transport error while `fail` is armed. Starts +/// DISARMED so `Pool::open` succeeds; a test arms it only to make the identity probe inconclusive. Mirrors +/// gtest_cas_sentinel_probe.cpp's `TransportFaultBackend`, but toggleable AFTER open. +class ToggleableTransportFaultBackend final : public InMemoryBackend +{ +public: + /// Unhide the base convenience overloads, matching every other Backend subclass in this suite. + using Backend::get; + using Backend::getStream; + using Backend::putIfAbsent; + using Backend::putIfAbsentStream; + using Backend::putOverwrite; + using Backend::casPut; + + HeadResult head(const String & key) override + { + if (fail.load()) + throw std::runtime_error("injected fault: transport error"); + return InMemoryBackend::head(key); + } + + std::optional get(const String & key, Range range) override + { + if (fail.load()) + throw std::runtime_error("injected fault: transport error"); + return InMemoryBackend::get(key, range); + } + + ListPage list(const String & prefix, const String & cursor, size_t limit) override + { + if (fail.load()) + throw std::runtime_error("injected fault: transport error"); + return InMemoryBackend::list(prefix, cursor, limit); + } + + std::atomic fail{false}; +}; + +} + +/// (a) `_pool_meta` + the owner anchor authoritatively absent → the gate enters `IdentityLost` (never +/// `Vanished`) and store()-class access fails loud. rev.8: `IdentityLost` is a fail-loud TERMINAL state — +/// `isVanished()` still reads false (it is a distinct terminal), but a direct gate re-probe refuses without +/// ever claiming/allocating/writing (the thread-exit behavior of the background observer is covered by +/// `RemountThreadSelfExitsOnceIdentityLost` below). +TEST(CASLifecycleCondition, SentinelsDeletedEntersIdentityLostTerminal) +{ + auto backend = std::make_shared(); + auto store = DB::Cas::tests::openPoolForTest(backend); + ASSERT_EQ(store->lifecycle(), PoolLifecycle::Live); + + const String meta_key = store->layout().poolMetaKey(); + const String owner_key = store->layout().ownerKey(kSrid); + + /// Both sentinels gone (other objects may or may not remain — rev.8 does not distinguish). + deleteKeyReturningBody(*backend, meta_key); + deleteKeyReturningBody(*backend, owner_key); + + /// Even from `Live` (no fence trip), a direct remount attempt transitions through `TransientNotLive` + /// and enters `IdentityLost` at step 0 — WITHOUT reaching `claimOwnerOrThrow`. + EXPECT_FALSE(store->tryRemountOnce()); + EXPECT_EQ(store->lifecycle(), PoolLifecycle::IdentityLost); + EXPECT_FALSE(store->isVanished()) << "IdentityLost is a distinct terminal, not a Vanished state"; + + /// store()-class access now fails loud with the typed lifecycle error. + DB::Cas::tests::expectThrowsCode(DB::ErrorCodes::INVALID_STATE, [&] { store->throwIfLifecycleTerminal(); }); + + /// A direct gate re-probe still refuses without mutating: it probes the sentinels authoritatively and + /// performs ZERO writes (never claims/allocates/mounts on a terminal pool). + backend->resetCounts(); + EXPECT_FALSE(store->tryRemountOnce()); + EXPECT_EQ(store->lifecycle(), PoolLifecycle::IdentityLost); + EXPECT_EQ(backend->putTotal(), 0u) << "a terminal-IdentityLost gate probe must never claim, allocate, or write"; + EXPECT_GE(backend->headCount(meta_key), 1u) << "the gate still probes _pool_meta authoritatively"; +} + +/// (a2) rev.8 thread-exit: `IdentityLost` is terminal, so the background self-remount thread must self-exit +/// — mirroring how a `Vanished` pool refuses to arm one. With `background_watermark = true`, `scheduleRemount` +/// must REFUSE to arm a recovery thread once the pool is `IdentityLost` (`remountTerminal()` covers it), +/// exactly as it refuses on a published `Vanished` intent. +TEST(CASLifecycleCondition, RemountThreadSelfExitsOnceIdentityLost) +{ + auto backend = std::make_shared(); + /// `background_watermark = true` so `scheduleRemount` actually arms a recovery thread in production mode + /// (mirrors gtest_cas_pool.cpp's ShutdownGuardRefusesToArmRemount setup). + auto store = DB::Cas::Pool::open(backend, + DB::Cas::PoolConfig{.pool_prefix = "p", .server_root_id = "test", .background_watermark = true}); + + /// Drive the pool terminal (IdentityLost) synchronously first — a direct gate call, no thread spawned. + deleteKeyReturningBody(*backend, store->layout().poolMetaKey()); + deleteKeyReturningBody(*backend, store->layout().ownerKey(kSrid)); + EXPECT_FALSE(store->tryRemountOnce()); + ASSERT_EQ(store->lifecycle(), PoolLifecycle::IdentityLost); + + /// The keeper's on-lost callback (or any `scheduleRemount`) must now refuse: no observer runs on a + /// terminal pool. + EXPECT_FALSE(store->scheduleRemountForTest()) + << "an IdentityLost pool is terminal (rev.8) — scheduleRemount must not arm a recovery thread"; +} + +/// (b) `_pool_meta` present but its `pool_id` is foreign → `Vanished(replaced)` immediately. +TEST(CASLifecycleCondition, PoolMetaForeignPoolIdEntersVanishedReplacedImmediately) +{ + auto backend = std::make_shared(); + auto store = DB::Cas::tests::openPoolForTest(backend); + ASSERT_EQ(store->lifecycle(), PoolLifecycle::Live); + + /// Overwrite `_pool_meta` with a FOREIGN pool_id (identity replaced); the object stays present. + const String meta_key = store->layout().poolMetaKey(); + const auto got = backend->get(meta_key); + ASSERT_TRUE(got.has_value()); + PoolMeta foreign = decodePoolMeta(got->bytes); + foreign.pool_id = foreign.pool_id + DB::UInt128(1); + ASSERT_EQ(backend->putOverwrite(meta_key, encodePoolMeta(foreign), got->token).outcome, PutOutcome::Done); + + EXPECT_FALSE(store->tryRemountOnce()); + EXPECT_EQ(store->lifecycle(), PoolLifecycle::VanishedReplaced); + EXPECT_TRUE(store->isVanished()); + DB::Cas::tests::expectThrowsCode(DB::ErrorCodes::INVALID_STATE, [&] { store->throwIfLifecycleTerminal(); }); +} + +/// (c) [B6] trap: `_pool_meta` present, pool_id + blob_header_len match, but `algos_used` differs → NOT a +/// replacement (`algos_used` is legally mutable); the existing recovery proceeds and the pool returns to +/// `Live`. +TEST(CASLifecycleCondition, PoolMetaAlgosUsedDifferIsNotReplacementRecoveryProceeds) +{ + auto backend = std::make_shared(); + auto store = DB::Cas::tests::openPoolForTest(backend); + + const String meta_key = store->layout().poolMetaKey(); + const auto got = backend->get(meta_key); + ASSERT_TRUE(got.has_value()); + PoolMeta mutated = decodePoolMeta(got->bytes); + /// pool_id + blob_header_len UNCHANGED; only `algos_used` gains a member (a mutable field, [B6]). + const auto extra = static_cast(BlobHashAlgo::XXH3_128); + ASSERT_FALSE(std::binary_search(mutated.algos_used.begin(), mutated.algos_used.end(), extra)); + mutated.algos_used.push_back(extra); + std::sort(mutated.algos_used.begin(), mutated.algos_used.end()); + ASSERT_EQ(backend->putOverwrite(meta_key, encodePoolMeta(mutated), got->token).outcome, PutOutcome::Done); + + /// Fence out the mount so the (correctly non-replacement) recovery cleanly reclaims a fresh incarnation. + fenceOutMount(*backend, store->layout().mountKey(kSrid)); + + /// A differing `algos_used` must NOT read as a foreign pool: the gate verdicts `Recover`, recovery + /// completes, and the pool is `Live` — never `Vanished`. + EXPECT_TRUE(store->tryRemountOnce()); + EXPECT_EQ(store->lifecycle(), PoolLifecycle::Live); + EXPECT_FALSE(store->isVanished()); +} + +/// (d) [D3] no auto-revival: from `IdentityLost`, restoring both sentinels with matching identity does NOT +/// bring the disk back — the observer stays fail-loud; only a restart recovers. +TEST(CASLifecycleCondition, IdentityLostDoesNotAutoReviveWhenSentinelsRestored) +{ + auto backend = std::make_shared(); + auto store = DB::Cas::tests::openPoolForTest(backend); + + const String meta_key = store->layout().poolMetaKey(); + const String owner_key = store->layout().ownerKey(kSrid); + + const String meta_body = deleteKeyReturningBody(*backend, meta_key); + const String owner_body = deleteKeyReturningBody(*backend, owner_key); + + EXPECT_FALSE(store->tryRemountOnce()); + ASSERT_EQ(store->lifecycle(), PoolLifecycle::IdentityLost); + + /// Restore both sentinels verbatim (a backup restore with matching identity). + ASSERT_EQ(backend->putIfAbsent(meta_key, meta_body).outcome, PutOutcome::Done); + ASSERT_EQ(backend->putIfAbsent(owner_key, owner_body).outcome, PutOutcome::Done); + + /// The gate now sees Present+match, but the state is `IdentityLost`, so it stays fail-loud. + EXPECT_FALSE(store->tryRemountOnce()); + EXPECT_EQ(store->lifecycle(), PoolLifecycle::IdentityLost); + DB::Cas::tests::expectThrowsCode(DB::ErrorCodes::INVALID_STATE, [&] { store->throwIfLifecycleTerminal(); }); +} + +/// (e) Transport error from the probe → the pool stays `TransientNotLive` (recoverable); absence is never +/// proven, so no terminal transition fires and store()-class access does NOT throw the terminal lifecycle +/// error (the transient class stays fence-gated until Task 8). +TEST(CASLifecycleCondition, ProbeTransportErrorStaysTransientAndRetries) +{ + auto backend = std::make_shared(); + auto store = DB::Cas::tests::openPoolForTest(backend); + ASSERT_EQ(store->lifecycle(), PoolLifecycle::Live); + + /// Arm the transport fault: the identity probe's head/get/list now throw → Indeterminate. + backend->fail.store(true); + + EXPECT_FALSE(store->tryRemountOnce()); + EXPECT_EQ(store->lifecycle(), PoolLifecycle::TransientNotLive); + EXPECT_FALSE(store->isVanished()); + EXPECT_NO_THROW(store->throwIfLifecycleTerminal()); + + /// A second attempt with the fault still armed remains transient (retries continue). + EXPECT_FALSE(store->tryRemountOnce()); + EXPECT_EQ(store->lifecycle(), PoolLifecycle::TransientNotLive); + + /// Disarm before teardown so `~Pool()`'s clean-farewell write is not fighting the injected fault. + backend->fail.store(false); +} diff --git a/src/Disks/tests/gtest_cas_lifecycle_snapshot.cpp b/src/Disks/tests/gtest_cas_lifecycle_snapshot.cpp new file mode 100644 index 000000000000..154c365a2a6d --- /dev/null +++ b/src/Disks/tests/gtest_cas_lifecycle_snapshot.cpp @@ -0,0 +1,236 @@ +#include + +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include + +/// Task 12 (rev.7 spec §7, [C5]-visibility): the NON-GATED lifecycle snapshot backing +/// `system.cas_mounts`. A Factory-class read (spec §1): I/O-free, no `store()`/`poolAccess`, +/// truthful in EVERY state — so a not-live / stopped / vanished / never-started disk stays VISIBLE to the +/// operator instead of silently missing from the table. These tests exercise the accessor directly (the +/// SQL-level assertions land in Task 14): `ContentAddressedMetadataStorage::lifecycleSnapshot` at the +/// storage level, and `Pool::lifecycleSnapshot` at the pool level (including the zero-backend-op proof). +/// Harness patterns follow gtest_cas_operation_gate.cpp / gtest_cas_forget.cpp. + +namespace DB::ErrorCodes +{ +extern const int INVALID_STATE; +} + +using namespace DB; +using DB::Cas::PoolLifecycle; +using DB::Cas::tests::CountingBackend; + +namespace +{ + +const std::string kSrid = "test"; + +/// A live table dir + committed part reused by the storage-level tests (the shape +/// gtest_cas_operation_gate.cpp / gtest_cas_forget.cpp use). +const std::string kTableDir = "sn0/sn0sn0s0-0808-4808-8808-080808080808"; +const std::string kPartDir = kTableDir + "/all_1_1_0"; +const std::string kPartFile = kPartDir + "/data.bin"; + +std::shared_ptr openSnapshotStorage() +{ + auto settings = Cas::tests::makeSettingsForTest( + kSrid, std::filesystem::temp_directory_path() / "ca_snapshot_scratch"); + auto storage = std::make_shared( + Cas::tests::makeLocalObjectStorageForTest(), "pool", "srv1", "", nullptr, settings); + storage->startup(); + return storage; +} + +void commitOnePart(ContentAddressedMetadataStorage & storage) +{ + auto tx = storage.createTransaction(); + auto & ca_tx = dynamic_cast(*tx); + auto buf = ca_tx.writeFile(kTableDir + "/tmp_insert_all_1_1_0/data.bin", 65536, WriteMode::Rewrite, {}); + const std::string bytes = "content-of-the-part"; + buf->write(bytes.data(), bytes.size()); + buf->finalize(); + tx->moveDirectory(kTableDir + "/tmp_insert_all_1_1_0", kPartDir); + tx->commit(NoCommitOptions{}); +} + +/// Delete an existing key exactly (its current token comes from the same GET) — used to drive a live pool +/// into a NATURAL `IdentityLost`. Mirrors gtest_cas_forget.cpp / gtest_cas_lifecycle_condition.cpp. +void deleteKeyExact(DB::Cas::Backend & backend, const String & key) +{ + const auto got = backend.get(key); + ASSERT_TRUE(got.has_value()) << "expected '" << key << "' to exist before deletion"; + if (got) + backend.deleteExact(key, got->token); +} + +} + +/// (a) Live: the snapshot reads `live` with no reason and no `since`, and always carries the disk's +/// last-known identity (pool_id + server_root_id). +TEST(CASLifecycleSnapshot, LiveIsTruthfulWithIdentity) +{ + auto storage = openSnapshotStorage(); + commitOnePart(*storage); + + const CasLifecycleSnapshot snap = storage->lifecycleSnapshot(); + EXPECT_EQ(snap.lifecycle, "live"); + EXPECT_TRUE(snap.reason.empty()) << snap.reason; + EXPECT_TRUE(snap.detail.empty()) << snap.detail; + EXPECT_EQ(snap.since, 0) << "a live pool has no lifecycle `since`"; + EXPECT_EQ(snap.server_root_id, storage->serverRootId()); + EXPECT_FALSE(snap.pool_id.empty()) << "a started disk knows its pool identity"; + EXPECT_EQ(snap.pool_id, storage->getPoolUUID()); +} + +/// (b) IdentityLost (forced from Live on the captured handle, the gate-test idiom): the snapshot names the +/// non-auto-recovering `identity_lost` state with the [D5] detail present and `since` set. The enum-clean +/// `reason` word is empty here — it carries only the `vanished` sub-state, and `identity_lost` is already +/// fully named by the `lifecycle` column. +TEST(CASLifecycleSnapshot, IdentityLostHasDetailAndSince) +{ + auto storage = openSnapshotStorage(); + commitOnePart(*storage); + auto pool = storage->store(); /// captured while Live (store() is fail-closed on a terminal pool) + + pool->setLifecycleForTest(PoolLifecycle::IdentityLost); + + const CasLifecycleSnapshot snap = storage->lifecycleSnapshot(); + EXPECT_EQ(snap.lifecycle, "identity_lost"); + EXPECT_TRUE(snap.reason.empty()) << "reason is the vanish sub-state word only: " << snap.reason; + EXPECT_NE(snap.detail.find("identity lost"), std::string::npos) << snap.detail; + EXPECT_NE(snap.since, 0) << "a not-live state carries the wall-clock instant it was entered"; + /// Identity survives a terminal state — the disk stays introspectable under it. + EXPECT_EQ(snap.pool_id, storage->getPoolUUID()); +} + +/// (c) VanishedForgotten via the REAL verb (`storage->forgetDisk()`): the snapshot reads `vanished` with the +/// enum-clean `reason` word `forgotten` (so Task 14's `lifecycle || '(' || lifecycle_reason || ')'` reads +/// EXACTLY `vanished(forgotten)`), the [D5] `detail` carrying the operator's decommission timestamp, `since` +/// set, and the identity still present. +TEST(CASLifecycleSnapshot, VanishedForgottenIsEnumCleanWithTimestampedDetail) +{ + auto storage = openSnapshotStorage(); + commitOnePart(*storage); + const String pool_id_before = storage->getPoolUUID(); + + storage->forgetDisk(); + + const CasLifecycleSnapshot snap = storage->lifecycleSnapshot(); + EXPECT_EQ(snap.lifecycle, "vanished"); + EXPECT_EQ(snap.reason, "forgotten"); + /// Task 14's teardown check depends on this exact concatenation. + EXPECT_EQ(snap.lifecycle + "(" + snap.reason + ")", "vanished(forgotten)"); + EXPECT_NE(snap.detail.find("SYSTEM CAS FORGET at "), std::string::npos) << snap.detail; + EXPECT_NE(snap.detail.find("erasure was NOT verified"), std::string::npos) << snap.detail; + EXPECT_NE(snap.since, 0); + /// The disk stays registered and introspectable under its identity after FORGET. + EXPECT_EQ(snap.pool_id, pool_id_before); + EXPECT_EQ(snap.server_root_id, storage->serverRootId()); +} + +/// (d) A null pool never crashes the accessor and reports the storage-level lifecycle: `constructing` +/// before the first startup, `shutdown` after teardown. reason/since stay empty/0 (no terminal cause). +TEST(CASLifecycleSnapshot, NullPoolReportsConstructingThenShutdown) +{ + auto settings = Cas::tests::makeSettingsForTest( + kSrid, std::filesystem::temp_directory_path() / "ca_snapshot_null_scratch"); + auto storage = std::make_shared( + Cas::tests::makeLocalObjectStorageForTest(), "pool", "srv1", "", nullptr, settings); + + /// Constructed but never started: no pool published. + const CasLifecycleSnapshot before = storage->lifecycleSnapshot(); + EXPECT_EQ(before.lifecycle, "constructing"); + EXPECT_TRUE(before.reason.empty()); + EXPECT_TRUE(before.detail.empty()); + EXPECT_EQ(before.since, 0); + EXPECT_TRUE(before.pool_id.empty()) << "no identity before startup"; + EXPECT_EQ(before.server_root_id, kSrid) << "the identity is known from config even pre-startup"; + + storage->startup(); + ASSERT_EQ(storage->lifecycleSnapshot().lifecycle, "live"); + + storage->shutdown(); + const CasLifecycleSnapshot after = storage->lifecycleSnapshot(); + EXPECT_EQ(after.lifecycle, "shutdown") << "a torn-down disk is distinguishable from a never-started one"; + EXPECT_FALSE(after.pool_id.empty()) << "the last-known identity survives shutdown"; +} + +/// (e) The accessor is I/O-free (spec §1 Factory class): NO backend op runs, in any lifecycle state. Proven +/// against a `CountingBackend` — the totals recorded after open do not move across snapshot reads, whether +/// the pool is Live or forced terminal. +TEST(CASLifecycleSnapshot, PerformsZeroBackendOps) +{ + auto backend = std::make_shared(); + auto store = DB::Cas::tests::openPoolForTest(backend); + + const uint64_t head0 = backend->headTotal(); + const uint64_t get0 = backend->getTotal(); + const uint64_t put0 = backend->putTotal(); + const uint64_t getstream0 = backend->getStreamTotal(); + const uint64_t list0 = backend->listTotal(); + + const auto assertNoIo = [&](const char * where) + { + EXPECT_EQ(backend->headTotal(), head0) << where; + EXPECT_EQ(backend->getTotal(), get0) << where; + EXPECT_EQ(backend->putTotal(), put0) << where; + EXPECT_EQ(backend->getStreamTotal(), getstream0) << where; + EXPECT_EQ(backend->listTotal(), list0) << where; + }; + + /// Live snapshot: zero I/O. + (void)store->lifecycleSnapshot(); + assertNoIo("live snapshot must not touch the backend"); + + /// Forced terminal snapshot (the very state the store()-class surface refuses): still zero I/O. + store->setLifecycleForTest(PoolLifecycle::VanishedReplaced); + const DB::Cas::Pool::LifecycleSnapshot vanished = store->lifecycleSnapshot(); + assertNoIo("a vanished-pool snapshot must not touch the backend"); + EXPECT_EQ(vanished.lifecycle, PoolLifecycle::VanishedReplaced); +} + +/// (f) A NATURAL transition (not the forced setter) captures the detail + `since`, and the snapshot's detail +/// is EXACTLY the [D5] text `throwIfLifecycleTerminal` throws (minus the pool-name prefix) — the spec §1 +/// "same reason strings in the snapshot and the error" guarantee, so the two can never drift. +TEST(CASLifecycleSnapshot, NaturalIdentityLostMatchesThrowDetail) +{ + auto backend = std::make_shared(); + auto store = DB::Cas::tests::openPoolForTest(backend); + + /// Delete both pool sentinels while other objects remain, then drive the identity gate → IdentityLost + /// (never Vanished), exactly gtest_cas_lifecycle_condition.cpp scenario (a). + deleteKeyExact(*backend, store->layout().poolMetaKey()); + deleteKeyExact(*backend, store->layout().ownerKey(kSrid)); + EXPECT_FALSE(store->tryRemountOnce()); + ASSERT_EQ(store->lifecycle(), PoolLifecycle::IdentityLost); + + const DB::Cas::Pool::LifecycleSnapshot snap = store->lifecycleSnapshot(); + EXPECT_EQ(snap.lifecycle, PoolLifecycle::IdentityLost); + EXPECT_NE(snap.since, 0) << "the natural enterIdentityLost transition stamps the wall-clock `since`"; + EXPECT_FALSE(snap.detail.empty()); + + /// The snapshot detail is the SAME [D5] text the typed error surfaces: the throw is + /// "content-addressed pool '' ", so the error message must contain the snapshot detail. + std::string thrown; + try + { + store->throwIfLifecycleTerminal(); + ADD_FAILURE() << "IdentityLost must throw from throwIfLifecycleTerminal"; + } + catch (const Exception & e) + { + thrown = std::string(e.message()); + } + EXPECT_NE(thrown.find(snap.detail), std::string::npos) + << "snapshot detail and the typed error must not drift\n detail: " << snap.detail + << "\n thrown: " << thrown; +} diff --git a/src/Disks/tests/gtest_cas_list_liar_end_to_end.cpp b/src/Disks/tests/gtest_cas_list_liar_end_to_end.cpp new file mode 100644 index 000000000000..1b48718663cb --- /dev/null +++ b/src/Disks/tests/gtest_cas_list_liar_end_to_end.cpp @@ -0,0 +1,624 @@ +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include "cas_test_helpers.h" + +#include +#include +#include + +/// THE 2026-07-25 RELEASE BLOCKER, AS A PERMANENT REGRESSION. +/// +/// The defect the object store actually exhibited (`reports/2026-07-26-list-incompleteness-proof/`): +/// objects that were durable, acked, and readable by exact key were OMITTED from enumeration, while a +/// LATER key under the same prefix was listed. Nothing was lost and nothing was corrupt -- the store +/// simply under-reported what it held. +/// +/// Every CAS reader that treated a listing as a CENSUS then drew a false conclusion from it, and the +/// two that mattered drew ruinous ones. The GC fold walked the ids the listing returned, so it skipped +/// the omitted records' owner edges AND sealed a cursor above them -- and nothing ever re-reads below a +/// sealed cursor, so those edges were lost permanently: blobs that were still referenced looked +/// unreferenced forever after. Recovery replayed the listing, so a table came back missing an ACKED +/// transaction while looking perfectly healthy. +/// +/// The answer is that a listing is a HINT and arithmetic is the census. Ids are dense `1..T` +/// within `(namespace, writer_epoch)` (INV-1), so the next record's id is COMPUTABLE and every record +/// is read by EXACT KEY. A hidden-but-durable contiguous id is then a NON-EVENT -- the walk finds it +/// anyway -- while a genuinely absent expected id is a durable HOLD, never a silent skip. +/// +/// This file is that claim stated end to end, against a store that lies exactly the way the real one +/// did. `setListOmissions` names the omitted keys; `get`/`head`/`putIfAbsent`/`casPut`/`deleteExact` +/// keep serving them honestly. Each test below asserts the lie changed NOTHING -- not the folded +/// edges, not the cursor, not the recovered table, not fsck's verdict -- and the arms that are about +/// reclamation additionally assert that reclamation still happens, so "nothing was deleted" can never +/// pass for "the lie was harmless". +/// +/// The unit-level statements about the walk itself live in `gtest_cas_gc_arithmetic_intake.cpp`, and +/// the destructive gate's own inventory lives in `gtest_cas_gc_frontier_gate.cpp`. This file is the +/// INTEGRATION of the two: real rounds, real recovery, real fsck, one lying store. +/// +/// The suite name is prefixed `Cas` so the `Cas*` unit-test gate filter covers it. + +using namespace DB::Cas; +using namespace DB::Cas::tests; + +namespace +{ + +const UInt128 kGc = hexToU128("00000000000000000000000000000001"); + +/// The lying store: LIST omits the named keys, everything else serves them honestly. Composed over +/// `CountingBackend` so the arms whose subject is reclamation can assert on DELETES rather than only on +/// what survived. +using LiarBackend = HintHoleBackendOn; + +String blobKeyOf(const Layout & layout, const DB::UInt128 & hash) +{ + return layout.blobKey(legacyMetaTestRef(hash)); +} + +/// The sealed fold cursor for `ns` as a full `RefTxnId`. Every fixture here writes ids inside writer +/// epoch 1, which is the assumption `foldCursorOf` (returning the sequence alone) already makes. +RefTxnId sealedCursorOf(Backend & backend, const Layout & layout, const RootNamespace & ns) +{ + return RefTxnId{1, foldCursorOf(backend, layout, ns, /*shard*/ 0)}; +} + +/// Drop the committed ref `ref_name` (currently naming `old_ref`) as ONE transaction at EXACTLY `id`. +/// The `dropRefTransition` helper allocates its id by LISTING, which a fixture that hides keys must +/// never do -- it would allocate over a hidden record. Every id in this file is therefore chosen. +void dropAt(Backend & backend, const Layout & layout, const RootNamespace & ns, const RefTxnId & id, + const String & ref_name, const ManifestRef & old_ref) +{ + writeTxnAt(backend, layout, ns, id, + {ownerTransitionOp(RefOwnerBinding{RefOwnerKind::Committed, ref_name, old_ref}, std::nullopt)}); +} + +/// The manifest `publishAt` mints for a given (id, build_sequence) -- needed to drop that ref later. +ManifestRef publishedManifest(const RefTxnId & id, uint64_t build_sequence) +{ + return ManifestRef{.writer_epoch = id.writer_epoch, .build_sequence = build_sequence, .manifest_ordinal = 1}; +} + +/// One round plus everything a verdict in this file is allowed to rest on: the report (which carries +/// the anomaly list), the two intake phase rows (which carry the hold count and the one remaining +/// whole-round ref abort), and the gate's own verdict off `fold_reduce` (read, not recomputed, so a test +/// cannot agree with a wrong formula just as readily as with the right one). +struct RoundEvidence +{ + RoundReport report; + std::map intake; /// `fold_ref_intake` + std::map group; /// `fold_ref_group` + bool saw_fold = false; + bool frontier_complete = false; + bool suppress_destructive = false; +}; + +RoundEvidence runRoundCapturing(Gc & gc, UniversePolicy policy) +{ + RoundEvidence evidence; + gc.setPhaseSink([&](const GcPhaseRecord & rec) + { + if (rec.phase == "fold_ref_intake") + evidence.intake = rec.metrics; + else if (rec.phase == "fold_ref_group") + evidence.group = rec.metrics; + else if (rec.phase == "fold_reduce") + { + evidence.saw_fold = true; + if (const auto it = rec.metrics.find("frontier_complete"); it != rec.metrics.end()) + evidence.frontier_complete = it->second != 0; + if (const auto it = rec.metrics.find("suppress_destructive"); it != rec.metrics.end()) + evidence.suppress_destructive = it->second != 0; + } + }); + evidence.report = gc.runRegularRound({}, /*allow_steal*/ true, policy); + gc.setPhaseSink({}); + return evidence; +} + +/// "ZERO ANOMALIES", spelled out once so every test means the same thing by it: the round recorded no +/// anomaly, sealed no hold, and did not abort ref folding. A lie the walk absorbs must be invisible in +/// all three -- a hold in particular would be a WRONG (if safe) answer, since it would suppress the +/// round's destructive half over records that were durable all along. +void expectNoAnomalies(const RoundEvidence & evidence, const char * where) +{ + EXPECT_TRUE(evidence.report.anomalies.empty()) + << where << ": the round recorded " << evidence.report.anomalies.size() + << " anomaly/anomalies; a hidden-but-durable contiguous id is a NON-EVENT"; + ASSERT_FALSE(evidence.intake.empty()) << where << ": no `fold_ref_intake` row was emitted"; + EXPECT_EQ(evidence.intake.at("tables_held"), 0u) + << where << ": a namespace was HELD -- the walk mistook an omitted-but-durable record for a gap"; + EXPECT_EQ(evidence.intake.at("ref_folding_aborted"), 0u) << where; + ASSERT_FALSE(evidence.group.empty()) << where << ": no `fold_ref_group` row was emitted"; + EXPECT_EQ(evidence.group.at("ref_folding_aborted"), 0u) << where; +} + +/// The pool's view of a table, rendered so a failing comparison prints something a human can read. +std::map refsOf(const PoolPtr & store, const RootNamespace & ns) +{ + std::map out; + for (const auto & [ref_name, resolved] : store->listRefs(ns)) + out[ref_name] = std::to_string(resolved.manifest_id.ref.writer_epoch) + "/" + + std::to_string(resolved.manifest_id.ref.build_sequence) + "/" + + std::to_string(resolved.manifest_id.ref.manifest_ordinal); + return out; +} + +/// THE STREAM UNDER TEST, written identically into any backend: five ordinary publishes at +/// `{1,1}..{1,5}`, each pinning its own blob, plus the `_ckpt` a recovering reader starts from. Shared +/// so the oracle arms can seed a lying store and an honest one from the SAME code and compare outcomes +/// rather than compare against a hand-written expectation. +void seedFiveRecordStream(Backend & backend, const Layout & layout, const RootNamespace & ns) +{ + seedPoolMetaForRestart(backend, layout.poolPrefix()); + for (uint64_t i = 1; i <= 5; ++i) + publishAt(backend, layout, ns, RefTxnId{1, i}, "ref_" + std::to_string(i), i, + DB::UInt128(i), /*birth=*/i == 1); + writeRecoverableCkptForRawFixture(backend, layout, ns, RefCkpt{ + .life_epoch = 1, + .committed_through = RefTxnId{1, 5}, + .checkpoint_snapshot_id = std::nullopt, + .last_epoch_seal = std::nullopt, + }); +} + +/// The exact defect shape: ids 3 and 4 invisible while the LATER id 5 is visible. +std::vector hiddenMiddleOf(const Layout & layout, const RootNamespace & ns) +{ + return {layout.refLogKey(fixture::fixtureLife(ns), RefTxnId{1, 3}), + layout.refLogKey(fixture::fixtureLife(ns), RefTxnId{1, 4})}; +} + +PoolConfig recoveryPoolConfig() +{ + PoolConfig config; + config.pool_prefix = "p"; + config.server_root_id = "test"; + config.server_id = DB::UInt128(1); + /// No background publication: a threshold-triggered snapshot would move the base under the + /// comparison these tests make about what recovery reconstructed. + config.snapshot_log_count_threshold = 1ULL << 40; + config.snapshot_log_bytes_threshold = 1ULL << 40; + return config; +} + +PoolPtr openRecoveryPool(const std::shared_ptr & backend) +{ + seedPoolMetaForRestart(*backend, "p"); + return Pool::open(backend, recoveryPoolConfig()); +} + +} + +/// ===================== THE BLOCKER, FULL PIPELINE ===================== +/// +/// Five durable records; the store lists 1, 2 and 5 and pretends 3 and 4 do not exist. Arithmetic +/// intake never asks the listing what to read next, so all five fold, every blob keeps its owner edge, +/// and the cursor lands on the true tail. +/// +/// Under listing-driven intake this fails on the BLOBS, not on the cursor: the cursor still reaches +/// `{1,5}` (the last listed id) while records 3 and 4 were never folded -- and since nothing re-reads +/// below a sealed cursor, their edges are gone for good. That is the production damage, exactly. +TEST(CASListLiarEndToEnd, TheHiddenMiddleOfTheStreamFoldsThroughUnnoticed) +{ + auto backend = std::make_shared(); + auto store = openPoolForTest(backend, /*gc_fold_max_defer_rounds=*/0); + const Layout & layout = store->layout(); + const RootNamespace ns{"00/blocker@cas@"}; + + seedFiveRecordStream(*backend, layout, ns); + backend->setListOmissions(hiddenMiddleOf(layout, ns)); + + Gc gc(store, kGc); + const RoundEvidence evidence = runRoundCapturing(gc, UniversePolicy::kDefault); + ASSERT_TRUE(evidence.report.acquired_lease); + ASSERT_GT(backend->holesServed(), 0u) + << "the omission was never actually served -- the test would pass vacuously"; + + EXPECT_EQ(sealedCursorOf(*backend, layout, ns), (RefTxnId{1, 5})) + << "the walk must reach the true tail of the stream"; + for (uint64_t i = 1; i <= 5; ++i) + EXPECT_EQ(inDegreeOf(*backend, layout, DB::UInt128(i)), 1) + << "blob " << i << " lost its owner edge: its record was skipped because the store hid it"; + + expectNoAnomalies(evidence, "hidden middle"); + EXPECT_EQ(evidence.intake.at("logs_applied"), 5u) << "all five records are APPLIED, not three"; + EXPECT_EQ(evidence.intake.at("logs_accounted"), evidence.intake.at("logs_applied")) + << "probe B1: the arithmetic cut the cursors claim must equal what the walk applied"; +} + +/// RECOVERY, UNDER THE SAME LIE, AGAINST AN HONEST ORACLE. The comparison is against a second pool +/// seeded by the SAME code over a store that does not lie -- not against a hand-written expectation, +/// which could encode the same mistake the code makes. +TEST(CASListLiarEndToEnd, RecoveryUnderTheSameLieReconstructsExactlyTheTruth) +{ + const Layout layout("p"); + const RootNamespace ns{"00/recover@cas@"}; + + auto honest_backend = std::make_shared(); + seedFiveRecordStream(*honest_backend, layout, ns); + auto honest = openRecoveryPool(honest_backend); + const std::map truth = refsOf(honest, ns); + + auto lying_backend = std::make_shared(); + seedFiveRecordStream(*lying_backend, layout, ns); + lying_backend->setListOmissions(hiddenMiddleOf(layout, ns)); + auto lying = openRecoveryPool(lying_backend); + const std::map recovered = refsOf(lying, ns); + + ASSERT_GT(lying_backend->holesServed(), 0u) + << "the omission was never actually served -- the test would pass vacuously"; + EXPECT_EQ(truth.size(), 5u) << "the oracle itself must see all five published refs"; + EXPECT_EQ(recovered, truth) + << "a table recovered under an omitting listing must be byte-identical to the truth; the " + "blocker's recovery came back missing an ACKED transaction and looked healthy"; +} + +/// ===================== THE DATA-LOSS ARM ===================== +/// +/// A blob with two owners. The `+1` that publishes the SECOND owner rides the hidden id; the `-1` that +/// releases the first is visible and lands above it. The arithmetic fold reads both, so the blob's +/// in-degree is 1 and it is never condemned. +/// +/// Listing-driven intake folds the visible `-1`, never folds the hidden `+1`, and seals the cursor +/// above it: the blob's in-degree reads zero while a live ref still names it, and the round deletes +/// data that is referenced. That is the data loss, and it is why this arm asserts the blob was never +/// even offered for deletion rather than merely that it is still present. +TEST(CASListLiarEndToEnd, AHiddenPlusOneKeepsItsBlobWhenAVisibleMinusOneLandsLater) +{ + auto backend = std::make_shared(); + auto store = openPoolForTest(backend, /*gc_fold_max_defer_rounds=*/0); + const Layout & layout = store->layout(); + const RootNamespace ns{"00/dataloss@cas@"}; + fixture::admitLive(*backend, store->layout(), ns); /// Stage B (Task 4-C): pin to the sentinel before the first real touch + const DB::UInt128 shared(0x5ade); + + /// `ref_a` and `ref_b` both pin `shared`; `ref_b`'s publish is the record the store will hide. + publishAt(*backend, layout, ns, RefTxnId{1, 1}, "ref_a", 1, shared, /*birth=*/true); + publishAt(*backend, layout, ns, RefTxnId{1, 2}, "ref_b", 2, shared); + dropAt(*backend, layout, ns, RefTxnId{1, 3}, "ref_a", publishedManifest(RefTxnId{1, 1}, 1)); + writeRecoverableCkptForRawFixture(*backend, layout, ns, RefCkpt{ + .life_epoch = 1, + .committed_through = RefTxnId{1, 3}, + .checkpoint_snapshot_id = std::nullopt, + .last_epoch_seal = std::nullopt, + }); + + backend->setListOmissions({layout.refLogKey(fixture::fixtureLife(ns), RefTxnId{1, 2})}); + + Gc gc(store, kGc); + const RoundEvidence first = runRoundCapturing(gc, UniversePolicy::Authoritative); + ASSERT_TRUE(first.report.acquired_lease); + ASSERT_GT(backend->holesServed(), 0u) + << "the omission was never actually served -- the test would pass vacuously"; + expectNoAnomalies(first, "hidden +1"); + + EXPECT_EQ(sealedCursorOf(*backend, layout, ns), (RefTxnId{1, 3})); + EXPECT_EQ(inDegreeOf(*backend, layout, shared), 1) + << "the hidden publish's `+1` must be folded: `ref_b` still owns this blob"; + + /// Rounds that are ALLOWED to reclaim, and would, if the in-degree were wrong. + for (int i = 0; i < 5; ++i) + { + runRegularRoundReclaiming(gc); + store->renewWatermarkOnce(); + } + EXPECT_TRUE(backend->head(blobKeyOf(layout, shared)).exists) + << "a blob a live ref still names was DELETED -- the hidden `+1` was never folded"; + EXPECT_EQ(backend->deleteCount(blobKeyOf(layout, shared)), 0u) + << "not merely still present: the delete was never even attempted"; +} + +/// ===================== THE LEAK ARM ===================== +/// +/// The mirror image, and the reason the arm above is not the whole story. Here the hidden record +/// carries the `-1` that releases the blob's last owner, and a visible record lands above it. The +/// arithmetic fold reads the `-1`, so the in-degree reaches zero and the blob is actually reclaimed. +/// +/// Listing-driven intake skips the `-1` and seals the cursor above it, so the blob keeps a phantom +/// owner forever: not data loss, but an object no incremental round can ever reclaim. Asserting the +/// blob DOES go away is also what stops the data-loss arm above from being satisfiable by a fold that +/// simply never deletes anything. +TEST(CASListLiarEndToEnd, AHiddenMinusOneIsStillFoldedSoTheBlobIsActuallyReclaimed) +{ + auto backend = std::make_shared(); + auto store = openPoolForTest(backend, /*gc_fold_max_defer_rounds=*/0); + const Layout & layout = store->layout(); + const RootNamespace ns{"00/leak@cas@"}; + fixture::admitLive(*backend, store->layout(), ns); /// Stage B (Task 4-C): pin to the sentinel before the first real touch + const DB::UInt128 released(0xdea1); + const DB::UInt128 unrelated(0xb00c); + + publishAt(*backend, layout, ns, RefTxnId{1, 1}, "ref_a", 1, released, /*birth=*/true); + dropAt(*backend, layout, ns, RefTxnId{1, 2}, "ref_a", publishedManifest(RefTxnId{1, 1}, 1)); + /// A VISIBLE record above the hidden one. Without it the hidden id would be the stream's tail, and + /// a listing-driven walk would merely stop below it -- deferring the `-1` rather than sealing past + /// it, which is not the permanent damage this arm is about. + publishAt(*backend, layout, ns, RefTxnId{1, 3}, "ref_c", 3, unrelated); + writeRecoverableCkptForRawFixture(*backend, layout, ns, RefCkpt{ + .life_epoch = 1, + .committed_through = RefTxnId{1, 3}, + .checkpoint_snapshot_id = std::nullopt, + .last_epoch_seal = std::nullopt, + }); + + backend->setListOmissions({layout.refLogKey(fixture::fixtureLife(ns), RefTxnId{1, 2})}); + + Gc gc(store, kGc); + const RoundEvidence condemning = runRoundCapturing(gc, UniversePolicy::Authoritative); + ASSERT_TRUE(condemning.report.acquired_lease); + ASSERT_GT(backend->holesServed(), 0u) + << "the omission was never actually served -- the test would pass vacuously"; + expectNoAnomalies(condemning, "hidden -1"); + + EXPECT_EQ(sealedCursorOf(*backend, layout, ns), (RefTxnId{1, 3})); + EXPECT_EQ(inDegreeOf(*backend, layout, released), 0) + << "the hidden `-1` must be folded: nothing owns this blob any more"; + EXPECT_TRUE(backend->head(blobKeyOf(layout, released)).exists) + << "round pacing: the round that CONDEMNS never also deletes"; + + store->renewWatermarkOnce(); + EXPECT_TRUE(runRoundsUntilAbsent(store, gc, *backend, layout, released)) + << "the blob was never reclaimed -- the hidden `-1` left it pinned by a phantom owner"; + EXPECT_TRUE(backend->head(blobKeyOf(layout, unrelated)).exists) + << "and the still-owned blob is untouched"; +} + +/// ===================== THE CROSS-NAMESPACE SHOT ===================== +/// +/// The shape that is not about walking a single namespace: it is about a namespace the store hides in +/// its ENTIRETY, never just one record inside it. +/// +/// Two namespaces share a blob. `visible` publishes it and then drops it, so the round observes `+1` +/// then `-1` and reads the blob's in-degree as zero. `hidden` also owns it -- durably, acked, readable +/// by exact key -- but the store omits its ENTIRE ref stream, so no listing mentions it. `hidden`'s own +/// publish still leaves it a real `_ckpt`, and a `_ckpt` is read by exact key, so the arithmetic walk's +/// first probe finds and folds `hidden`'s `+1` regardless of what the listing omits: the blob survives +/// on its own complete, folded frontier. +/// +/// This is NOT a duplicate of `gtest_cas_gc_frontier_gate.cpp`'s twin: that file's backend hides only a +/// hint prefix, while this one is the end-to-end LIST-liar backend from this file's own header -- the +/// distinct thing this test proves is that arithmetic intake reads a record the backend actively hides +/// from every enumeration, in the full pipeline (real pool, real recovery-shaped checkpoints), not that +/// the gate's universe/count terms hold. `gtest_cas_gc_frontier_gate.cpp` owns those terms: its +/// (3a)/(3b)/(3c) suppressor arms are what pin `universe_authoritative`, the empty-universe floor, and +/// the probe budget -- terms this fixture cannot exercise, because grounding both namespaces here makes +/// `frontier_namespaces > 0` and `universe_authoritative` true unconditionally. + +namespace +{ +/// Build the shared-blob scenario and return the manifest `visible` will drop. Both namespaces are +/// grounded with a real `_ckpt` reflecting what was actually published (`writeRecoverableCkptForRawFixture`, +/// the idiom every other test in this file uses): otherwise neither namespace has a usable checkpoint at +/// all, the round suppresses on that anomaly alone, and the scenario proves nothing about `hidden` +/// specifically. +ManifestRef buildKillShot(const std::shared_ptr & backend, const Layout & layout, + const RootNamespace & hidden, const RootNamespace & visible, + const DB::UInt128 & blob) +{ + publishAt(*backend, layout, hidden, RefTxnId{1, 1}, "kept_ref", 1, blob, /*birth=*/true); + writeRecoverableCkptForRawFixture(*backend, layout, hidden, RefCkpt{ + .life_epoch = 1, + .committed_through = RefTxnId{1, 1}, + .checkpoint_snapshot_id = std::nullopt, + .last_epoch_seal = std::nullopt, + }); + + publishAt(*backend, layout, visible, RefTxnId{1, 1}, "dropped_ref", 2, blob, /*birth=*/true); + const ManifestRef dropped = publishedManifest(RefTxnId{1, 1}, 2); + dropAt(*backend, layout, visible, RefTxnId{1, 2}, "dropped_ref", dropped); + writeRecoverableCkptForRawFixture(*backend, layout, visible, RefCkpt{ + .life_epoch = 1, + .committed_through = RefTxnId{1, 2}, + .checkpoint_snapshot_id = std::nullopt, + .last_epoch_seal = std::nullopt, + }); + + /// The whole of `hidden`'s ref stream goes invisible -- the namespace itself is what the listing + /// stops mentioning, not a record inside it. Its `_ckpt` stays readable by exact key, which is what + /// lets the arithmetic walk find and fold its birth despite the omission. + backend->setListOmissions({layout.refLogKey(fixture::fixtureLife(hidden), RefTxnId{1, 1}), + layout.refCkptKey(fixture::fixtureLife(hidden))}); + return dropped; +} +} + +/// Rounds on the PRODUCTION path -- no policy argument anywhere -- because that is the posture the +/// claim is about: the arithmetic walk's exact-key probe reaches `hidden`'s birth despite the store +/// hiding its whole stream from every listing, so the frontier it proves is complete and the blob +/// survives on its own folded in-degree, not on a caller declining to supply a universe. +TEST(CASListLiarEndToEnd, AHiddenNamespacesBirthIsFoundByExactKeyAndSavesTheBlobOnACompleteFrontier) +{ + auto backend = std::make_shared(); + auto store = openPoolForTest(backend, /*gc_fold_max_defer_rounds=*/0); + const Layout & layout = store->layout(); + const RootNamespace hidden{"00/hidden@cas@"}; + const RootNamespace visible{"00/visible@cas@"}; + const DB::UInt128 blob(0x5ade); + + buildKillShot(backend, layout, hidden, visible, blob); + + Gc gc(store, kGc); + backend->resetCounts(); + RoundEvidence evidence; + for (int i = 0; i < 5; ++i) + { + const RoundEvidence round = runRoundCapturing(gc, UniversePolicy::kDefault); + if (round.saw_fold) + evidence = round; + store->renewWatermarkOnce(); + } + + ASSERT_TRUE(evidence.saw_fold) << "no round folded, so none published a gate verdict"; + ASSERT_GT(backend->holesServed(), 0u) + << "the omission was never actually served -- the test would pass vacuously"; + EXPECT_TRUE(backend->head(blobKeyOf(layout, blob)).exists) + << "the blob a hidden namespace still owns must survive"; + EXPECT_EQ(backend->deleteCount(blobKeyOf(layout, blob)), 0u) + << "not merely still present: the blob must never even be offered for deletion"; + EXPECT_TRUE(evidence.frontier_complete) + << "`hidden`'s own `_ckpt` is read by exact key, so its frontier is provable despite the " + "listing omission -- if this is false the blob above survived on suppression instead of on " + "its own in-degree, which proves nothing about the edge"; + EXPECT_FALSE(evidence.suppress_destructive); +} + +/// The arm above asserts "nothing was deleted", which on its own does not distinguish the gate correctly +/// refusing from the round simply never deleting anything. Positive control: +/// `hidden` drops its OWN reference too (still by exact key, still hidden from every listing), so its +/// frontier is REALLY proven by the arithmetic-intake exact-key probe -- never declared so by fiat -- +/// and the blob is REALLY unreferenced by both namespaces. The round drains it. +TEST(CASListLiarEndToEnd, TheSameBlobDrainsOnceHiddenGenuinelyProvesItsOwnFrontier) +{ + auto backend = std::make_shared(); + auto store = openPoolForTest(backend, /*gc_fold_max_defer_rounds=*/0); + const Layout & layout = store->layout(); + const RootNamespace hidden{"00/hidden@cas@"}; + const RootNamespace visible{"00/visible@cas@"}; + const DB::UInt128 blob(0x5ade); + + publishAt(*backend, layout, hidden, RefTxnId{1, 1}, "kept_ref", 1, blob, /*birth=*/true); + const ManifestRef kept = publishedManifest(RefTxnId{1, 1}, 1); + writeRecoverableCkptForRawFixture(*backend, layout, hidden, RefCkpt{ + .life_epoch = 1, + .committed_through = RefTxnId{1, 1}, + .checkpoint_snapshot_id = std::nullopt, + .last_epoch_seal = std::nullopt, + }); + + Gc gc(store, kGc); + + /// `hidden`'s birth is folded (and its cursor SEALED) while everything is still listed. Its + /// checkpoint proves that exact initial frontier; the real fold then makes the arithmetic + /// (cursor-relative) genesis available for what follows. + ASSERT_TRUE(gc.runRegularRound().acquired_lease); + store->renewWatermarkOnce(); + + /// NOW `hidden` drops its own reference, and ONLY THEN does its whole prefix vanish from LIST. With + /// a sealed cursor already in hand, the walk's genesis for `hidden` is arithmetic (`cursor + 1`), so + /// this drop is found and folded by exact key alone -- the arithmetic-intake mechanism this whole + /// file is about, exercised honestly rather than declared past by fiat. + dropAt(*backend, layout, hidden, RefTxnId{1, 2}, "kept_ref", kept); + advanceRecoverableCkptForRawFixture(*backend, layout, hidden, RefTxnId{1, 2}); + backend->hidePrefix(layout.namespaceStreamPrefix(fixture::fixtureLife(hidden))); + + publishAt(*backend, layout, visible, RefTxnId{1, 1}, "dropped_ref", 2, blob, /*birth=*/true); + const ManifestRef dropped = publishedManifest(RefTxnId{1, 1}, 2); + dropAt(*backend, layout, visible, RefTxnId{1, 2}, "dropped_ref", dropped); + writeRecoverableCkptForRawFixture(*backend, layout, visible, RefCkpt{ + .life_epoch = 1, + .committed_through = RefTxnId{1, 2}, + .checkpoint_snapshot_id = std::nullopt, + .last_epoch_seal = std::nullopt, + }); + + for (int i = 0; i < 5; ++i) + { + runRegularRoundReclaiming(gc); + store->renewWatermarkOnce(); + } + + ASSERT_GT(backend->holesServed(), 0u) + << "the omission was never actually served -- the test would pass vacuously"; + EXPECT_FALSE(backend->head(blobKeyOf(layout, blob)).exists) + << "both namespaces genuinely proved their frontier and the blob is genuinely unreferenced -- " + "the round must still be able to reclaim it"; +} + +/// ===================== FSCK ===================== +/// +/// fsck runs two checkpoint-grounded passes over a namespace's ref stream. +/// +/// * `checkRefStream` walks arithmetically by exact key. An omitted-but-durable record is a +/// non-event to it, exactly as it is to the GC fold. That is the pass the arms below pin. +/// * the reachability pass uses the same catalog row and exact `_ckpt` to recover the ref table +/// without stream enumeration. +/// +/// Both arms are written against an HONEST TWIN seeded by the same code, not against hand-written +/// expectations: the claim is "identical to the truth", and a pass that quietly examined fewer records +/// would satisfy a hand-written "clean" just as well. + +TEST(CASListLiarEndToEnd, FsckArithmeticStreamAuditIsUnmovedByAHiddenMiddle) +{ + const Layout layout("p"); + const RootNamespace ns{"00/fsck@cas@"}; + + auto honest_backend = std::make_shared(); + seedFiveRecordStream(*honest_backend, layout, ns); + auto honest = openRecoveryPool(honest_backend); + const FsckReport truth = runFsck(*honest, /*detail=*/true); + + auto lying_backend = std::make_shared(); + seedFiveRecordStream(*lying_backend, layout, ns); + lying_backend->setListOmissions(hiddenMiddleOf(layout, ns)); + auto lying = openRecoveryPool(lying_backend); + const FsckReport under_lie = runFsck(*lying, /*detail=*/true); + + ASSERT_GT(lying_backend->holesServed(), 0u) + << "the omission was never actually served -- the test would pass vacuously"; + + EXPECT_TRUE(truth.clean()) << "the oracle itself must be clean, or the comparison means nothing"; + EXPECT_GT(truth.ref_records_walked, 0u); + EXPECT_GT(truth.reachable, 0u) + << "the honest oracle must recover at least one live object, or reachability equality is vacuous"; + + /// The arithmetic pass: a hidden record is a non-event, and no finding is manufactured out of it. + EXPECT_TRUE(under_lie.clean()) + << "fsck must not manufacture a finding out of an omitted-but-durable record"; + EXPECT_EQ(under_lie.chain_broken, 0u) + << "a record the listing hid is NOT a broken chain: the walk reads it by exact key"; + EXPECT_EQ(under_lie.dangling, 0u); + EXPECT_EQ(under_lie.ref_records_walked, truth.ref_records_walked) + << "the arithmetic walk must read the SAME number of records under the lie"; + + EXPECT_EQ(under_lie.unchecked, 0u) + << "an omitted durable record must not turn a healthy checkpoint-bounded namespace unchecked"; + EXPECT_EQ(under_lie.reachable, truth.reachable) + << "the reachability recovery must observe the same exact committed frontier under the lie"; +} + +/// A hidden tail record is the silent variant of the historical residual: a LIST-driven replay could +/// return a plausible but short table. Checkpoint-bounded recovery must produce the honest table even +/// though the list omission is served. +TEST(CASListLiarEndToEnd, FsckReachabilityRecoveryMatchesTruthUnderAHiddenTailTransaction) +{ + const Layout layout("p"); + const RootNamespace ns{"00/fsck_tail@cas@"}; + /// Stage B (Task 4-C): no pin needed -- `seedFiveRecordStream` below calls `publishAt` (draining + /// into `writeRefLogTxnRaw`), which admits `ns` into each of the two independent backends' own + /// catalogs itself. + + auto honest_backend = std::make_shared(); + seedFiveRecordStream(*honest_backend, layout, ns); + auto honest = openRecoveryPool(honest_backend); + const FsckReport truth = runFsck(*honest, /*detail=*/true); + + auto lying_backend = std::make_shared(); + seedFiveRecordStream(*lying_backend, layout, ns); + lying_backend->setListOmissions({layout.refLogKey(fixture::fixtureLife(ns), RefTxnId{1, 5})}); + auto lying = openRecoveryPool(lying_backend); + const FsckReport under_lie = runFsck(*lying, /*detail=*/true); + + ASSERT_GT(lying_backend->holesServed(), 0u) + << "the omission was never actually served -- the test would pass vacuously"; + EXPECT_GT(truth.reachable, 0u) + << "the honest oracle must recover at least one live object, or reachability equality is vacuous"; + + /// The arithmetic pass is unmoved here too: it probes `{1,5}` by exact key and finds it. + EXPECT_EQ(under_lie.chain_broken, 0u); + EXPECT_EQ(under_lie.ref_records_walked, truth.ref_records_walked) + << "the arithmetic walk reads the hidden tail by exact key, so it counts the same records"; + + EXPECT_EQ(under_lie.unchecked, 0u) + << "a LIST omission must not make a checkpoint-bounded namespace unchecked"; + EXPECT_EQ(under_lie.reachable, truth.reachable) + << "the exact committed frontier must include the hidden tail transaction"; +} diff --git a/src/Disks/tests/gtest_cas_manifest_id.cpp b/src/Disks/tests/gtest_cas_manifest_id.cpp new file mode 100644 index 000000000000..8be89457de93 --- /dev/null +++ b/src/Disks/tests/gtest_cas_manifest_id.cpp @@ -0,0 +1,86 @@ +#include +#include +#include +#include +#include +#include + +using namespace DB::Cas; + +namespace +{ + +ManifestRef ref(uint64_t w, uint64_t seq, uint64_t m) +{ + return ManifestRef{w, seq, static_cast(m)}; +} + +ManifestId id(const char * ns, uint64_t w, uint64_t seq, uint64_t m) +{ + return ManifestId{RootNamespace(ns), ref(w, seq, m)}; +} + +} + +TEST(CASManifestId, RefEqualityAndOrdering) +{ + EXPECT_EQ(ref(1, 2, 3), ref(1, 2, 3)); + EXPECT_NE(ref(1, 2, 3), ref(1, 2, 4)); + /// Strict total order: distinct by manifest_ordinal, then build_sequence, then writer_epoch. + EXPECT_LT(ref(1, 2, 3), ref(1, 2, 4)); + EXPECT_LT(ref(1, 2, 9), ref(1, 3, 0)); + EXPECT_LT(ref(1, 9, 9), ref(2, 0, 0)); + EXPECT_FALSE(ref(1, 2, 3) < ref(1, 2, 3)); +} + +TEST(CASManifestId, IdIsNamespaceQualified) +{ + /// Same ref tuple, different namespace => DIFFERENT ids (the SabotageKeyByRefNotId guard). + EXPECT_NE(id("nsA", 1, 1, 1), id("nsB", 1, 1, 1)); + EXPECT_EQ(id("nsA", 1, 1, 1), id("nsA", 1, 1, 1)); + /// Ordering separates by namespace first. + EXPECT_LT(id("nsA", 9, 9, 9), id("nsB", 0, 0, 0)); +} + +TEST(CASManifestId, UsableAsMapAndSetKey) +{ + std::set s; + s.insert(id("nsA", 1, 1, 1)); + s.insert(id("nsB", 1, 1, 1)); /// distinct namespace -> distinct key + s.insert(id("nsA", 1, 1, 1)); /// duplicate -> no growth + EXPECT_EQ(s.size(), 2u); + + std::map m; + m[ref(1, 1, 1)] = 10; + m[ref(1, 1, 2)] = 20; + EXPECT_EQ(m.size(), 2u); + EXPECT_EQ(m[ref(1, 1, 1)], 10); +} + +TEST(CASManifestId, UsableInUnorderedContainers) +{ + /// std::hash / std::hash let the read-path cache (Phase 1c) and GC use + /// unordered_map/set. Equal values => equal hash; distinct values => (overwhelmingly) distinct. + std::unordered_set s; + s.insert(id("nsA", 1, 1, 1)); + s.insert(id("nsB", 1, 1, 1)); /// distinct namespace -> distinct key + s.insert(id("nsA", 1, 1, 1)); /// duplicate -> no growth + EXPECT_EQ(s.size(), 2u); + + std::unordered_map m; + m[ref(1, 1, 1)] = 10; + m[ref(1, 1, 1)] = 11; /// same key overwrites + m[ref(1, 1, 2)] = 20; + EXPECT_EQ(m.size(), 2u); + EXPECT_EQ(m.at(ref(1, 1, 1)), 11); + + EXPECT_EQ(std::hash{}(id("nsA", 1, 1, 1)), std::hash{}(id("nsA", 1, 1, 1))); +} + +TEST(CASManifestId, ManifestOrdinalFileName) +{ + EXPECT_EQ(manifestOrdinalFileName(1), "000001.zst"); + EXPECT_EQ(manifestOrdinalFileName(999999), "999999.zst"); + EXPECT_THROW(manifestOrdinalFileName(0), DB::Exception); + EXPECT_THROW(manifestOrdinalFileName(1000000), DB::Exception); +} diff --git a/src/Disks/tests/gtest_cas_mount.cpp b/src/Disks/tests/gtest_cas_mount.cpp new file mode 100644 index 000000000000..0d61f91206e8 --- /dev/null +++ b/src/Disks/tests/gtest_cas_mount.cpp @@ -0,0 +1,1602 @@ +#include +#include "cas_test_helpers.h" +#include +#include +#include + +#include +#include +#include +#include + +namespace DB::ErrorCodes +{ + extern const int BAD_ARGUMENTS; + extern const int CORRUPTED_DATA; + extern const int FILE_DOESNT_EXIST; + extern const int LOGICAL_ERROR; +} + +namespace ProfileEvents +{ + extern const Event CASMountLeaseLost; + extern const Event CASMountExclusivityViolation; +} + +using namespace DB::Cas; + +namespace +{ + +const ObserveRefCatalog & emptyCatalogObservation() +{ + static const ObserveRefCatalog observe = [] { return RefCatalog{}; }; + return observe; +} + +RefCatalog catalogOwning(const String & ns, NsState state) +{ + CatalogEntry entry{.ns = RootNamespace{ns}, .state = state, .incarnation = UInt128{42}}; + if (state == NsState::Creating) + entry.creator = CreatorFence{.server_root_id = "root/x", .writer_epoch = 1, .fence_generation = 1}; + return RefCatalog{.entries = {std::move(entry)}}; +} + +class OwnerConflictRevealsManifestBackend : public InMemoryBackend +{ +public: + using InMemoryBackend::putIfAbsent; + + PutResult putIfAbsent(const String & key, const String & bytes, const ObjectMeta & meta) override + { + if (!fired && key == "p/gc/server-roots/root/x/owner") + { + fired = true; + InMemoryBackend::putIfAbsent("p/cas/manifests/root/x/table/debris", "x"); + return {PutOutcome::PreconditionFailed, {}}; + } + return InMemoryBackend::putIfAbsent(key, bytes, meta); + } + + bool fired = false; +}; + +class EpochConflictRevealsManifestBackend : public InMemoryBackend +{ +public: + using InMemoryBackend::casPut; + + CasResult casPut( + const String & key, const String & bytes, const std::optional & expected, + const ObjectMeta & meta) override + { + if (!fired && key == "p/gc/server-roots/root/x/epoch") + { + fired = true; + /// Install the competing allocator's winning epoch before revealing owned work. The + /// retry must not accept that now-present epoch without rechecking the entire emptiness + /// bundle that authorized the original absent-epoch attempt. + const CasResult winner = InMemoryBackend::casPut( + key, encodeServerEpoch(ServerEpoch{.next_writer_epoch = 2}), expected, meta); + winner_installed = winner.outcome == CasOutcome::Committed; + InMemoryBackend::putIfAbsent("p/cas/manifests/root/x/table/debris", "x"); + return {CasOutcome::Conflict, {}}; + } + return InMemoryBackend::casPut(key, bytes, expected, meta); + } + + bool fired = false; + bool winner_installed = false; +}; + +} + +TEST(CASServerRootId, ValidationAcceptsCleanPathsRejectsBad) +{ + EXPECT_NO_THROW(validateServerRootId("replica-a")); + EXPECT_NO_THROW(validateServerRootId("shard-01/replica-a")); + EXPECT_THROW(validateServerRootId(""), DB::Exception); + EXPECT_THROW(validateServerRootId("/replica"), DB::Exception); + EXPECT_THROW(validateServerRootId("replica/"), DB::Exception); + EXPECT_THROW(validateServerRootId("a//b"), DB::Exception); + EXPECT_THROW(validateServerRootId("a/../b"), DB::Exception); + EXPECT_THROW(validateServerRootId("a/_files/b"), DB::Exception); +} + +TEST(CASServerRoot, KeysAndCodecsRoundTrip) +{ + Layout layout("p"); + + /// Layout keys under gc/server-roots//. + EXPECT_EQ(layout.serverRootPrefix("replica-a"), "p/gc/server-roots/replica-a/"); + EXPECT_EQ(layout.ownerKey("replica-a"), "p/gc/server-roots/replica-a/owner"); + EXPECT_EQ(layout.epochKey("replica-a"), "p/gc/server-roots/replica-a/epoch"); + EXPECT_EQ(layout.mountKey("replica-a"), "p/gc/server-roots/replica-a/mount"); + + /// Owner round-trip. + { + OwnerObject o; + o.server_uuid = (UInt128(0x0123456789abcdefULL) << 64) | UInt128(0xfedcba9876543210ULL); + const OwnerObject back = decodeOwner(encodeOwner(o)); + EXPECT_EQ(back.server_uuid, o.server_uuid); + } + + /// ServerEpoch round-trip. + { + ServerEpoch e; + e.next_writer_epoch = 4242; + const ServerEpoch back = decodeServerEpoch(encodeServerEpoch(e)); + EXPECT_EQ(back.next_writer_epoch, e.next_writer_epoch); + } + + /// MountLease round-trip. + { + MountLease m; + m.server_uuid = (UInt128(0xdeadbeefcafef00dULL) << 64) | UInt128(0x0011223344556677ULL); + m.writer_epoch = 7; + m.hostname = "host-1.example.com"; + m.pid = 12345; + m.started_at_ms = 1700000000000ULL; + m.seq = 99; + m.expires_at_ms = 1700000030000ULL; + const MountLease back = decodeMountLease(encodeMountLease(m)); + EXPECT_EQ(back.server_uuid, m.server_uuid); + EXPECT_EQ(back.writer_epoch, m.writer_epoch); + EXPECT_EQ(back.hostname, m.hostname); + EXPECT_EQ(back.pid, m.pid); + EXPECT_EQ(back.started_at_ms, m.started_at_ms); + EXPECT_EQ(back.seq, m.seq); + EXPECT_EQ(back.expires_at_ms, m.expires_at_ms); + } + + /// Fail-closed decode on garbage bytes. + EXPECT_THROW(decodeOwner("not-a-proto-with-magic"), DB::Exception); + EXPECT_THROW(decodeServerEpoch(""), DB::Exception); + EXPECT_THROW(decodeMountLease(""), DB::Exception); +} + +TEST(CASServerRootClaim, OwnerStickyAndForeignFailsClosed) +{ + auto b = std::make_shared(); + Layout l("p"); + EXPECT_NO_THROW(claimOwnerOrThrow(*b, l, "r", UInt128(1), emptyCatalogObservation())); // fresh empty root → claim + EXPECT_NO_THROW(claimOwnerOrThrow(*b, l, "r", UInt128(1), emptyCatalogObservation())); // same uuid → ok + EXPECT_THROW(claimOwnerOrThrow(*b, l, "r", UInt128(2), emptyCatalogObservation()), DB::Exception); // foreign → fail closed +} + +TEST(CASServerRootClaim, TombstonedSameOwnerFailsClosed) +{ + auto b = std::make_shared(); + Layout l("p"); + b->putIfAbsent(l.ownerKey("r"), encodeOwner(OwnerObject{ + .server_uuid = UInt128(1), + .retired_at_ms = 1752537600000ULL, + })); + + try + { + claimOwnerOrThrow(*b, l, "r", UInt128(1), emptyCatalogObservation()); + FAIL() << "expected a tombstoned owner claim to fail closed"; + } + catch (const DB::Exception & e) + { + EXPECT_EQ(e.code(), DB::ErrorCodes::CORRUPTED_DATA); + EXPECT_NE(e.message().find("decommissioned"), String::npos) << e.message(); + EXPECT_EQ(e.message().find("owned by a different server"), String::npos) << e.message(); + } +} + +TEST(CASServerRootEpoch, AllocatorIsMonotoneAndSurvivesMountConcept) +{ + auto b = std::make_shared(); + Layout l("r"); + claimOwnerOrThrow(*b, l, "r", UInt128(1), emptyCatalogObservation()); + const uint64_t e1 = allocateWriterEpoch(*b, l, "r", EpochMintPolicy::NormalMount, 0, emptyCatalogObservation()); + const uint64_t e2 = allocateWriterEpoch(*b, l, "r", EpochMintPolicy::NormalMount, 0, emptyCatalogObservation()); + EXPECT_GE(e1, 1u); // 0 is a reserved sentinel + EXPECT_GT(e2, e1); // strictly increasing + + /// Deleting the (separate) mount object must NOT reset the epoch. No mount has been written in + /// Task 4, so deleteExact of a non-existent mount is a NotFound no-op that touches nothing. + const auto del = b->deleteExact(l.mountKey("r"), b->head(l.mountKey("r")).token); + EXPECT_EQ(del.kind, DeleteOutcome::Kind::NotFound); + EXPECT_GT(allocateWriterEpoch(*b, l, "r", EpochMintPolicy::NormalMount, 0, emptyCatalogObservation()), e2); +} + +/// Phase C (spec rev.4): an ABSENT epoch object over a PRESENT mount object means durable epoch +/// state was lost while a mount is live/recent — re-minting epoch 1 there is how a same-(uuid, +/// epoch) twin is born. Refuse. +TEST(CASMount, EpochRemintOverExistingMountRefuses) +{ + auto b = std::make_shared(); + Layout l("p"); + claimOwnerOrThrow(*b, l, "r", UInt128(1), emptyCatalogObservation()); + ASSERT_EQ(claimMount(*b, l, "r", UInt128(1), /*our_epoch=*/1, /*now_ms=*/1000, /*ttl_ms=*/30000).kind, + MountClaimResult::Claimed); + /// The epoch object is ABSENT (never created in this sequence) while the mount exists: + EXPECT_THROW(allocateWriterEpoch(*b, l, "r", EpochMintPolicy::NormalMount, 0, emptyCatalogObservation()), DB::Exception); /// CORRUPTED_DATA +} + +TEST(CASMount, EpochRemintAuthoritativeAbsenceMints) +{ + auto b = std::make_shared(); + Layout l("p"); + claimOwnerOrThrow(*b, l, "r", UInt128(1), emptyCatalogObservation()); + EXPECT_EQ(allocateWriterEpoch(*b, l, "r", EpochMintPolicy::NormalMount, 0, emptyCatalogObservation()), 1u); /// fresh root: both control objects absent + EXPECT_EQ(allocateWriterEpoch(*b, l, "r", EpochMintPolicy::NormalMount, 0, emptyCatalogObservation()), 2u); /// epoch present now: normal CAS bump, no probe +} + +/// The probe outcome gates the mint: anything short of authoritative KeyAbsent fails closed. +TEST(CASMount, EpochRemintIndeterminateProbeFailsClosed) +{ + class IndeterminateProbeBackend final : public InMemoryBackend + { + public: + SentinelProbeResult probeSentinelRaw(const String &) override + { + return {.outcome = ProbeOutcome::Indeterminate, .body = std::nullopt}; + } + }; + auto b = std::make_shared(); + Layout l("p"); + claimOwnerOrThrow(*b, l, "r", UInt128(1), emptyCatalogObservation()); + EXPECT_THROW(allocateWriterEpoch(*b, l, "r", EpochMintPolicy::NormalMount, 0, emptyCatalogObservation()), DB::Exception); +} + +/// Decommission over a TERMINAL (expired/fenced) mount with a lost epoch object proceeds and mints +/// an epoch DISTINCT from the surviving mount's — the same-pair state is unrepresentable. +TEST(CASMount, DecommissionRemintOverTerminalMountMintsDistinctEpoch) +{ + auto b = std::make_shared(); + Layout l("p"); + claimOwnerOrThrow(*b, l, "r", UInt128(1), emptyCatalogObservation()); + ASSERT_EQ(claimMount(*b, l, "r", UInt128(1), /*our_epoch=*/3, /*now_ms=*/1000, /*ttl_ms=*/100).kind, + MountClaimResult::Claimed); + /// now_ms=5000: the ttl_ms=100 lease above is long expired -> terminal. + EXPECT_EQ(allocateWriterEpoch(*b, l, "r", EpochMintPolicy::DecommissionRecovery, /*now_ms=*/5000, emptyCatalogObservation()), 4u); +} + +/// Decommission over a LIVE mount with a lost epoch refuses — the blind bypass would recreate the +/// forbidden pair (codex round-3 finding 1) and defeat CASDecommission.RefusesLiveMember. +TEST(CASMount, DecommissionRemintOverLiveMountRefuses) +{ + auto b = std::make_shared(); + Layout l("p"); + claimOwnerOrThrow(*b, l, "r", UInt128(1), emptyCatalogObservation()); + ASSERT_EQ(claimMount(*b, l, "r", UInt128(1), /*our_epoch=*/1, /*now_ms=*/1000, /*ttl_ms=*/30000).kind, + MountClaimResult::Claimed); + EXPECT_THROW(allocateWriterEpoch(*b, l, "r", EpochMintPolicy::DecommissionRecovery, /*now_ms=*/2000, emptyCatalogObservation()), + DB::Exception); /// ABORTED: live member +} + +/// The steady-state path (epoch object PRESENT) must never pay the probe — pins the zero +/// normal-path cost the spec claims. +TEST(CASMount, EpochBumpWithPresentEpochIssuesNoProbe) +{ + class ProbeCountingBackend final : public InMemoryBackend + { + public: + int probes = 0; + SentinelProbeResult probeSentinelRaw(const String & k) override + { + ++probes; + return InMemoryBackend::probeSentinelRaw(k); + } + }; + auto b = std::make_shared(); + Layout l("p"); + claimOwnerOrThrow(*b, l, "r", UInt128(1), emptyCatalogObservation()); + EXPECT_EQ(allocateWriterEpoch(*b, l, "r", EpochMintPolicy::NormalMount, 0, emptyCatalogObservation()), 1u); /// bootstrap: ONE probe (absent-epoch branch) + const int probes_after_bootstrap = b->probes; + EXPECT_EQ(allocateWriterEpoch(*b, l, "r", EpochMintPolicy::NormalMount, 0, emptyCatalogObservation()), 2u); /// epoch present: normal CAS bump... + EXPECT_EQ(b->probes, probes_after_bootstrap) << "...must not probe the mount key"; +} + +TEST(CASServerRootClaim, MissingOwnerOverNonEmptyRootIsCorrupted) +{ + auto b = std::make_shared(); + Layout l("p"); + /// Simulate existing data without an owner (identity lost): plant a key under roots//. + b->putIfAbsent(l.serverRootDataPrefix("r") + "some-data", "x"); + EXPECT_THROW(claimOwnerOrThrow(*b, l, "r", UInt128(1), emptyCatalogObservation()), DB::Exception); +} + +TEST(CASServerRootSafety, EveryCatalogLifecycleStateBlocksOwnerAndEpochRecreation) +{ + const Layout layout("p"); + for (const NsState state : {NsState::Creating, NsState::Live, NsState::Removing}) + { + RefCatalog catalog = catalogOwning("root/x/table", state); + const ObserveRefCatalog observe = [catalog] { return catalog; }; + + InMemoryBackend owner_backend; + EXPECT_THROW(claimOwnerOrThrow(owner_backend, layout, "root/x", UInt128{1}, observe), DB::Exception); + EXPECT_FALSE(owner_backend.head(layout.ownerKey("root/x")).exists); + + InMemoryBackend epoch_backend; + EXPECT_THROW(allocateWriterEpoch( + epoch_backend, layout, "root/x", EpochMintPolicy::NormalMount, 0, observe), DB::Exception); + EXPECT_FALSE(epoch_backend.head(layout.epochKey("root/x")).exists); + } +} + +TEST(CASServerRootSafety, OwnershipUsesAPathComponentBoundary) +{ + InMemoryBackend backend; + const Layout layout("p"); + EXPECT_TRUE(serverRootSubtreeEmpty( + backend, layout, "root/x", catalogOwning("root/xy/table", NsState::Live))); + EXPECT_FALSE(serverRootSubtreeEmpty( + backend, layout, "root/x", catalogOwning("root/x/table", NsState::Live))); +} + +TEST(CASServerRootSafety, OpaqueStreamAndStateDebrisAloneDoesNotBlockRecreation) +{ + InMemoryBackend backend; + const Layout layout("p"); + const NamespaceLifeId dead = NamespaceLifeId::fromCatalogEntry(RootNamespace{"unowned"}, UInt128{99}); + ASSERT_EQ(backend.putIfAbsent(layout.refLogKey(dead, RefTxnId{1, 1}), "debris").outcome, PutOutcome::Done); + ASSERT_EQ(backend.putIfAbsent(layout.refCkptKey(dead), "debris").outcome, PutOutcome::Done); + ASSERT_EQ(backend.putIfAbsent(layout.namespaceFileKey(dead, "f"), "debris").outcome, PutOutcome::Done); + + EXPECT_NO_THROW(claimOwnerOrThrow(backend, layout, "root/x", UInt128{1}, emptyCatalogObservation())); + EXPECT_EQ(allocateWriterEpoch( + backend, layout, "root/x", EpochMintPolicy::NormalMount, 0, emptyCatalogObservation()), 1u); +} + +TEST(CASServerRootSafety, ManifestAndLooseRootDebrisStillBlockRecreation) +{ + const Layout layout("p"); + for (const String & key : { + layout.casManifestsServerPrefix("root/x") + "table/debris", + layout.serverRootDataPrefix("root/x") + "loose"}) + { + InMemoryBackend backend; + ASSERT_EQ(backend.putIfAbsent(key, "x").outcome, PutOutcome::Done); + EXPECT_THROW(claimOwnerOrThrow( + backend, layout, "root/x", UInt128{1}, emptyCatalogObservation()), DB::Exception); + EXPECT_THROW(allocateWriterEpoch( + backend, layout, "root/x", EpochMintPolicy::NormalMount, 0, emptyCatalogObservation()), DB::Exception); + } +} + +TEST(CASServerRootSafety, UnreadableCatalogNeverFallsBackToPhysicalGuesses) +{ + InMemoryBackend backend; + const Layout layout("p"); + const ObserveRefCatalog unreadable = []() -> RefCatalog + { + throw DB::Exception(DB::ErrorCodes::CORRUPTED_DATA, "injected unreadable catalog"); + }; + EXPECT_THROW(claimOwnerOrThrow(backend, layout, "root/x", UInt128{1}, unreadable), DB::Exception); + EXPECT_THROW(allocateWriterEpoch( + backend, layout, "root/x", EpochMintPolicy::NormalMount, 0, unreadable), DB::Exception); + EXPECT_FALSE(backend.head(layout.ownerKey("root/x")).exists); + EXPECT_FALSE(backend.head(layout.epochKey("root/x")).exists); +} + +TEST(CASServerRootSafety, OwnerConflictRecomputesTheWholeEmptinessBundle) +{ + OwnerConflictRevealsManifestBackend backend; + const Layout layout("p"); + EXPECT_THROW(claimOwnerOrThrow( + backend, layout, "root/x", UInt128{1}, emptyCatalogObservation()), DB::Exception); + EXPECT_TRUE(backend.fired); + EXPECT_FALSE(backend.head(layout.ownerKey("root/x")).exists); +} + +TEST(CASServerRootSafety, EpochConflictRecomputesTheWholeEmptinessBundle) +{ + EpochConflictRevealsManifestBackend backend; + const Layout layout("p"); + EXPECT_THROW(allocateWriterEpoch( + backend, layout, "root/x", EpochMintPolicy::NormalMount, 0, emptyCatalogObservation()), DB::Exception); + EXPECT_TRUE(backend.fired); + ASSERT_TRUE(backend.winner_installed); + const auto epoch = backend.get(layout.epochKey("root/x")); + ASSERT_TRUE(epoch.has_value()); + EXPECT_EQ(decodeServerEpoch(epoch->bytes).next_writer_epoch, 2u) + << "the rejected allocator must not consume an epoch from the conflict winner"; +} + +TEST(CASMountLease, AbsentClaimThenRenewBumpsSeq) +{ + auto b = std::make_shared(); + Layout l("p"); + uint64_t now = 1000; + auto r = claimMount(*b, l, "r", UInt128(1), /*epoch*/ 7, now, /*ttl*/ 100); + EXPECT_EQ(r.kind, MountClaimResult::Claimed); + MountLeaseKeeper k(b, l, "r", UInt128(1), 7, std::chrono::milliseconds(100), [&] { return now; }, + [] { return uint64_t{0}; }); + k.start(); + EXPECT_EQ(decodeMountLease(b->get(l.mountKey("r"))->bytes).seq, 1u); + k.renewOnce(); + EXPECT_EQ(decodeMountLease(b->get(l.mountKey("r"))->bytes).seq, 2u); +} + +/// STID 3982-3b48: `rm -rf` of the pool dir under a live mount deletes the mount slot object out from +/// under a running keeper. The next background renewal must fail closed (stop renewing, latch the +/// write fence to lost) WITHOUT constructing a `LOGICAL_ERROR` -- that aborts debug/ASan builds at +/// exception construction, and there is no foreign writer here to fail closed against, only an +/// environmental condition. +TEST(CASMountLease, VanishedBackingStoreStopsRenewalWithoutLogicalError) +{ + auto b = std::make_shared(); + Layout l("p"); + uint64_t now = 1000; + ASSERT_EQ(claimMount(*b, l, "r", UInt128(1), /*epoch*/ 7, now, /*ttl*/ 100).kind, MountClaimResult::Claimed); + MountLeaseKeeper k(b, l, "r", UInt128(1), 7, std::chrono::milliseconds(100), [&] { return now; }, + [] { return uint64_t{0}; }); + k.start(); + + const String mount_key = l.mountKey("r"); + const auto lost_before = ProfileEvents::global_counters[ProfileEvents::CASMountLeaseLost].load(); /// NOLINT(clang-analyzer-deadcode.DeadStores) + + /// Simulate `rm -rf` of the backing store: the mount slot object is gone, but the keeper still + /// holds a (now stale) token for it. + ASSERT_EQ(b->deleteExact(mount_key, b->head(mount_key).token).kind, DeleteOutcome::Kind::Deleted); + + try + { + k.renewOnce(); + FAIL() << "renew against a vanished mount object must throw"; + } + catch (const DB::Exception & e) + { + EXPECT_EQ(e.code(), DB::ErrorCodes::FILE_DOESNT_EXIST) << e.message(); + EXPECT_NE(e.code(), DB::ErrorCodes::LOGICAL_ERROR); + } + EXPECT_EQ(ProfileEvents::global_counters[ProfileEvents::CASMountLeaseLost].load(), lost_before + 1); +} + +/// STID 3982-3b48 (part 1b): the terminal/clean-release counterpart to the renewal fix above. When +/// the backing store vanishes (`rm -rf` of the pool dir), the renewal side already stops non-fatally +/// (see the previous test); teardown then runs the terminal release (`stop()` -> `terminate()`), +/// which used to unconditionally throw `LOGICAL_ERROR` once the token-guarded farewell PUT observed +/// an absent object. The desired end state of a release ("no live lease object") is already true, so +/// this must be a no-op, never a `LOGICAL_ERROR` (which aborts debug/ASan builds). +/// +/// Driven WITHOUT a prior failed renew, so the count is deterministic: this is the only place along +/// this path that increments `CASMountLeaseLost`, so we expect exactly +1 (not +2, since renewal was +/// never invoked here). +TEST(CASMountLease, TerminateAfterVanishedBackingStoreIsNoOpRelease) +{ + auto b = std::make_shared(); + Layout l("p"); + uint64_t now = 1000; + ASSERT_EQ(claimMount(*b, l, "r", UInt128(1), /*epoch*/ 7, now, /*ttl*/ 100).kind, MountClaimResult::Claimed); + MountLeaseKeeper k(b, l, "r", UInt128(1), 7, std::chrono::milliseconds(100), [&] { return now; }, + [] { return uint64_t{0}; }); + k.start(); + + const String mount_key = l.mountKey("r"); + const auto lost_before = ProfileEvents::global_counters[ProfileEvents::CASMountLeaseLost].load(); + + /// Simulate `rm -rf` of the backing store: the mount slot object is gone before we ever attempt + /// a renewal, so `terminate()`'s token-guarded farewell PUT is the first thing to observe it. + ASSERT_EQ(b->deleteExact(mount_key, b->head(mount_key).token).kind, DeleteOutcome::Kind::Deleted); + + EXPECT_NO_THROW(k.stop()) + << "clean release against a vanished store must be a no-op, not a LOGICAL_ERROR abort"; + EXPECT_EQ(ProfileEvents::global_counters[ProfileEvents::CASMountLeaseLost].load(), lost_before + 1); +} + +/// rev.6: a bare `claimMount` (no `proven_dead_token`) NEVER reclaims a same-uuid, different-epoch +/// lease off a wall-clock-looking-expired stamp — only `claimMountAwaitingExpiry`'s observation loop +/// can turn that into a reclaim. Renamed from `...ExpiredReclaims` to describe the corrected behavior. +TEST(CASMountLease, SameUuidLiveFailsForeignFailsExpiredStillLiveDoubleStart) +{ + auto b = std::make_shared(); + Layout l("p"); + claimMount(*b, l, "r", UInt128(1), 7, /*now*/ 1000, /*ttl*/ 100); // A live until 1100 + // same uuid, lease still live → double-start guard: + EXPECT_EQ(claimMount(*b, l, "r", UInt128(1), 8, 1050, 100).kind, MountClaimResult::LiveDoubleStart); + // foreign uuid, even after expiry → fail closed: + EXPECT_EQ(claimMount(*b, l, "r", UInt128(2), 1, 1200, 100).kind, MountClaimResult::ForeignOwner); + // same uuid, even after the stamp LOOKS expired on our wall clock → still LiveDoubleStart: no + // proven_dead_token was supplied, so there is no certificate of death to reclaim on. + EXPECT_EQ(claimMount(*b, l, "r", UInt128(1), 9, 1200, 100).kind, MountClaimResult::LiveDoubleStart); +} + +TEST(CASMountMessage, DoubleStartTextHasIdentityAndRemediation) +{ + MountLease m; + m.server_uuid = (UInt128(0xdeadbeefcafef00dULL) << 64) | UInt128(0x0011223344556677ULL); + m.writer_epoch = 7; + m.hostname = "host-9.example.com"; + m.pid = 4242; + m.seq = 13; + m.expires_at_ms = 1700000030000ULL; + + const std::string msg = mountDoubleStartMessage("replica-a", m); + + /// Identity / existing-holder fields. + EXPECT_NE(msg.find("server_root_id"), std::string::npos); + EXPECT_NE(msg.find("'replica-a'"), std::string::npos); + EXPECT_NE(msg.find("hostname=host-9.example.com"), std::string::npos); + EXPECT_NE(msg.find("pid=4242"), std::string::npos); + EXPECT_NE(msg.find("last_seq=13"), std::string::npos); + EXPECT_NE(msg.find("expires_at_ms=1700000030000"), std::string::npos); + /// New wait-aware remediation (this server already waited; the lease kept being renewed). + EXPECT_NE(msg.find("waited"), std::string::npos); + EXPECT_NE(msg.find("unique"), std::string::npos); + EXPECT_NE(msg.find("reclaim the mount on restart"), std::string::npos); + EXPECT_NE(msg.find("uuid file"), std::string::npos); + /// Clock-skew caveat + manual mount-object delete escape hatch. + EXPECT_NE(msg.find("CLOCK SKEW"), std::string::npos); + EXPECT_NE(msg.find("NTP"), std::string::npos); + EXPECT_NE(msg.find("manually delete the mount"), std::string::npos); + EXPECT_NE(msg.find("gc/server-roots/replica-a/mount"), std::string::npos); +} + +/// rev.6: a stamped `expires_at_ms` that already looks past-due on our wall clock must NOT shortcut +/// the observation wait — the old "instant, zero-sleep" reclaim this test name described was exactly +/// the cross-node wall-clock trust rev.6 removes. Renamed to describe the CORRECTED behavior: the +/// wall-clock-looking-expired stamp buys nothing, the full threshold is still observed. +TEST(CASMountAwaitExpiry, PastExpiryStillPaysTheFullObservationThreshold) +{ + auto b = std::make_shared(); + Layout l("p"); + /// A prior incarnation (uuid=1, epoch=7) claimed a lease live until 1100. + ASSERT_EQ(claimMount(*b, l, "r", UInt128(1), 7, /*now*/ 1000, /*ttl*/ 100).kind, MountClaimResult::Claimed); + + uint64_t wall = 1200; // already past 1100 on wall clock — irrelevant to the decision + uint64_t mono = 0; + int sleeps = 0; + auto now_fn = [&] { return wall; }; + auto mono_fn = [&] { return mono; }; + auto sleep_fn = [&](uint64_t ms) { wall += ms; mono += ms; ++sleeps; }; + + const auto r = claimMountAwaitingExpiry( + *b, l, "r", UInt128(1), /*our_epoch*/ 8, now_fn, mono_fn, /*ttl*/ 100, /*poll*/ 25, sleep_fn); + EXPECT_EQ(r.kind, MountClaimResult::Claimed); + EXPECT_GT(sleeps, 0); // NOT instant — no wall-clock trust + EXPECT_GE(mono, 100 + 100 / 20 + 25); // full observation threshold paid + EXPECT_EQ(decodeMountLease(b->get(l.mountKey("r"))->bytes).writer_epoch, 8u); // reclaimed as us +} + +TEST(CASMountAwaitExpiry, FutureExpiryReclaimsAfterClockAdvances) +{ + auto b = std::make_shared(); + Layout l("p"); + ASSERT_EQ(claimMount(*b, l, "r", UInt128(1), 7, /*now*/ 1000, /*ttl*/ 100).kind, MountClaimResult::Claimed); + + uint64_t wall = 1000; // lease looks live until 1100, holder does NOT renew + uint64_t mono = 0; + auto now_fn = [&] { return wall; }; + auto mono_fn = [&] { return mono; }; + auto sleep_fn = [&](uint64_t ms) { wall += ms; mono += ms; }; + + const auto r = claimMountAwaitingExpiry( + *b, l, "r", UInt128(1), /*our_epoch*/ 8, now_fn, mono_fn, /*ttl*/ 100, /*poll*/ 50, sleep_fn); + EXPECT_EQ(r.kind, MountClaimResult::Claimed); + const auto body = decodeMountLease(b->get(l.mountKey("r"))->bytes); + EXPECT_EQ(body.writer_epoch, 8u); + EXPECT_EQ(body.seq, 2u); // reclaim continues seq (prev 1 + 1) +} + +/// rev.6: a genuinely live twin now times out via BOUNDED OBSERVATION RESTARTS (its every renewal +/// bumps the write-token, forcing a restart each poll), never via a wall-clock deadline. +TEST(CASMountAwaitExpiry, LiveRenewingTwinTimesOutAsDoubleStart) +{ + auto b = std::make_shared(); + Layout l("p"); + ASSERT_EQ(claimMount(*b, l, "r", UInt128(1), 7, /*now*/ 1000, /*ttl*/ 100).kind, MountClaimResult::Claimed); + + uint64_t wall = 1000; + uint64_t mono = 0; + auto now_fn = [&] { return wall; }; + auto mono_fn = [&] { return mono; }; + /// Each poll: both clocks advance AND the live holder (uuid=1, epoch=7) renews its own lease — + /// the observed write-token changes on EVERY poll, forcing a restart every time. + auto sleep_fn = [&](uint64_t ms) + { + wall += ms; + mono += ms; + ASSERT_EQ(claimMount(*b, l, "r", UInt128(1), 7, wall, 100).kind, MountClaimResult::Claimed); + }; + + const auto r = claimMountAwaitingExpiry( + *b, l, "r", UInt128(1), /*our_epoch*/ 8, now_fn, mono_fn, /*ttl*/ 100, /*poll*/ 20, sleep_fn); + EXPECT_EQ(r.kind, MountClaimResult::LiveDoubleStart); + EXPECT_EQ(decodeMountLease(b->get(l.mountKey("r"))->bytes).writer_epoch, 7u); // still the holder's +} + +namespace +{ +/// fix-round F5 harness: makes the mount key vanish to EVERY `get()`, unconditionally, while the real +/// underlying object stays put -- forcing `claimMount`'s own internal GET to take the absent-slot race +/// branch every call (its `putIfAbsent` then fails against the real, still-present object, returning +/// `LiveDoubleStart` with no token -- fix-round F8 leaves `.token` unset on exactly this branch, since +/// no re-read was done). That in turn forces `claimMountAwaitingExpiry`'s F8 fallback re-GET, which +/// ALSO sees the slot as vanished -- deterministically reproducing "the slot vanished between +/// claimMount's own GET and ours" on EVERY loop iteration, not just a lucky one-shot race. +class AlwaysVanishesBackend final : public DB::Cas::Backend +{ +public: + explicit AlwaysVanishesBackend(std::shared_ptr inner_) : inner(std::move(inner_)) {} + String watched_key; + + std::optional get(const String & k, DB::Cas::Range r) override + { + if (k == watched_key) + return std::nullopt; + return inner->get(k, r); + } + std::optional getStream(const String & k, DB::Cas::Range r) override { return inner->getStream(k, r); } + DB::Cas::HeadResult head(const String & k) override { return inner->head(k); } + DB::Cas::ListPage list(const String & p, const String & c, size_t l) override { return inner->list(p, c, l); } + DB::Cas::PutResult putIfAbsent(const String & k, const String & b, const DB::Cas::ObjectMeta & m) override { return inner->putIfAbsent(k, b, m); } + DB::Cas::WriteSinkPtr putIfAbsentStream(const String & k, const DB::Cas::ObjectMeta & m) override { return inner->putIfAbsentStream(k, m); } + DB::Cas::PutResult putOverwrite(const String & k, const String & b, const DB::Cas::Token & e, const DB::Cas::ObjectMeta & m) override { return inner->putOverwrite(k, b, e, m); } + DB::Cas::CasResult casPut(const String & k, const String & b, const std::optional & e, const DB::Cas::ObjectMeta & m) override { return inner->casPut(k, b, e, m); } + DB::Cas::DeleteOutcome deleteExact(const String & k, const DB::Cas::Token & t) override { return inner->deleteExact(k, t); } + bool supportsListTokens() const override { return inner->supportsListTokens(); } + +private: + std::shared_ptr inner; +}; +} + +/// fix-round F5 (author-review: `!got -> continue` in the observation loop, with no sleep and outside +/// the restart limit, spins `get`/`claimMount`/`put` at backend RTT under persistent slot churn). A +/// backend that makes the mount slot look vanished to every GET must still terminate (bounded restarts, +/// not an infinite loop) AND must pace itself (the injected `sleep_fn` must actually fire) rather than +/// busy-spin. +TEST(CASMountAwaitExpiry, PersistentSlotVanishPacesAndBoundsRestartsInsteadOfSpinning) +{ + auto inner = std::make_shared(); + Layout l("p"); + /// A real slot exists underneath (uuid 1, epoch 7) so `claimMount`'s absent-slot `putIfAbsent` + /// genuinely fails every time (never accidentally re-mints). + ASSERT_EQ(claimMount(*inner, l, "r", UInt128(1), 7, /*now*/ 1000, /*ttl*/ 100).kind, MountClaimResult::Claimed); + + auto vanishing = std::make_shared(inner); + vanishing->watched_key = l.mountKey("r"); + + uint64_t wall = 1000; + uint64_t mono = 0; + int sleeps = 0; + auto now_fn = [&] { return wall; }; + auto mono_fn = [&] { return mono; }; + auto sleep_fn = [&](uint64_t ms) { wall += ms; mono += ms; ++sleeps; }; + + const auto r = claimMountAwaitingExpiry( + *vanishing, l, "r", UInt128(1), /*our_epoch*/ 8, now_fn, mono_fn, /*ttl*/ 100, /*poll*/ 20, sleep_fn); + EXPECT_EQ(r.kind, MountClaimResult::LiveDoubleStart) << "must terminate (bounded), not loop forever"; + EXPECT_GT(sleeps, 0) << "a persistently vanishing slot must still pace via sleep_fn, not busy-spin"; + /// The real epoch-7 lease is untouched -- every `putIfAbsent` attempt against it genuinely fails + /// (the object is still there), so it is never accidentally re-minted over. + EXPECT_EQ(decodeMountLease(inner->get(l.mountKey("r"))->bytes).writer_epoch, 7u); +} + +TEST(CASMountAwaitExpiry, ForeignUuidFailsClosedImmediately) +{ + auto b = std::make_shared(); + Layout l("p"); + /// A foreign server (uuid=2) holds the mount. + ASSERT_EQ(claimMount(*b, l, "r", UInt128(2), 1, /*now*/ 1000, /*ttl*/ 100).kind, MountClaimResult::Claimed); + + uint64_t now = 1000; + int sleeps = 0; + auto now_fn = [&] { return now; }; + auto mono_fn = [&] { return uint64_t{0}; }; + auto sleep_fn = [&](uint64_t ms) { now += ms; ++sleeps; }; + + const auto r = claimMountAwaitingExpiry( + *b, l, "r", UInt128(1), /*our_epoch*/ 8, now_fn, mono_fn, /*ttl*/ 100, /*poll*/ 25, sleep_fn); + EXPECT_EQ(r.kind, MountClaimResult::ForeignOwner); + EXPECT_EQ(sleeps, 0); // never waits across UUIDs +} + +/// rev.6: the predecessor's own stamped `expires_at_ms` (however skewed) is NEVER consulted for the +/// reclaim decision any more — the wait is bounded purely by OUR OWN `ttl_ms`-derived threshold. A +/// prior incarnation minted with an absurdly large `ttl` (so its own stamp claims aliveness for +/// ~100000ms) still reclaims within the SAME small threshold as any other case, because that stamp is +/// never read for timing. +TEST(CASMountAwaitExpiry, SkewedFarFutureExpiryHasNoEffectOnObservationThreshold) +{ + auto b = std::make_shared(); + Layout l("p"); + ASSERT_EQ(claimMount(*b, l, "r", UInt128(1), 7, /*now*/ 1000, /*ttl*/ 100000).kind, MountClaimResult::Claimed); + + uint64_t wall = 1000; + uint64_t mono = 0; + auto now_fn = [&] { return wall; }; + auto mono_fn = [&] { return mono; }; + auto sleep_fn = [&](uint64_t ms) { wall += ms; mono += ms; }; + + const auto r = claimMountAwaitingExpiry( + *b, l, "r", UInt128(1), /*our_epoch*/ 8, now_fn, mono_fn, /*ttl*/ 100, /*poll*/ 20, sleep_fn); + EXPECT_EQ(r.kind, MountClaimResult::Claimed); + EXPECT_LE(mono, 100u + 100u / 20 + 20u + 20u); // bounded by OUR threshold, not the predecessor's stamp + EXPECT_EQ(decodeMountLease(b->get(l.mountKey("r"))->bytes).writer_epoch, 8u); // reclaimed +} + +TEST(CASMountLease, KeeperStartAdoptsOurOwnClaimNotDoubleStart) +{ + auto b = std::make_shared(); + Layout l("p"); + uint64_t now = 1000; + // The normal flow: claimMount writes the live mount under (uuid=1, epoch=7), THEN keeper.start(). + ASSERT_EQ(claimMount(*b, l, "r", UInt128(1), /*epoch*/ 7, now, /*ttl*/ 100).kind, MountClaimResult::Claimed); + MountLeaseKeeper k(b, l, "r", UInt128(1), /*epoch*/ 7, std::chrono::milliseconds(100), [&] { return now; }, + [] { return uint64_t{0}; }); + EXPECT_NO_THROW(k.start()); // adopts our own live (uuid=1,epoch=7) mount — NOT a double-start + EXPECT_EQ(decodeMountLease(b->get(l.mountKey("r"))->bytes).writer_epoch, 7u); + + // A keeper for the SAME uuid but a DIFFERENT live epoch must fail closed (superseded/double-start): + MountLeaseKeeper k2(b, l, "r", UInt128(1), /*epoch*/ 8, std::chrono::milliseconds(100), [&] { return now; }, + [] { return uint64_t{0}; }); + EXPECT_DEATH( + { + DB::abort_on_logical_error.store(true, std::memory_order_relaxed); + k2.start(); + }, + "held by a different writer_epoch"); +} + +TEST(CASMountFence, SupersededWriterRefusedNoS3Read) +{ + auto b = std::make_shared(); + auto store = Pool::open(b, PoolConfig{.pool_prefix = "p", .server_root_id = "r"}); + + /// Permissive default: a Pool that has NOT armed the fence allows mutations. + EXPECT_TRUE(store->mayMutate()); + + /// Latching loss: once the renewer trips the fence it stays lost (purely local — no S3 read). + store->tripMountLost(); + EXPECT_FALSE(store->mayMutate()); + + /// A real mutate entrypoint that funnels through mutateShard now fails closed at the gate, BEFORE + /// the mutate lambda runs (so this is the ABORTED gate throw, not a FILE_DOESNT_EXIST from inside). + const RootNamespace ns{"srv1/tbl"}; + EXPECT_THROW(store->dropRef(ns, "any_ref"), DB::Exception); +} + +TEST(CASMountStartup, SecondServerSameRootFailsClosed) +{ + auto b = std::make_shared(); + auto s1 = Pool::open(b, PoolConfig{ + .pool_prefix = "p", .server_id = UInt128(1), .server_root_id = "r"}); + /// A second server (different uuid) on the SAME server_root_id + same backend → fail closed + /// (the owner gate rejects the foreign uuid before any mount/epoch mutation). + EXPECT_THROW( + Pool::open(b, PoolConfig{ + .pool_prefix = "p", .server_id = UInt128(2), .server_root_id = "r"}), + DB::Exception); +} + +TEST(CASMountStartup, WriterEpochStrictlyIncreasesAcrossReopen) +{ + auto b = std::make_shared(); + auto s1 = Pool::open(b, PoolConfig{ + .pool_prefix = "p", .server_id = UInt128(1), .server_root_id = "r"}); + const uint64_t e1 = s1->writerEpoch(); + + /// Simulate shutdown: the Pool dtor stops the keeper, whose terminate() retires the lease + /// (stamps it already-expired). The owner + the durable epoch object stay sticky. + s1.reset(); + + /// Same server reopen → reclaims the (now-expired, different-epoch) mount and allocates a strictly + /// higher durable writer_epoch. + auto s2 = Pool::open(b, PoolConfig{ + .pool_prefix = "p", .server_id = UInt128(1), .server_root_id = "r"}); + const uint64_t e2 = s2->writerEpoch(); + EXPECT_GT(e2, e1); +} + +TEST(CASMountStartup, FreshWritablePoolBootstrapsAnExplicitEmptyCatalog) +{ + auto backend = std::make_shared(); + const Layout layout("p"); + + auto store = Pool::open(backend, PoolConfig{ + .pool_prefix = "p", .server_id = UInt128(1), .server_root_id = "r", + .skip_access_check = true}); + + const auto catalog = backend->get(layout.refCatalogKey()); + ASSERT_TRUE(catalog.has_value()); + EXPECT_TRUE(decodeRefCatalog(catalog->bytes).entries.empty()); +} + +TEST(CASMountStartup, ExistingPoolWithoutCatalogFailsBeforeSlotMutation) +{ + auto backend = std::make_shared(); + const Layout layout("p"); + { + auto store = Pool::open(backend, PoolConfig{ + .pool_prefix = "p", .server_id = UInt128(1), .server_root_id = "r", + .skip_access_check = true}); + } + + /// Old raw fixtures did not persist an empty catalog. Make this an explicit existing-pool + /// fixture before removing the mandatory object whose loss the mount must reject. + if (!backend->head(layout.refCatalogKey()).exists) + ASSERT_EQ(backend->putIfAbsent(layout.refCatalogKey(), encodeRefCatalog(RefCatalog{})).outcome, + PutOutcome::Done); + const HeadResult catalog_head = backend->head(layout.refCatalogKey()); + ASSERT_TRUE(catalog_head.exists); + ASSERT_EQ(backend->deleteExact(layout.refCatalogKey(), catalog_head.token).kind, + DeleteOutcome::Kind::Deleted); + + const auto owner_before = backend->get(layout.ownerKey("r")); + const auto epoch_before = backend->get(layout.epochKey("r")); + const auto mount_before = backend->get(layout.mountKey("r")); + ASSERT_TRUE(owner_before.has_value()); + ASSERT_TRUE(epoch_before.has_value()); + ASSERT_TRUE(mount_before.has_value()); + + EXPECT_THROW(Pool::open(backend, PoolConfig{ + .pool_prefix = "p", .server_id = UInt128(1), .server_root_id = "r", + .skip_access_check = true}), DB::Exception); + + const auto owner_after = backend->get(layout.ownerKey("r")); + const auto epoch_after = backend->get(layout.epochKey("r")); + const auto mount_after = backend->get(layout.mountKey("r")); + ASSERT_TRUE(owner_after.has_value()); + ASSERT_TRUE(epoch_after.has_value()); + ASSERT_TRUE(mount_after.has_value()); + EXPECT_EQ(owner_after->bytes, owner_before->bytes); + EXPECT_EQ(owner_after->token, owner_before->token); + EXPECT_EQ(epoch_after->bytes, epoch_before->bytes); + EXPECT_EQ(epoch_after->token, epoch_before->token); + EXPECT_EQ(mount_after->bytes, mount_before->bytes); + EXPECT_EQ(mount_after->token, mount_before->token); +} + +TEST(CASMountReadOnly, ForeignOwnedPoolOpensWithoutMutation) +{ + auto b = std::make_shared(); + Layout l("p"); + + /// Server A claims the pool (writable): owner = uuid(1), a durable epoch + a live mount lease. + auto a = Pool::open(b, PoolConfig{ + .pool_prefix = "p", .server_id = UInt128(1), .server_root_id = "r"}); + + /// Capture the control objects BEFORE the read-only open so we can prove it mutated nothing. + const auto owner_before = b->get(l.ownerKey("r")); + const auto mount_before = b->get(l.mountKey("r")); + const auto epoch_before = b->get(l.epochKey("r")); + ASSERT_TRUE(owner_before.has_value()); + ASSERT_TRUE(mount_before.has_value()); + ASSERT_TRUE(epoch_before.has_value()); + + /// A READ-ONLY observer with a DIFFERENT server_id on the SAME backend/server_root_id must NOT + /// throw — a read-only mount never participates in the owner/epoch/mount protocol, so a pool + /// owned by another server_uuid is freely observable. + PoolPtr ro; + EXPECT_NO_THROW( + ro = Pool::open(b, PoolConfig{ + .pool_prefix = "p", .server_id = UInt128(2), .server_root_id = "r", + .read_only = true})); + EXPECT_NE(ro, nullptr); + + /// And it mutated nothing: owner still decodes to A's uuid, the mount body is still A's, and the + /// raw bytes of owner/epoch/mount are byte-for-byte unchanged (no second owner, no re-claim). + const auto owner_after = b->get(l.ownerKey("r")); + const auto mount_after = b->get(l.mountKey("r")); + const auto epoch_after = b->get(l.epochKey("r")); + ASSERT_TRUE(owner_after.has_value()); + ASSERT_TRUE(mount_after.has_value()); + ASSERT_TRUE(epoch_after.has_value()); + + EXPECT_EQ(decodeOwner(owner_after->bytes).server_uuid, UInt128(1)); + EXPECT_EQ(decodeMountLease(mount_after->bytes).server_uuid, UInt128(1)); + + EXPECT_EQ(owner_after->bytes, owner_before->bytes); + EXPECT_EQ(mount_after->bytes, mount_before->bytes); + EXPECT_EQ(epoch_after->bytes, epoch_before->bytes); +} + +/// Pool::open must call validateCasRequestBudget itself (not just the free function in isolation — +/// see gtest_cas_request_control.cpp for that): an inconsistent cas_request_budget must refuse a +/// writable mount end-to-end (RFC cas-s3-timeout-retry-control §required-timeout-model), never mount +/// silently with a budget that could let a controlled attempt outlive the lease it is fenced under. +TEST(CASMountStartup, RefusesWritableOpenWithInconsistentCasRequestBudget) +{ + auto b = std::make_shared(); + + /// attempt_timeout_ms + lease_safety_margin_ms == mount_lease_ttl_ms below (30000): not STRICTLY + /// less, so this must be rejected. + const CasRequestBudget bad_budget{ + .attempt_timeout_ms = 25000, .operation_deadline_ms = 30000, .max_attempts = 3, .lease_safety_margin_ms = 5000}; + DB::Cas::tests::expectThrowsCode(DB::ErrorCodes::BAD_ARGUMENTS, [&] + { + Pool::open(b, PoolConfig{ + .pool_prefix = "p", .server_id = UInt128(1), .server_root_id = "r", + .mount_lease_ttl_ms = std::chrono::milliseconds(30000), + .cas_request_budget = bad_budget}); + }); +} + +TEST(CASMountStartup, StaleSelfMountReclaimedAfterWait) +{ + auto b = std::make_shared(); + + /// Server A opens writable with a SHORT lease TTL and no background renewer (`background_watermark` + /// defaults false). The test captures its live mount body, destroys the real Pool cleanly, then + /// replays that body to simulate a crashed process whose lease survives but is never renewed. + /// This test's short lease TTL is far below the CasRequestBudget defaults (RFC + /// cas-s3-timeout-retry-control §required-timeout-model requires attempt_timeout + safety_margin < + /// lease TTL), so it also scales down cas_request_budget to fit — the budget itself is not + /// exercised here, only Pool::open's validateCasRequestBudget startup gate. + const CasRequestBudget tiny_budget{ + .attempt_timeout_ms = 50, .operation_deadline_ms = 500, .max_attempts = 1, .lease_safety_margin_ms = 50}; + auto a = Pool::open(b, PoolConfig{ + .pool_prefix = "p", .server_id = UInt128(1), .server_root_id = "r", + .mount_lease_ttl_ms = std::chrono::milliseconds(300), + .mount_renew_period = std::chrono::milliseconds(100), + .cas_request_budget = tiny_budget}); + ASSERT_NE(a, nullptr); + const uint64_t e1 = a->writerEpoch(); + const String mount_key = a->layout().mountKey("r"); + const auto stale_mount = b->get(mount_key); + ASSERT_TRUE(stale_mount.has_value()); + + /// Preserve A's live lease as if its process disappeared without running C++ teardown. Destroying + /// the real Pool first keeps the parent process valid; replaying the saved body recreates the exact + /// durable stale-lease state that a crashed process would leave behind. + a.reset(); + const auto farewell = b->get(mount_key); + ASSERT_TRUE(farewell.has_value()); + ASSERT_EQ(b->putOverwrite(mount_key, stale_mount->bytes, farewell->token).outcome, PutOutcome::Done); + + /// A restart of the SAME server (same uuid) must NOT abort: it waits out the stale lease (<= ~300ms) + /// and reclaims the mount, coming up with a strictly higher durable writer_epoch. The replayed live + /// body hides A's clean farewell, so the reclaim is `MountPriorState::UncleanObserved`. Inject a + /// fake `boot_ms_fn` + `wait_sleep_fn` (mirroring + /// `CASMountOpenWaits.UncleanOpenPaysOnlyTheObservationWindow`) so the observation window resolves + /// instantly instead of blocking this test on real time. + uint64_t a2_fake_boot = 0; + PoolPtr a2; + EXPECT_NO_THROW( + a2 = Pool::open(b, PoolConfig{ + .pool_prefix = "p", .server_id = UInt128(1), .server_root_id = "r", + .mount_lease_ttl_ms = std::chrono::milliseconds(300), + .mount_renew_period = std::chrono::milliseconds(100), + .cas_request_budget = tiny_budget, + .boot_ms_fn = [&a2_fake_boot] { return a2_fake_boot; }, + .wait_sleep_fn = [&a2_fake_boot](uint64_t ms) { a2_fake_boot += ms; }})); + ASSERT_NE(a2, nullptr); + EXPECT_GT(a2->writerEpoch(), e1); + + /// The original live-object overlap: a first Pool is still alive when a replacement reclaims its + /// slot, so the first one's release meets a stranger. This was an `EXPECT_DEATH` pinning a + /// `LOGICAL_ERROR` abort — which fires from `~Pool`, defeating `finishTeardown`'s own catch by + /// aborting at exception construction. The first Pool never observed a deposition (nothing failed + /// its renewal; the slot was reclaimed underneath it), so this is the exclusivity-violation arm: + /// refuse, leave the reclaimer's slot untouched, latch the fence, and SURVIVE. + auto overlap_backend = std::make_shared(); + auto first = Pool::open(overlap_backend, PoolConfig{ + .pool_prefix = "p", .server_id = UInt128(1), .server_root_id = "r", + .mount_lease_ttl_ms = std::chrono::milliseconds(300), + .mount_renew_period = std::chrono::milliseconds(100), + .cas_request_budget = tiny_budget}); + const String overlap_mount_key = first->layout().mountKey("r"); + + uint64_t overlap_fake_boot = 0; + auto replacement = Pool::open(overlap_backend, PoolConfig{ + .pool_prefix = "p", .server_id = UInt128(1), .server_root_id = "r", + .mount_lease_ttl_ms = std::chrono::milliseconds(300), + .mount_renew_period = std::chrono::milliseconds(100), + .cas_request_budget = tiny_budget, + .boot_ms_fn = [&overlap_fake_boot] { return overlap_fake_boot; }, + .wait_sleep_fn = [&overlap_fake_boot](uint64_t ms) { overlap_fake_boot += ms; }}); + ASSERT_NE(replacement, nullptr); + + const auto reclaimer_slot_before = overlap_backend->get(overlap_mount_key); + ASSERT_TRUE(reclaimer_slot_before.has_value()); + const uint64_t overlap_violations_before + = ProfileEvents::global_counters[ProfileEvents::CASMountExclusivityViolation].load(); + + first.reset(); /// must not abort, must not terminate + + EXPECT_EQ(ProfileEvents::global_counters[ProfileEvents::CASMountExclusivityViolation].load(), + overlap_violations_before + 1); + const auto reclaimer_slot_after = overlap_backend->get(overlap_mount_key); + ASSERT_TRUE(reclaimer_slot_after.has_value()); + EXPECT_EQ(reclaimer_slot_after->bytes, reclaimer_slot_before->bytes) + << "the deposed Pool's release must not retire the reclaimer's lease"; + EXPECT_TRUE(replacement->mayMutate()) << "and must not disturb the live reclaimer"; +} + +TEST(CASMountLease, BodyCarriesFloorAndFence) +{ + MountLease m; + m.server_uuid = UInt128(0xAB); + m.writer_epoch = 7; + m.hostname = "h"; + m.pid = 42; + m.started_at_ms = 1000; + m.seq = 3; + m.expires_at_ms = 2000; + m.min_active = 5; + m.gc_fenced = true; + const MountLease d = decodeMountLease(encodeMountLease(m)); + EXPECT_EQ(d.min_active, 5u); + EXPECT_TRUE(d.gc_fenced); + EXPECT_EQ(d.writer_epoch, 7u); +} + +TEST(CASMountLease, RetiredSentinelRoundTrips) +{ + MountLease m; + m.min_active = std::numeric_limits::max(); + EXPECT_EQ(decodeMountLease(encodeMountLease(m)).min_active, + std::numeric_limits::max()); +} + +/// ---- Task 7 / Task 9: GC heartbeat classification with token-guarded, observation-based fence-out ---- + +namespace +{ +/// A fixed, fake "now" — no real clocks in these tests. Lease timestamps are chosen relative to it. +/// Rev.6 §token-stability observation removed the wall clock from the fence DECISION; `kNowMs` below +/// is threaded through only as `computeHeartbeatFloor`'s audit-only `now_ms`. +constexpr uint64_t kNowMs = 1'000'000; +/// The fence-out threshold measured on the LEADER's OWN monotonic clock (`mono_now_ms`), independent +/// of any lease's stamped `expires_at_ms`. +constexpr uint64_t kStableThresholdMs = 10'000; + +/// Seed one mount body under mountKey(srid) via the on-storage codec (`encodeMountLease` + +/// `putIfAbsent`) — the same interface the keeper writes through. +MountLease seedMount( + Backend & b, const Layout & l, const String & srid, + uint64_t expires_at_ms, bool gc_fenced, uint64_t min_active, uint64_t seq = 1) +{ + MountLease m; + m.server_uuid = UInt128(srid.back()); // distinct per srid; content is irrelevant to the gate + m.writer_epoch = 1; + m.hostname = "h-" + srid; + m.pid = 100; + m.started_at_ms = kNowMs; + m.seq = seq; + m.expires_at_ms = expires_at_ms; + m.min_active = min_active; + m.gc_fenced = gc_fenced; + b.putIfAbsent(l.mountKey(srid), encodeMountLease(m)); + return m; +} + +/// Simulate a keeper's real renewal between two `computeHeartbeatFloor` calls: a token-guarded +/// overwrite that bumps `seq` (and so mints a fresh backend token), leaving everything else as-is. +/// Models the one thing the observation-based fence cares about: the write token changed, so any +/// in-progress observation of the OLD token must restart. +void renewMount(Backend & b, const Layout & l, const String & srid) +{ + const auto got = b.get(l.mountKey(srid)); + ASSERT_TRUE(got.has_value()); + MountLease m = decodeMountLease(got->bytes); + m.seq += 1; + const PutResult res = b.putOverwrite(l.mountKey(srid), encodeMountLease(m), got->token); + ASSERT_EQ(res.outcome, PutOutcome::Done); +} +} + +TEST(CASHeartbeatFloor, FirstSightNeverFencesEvenIfStampLooksExpired) +{ + auto b = std::make_shared(); + Layout l("p"); + + /// A stamp that would have read as long-expired under the old skew-margin comparison — under + /// rev.6 observation the stamp is never even consulted for the fence decision. + seedMount(*b, l, "s1", /*expires*/ 10, /*fenced*/ false, /*min_active*/ 0); + + MountObservationMap obs; + const HeartbeatFloor floor = computeHeartbeatFloor(*b, l, /*now_ms*/ kNowMs, /*mono_now_ms*/ 0, + kStableThresholdMs, obs); + + EXPECT_EQ(floor.fenced_now, 0u); + EXPECT_EQ(floor.live, 1u); + ASSERT_TRUE(obs.contains("s1")); + EXPECT_EQ(obs.at("s1").first_seen_mono_ms, 0u); +} + +TEST(CASHeartbeatFloor, StableTokenPastThresholdIsFenced) +{ + auto b = std::make_shared(); + Layout l("p"); + seedMount(*b, l, "s1", /*expires*/ 10, /*fenced*/ false, /*min_active*/ 0); + + MountObservationMap obs; + const HeartbeatFloor floor_before = computeHeartbeatFloor(*b, l, kNowMs, /*mono*/ 0, kStableThresholdMs, obs); + EXPECT_EQ(floor_before.fenced_now, 0u); + + const MountLease before = decodeMountLease(b->get(l.mountKey("s1"))->bytes); + + /// No renewal in between: the SAME token, observed since mono 0, is now stable for the full + /// threshold on the leader's own clock. + const HeartbeatFloor floor2 = computeHeartbeatFloor(*b, l, kNowMs, /*mono*/ kStableThresholdMs, + kStableThresholdMs, obs); + + EXPECT_EQ(floor2.fenced_now, 1u); + EXPECT_EQ(floor2.fenced_srids, std::vector{"s1"}); + const MountLease fenced = decodeMountLease(b->get(l.mountKey("s1"))->bytes); + EXPECT_TRUE(fenced.gc_fenced); + EXPECT_EQ(fenced.seq, before.seq + 1); +} + +TEST(CASHeartbeatFloor, RenewalBetweenRoundsRestartsObservation) +{ + auto b = std::make_shared(); + Layout l("p"); + seedMount(*b, l, "s1", /*expires*/ 10, /*fenced*/ false, /*min_active*/ 0); + + MountObservationMap obs; + computeHeartbeatFloor(*b, l, kNowMs, /*mono*/ 0, kStableThresholdMs, obs); + ASSERT_TRUE(obs.contains("s1")); + const Token first_token = obs.at("s1").token; + + renewMount(*b, l, "s1"); + const Token renewed_token = b->get(l.mountKey("s1"))->token; + EXPECT_NE(renewed_token, first_token); + + const HeartbeatFloor floor2 = computeHeartbeatFloor(*b, l, kNowMs, /*mono*/ kStableThresholdMs, + kStableThresholdMs, obs); + + EXPECT_EQ(floor2.fenced_now, 0u); + ASSERT_TRUE(obs.contains("s1")); + EXPECT_EQ(obs.at("s1").token, renewed_token); + EXPECT_EQ(obs.at("s1").first_seen_mono_ms, kStableThresholdMs); +} + +/// fix-round F7 (author-review: `Gc::mount_obs` not pruned for srids gone from LIST -> slow unbounded +/// growth on a long-lived leader, worsened by pool-member decommission). A srid whose `/mount` key is +/// removed ENTIRELY (not merely fenced/terminated -- those already `obs.erase` themselves mid-loop) is +/// never visited by a later LIST pass again, so its observation entry must be pruned at end-of-round, +/// not linger in `obs` forever. +TEST(CASHeartbeatFloor, UnseenSridPrunedFromObservationMap) +{ + auto b = std::make_shared(); + Layout l("p"); + seedMount(*b, l, "s1", /*expires*/ 10, /*fenced*/ false, /*min_active*/ 0); + seedMount(*b, l, "s2", /*expires*/ 10, /*fenced*/ false, /*min_active*/ 0); + + MountObservationMap obs; + computeHeartbeatFloor(*b, l, kNowMs, /*mono*/ 0, kStableThresholdMs, obs); + ASSERT_TRUE(obs.contains("s1")); + ASSERT_TRUE(obs.contains("s2")); + + /// s2's `/mount` key is removed entirely -- e.g. `SYSTEM CAS DROP POOL MEMBER` -- so + /// no future LIST pass will ever visit it again. s1 renews (a live keeper would), so its OWN + /// observation restarts and it stays `live` -- isolating this test to the pruning behavior alone, + /// not confounding it with s1 also becoming fence-eligible (which would erase its `obs` entry too, + /// for an unrelated reason). + renewMount(*b, l, "s1"); + const auto s2_key = l.mountKey("s2"); + const auto got = b->get(s2_key); + ASSERT_TRUE(got.has_value()); + ASSERT_EQ(b->deleteExact(s2_key, got->token).kind, DeleteOutcome::Kind::Deleted); + + computeHeartbeatFloor(*b, l, kNowMs, /*mono*/ kStableThresholdMs, kStableThresholdMs, obs); + EXPECT_TRUE(obs.contains("s1")); + EXPECT_FALSE(obs.contains("s2")) + << "a srid removed from the LIST entirely must be pruned from obs, not linger forever"; +} + +TEST(CASHeartbeatFloor, ClassifiesAndFencesOut) +{ + auto b = std::make_shared(); + Layout l("p"); + + /// two live mounts — genuinely renewing between the two rounds below, so their observation never + /// stabilizes. + seedMount(*b, l, "s1", /*expires*/ kNowMs + 60'000, /*fenced*/ false, /*min_active*/ 0); + seedMount(*b, l, "s2", /*expires*/ kNowMs + 60'000, /*fenced*/ false, /*min_active*/ 0); + /// dead — no renewal between the two rounds below — must be fenced-out by the second call. + seedMount(*b, l, "s3", /*expires*/ kNowMs - 60'000, /*fenced*/ false, /*min_active*/ 0); + /// already-fenced — excluded, body byte-identical after both calls (no PUT). + seedMount(*b, l, "s4", /*expires*/ kNowMs - 60'000, /*fenced*/ true, /*min_active*/ 0); + /// terminated (min_active == UINT64_MAX) with expired-looking timestamps — excluded, not fenced. + seedMount(*b, l, "s5", /*expires*/ kNowMs - 60'000, /*fenced*/ false, + /*min_active*/ std::numeric_limits::max()); + + MountObservationMap obs; + + /// Round 1 (mono 0): first sight of every non-terminal mount — nothing is fence-eligible yet. + const HeartbeatFloor floor_before = computeHeartbeatFloor(*b, l, kNowMs, /*mono*/ 0, kStableThresholdMs, obs); + EXPECT_EQ(floor_before.live, 3u); // s1, s2, s3: observation just started + EXPECT_EQ(floor_before.terminated, 1u); // s5 + EXPECT_EQ(floor_before.fenced_now, 0u); + EXPECT_EQ(floor_before.already_fenced, 1u); // s4 + + /// s1 and s2 renew between rounds (as a live keeper would); s3 does not (it crashed). + renewMount(*b, l, "s1"); + renewMount(*b, l, "s2"); + + const auto s3_before = b->get(l.mountKey("s3")); + const auto s4_before = b->get(l.mountKey("s4")); + ASSERT_TRUE(s3_before.has_value()); + ASSERT_TRUE(s4_before.has_value()); + + /// Round 2 (mono == threshold): s1/s2's renewed tokens restart their observation (still live); + /// s3's original token has now held stable for the full threshold -> fenced. + const HeartbeatFloor floor2 = computeHeartbeatFloor(*b, l, kNowMs, /*mono*/ kStableThresholdMs, + kStableThresholdMs, obs); + + EXPECT_EQ(floor2.live, 2u); // s1, s2: renewed, observation restarted + EXPECT_EQ(floor2.terminated, 1u); // s5 + EXPECT_EQ(floor2.fenced_now, 1u); // s3 + EXPECT_EQ(floor2.already_fenced, 1u); // s4 + + /// The dead body was fenced: gc_fenced set, seq bumped, the rest of the body preserved. + const auto s3_after = b->get(l.mountKey("s3")); + ASSERT_TRUE(s3_after.has_value()); + const MountLease s3_prev = decodeMountLease(s3_before->bytes); + const MountLease s3_now = decodeMountLease(s3_after->bytes); + EXPECT_TRUE(s3_now.gc_fenced); + EXPECT_EQ(s3_now.seq, s3_prev.seq + 1); + EXPECT_EQ(s3_now.server_uuid, s3_prev.server_uuid); + EXPECT_EQ(s3_now.writer_epoch, s3_prev.writer_epoch); + EXPECT_EQ(s3_now.hostname, s3_prev.hostname); + EXPECT_EQ(s3_now.expires_at_ms, s3_prev.expires_at_ms); + + /// The already-fenced body was not touched (no PUT) across either call. + const auto s4_after = b->get(l.mountKey("s4")); + ASSERT_TRUE(s4_after.has_value()); + EXPECT_EQ(s4_after->bytes, s4_before->bytes); +} + +namespace +{ +/// A delegating backend whose `putOverwrite` of the target mount key first performs an inner renewal +/// (a real, token-correct overwrite that pushes expiry far into the future) and THEN delegates — so +/// the caller's fence-out overwrite lands on a stale token and returns PreconditionFailed. The inner +/// renewal runs exactly once (`renewed`), modelling a holder that renews concurrently in the window +/// between the function's GET and its fence-out PUT. +class RenewOnFenceBackend : public InMemoryBackend +{ +public: + RenewOnFenceBackend(String target_key_, uint64_t renewed_expires_ms_) + : target_key(std::move(target_key_)), renewed_expires_ms(renewed_expires_ms_) + { + } + + PutResult putOverwrite(const String & key, const String & bytes, const Token & expected, + const ObjectMeta & meta) override + { + if (key == target_key && !renewed) + { + renewed = true; + /// The holder renews under the real current token: fresh far-future expiry. + const auto got = InMemoryBackend::get(key, {}); + MountLease m = decodeMountLease(got->bytes); + m.seq += 1; + m.expires_at_ms = renewed_expires_ms; + const PutResult renew = InMemoryBackend::putOverwrite(key, encodeMountLease(m), got->token); + EXPECT_EQ(renew.outcome, PutOutcome::Done); + } + return InMemoryBackend::putOverwrite(key, bytes, expected, meta); + } + +private: + String target_key; + uint64_t renewed_expires_ms; + bool renewed = false; +}; +} + +TEST(CASHeartbeatFloor, FenceOutLosesTokenRaceReclassifiesLive) +{ + Layout l("p"); + auto b = std::make_shared( + l.mountKey("s1"), /*renewed_expires*/ kNowMs + 120'000); + + seedMount(*b, l, "s1", /*expires*/ kNowMs - 60'000, /*fenced*/ false, /*min_active*/ 0); + + MountObservationMap obs; + /// Round 1: first sight, observation starts — never reaches the fence-out path (the race + /// decorator stays armed for round 2). + const HeartbeatFloor floor_before = computeHeartbeatFloor(*b, l, kNowMs, /*mono*/ 0, kStableThresholdMs, obs); + EXPECT_EQ(floor_before.fenced_now, 0u); + + /// Round 2: the token has been stable past threshold, so the function attempts the fence-out. + /// The decorator renews concurrently under the real token, the PUT hits PreconditionFailed, the + /// function re-GETs and reclassifies it as live (observation restarted on the new token) — never + /// fenced. + const HeartbeatFloor floor2 = computeHeartbeatFloor(*b, l, kNowMs, /*mono*/ kStableThresholdMs, + kStableThresholdMs, obs); + + EXPECT_EQ(floor2.fenced_now, 0u); + EXPECT_EQ(floor2.live, 1u); + + const auto after = b->get(l.mountKey("s1")); + ASSERT_TRUE(after.has_value()); + EXPECT_FALSE(decodeMountLease(after->bytes).gc_fenced); +} + +TEST(CASHeartbeatFloor, EmptyPrefixYieldsNoLiveMounts) +{ + auto b = std::make_shared(); + Layout l("p"); + + MountObservationMap obs; + const HeartbeatFloor floor = computeHeartbeatFloor(*b, l, kNowMs, /*mono*/ 0, kStableThresholdMs, obs); + + EXPECT_EQ(floor.live, 0u); + EXPECT_EQ(floor.terminated, 0u); + EXPECT_EQ(floor.fenced_now, 0u); + EXPECT_EQ(floor.already_fenced, 0u); +} + +/// ---- Task 1 (Phase 2): `listMounts` — read-only mount-slot enumeration for introspection ---- + +TEST(CASListMounts, ClassifiesEveryStateReadOnly) +{ + auto backend = std::make_shared(); + Layout layout("pool"); + const uint64_t now_ms = 1'000'000; + const uint64_t ttl_ms = 10'000; + + /// live: fresh claim for srid "a" + ASSERT_EQ(claimMount(*backend, layout, "a", UInt128{1}, /*our_epoch=*/1, now_ms, ttl_ms).kind, + MountClaimResult::Claimed); + /// expired: claim for "b" whose lease ran out long before now_ms + ASSERT_EQ(claimMount(*backend, layout, "b", UInt128{2}, 1, now_ms - 100'000, ttl_ms).kind, + MountClaimResult::Claimed); + /// corrupt: garbage bytes in "c"'s mount slot + backend->putIfAbsent(layout.mountKey("c"), "garbage-not-a-proto", {}); + + auto mounts = listMounts(*backend, layout, now_ms, /*skew_margin_ms=*/ttl_ms / 2); + ASSERT_EQ(mounts.size(), 3u); + std::map by_srid; + for (const auto & m : mounts) + by_srid[m.srid] = m.state; + EXPECT_EQ(by_srid["a"], "live"); + EXPECT_EQ(by_srid["b"], "expired"); + EXPECT_EQ(by_srid["c"], "corrupt"); + + /// READ-ONLY guarantee: "b" is expired but must NOT be fenced by listMounts + /// (computeHeartbeatFloor would stamp gc_fenced=true; the introspection view must not). + auto again = listMounts(*backend, layout, now_ms, ttl_ms / 2); + for (const auto & m : again) + if (m.srid == "b") + { + EXPECT_FALSE(m.lease.gc_fenced); + EXPECT_EQ(m.state, "expired"); + } +} + +/// A `srid` may itself contain `/` (e.g. `shard-01/replica-a` — legal per +/// `CASServerRootId.ValidationAcceptsCleanPathsRejectsBad`). Slicing the key by the last `/` before +/// the `/mount` suffix (as opposed to by `serverRootsPrefix()` length) truncates it to `replica-a`. +TEST(CASListMounts, NestedSridIsNotTruncated) +{ + auto backend = std::make_shared(); + Layout layout("pool"); + const uint64_t now_ms = 1'000'000; + const uint64_t ttl_ms = 10'000; + + ASSERT_EQ(claimMount(*backend, layout, "shard-01/replica-a", UInt128{1}, /*our_epoch=*/1, now_ms, ttl_ms).kind, + MountClaimResult::Claimed); + + auto mounts = listMounts(*backend, layout, now_ms, /*skew_margin_ms=*/ttl_ms / 2); + ASSERT_EQ(mounts.size(), 1u); + EXPECT_EQ(mounts[0].srid, "shard-01/replica-a"); + EXPECT_EQ(mounts[0].state, "live"); +} + +/// "A fence costs an epoch": a same-(uuid, epoch) re-claim must NOT refresh a `gc_fenced` body in +/// place — that would resurrect a fenced incarnation. It is terminal for THIS epoch; only a +/// DIFFERENT (fresh) epoch may reclaim the slot. +TEST(CASClaimMount, SameEpochFencedIsNotRefreshable) +{ + using namespace DB::Cas; + auto backend = std::make_shared(); + Layout layout("pool"); + /// mint for (uuid 1, epoch 1), then fence it in place (what computeHeartbeatFloor does): + ASSERT_EQ(claimMount(*backend, layout, "a", DB::UInt128{1}, 1, 1000, 10'000).kind, + MountClaimResult::Claimed); + { + auto got = backend->get(layout.mountKey("a")); + MountLease fenced = decodeMountLease(got->bytes); + fenced.gc_fenced = true; + fenced.seq += 1; + ASSERT_EQ(backend->putOverwrite(layout.mountKey("a"), encodeMountLease(fenced), got->token).outcome, + PutOutcome::Done); + } + /// Same (uuid, epoch) re-claim must NOT refresh a fenced body — a fence costs an epoch: + const auto r = claimMount(*backend, layout, "a", DB::UInt128{1}, 1, 2000, 10'000); + EXPECT_EQ(r.kind, MountClaimResult::FencedSelf); + /// The body on the backend is still the fenced one (no write happened): + EXPECT_TRUE(decodeMountLease(backend->get(layout.mountKey("a"))->bytes).gc_fenced); + /// A DIFFERENT epoch reclaims immediately (existing branch, unchanged): + EXPECT_EQ(claimMount(*backend, layout, "a", DB::UInt128{1}, 2, 2000, 10'000).kind, + MountClaimResult::Claimed); +} + +/// ---- rev.6 Task 4: observation-based lease reclaim (no cross-node wall-clock trust) ---- + +/// A same-uuid, different-epoch lease whose STAMPED `expires_at_ms` looks long expired on OUR wall +/// clock must NOT be reclaimed by that comparison alone — a clock-skewed or simply late-observing +/// caller must never trust a bare wall-clock read across incarnations. `claimMount` (without a +/// `proven_dead_token`) always reports `LiveDoubleStart` for this branch now; only the observation +/// loop (`claimMountAwaitingExpiry`) may turn it into a reclaim, and only after proving death on ITS +/// OWN clock. +TEST(CASMountObservation, ExpiredLookingLeaseIsNotReclaimedByWallClock) +{ + auto b = std::make_shared(); + Layout l{"p"}; + /// Predecessor epoch 7 stamped expires_at_ms = 1000; our wall clock says 999999 (long past). + auto first = claimMount(*b, l, "r", UInt128(1), 7, /*now_ms=*/500, /*ttl_ms=*/500); + ASSERT_EQ(first.kind, MountClaimResult::Claimed); + auto r = claimMount(*b, l, "r", UInt128(1), /*our_epoch=*/8, /*now_ms=*/999999, 500); + EXPECT_EQ(r.kind, MountClaimResult::LiveDoubleStart); /// no wall-clock trust +} + +/// The observation loop reclaims once the write-token has held stable for the FULL rate-bound +/// threshold (`ttl_ms + ttl_ms/20 + poll_interval_ms`) on its OWN (injected, fake) clock — never +/// short-circuiting on the wall clock, which this test drives to an irrelevant, already-expired value. +TEST(CASMountObservation, TokenStableForThresholdThenReclaimed) +{ + auto b = std::make_shared(); + Layout l{"p"}; + ASSERT_EQ(claimMount(*b, l, "r", UInt128(1), 7, 500, 500).kind, MountClaimResult::Claimed); + uint64_t mono = 0; + std::vector sleeps; + auto r = claimMountAwaitingExpiry(*b, l, "r", UInt128(1), 8, + []{ return uint64_t{999999}; }, /// wall clock: irrelevant + [&]{ return mono; }, /// observation clock + /*ttl_ms=*/500, /*poll_interval_ms=*/50, + [&](uint64_t ms){ sleeps.push_back(ms); mono += ms; }); + EXPECT_EQ(r.kind, MountClaimResult::Claimed); + EXPECT_EQ(r.prior, MountPriorState::UncleanObserved); + EXPECT_GE(mono, 500 + 500 / 20 + 50); /// full threshold actually waited +} + +/// A renewal DURING the observation window (the real holder is still alive) bumps the write-token — +/// the loop must detect the mismatch and RESTART the observation from the new token, never reclaiming +/// off a window that started watching a now-superseded token. +TEST(CASMountObservation, RenewalDuringObservationRestartsIt) +{ + auto b = std::make_shared(); + Layout l{"p"}; + ASSERT_EQ(claimMount(*b, l, "r", UInt128(1), 7, 500, 500).kind, MountClaimResult::Claimed); + + /// The real (still-alive) holder's keeper for epoch 7: `start()` adopts the slot `claimMount` just + /// wrote (no seq bump, per the ADOPT RULE), then `renewOnce()` bumps the token mid-observation. + uint64_t keeper_wall = 500; + MountLeaseKeeper keeper(b, l, "r", UInt128(1), 7, std::chrono::milliseconds(500), + [&] { return keeper_wall; }, [] { return uint64_t{0}; }); + keeper.start(); + + const uint64_t threshold_ms = 500 + 500 / 20 + 50; /// = 575 + uint64_t mono = 0; + bool renewed = false; + int wait_starts = 0; + auto r = claimMountAwaitingExpiry(*b, l, "r", UInt128(1), 8, + []{ return uint64_t{999999}; }, /// wall clock: irrelevant + [&]{ return mono; }, /// observation clock + /*ttl_ms=*/500, /*poll_interval_ms=*/50, + [&](uint64_t ms) + { + mono += ms; + /// Renew once, close to (but before) the first window's threshold would complete — + /// almost the whole first window is wasted, forcing a near-full second window. + if (!renewed && mono >= threshold_ms - 50) + { + renewed = true; + keeper.renewOnce(); + } + }, + /*on_wait_start=*/[&](const MountLease &, uint64_t) { ++wait_starts; }); + + EXPECT_EQ(r.kind, MountClaimResult::Claimed); + EXPECT_EQ(r.prior, MountPriorState::UncleanObserved); + EXPECT_EQ(wait_starts, 2); /// the renewal forced exactly one restart + /// The restart's own window did not begin until at least (threshold - poll) had already elapsed, + /// so total elapsed time is well over a single threshold window. + EXPECT_GE(mono, (threshold_ms - 50) + threshold_ms); +} + +/// A GC-fenced lease is a terminal, already-threshold-gated certificate of death (the fence-out +/// itself cost the predecessor an epoch) — the observation loop must reclaim it on the FIRST attempt, +/// with zero polling/sleeping. +TEST(CASMountObservation, GcFencedIsReclaimedInstantlyWithPriorFenced) +{ + auto b = std::make_shared(); + Layout l{"p"}; + ASSERT_EQ(claimMount(*b, l, "r", UInt128(1), 7, 1000, 500).kind, MountClaimResult::Claimed); + + /// Fence it manually (what `computeHeartbeatFloor`'s fence-out does): gc_fenced=true, seq+1, + /// token-guarded. + { + auto got = b->get(l.mountKey("r")); + ASSERT_TRUE(got.has_value()); + MountLease fenced = decodeMountLease(got->bytes); + fenced.gc_fenced = true; + fenced.seq += 1; + ASSERT_EQ(b->putOverwrite(l.mountKey("r"), encodeMountLease(fenced), got->token).outcome, + PutOutcome::Done); + } + + int sleeps = 0; + auto r = claimMountAwaitingExpiry(*b, l, "r", UInt128(1), /*our_epoch=*/8, + []{ return uint64_t{999999}; }, + []{ return uint64_t{0}; }, + /*ttl_ms=*/500, /*poll_interval_ms=*/50, + [&](uint64_t) { ++sleeps; }); + + EXPECT_EQ(r.kind, MountClaimResult::Claimed); + EXPECT_EQ(r.prior, MountPriorState::Fenced); + EXPECT_EQ(sleeps, 0); +} + +/// ---- Stage B Task 3: `isCreatorFenceTerminal` -- the cross-process terminality predicate +/// `CasRefCatalog::reconcileStaleCreator` gates on. Built from `writer_epoch` plus the SAME two +/// clock-free certificates `probeNonTerminalMountSlots`/`computeHeartbeatFloor` already use, PLUS a +/// third certificate available only here: a currently-live DIFFERENT `writer_epoch` at the slot. ---- + +TEST(CASFenceTerminal, AbsentMountSlotIsNotTerminal) +{ + InMemoryBackend b; + Layout l{"p"}; + EXPECT_FALSE(isCreatorFenceTerminal(b, l, "never-mounted", 1)) + << "absence proves nothing about liveness -- never waved through"; +} + +TEST(CASFenceTerminal, UndecodableMountBodyIsNotTerminal) +{ + InMemoryBackend b; + Layout l{"p"}; + b.putIfAbsent(l.mountKey("r"), "garbage-not-a-lease", {}); + EXPECT_FALSE(isCreatorFenceTerminal(b, l, "r", 1)) + << "an unreadable lease of some other format generation must block, never wave through"; +} + +TEST(CASFenceTerminal, GcFencedIsTerminal) +{ + InMemoryBackend b; + Layout l{"p"}; + ASSERT_EQ(claimMount(b, l, "r", UInt128(1), /*our_epoch=*/7, 1000, 500).kind, MountClaimResult::Claimed); + auto got = b.get(l.mountKey("r")); + ASSERT_TRUE(got.has_value()); + MountLease fenced = decodeMountLease(got->bytes); + fenced.gc_fenced = true; + ASSERT_EQ(b.putOverwrite(l.mountKey("r"), encodeMountLease(fenced), got->token).outcome, PutOutcome::Done); + + EXPECT_TRUE(isCreatorFenceTerminal(b, l, "r", 7)); +} + +TEST(CASFenceTerminal, CleanFarewellIsTerminal) +{ + InMemoryBackend b; + Layout l{"p"}; + ASSERT_EQ(claimMount(b, l, "r", UInt128(1), /*our_epoch=*/7, 1000, 500).kind, MountClaimResult::Claimed); + auto got = b.get(l.mountKey("r")); + ASSERT_TRUE(got.has_value()); + MountLease retired = decodeMountLease(got->bytes); + retired.min_active = std::numeric_limits::max(); + ASSERT_EQ(b.putOverwrite(l.mountKey("r"), encodeMountLease(retired), got->token).outcome, PutOutcome::Done); + + EXPECT_TRUE(isCreatorFenceTerminal(b, l, "r", 7)); +} + +TEST(CASFenceTerminal, ADifferentLiveWriterEpochIsTerminalForTheOldOne) +{ + InMemoryBackend b; + Layout l{"p"}; + /// Slot now held at epoch 8 -- epoch 7's incarnation is superseded regardless of ITS OWN + /// certificate (neither fenced nor farewelled). + ASSERT_EQ(claimMount(b, l, "r", UInt128(1), /*our_epoch=*/8, 1000, 500).kind, MountClaimResult::Claimed); + + EXPECT_TRUE(isCreatorFenceTerminal(b, l, "r", 7)) + << "a different epoch is currently live at this slot -- epoch 7 can never reclaim it"; + EXPECT_FALSE(isCreatorFenceTerminal(b, l, "r", 8)) + << "epoch 8 IS the current live epoch -- not terminal"; +} + +/// A merely EXPIRED lease (wall-clock past `expires_at_ms`, same epoch, no certificate) must NOT be +/// treated as terminal -- mirrors `claimMount`'s own refusal to trust a bare timestamp comparison. +TEST(CASFenceTerminal, ExpiredButSameEpochAndUncertifiedIsNotTerminal) +{ + InMemoryBackend b; + Layout l{"p"}; + /// A lease whose stamped expiry is already far in the past, same epoch throughout. + ASSERT_EQ(claimMount(b, l, "r", UInt128(1), /*our_epoch=*/7, /*now_ms=*/0, /*ttl_ms=*/1).kind, + MountClaimResult::Claimed); + + EXPECT_FALSE(isCreatorFenceTerminal(b, l, "r", 7)) + << "expiry alone is never a certificate of death, exactly like claimMount's own discipline"; +} diff --git a/src/Disks/tests/gtest_cas_namespace_file_request_profile.cpp b/src/Disks/tests/gtest_cas_namespace_file_request_profile.cpp new file mode 100644 index 000000000000..bfb62f817ecb --- /dev/null +++ b/src/Disks/tests/gtest_cas_namespace_file_request_profile.cpp @@ -0,0 +1,558 @@ +#include +#include "cas_test_helpers.h" + +#include +#include +#include + +/// Per-TU declaration of the one setting this file overrides, following the pattern `cas_test_helpers.h` +/// documents for `server_root_id`/`scratch_path`: defined once in `ContentAddressedSettings.cpp`, declared +/// by each consumer for what it actually references. +namespace DB::ContentAddressedSetting +{ + extern const ContentAddressedSettingsBool gc_enabled; +} + +#include +#include +#include +#include +#include + +/// The namespace-file REQUEST PROFILE gate (directive §dedup-performance-constraint) -- for the +/// `Pool` namespace-file surface, which is the layer that must be read carefully below. +/// +/// `MergeTreeDeduplicationLog` rotates namespace files on the insert path because the CA disk cannot +/// append, so every namespace-file operation's request profile is insert latency. The directive's +/// constraint has five clauses: no catalog request per file operation, no ref-log append, no blob +/// upload, no folder-manifest rewrite, and unchanged direct-object backend request counts. +/// +/// WHAT THIS FILE PINS: the last clause, per key. The counts below were READ OFF this tree before any +/// key change and pasted as literals, which is the whole point of the file -- expectations re-derived +/// after a change measure the change against itself. Incarnation qualification changes the KEY a +/// namespace file is stored under, so the keys are derived from `Layout` rather than spelled out; what +/// must not move is the count per key and the set of keys touched. +/// +/// TWO KINDS OF GATE LIVE IN THIS FILE, and mistaking one for the other would misread what it proves. +/// The per-operation COUNTS above are baseline-anchored: they were read off the tree BEFORE the key change +/// and pasted as literals, so they detect DRIFT from a measured past. The four negatives below are a +/// FORWARD ALARM: a zero has no baseline to drift from, and the disk-layer cases could not have existed +/// before the life resolution they measure was put on that path. They are no weaker for it -- they fail +/// the moment a catalog, ref-log, blob or manifest request appears where none belongs -- but they are not +/// evidence that anything was "unchanged". +/// +/// WHERE THE OTHER FOUR CLAUSES ARE FENCED, and why they could not be fenced by the cases above. Every +/// pool-layer case drives `Pool::putNamespaceFile`/`getNamespaceFile`/`removeNamespaceFile`/ +/// `listNamespaceFiles`, which reach `CasPlainObjects` and have no catalog, ref-log, blob or manifest +/// path to take -- so at THAT layer the four negatives hold by construction of the call and measuring +/// them proves nothing. The layer where they can be violated is the DISK operation above, where +/// `ContentAddressedTransaction::writeFile` resolves the namespace's life; that is exactly where Task 4b +/// put a life resolution, so that is where a per-operation catalog GET would appear. The two +/// `CASNamespaceFileDiskProfile` cases at the bottom of this file fence it there, through a recording +/// `IObjectStorage` (the metadata storage builds its own `Backend` from an `ObjectStoragePtr`, so the +/// object storage is the injectable seam -- no production surface is widened for the test's benefit). + +using namespace DB::Cas; +using namespace DB::Cas::tests; + +namespace +{ + +const String kNsString = "test/req_profile@cas@"; +const String kFile = "format_version.txt"; +/// A NESTED relative name, which is what the dedup log actually stores (its segments live in a +/// table-level subdirectory), so the profile is captured on the shape the constraint is about. +const String kSegment1 = "deduplication_logs/deduplication_log_1.txt"; +const String kSegment2 = "deduplication_logs/deduplication_log_2.txt"; + +/// The identity every case below operates under. `fixture::fixtureLife` is the transitional mint Task 6 +/// deletes; what matters to this file is only that ONE life is used throughout, so a count is not +/// split across two prefixes. +NamespaceLifeId testLife() +{ + return fixture::fixtureLife(RootNamespace{kNsString}); +} + +/// A pool over `CountingBackend`, with the counts reset AFTER open: `Pool::open` runs its own +/// capability probe and mount claim, and those requests belong to no file operation. +PoolPtr openCountedPool(std::shared_ptr & out_backend) +{ + out_backend = std::make_shared(); + PoolPtr store = openPoolForTest(out_backend); + out_backend->resetCounts(); + return store; +} + +} + +/// CREATE (the key is absent) and REWRITE (the key is present) are different request shapes on the +/// same call, and the profile pins both: one HEAD to learn the token, then the create-if-absent or the +/// token-conditioned replacement that HEAD selected. +TEST(CASNamespaceFileRequestProfile, CreateThenRewrite) +{ + std::shared_ptr backend; + PoolPtr store = openCountedPool(backend); + const NamespaceLifeId life = testLife(); + const String key = store->layout().namespaceFileKey(life, kFile); + + store->putNamespaceFile(life, kFile, "1\n"); + + EXPECT_EQ(backend->headCount(key), 1u); + EXPECT_EQ(backend->putCount(key), 1u); /// putIfAbsent -- the key was absent + EXPECT_EQ(backend->putOverwriteCount(key), 0u); + EXPECT_EQ(backend->getCount(key), 0u); + EXPECT_EQ(backend->deleteCount(key), 0u); + EXPECT_EQ(backend->listTotal(), 0u); + EXPECT_EQ(backend->casPutTotal(), 0u); + EXPECT_EQ(backend->touchedKeys(), std::vector{key}); + + backend->resetCounts(); + store->putNamespaceFile(life, kFile, "2\n"); + + EXPECT_EQ(backend->headCount(key), 1u); + EXPECT_EQ(backend->putOverwriteCount(key), 1u); /// token-conditioned replacement -- it existed + EXPECT_EQ(backend->putCount(key), 0u); + EXPECT_EQ(backend->getCount(key), 0u); + EXPECT_EQ(backend->deleteCount(key), 0u); + EXPECT_EQ(backend->listTotal(), 0u); + EXPECT_EQ(backend->casPutTotal(), 0u); + EXPECT_EQ(backend->touchedKeys(), std::vector{key}); +} + +/// A plain read is one whole-object GET and nothing else. +TEST(CASNamespaceFileRequestProfile, Read) +{ + std::shared_ptr backend; + PoolPtr store = openCountedPool(backend); + const NamespaceLifeId life = testLife(); + const String key = store->layout().namespaceFileKey(life, kFile); + + store->putNamespaceFile(life, kFile, "1\n"); + backend->resetCounts(); + + EXPECT_EQ(store->getNamespaceFile(life, kFile), String("1\n")); + + EXPECT_EQ(backend->getCount(key), 1u); + EXPECT_EQ(backend->wholeGetCount(key), 1u); + EXPECT_EQ(backend->headCount(key), 0u); + EXPECT_EQ(backend->putCount(key), 0u); + EXPECT_EQ(backend->putOverwriteCount(key), 0u); + EXPECT_EQ(backend->touchedKeys(), std::vector{key}); +} + +/// APPEND on a CA disk is serviced by read-modify-rewrite, and its request shape is the composition of +/// the two calls that implement it: a GET of the current body, then a whole-body PUT of base+delta. +/// Driven here as that composition against the same key, which is the shape whose count must not move. +TEST(CASNamespaceFileRequestProfile, ReadModifyRewriteAppend) +{ + std::shared_ptr backend; + PoolPtr store = openCountedPool(backend); + const NamespaceLifeId life = testLife(); + const String key = store->layout().namespaceFileKey(life, kSegment1); + + store->putNamespaceFile(life, kSegment1, "base"); + backend->resetCounts(); + + const std::optional carried = store->getNamespaceFile(life, kSegment1); + ASSERT_TRUE(carried.has_value()); + store->putNamespaceFile(life, kSegment1, *carried + "-delta"); + + EXPECT_EQ(backend->getCount(key), 1u); + EXPECT_EQ(backend->headCount(key), 1u); + EXPECT_EQ(backend->putOverwriteCount(key), 1u); + EXPECT_EQ(backend->putCount(key), 0u); + EXPECT_EQ(backend->deleteCount(key), 0u); + EXPECT_EQ(backend->listTotal(), 0u); + EXPECT_EQ(backend->touchedKeys(), std::vector{key}); + EXPECT_EQ(store->getNamespaceFile(life, kSegment1), String("base-delta")); +} + +/// REMOVE is exact-token deletion, so it is one HEAD for the token plus one delete against it. +TEST(CASNamespaceFileRequestProfile, Remove) +{ + std::shared_ptr backend; + PoolPtr store = openCountedPool(backend); + const NamespaceLifeId life = testLife(); + const String key = store->layout().namespaceFileKey(life, kFile); + + store->putNamespaceFile(life, kFile, "1\n"); + backend->resetCounts(); + + store->removeNamespaceFile(life, kFile); + + EXPECT_EQ(backend->headCount(key), 1u); + EXPECT_EQ(backend->deleteCount(key), 1u); + EXPECT_EQ(backend->getCount(key), 0u); + EXPECT_EQ(backend->putCount(key), 0u); + EXPECT_EQ(backend->putOverwriteCount(key), 0u); + EXPECT_EQ(backend->listTotal(), 0u); + EXPECT_EQ(backend->touchedKeys(), std::vector{key}); + EXPECT_FALSE(store->getNamespaceFile(life, kFile).has_value()); +} + +/// ROTATION is the sequence the constraint names: the retiring segment is enumerated, the new segment +/// is created, and the retired one is removed. One LIST of the files prefix serves the enumeration (a +/// single page here), and each segment carries its own create or remove shape. +TEST(CASNamespaceFileRequestProfile, DedupLogRotation) +{ + std::shared_ptr backend; + PoolPtr store = openCountedPool(backend); + const NamespaceLifeId life = testLife(); + const String prefix = store->layout().namespaceFilesPrefix(life); + const String old_key = store->layout().namespaceFileKey(life, kSegment1); + const String new_key = store->layout().namespaceFileKey(life, kSegment2); + + store->putNamespaceFile(life, kSegment1, "segment-1-records"); + backend->resetCounts(); + + const std::vector before = store->listNamespaceFiles(life); + ASSERT_EQ(before, std::vector{kSegment1}); + store->putNamespaceFile(life, kSegment2, "segment-2-records"); + store->removeNamespaceFile(life, kSegment1); + + EXPECT_EQ(backend->listCount(prefix), 1u); + EXPECT_EQ(backend->listTotal(), 1u); + EXPECT_EQ(backend->headCount(new_key), 1u); + EXPECT_EQ(backend->putCount(new_key), 1u); + EXPECT_EQ(backend->putOverwriteCount(new_key), 0u); + EXPECT_EQ(backend->headCount(old_key), 1u); + EXPECT_EQ(backend->deleteCount(old_key), 1u); + EXPECT_EQ(backend->getTotal(), 0u); /// rotation reads no body + EXPECT_EQ(backend->casPutTotal(), 0u); + /// Sorted, and the files prefix is a proper prefix of both segment keys, so it comes first. + EXPECT_EQ(backend->touchedKeys(), (std::vector{prefix, old_key, new_key})); + + EXPECT_EQ(store->listNamespaceFiles(life), std::vector{kSegment2}); +} + + +/// ===================== THE FOUR NEGATIVES, AT THE DISK LAYER ===================== +/// +/// Constraint 16's other four clauses: a namespace-file operation performs no catalog request, no +/// ref-log append, no blob upload and no folder-manifest rewrite. They are asserted here rather than +/// above because only here is there a life resolution to get wrong. +/// +/// WHAT MAKES THE CLAIM NON-TRIVIAL. Task 4b's read and write paths resolve a catalog-minted life. That +/// resolution is per TABLE-OPEN -- `CasRefLedger` caches it on the table's runtime -- so the steady-state +/// operation pays nothing for it. If it ever became per-operation, `format_version.txt` and every +/// dedup-log rotation on the insert path would carry a catalog round trip, and nothing else in the suite +/// would notice. `SteadyStateFileOperationsTouchNoCatalogRefBlobOrManifestKey` is that alarm, and +/// `TheLifeResolutionIsPaidOncePerTableOpen` is the other half: it shows the birth cost EXISTS and is +/// paid exactly once, so the steady-state zeros are a real property rather than an artifact of a fixture +/// that never triggered a resolution at all. + +namespace +{ + +/// A `LocalObjectStorage` that records every key it is asked about, per operation family. Used to ask +/// "was any key under these four families touched at all", which is a question about WHICH keys an +/// operation reaches -- not about counts -- so recording the key set is the whole instrument. +/// +/// It overrides every method `CasObjectStorageBackend` reaches: a family left un-overridden would be an +/// unrecorded path, and an assertion of "nothing touched it" would then be silently satisfied by the +/// gap rather than by the behaviour. +class RecordingObjectStorage : public DB::LocalObjectStorage +{ +public: + using DB::LocalObjectStorage::LocalObjectStorage; + + bool exists(const DB::StoredObject & object) const override + { + record(object.remote_path, /*is_write*/ false); + return DB::LocalObjectStorage::exists(object); + } + + std::unique_ptr readObject( + const DB::StoredObject & object, const DB::ReadSettings & read_settings, + std::optional read_hint, bool use_external_buffer, + bool restrict_seek) const override + { + record(object.remote_path, /*is_write*/ false); + return DB::LocalObjectStorage::readObject(object, read_settings, read_hint, use_external_buffer, restrict_seek); + } + + std::unique_ptr writeObject( + const DB::StoredObject & object, DB::WriteMode mode, + std::optional attributes, + size_t buf_size, + const DB::WriteSettings & write_settings) override + { + record(object.remote_path, /*is_write*/ true); + return DB::LocalObjectStorage::writeObject(object, mode, attributes, buf_size, write_settings); + } + + void removeObjectIfExists(const DB::StoredObject & object) override + { + record(object.remote_path, /*is_write*/ true); + DB::LocalObjectStorage::removeObjectIfExists(object); + } + + void removeObjectsIfExist(const DB::StoredObjects & objects) override + { + for (const DB::StoredObject & object : objects) + record(object.remote_path, /*is_write*/ true); + DB::LocalObjectStorage::removeObjectsIfExist(objects); + } + + DB::ObjectMetadata getObjectMetadata(const std::string & path, bool with_tags) const override + { + record(path, /*is_write*/ false); + return DB::LocalObjectStorage::getObjectMetadata(path, with_tags); + } + + std::optional tryGetObjectMetadata(const std::string & path, bool with_tags) const override + { + record(path, /*is_write*/ false); + return DB::LocalObjectStorage::tryGetObjectMetadata(path, with_tags); + } + + void listObjects(const std::string & path, DB::RelativePathsWithMetadata & children, size_t max_keys) const override + { + record(path, /*is_write*/ false); + DB::LocalObjectStorage::listObjects(path, children, max_keys); + } + + bool existsOrHasAnyChild(const std::string & path) const override + { + record(path, /*is_write*/ false); + return DB::LocalObjectStorage::existsOrHasAnyChild(path); + } + + void copyObject( + const DB::StoredObject & object_from, const DB::StoredObject & object_to, + const DB::ReadSettings & read_settings, const DB::WriteSettings & write_settings, + std::optional object_to_attributes) override + { + record(object_from.remote_path, /*is_write*/ false); + record(object_to.remote_path, /*is_write*/ true); + DB::LocalObjectStorage::copyObject(object_from, object_to, read_settings, write_settings, object_to_attributes); + } + + /// Every recorded key containing `needle`, in first-touch order, so a failure names the offender. + std::vector touchedContaining(std::string_view needle) const + { + std::lock_guard lock(mutex); + std::vector out; + for (const String & key : touched) + if (key.find(needle) != String::npos) + out.push_back(key); + return out; + } + + std::vector writtenContaining(std::string_view needle) const + { + std::lock_guard lock(mutex); + std::vector out; + for (const String & key : written) + if (key.find(needle) != String::npos) + out.push_back(key); + return out; + } + + void resetRecords() + { + std::lock_guard lock(mutex); + touched.clear(); + written.clear(); + } + +private: + void record(const std::string & key, bool is_write) const + { + std::lock_guard lock(mutex); + touched.push_back(key); + if (is_write) + written.push_back(key); + } + + mutable std::mutex mutex; + mutable std::vector touched; + mutable std::vector written; +}; + +const std::string kTableUuid = "a11a11a1-1111-4111-8111-111111111111"; +const std::string kTablePath = "a11/a11a11a1-1111-4111-8111-111111111111"; + +/// The four families the constraint forbids a file operation from touching, as key substrings. Taken +/// from `Layout` where a helper exists rather than spelled out, so a layout change breaks this by +/// failing to compile or by moving the substring, not by silently matching nothing. +struct ForbiddenFamily +{ + String needle; + String clause; +}; + +std::vector forbiddenFamilies(const DB::Cas::Layout & layout) +{ + return { + {layout.refCatalogKey(), "no catalog request"}, + {layout.casRefsPrefix(), "no ref-log append"}, + {layout.blobsPrefix(), "no blob upload"}, + {layout.casManifestsPrefix(), "no folder-manifest rewrite"}, + }; +} + +std::shared_ptr openRecordingStorage( + std::shared_ptr & out_object_storage) +{ + static std::atomic counter{0}; + const String unique = std::to_string(::getpid()) + "_" + std::to_string(counter.fetch_add(1)); + const auto root = (std::filesystem::temp_directory_path() / ("cas_ns_file_profile_" + unique)).string(); + std::error_code ec; + std::filesystem::remove_all(root, ec); + std::filesystem::create_directories(root, ec); + + out_object_storage = std::make_shared( + DB::LocalObjectStorageSettings("test", root, /*read_only_=*/false)); + + auto settings = makeSettingsForTest( + "test", std::filesystem::temp_directory_path() / ("cas_ns_file_profile_scratch_" + unique)); + /// A GC round touches `cas/ref_catalog`, `cas/ns/stream/` and `cas/manifests/`, so with the + /// background scheduler enabled these zeros would hold only because the first tick (60s) outlives the + /// test. A timer is not a fence. + settings[DB::ContentAddressedSetting::gc_enabled] = false; + auto storage = std::make_shared( + out_object_storage, "pool", "srv1", "", nullptr, settings); + storage->startup(); + return storage; +} + +/// One verbatim namespace file written through the REAL disk write path (the buffer whose finalize +/// callback reaches `putNamespaceFile`), not through the pool surface. +void writeVerbatimThroughDisk( /// ASSERT_* inside -> must return void + DB::ContentAddressedMetadataStorage & storage, const std::string & path, const String & bytes, + DB::WriteMode mode = DB::WriteMode::Rewrite) +{ + /// `tryCreateWriteBuffer` is the interface entry the disk itself uses, so this drives the same + /// buffer construction (and the same autocommit-on-finalize contract for verbatim files) that a real + /// write does. `owner` is null here: only a part-blob buffer's deferred finalize needs the pin, and + /// a verbatim file finalizes inline, inside this call's scope. + auto tx = storage.createTransaction(); + auto buf = tx->tryCreateWriteBuffer( + /*owner*/ nullptr, path, DB::DBMS_DEFAULT_BUFFER_SIZE, mode, {}, /*autocommit*/ true); + ASSERT_TRUE(buf != nullptr); + DB::writeString(bytes, *buf); + buf->finalize(); +} + +} + +/// The steady state: with the table open and its life already resolved, no namespace-file operation -- +/// rewrite, append, read, rotation, remove -- touches a catalog, ref, blob or manifest key. +TEST(CASNamespaceFileDiskProfile, SteadyStateFileOperationsTouchNoCatalogRefBlobOrManifestKey) +{ + std::shared_ptr object_storage; + auto storage = openRecordingStorage(object_storage); + const DB::Cas::Layout & layout = storage->store()->layout(); + + /// Open the table by doing the first file operation, which is what resolves (and here mints) the + /// life. Everything measured below happens after it. + writeVerbatimThroughDisk(*storage, kTablePath + "/format_version.txt", "1\n"); + ASSERT_TRUE(storage->existsFile(kTablePath + "/format_version.txt")); + + object_storage->resetRecords(); + + /// A whole-file rewrite, the read-modify-rewrite append, a read, and a dedup-log rotation + /// (create the new segment, enumerate, drop the retired one) -- the four shapes the constraint names. + writeVerbatimThroughDisk(*storage, kTablePath + "/format_version.txt", "2\n"); + writeVerbatimThroughDisk(*storage, kTablePath + "/deduplication_logs/deduplication_log_1.txt", "a"); + writeVerbatimThroughDisk( + *storage, kTablePath + "/deduplication_logs/deduplication_log_1.txt", "b", DB::WriteMode::Append); + EXPECT_EQ(storage->tryGetInManifestBytes(kTablePath + "/deduplication_logs/deduplication_log_1.txt"), + std::optional("ab")); + writeVerbatimThroughDisk(*storage, kTablePath + "/deduplication_logs/deduplication_log_2.txt", "c"); + storage->createTransaction()->unlinkFile( + kTablePath + "/deduplication_logs/deduplication_log_1.txt", /*if_exists*/ false, /*remove_metadata_only*/ false); + + for (const ForbiddenFamily & family : forbiddenFamilies(layout)) + EXPECT_EQ(object_storage->touchedContaining(family.needle), std::vector{}) + << "Constraint 16, '" << family.clause << "': a namespace-file operation reached " << family.needle; + + /// A positive control on the instrument itself: the operations above DID reach the store, so the + /// four empty answers are the absence of those families and not a recorder that recorded nothing. + EXPECT_FALSE(object_storage->writtenContaining("/_files/").empty()) + << "the recorder must have seen the file writes themselves"; +} + +/// The other half: the life resolution is real and is paid ONCE per table-open. Without this, the zeros +/// above could be produced by a fixture in which no resolution ever happened. +TEST(CASNamespaceFileDiskProfile, TheLifeResolutionIsPaidOncePerTableOpen) +{ + std::shared_ptr object_storage; + auto storage = openRecordingStorage(object_storage); + const DB::Cas::Layout & layout = storage->store()->layout(); + + /// The FIRST namespace-file operation on a never-opened table resolves the life from the catalog, + /// minting the namespace when it names none -- so it DOES reach the catalog. That is the per-open + /// cost, and the reason the steady-state case above resets its records after this point. + writeVerbatimThroughDisk(*storage, kTablePath + "/format_version.txt", "1\n"); + EXPECT_FALSE(object_storage->touchedContaining(layout.refCatalogKey()).empty()) + << "the first file operation must resolve a life, which reaches the catalog"; + + object_storage->resetRecords(); + + /// The second operation on the SAME open table resolves nothing: the life is cached on the table's + /// runtime. This is the assertion that says "per table-open", and it is the one that would fail if a + /// future change moved the resolution onto the operation. + writeVerbatimThroughDisk(*storage, kTablePath + "/format_version.txt", "2\n"); + EXPECT_EQ(object_storage->touchedContaining(layout.refCatalogKey()), std::vector{}) + << "a second file operation must not re-resolve the life"; +} + + +/// THE REMOVAL PATHS MUST NOT CREATE A NAMESPACE — the case that regressed silently in this task's first +/// round, so it is pinned on the catalog rather than on the file outcome. +/// +/// Why the file outcome cannot pin it: `unlinkFile`/`removeRecursive` against a never-opened table +/// answer "absent" both before and after the defect, because a freshly minted namespace has no files +/// either. The only observable difference is the catalog write, so that is what is asserted. And it +/// matters twice over: `unlinkFile(..., if_exists = true)` is called from cleanup paths whose contract is +/// to be a no-op, and the catalog is ONE pool-wide object under a capacity-admission predicate — a +/// removal that admits an entry per never-created table grows it without bound. +TEST(CASNamespaceFileDiskProfile, RemovalOnANeverOpenedTableLeavesTheCatalogUntouched) +{ + std::shared_ptr object_storage; + auto storage = openRecordingStorage(object_storage); + const DB::Cas::Layout & layout = storage->store()->layout(); + + /// A valid pool already owns its explicit empty mandatory catalog. Nothing has opened this table: + /// no namespace file written, no part published, and no ref operation has changed that object. + const auto catalog_before = storage->store()->backend().get(layout.refCatalogKey()); + ASSERT_TRUE(catalog_before); + EXPECT_TRUE(decodeRefCatalog(catalog_before->bytes).entries.empty()); + object_storage->resetRecords(); + + /// Three removal shapes, all against paths under a table that does not exist. + storage->createTransaction()->unlinkFile( + kTablePath + "/format_version.txt", /*if_exists*/ true, /*remove_metadata_only*/ false); + storage->createTransaction()->removeRecursive( + kTablePath + "/deduplication_logs", DB::IMetadataTransaction::ShouldRemoveObjectsPredicate{}); + /// The table directory ITSELF, which is a different arm from the subdirectory above: it is the one + /// that reaches the ref layer's namespace drop and its ref enumeration, rather than only the + /// namespace-file resolver. + storage->createTransaction()->removeRecursive( + kTablePath, DB::IMetadataTransaction::ShouldRemoveObjectsPredicate{}); + EXPECT_FALSE(storage->existsFile(kTablePath + "/format_version.txt")); + + EXPECT_EQ(object_storage->writtenContaining(layout.refCatalogKey()), std::vector{}) + << "a removal must not write the catalog: it must not birth the namespace it is removing from"; + const auto catalog_after_removal = storage->store()->backend().get(layout.refCatalogKey()); + ASSERT_TRUE(catalog_after_removal); + EXPECT_EQ(catalog_after_removal->bytes, catalog_before->bytes); + EXPECT_EQ(catalog_after_removal->token, catalog_before->token) + << "the mandatory catalog must remain byte-for-byte and token-for-token unchanged"; + + /// Not vacuous: the SAME operations on the same table after a write do reach the file, so the zeros + /// above are the absence of a birth and not the absence of any work. + writeVerbatimThroughDisk(*storage, kTablePath + "/format_version.txt", "1\n"); + ASSERT_TRUE(storage->existsFile(kTablePath + "/format_version.txt")); + storage->createTransaction()->unlinkFile( + kTablePath + "/format_version.txt", /*if_exists*/ false, /*remove_metadata_only*/ false); + EXPECT_FALSE(storage->existsFile(kTablePath + "/format_version.txt")); + /// Positive control: the write really did birth the namespace and mutate the same catalog object + /// whose stability the removal assertions pin above. + const auto catalog_after_birth = storage->store()->backend().get(layout.refCatalogKey()); + ASSERT_TRUE(catalog_after_birth); + EXPECT_NE(catalog_after_birth->bytes, catalog_after_removal->bytes); + EXPECT_NE(catalog_after_birth->token, catalog_after_removal->token); +} diff --git a/src/Disks/tests/gtest_cas_namespace_janitor.cpp b/src/Disks/tests/gtest_cas_namespace_janitor.cpp new file mode 100644 index 000000000000..7a3455738bc4 --- /dev/null +++ b/src/Disks/tests/gtest_cas_namespace_janitor.cpp @@ -0,0 +1,607 @@ +#include "cas_test_helpers.h" +#include +#include + +using namespace DB::Cas; +using namespace DB::Cas::tests; + +namespace +{ + +class OrderedJanitorBackend : public CountingBackend +{ +public: + using CountingBackend::get; + std::vector events; + + ListPage list(const String & prefix, const String & cursor, size_t limit) override + { + if (prefix.ends_with("/cas/ns/")) + events.push_back("list"); + return CountingBackend::list(prefix, cursor, limit); + } + + std::optional get(const String & key, Range range) override + { + if (key.ends_with("/cas/ref_catalog")) + events.push_back("catalog"); + return CountingBackend::get(key, range); + } +}; + +class OmitFirstNamespacePageBackend : public CountingBackend +{ +public: + ListPage list(const String & prefix, const String & cursor, size_t limit) override + { + if (omit && prefix.ends_with("/cas/ns/")) + { + omit = false; + return {}; + } + return CountingBackend::list(prefix, cursor, limit); + } +private: + bool omit = true; +}; + +class ReplaceBeforeJanitorDeleteBackend : public CountingBackend +{ +public: + DeleteOutcome deleteExact(const String & key, const Token & token) override + { + if (!replaced) + { + replaced = true; + const auto current = InMemoryBackend::get(key); + if (current) + (void)InMemoryBackend::casPut(key, "winner", current->token); + } + return CountingBackend::deleteExact(key, token); + } +private: + bool replaced = false; +}; + +class TokenlessListBackend : public CountingBackend +{ +public: + ListPage list(const String & prefix, const String & cursor, size_t limit) override + { + ListPage page = CountingBackend::list(prefix, cursor, limit); + for (ListedKey & key : page.keys) + key.token.reset(); + return page; + } + + bool supportsListTokens() const override { return false; } + + HeadResult head(const String & key) override + { + HeadResult result = CountingBackend::head(key); + if (!replaced && result.exists && key == replace_on_head) + { + replaced = true; + (void)InMemoryBackend::casPut(key, "winner", result.token); + } + return result; + } + + String replace_on_head; + +private: + bool replaced = false; +}; + +class FenceLossDuringHeadBackend : public TokenlessListBackend +{ +public: + HeadResult head(const String & key) override + { + HeadResult result = TokenlessListBackend::head(key); + fence_held = false; + return result; + } + + bool fence_held = true; +}; + +class CatalogAfterListBackend : public CountingBackend +{ +public: + explicit CatalogAfterListBackend(NamespaceLifeId life_) : protected_life(std::move(life_)) {} + + ListPage list(const String & prefix, const String & cursor, size_t limit) override + { + ListPage page = CountingBackend::list(prefix, cursor, limit); + if (!published && prefix.ends_with("/cas/ns/")) + { + published = true; + const String catalog_key = "p/cas/ref_catalog"; + /// This models a CONCURRENT actor's read, not the janitor's own -- counting it here would + /// make `PostListCatalogCutProtectsConcurrentCreationWithOneGet`'s "exactly one get" assertion + /// count this simulated actor's read as the janitor's, defeating the point of that assertion. + const auto current = InMemoryBackend::get(catalog_key, {}); // NOLINT(bugprone-parent-virtual-call) + if (current) + { + RefCatalog catalog; + catalog.entries.push_back(CatalogEntry{.ns = protected_life.ns, .state = NsState::Live, + .incarnation = protected_life.incarnation}); + (void)InMemoryBackend::casPut(catalog_key, encodeRefCatalog(catalog), current->token); + } + } + return page; + } +private: + NamespaceLifeId protected_life; + bool published = false; +}; + +class RejectCursorBackend : public CountingBackend +{ +public: + ListPage list(const String & prefix, const String & cursor, size_t limit) override + { + if (prefix.ends_with("/cas/ns/") && !cursor.empty()) + throw std::runtime_error("backend rejected cursor"); + return CountingBackend::list(prefix, cursor, limit); + } +}; + +class FailMaintenancePublicationBackend : public CountingBackend +{ +public: + CasResult casPut(const String & key, const String & bytes, const std::optional & expected, + const ObjectMeta & meta) override + { + if (fail_publication && key.ends_with("/gc/maintenance_state")) + throw std::runtime_error("maintenance publication failed"); + return CountingBackend::casPut(key, bytes, expected, meta); + } + bool fail_publication = false; +}; + +void seedCatalog(CountingBackend & backend, const Layout & layout, RefCatalog catalog = {}) +{ + ASSERT_EQ(backend.putIfAbsent(layout.refCatalogKey(), encodeRefCatalog(catalog)).outcome, PutOutcome::Done); +} + +NamespaceLifeId life(const char * name, uint64_t id) +{ + const RootNamespace ns{name}; + return NamespaceLifeId::fromCatalogEntry(ns, UInt128{id}); +} + +} + +TEST(CASNamespaceJanitor, DeletesDeadFilesAndCheckpointFromOnePostListCatalogCut) +{ + CountingBackend backend; + const Layout layout("p"); + seedCatalog(backend, layout); + const auto dead = life("dead", 41); + const String file = layout.namespaceFilesPrefix(dead) + "part/data.bin"; + const String ckpt = layout.refCkptKey(dead); + ASSERT_EQ(backend.putIfAbsent(file, "file-bytes").outcome, PutOutcome::Done); + ASSERT_EQ(backend.putIfAbsent(ckpt, "ckpt-bytes").outcome, PutOutcome::Done); + backend.resetCounts(); + + NamespaceJanitor janitor(backend, layout, 100); + const NamespaceJanitorResult result = janitor.runOnePage(false, [] { return true; }); + + EXPECT_EQ(result.pages, 1u); + EXPECT_EQ(result.keys, 2u); + EXPECT_EQ(result.deleted, 2u); + EXPECT_FALSE(backend.get(file)); + EXPECT_FALSE(backend.get(ckpt)); + EXPECT_EQ(backend.listCount(layout.namespaceRootPrefix()), 1u); + EXPECT_EQ(backend.getCount(layout.refCatalogKey()), 1u); + EXPECT_EQ(readGcMaintenanceState(backend, layout).state, GcMaintenanceState{}); +} + +TEST(CASNamespaceJanitor, RetainsEveryCurrentLifecycleAndSuppressesAmbiguousCut) +{ + CountingBackend backend; + const Layout layout("p"); + RefCatalog catalog; + CatalogEntry creating{.ns = RootNamespace{"creating"}, .state = NsState::Creating, .incarnation = UInt128{51}, + .creator = CreatorFence{.server_root_id = "srv", .writer_epoch = 1, .fence_generation = 1}}; + CatalogEntry live{.ns = RootNamespace{"live"}, .state = NsState::Live, .incarnation = UInt128{52}}; + CatalogEntry removing{.ns = RootNamespace{"removing"}, .state = NsState::Removing, .incarnation = UInt128{53}, + .removal_started_round = 1}; + catalog.entries = {creating, live, removing}; + seedCatalog(backend, layout, catalog); + for (const auto & entry : catalog.entries) + ASSERT_EQ(backend.putIfAbsent(layout.refCkptKey( + NamespaceLifeId::fromCatalogEntry(entry.ns, entry.incarnation)), "keep").outcome, PutOutcome::Done); + + NamespaceJanitor janitor(backend, layout, 100); + const auto result = janitor.runOnePage(false, [] { return true; }); + EXPECT_EQ(result.deleted, 0u); + EXPECT_EQ(backend.deleteTotal(), 0u); +} + +TEST(CASNamespaceJanitor, CatalogFirstCreatingRetainsEveryObjectOfTheNewLife) +{ + CountingBackend backend; + const Layout layout("p"); + const CatalogEntry creating{ + .ns = RootNamespace{"catalog-first"}, + .state = NsState::Creating, + .incarnation = UInt128{54}, + .creator = CreatorFence{.server_root_id = "srv", .writer_epoch = 2, .fence_generation = 3}}; + seedCatalog(backend, layout, RefCatalog{.entries = {creating}}); + + /// The production creation order is the point: the catalog row is durable before either object. + const NamespaceLifeId creating_life + = NamespaceLifeId::fromCatalogEntry(creating.ns, creating.incarnation); + const String ckpt = layout.refCkptKey(creating_life); + const String file = layout.namespaceFilesPrefix(creating_life) + "data"; + ASSERT_EQ(backend.putIfAbsent(ckpt, "checkpoint").outcome, PutOutcome::Done); + ASSERT_EQ(backend.putIfAbsent(file, "file").outcome, PutOutcome::Done); + backend.resetCounts(); + + const NamespaceJanitorResult result + = NamespaceJanitor(backend, layout, 100).runOnePage(false, [] { return true; }); + + EXPECT_EQ(result.deleted, 0u); + EXPECT_EQ(backend.deleteTotal(), 0u); + EXPECT_EQ(backend.getCount(layout.refCatalogKey()), 1u); + EXPECT_TRUE(backend.get(ckpt)); + EXPECT_TRUE(backend.get(file)); +} + +TEST(CASNamespaceJanitor, CancelledCreatingCheckpointIsReclaimedThroughPublicLifecycle) +{ + CountingBackend backend; + const Layout layout("p"); + const CatalogEntry creating{ + .ns = RootNamespace{"cancelled"}, + .state = NsState::Creating, + .incarnation = UInt128{55}, + .creator = CreatorFence{.server_root_id = "dead-srv", .writer_epoch = 4, .fence_generation = 5}}; + seedCatalog(backend, layout, RefCatalog{.entries = {creating}}); + const String ckpt = layout.refCkptKey( + NamespaceLifeId::fromCatalogEntry(creating.ns, creating.incarnation)); + ASSERT_EQ(backend.putIfAbsent(ckpt, "cancelled-checkpoint").outcome, PutOutcome::Done); + + ASSERT_EQ(CasRefCatalog::cancelStalledCreating( + backend, layout, creating, [](const CreatorFence &) { return true; }, + /*admitted_generation=*/7, [](uint64_t) {}), + CasRefCatalog::StalledCreatingCancelOutcome::Cancelled); + EXPECT_TRUE(CasRefCatalog::read(backend, layout).catalog.entries.empty()); + + const NamespaceJanitorResult result + = NamespaceJanitor(backend, layout, 100).runOnePage(false, [] { return true; }); + EXPECT_EQ(result.deleted, 1u); + EXPECT_FALSE(backend.get(ckpt)); +} + +TEST(CASNamespaceJanitor, SuppressionAndFenceLossDeleteNothing) +{ + CountingBackend backend; + const Layout layout("p"); + seedCatalog(backend, layout); + const String first = layout.refCkptKey(life("dead-a", 61)); + const String second = layout.refCkptKey(life("dead-b", 62)); + ASSERT_EQ(backend.putIfAbsent(first, "first").outcome, PutOutcome::Done); + ASSERT_EQ(backend.putIfAbsent(second, "second").outcome, PutOutcome::Done); + + NamespaceJanitor janitor(backend, layout, 1); + EXPECT_EQ(janitor.runOnePage(true, [] { return true; }).deleted, 0u); + EXPECT_EQ(readGcMaintenanceState(backend, layout).status, GcMaintenanceReadStatus::Absent) + << "a globally suppressed page is undecided and must not mint cleanup progress"; + EXPECT_EQ(backend.putCount(layout.gcMaintenanceStateKey()), 0u); + EXPECT_EQ(backend.casPutCount(layout.gcMaintenanceStateKey()), 0u); + EXPECT_EQ(janitor.runOnePage(false, [] { return false; }).deleted, 0u); + EXPECT_EQ(readGcMaintenanceState(backend, layout).status, GcMaintenanceReadStatus::Absent) + << "fence loss must not mint progress past a page whose deletion was not authorized"; + EXPECT_TRUE(backend.get(first)); + EXPECT_TRUE(backend.get(second)); + EXPECT_EQ(backend.deleteTotal(), 0u); +} + +TEST(CASNamespaceJanitor, FenceLossOnRetainedOnlyPageDoesNotAdvanceCursor) +{ + CountingBackend backend; + const Layout layout("p"); + const CatalogEntry current{ + .ns = RootNamespace{"current"}, .state = NsState::Live, .incarnation = UInt128{63}}; + seedCatalog(backend, layout, RefCatalog{.entries = {current}}); + const NamespaceLifeId current_life + = NamespaceLifeId::fromCatalogEntry(current.ns, current.incarnation); + const String ckpt = layout.refCkptKey(current_life); + const String file = layout.namespaceFilesPrefix(current_life) + "data"; + ASSERT_EQ(backend.putIfAbsent(ckpt, "checkpoint").outcome, PutOutcome::Done); + ASSERT_EQ(backend.putIfAbsent(file, "file").outcome, PutOutcome::Done); + + const NamespaceJanitorResult result + = NamespaceJanitor(backend, layout, 1).runOnePage(false, [] { return false; }); + + EXPECT_EQ(result.deleted, 0u); + EXPECT_EQ(backend.deleteTotal(), 0u); + EXPECT_TRUE(backend.get(ckpt)); + EXPECT_TRUE(backend.get(file)); + EXPECT_EQ(readGcMaintenanceState(backend, layout).status, GcMaintenanceReadStatus::Absent) + << "a tenure that observes fence loss cannot publish progress even when every object was retained"; +} + +TEST(CASNamespaceJanitor, FenceLossAfterLastDeleteRetainsCursorWithoutRollingBackDelete) +{ + CountingBackend backend; + const Layout layout("p"); + seedCatalog(backend, layout); + const String dead = layout.refCkptKey(life("dead-after-delete", 64)); + ASSERT_EQ(backend.putIfAbsent(dead, "dead").outcome, PutOutcome::Done); + uint64_t fence_checks = 0; + + const NamespaceJanitorResult result + = NamespaceJanitor(backend, layout, 1).runOnePage(false, [&] { return fence_checks++ == 0; }); + + EXPECT_EQ(result.deleted, 1u); + EXPECT_FALSE(backend.get(dead)) + << "the exact delete completed under the fence and is never rolled back"; + EXPECT_EQ(fence_checks, 2u) + << "the fence must be checked before deletion and again immediately before cursor publication"; + EXPECT_EQ(readGcMaintenanceState(backend, layout).status, GcMaintenanceReadStatus::Absent) + << "losing the fence after the delete keeps this page selected for an idempotent retry"; +} + +TEST(CASNamespaceJanitor, CursorResumesThenResetsAtEnd) +{ + CountingBackend backend; + const Layout layout("p"); + seedCatalog(backend, layout); + const auto dead = life("dead", 71); + ASSERT_EQ(backend.putIfAbsent(layout.namespaceFilesPrefix(dead) + "a", "a").outcome, PutOutcome::Done); + ASSERT_EQ(backend.putIfAbsent(layout.namespaceFilesPrefix(dead) + "b", "b").outcome, PutOutcome::Done); + + NamespaceJanitor first_process(backend, layout, 1); + EXPECT_EQ(first_process.runOnePage(false, [] { return true; }).deleted, 1u); + const auto mid = readGcMaintenanceState(backend, layout); + ASSERT_EQ(mid.status, GcMaintenanceReadStatus::Valid); + ASSERT_TRUE(mid.state); + EXPECT_FALSE(mid.state->janitor_cursor.empty()); + NamespaceJanitor restarted_process(backend, layout, 1); + EXPECT_EQ(restarted_process.runOnePage(false, [] { return true; }).deleted, 1u); + EXPECT_TRUE(readGcMaintenanceState(backend, layout).state->janitor_cursor.empty()); +} + +TEST(CASNamespaceJanitor, TakesOneCatalogCutAfterListingAndContinuesPastMalformedKey) +{ + OrderedJanitorBackend backend; + const Layout layout("p"); + seedCatalog(backend, layout); + const auto dead = life("dead", 81); + const String valid = layout.namespaceFilesPrefix(dead) + "data"; + const String malformed = layout.namespaceStreamRootPrefix() + "not-a-life/_log/1-1.zst"; + const String malformed_state = layout.namespaceStateRootPrefix() + "not-a-life/_ckpt"; + ASSERT_EQ(backend.putIfAbsent(valid, "v").outcome, PutOutcome::Done); + ASSERT_EQ(backend.putIfAbsent(malformed, "bad").outcome, PutOutcome::Done); + ASSERT_EQ(backend.putIfAbsent(malformed_state, "bad-state").outcome, PutOutcome::Done); + backend.resetCounts(); + backend.events.clear(); + + const auto result = NamespaceJanitor(backend, layout, 100).runOnePage(false, [] { return true; }); + EXPECT_EQ(result.deleted, 1u); + EXPECT_FALSE(result.anomalies.empty()); + EXPECT_TRUE(backend.get(malformed)); + EXPECT_TRUE(backend.get(malformed_state)); + ASSERT_EQ(backend.events.size(), 2u); + EXPECT_EQ(backend.events[0], "list"); + EXPECT_EQ(backend.events[1], "catalog"); + EXPECT_EQ(backend.getCount(layout.refCatalogKey()), 1u); +} + +TEST(CASNamespaceJanitor, MalformedKeyIsFinalAndAdvancesCursor) +{ + CountingBackend backend; + const Layout layout("p"); + seedCatalog(backend, layout); + const String first = layout.namespaceStreamRootPrefix() + "bad-a/_log/1-1.zst"; + const String second = layout.namespaceStreamRootPrefix() + "bad-b/_log/1-1.zst"; + ASSERT_EQ(backend.putIfAbsent(first, "first").outcome, PutOutcome::Done); + ASSERT_EQ(backend.putIfAbsent(second, "second").outcome, PutOutcome::Done); + + const NamespaceJanitorResult result + = NamespaceJanitor(backend, layout, 1).runOnePage(false, [] { return true; }); + + EXPECT_EQ(result.deleted, 0u); + EXPECT_FALSE(result.anomalies.empty()); + EXPECT_TRUE(backend.get(first)); + EXPECT_TRUE(backend.get(second)); + const GcMaintenanceReadResult progress = readGcMaintenanceState(backend, layout); + ASSERT_EQ(progress.status, GcMaintenanceReadStatus::Valid); + ASSERT_TRUE(progress.state); + EXPECT_FALSE(progress.state->janitor_cursor.empty()) + << "malformed keys are surfaced and skipped, but do not pin the cleanup cycle"; +} + +TEST(CASNamespaceJanitor, DuplicateCurrentLifeSuppressesWholePage) +{ + CountingBackend backend; + const Layout layout("p"); + RefCatalog catalog; + catalog.entries = { + CatalogEntry{.ns = RootNamespace{"a"}, .state = NsState::Live, .incarnation = UInt128{91}}, + CatalogEntry{.ns = RootNamespace{"b"}, .state = NsState::Live, .incarnation = UInt128{91}}}; + seedCatalog(backend, layout, catalog); + const String dead_a = layout.refCkptKey(life("dead-a", 92)); + const String dead_b = layout.refCkptKey(life("dead-b", 93)); + ASSERT_EQ(backend.putIfAbsent(dead_a, "a").outcome, PutOutcome::Done); + ASSERT_EQ(backend.putIfAbsent(dead_b, "b").outcome, PutOutcome::Done); + const auto result = NamespaceJanitor(backend, layout, 1).runOnePage(false, [] { return true; }); + EXPECT_EQ(result.deleted, 0u); + EXPECT_EQ(backend.deleteTotal(), 0u); + EXPECT_TRUE(backend.get(dead_a)); + EXPECT_TRUE(backend.get(dead_b)); + EXPECT_EQ(readGcMaintenanceState(backend, layout).status, GcMaintenanceReadStatus::Absent) + << "an ambiguous catalog cut leaves the selected page undecided for an authoritative retry"; +} + +TEST(CASNamespaceJanitor, CorruptProgressResetsWithoutDeletingAndFilesOnlyOmittedCycleRetries) +{ + OmitFirstNamespacePageBackend backend; + const Layout layout("p"); + seedCatalog(backend, layout); + const String dead = layout.namespaceFilesPrefix(life("dead", 101)) + "only-residue"; + ASSERT_EQ(backend.putIfAbsent(dead, "bytes").outcome, PutOutcome::Done); + ASSERT_EQ(backend.putIfAbsent(layout.gcMaintenanceStateKey(), "corrupt").outcome, PutOutcome::Done); + EXPECT_EQ(NamespaceJanitor(backend, layout, 100).runOnePage(false, [] { return true; }).deleted, 0u); + EXPECT_TRUE(backend.get(dead)); + EXPECT_EQ(readGcMaintenanceState(backend, layout).status, GcMaintenanceReadStatus::Valid); + EXPECT_EQ(NamespaceJanitor(backend, layout, 100).runOnePage(false, [] { return true; }).deleted, 0u); + EXPECT_TRUE(backend.get(dead)); + EXPECT_EQ(NamespaceJanitor(backend, layout, 100).runOnePage(false, [] { return true; }).deleted, 1u); + EXPECT_FALSE(backend.get(dead)); +} + +TEST(CASNamespaceJanitor, ExactTokenMismatchRetainsConcurrentReplacement) +{ + ReplaceBeforeJanitorDeleteBackend backend; + const Layout layout("p"); + seedCatalog(backend, layout); + const String dead = layout.refCkptKey(life("dead-a", 111)); + const String later = layout.refCkptKey(life("dead-b", 112)); + ASSERT_EQ(backend.putIfAbsent(dead, "old").outcome, PutOutcome::Done); + ASSERT_EQ(backend.putIfAbsent(later, "later").outcome, PutOutcome::Done); + const auto result = NamespaceJanitor(backend, layout, 1).runOnePage(false, [] { return true; }); + EXPECT_EQ(result.deleted, 0u); + ASSERT_TRUE(backend.get(dead)); + EXPECT_EQ(backend.get(dead)->bytes, "winner"); + EXPECT_TRUE(backend.get(later)); + const GcMaintenanceReadResult progress = readGcMaintenanceState(backend, layout); + ASSERT_EQ(progress.status, GcMaintenanceReadStatus::Valid); + ASSERT_TRUE(progress.state); + EXPECT_FALSE(progress.state->janitor_cursor.empty()) + << "an exact-token mismatch retains the rewrite but completes this page's decision"; +} + +TEST(CASNamespaceJanitor, TokenlessListHeadsDeadKeysAndRetainsConcurrentReplacement) +{ + TokenlessListBackend backend; + const Layout layout("p"); + const CatalogEntry current{ + .ns = RootNamespace{"current"}, .state = NsState::Live, .incarnation = UInt128{161}}; + seedCatalog(backend, layout, RefCatalog{.entries = {current}}); + const String live_key = layout.refCkptKey(NamespaceLifeId::fromCatalogEntry(current.ns, current.incarnation)); + const String dead_key = layout.refCkptKey(life("dead", 162)); + const String raced_key = layout.namespaceFilesPrefix(life("raced", 163)) + "data"; + ASSERT_EQ(backend.putIfAbsent(live_key, "live").outcome, PutOutcome::Done); + ASSERT_EQ(backend.putIfAbsent(dead_key, "dead").outcome, PutOutcome::Done); + ASSERT_EQ(backend.putIfAbsent(raced_key, "old").outcome, PutOutcome::Done); + backend.replace_on_head = raced_key; + backend.resetCounts(); + + const auto result = NamespaceJanitor(backend, layout, 100).runOnePage(false, [] { return true; }); + + EXPECT_EQ(result.deleted, 1u); + EXPECT_TRUE(result.anomalies.empty()); + EXPECT_TRUE(backend.get(live_key)); + EXPECT_FALSE(backend.get(dead_key)); + ASSERT_TRUE(backend.get(raced_key)); + EXPECT_EQ(backend.get(raced_key)->bytes, "winner"); + EXPECT_EQ(backend.headCount(live_key), 0u); + EXPECT_EQ(backend.headCount(dead_key), 1u); + EXPECT_EQ(backend.headCount(raced_key), 1u); + EXPECT_EQ(backend.deleteCount(dead_key), 1u); + EXPECT_EQ(backend.deleteCount(raced_key), 1u); +} + +TEST(CASNamespaceJanitor, TokenlessListRechecksFenceAfterHeadBeforeDelete) +{ + FenceLossDuringHeadBackend backend; + const Layout layout("p"); + seedCatalog(backend, layout); + const String dead_key = layout.refCkptKey(life("dead", 164)); + ASSERT_EQ(backend.putIfAbsent(dead_key, "dead").outcome, PutOutcome::Done); + backend.resetCounts(); + + const auto result = NamespaceJanitor(backend, layout, 100).runOnePage( + false, [&] { return backend.fence_held; }); + + EXPECT_EQ(result.deleted, 0u); + EXPECT_EQ(backend.headCount(dead_key), 1u); + EXPECT_EQ(backend.deleteCount(dead_key), 0u); + EXPECT_TRUE(backend.get(dead_key)); +} + +TEST(CASNamespaceJanitor, PostListCatalogCutProtectsConcurrentCreationWithOneGet) +{ + const auto created = life("created", 121); + CatalogAfterListBackend backend(created); + const Layout layout("p"); + seedCatalog(backend, layout); + const String first = layout.refCkptKey(created); + const String second = layout.namespaceFilesPrefix(created) + "data"; + ASSERT_EQ(backend.putIfAbsent(first, "ckpt").outcome, PutOutcome::Done); + ASSERT_EQ(backend.putIfAbsent(second, "file").outcome, PutOutcome::Done); + backend.resetCounts(); + const auto result = NamespaceJanitor(backend, layout, 100).runOnePage(false, [] { return true; }); + EXPECT_EQ(result.deleted, 0u); + EXPECT_EQ(backend.deleteTotal(), 0u); + EXPECT_EQ(backend.getCount(layout.refCatalogKey()), 1u); + EXPECT_TRUE(backend.get(first)); + EXPECT_TRUE(backend.get(second)); +} + +TEST(CASNamespaceJanitor, BackendRejectedCursorResetsExactlyAndDeletesNothing) +{ + RejectCursorBackend backend; + const Layout layout("p"); + seedCatalog(backend, layout); + const String dead = layout.refCkptKey(life("dead", 131)); + ASSERT_EQ(backend.putIfAbsent(dead, "bytes").outcome, PutOutcome::Done); + ASSERT_EQ(backend.putIfAbsent(layout.gcMaintenanceStateKey(), + encodeGcMaintenanceState({.janitor_cursor = "rejected"})).outcome, PutOutcome::Done); + EXPECT_THROW(NamespaceJanitor(backend, layout, 100).runOnePage(false, [] { return true; }), std::runtime_error); + EXPECT_EQ(backend.deleteTotal(), 0u); + EXPECT_TRUE(backend.get(dead)); + EXPECT_TRUE(readGcMaintenanceState(backend, layout).state->janitor_cursor.empty()); +} + +TEST(CASNamespaceJanitor, CursorPublicationFailureIsLeakOnly) +{ + FailMaintenancePublicationBackend backend; + const Layout layout("p"); + seedCatalog(backend, layout); + const String dead = layout.refCkptKey(life("dead", 141)); + ASSERT_EQ(backend.putIfAbsent(dead, "bytes").outcome, PutOutcome::Done); + backend.fail_publication = true; + const auto result = NamespaceJanitor(backend, layout, 100).runOnePage(false, [] { return true; }); + EXPECT_EQ(result.deleted, 1u); + EXPECT_FALSE(result.anomalies.empty()); + EXPECT_FALSE(backend.get(dead)); +} + +TEST(CASNamespaceJanitorIntegration, RegularGcRoundDeletesDeadNamespaceBytes) +{ + auto backend = std::make_shared(); + auto store = openPoolForTest(backend, /*gc_fold_max_defer_rounds=*/0); + const Layout & layout = store->layout(); + const RootNamespace live_namespace{"00/live@cas@"}; + fixture::admitLive(*backend, layout, live_namespace); + ASSERT_EQ(backend->putIfAbsent(layout.refCkptKey(fixture::fixtureLife(live_namespace)), + encodeRefCkpt(RefCkpt{.life_epoch = std::optional{1}, + .checkpoint_snapshot_id = std::nullopt, .last_epoch_seal = std::nullopt})).outcome, + PutOutcome::Done); + const String dead = layout.refCkptKey(life("dead", 151)); + ASSERT_EQ(backend->putIfAbsent(dead, "checkpoint").outcome, PutOutcome::Done); + + std::map namespace_cleanup; + Gc gc(store, UInt128{152}); + gc.setPhaseSink([&](const GcPhaseRecord & record) + { + if (record.phase == "namespace_cleanup") + namespace_cleanup = record.metrics; + }); + const RoundReport report = runRegularRoundReclaiming(gc); + gc.setPhaseSink({}); + + ASSERT_TRUE(report.acquired_lease); + EXPECT_FALSE(backend->get(dead)); + ASSERT_FALSE(namespace_cleanup.empty()); + EXPECT_EQ(namespace_cleanup["janitor_pages"], 1u); + EXPECT_GE(namespace_cleanup["janitor_keys"], 1u); + EXPECT_EQ(namespace_cleanup["janitor_deleted"], 1u); +} diff --git a/src/Disks/tests/gtest_cas_namespace_life_id.cpp b/src/Disks/tests/gtest_cas_namespace_life_id.cpp new file mode 100644 index 000000000000..9c2156d52aea --- /dev/null +++ b/src/Disks/tests/gtest_cas_namespace_life_id.cpp @@ -0,0 +1,429 @@ +#include +#include +#include +#include +#include +/// Explicit rather than relying on a transitive path: `DEBUG_OR_SANITIZER_BUILD` (used below to gate +/// the `*DeathTest` split) must resolve in THIS translation unit. +#include + +#include +#include + +using namespace DB::Cas; + +namespace DB::ErrorCodes +{ + extern const int CORRUPTED_DATA; +} + +namespace +{ + +/// The two incarnations of ONE namespace used throughout: distinct, nonzero, and rendering to two +/// hex segments that differ in the first character, so a key that carried the wrong one is visible +/// in the failure message rather than hidden in the tail of 32 digits. +UInt128 incarnationA() +{ + return (static_cast(0x1122'3344'5566'7788ULL) << 64) | static_cast(0x99aa'bbcc'ddee'ff01ULL); +} + +UInt128 incarnationB() +{ + return (static_cast(0xfedc'ba98'7654'3210ULL) << 64) | static_cast(0x0123'4567'89ab'cdefULL); +} + +const String kNs = "srv1/tbl@cas@"; +const String kHexA = "112233445566778899aabbccddeeff01"; +const String kHexB = "fedcba98765432100123456789abcdef"; +const String kTxn = "0000000000000007-000000000000008e"; + +/// Asserts that `body` refuses with CORRUPTED_DATA and that the message names `key` -- the refusal is +/// only useful to an operator if it says which object was rejected (the CI-observability rule). +template +void expectRefusalNaming(F && body, const String & key) +{ + try + { + std::forward(body)(); + FAIL() << "expected a refusal for key '" << key << "', got none"; + } + catch (const DB::Exception & e) + { + EXPECT_EQ(e.code(), DB::ErrorCodes::CORRUPTED_DATA) << "for key '" << key << "'"; + EXPECT_NE(e.message().find(key), String::npos) + << "refusal does not name the offending key '" << key << "'; message: " << e.message(); + } +} + +/// The compile-time half of spec §9 r9-5 #3, one pair per migrated helper. The NEGATIVE proves the +/// namespace-only overload is gone; the POSITIVE proves the concept is actually looking at a real +/// member, so a typo in the requires-clause cannot make the negative pass vacuously. Both halves are +/// genuinely templated on `L`, so a missing member is a substitution failure rather than a hard error. +template +concept HasNamespaceOnlyRefsNamespacePrefix = + requires(const L & l, const RootNamespace & ns) { l.namespaceStreamPrefix(ns); }; +template +concept HasIncarnationRefsNamespacePrefix = + requires(const L & l, const NamespaceLifeId & id) { l.namespaceStreamPrefix(id); }; + +template +concept HasNamespaceOnlyRefLogKey = + requires(const L & l, const RootNamespace & ns, const RefTxnId & id) { l.refLogKey(ns, id); }; +template +concept HasIncarnationRefLogKey = + requires(const L & l, const NamespaceLifeId & ns_id, const RefTxnId & id) { l.refLogKey(ns_id, id); }; + +template +concept HasNamespaceOnlyRefSnapshotKey = + requires(const L & l, const RootNamespace & ns, const RefTxnId & id) { l.refSnapshotKey(ns, id); }; +template +concept HasIncarnationRefSnapshotKey = + requires(const L & l, const NamespaceLifeId & ns_id, const RefTxnId & id) { l.refSnapshotKey(ns_id, id); }; + +template +concept HasNamespaceOnlyRefCkptKey = + requires(const L & l, const RootNamespace & ns) { l.refCkptKey(ns); }; +template +concept HasIncarnationRefCkptKey = + requires(const L & l, const NamespaceLifeId & id) { l.refCkptKey(id); }; + +/// The namespace-FILE half of the same pattern (directive §1: "Delete all namespace-only ref and +/// namespace-file key overloads"), paired the same way. +template +concept HasNamespaceOnlyNamespaceFileKey = + requires(const L & l, const RootNamespace & ns, const String & n) { l.namespaceFileKey(ns, n); }; +template +concept HasIncarnationNamespaceFileKey = + requires(const L & l, const NamespaceLifeId & life, const String & n) { l.namespaceFileKey(life, n); }; + +template +concept HasNamespaceOnlyNamespaceFilesPrefix = + requires(const L & l, const RootNamespace & ns) { l.namespaceFilesPrefix(ns); }; +template +concept HasIncarnationNamespaceFilesPrefix = + requires(const L & l, const NamespaceLifeId & life) { l.namespaceFilesPrefix(life); }; + +/// The two OUT-OF-SCOPE families (Constraint 12, directive §2 "Keep these unchanged"): loose +/// mountpoint objects and part manifests keep the identity they have today. Each is paired in the +/// opposite direction from the migrated helpers -- the POSITIVE is the un-life-scoped overload that +/// must survive, the NEGATIVE is the life-scoped overload that must never appear. +template +concept HasUnscopedMountpointObjectKey = + requires(const L & l, const String & key) { l.mountpointObjectKey(key); }; +template +concept HasLifeScopedMountpointObjectKey = + requires(const L & l, const NamespaceLifeId & life, const String & key) { l.mountpointObjectKey(life, key); }; + +template +concept HasNamespaceOnlyManifestNamespacePrefix = + requires(const L & l, const RootNamespace & ns) { l.manifestNamespacePrefix(ns); }; +template +concept HasLifeScopedManifestNamespacePrefix = + requires(const L & l, const NamespaceLifeId & life) { l.manifestNamespacePrefix(life); }; + +} + +/// Every ref-layer key names one life by an opaque fixed-width physical id. The logical namespace is +/// intentionally absent and must be supplied by a catalog join. +TEST(CASNamespaceLifeId, KeysCarryTheIncarnationSegment) +{ + Layout l("p"); + const NamespaceLifeId id = NamespaceLifeId::fromCatalogEntry(RootNamespace{kNs}, incarnationA()); + const RefTxnId txn{7, 0x8e}; + const String life = "p/cas/ns/stream/" + kHexA + "/"; + const String state = "p/cas/ns/state/" + kHexA + "/"; + + EXPECT_EQ(l.namespaceStreamPrefix(id), life); + EXPECT_EQ(l.refLogKey(id, txn), life + "_log/" + kTxn + ".zst"); + EXPECT_EQ(l.refSnapshotKey(id, txn), life + "_snap/" + kTxn + ".zst"); + EXPECT_EQ(l.refCkptKey(id), state + "_ckpt"); + + const auto parsed_log = l.parseRefObjectKey(l.refLogKey(id, txn)); + ASSERT_TRUE(parsed_log.has_value()); + EXPECT_EQ(parsed_log->life_id, id.incarnation); + EXPECT_EQ(parsed_log->kind, RefObjectKind::Log); + EXPECT_EQ(parsed_log->txn_id, txn); + + const auto parsed_snap = l.parseRefObjectKey(l.refSnapshotKey(id, txn)); + ASSERT_TRUE(parsed_snap.has_value()); + EXPECT_EQ(parsed_snap->life_id, id.incarnation); + EXPECT_EQ(parsed_snap->kind, RefObjectKind::Snap); + + EXPECT_EQ(l.parseRefCkptKey(l.refCkptKey(id)), id.incarnation); +} + +/// The property the type exists for: two lives of the SAME namespace name share no key at all, so a +/// reborn namespace can neither read nor delete the previous life's objects by name. +TEST(CASNamespaceLifeId, TwoLivesOfOneNamespaceShareNoKeys) +{ + Layout l("p"); + const RefTxnId txn{7, 0x8e}; + const NamespaceLifeId first = NamespaceLifeId::fromCatalogEntry(RootNamespace{kNs}, incarnationA()); + const NamespaceLifeId second = NamespaceLifeId::fromCatalogEntry(RootNamespace{kNs}, incarnationB()); + + EXPECT_EQ(first.ns, second.ns); + EXPECT_NE(first, second); + EXPECT_NE(l.namespaceStreamPrefix(first), l.namespaceStreamPrefix(second)); + EXPECT_NE(l.refLogKey(first, txn), l.refLogKey(second, txn)); + EXPECT_NE(l.refCkptKey(first), l.refCkptKey(second)); + + /// Neither life's prefix covers the other: a LIST of one enumerates only its own objects. + EXPECT_FALSE(l.refLogKey(second, txn).starts_with(l.namespaceStreamPrefix(first))); + EXPECT_FALSE(l.refLogKey(first, txn).starts_with(l.namespaceStreamPrefix(second))); + + /// A key spelling the other life parses back to the OTHER id -- the parser reports what the key + /// says; it is the catalog, not the parser, that decides which lives are current. + const auto parsed = l.parseRefObjectKey(l.refLogKey(second, txn)); + ASSERT_TRUE(parsed.has_value()); + EXPECT_EQ(parsed->life_id, second.incarnation); + EXPECT_NE(parsed->life_id, first.incarnation); +} + +/// Zero is not a wildcard and not "the namespace itself": it can never be constructed, so it can +/// never reach a key builder. +/// +/// Both throws below raise `LOGICAL_ERROR`, which aborts the process in debug/sanitizer builds +/// instead of behaving like a catchable exception (`Common/Exception.cpp`'s `handle_error_code`) -- +/// `CASNamespaceLifeIdDeathTest.ZeroIncarnationIsUnconstructibleAborts` below proves the abort +/// positively in those builds instead. +#ifndef DEBUG_OR_SANITIZER_BUILD +TEST(CASNamespaceLifeId, ZeroIncarnationIsUnconstructible) +{ + EXPECT_THROW(NamespaceLifeId::fromCatalogEntry(RootNamespace{kNs}, UInt128{0}), DB::Exception); + EXPECT_THROW(renderIncarnation(UInt128{0}), DB::Exception); + EXPECT_NO_THROW(NamespaceLifeId::fromCatalogEntry(RootNamespace{kNs}, incarnationA())); +} +#endif + +#if defined(DEBUG_OR_SANITIZER_BUILD) +TEST(CASNamespaceLifeIdDeathTest, ZeroIncarnationIsUnconstructibleAborts) +{ + EXPECT_DEATH({ (void)NamespaceLifeId::fromCatalogEntry(RootNamespace{kNs}, UInt128{0}); }, "incarnation must be nonzero"); + EXPECT_DEATH({ (void)renderIncarnation(UInt128{0}); }, "incarnation must be nonzero"); + EXPECT_NO_THROW(NamespaceLifeId::fromCatalogEntry(RootNamespace{kNs}, incarnationA())); +} +#endif + +/// Generation-5 namespace-bearing keys are outside the generation-6 parser roots altogether. Pool +/// admission rejects their generation before any listed-key parser is involved. +TEST(CASNamespaceLifeId, GenerationFiveNamespaceBearingKeysAreOutsideTheFinalGrammar) +{ + Layout l("p"); + const String legacy_log = "p/cas/refs/" + kNs + "/_log/" + kTxn + ".zst"; + const String legacy_snap = "p/cas/refs/" + kNs + "/_snap/" + kTxn + ".zst"; + const String legacy_cleanup = "p/cas/refs/" + kNs + "/_cleanup/" + kTxn; + const String legacy_ckpt = "p/cas/refs/" + kNs + "/_ckpt"; + /// A single-segment namespace leaves nothing at all where the incarnation belongs. + const String legacy_single_segment = "p/cas/refs/srv1/_log/" + kTxn + ".zst"; + + EXPECT_FALSE(l.parseRefObjectKey(legacy_log)); + EXPECT_FALSE(l.parseRefObjectKey(legacy_snap)); + EXPECT_FALSE(l.parseRefObjectKey(legacy_cleanup)); + EXPECT_FALSE(l.parseRefObjectKey(legacy_single_segment)); + EXPECT_FALSE(l.parseRefCkptKey(legacy_ckpt)); +} + +/// An all-zero incarnation segment is well-formed hex naming no life, so it is corruption on the read +/// side exactly as it is unconstructible on the write side. +TEST(CASNamespaceLifeId, ParsersRefuseAZeroIncarnation) +{ + Layout l("p"); + const String zeros(32, '0'); + const String zero_log = "p/cas/ns/stream/" + zeros + "/_log/" + kTxn + ".zst"; + const String zero_ckpt = "p/cas/ns/state/" + zeros + "/_ckpt"; + + expectRefusalNaming([&] { l.parseRefObjectKey(zero_log); }, zero_log); + expectRefusalNaming([&] { l.parseRefCkptKey(zero_ckpt); }, zero_ckpt); +} + +/// The incarnation segment has ONE canonical spelling. A key that is nearly right -- wrong width, +/// upper case, a non-hex digit -- is refused rather than repaired, so two spellings of one life can +/// never both exist. +TEST(CASNamespaceLifeId, ParsersRefuseAMalformedIncarnationSegment) +{ + Layout l("p"); + const String upper = "112233445566778899AABBCCDDEEFF01"; + const String too_short = kHexA.substr(0, 31); + const String too_long = kHexA + "0"; + const String non_hex = kHexA.substr(0, 31) + "z"; + + for (const String & bad : {upper, too_short, too_long, non_hex}) + { + const String log_key = "p/cas/ns/stream/" + bad + "/_log/" + kTxn + ".zst"; + const String ckpt_key = "p/cas/ns/state/" + bad + "/_ckpt"; + expectRefusalNaming([&] { l.parseRefObjectKey(log_key); }, log_key); + expectRefusalNaming([&] { l.parseRefCkptKey(ckpt_key); }, ckpt_key); + } +} + +/// The boundary between "corrupt" and "not ours". Refusal is reserved for keys the parser has already +/// recognized as OUR ref objects; anything else keeps returning `std::nullopt`, because classifying an +/// untrusted listed key remains an ordinary "is this ours" question and a sweep must be able to walk +/// past foreign debris without an exception. +TEST(CASNamespaceLifeId, ForeignAndUnrecognizedKeysStayInert) +{ + Layout l("p"); + const NamespaceLifeId id = NamespaceLifeId::fromCatalogEntry(RootNamespace{kNs}, incarnationA()); + const RefTxnId txn{7, 0x8e}; + + /// Foreign top-level prefix (another pool, another subtree). + EXPECT_FALSE(l.parseRefObjectKey("q/cas/refs/" + kNs + "/" + kHexA + "/_log/" + kTxn + ".zst").has_value()); + EXPECT_FALSE(l.parseRefCkptKey("q/cas/refs/" + kNs + "/" + kHexA + "/_ckpt").has_value()); + EXPECT_FALSE(l.parseRefObjectKey("p/cas/manifests/" + kNs + "/" + kHexA + "/_log/" + kTxn).has_value()); + /// An unrecognized kind directory is not one of our ref objects, so its incarnation segment is + /// never even reached. + EXPECT_FALSE(l.parseRefObjectKey("p/cas/refs/" + kNs + "/_bogus/" + kTxn).has_value()); + /// A non-canonical transaction id likewise loses the key before the incarnation is judged. + EXPECT_FALSE(l.parseRefObjectKey(l.namespaceStreamPrefix(id) + "_log/7-8e").has_value()); + /// The two parsers stay disjoint: neither claims the other's objects. + EXPECT_FALSE(l.parseRefObjectKey(l.refCkptKey(id)).has_value()); + EXPECT_FALSE(l.parseRefCkptKey(l.refLogKey(id, txn)).has_value()); + /// No namespace and no incarnation at all. + EXPECT_FALSE(l.parseRefObjectKey("p/cas/refs/_log/" + kTxn + ".zst").has_value()); + EXPECT_FALSE(l.parseRefCkptKey("p/cas/refs/_ckpt").has_value()); +} + +/// Namespace files are life-keyed too: `cas/ns/state//_files/`. The +/// round trip covers a flat name and a NESTED one, because the dedup log's segments live in a +/// table-level subdirectory and the nested shape is the one on the insert path. +TEST(CASNamespaceLifeId, NamespaceFileKeysCarryTheIncarnationSegment) +{ + Layout l("p"); + const NamespaceLifeId life = NamespaceLifeId::fromCatalogEntry(RootNamespace{kNs}, incarnationA()); + const String files = "p/cas/ns/state/" + kHexA + "/_files/"; + + EXPECT_EQ(l.namespaceFilesPrefix(life), files); + EXPECT_EQ(l.namespaceFileKey(life, "format_version.txt"), files + "format_version.txt"); + EXPECT_EQ(l.namespaceFileKey(life, "deduplication_logs/deduplication_log_1.txt"), + files + "deduplication_logs/deduplication_log_1.txt"); + + for (const String & name : {String("format_version.txt"), String("deduplication_logs/deduplication_log_1.txt")}) + { + const auto parsed = l.parseNamespaceFileKey(l.namespaceFileKey(life, name)); + ASSERT_TRUE(parsed.has_value()) << "for name '" << name << "'"; + EXPECT_EQ(parsed->life_id, life.incarnation); + EXPECT_EQ(parsed->relative_name, name); + } + + /// Two lives of one namespace share no file key either, and neither files prefix covers the other. + const NamespaceLifeId second = NamespaceLifeId::fromCatalogEntry(RootNamespace{kNs}, incarnationB()); + EXPECT_NE(l.namespaceFileKey(life, "format_version.txt"), l.namespaceFileKey(second, "format_version.txt")); + EXPECT_FALSE(l.namespaceFileKey(second, "format_version.txt").starts_with(l.namespaceFilesPrefix(life))); +} + +/// Generation-5 namespace-bearing file keys are outside the final parser root. Malformed ids under the +/// final state root are corruption and name the offending key. +TEST(CASNamespaceLifeId, NamespaceFileParserRefusesLegacyAndMalformedIncarnations) +{ + Layout l("p"); + const String zeros(32, '0'); + const String legacy = "p/roots/" + kNs + "/_files/format_version.txt"; + /// A single-segment namespace leaves nothing at all where the incarnation belongs. + const String legacy_single_segment = "p/roots/srv1/_files/format_version.txt"; + const String zero_inc = "p/cas/ns/state/" + zeros + "/_files/format_version.txt"; + + EXPECT_FALSE(l.parseNamespaceFileKey(legacy)); + EXPECT_FALSE(l.parseNamespaceFileKey(legacy_single_segment)); + expectRefusalNaming([&] { l.parseNamespaceFileKey(zero_inc); }, zero_inc); + + const String upper = "112233445566778899AABBCCDDEEFF01"; + const String too_short = kHexA.substr(0, 31); + const String too_long = kHexA + "0"; + const String non_hex = kHexA.substr(0, 31) + "z"; + for (const String & bad : {upper, too_short, too_long, non_hex}) + { + const String key = "p/cas/ns/state/" + bad + "/_files/format_version.txt"; + expectRefusalNaming([&] { l.parseNamespaceFileKey(key); }, key); + } +} + +/// The corrupt/not-ours boundary for file keys, mirroring the ref parsers': refusal is reserved for +/// keys already identified as OUR namespace files by their reserved `_files` segment. A loose +/// mountpoint object has no such segment and is a legitimate inhabitant of `roots/`, so it must parse +/// as `std::nullopt` and never as damage. +TEST(CASNamespaceLifeId, ForeignAndMountpointKeysStayInertForTheFileParser) +{ + Layout l("p"); + const NamespaceLifeId life = NamespaceLifeId::fromCatalogEntry(RootNamespace{kNs}, incarnationA()); + + EXPECT_FALSE(l.parseNamespaceFileKey("q/roots/" + kNs + "/" + kHexA + "/_files/x").has_value()); + EXPECT_FALSE(l.parseNamespaceFileKey(l.mountpointObjectKey("srv1/clickhouse_access_check_abc")).has_value()); + EXPECT_FALSE(l.parseNamespaceFileKey("p/cas/refs/" + kNs + "/" + kHexA + "/_files/x").has_value()); + /// The files prefix itself names no file: there is no relative name after the reserved segment. + EXPECT_FALSE(l.parseNamespaceFileKey(l.namespaceFilesPrefix(life)).has_value()); + /// And the ref parsers do not claim a file key. + EXPECT_FALSE(l.parseRefObjectKey(l.namespaceFileKey(life, "x")).has_value()); + EXPECT_FALSE(l.parseRefCkptKey(l.namespaceFileKey(life, "x")).has_value()); +} + +/// Physical file keys use only `life_id`, so changing the logical spelling cannot redirect a key. A +/// relative name may still contain `_files` and round-trips after the fixed delimiter. +TEST(CASNamespaceLifeId, PhysicalFileKeysIgnoreLogicalNamespaceSpelling) +{ + Layout l("p"); + const NamespaceLifeId life = NamespaceLifeId::fromCatalogEntry(RootNamespace{kNs}, incarnationA()); + const NamespaceLifeId differently_named = NamespaceLifeId::fromCatalogEntry( + RootNamespace{"different/_files/spelling"}, incarnationA()); + EXPECT_EQ(l.namespaceFilesPrefix(life), l.namespaceFilesPrefix(differently_named)); + const String nested_name = "deduplication_logs/_files/log_1.txt"; + const auto parsed = l.parseNamespaceFileKey(l.namespaceFileKey(life, nested_name)); + ASSERT_TRUE(parsed.has_value()); + EXPECT_EQ(parsed->life_id, life.incarnation); + EXPECT_EQ(parsed->relative_name, nested_name); +} + +/// The "cannot compile" half of spec §9 r9-5 #3: after this task there is no way to reach a ref-layer +/// key from a namespace alone, so dropping the incarnation is a compile error rather than an aliasing +/// bug. Each helper is asserted twice -- the namespace-only form absent, the incarnation form present. +TEST(CASNamespaceLifeId, NamespaceOnlyKeyHelpersDoNotExist) +{ + static_assert(!HasNamespaceOnlyRefsNamespacePrefix); + static_assert(HasIncarnationRefsNamespacePrefix); + + static_assert(!HasNamespaceOnlyRefLogKey); + static_assert(HasIncarnationRefLogKey); + + static_assert(!HasNamespaceOnlyRefSnapshotKey); + static_assert(HasIncarnationRefSnapshotKey); + + static_assert(!HasNamespaceOnlyRefCkptKey); + static_assert(HasIncarnationRefCkptKey); + + static_assert(!HasNamespaceOnlyNamespaceFileKey); + static_assert(HasIncarnationNamespaceFileKey); + + static_assert(!HasNamespaceOnlyNamespaceFilesPrefix); + static_assert(HasIncarnationNamespaceFilesPrefix); + + SUCCEED(); +} + +/// Directive §1's remaining requirements on the type, fenced rather than fixed: the type declares no +/// conversion operator and no `RootNamespace` constructor takes a `NamespaceLifeId`, so nothing +/// interconverts in either direction today and only an explicit `.ns` crosses. Without these +/// assertions a later convenience conversion would land unnoticed, and dropping the incarnation would +/// become representable again -- which is the property the whole re-keying rests on. +TEST(CASNamespaceLifeId, NamespaceLifeIdAndRootNamespaceDoNotInterconvert) +{ + static_assert(!std::convertible_to); + static_assert(!std::constructible_from); + static_assert(!std::is_default_constructible_v); + + SUCCEED(); +} + +/// The out-of-scope fences, and they are POSITIVE on purpose: Constraint 12 keeps loose mountpoint +/// objects and part manifests on the identity they have today, so this task must NOT have qualified +/// them. If a negative here fails, someone added a life-scoped overload to a family the amendment +/// explicitly excluded; if a positive fails, someone removed the un-scoped one those callers use. +TEST(CASNamespaceLifeId, MountpointObjectsAndManifestsStayUnqualified) +{ + static_assert(HasUnscopedMountpointObjectKey); + static_assert(!HasLifeScopedMountpointObjectKey); + + static_assert(HasNamespaceOnlyManifestNamespacePrefix); + static_assert(!HasLifeScopedManifestNamespacePrefix); + + SUCCEED(); +} diff --git a/src/Disks/tests/gtest_cas_ns_creation_lifecycle.cpp b/src/Disks/tests/gtest_cas_ns_creation_lifecycle.cpp new file mode 100644 index 000000000000..7acab161e63d --- /dev/null +++ b/src/Disks/tests/gtest_cas_ns_creation_lifecycle.cpp @@ -0,0 +1,530 @@ +#include "cas_test_helpers.h" +#include +#include +#include +/// Explicit rather than relying on a transitive path: `DEBUG_OR_SANITIZER_BUILD` (used below to gate +/// the `*DeathTest` split) must resolve in THIS translation unit. +#include +#include + +using namespace DB::Cas; + +namespace DB::ErrorCodes +{ + extern const int LOGICAL_ERROR; + extern const int NETWORK_ERROR; +} + +/// Stage B Task 3 (spec §3, the ref-chain catalog's creation lifecycle): the three-conditional-write +/// sequence that carries a namespace from nothing to `Live` -- +/// 1. catalog CAS: insert `{ns, Creating, fresh incarnation, creator}` (`CasRefCatalog::createNamespace`, +/// built on Task 2's `casAdmitEntry`); +/// 2. `_ckpt` create (`CasRefCatalog::completeCreation`, step 2 -- Stage A's `publishCkpt` unchanged); +/// 3. catalog CAS: `Creating -> Live`, re-presenting the creator's admission GENERATION and +/// value-CASing the OBSERVED entry (`completeCreation`, step 3 -- the "ZombieGoLive" guard) -- +/// plus stale-`Creating` reconciliation (`CasRefCatalog::reconcileStaleCreator`) and the publication +/// gate (`checkPublicationAdmittedOrThrow`). +/// +/// The suite name is prefixed `Cas` so it is covered by the `Cas*` unit-test gate filter. + +namespace +{ + +/// A fence that never refuses, for tests whose subject is not the fence -- same helper, same intent, +/// as `gtest_cas_ref_ckpt.cpp`'s identically-named constant (not shared: each `_ckpt`/catalog test file +/// defines its own copy, matching that file's own precedent). +const std::function ALWAYS_ADMITTED = [](uint64_t) {}; + +/// A deadline far enough out that only the test's own contention decides the outcome -- mirrors +/// `gtest_cas_ref_ckpt.cpp`'s `generousDeadline`. +CkptDeadline generousDeadline() +{ + return CkptDeadline{[] { return uint64_t{1000}; }, 60000}; +} + +CreatorFence creatorFence(const String & srid, uint64_t writer_epoch, uint64_t fence_generation = 1) +{ + return CreatorFence{.server_root_id = srid, .writer_epoch = writer_epoch, .fence_generation = fence_generation}; +} + +/// A `is_creator_fence_terminal` stub that answers the same fixed verdict for every fence -- for tests +/// whose subject is not terminality itself (that predicate's own tests live in `gtest_cas_mount.cpp`, +/// next to `isCreatorFenceTerminal`, the real implementation this stub stands in for). +std::function fixedTerminality(bool terminal) +{ + return [terminal](const CreatorFence &) { return terminal; }; +} + +const CatalogEntry * findEntryForTest(const RefCatalog & catalog, const RootNamespace & ns) +{ + for (const CatalogEntry & e : catalog.entries) + if (e.ns.string() == ns.string()) + return &e; + return nullptr; +} + +/// Raw lifecycle tests operate below `Pool::open`, so model an already-bootstrapped pool explicitly. +class InitializedCatalogBackend : public InMemoryBackend +{ +public: + InitializedCatalogBackend() + { + CasRefCatalog::initializeEmptyForNewPool(*this, Layout("p")); + } +}; + +} + +/// --------------------------------------------------------------------------------------------- +/// Happy path: all three writes land +/// --------------------------------------------------------------------------------------------- + +TEST(CASNsCreationLifecycle, HappyPathReachesLiveWithADurableCkptAndAStableIncarnation) +{ + InitializedCatalogBackend backend; + Layout layout("p"); + const RootNamespace ns{"a"}; + const CreatorFence creator = creatorFence("srv1", /*writer_epoch=*/5); + + const auto outcome = CasRefCatalog::createNamespace( + backend, layout, 1, ns, creator, /*admitted_generation=*/1, ALWAYS_ADMITTED, generousDeadline()); + EXPECT_EQ(outcome, CasRefCatalog::NamespaceCreationOutcome::Live); + + const CasRefCatalog::Snapshot snap = CasRefCatalog::read(backend, layout); + const CatalogEntry * entry = findEntryForTest(snap.catalog, ns); + ASSERT_NE(entry, nullptr); + EXPECT_EQ(entry->state, NsState::Live); + EXPECT_EQ(entry->creator, std::nullopt) << "creator is forbidden outside Creating (strict grammar)"; + const UInt128 incarnation = entry->incarnation; + EXPECT_NE(incarnation, UInt128(0)); + + const std::optional ckpt = readCkpt(backend, layout, NamespaceLifeId::fromCatalogEntry(entry->ns, incarnation)); + ASSERT_TRUE(ckpt.has_value()) << "step 2's _ckpt must be durable"; + EXPECT_EQ(ckpt->ckpt.life_epoch, 5u) << "INV-4's genesis epoch is the creator's writer_epoch"; + + /// Re-reading the catalog again must show the SAME incarnation -- nothing mints a second one. + EXPECT_EQ(CasRefCatalog::read(backend, layout).catalog.entries.at(0).incarnation, incarnation); +} + +/// --------------------------------------------------------------------------------------------- +/// `createNamespace` refuses a namespace that already has an entry (Task 2 review's own note: this +/// is Task 3's job, not `casAdmitEntry`'s duplicate-namespace grammar refusal). +/// --------------------------------------------------------------------------------------------- + +#ifndef DEBUG_OR_SANITIZER_BUILD +TEST(CASNsCreationLifecycle, CreateNamespaceRejectsAnAlreadyExistingEntry) +{ + InitializedCatalogBackend backend; + Layout layout("p"); + const RootNamespace ns{"a"}; + const CreatorFence creator = creatorFence("srv1", 1); + ASSERT_EQ(CasRefCatalog::createNamespace(backend, layout, 1, ns, creator, 1, ALWAYS_ADMITTED, generousDeadline()), + CasRefCatalog::NamespaceCreationOutcome::Live); + + DB::Cas::tests::expectThrowsCode(DB::ErrorCodes::LOGICAL_ERROR, [&] + { + CasRefCatalog::createNamespace(backend, layout, 1, ns, creatorFence("srv2", 2), 1, ALWAYS_ADMITTED, generousDeadline()); + }); +} +#endif + +#if defined(DEBUG_OR_SANITIZER_BUILD) +TEST(CASNsCreationLifecycleDeathTest, CreateNamespaceRejectsAnAlreadyExistingEntryAborts) +{ + InitializedCatalogBackend backend; + Layout layout("p"); + const RootNamespace ns{"a"}; + const CreatorFence creator = creatorFence("srv1", 1); + ASSERT_EQ(CasRefCatalog::createNamespace(backend, layout, 1, ns, creator, 1, ALWAYS_ADMITTED, generousDeadline()), + CasRefCatalog::NamespaceCreationOutcome::Live); + + EXPECT_DEATH( + { + CasRefCatalog::createNamespace(backend, layout, 1, ns, creatorFence("srv2", 2), 1, ALWAYS_ADMITTED, generousDeadline()); + }, + "already carries a catalog entry"); +} +#endif + +/// --------------------------------------------------------------------------------------------- +/// `Creating` forbids publication +/// --------------------------------------------------------------------------------------------- + +TEST(CASNsCreationLifecycle, CreatingForbidsPublication) +{ + RefCatalog catalog; + catalog.entries.push_back(CatalogEntry{.ns = RootNamespace{"a"}, .state = NsState::Creating, + .incarnation = UInt128(1), .creator = creatorFence("srv1", 1)}); + DB::Cas::tests::expectThrowsCode(DB::ErrorCodes::NETWORK_ERROR, + [&] { CasRefCatalog::checkPublicationAdmittedOrThrow(catalog, RootNamespace{"a"}); }); +} + +TEST(CASNsCreationLifecycle, LiveAndRemovingAndAbsentAllAdmitPublication) +{ + RefCatalog catalog; + catalog.entries.push_back(CatalogEntry{.ns = RootNamespace{"live"}, .state = NsState::Live, .incarnation = UInt128(1)}); + catalog.entries.push_back(CatalogEntry{.ns = RootNamespace{"removing"}, .state = NsState::Removing, .incarnation = UInt128(2)}); + EXPECT_NO_THROW(CasRefCatalog::checkPublicationAdmittedOrThrow(catalog, RootNamespace{"live"})); + EXPECT_NO_THROW(CasRefCatalog::checkPublicationAdmittedOrThrow(catalog, RootNamespace{"removing"})); + EXPECT_NO_THROW(CasRefCatalog::checkPublicationAdmittedOrThrow(catalog, RootNamespace{"never-heard-of"})); +} + +/// --------------------------------------------------------------------------------------------- +/// ZombieGoLive: fenced-out between the `_ckpt` publish and the `Creating -> Live` CAS +/// --------------------------------------------------------------------------------------------- + +/// A fence callback that admits its FIRST call (spent by step 2's `publishCkpt`) and refuses every +/// call after (spent by step 3's `mutate`) -- deterministically reproducing "fenced out between the +/// `_ckpt` create and the `Creating -> Live` CAS" without a second thread or fault injection. +namespace +{ +std::function admittedOnceThenFenced() +{ + auto calls = std::make_shared(0); + return [calls](uint64_t admitted) + { + if (++*calls > 1) + throw DB::Exception(DB::ErrorCodes::NETWORK_ERROR, "fence generation moved since admission ({})", admitted); + }; +} +} + +TEST(CASNsCreationLifecycle, FencedOutBetweenTheCkptPublishAndGoLiveRefusesAndLeavesEntryCreating) +{ + InitializedCatalogBackend backend; + Layout layout("p"); + const RootNamespace ns{"a"}; + const CreatorFence creator = creatorFence("srv1", 5); + + const auto outcome = CasRefCatalog::createNamespace( + backend, layout, 1, ns, creator, /*admitted_generation=*/1, admittedOnceThenFenced(), generousDeadline()); + EXPECT_EQ(outcome, CasRefCatalog::NamespaceCreationOutcome::FencedOut); + + const CasRefCatalog::Snapshot snap = CasRefCatalog::read(backend, layout); + const CatalogEntry * entry = findEntryForTest(snap.catalog, ns); + ASSERT_NE(entry, nullptr); + EXPECT_EQ(entry->state, NsState::Creating) << "step 3 never ran its CAS -- ZombieGoLive refuses before sending it"; + ASSERT_TRUE(entry->creator.has_value()); + EXPECT_EQ(*entry->creator, creator); + + /// Step 2's _ckpt DID land (it is not what the fence check gates) -- CKPT-FAILED-BIRTH-DEBRIS is a + /// different mechanism (the OLD `RefOpKind::NamespaceBirth` writer, `Pool/CasRefLedger.cpp`); this + /// driver's own `_ckpt` is simply left in place for whichever actor next reconciles this entry. + EXPECT_TRUE(readCkpt(backend, layout, NamespaceLifeId::fromCatalogEntry(entry->ns, entry->incarnation)).has_value()); +} + +/// Regression (CI PR#2073, `03611_freeze_partition_parallel_verbose` under `amd_tsan, cas s3 storage`): +/// sibling openers of the SAME namespace race `resolveNamespaceLife`'s "no entry" read the same way +/// concurrent per-part `ALTER TABLE ... FREEZE` threads race the table's one shadow-store namespace. +/// The loser's own `createNamespace` read lands AFTER the winner's step 1, observing `Creating` -- that +/// must send the loser back through the resume loop (`Superseded`), never abort the server. +TEST(CASNsCreationLifecycle, CreateNamespaceRacingASiblingsStillCreatingEntryReportsSupersededNotAbort) +{ + InitializedCatalogBackend backend; + Layout layout("p"); + const RootNamespace ns{"a"}; + const CreatorFence winner = creatorFence("srv1", 1); + + /// Leaves the entry in `Creating` without reaching `Live` -- the same shape `resolveNamespaceLife` + /// observes when a sibling thread's `casAdmitEntry` has landed but its `completeCreation` has not. + const auto winner_outcome = CasRefCatalog::createNamespace( + backend, layout, 1, ns, winner, /*admitted_generation=*/1, admittedOnceThenFenced(), generousDeadline()); + ASSERT_EQ(winner_outcome, CasRefCatalog::NamespaceCreationOutcome::FencedOut); + ASSERT_EQ(CasRefCatalog::read(backend, layout).catalog.entries.at(0).state, NsState::Creating); + + /// The loser: a second call, as if a sibling thread's own outer "no entry" read had raced ahead of + /// this one. Same fence as the winner (sibling threads of one query share a mount's fence) -- + /// exercising exactly the case `resolveNamespaceLife`'s "own fence -> completeCreation" branch is + /// built to resume, never a `LOGICAL_ERROR` abort. + const auto loser_outcome = CasRefCatalog::createNamespace( + backend, layout, 1, ns, winner, /*admitted_generation=*/1, ALWAYS_ADMITTED, generousDeadline()); + EXPECT_EQ(loser_outcome, CasRefCatalog::NamespaceCreationOutcome::Superseded); + + /// Nothing about the winner's own still-`Creating` entry was disturbed by the loser's refused call. + const CasRefCatalog::Snapshot snap = CasRefCatalog::read(backend, layout); + const CatalogEntry * entry = findEntryForTest(snap.catalog, ns); + ASSERT_NE(entry, nullptr); + EXPECT_EQ(entry->state, NsState::Creating); + ASSERT_TRUE(entry->creator.has_value()); + EXPECT_EQ(*entry->creator, winner); +} + +/// Second catch-point of the same CI PR#2073 race, distinct from the test above. That test starts the +/// winner FIRST, so the loser's own outer pre-check read (`createNamespace`'s `read(...)` before step +/// 1) already observes `Creating` and takes the fast top-of-function refusal. This test instead lands +/// the winner's ENTIRE `createNamespace` call inside the window between the loser's pre-check read +/// (which observes NOTHING) and the loser's own step 1 read -- the shape CI actually hit as an +/// encode-time `LOGICAL_ERROR` ("entries are not canonically ordered ... no duplicate namespace"), not +/// the top-of-function one: both openers pass the pre-check, so both proceed to admit a row for the +/// same namespace, and only `createNamespaceStep1`'s own per-read recheck (not `createNamespace`'s +/// single upfront read) can catch it. +TEST(CASNsCreationLifecycle, CreateNamespaceRacingASiblingsFullCreateBetweenPreCheckAndStep1ReportsSupersededNotAbort) +{ + InitializedCatalogBackend backend; + Layout layout("p"); + const RootNamespace ns{"a"}; + const CreatorFence winner = creatorFence("srv1", 1); + const CreatorFence loser = creatorFence("srv1", 2); + + /// Fires exactly once, inside the LOSER's `createNamespace` call, after its pre-check read already + /// observed no entry -- synchronously runs the winner's own full `createNamespace` to completion + /// (all the way to `Live`) before the loser's step 1 performs its own first read. The production + /// call site swaps the hook into a local before invoking it, so the global is already empty by the + /// time this body runs: the winner's own nested call, and every later call in the test, run + /// hook-free without this body needing to clear it itself. + CasRefCatalog::setCreateNamespaceStep1PreReadHookForTest([&] + { + const auto winner_outcome = CasRefCatalog::createNamespace( + backend, layout, 1, ns, winner, /*admitted_generation=*/1, ALWAYS_ADMITTED, generousDeadline()); + ASSERT_EQ(winner_outcome, CasRefCatalog::NamespaceCreationOutcome::Live); + }); + + const auto loser_outcome = CasRefCatalog::createNamespace( + backend, layout, 1, ns, loser, /*admitted_generation=*/1, ALWAYS_ADMITTED, generousDeadline()); + EXPECT_EQ(loser_outcome, CasRefCatalog::NamespaceCreationOutcome::Superseded); + + /// Exactly one row for `ns`, owned by the winner, at `Live` -- the loser's refused admission left + /// no trace and did not disturb it. + const CasRefCatalog::Snapshot snap = CasRefCatalog::read(backend, layout); + size_t rows_for_ns = 0; + for (const CatalogEntry & e : snap.catalog.entries) + if (e.ns.string() == ns.string()) + ++rows_for_ns; + EXPECT_EQ(rows_for_ns, 1u); + const CatalogEntry * entry = findEntryForTest(snap.catalog, ns); + ASSERT_NE(entry, nullptr); + EXPECT_EQ(entry->state, NsState::Live); + EXPECT_FALSE(entry->creator.has_value()) << "Live entries carry no creator fence"; +} + +/// --------------------------------------------------------------------------------------------- +/// Token-stale: the observed entry no longer matches at the `Creating -> Live` CAS +/// --------------------------------------------------------------------------------------------- + +TEST(CASNsCreationLifecycle, EntryStolenByAConcurrentReconcilerRefusesGoLiveAndLeavesTheStolenEntryAlone) +{ + InitializedCatalogBackend backend; + Layout layout("p"); + const RootNamespace ns{"a"}; + const CreatorFence original_creator = creatorFence("srv1", 5); + const CreatorFence thief = creatorFence("srv2", 9); + + /// Write 1 only -- models "crash after write 1": no _ckpt yet, entry still Creating. + const CatalogEntry entry{.ns = ns, .state = NsState::Creating, .incarnation = UInt128(42), .creator = original_creator}; + CasRefCatalog::casAdmitEntry(backend, layout, 1, entry); + + /// `check_fence_or_throw` is the seam this driver calls on EVERY attempt -- once inside step 2's + /// `publishCkpt`, once more inside step 3's own `mutate` -- so smuggling a REAL concurrent write + /// into it (rather than faking the outcome) has to land on the SECOND call specifically, or the + /// steal itself would run twice (and the second run would see its own first result and refuse). + /// This reproduces "stolen between the creator's _ckpt publish and its Creating -> Live CAS" + /// without a second thread. The steal itself must succeed (asserted), so the mismatch + /// `completeCreation` sees below is the entry ACTUALLY changing, not a contrived stub. + auto calls = std::make_shared(0); + const std::function steal_before_the_go_live_cas = [&, calls](uint64_t) + { + if (++*calls == 2) + ASSERT_EQ(CasRefCatalog::reconcileStaleCreator(backend, layout, entry, thief, fixedTerminality(true), /*admitted_generation=*/1, ALWAYS_ADMITTED), + CasRefCatalog::ReconcileCreatorOutcome::Reconciled); + }; + + const auto outcome = CasRefCatalog::completeCreation( + backend, layout, entry, /*admitted_generation=*/1, steal_before_the_go_live_cas, generousDeadline()); + EXPECT_EQ(outcome, CasRefCatalog::NamespaceCreationOutcome::Superseded); + + /// `read`'s `Snapshot` is bound to a name here, not chained through a temporary: a `const + /// CatalogEntry *` taken from `.catalog` of an unbound temporary dangles the instant the full + /// expression ends, which every other site in this file (and the copy/paste that spread it) got + /// wrong until ASan caught it. + const CasRefCatalog::Snapshot snap_after = CasRefCatalog::read(backend, layout); + const CatalogEntry * after = findEntryForTest(snap_after.catalog, ns); + ASSERT_NE(after, nullptr); + EXPECT_EQ(after->state, NsState::Creating) << "the ORIGINAL creator's attempt wrote nothing -- only the thief's CAS did"; + ASSERT_TRUE(after->creator.has_value()); + EXPECT_EQ(*after->creator, thief) << "the entry is exactly what the thief left it as, untouched by our refused attempt"; + EXPECT_EQ(after->incarnation, entry.incarnation) << "reconciliation never mints a fresh incarnation"; +} + +/// --------------------------------------------------------------------------------------------- +/// Both stale at once: fence moved AND the entry was stolen -- refused (fence checked first). +/// --------------------------------------------------------------------------------------------- + +TEST(CASNsCreationLifecycle, BothFenceAndEntryStaleRefusesGoLiveViaTheFenceCheckWhichRunsFirst) +{ + InitializedCatalogBackend backend; + Layout layout("p"); + const RootNamespace ns{"a"}; + const CreatorFence original_creator = creatorFence("srv1", 5); + const CreatorFence thief = creatorFence("srv2", 9); + + const CatalogEntry entry{.ns = ns, .state = NsState::Creating, .incarnation = UInt128(42), .creator = original_creator}; + CasRefCatalog::casAdmitEntry(backend, layout, 1, entry); + + /// Same steal as the test above, landing on the SECOND `check_fence_or_throw` call (step 3's own + /// `mutate`, not step 2's `publishCkpt`) -- but this one ALSO throws on that same second call, so + /// both axes go stale in the SAME `mutate` invocation. `completeCreation`'s fence check runs before + /// its entry check (documented ordering), so this is reported `FencedOut`; the assertions below + /// confirm the entry ALSO changed, so the test is not merely re-proving the fence-only case above. + auto calls = std::make_shared(0); + const std::function steal_and_fence_before_the_go_live_cas = [&, calls](uint64_t admitted) + { + if (++*calls == 2) + { + ASSERT_EQ(CasRefCatalog::reconcileStaleCreator(backend, layout, entry, thief, fixedTerminality(true), /*admitted_generation=*/1, ALWAYS_ADMITTED), + CasRefCatalog::ReconcileCreatorOutcome::Reconciled); + throw DB::Exception(DB::ErrorCodes::NETWORK_ERROR, "fence generation moved since admission ({})", admitted); + } + }; + + const auto outcome = CasRefCatalog::completeCreation( + backend, layout, entry, /*admitted_generation=*/1, steal_and_fence_before_the_go_live_cas, generousDeadline()); + EXPECT_EQ(outcome, CasRefCatalog::NamespaceCreationOutcome::FencedOut) + << "both checks would refuse; the fence check speaks first by this driver's fixed ordering"; + + const CasRefCatalog::Snapshot snap_after = CasRefCatalog::read(backend, layout); + const CatalogEntry * after = findEntryForTest(snap_after.catalog, ns); + ASSERT_NE(after, nullptr); + ASSERT_TRUE(after->creator.has_value()); + EXPECT_EQ(*after->creator, thief) << "confirms the entry axis really did go stale too, not just the fence"; +} + +/// --------------------------------------------------------------------------------------------- +/// Stale-`Creating` reconciliation +/// --------------------------------------------------------------------------------------------- + +TEST(CASNsCreationLifecycle, ReconcileRefusedWhileTheOriginalCreatorFenceIsStillLive) +{ + InitializedCatalogBackend backend; + Layout layout("p"); + const RootNamespace ns{"a"}; + const CatalogEntry entry{.ns = ns, .state = NsState::Creating, .incarnation = UInt128(7), + .creator = creatorFence("srv1", 5)}; + CasRefCatalog::casAdmitEntry(backend, layout, 1, entry); + + const auto outcome = CasRefCatalog::reconcileStaleCreator( + backend, layout, entry, creatorFence("srv2", 9), fixedTerminality(false), /*admitted_generation=*/1, ALWAYS_ADMITTED); + EXPECT_EQ(outcome, CasRefCatalog::ReconcileCreatorOutcome::CreatorFenceStillLive); + + const CasRefCatalog::Snapshot snap_after = CasRefCatalog::read(backend, layout); + const CatalogEntry * after = findEntryForTest(snap_after.catalog, ns); + ASSERT_NE(after, nullptr); + EXPECT_EQ(*after, entry) << "refused -- nothing written"; +} + +TEST(CASNsCreationLifecycle, ReconcileSucceedsTokenExactlyAfterTheOriginalCreatorFenceIsTerminalThenResumesToLive) +{ + InitializedCatalogBackend backend; + Layout layout("p"); + const RootNamespace ns{"a"}; + const CreatorFence original_creator = creatorFence("srv1", 5); + const CreatorFence new_creator = creatorFence("srv2", 9); + const CatalogEntry entry{.ns = ns, .state = NsState::Creating, .incarnation = UInt128(7), .creator = original_creator}; + CasRefCatalog::casAdmitEntry(backend, layout, 1, entry); /// "crash after write 1" -- no _ckpt yet + + ASSERT_EQ(CasRefCatalog::reconcileStaleCreator(backend, layout, entry, new_creator, fixedTerminality(true), /*admitted_generation=*/1, ALWAYS_ADMITTED), + CasRefCatalog::ReconcileCreatorOutcome::Reconciled); + + CatalogEntry taken_over = entry; + taken_over.creator = new_creator; + const CasRefCatalog::Snapshot snap_mid = CasRefCatalog::read(backend, layout); + const CatalogEntry * mid = findEntryForTest(snap_mid.catalog, ns); + ASSERT_NE(mid, nullptr); + EXPECT_EQ(*mid, taken_over) << "creator moved to the new actor; state and incarnation unchanged"; + + const auto outcome = CasRefCatalog::completeCreation( + backend, layout, taken_over, /*admitted_generation=*/1, ALWAYS_ADMITTED, generousDeadline()); + EXPECT_EQ(outcome, CasRefCatalog::NamespaceCreationOutcome::Live); + + const CasRefCatalog::Snapshot snap_final = CasRefCatalog::read(backend, layout); + const CatalogEntry * final_entry = findEntryForTest(snap_final.catalog, ns); + ASSERT_NE(final_entry, nullptr); + EXPECT_EQ(final_entry->state, NsState::Live); + EXPECT_EQ(final_entry->incarnation, entry.incarnation) << "the SAME incarnation throughout -- resumption, not rebirth"; + const std::optional ckpt = readCkpt(backend, layout, NamespaceLifeId::fromCatalogEntry(final_entry->ns, final_entry->incarnation)); + ASSERT_TRUE(ckpt.has_value()); + EXPECT_EQ(ckpt->ckpt.life_epoch, new_creator.writer_epoch) + << "the RESUMING actor's writer_epoch is the genesis epoch that actually landed"; +} + +/// "Stale token at reconciliation -> fail closed": a SECOND reconciler racing the first, both reading +/// the SAME stale `observed` before either writes. +TEST(CASNsCreationLifecycle, ReconcileFailsClosedWhenTheEntryAlreadyChanged) +{ + InitializedCatalogBackend backend; + Layout layout("p"); + const RootNamespace ns{"a"}; + const CatalogEntry entry{.ns = ns, .state = NsState::Creating, .incarnation = UInt128(7), + .creator = creatorFence("srv1", 5)}; + CasRefCatalog::casAdmitEntry(backend, layout, 1, entry); + + const CreatorFence first_reconciler = creatorFence("srv2", 9); + const CreatorFence second_reconciler = creatorFence("srv3", 11); + + ASSERT_EQ(CasRefCatalog::reconcileStaleCreator(backend, layout, entry, first_reconciler, fixedTerminality(true), /*admitted_generation=*/1, ALWAYS_ADMITTED), + CasRefCatalog::ReconcileCreatorOutcome::Reconciled); + + /// The second reconciler still holds the ORIGINAL `entry` it read before either of them wrote -- + /// token-exactness must refuse it even though the terminality predicate would still say yes. + const auto outcome = CasRefCatalog::reconcileStaleCreator( + backend, layout, entry, second_reconciler, fixedTerminality(true), /*admitted_generation=*/1, ALWAYS_ADMITTED); + EXPECT_EQ(outcome, CasRefCatalog::ReconcileCreatorOutcome::EntryChanged); + + const CasRefCatalog::Snapshot snap_after = CasRefCatalog::read(backend, layout); + const CatalogEntry * after = findEntryForTest(snap_after.catalog, ns); + ASSERT_NE(after, nullptr); + ASSERT_TRUE(after->creator.has_value()); + EXPECT_EQ(*after->creator, first_reconciler) << "the second reconciler's refused attempt changed nothing"; +} + +/// --------------------------------------------------------------------------------------------- +/// Preconditions: `completeCreation`/`reconcileStaleCreator` refuse anything but a `Creating` entry +/// with a creator fence -- a caller bug, not a race, hence `LOGICAL_ERROR`. +/// --------------------------------------------------------------------------------------------- + +#ifndef DEBUG_OR_SANITIZER_BUILD +TEST(CASNsCreationLifecycle, CompleteCreationRejectsANonCreatingEntry) +{ + InitializedCatalogBackend backend; + Layout layout("p"); + const CatalogEntry live{.ns = RootNamespace{"a"}, .state = NsState::Live, .incarnation = UInt128(1)}; + DB::Cas::tests::expectThrowsCode(DB::ErrorCodes::LOGICAL_ERROR, [&] + { + CasRefCatalog::completeCreation(backend, layout, live, 1, ALWAYS_ADMITTED, generousDeadline()); + }); +} + +TEST(CASNsCreationLifecycle, ReconcileStaleCreatorRejectsANonCreatingEntry) +{ + InitializedCatalogBackend backend; + Layout layout("p"); + const CatalogEntry live{.ns = RootNamespace{"a"}, .state = NsState::Live, .incarnation = UInt128(1)}; + DB::Cas::tests::expectThrowsCode(DB::ErrorCodes::LOGICAL_ERROR, [&] + { + CasRefCatalog::reconcileStaleCreator(backend, layout, live, creatorFence("srv2", 2), fixedTerminality(true), /*admitted_generation=*/1, ALWAYS_ADMITTED); + }); +} +#endif + +#if defined(DEBUG_OR_SANITIZER_BUILD) +TEST(CASNsCreationLifecycleDeathTest, CompleteCreationRejectsANonCreatingEntryAborts) +{ + InitializedCatalogBackend backend; + Layout layout("p"); + const CatalogEntry live{.ns = RootNamespace{"a"}, .state = NsState::Live, .incarnation = UInt128(1)}; + EXPECT_DEATH( + { CasRefCatalog::completeCreation(backend, layout, live, 1, ALWAYS_ADMITTED, generousDeadline()); }, + "not a Creating entry"); +} + +TEST(CASNsCreationLifecycleDeathTest, ReconcileStaleCreatorRejectsANonCreatingEntryAborts) +{ + InitializedCatalogBackend backend; + Layout layout("p"); + const CatalogEntry live{.ns = RootNamespace{"a"}, .state = NsState::Live, .incarnation = UInt128(1)}; + EXPECT_DEATH( + { + CasRefCatalog::reconcileStaleCreator(backend, layout, live, creatorFence("srv2", 2), fixedTerminality(true), /*admitted_generation=*/1, ALWAYS_ADMITTED); + }, + "not a Creating entry"); +} +#endif diff --git a/src/Disks/tests/gtest_cas_ns_file_incarnation.cpp b/src/Disks/tests/gtest_cas_ns_file_incarnation.cpp new file mode 100644 index 000000000000..901b223cb90f --- /dev/null +++ b/src/Disks/tests/gtest_cas_ns_file_incarnation.cpp @@ -0,0 +1,279 @@ +#include + +#include +#include +#include +#include +#include +#include +#include "cas_test_helpers.h" +#include + +namespace DB::ErrorCodes +{ + extern const int UNKNOWN_FORMAT_VERSION; +} + +/// Namespace files are keyed by an opaque LIFE, not by its name: `cas/ns/state//_files/` +/// (Stage B Task 4b, directive design change 2). This file pins the three properties that re-key exists +/// to produce, and the one it must NOT produce. +/// +/// THE HOLE IT CLOSES. Before the re-key, a namespace file lived at a name-keyed prefix shared by every +/// life of that name. A file the store's LIST omitted therefore survived namespace removal -- nothing +/// enumerated it, so nothing deleted it -- and then became VISIBLE to the next namespace created under +/// the same name, because that namespace read the same prefix. Deletion was load-bearing for +/// correctness, and deletion depends on enumeration, which is the one thing an object store is allowed +/// to be late about (`HintHoleBackendOn` is that lateness as an interface -- see its doc). +/// +/// WHY THE KEY IS THE FIX AND THE DELETE IS NOT. After the re-key the old file is at a prefix the new +/// life cannot name. It is unreachable whether or not it was ever deleted, so a blind LIST costs +/// STORAGE and nothing else -- the directive's "LIST omission may only leak storage, never visibility, +/// rebirth or deletion safety". `ColdReaderUsesCatalogCutWhileOldFileSurvivesRemoval` asserts exactly +/// that split by leaving the old object physically present and byte-intact through the real lifecycle. +/// +/// WHAT REBIRTH NO LONGER WAITS FOR. Catalog removal depends on folded terminal evidence for the old +/// opaque life, not on a physical-empty proof. `RebirthDoesNotWaitForFilesToBeEmpty` keeps old `_files` +/// bytes present while that evidence is adopted; their later reclamation belongs to the perpetual +/// janitor and is not a precondition for same-name reuse. + +using namespace DB::Cas; +using namespace DB::Cas::tests; + +namespace +{ + +const String kNsString = "00/aa@cas@"; +const String kFile = "format_version.txt"; + +const UInt128 kGcId = hexToU128("00000000000000000000000000000001"); + +/// Create a real catalog life and a replay-valid `Live` ref table through the production writer path. +void publishWithProductionBirth(const PoolPtr & store, const RootNamespace & ns, const String & ref) +{ + PartWriteInfo info; + info.intended_namespace = ns; + info.intended_ref = ns.string() + "/" + ref; + auto build = store->beginPartWrite(info); + const ManifestId id = build->stageManifest({}); + build->precommitAdd(ns, ref, id); + build->promote(ns, ref, build->buildId(), id); +} + +} + +/// THE COUPLED HEADLINE. A real removal reaches a catalog-absent cut even when LIST permanently omits +/// an old-life file. A cold reader follows that catalog cut rather than the physical residue, while an +/// already-held exact life remains stale-or-NotFound and can never cross into the successor life. +TEST(CASNsFileIncarnation, ColdReaderUsesCatalogCutWhileOldFileSurvivesRemoval) +{ + auto backend = std::make_shared>(); + PoolPtr store = openPoolForTest(backend, /*gc_fold_max_defer_rounds=*/0); + const Layout & layout = store->layout(); + const RootNamespace ns{kNsString}; + const String old_bytes = "old-life\n"; + const String successor_bytes = "successor-life\n"; + + publishWithProductionBirth(store, ns, "predecessor"); + const std::optional old_life = store->namespaceFilesLifeIfReadable(ns); + ASSERT_TRUE(old_life); + store->putNamespaceFile(*old_life, kFile, old_bytes); + const String old_key = layout.namespaceFileKey(*old_life, kFile); + backend->hide(old_key); + + ASSERT_TRUE(backend->head(old_key).exists) << "the lie must be in LIST only -- the object is durable"; + ASSERT_TRUE(store->listNamespaceFiles(*old_life).empty()) + << "precondition: enumeration omits the file, so no cleanup pass can ever find it"; + const size_t holes_before_gc = backend->holesServed(); + + store->dropNamespace(ns); + ASSERT_TRUE(CasRefCatalog::lifeIfCataloged(*backend, layout, ns)); + + Gc gc(store, kGcId); + ASSERT_FALSE(runRegularRoundReclaiming(gc).deferred) << "N: the production terminal must fold"; + ASSERT_TRUE(CasRefCatalog::lifeIfCataloged(*backend, layout, ns)) + << "the terminal fold alone must not erase its catalog row"; + (void)runRegularRoundReclaiming(gc); + ASSERT_FALSE(CasRefCatalog::lifeIfCataloged(*backend, layout, ns)) + << "N+1: the pre-fold drain must erase the exact completed Removing row"; + ASSERT_GT(backend->holesServed(), holes_before_gc) + << "the GC janitor must observe the injected LIST hole after the explicit precondition LIST"; + + const auto old_head = backend->head(old_key); + ASSERT_TRUE(old_head.exists) << "logical removal must not depend on physical empty"; + const auto old_object = backend->get(old_key); + ASSERT_TRUE(old_object); + EXPECT_EQ(old_object->bytes, old_bytes); + + PoolPtr cold = Pool::open(backend, + PoolConfig{.pool_prefix = "p", .server_root_id = "cold-reader", .gc_fold_max_defer_rounds = 0}); + EXPECT_FALSE(cold->namespaceFilesLifeIfReadable(ns)) + << "a fresh reader follows the absent catalog row, not discoverable or exact-key old bytes"; + + publishWithProductionBirth(store, ns, "successor"); + const std::optional successor_life = cold->namespaceFilesLifeIfReadable(ns); + ASSERT_TRUE(successor_life); + ASSERT_NE(successor_life->incarnation, old_life->incarnation); + cold->putNamespaceFile(*successor_life, kFile, successor_bytes); + EXPECT_EQ(cold->getNamespaceFile(*successor_life, kFile), successor_bytes); + + const std::optional retained_old = store->getNamespaceFile(*old_life, kFile); + EXPECT_TRUE(!retained_old || *retained_old == old_bytes) + << "an exact predecessor life may be stale or NotFound, but never aliases successor bytes"; + EXPECT_NE(retained_old, std::optional{successor_bytes}); + + for (const String & key : backend->touchedKeys()) + EXPECT_EQ(key.find("/_cleanup/"), String::npos) << key; +} + +/// The non-minting reader assignment site accepts exactly a catalog `Live` row. `Creating`, +/// `Removing`, and absence neither install a runtime life nor mutate durable catalog/stream state. +TEST(CASNsFileIncarnation, FreshReaderAssignsOnlyLiveCatalogLifeWithoutMutation) +{ + auto backend = std::make_shared(); + PoolPtr store = openPoolForTest(backend); + const Layout & layout = store->layout(); + const RootNamespace creating{"00/creating@cas@"}; + const RootNamespace live{"00/live@cas@"}; + const RootNamespace removing{"00/removing@cas@"}; + const RootNamespace absent{"00/absent@cas@"}; + + CasRefCatalog::casAdmitEntry(*backend, layout, 1, CatalogEntry{ + .ns = creating, + .state = NsState::Creating, + .incarnation = UInt128{31}, + .creator = CreatorFence{.server_root_id = "foreign", .writer_epoch = 7, .fence_generation = 1}}); + CasRefCatalog::casAdmitEntry(*backend, layout, 1, CatalogEntry{ + .ns = live, .state = NsState::Live, .incarnation = UInt128{32}}); + CasRefCatalog::casAdmitEntry(*backend, layout, 1, CatalogEntry{ + .ns = removing, .state = NsState::Live, .incarnation = UInt128{33}}); + CasRefCatalog::casUpdate(*backend, layout, [&](const RefCatalog & current) + { + RefCatalog next = current; + const auto it = std::find_if(next.entries.begin(), next.entries.end(), [&](const CatalogEntry & entry) + { + return entry.ns == removing; + }); + chassert(it != next.entries.end()); + it->state = NsState::Removing; + it->removal_started_round = 1; + return next; + }); + + /// Only a `Live` catalog row is readable. Give that exact life the empty checkpoint authority + /// that production creation publishes; the other rows deliberately remain raw lifecycle states. + writeRecoverableCkptForRawFixture(*backend, layout, live, RefCkpt{ + .life_epoch = 1, + .committed_through = std::nullopt, + .checkpoint_snapshot_id = std::nullopt, + .last_epoch_seal = std::nullopt, + }); + + backend->resetCounts(); + EXPECT_FALSE(store->namespaceFilesLifeIfReadable(creating)); + EXPECT_FALSE(store->namespaceFilesLifeIfReadable(removing)); + EXPECT_FALSE(store->namespaceFilesLifeIfReadable(absent)); + const std::optional readable = store->namespaceFilesLifeIfReadable(live); + ASSERT_TRUE(readable); + EXPECT_EQ(readable->incarnation, UInt128{32}); + + EXPECT_FALSE(store->refTableLifeForTest(creating)); + EXPECT_FALSE(store->refTableLifeForTest(removing)); + EXPECT_FALSE(store->refTableLifeForTest(absent)); + ASSERT_TRUE(store->refTableLifeForTest(live)); + EXPECT_EQ(store->refTableLifeForTest(live)->incarnation, UInt128{32}); + EXPECT_EQ(backend->putTotal(), 0u); + EXPECT_EQ(backend->putOverwriteTotal(), 0u); + EXPECT_EQ(backend->casPutTotal(), 0u); +} + +/// A real GC fold records terminal evidence for the previous life while its namespace-file debris +/// remains physically present. Lifecycle completion is therefore independent of `_files` enumeration; +/// the perpetual janitor may reclaim the bytes later without participating in the removal proof. +TEST(CASNsFileIncarnation, RebirthDoesNotWaitForFilesToBeEmpty) +{ + auto backend = std::make_shared(); + PoolPtr store = openPoolForTest(backend, /*gc_fold_max_defer_rounds*/ 0); + const Layout & layout = store->layout(); + const RootNamespace ns{kNsString}; + + /// A removed namespace (a bare `remove_namespace` transaction -- no committed refs, so no + /// owner-removal edge confounds this with an unconditional delete path) whose only surviving + /// physical objects are namespace files: one flat, one nested in the dedup-log shape. + { + RefOp remove_op; + remove_op.kind = RefOpKind::RemoveNamespace; + appendRefLogSeed(*backend, layout, ns, {remove_op}); + } + const NamespaceLifeId life = CasRefCatalog::lifeIfCataloged(*backend, layout, ns).value(); + writeRecoverableCkptForRawFixture(*backend, layout, ns, RefCkpt{ + .life_epoch = 1, + .committed_through = RefTxnId{1, 1}, + .checkpoint_snapshot_id = std::nullopt, + .last_epoch_seal = std::nullopt, + }); + const String debris_key = layout.namespaceFileKey(life, kFile); + backend->putIfAbsent(debris_key, "1\n"); + backend->putIfAbsent(layout.namespaceFileKey(life, "deduplication_logs/deduplication_log_1.txt"), "records"); + + Gc gc(store, kGcId); + gc.runRegularRound(); + + /// Folding the terminal records positive evidence on the same life row even though files remain. + const GcState state = decodeGcState(backend->get(layout.gcStateKey())->bytes); + ASSERT_GT(state.snap_generation, 0u); + const CasFoldSeal seal = decodeFoldSeal( + backend->get(layout.foldSealKey(state.snap_generation, state.snap_attempt))->bytes); + const auto row_it = seal.ref_lives.find(life.incarnation); + ASSERT_NE(row_it, seal.ref_lives.end()); + ASSERT_TRUE(row_it->second.cleanup_evidence.has_value()); + EXPECT_EQ(row_it->second.cleanup_evidence->remove_txn_id, (RefTxnId{1, 1})); + EXPECT_TRUE(backend->head(debris_key).exists) << "cleanup evidence does not gate on physical deletion"; +} + +/// An old-format pool carrying unqualified `roots//_files/x` keys is REFUSED AT OPEN. It is not +/// read, not migrated, and not silently re-keyed: the file layer rides Task 4's format bump B, and the +/// pool-open floor is what makes "there is nothing to migrate" true rather than merely intended. +/// +/// Asserted at OPEN rather than at the parser on purpose: `Layout` has no unqualified key constructor +/// at all (a compile-time concept check in `gtest_cas_namespace_life_id.cpp` pins that, and +/// `parseNamespaceFileKey`'s refusal of a legacy key is pinned there too), so the only reachable +/// question left is whether a pool that CONTAINS such keys can be opened. It cannot. +TEST(CASNsFileIncarnation, LegacyUnqualifiedFileKeyIsRefusedAtOpen) +{ + auto backend = std::make_shared(); + const Layout layout("p"); + + /// A generation-5 `_pool_meta`: the current encoder's output with its header generation moved back + /// one, so every other byte is exactly what that generation really wrote. + PoolMeta meta; + meta.pool_id = hexToU128("0123456789abcdef0123456789abcdef"); + meta.blob_header_len = 256; + meta.min_reader_generation = kNamespaceLifeKeyedGeneration - 1; + meta.algos_used = {static_cast(BlobHashAlgo::CityHash128)}; + String encoded = encodePoolMeta(meta); + const String current_v = "\"v\":" + std::to_string(G_BUILD); + const String legacy_v = "\"v\":" + std::to_string(kNamespaceLifeKeyedGeneration); + const size_t at = encoded.find(current_v); + /// Guard the substitution itself: a silent no-op here would leave a CURRENT-generation pool and the + /// test would pass by opening a pool it believes it downgraded. + ASSERT_NE(at, String::npos) << "pool-meta header no longer spells its generation as " << current_v; + encoded.replace(at, current_v.size(), legacy_v); + ASSERT_NE(encoded.find(legacy_v), String::npos); + backend->putIfAbsent(layout.poolMetaKey(), encoded); + + /// The legacy artifact this task removes: a namespace file keyed by NAME ONLY, with no incarnation + /// segment. Written as raw bytes because no code path in the tree can produce this key any more. + backend->putIfAbsent("p/roots/" + kNsString + "/_files/" + kFile, "1\n"); + + try + { + openPoolForTest(backend); + FAIL() << "an old-format pool must fail closed at open, naming recreation"; + } + catch (const DB::Exception & e) + { + EXPECT_EQ(e.code(), DB::ErrorCodes::UNKNOWN_FORMAT_VERSION); + EXPECT_NE(e.message().find("recreate"), String::npos) + << "the refusal must tell the operator what to do; got: " << e.message(); + } +} diff --git a/src/Disks/tests/gtest_cas_ns_file_read_contract.cpp b/src/Disks/tests/gtest_cas_ns_file_read_contract.cpp new file mode 100644 index 000000000000..3166230f5ec8 --- /dev/null +++ b/src/Disks/tests/gtest_cas_ns_file_read_contract.cpp @@ -0,0 +1,247 @@ +#include + +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include + +namespace DB::ContentAddressedSetting +{ + extern const ContentAddressedSettingsBool gc_enabled; +} + +namespace DB::ErrorCodes +{ + extern const int LOGICAL_ERROR; + extern const int NETWORK_ERROR; +} + +using namespace DB::Cas; +using namespace DB::Cas::tests; + +namespace +{ + +const String kTableUuid = "a11a11a1-1111-4111-8111-111111111111"; +const String kTablePath = "a11/a11a11a1-1111-4111-8111-111111111111"; +const String kFile = "format_version.txt"; +const String kFilePath = kTablePath + "/" + kFile; +const UInt128 kLife2Id = hexToU128("22222222222222222222222222222222"); + +struct DiskFixture +{ + DB::ObjectStoragePtr object_storage; + std::shared_ptr storage; +}; + +DiskFixture openDiskFixture() +{ + static std::atomic counter{0}; + const String unique = std::to_string(::getpid()) + "_" + std::to_string(counter.fetch_add(1)); + auto settings = makeSettingsForTest( + "srv1", std::filesystem::temp_directory_path() / ("cas_ns_file_contract_scratch_" + unique)); + settings[DB::ContentAddressedSetting::gc_enabled] = false; + + DiskFixture fixture; + fixture.object_storage = makeLocalObjectStorageForTest(); + fixture.storage = std::make_shared( + fixture.object_storage, "pool", "srv1", "", nullptr, settings); + fixture.storage->startup(); + return fixture; +} + +void writeVerbatimThroughDisk( + DB::ContentAddressedMetadataStorage & storage, const String & path, const String & bytes) +{ + auto transaction = storage.createTransaction(); + auto & ca_transaction = dynamic_cast(*transaction); + auto buffer = ca_transaction.writeFile(path, DB::DBMS_DEFAULT_BUFFER_SIZE, DB::WriteMode::Rewrite, {}); + ASSERT_NE(dynamic_cast(buffer.get()), nullptr); + DB::writeString(bytes, *buffer); + buffer->finalize(); +} + +/// Delete the current catalog life through the production exact-removal authority while retaining all +/// old physical bytes and the original process's already-resident runtime. +void deleteCatalogLife( + DB::ContentAddressedMetadataStorage & storage, const NamespaceLifeId & life1) +{ + Backend & backend = storage.store()->backend(); + const Layout & layout = storage.store()->layout(); + CasRefCatalog::casUpdate(backend, layout, [&](const RefCatalog & current) + { + RefCatalog next = current; + const auto it = std::find_if(next.entries.begin(), next.entries.end(), [&](const CatalogEntry & entry) + { + return entry.ns == life1.ns && entry.incarnation == life1.incarnation; + }); + if (it == next.entries.end()) + throw DB::Exception( + DB::ErrorCodes::LOGICAL_ERROR, "Missing fixture catalog life '{}'", life1.ns.string()); + it->state = NsState::Removing; + it->removal_started_round = 1; + return next; + }); + + const CasRefCatalog::Snapshot snapshot = CasRefCatalog::read(backend, layout); + const auto it = std::find_if(snapshot.catalog.entries.begin(), snapshot.catalog.entries.end(), [&](const CatalogEntry & entry) + { + return entry.ns == life1.ns && entry.incarnation == life1.incarnation; + }); + if (it == snapshot.catalog.entries.end()) + throw DB::Exception( + DB::ErrorCodes::LOGICAL_ERROR, "Missing Removing fixture catalog life '{}'", life1.ns.string()); + + CasFoldSeal parent; + parent.ref_lives.emplace(life1.incarnation, RefLifeFoldState{ + .coverage = RefCoverage{.classification = 2, .last_folded_ref_id = RefTxnId{1, 1}}, + .cleanup_evidence = RefCleanupEvidence{.remove_txn_id = RefTxnId{1, 1}}}); + if (CasRefCatalog::deleteCompletedRemoving( + backend, layout, *it, parent, 1, + [](uint64_t) { return CasRefCatalog::LeaderFenceStatus::Held; }) + != CasRefCatalog::CompletedRemovingDeleteOutcome::Deleted) + throw DB::Exception( + DB::ErrorCodes::LOGICAL_ERROR, "Failed to delete fixture catalog life '{}'", life1.ns.string()); + +} + +NamespaceLifeId admitReplacementLife( + DB::ContentAddressedMetadataStorage & storage, const NamespaceLifeId & life1) +{ + if (life1.incarnation == kLife2Id) + throw DB::Exception(DB::ErrorCodes::LOGICAL_ERROR, "Fixture life ids unexpectedly collide"); + const NamespaceLifeId life2 = NamespaceLifeId::fromCatalogEntry(life1.ns, kLife2Id); + CasRefCatalog::casAdmitEntry( + storage.store()->backend(), storage.store()->layout(), storage.store()->poolConfig().gc_shards, CatalogEntry{ + .ns = life2.ns, .state = NsState::Live, .incarnation = life2.incarnation}); + return life2; +} + +NamespaceLifeId replaceCatalogLife( + DB::ContentAddressedMetadataStorage & storage, const NamespaceLifeId & life1) +{ + deleteCatalogLife(storage, life1); + return admitReplacementLife(storage, life1); +} + +NamespaceLifeId currentLife(DB::ContentAddressedMetadataStorage & storage) +{ + const RootNamespace ns = storage.liveNamespace(kTableUuid); + const auto life = storage.store()->namespaceFilesLifeIfReadable(ns); + if (!life) + throw DB::Exception(DB::ErrorCodes::LOGICAL_ERROR, "Fixture namespace '{}' has no readable life", ns.string()); + return *life; +} + +} + +/// This storage already holds life 1. Reusing its warm runtime after same-name rebirth is a retained +/// life-handle operation, not a fresh logical-name admission: it may still see predecessor bytes (or +/// answer absent), but the opaque physical life id makes successor bytes structurally unreachable. +TEST(CASNamespaceFileReadContract, HeldLifeAfterSameNameRebirthNeverSeesSuccessorBytes) +{ + DiskFixture fixture = openDiskFixture(); + writeVerbatimThroughDisk(*fixture.storage, kFilePath, "life-1\n"); + const NamespaceLifeId life1 = currentLife(*fixture.storage); + const NamespaceLifeId life2 = replaceCatalogLife(*fixture.storage, life1); + fixture.storage->store()->putNamespaceFile(life2, kFile, "life-2\n"); + + const std::optional held_read = fixture.storage->tryGetInManifestBytes(kFilePath); + EXPECT_NE(held_read, std::optional{"life-2\n"}); + EXPECT_TRUE(!held_read || held_read == std::optional{"life-1\n"}); + EXPECT_EQ(fixture.storage->store()->getNamespaceFile(life1, kFile), std::optional{"life-1\n"}); +} + +/// Mutation caught: capturing only the namespace name and resolving it when the buffer finalizes would +/// overwrite life 2. The real buffer must retain the exact life admitted when it was opened. +TEST(CASNamespaceFileReadContract, DelayedInlineFinalizeCannotChangeSuccessorTokenOrBytes) +{ + DiskFixture fixture = openDiskFixture(); + writeVerbatimThroughDisk(*fixture.storage, kFilePath, "life-1-before\n"); + const NamespaceLifeId life1 = currentLife(*fixture.storage); + + auto delayed_transaction = fixture.storage->createTransaction(); + auto & ca_transaction = dynamic_cast(*delayed_transaction); + auto delayed_buffer = ca_transaction.writeFile( + kFilePath, DB::DBMS_DEFAULT_BUFFER_SIZE, DB::WriteMode::Rewrite, {}); + ASSERT_NE(dynamic_cast(delayed_buffer.get()), nullptr); + DB::writeString("life-1-delayed\n", *delayed_buffer); + + const NamespaceLifeId life2 = replaceCatalogLife(*fixture.storage, life1); + fixture.storage->store()->putNamespaceFile(life2, kFile, "life-2-stable\n"); + Backend & backend = fixture.storage->store()->backend(); + const Layout & layout = fixture.storage->store()->layout(); + const String life1_key = layout.namespaceFileKey(life1, kFile); + const String life2_key = layout.namespaceFileKey(life2, kFile); + ASSERT_TRUE(std::filesystem::exists(nativeKeyUnder(fixture.object_storage, life2_key))); + const HeadResult life2_before = backend.head(life2_key); + ASSERT_TRUE(life2_before.exists); + const auto life2_body_before = backend.get(life2_key); + ASSERT_TRUE(life2_body_before.has_value()); + ASSERT_EQ(life2_body_before->bytes, "life-2-stable\n"); + + bool stale_failure = false; + try + { + delayed_buffer->finalize(); + } + catch (const DB::Exception & e) + { + stale_failure = true; + EXPECT_EQ(e.code(), DB::ErrorCodes::NETWORK_ERROR); + EXPECT_NE(e.message().find("retrying later"), String::npos); + } + + const HeadResult life2_after = backend.head(life2_key); + ASSERT_TRUE(life2_after.exists); + EXPECT_EQ(life2_after.token, life2_before.token); + const auto life2_body_after = backend.get(life2_key); + ASSERT_TRUE(life2_body_after.has_value()); + EXPECT_EQ(life2_body_after->bytes, "life-2-stable\n"); + + if (!stale_failure) + { + ASSERT_TRUE(std::filesystem::exists(nativeKeyUnder(fixture.object_storage, life1_key))); + const auto life1_body = backend.get(life1_key); + ASSERT_TRUE(life1_body.has_value()); + EXPECT_EQ(life1_body->bytes, "life-1-delayed\n"); + } +} + +/// `listNamespaceFiles` derives its LIST prefix from `layout.namespaceFilesPrefix(life)` -- a physical +/// life-scoped stream, not the catalog. Listing under a held life must cost exactly one LIST of the +/// files prefix and nothing else. +TEST(CASNamespaceFileReadContract, ListThroughHeldLifeIssuesZeroCatalogRequests) +{ + auto backend = std::make_shared(); + PoolPtr store = openPoolForTest(backend); + const NamespaceLifeId life = fixture::fixtureLife(RootNamespace{"00/ns_file_list_contract@cas@"}); + const String prefix = store->layout().namespaceFilesPrefix(life); + + store->putNamespaceFile(life, "a.txt", "a\n"); + store->putNamespaceFile(life, "b.txt", "b\n"); + backend->resetCounts(); + + const std::vector names = store->listNamespaceFiles(life); + + std::vector sorted_names = names; + std::sort(sorted_names.begin(), sorted_names.end()); + EXPECT_EQ(sorted_names, (std::vector{"a.txt", "b.txt"})); + + /// Positive control: the journal recorded exactly one LIST against the namespace-file stream + /// prefix and nothing else, so the touched-set assertion below names an absence, not a + /// recorder that never saw anything. + EXPECT_EQ(backend->listCount(prefix), 1u); + EXPECT_EQ(backend->listTotal(), 1u); + EXPECT_EQ(backend->touchedKeys(), std::vector{prefix}); +} diff --git a/src/Disks/tests/gtest_cas_observability.cpp b/src/Disks/tests/gtest_cas_observability.cpp new file mode 100644 index 000000000000..ada9cc9e572a --- /dev/null +++ b/src/Disks/tests/gtest_cas_observability.cpp @@ -0,0 +1,355 @@ +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace ProfileEvents +{ +extern const Event CASGCRetiredCondemned; +extern const Event CASGCRetireReplaced; +} + +using namespace DB::Cas; +using DB::Cas::tests::idOf; +using DB::Cas::tests::u128Of; +using DB::Cas::tests::currentRetiredSet; + +namespace +{ + +PoolPtr openPool(std::shared_ptr & b) +{ + b = std::make_shared(); + return Pool::open(b, PoolConfig{.pool_prefix = "p", .server_root_id = "test"}); +} + +/// Publish ONE ref naming a single-blob part through the real writer sequence (mirrors +/// `publishOneBlobPart` in `gtest_cas_gc_leak.cpp`, duplicated here because that helper has internal +/// linkage in its own translation unit). +ManifestId publishOneBlobPart( + const PoolPtr & s, const RootNamespace & ns, const String & ref, const String & payload) +{ + PartWriteInfo info; + info.intended_ref = ns.string() + "/" + ref; + auto build = s->beginPartWrite(info); + DB::Cas::ManifestEntry e; + e.path = "data.bin"; + e.placement = EntryPlacement::Blob; + e.ref = DB::Cas::BlobRef{DB::Cas::BlobHashAlgo::CityHash128, DB::Cas::BlobDigest::fromU128(u128Of(payload))}; + + e.blob_size = payload.size(); + /// Wiring order (EDGE-BEFORE-OBSERVE): stageManifest -> precommitAdd -> putBlob -> promote. + const ManifestId id = build->stageManifest({e}); + build->precommitAdd(ns, ref, id); + build->putBlob(idOf(payload), BlobSource::fromString(payload)); + build->promote(ns, ref, build->buildId(), id); + return id; +} + +} + +/// B170/Task 1 (Part A audit events): `PartWriteTxn::stageManifest` writes a part-manifest body but never +/// emitted an audit row for it — the log could not answer "when was this manifest written." Verifies +/// the emitted `ManifestPut` event (exactly once per successful stage). +TEST(CASObservability, StageManifestEmitsManifestPut) +{ + std::shared_ptr b; + std::vector seen; /// declared BEFORE the Pool so it outlives the background syncer's emits (ASan 2026-07-09) + auto s = openPool(b); + s->setEventSink([&](const CasEvent & e){ seen.push_back(e); }); + + const RootNamespace ns{"srv/tbl@cas@"}; + auto build = s->beginPartWrite(PartWriteInfo{.intended_ref = ns.string() + "/all_0_0_0", .intended_namespace = ns}); + ManifestEntry e; + e.path = "f"; + e.placement = EntryPlacement::Inline; + e.inline_bytes = "AAA"; + const ManifestId id = build->stageManifest({e}); + s->setEventSink(nullptr); + + EXPECT_EQ(std::count_if(seen.begin(), seen.end(), + [](const CasEvent & x){ return x.type == CasEventType::ManifestPut; }), 1); + + const auto it = std::find_if(seen.begin(), seen.end(), + [](const CasEvent & x){ return x.type == CasEventType::ManifestPut; }); + ASSERT_NE(it, seen.end()); + EXPECT_EQ(it->object_kind, CasEventObjectKind::Manifest); + EXPECT_EQ(it->object_hash, manifestRefDebugString(id.ref)); + EXPECT_FALSE(it->token.empty()); +} + +/// `PartWriteTxn::abandon` removes a live precommit's owner binding (the correctness-bearing step) but never +/// audited the removal — the log could not distinguish "never precommitted" from "precommitted then +/// abandoned." Verifies the emitted `PrecommitRemoved` event (exactly once, only when a precommit was +/// actually live). +TEST(CASObservability, AbandonEmitsPrecommitRemoved) +{ + std::shared_ptr b; + std::vector seen; /// declared BEFORE the Pool so it outlives the background syncer's emits (ASan 2026-07-09) + auto s = openPool(b); + + const RootNamespace ns{"srv/tbl@cas@"}; + auto build = s->beginPartWrite(PartWriteInfo{.intended_ref = ns.string() + "/all_0_0_0", .intended_namespace = ns}); + ManifestEntry e; + e.path = "f"; + e.placement = EntryPlacement::Inline; + e.inline_bytes = "AAA"; + const ManifestId id = build->stageManifest({e}); + build->precommitAdd(ns, "all_0_0_0", id); + + s->setEventSink([&](const CasEvent & x){ seen.push_back(x); }); + build->abandon(); + s->setEventSink(nullptr); + + EXPECT_EQ(std::count_if(seen.begin(), seen.end(), + [](const CasEvent & x){ return x.type == CasEventType::PrecommitRemoved; }), 1); + + const auto it = std::find_if(seen.begin(), seen.end(), + [](const CasEvent & x){ return x.type == CasEventType::PrecommitRemoved; }); + ASSERT_NE(it, seen.end()); + EXPECT_EQ(it->namespace_, ns.string()); + EXPECT_EQ(it->ref_name, "all_0_0_0"); + EXPECT_EQ(it->object_kind, CasEventObjectKind::Root); + EXPECT_EQ(it->object_hash, manifestRefDebugString(id.ref)); +} + +/// A build that never precommitted has nothing to remove: `abandon` must not fabricate a +/// `PrecommitRemoved` row for a binding that was never live. +TEST(CASObservability, AbandonWithoutPrecommitEmitsNoPrecommitRemoved) +{ + std::shared_ptr b; + std::vector seen; /// declared BEFORE the Pool so it outlives the background syncer's emits (ASan 2026-07-09) + auto s = openPool(b); + + const RootNamespace ns{"srv/tbl@cas@"}; + auto build = s->beginPartWrite(PartWriteInfo{.intended_ref = ns.string() + "/all_0_0_0", .intended_namespace = ns}); + ManifestEntry e; + e.path = "f"; + e.placement = EntryPlacement::Inline; + e.inline_bytes = "AAA"; + build->stageManifest({e}); /// staged, never precommitted + + s->setEventSink([&](const CasEvent & x){ seen.push_back(x); }); + build->abandon(); + s->setEventSink(nullptr); + + EXPECT_EQ(std::count_if(seen.begin(), seen.end(), + [](const CasEvent & x){ return x.type == CasEventType::PrecommitRemoved; }), 0); +} + +/// Task 2 (Part A audit fix, 2026-07-08): the resurrect-supersede branch inside `closeBlob` +/// (`CasBlobInDegree.cpp`) used to peek the current token via `head_blob` — the FRESH-CONDEMN +/// observation hook — which double-emitted `blob_retire` alongside `blob_retire_replaced` and +/// double-counted `CASGCRetiredCondemned` for what is really ONE physical condemnation (the resurrect +/// replaced a stale retired entry with the current token). Drives the same condemn-A / resurrect-B / +/// drop-B sequence as `CASGCLeak.ResurrectReplacedIncarnationReclaimed`, then isolates the ONE round +/// that folds B's create+drop and supersedes A's stale retired entry: that round must emit exactly one +/// `blob_retire_replaced` (carrying the STALE token A in `detail["superseded_token"]`), ZERO +/// `blob_retire` for this hash, one `CASGCRetireReplaced` increment, and NO `CASGCRetiredCondemned` +/// double-count. +TEST(CASObservability, ResurrectSupersedeEmitsOnlyRetireReplacedWithOldToken) +{ + std::shared_ptr b; + std::vector seen; /// declared BEFORE the Pool so it outlives the background syncer's emits (ASan 2026-07-09) + auto s = openPool(b); + const RootNamespace ns{"test/tbl"}; + const String P = "resurrect-payload-audit"; + + /// 1. Publish ref r1 -> token A referenced; drop it; ONE GC round condemns A (retired, not deleted). + publishOneBlobPart(s, ns, "r1", P); + const HeadResult hA = b->head(s->layout().blobKey(idOf(P))); + ASSERT_TRUE(hA.exists); + s->dropRef(ns, "r1"); + s->renewWatermarkOnce(); + + Gc gc(s, hexToU128("000000000000000000000000000000ab")); + { + const RoundReport rep = gc.runRegularRound(); + ASSERT_TRUE(rep.acquired_lease); + } + { + const auto lm = DB::Cas::tests::loadMetaForTest(*b, s->layout(), u128Of(P)); + ASSERT_TRUE(lm.has_value() && lm->meta.state == MetaState::Condemned) + << "precondition: token A must be condemned before the resurrect"; + } + + /// 2. RESURRECT: r2 dedup-hits P while A is condemned -> mints a fresh incarnation B; drop it too. + publishOneBlobPart(s, ns, "r2", P); + const HeadResult hB = b->head(s->layout().blobKey(idOf(P))); + ASSERT_TRUE(hB.exists); + ASSERT_NE(hB.token.value, hA.token.value) << "resurrect must mint a new incarnation token B"; + s->dropRef(ns, "r2"); + s->renewWatermarkOnce(); + + /// 3. The NEXT round folds r2's create+drop in one pass and must SUPERSEDE A's stale retired entry + /// with a fresh condemn of B (peek, not the fresh-condemn `head_blob` hook). Capture events + the + /// counters for exactly THIS round. + using ProfileEvents::global_counters; + const auto condemned_before = global_counters[ProfileEvents::CASGCRetiredCondemned].load(); + const auto replaced_before = global_counters[ProfileEvents::CASGCRetireReplaced].load(); + + s->setEventSink([&](const CasEvent & e){ seen.push_back(e); }); + const RoundReport rep = gc.runRegularRound(); + s->setEventSink(nullptr); + ASSERT_TRUE(rep.acquired_lease); + + const auto condemned_after = global_counters[ProfileEvents::CASGCRetiredCondemned].load(); + const auto replaced_after = global_counters[ProfileEvents::CASGCRetireReplaced].load(); + + /// Phase 3 (mixed-algo pools): event `object_hash` renders are `blobIdOf(ref)` (":"), + /// never a bare hex. + const String hash_hex = DB::Cas::blobIdOf(DB::Cas::BlobRef{DB::Cas::BlobHashAlgo::CityHash128, DB::Cas::BlobDigest::fromU128(u128Of(P))}); + const auto is_this_blob = [&](const CasEvent & e){ return e.object_hash == hash_hex; }; + + EXPECT_EQ(std::count_if(seen.begin(), seen.end(), + [&](const CasEvent & e){ return is_this_blob(e) && e.type == CasEventType::BlobRetire; }), 0) + << "supersede must not also emit blob_retire (that is the fresh-condemn hook's event)"; + + std::vector replaced_events; + std::copy_if(seen.begin(), seen.end(), std::back_inserter(replaced_events), + [&](const CasEvent & e){ return is_this_blob(e) && e.type == CasEventType::BlobRetireReplaced; }); + ASSERT_EQ(replaced_events.size(), 1u) << "exactly one blob_retire_replaced for the supersede"; + EXPECT_EQ(replaced_events[0].token, hB.token.value) << "the event's own token is the fresh CURRENT token B"; + ASSERT_TRUE(replaced_events[0].detail.count("superseded_token")); + EXPECT_FALSE(replaced_events[0].detail.at("superseded_token").empty()); + EXPECT_EQ(replaced_events[0].detail.at("superseded_token"), hA.token.value) + << "superseded_token must name the STALE token (A) the resurrect replaced"; + + EXPECT_EQ(replaced_after - replaced_before, 1u) << "CASGCRetireReplaced increments exactly once"; + EXPECT_EQ(condemned_after - condemned_before, 0u) + << "supersede peek must not fresh-condemn -- CASGCRetiredCondemned must not double-count"; + + /// Size-unit regression guard (audit fix, 2026-07-08): `peek_head` used to return the RAW + /// `backend.head(...)` size (physical, header-included), while the fresh-condemn hook `head_blob` + /// strips the pool's fixed blob header via `retiredLogicalSize` before the size lands in + /// `RetiredEntry.size`. That mismatch meant supersede-minted entries and fresh-condemn entries carried + /// two different unit conventions in the SAME persisted `RetiredSet`. The superseded entry (now naming + /// the fresh token B) must carry the LOGICAL size -- i.e. the payload length, with the pool's blob + /// header already stripped -- exactly like a fresh condemn of the same blob would. + const std::vector retired = currentRetiredSet(*b, s->layout(), /*shard*/0); + const auto it = std::find_if(retired.begin(), retired.end(), + [&](const RetiredEntry & e){ return e.kind == ObjectKind::Blob && e.ref == DB::Cas::BlobRef{DB::Cas::BlobHashAlgo::CityHash128, DB::Cas::BlobDigest::fromU128(u128Of(P))}; }); + ASSERT_NE(it, retired.end()) << "the superseded entry must be present in the current retired set"; + EXPECT_EQ(it->token.value, hB.token.value) << "the persisted entry names the fresh CURRENT token B"; + EXPECT_EQ(it->size, P.size()) + << "supersede must persist the LOGICAL size (payload length, header stripped), matching what " + "a fresh condemn of the same blob would carry -- not the raw physical (header-included) size"; +} + +/// Task 3 (Part B, `clickhouse-disks cas-inspect`): `caInspectToJson` is a FREE function (no +/// disk/backend involved) that decodes any CA bucket object at `key` and renders it as JSON, purely +/// by matching `key` against `Layout`'s prefixes/key-shapes and calling the matching `decode*`. +/// These tests drive it directly against real encoder output (one per recognized key shape) plus the +/// unknown-key fail-closed path — the same function the CLI command (`CommandCaInspect.cpp`) calls. + +/// The legacy mutable ref-shard object is gone (snapshot+log ref model); inspect now decodes the two +/// immutable ref objects. A `_snap/.proto` renders as a ref-table snapshot... +TEST(CASObservability, CaInspectDecodesRefSnapshotToJson) +{ + using DB::Cas::tests::committedRow; + using DB::Cas::tests::minimalLiveSnapshot; + Layout layout("p"); + const RootNamespace ns{"srv/tbl@cas@"}; + const RefTxnId snap_id{1, 7}; + const RefTableSnapshot snap = minimalLiveSnapshot(ns.string(), snap_id, + {committedRow("all_0_0_0", ManifestRef{.writer_epoch = 1, .build_sequence = 2, .manifest_ordinal = 1})}); + const String key = layout.refSnapshotKey(DB::Cas::tests::fixture::fixtureLife(ns), snap_id); + const String json = caInspectToJson( + layout, key, encodeRefTableSnapshot(snap), DB::Cas::tests::fixture::fixtureLife(ns)); + EXPECT_NE(json.find(R"("object":"ref_snapshot")"), String::npos) << json; + EXPECT_NE(json.find(R"("namespace":"srv/tbl@cas@")"), String::npos) << json; + EXPECT_NE(json.find(R"("snapshot_id":{"writer_epoch":1,"ref_sequence":7})"), String::npos) << json; + EXPECT_NE(json.find(R"("ref_name":"all_0_0_0")"), String::npos) << json; + EXPECT_NE(json.find(R"("precommits":[])"), String::npos) << json; + EXPECT_EQ(json.find("\"lifecycle\""), String::npos) + << "generation-8 snapshot inspection must not recreate lifecycle state retired from the snapshot DTO"; +} + +/// ...and a `_log/` renders as a ref-transaction log. +TEST(CASObservability, CaInspectDecodesRefLogToJson) +{ + Layout layout("p"); + const RootNamespace ns{"srv/tbl@cas@"}; + const RefTxnId txn_id{1, 8}; + RefLogTxn txn; + txn.ns = ns.string(); + txn.txn_id = txn_id; + RefOp add; + add.kind = RefOpKind::OwnerTransition; + add.new_binding = RefOwnerBinding{RefOwnerKind::Precommit, "all_0_0_0", + ManifestRef{.writer_epoch = 1, .build_sequence = 2, .manifest_ordinal = 1}}; + txn.ops = {add}; + const String key = layout.refLogKey(DB::Cas::tests::fixture::fixtureLife(ns), txn_id); + const String json = caInspectToJson( + layout, key, encodeRefLogTxn(txn), DB::Cas::tests::fixture::fixtureLife(ns)); + EXPECT_NE(json.find("ref_log"), String::npos); + EXPECT_NE(json.find("OwnerTransition"), String::npos); + EXPECT_NE(json.find("all_0_0_0"), String::npos); +} + +TEST(CASObservability, CaInspectDecodesPartManifestToJson) +{ + Layout layout("p"); + const RootNamespace ns{"srv/tbl@cas@"}; + + PartManifest m; + m.ref = ManifestRef{.writer_epoch = 1, .build_sequence = 2, .manifest_ordinal = 3}; + m.root_namespace_id = ns; + ManifestEntry e; + e.path = "data.bin"; + e.placement = EntryPlacement::Inline; + e.inline_bytes = "hello"; + m.entries = {e}; + m.payload_digest = computePayloadDigest(m); + + const ManifestId id{.root_namespace = ns, .ref = m.ref}; + const String key = layout.manifestKey(id); + const String json = caInspectToJson(layout, key, encodePartManifest(m)); + EXPECT_NE(json.find("\"root_namespace_id\""), String::npos); + EXPECT_NE(json.find("data.bin"), String::npos); + EXPECT_NE(json.find("\"manifest_ordinal\":3"), String::npos); +} + +TEST(CASObservability, CaInspectDecodesMountLeaseToJson) +{ + Layout layout("p"); + MountLease lease; + lease.server_uuid = hexToU128("000000000000000000000000000000ab"); + lease.writer_epoch = 5; + lease.hostname = "host1"; + lease.pid = 123; + + const String key = layout.mountKey("srid1"); + const String json = caInspectToJson(layout, key, encodeMountLease(lease)); + EXPECT_NE(json.find("\"writer_epoch\":5"), String::npos); + EXPECT_NE(json.find("host1"), String::npos); +} + +TEST(CASObservability, CaInspectDecodesGcStateToJson) +{ + Layout layout("p"); + GcState state; + state.round = 42; + state.gc_shards = 4; + + const String key = layout.gcStateKey(); + const String json = caInspectToJson(layout, key, encodeGcState(state)); + EXPECT_NE(json.find("\"round\":42"), String::npos); + EXPECT_NE(json.find("\"gc_shards\":4"), String::npos); +} + +TEST(CASObservability, CaInspectUnknownKeyThrows) +{ + Layout layout("p"); + EXPECT_THROW(caInspectToJson(layout, "p/not/a/ca/object", "xxxx"), DB::Exception); /// BAD_ARGUMENTS +} diff --git a/src/Disks/tests/gtest_cas_operation_gate.cpp b/src/Disks/tests/gtest_cas_operation_gate.cpp new file mode 100644 index 000000000000..6d16c3946fae --- /dev/null +++ b/src/Disks/tests/gtest_cas_operation_gate.cpp @@ -0,0 +1,436 @@ +#include + +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include + +/// Task 8 (rev.7 spec §1): the central six-class operation gate (`checkOpAdmitted`), the `Vanished` truth +/// semantics, and the [D5] per-reason typed messages. These tests build a real +/// `ContentAddressedMetadataStorage` over a Local object storage (the same harness as +/// gtest_ca_transaction.cpp), commit a real part, then force the pool lifecycle condition directly via the +/// Task-5 setter (`Pool::setLifecycleForTest`) to pin each class × state cell of the spec §1 table and +/// assert what every public entry does. +/// +/// NOTE the harness idiom: `store()` itself is fail-closed on a terminal pool (it throws), so a test +/// captures the `PoolPtr` ONCE while the pool is still `Live` and drives `setLifecycleForTest` on that +/// captured handle -- the SAME object the metadata storage's `cas_store` points at -- rather than calling +/// `store()` again after forcing a terminal state. + +namespace DB::ErrorCodes +{ +extern const int INVALID_STATE; +extern const int NETWORK_ERROR; +extern const int FILE_DOESNT_EXIST; +} + +using namespace DB; +using DB::Cas::PoolLifecycle; + +namespace +{ + +/// A live table dir + part reused across the tests (the exact shape gtest_ca_transaction.cpp uses). +const std::string kTableDir = "g80/g80g80g8-0808-4808-8808-080808080808"; +const std::string kPartDir = kTableDir + "/all_1_1_0"; +const std::string kPartFile = kPartDir + "/data.bin"; + +std::shared_ptr openGateStorage() +{ + auto settings = Cas::tests::makeSettingsForTest( + "test", std::filesystem::temp_directory_path() / "ca_op_gate_scratch"); + auto storage = std::make_shared( + Cas::tests::makeLocalObjectStorageForTest(), "pool", "srv1", "", nullptr, settings); + storage->startup(); + return storage; +} + +/// Commit one real part (tmp -> final rename -> commit), leaving `kPartFile` durable and `kPartDir`/ +/// `kTableDir` non-empty. Every op below runs against this committed state. +void commitOnePart(ContentAddressedMetadataStorage & storage) +{ + auto tx = storage.createTransaction(); + auto & ca_tx = dynamic_cast(*tx); + auto buf = ca_tx.writeFile(kTableDir + "/tmp_insert_all_1_1_0/data.bin", 65536, WriteMode::Rewrite, {}); + const std::string bytes = "content-of-the-part"; + buf->write(bytes.data(), bytes.size()); + buf->finalize(); + tx->moveDirectory(kTableDir + "/tmp_insert_all_1_1_0", kPartDir); + tx->commit(NoCommitOptions{}); +} + +std::string messageOf(const std::function & fn) +{ + try + { + fn(); + } + catch (const Exception & e) + { + return std::string(e.message()); + } + ADD_FAILURE() << "expected a DB::Exception"; + return {}; +} + +/// The thrown exception itself, for the tests that assert against an upstream CLASSIFIER rather than +/// against an error code. NEVER returns a null `exception_ptr`: every consumer feeds the result to a +/// classifier that rethrows it, and `std::rethrow_exception(nullptr)` is undefined behaviour that takes +/// the whole binary down instead of failing one test. On the nothing-was-thrown path the failure is +/// recorded and a SENTINEL is returned -- the test has already failed by then, and the sentinel merely +/// keeps the assertion that follows harmless. +std::exception_ptr exceptionOf(const std::function & fn) +{ + try + { + fn(); + } + catch (...) + { + return std::current_exception(); + } + ADD_FAILURE() << "expected a DB::Exception, nothing was thrown"; + return std::make_exception_ptr(std::runtime_error("exceptionOf sentinel: nothing was thrown")); +} + +/// The Pool-level `server_root_id` a test mount uses (mirrors gtest_cas_lifecycle_condition.cpp). +const std::string kSrid = "test"; + +/// GC's fence-out applied to the mount lease (preserve the body, set `gc_fenced`, bump `seq`) so a +/// subsequent `tryRemountOnce` verdicts `Recover` and reclaims a FRESH incarnation immediately, driving a +/// transient-not-live pool back to `Live` without a lease-expiry wait. Mirrors +/// gtest_cas_lifecycle_condition.cpp's helper. +void fenceOutMount(DB::Cas::Backend & backend, const String & mount_key) +{ + const auto got = backend.get(mount_key); + ASSERT_TRUE(got.has_value()); + DB::Cas::MountLease m = DB::Cas::decodeMountLease(got->bytes); + m.gc_fenced = true; + m.seq += 1; + ASSERT_EQ(backend.putOverwrite(mount_key, DB::Cas::encodeMountLease(m), got->token).outcome, + DB::Cas::PutOutcome::Done); +} + +} + +/// (a) Probes on a Vanished disk answer the truth: absent/empty, WITHOUT reaching the pool. +TEST(CASOperationGate, ProbesOnVanishedAnswerAbsentEmpty) +{ + auto storage = openGateStorage(); + commitOnePart(*storage); + auto pool = storage->store(); /// captured while Live + + /// Live baseline: the probes see the committed part. + ASSERT_TRUE(storage->existsFile(kPartFile)); + ASSERT_TRUE(storage->existsDirectory(kPartDir)); + ASSERT_TRUE(storage->existsFileOrDirectory(kPartFile)); + ASSERT_FALSE(storage->isDirectoryEmpty(kTableDir)); + ASSERT_FALSE(storage->listDirectory(kTableDir).empty()); + ASSERT_TRUE(storage->getStorageObjectsIfExist(kPartFile).has_value()); + + pool->setLifecycleForTest(PoolLifecycle::VanishedReplaced); + + EXPECT_FALSE(storage->existsFile(kPartFile)); + EXPECT_FALSE(storage->existsDirectory(kPartDir)); + EXPECT_FALSE(storage->existsFileOrDirectory(kPartFile)); + EXPECT_TRUE(storage->listDirectory(kTableDir).empty()); + EXPECT_FALSE(storage->iterateDirectory(kTableDir)->isValid()); + EXPECT_TRUE(storage->isDirectoryEmpty(kTableDir)); + EXPECT_FALSE(storage->getStorageObjectsIfExist(kPartFile).has_value()); + /// The offender `liveTreeDirHasChildren` hardcoded-true is now truthful too: the disk root reads absent. + EXPECT_FALSE(storage->liveTreeDirHasChildren("")); +} + +/// (b) Removes on a Vanished disk are no-op SUCCESS and never touch the backend: after restoring Live the +/// part is still there. This is what lets a vanished-disk table's DROP complete. +TEST(CASOperationGate, RemovesOnVanishedAreNoOpSuccessBackendUntouched) +{ + auto storage = openGateStorage(); + commitOnePart(*storage); + auto pool = storage->store(); /// captured while Live + ASSERT_TRUE(storage->existsDirectory(kPartDir)); + + pool->setLifecycleForTest(PoolLifecycle::VanishedReplaced); + + /// A whole-table removeRecursive + commit (the DROP shape): both no-op-succeed. + { + auto tx = storage->createTransaction(); + EXPECT_NO_THROW(tx->removeRecursive(kTableDir, /*should_remove_objects=*/nullptr)); + EXPECT_NO_THROW(tx->commit(NoCommitOptions{})); /// empty parts -> Remove -> no-op success + } + /// A single removeDirectory of the part dir + commit: no-op-succeed. + { + auto tx = storage->createTransaction(); + EXPECT_NO_THROW(tx->removeDirectory(kPartDir)); + EXPECT_NO_THROW(tx->commit(NoCommitOptions{})); + } + + /// Truth check: nothing was actually removed. Back on Live the part is intact. + pool->setLifecycleForTest(PoolLifecycle::Live); + EXPECT_TRUE(storage->existsDirectory(kPartDir)) << "a remove on a Vanished disk must not touch the backend"; + EXPECT_TRUE(storage->existsFile(kPartFile)); +} + +/// (c) A content read on a Vanished disk throws the typed per-reason [D5] message -- the exact substring +/// names the ACTUAL sub-state (replaced / forgotten), never a wrong diagnosis. +TEST(CASOperationGate, ContentReadOnVanishedThrowsTypedPerReasonMessage) +{ + auto storage = openGateStorage(); + commitOnePart(*storage); + auto pool = storage->store(); /// captured while Live + + pool->setLifecycleForTest(PoolLifecycle::VanishedReplaced); + EXPECT_NE(messageOf([&] { storage->getFileSize(kPartFile); }).find("foreign pool"), std::string::npos); + EXPECT_NE(messageOf([&] { storage->getStorageObjects(kPartFile); }).find("foreign pool"), std::string::npos); + + pool->setLifecycleForTest(PoolLifecycle::VanishedForgotten); + EXPECT_NE(messageOf([&] { storage->getFileSize(kPartFile); }).find("erasure was NOT verified"), + std::string::npos); +} + +/// (d1) Every class but Factory refuses on `TransientNotLive` — and the refusal carries the TRANSIENT +/// class (`NETWORK_ERROR`), not the terminal 668. The split from `IdentityLost` (test d2) is the whole +/// point: a lease blip is unavailability, an identity loss is damage, and consumers outside CAS act on +/// the difference. `ReplicatedMergeTreePartCheckThread` declares a part broken and detaches it for any +/// refusal its `isRetryableException` hatch does not recognise, so coding a blip 668 made healthy parts +/// look corrupt (BACKLOG {#lease-blip-part-check-collapse}). +TEST(CASOperationGate, EveryClassThrowsRetryableTransientOnTransientNotLive) +{ + auto storage = openGateStorage(); + commitOnePart(*storage); + storage->store()->setLifecycleForTest(PoolLifecycle::TransientNotLive); /// one force from Live + + /// Probe + Cas::tests::expectThrowsCode(ErrorCodes::NETWORK_ERROR, [&] { storage->existsFile(kPartFile); }); + Cas::tests::expectThrowsCode(ErrorCodes::NETWORK_ERROR, [&] { storage->existsDirectory(kPartDir); }); + Cas::tests::expectThrowsCode(ErrorCodes::NETWORK_ERROR, [&] { storage->listDirectory(kTableDir); }); + Cas::tests::expectThrowsCode(ErrorCodes::NETWORK_ERROR, [&] { storage->isDirectoryEmpty(kTableDir); }); + /// ContentRead + Cas::tests::expectThrowsCode(ErrorCodes::NETWORK_ERROR, [&] { storage->getFileSize(kPartFile); }); + Cas::tests::expectThrowsCode(ErrorCodes::NETWORK_ERROR, [&] { storage->getStorageObjects(kPartFile); }); + /// Write (via a transaction) + Cas::tests::expectThrowsCode(ErrorCodes::NETWORK_ERROR, [&] { + auto tx = storage->createTransaction(); + auto & ca_tx = dynamic_cast(*tx); + ca_tx.writeFile(kTableDir + "/tmp_x/data.bin", 65536, WriteMode::Rewrite, {}); + }); + /// Remove (via a transaction) + Cas::tests::expectThrowsCode(ErrorCodes::NETWORK_ERROR, [&] { + auto tx = storage->createTransaction(); + tx->removeRecursive(kTableDir, /*should_remove_objects=*/nullptr); + }); + /// Admin + Cas::tests::expectThrowsCode(ErrorCodes::NETWORK_ERROR, [&] { storage->runOneGcRoundForTest(); }); + + /// The coarser code buys retryability at the cost of precision, so the MESSAGE carries the whole + /// truth: which CA condition, and that it is transient rather than an error of record. + const std::string msg = messageOf([&] { storage->getFileSize(kPartFile); }); + EXPECT_NE(msg.find("mount lease not held"), std::string::npos) << msg; + EXPECT_NE(msg.find("TRANSIENT"), std::string::npos) << msg; + EXPECT_NE(msg.find("recovers to Live"), std::string::npos) << msg; +} + +/// (d2) `IdentityLost` is TERMINAL — the sentinels are gone, nothing auto-recovers — so it keeps the 668 +/// (`INVALID_STATE`) class and its own richer [D5] diagnosis ("identity lost … restart or FORGET"). +/// Nothing about the transient re-coding may leak here: a terminal state that read as retryable would +/// make every consumer spin forever on a disk that will never come back. +TEST(CASOperationGate, EveryClassThrows668OnIdentityLost) +{ + auto storage = openGateStorage(); + commitOnePart(*storage); + storage->store()->setLifecycleForTest(PoolLifecycle::IdentityLost); /// one force from Live + + /// Probe + Cas::tests::expectThrowsCode(ErrorCodes::INVALID_STATE, [&] { storage->existsFile(kPartFile); }); + Cas::tests::expectThrowsCode(ErrorCodes::INVALID_STATE, [&] { storage->existsDirectory(kPartDir); }); + Cas::tests::expectThrowsCode(ErrorCodes::INVALID_STATE, [&] { storage->listDirectory(kTableDir); }); + Cas::tests::expectThrowsCode(ErrorCodes::INVALID_STATE, [&] { storage->isDirectoryEmpty(kTableDir); }); + /// ContentRead + Cas::tests::expectThrowsCode(ErrorCodes::INVALID_STATE, [&] { storage->getFileSize(kPartFile); }); + Cas::tests::expectThrowsCode(ErrorCodes::INVALID_STATE, [&] { storage->getStorageObjects(kPartFile); }); + /// Write (via a transaction) + Cas::tests::expectThrowsCode(ErrorCodes::INVALID_STATE, [&] { + auto tx = storage->createTransaction(); + auto & ca_tx = dynamic_cast(*tx); + ca_tx.writeFile(kTableDir + "/tmp_x/data.bin", 65536, WriteMode::Rewrite, {}); + }); + /// Remove (via a transaction) + Cas::tests::expectThrowsCode(ErrorCodes::INVALID_STATE, [&] { + auto tx = storage->createTransaction(); + tx->removeRecursive(kTableDir, /*should_remove_objects=*/nullptr); + }); + /// Admin + Cas::tests::expectThrowsCode(ErrorCodes::INVALID_STATE, [&] { storage->runOneGcRoundForTest(); }); + + EXPECT_NE(messageOf([&] { storage->getFileSize(kPartFile); }).find("identity lost"), std::string::npos); +} + +/// (d3) The contract the d1/d2 split exists to satisfy, asserted against upstream's OWN predicate instead +/// of a code number: `isRetryableException` is what `ReplicatedMergeTreePartCheckThread::checkPartImpl` +/// consults before declaring a part broken. A transient CA refusal must satisfy it (the part stays +/// queued); a terminal one must not (the disk is genuinely unusable and must surface). Pinning the +/// predicate rather than `NETWORK_ERROR` keeps this test meaningful if upstream's list ever moves. +TEST(CASOperationGate, TransientRefusalIsUpstreamRetryableTerminalIsNot) +{ + { + auto storage = openGateStorage(); + commitOnePart(*storage); + storage->store()->setLifecycleForTest(PoolLifecycle::TransientNotLive); + EXPECT_TRUE(isRetryableException(exceptionOf([&] { storage->getFileSize(kPartFile); }))) + << "a lease blip must not read as part damage to the part-check thread"; + } + { + auto storage = openGateStorage(); + commitOnePart(*storage); + storage->store()->setLifecycleForTest(PoolLifecycle::IdentityLost); + /// Pin WHICH error is being classified before classifying it: `EXPECT_FALSE` alone passes for any + /// non-retryable error, so a future regression that threw something else entirely here -- or threw + /// from the wrong site -- would slip through as a pass. + Cas::tests::expectThrowsCode(ErrorCodes::INVALID_STATE, [&] { storage->getFileSize(kPartFile); }); + EXPECT_NE(messageOf([&] { storage->getFileSize(kPartFile); }).find("identity lost"), std::string::npos); + EXPECT_FALSE(isRetryableException(exceptionOf([&] { storage->getFileSize(kPartFile); }))) + << "a terminal identity loss must NOT be retried forever as if it were transient"; + } +} + +/// (e) `createTransaction` (Factory: I/O-free) and the capability/introspection getters construct fine on +/// a Vanished disk -- so a vanished-disk table's DROP can allocate its removal transaction. +TEST(CASOperationGate, FactoryClassWorksOnVanished) +{ + auto storage = openGateStorage(); + storage->store()->setLifecycleForTest(PoolLifecycle::VanishedForgotten); /// one force from Live + + EXPECT_NO_THROW({ auto tx = storage->createTransaction(); (void)tx; }); + EXPECT_EQ(storage->getType(), MetadataStorageType::CAS); + EXPECT_NO_THROW((void)storage->getPath()); + EXPECT_NO_THROW((void)storage->isContentAddressed()); +} + +/// (f) `tryGetInManifestBytes` PROPAGATES the typed refusal — terminal 668 on a `Vanished` disk, the +/// transient class in a lease gap — rather than converting either into a silent-absent `std::nullopt` +/// (the narrowed catch). RED before the narrowing. +TEST(CASOperationGate, TryGetInManifestBytesPropagatesTypedError) +{ + auto storage = openGateStorage(); + commitOnePart(*storage); + auto pool = storage->store(); /// captured while Live + + pool->setLifecycleForTest(PoolLifecycle::VanishedReplaced); + /// Never FILE_DOESNT_EXIST, never a swallowed nullopt -- the typed INVALID_STATE escapes. + Cas::tests::expectThrowsCode(ErrorCodes::INVALID_STATE, [&] { + storage->tryGetInManifestBytes(kTableDir + "/format_version.txt"); + }); + + pool->setLifecycleForTest(PoolLifecycle::TransientNotLive); + Cas::tests::expectThrowsCode(ErrorCodes::NETWORK_ERROR, [&] { + storage->tryGetInManifestBytes(kTableDir + "/format_version.txt"); + }); +} + +/// (g) (rev.8, Task 15) Null-pool fail-loud: the Dormant/UNMOUNT rollback replaced the transitional +/// not-Mounted branch (which answered `Probe`->benign-absent) with a null-pool fail-loud. A storage whose +/// pool is torn down (`shutdown()`) refuses EVERY class, `Probe` included, with `INVALID_STATE` +/// ("not started") -- there is no benign-absent answer for a not-started disk; only a genuinely `Vanished` +/// POOL answers truth-absent. Replaces the deleted `DormantDiskKeepsOldBenignAbsent_RemoveAtTask15`. +TEST(CASOperationGate, NullPoolFailsLoudForEveryClass) +{ + auto storage = openGateStorage(); + commitOnePart(*storage); + ASSERT_TRUE(storage->existsDirectory(kPartDir)); + + storage->shutdown(); /// null pool -- the ShutDown storage lifecycle + + /// Probes now THROW (not started), NOT the transitional benign-absent answer. + Cas::tests::expectThrowsCode(ErrorCodes::INVALID_STATE, [&] { storage->existsFile(kPartFile); }); + Cas::tests::expectThrowsCode(ErrorCodes::INVALID_STATE, [&] { storage->existsDirectory(kPartDir); }); + Cas::tests::expectThrowsCode(ErrorCodes::INVALID_STATE, [&] { (void)storage->listDirectory(kTableDir); }); + Cas::tests::expectThrowsCode(ErrorCodes::INVALID_STATE, [&] { storage->isDirectoryEmpty(kTableDir); }); + /// Store-class ops throw the same INVALID_STATE ("not started"), not the typed Vanished message. + Cas::tests::expectThrowsCode(ErrorCodes::INVALID_STATE, [&] { storage->getFileSize(kPartFile); }); +} + +/// (h) The raw GC round entry points refuse on a not-live pool (Admin class): typed [D5] reason once Vanished. +TEST(CASOperationGate, GcEntryPointsRefuseOnNotLive) +{ + auto storage = openGateStorage(); + auto pool = storage->store(); /// captured while Live + + pool->setLifecycleForTest(PoolLifecycle::VanishedReplaced); + EXPECT_NE(messageOf([&] { storage->runOneGcRoundForTest(); }).find("foreign pool"), std::string::npos); + + pool->setLifecycleForTest(PoolLifecycle::TransientNotLive); + Cas::tests::expectThrowsCode(ErrorCodes::NETWORK_ERROR, [&] { storage->runOneGcRoundForTest(); }); +} + +/// (i) `CasGcScheduler::isQuiescent` reflects the round-in-flight flag: a round in flight => not quiescent. +/// (This is the join-completion signal the FORGET / GC-STOP tests rely on.) +TEST(CASOperationGate, GcSchedulerIsQuiescentReflectsRoundInFlight) +{ + auto backend = std::make_shared(); + auto pool = Cas::tests::openPoolForTest(backend); + auto scheduler = std::make_shared( + pool, std::chrono::seconds(3600), "op-gate-test-gc", "disk", Cas::GcRoundLogger{}); + EXPECT_TRUE(scheduler->isQuiescent()); + scheduler->setRoundInFlightForTest(true); + EXPECT_FALSE(scheduler->isQuiescent()) << "a round in flight must NOT read as GC-quiescent"; + scheduler->setRoundInFlightForTest(false); + EXPECT_TRUE(scheduler->isQuiescent()); +} + +/// (j) (acceptance matrix — transient auto-recovery / DROP-drain round-trip) The full §4 recovery arc on ONE +/// storage: a Remove-class op (the DROP shape) throws the typed transient refusal while the mount lease is +/// lost, then SUCCEEDS and actually drains once the disk self-remounts back to Live — no operator action, +/// no restart. Where test (d) forces `TransientNotLive` via the setter to pin the gap, this drives a REAL +/// transient→Live recovery (`tripMountLost` → fence-out → `tryRemountOnce`) so the throw-then-drain is one +/// continuous arc on the same pool. Closes the "access throws in the gap, auto-recovers, a Remove re-queues +/// and drains" matrix row end-to-end (the per-table DROP re-queue itself is the MergeTree caller's job; the +/// CAS contract is exactly this: refuse in the gap, admit after recovery). +TEST(CASOperationGate, RemoveThrowsDuringTransientAndDrainsAfterRecovery) +{ + auto storage = openGateStorage(); + commitOnePart(*storage); + auto pool = storage->store(); /// captured while Live (store() is fail-closed once not-live) + ASSERT_EQ(pool->lifecycle(), PoolLifecycle::Live); + ASSERT_TRUE(storage->existsDirectory(kPartDir)); /// Live baseline: the part is present. + + /// The mount lease is transiently lost — the pool goes TransientNotLive. + pool->tripMountLost(); + ASSERT_EQ(pool->lifecycle(), PoolLifecycle::TransientNotLive); + + /// In the gap, EVERY store-class access throws the typed transient refusal — the Remove (DROP shape) + /// included, and a content read too. Nothing is answered benign, nothing is silently dropped. + Cas::tests::expectThrowsCode(ErrorCodes::NETWORK_ERROR, [&] { + auto tx = storage->createTransaction(); + tx->removeRecursive(kTableDir, /*should_remove_objects=*/nullptr); + }); + Cas::tests::expectThrowsCode(ErrorCodes::NETWORK_ERROR, [&] { storage->getFileSize(kPartFile); }); + const std::string gap_msg = messageOf([&] { storage->getFileSize(kPartFile); }); + EXPECT_NE(gap_msg.find("mount lease not held"), std::string::npos) + << "the gap message must name the transient (auto-recovering) condition: " << gap_msg; + + /// The lease is restored: the disk self-remounts a fresh incarnation and auto-recovers to Live. + fenceOutMount(pool->backend(), pool->layout().mountKey(kSrid)); + ASSERT_TRUE(pool->tryRemountOnce()) << "the self-remount must reclaim a fresh incarnation"; + ASSERT_EQ(pool->lifecycle(), PoolLifecycle::Live) << "the pool must auto-recover to Live"; + + /// After recovery the SAME Remove drains: it commits cleanly and actually removes the part. + { + auto tx = storage->createTransaction(); + EXPECT_NO_THROW(tx->removeRecursive(kTableDir, /*should_remove_objects=*/nullptr)); + EXPECT_NO_THROW(tx->commit(NoCommitOptions{})); + } + EXPECT_FALSE(storage->existsDirectory(kPartDir)) + << "the re-queued removal must drain (really remove the part) once the disk recovers to Live"; + EXPECT_FALSE(storage->existsFile(kPartFile)); +} diff --git a/src/Disks/tests/gtest_cas_orphan_manifest_sweep.cpp b/src/Disks/tests/gtest_cas_orphan_manifest_sweep.cpp new file mode 100644 index 000000000000..45f82c1a9e33 --- /dev/null +++ b/src/Disks/tests/gtest_cas_orphan_manifest_sweep.cpp @@ -0,0 +1,678 @@ +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include "cas_sweep_test_support.h" +#include "cas_test_helpers.h" +#include +#include +#include +#include +#include + +using namespace DB::Cas; +using namespace DB::Cas::tests; + +namespace +{ +constexpr uint64_t kWriterEpoch = 7; +const String kServerRoot = "00"; +ManifestRef ref(uint64_t seq, uint64_t inst) +{ + return ManifestRef{.writer_epoch = kWriterEpoch, .build_sequence = seq, .manifest_ordinal = static_cast(inst)}; +} + +/// The §6 deletion premise (`manifestDeletionPremise`) is a SECOND precondition on every deletion below, +/// alongside the watermark eligibility these tests are about: a manifest of an epoch-`E` build is +/// deletable only once the namespace's sealed fold cursor sits in an epoch strictly above `E`. Tests +/// whose subject is the eligibility or ownership rule therefore have to establish it, or they would +/// assert a deletion the premise (not the rule under test) prevented. Tests whose subject is RETENTION +/// deliberately do NOT call this — see `CASSweepDeletionPremise` for the premise's own coverage. +void seedConsumedSealCursor(InMemoryBackend & backend, const Layout & layout, const RootNamespace & ns) +{ + seedFoldCursorForTest(backend, layout, ns, RefTxnId{kWriterEpoch + 1, 1}); +} + +/// The catalog cut and `_ckpt` are recovery's sole authority. A fixture that expects a catalog-named +/// life to be swept must establish the same empty, fully readable recovery state a real completed +/// creation would have, rather than relying on the retired sentinel fallback. +void seedEmptyRecoveryAuthority(InMemoryBackend & backend, const Layout & layout, const RootNamespace & ns) +{ + const CasRefCatalog::Snapshot catalog = CasRefCatalog::read(backend, layout); + const auto entry = std::find_if(catalog.catalog.entries.begin(), catalog.catalog.entries.end(), + [&](const CatalogEntry & candidate) { return candidate.ns == ns; }); + ASSERT_NE(entry, catalog.catalog.entries.end()); + const NamespaceLifeId life = NamespaceLifeId::fromCatalogEntry(entry->ns, entry->incarnation); + ASSERT_EQ(backend.putIfAbsent(layout.refCkptKey(life), encodeRefCkpt(RefCkpt{ + .life_epoch = std::optional{kWriterEpoch}, + .committed_through = std::nullopt, + .checkpoint_snapshot_id = std::nullopt, + .last_epoch_seal = std::nullopt})).outcome, PutOutcome::Done); +} + +/// Replaces the catalog row immediately before its second read after arming. The legacy orphan path +/// reads a catalog cut for coverage, then resolves the name again inside recovery; the second read can +/// splice a successor life into the old coverage decision. An authority-threaded path has no second +/// catalog read, so this seam must remain dormant. +class CatalogChangingOnSecondReadBackend : public InMemoryBackend +{ +public: + using Backend::get; + + void arm(const Layout & layout, CatalogEntry predecessor_, CatalogEntry successor_) + { + catalog_key = layout.refCatalogKey(); + predecessor = std::move(predecessor_); + successor = std::move(successor_); + catalog_reads = 0; + armed = true; + } + + bool didSwitch() const { return did_switch; } + + std::optional get(const String & key, Range range) override + { + if (armed && key == catalog_key && ++catalog_reads == 2) + { + const auto current = InMemoryBackend::get(key, range); + if (!current) + throw std::runtime_error("test catalog disappeared"); + RefCatalog next = decodeRefCatalog(current->bytes); + const auto it = std::find(next.entries.begin(), next.entries.end(), predecessor); + if (it == next.entries.end()) + throw std::runtime_error("test predecessor catalog row disappeared"); + *it = successor; + if (InMemoryBackend::casPut(key, encodeRefCatalog(next), current->token).outcome != CasOutcome::Committed) + throw std::runtime_error("test catalog replacement conflicted"); + did_switch = true; + } + return InMemoryBackend::get(key, range); + } + +private: + String catalog_key; + CatalogEntry predecessor; + CatalogEntry successor; + uint64_t catalog_reads = 0; + bool armed = false; + bool did_switch = false; +}; + +/// Rewrites a listed manifest after the page captured it but before the page takes its lifecycle cut. +/// The old implementation performed its candidate GET after that cut and would delete this replacement +/// with its new token. The fixed path may nominate the old observation, but exact-token deletion loses. +class ReplacingManifestAfterObservationBackend : public InMemoryBackend +{ +public: + using Backend::get; + + void arm(const Layout & layout, String manifest_key_) + { + catalog_key = layout.refCatalogKey(); + manifests_prefix = layout.casManifestsPrefix(); + manifest_key = std::move(manifest_key_); + listed_page = false; + replaced_manifest = false; + armed = true; + } + + bool didReplace() const { return replaced_manifest; } + + ListPage list(const String & prefix, const String & cursor, size_t limit) override + { + const ListPage page = InMemoryBackend::list(prefix, cursor, limit); + if (armed && prefix == manifests_prefix) + listed_page = true; + return page; + } + + std::optional get(const String & key, Range range) override + { + const auto result = InMemoryBackend::get(key, range); + if (armed && listed_page && !replaced_manifest && key == catalog_key) + { + const auto current = InMemoryBackend::get(manifest_key); + if (!current) + throw std::runtime_error("test manifest disappeared before replacement"); + if (InMemoryBackend::casPut(manifest_key, current->bytes, current->token).outcome != CasOutcome::Committed) + throw std::runtime_error("test manifest replacement conflicted"); + replaced_manifest = true; + } + return result; + } + +private: + String catalog_key; + String manifests_prefix; + String manifest_key; + bool armed = false; + bool listed_page = false; + bool replaced_manifest = false; +}; +} + +/// A staged-but-unowned body in an ELIGIBLE prefix, absent from the owner view, is deleted (#7). +TEST(CASOrphanManifestSweep, EligibleAndUnownedIsDeleted) +{ + auto backend = std::make_shared(); + auto store = openPoolForTest(backend); + const RootNamespace ns{"00/aa@cas@"}; + registerNamespaceRaw(*backend, store->layout(), ns); + const ManifestRef r = ref(5, 0xAB); + writeManifestRaw(*backend, store->layout(), ns, r, {blobEntryFor("a", DB::UInt128(1))}); // body, no owner + setWatermarkMinActive(*backend, store->layout(), kServerRoot, kWriterEpoch, /*min_active*/6); // 6 > 5 => eligible + seedConsumedSealCursor(*backend, store->layout(), ns); + seedEmptyRecoveryAuthority(*backend, store->layout(), ns); + + sweepNamespace(*store, ns, BuildPrefix{.writer_epoch = kWriterEpoch, .build_sequence = 5}); + EXPECT_FALSE(backend->head(store->layout().manifestKey(ManifestId{ns, r})).exists); +} + +/// The orphan sweep must not turn a forged same-id snapshot at an OLDER `EpochSeal` into an empty owner +/// view. The base differs from `last_epoch_seal`, so metadata equality cannot catch it; the candidate +/// remains retained until the checkpoint is repaired. +TEST(CASOrphanManifestSweep, CheckpointSnapshotAtOlderEpochSealSkipsDeletion) +{ + auto backend = std::make_shared(); + auto store = openPoolForTest(backend); + const Layout & layout = store->layout(); + const RootNamespace ns{"00/sweep-checkpoint-base-seal@cas@"}; + fixture::admitLive(*backend, layout, ns); + const NamespaceLifeId life = *CasRefCatalog::lifeIfCataloged(*backend, layout, ns); + + const RefLogTxn birth{ + .ns = ns.string(), .txn_id = RefTxnId{1, 1}, .ops = {namespaceBirthOp()}, + .prev_epoch_seal = std::nullopt}; + fixture::writeRefLogRaw(*backend, layout, birth); + RefOp seal; + seal.kind = RefOpKind::EpochSeal; + const RefLogTxn seal_txn{ + .ns = ns.string(), .txn_id = RefTxnId{1, 2}, .ops = {seal}, + .prev_epoch_seal = std::nullopt}; + fixture::writeRefLogRaw(*backend, layout, seal_txn); + RefOp later_seal; + later_seal.kind = RefOpKind::EpochSeal; + fixture::writeRefLogRaw(*backend, layout, RefLogTxn{ + .ns = ns.string(), .txn_id = RefTxnId{2, 1}, .ops = {later_seal}, + .prev_epoch_seal = RefTxnId{1, 2}}); + RefTableState through_seal; + applyRefLogTxn(through_seal, birth); + applyRefLogTxn(through_seal, seal_txn); + writeRefSnapshotRaw(*backend, layout, snapshotOf(through_seal, ns.string())); + ASSERT_EQ(backend->putIfAbsent(layout.refCkptKey(life), encodeRefCkpt(RefCkpt{ + .life_epoch = 1, + .committed_through = RefTxnId{2, 1}, + .checkpoint_snapshot_id = RefTxnId{1, 2}, + .last_epoch_seal = RefTxnId{2, 1}})).outcome, PutOutcome::Done); + + const ManifestRef candidate = ref(5, 0xAC); + const String candidate_key = layout.manifestKey(ManifestId{ns, candidate}); + writeManifestRaw(*backend, layout, ns, candidate, {blobEntryFor("a", DB::UInt128(1))}); + setWatermarkMinActive(*backend, layout, kServerRoot, kWriterEpoch, /*min_active=*/6); + seedConsumedSealCursor(*backend, layout, ns); + + std::vector warnings; + EXPECT_EQ(sweepNamespace(*store, ns, BuildPrefix{.writer_epoch = kWriterEpoch, .build_sequence = 5}, &warnings), 0u); + EXPECT_TRUE(backend->head(candidate_key).exists); + ASSERT_FALSE(warnings.empty()); +} + +/// A body that IS in the owner view (committed) is NEVER swept (#8). +TEST(CASOrphanManifestSweep, OwnedBodyIsSkipped) +{ + auto backend = std::make_shared(); + auto store = openPoolForTest(backend); + const RootNamespace ns{"00/aa@cas@"}; + const ManifestRef r = ref(5, 0xAB); + writeManifestRaw(*backend, store->layout(), ns, r, {blobEntryFor("a", DB::UInt128(1))}); + publishCommittedTransition(*backend, store->layout(), ns, "tbl", std::nullopt, r); // now owned + setWatermarkMinActive(*backend, store->layout(), kServerRoot, kWriterEpoch, 6); + + sweepNamespace(*store, ns, BuildPrefix{.writer_epoch = kWriterEpoch, .build_sequence = 5}); + EXPECT_TRUE(backend->head(store->layout().manifestKey(ManifestId{ns, r})).exists); +} + +/// GC-WEDGE regression (2026-07-10): a COMMITTED ref that has been DROPPED but whose removal `-1` is NOT +/// yet sealed (transition_version above the sealed fold cursor, which is 0 for this fresh pool) must +/// SURVIVE the sweep — the GC fold still needs the body to emit the `-1` (delete-after-sealed-decrements). +/// A promoted build retires its build_seq, so the prefix is watermark-eligible; before the fix the sweep +/// deleted the body in the dropRef→fold window → the removal-fold then clamped FOREVER on the missing +/// committed body → pool-wide GC stop. The pending-removal protection now covers COMMITTED (not only +/// PRECOMMIT) removals. +/// +/// SINCE THE §6 PREMISE, this shape is held by TWO independent facts: the tail-removal protection this +/// test is named for, and the premise's rule (1) — the fixture seals no fold cursor, so epoch +/// `kWriterEpoch`'s closing seal is not consumed either. They cannot be separated HERE: the removal log +/// sits in a lower epoch than the build, so any cursor high enough to satisfy rule (1) would also sit +/// above the log and stop the tail scan from reading it at all. The case where the tail-removal +/// protection is the ONLY thing standing — a removal in a LATER epoch, which is the direction removals +/// actually cross — is `CASSweepDeletionPremise.AnUnconsumedTailRemovalRetainsItsTarget`. +TEST(CASOrphanManifestSweep, PendingCommittedRemovalBodyIsSkipped) +{ + auto backend = std::make_shared(); + auto store = openPoolForTest(backend); + const RootNamespace ns{"00/aa@cas@"}; + const ManifestRef r = ref(5, 0xAB); + writeManifestRaw(*backend, store->layout(), ns, r, {blobEntryFor("a", DB::UInt128(1))}); + publishCommittedTransition(*backend, store->layout(), ns, "tbl", std::nullopt, r); // committed owner + dropRefTransition(*backend, store->layout(), ns, "tbl", r); // dropped: pending committed removal, -1 unsealed + setWatermarkMinActive(*backend, store->layout(), kServerRoot, kWriterEpoch, 6); // 6 > 5 => prefix eligible + + sweepNamespace(*store, ns, BuildPrefix{.writer_epoch = kWriterEpoch, .build_sequence = 5}); + EXPECT_TRUE(backend->head(store->layout().manifestKey(ManifestId{ns, r})).exists) + << "a dropped-but-unsealed committed manifest body must survive the sweep (delete-after-sealed-" + "decrements) — else the removal-fold clamps forever on the missing body (GC-WEDGE-2026-07-10)"; +} + +/// The sweep emits NO blob deltas: the in-degree generation is unchanged. +TEST(CASOrphanManifestSweep, EmitsNoBlobDeltas) +{ + auto backend = std::make_shared(); + auto store = openPoolForTest(backend); + const RootNamespace ns{"00/aa@cas@"}; + const ManifestRef r = ref(5, 0xAB); + writeManifestRaw(*backend, store->layout(), ns, r, {blobEntryFor("a", DB::UInt128(1))}); + setWatermarkMinActive(*backend, store->layout(), kServerRoot, kWriterEpoch, 6); + seedConsumedSealCursor(*backend, store->layout(), ns); + seedEmptyRecoveryAuthority(*backend, store->layout(), ns); + // The sweep must not advance the in-degree generation: capture it AFTER the fixture's own seal. + const uint64_t gen_before = currentGenerationOf(*backend, store->layout()); + + sweepNamespace(*store, ns, BuildPrefix{.writer_epoch = kWriterEpoch, .build_sequence = 5}); + EXPECT_EQ(currentGenerationOf(*backend, store->layout()), gen_before); + EXPECT_EQ(inDegreeOf(*backend, store->layout(), DB::UInt128(1)), 0); +} + +TEST(CASOrphanManifestSweep, CursorPageAdvancesAndWrapsWithListBudget) +{ + auto backend = std::make_shared(); + auto store = openPoolForTest(backend); + const RootNamespace ns{"00/aa@cas@"}; + registerNamespaceRaw(*backend, store->layout(), ns); + const ManifestRef r1 = ref(5, 0xE1); + const ManifestRef r2 = ref(5, 0xE2); + writeManifestRaw(*backend, store->layout(), ns, r1, {blobEntryFor("a", DB::UInt128(1))}); + writeManifestRaw(*backend, store->layout(), ns, r2, {blobEntryFor("b", DB::UInt128(2))}); + setWatermarkMinActive(*backend, store->layout(), kServerRoot, kWriterEpoch, /*min_active*/6); + + const ManifestSweepResult first = sweepManifestCursorPageForTest(*store, "", /*list_budget*/1, /*delete_budget*/0); + EXPECT_EQ(first.listed, 1u); + EXPECT_FALSE(first.wrapped); + EXPECT_FALSE(first.next_cursor.empty()); + + const ManifestSweepResult second = sweepManifestCursorPageForTest(*store, first.next_cursor, /*list_budget*/100, /*delete_budget*/0); + EXPECT_GE(second.listed, 1u); + EXPECT_TRUE(second.wrapped); + EXPECT_TRUE(second.next_cursor.empty()); +} + +/// A NON-eligible prefix (no watermark fact) deletes NOTHING (#9: frozen-seq is not authority). +TEST(CASOrphanManifestSweep, NoWatermarkIsNotAuthority) +{ + auto backend = std::make_shared(); + auto store = openPoolForTest(backend); + const RootNamespace ns{"00/aa@cas@"}; + const ManifestRef r = ref(5, 0xAB); + writeManifestRaw(*backend, store->layout(), ns, r, {blobEntryFor("a", DB::UInt128(1))}); + // No setWatermarkMinActive — no durable fact => not eligible. + sweepNamespace(*store, ns, BuildPrefix{.writer_epoch = kWriterEpoch, .build_sequence = 5}); + EXPECT_TRUE(backend->head(store->layout().manifestKey(ManifestId{ns, r})).exists); +} + +TEST(CASOrphanManifestSweep, CursorPageDeletesEligibleUnownedBody) +{ + auto backend = std::make_shared(); + auto store = openPoolForTest(backend); + const RootNamespace ns{"00/aa@cas@"}; + registerNamespaceRaw(*backend, store->layout(), ns); + const ManifestRef r = ref(5, 0xAC); + writeManifestRaw(*backend, store->layout(), ns, r, {blobEntryFor("a", DB::UInt128(1))}); + setWatermarkMinActive(*backend, store->layout(), kServerRoot, kWriterEpoch, 6); + seedConsumedSealCursor(*backend, store->layout(), ns); + seedEmptyRecoveryAuthority(*backend, store->layout(), ns); + + const ManifestSweepResult result = sweepManifestCursorPageForTest(*store, "", /*list_budget*/100, /*delete_budget*/10); + EXPECT_GE(result.listed, 1u); + EXPECT_EQ(result.deleted, 1u); + EXPECT_FALSE(backend->head(store->layout().manifestKey(ManifestId{ns, r})).exists); +} + +TEST(CASOrphanManifestSweep, CursorPageRespectsDeleteBudget) +{ + auto backend = std::make_shared(); + auto store = openPoolForTest(backend); + const RootNamespace ns{"00/aa@cas@"}; + registerNamespaceRaw(*backend, store->layout(), ns); + const ManifestRef r1 = ref(5, 0xAD); + const ManifestRef r2 = ref(5, 0xAE); + writeManifestRaw(*backend, store->layout(), ns, r1, {blobEntryFor("a", DB::UInt128(1))}); + writeManifestRaw(*backend, store->layout(), ns, r2, {blobEntryFor("b", DB::UInt128(2))}); + setWatermarkMinActive(*backend, store->layout(), kServerRoot, kWriterEpoch, 6); + seedConsumedSealCursor(*backend, store->layout(), ns); + seedEmptyRecoveryAuthority(*backend, store->layout(), ns); + + const ManifestSweepResult result = sweepManifestCursorPageForTest(*store, "", /*list_budget*/100, /*delete_budget*/1); + EXPECT_EQ(result.deleted, 1u); + const bool first_exists = backend->head(store->layout().manifestKey(ManifestId{ns, r1})).exists; + const bool second_exists = backend->head(store->layout().manifestKey(ManifestId{ns, r2})).exists; + EXPECT_NE(first_exists, second_exists); +} + +/// A physical manifest captured before a catalog cut which omits its name is dead-life debris: a live +/// creation cannot publish a life-owned object before its catalog row. It therefore has an eventual +/// page-sweep owner without trying to reconstruct a deleted incarnation from the key. +TEST(CASOrphanManifestSweep, CursorPageDeletesObservedBodyWhenCatalogOmitsNamespace) +{ + auto backend = std::make_shared(); + auto store = openPoolForTest(backend); + const RootNamespace ns{"00/catalog-absent-debris@cas@"}; + const ManifestRef r = ref(5, 0xA9); + writeManifestRaw(*backend, store->layout(), ns, r, {blobEntryFor("debris", DB::UInt128(9))}); + /// No mount lease/watermark exists: after legal catalog-row deletion there may be no server-root + /// state left to supply one. The post-observation absent row is the complete dead-life proof. + + const ManifestSweepResult result = sweepManifestCursorPageForTest(*store, "", /*list_budget=*/100, /*delete_budget=*/10); + + EXPECT_EQ(result.deleted, 1u); + EXPECT_FALSE(backend->head(store->layout().manifestKey(ManifestId{ns, r})).exists); +} + +/// The candidate body and token must be frozen before the later catalog cut. A concurrent same-key +/// replacement after that observation is a new physical incarnation and must lose the old-token delete. +TEST(CASOrphanManifestSweep, CursorPageCannotDeleteManifestReplacedAfterObservation) +{ + auto backend = std::make_shared(); + auto store = openPoolForTest(backend); + const RootNamespace ns{"00/replace-after-observation@cas@"}; + registerNamespaceRaw(*backend, store->layout(), ns); + const ManifestRef r = ref(5, 0xAA); + const String key = store->layout().manifestKey(ManifestId{ns, r}); + writeManifestRaw(*backend, store->layout(), ns, r, {blobEntryFor("body", DB::UInt128(10))}); + setWatermarkMinActive(*backend, store->layout(), kServerRoot, kWriterEpoch, 6); + seedConsumedSealCursor(*backend, store->layout(), ns); + seedEmptyRecoveryAuthority(*backend, store->layout(), ns); + backend->arm(store->layout(), key); + + const ManifestSweepResult result = sweepManifestCursorPageForTest(*store, "", /*list_budget=*/100, /*delete_budget=*/10); + + EXPECT_TRUE(backend->didReplace()); + EXPECT_EQ(result.deleted, 0u); + EXPECT_TRUE(backend->head(key).exists); +} + +/// Any duplicate current life id makes the catalog-to-physical join ambiguous. The cursor page is +/// destructive, so the whole cut must be rejected before it can nominate even an unrelated body. +TEST(CASOrphanManifestSweep, CursorPageRefusesAmbiguousCatalogLifeIndex) +{ + auto backend = std::make_shared(); + auto store = openPoolForTest(backend); + const RootNamespace ns{"00/ambiguous-life@cas@"}; + registerNamespaceRaw(*backend, store->layout(), ns); + const ManifestRef r = ref(5, 0xAB); + const String key = store->layout().manifestKey(ManifestId{ns, r}); + writeManifestRaw(*backend, store->layout(), ns, r, {blobEntryFor("body", DB::UInt128(11))}); + setWatermarkMinActive(*backend, store->layout(), kServerRoot, kWriterEpoch, 6); + seedConsumedSealCursor(*backend, store->layout(), ns); + seedEmptyRecoveryAuthority(*backend, store->layout(), ns); + + const CasRefCatalog::Snapshot before = CasRefCatalog::read(*backend, store->layout()); + RefCatalog damaged = before.catalog; + CatalogEntry duplicate = damaged.entries.front(); + duplicate.ns = RootNamespace{"00/ambiguous-life-twin@cas@"}; + damaged.entries.push_back(duplicate); + std::sort(damaged.entries.begin(), damaged.entries.end(), + [](const CatalogEntry & lhs, const CatalogEntry & rhs) { return lhs.ns.string() < rhs.ns.string(); }); + ASSERT_EQ(backend->casPut(store->layout().refCatalogKey(), encodeRefCatalog(damaged), before.token).outcome, + CasOutcome::Committed); + + DB::Cas::tests::expectThrowsCode(DB::ErrorCodes::CORRUPTED_DATA, + [&] { sweepManifestCursorPageForTest(*store, "", /*list_budget=*/100, /*delete_budget=*/10); }); + EXPECT_TRUE(backend->head(key).exists); +} + +TEST(CASOrphanManifestSweep, CursorPageSkipsOwnedBody) +{ + auto backend = std::make_shared(); + auto store = openPoolForTest(backend); + const RootNamespace ns{"00/aa@cas@"}; + const ManifestRef r = ref(5, 0xAF); + writeManifestRaw(*backend, store->layout(), ns, r, {blobEntryFor("a", DB::UInt128(1))}); + publishCommittedTransition(*backend, store->layout(), ns, "tbl", std::nullopt, r); + setWatermarkMinActive(*backend, store->layout(), kServerRoot, kWriterEpoch, 6); + + const ManifestSweepResult result = sweepManifestCursorPageForTest(*store, "", /*list_budget*/100, /*delete_budget*/10); + EXPECT_EQ(result.deleted, 0u); + EXPECT_TRUE(backend->head(store->layout().manifestKey(ManifestId{ns, r})).exists); +} + +/// A catalog-named life cannot be treated as an empty table merely because its mandatory recovery +/// checkpoint is missing. The orphan sweep is destructive, so it must retain the body until the +/// caller can recover from the same frozen catalog row and its exact `_ckpt`. +TEST(CASOrphanManifestSweep, MissingRequiredCheckpointSuppressesDestructiveDecision) +{ + auto backend = std::make_shared(); + auto store = openPoolForTest(backend); + const RootNamespace ns{"00/authority-required@cas@"}; + fixture::admitLive(*backend, store->layout(), ns); + const ManifestRef r = ref(5, 0xB0); + writeManifestRaw(*backend, store->layout(), ns, r, {blobEntryFor("a", DB::UInt128(1))}); + setWatermarkMinActive(*backend, store->layout(), kServerRoot, kWriterEpoch, 6); + seedConsumedSealCursor(*backend, store->layout(), ns); + + const CatalogEntry entry = CasRefCatalog::read(*backend, store->layout()).catalog.entries.front(); + ASSERT_FALSE(readCkpt(*backend, store->layout(), NamespaceLifeId::fromCatalogEntry(entry.ns, entry.incarnation))); + + sweepNamespace(*store, ns, BuildPrefix{.writer_epoch = kWriterEpoch, .build_sequence = 5}); + + EXPECT_TRUE(backend->head(store->layout().manifestKey(ManifestId{ns, r})).exists) + << "without the exact _ckpt required by a Live catalog row, the sweep must retain rather than " + "derive an empty owner set"; +} + +/// A decoded fold cursor at an `EpochSeal` advances to the next GLOBAL writer epoch. Even when this +/// namespace was inactive, every intermediate epoch exists as a chained sequence-1 empty seal, so the +/// exact tail begins at `{E+1, 1}` and must consume each one before reaching a later removal. The +/// removal's target stays protected while an unrelated eligible body remains deletable; retaining both +/// would hide a false missing-log failure at the first intermediate seal. +TEST(CASOrphanManifestSweep, EpochSealFoldCursorCrossesTailByExactDecodedSuccessor) +{ + auto backend = std::make_shared(); + auto store = openPoolForTest(backend); + const RootNamespace ns{"00/seal-cursor-tail@cas@"}; + fixture::admitLive(*backend, store->layout(), ns); + const CatalogEntry entry = CasRefCatalog::read(*backend, store->layout()).catalog.entries.front(); + const NamespaceLifeId life = NamespaceLifeId::fromCatalogEntry(entry.ns, entry.incarnation); + + const ManifestRef removed{.writer_epoch = 1, .build_sequence = 5, .manifest_ordinal = 1}; + const ManifestRef unowned{.writer_epoch = 1, .build_sequence = 6, .manifest_ordinal = 1}; + const ManifestRef still_owned{.writer_epoch = 2, .build_sequence = 1, .manifest_ordinal = 1}; + publishAt(*backend, store->layout(), ns, RefTxnId{1, 1}, "dropped", removed.build_sequence, DB::UInt128(0xA1), /*birth=*/true); + writeSealAt(*backend, store->layout(), ns, RefTxnId{1, 2}); + writeTxnAt(*backend, store->layout(), ns, RefTxnId{2, 1}, publishCommittedOps("still-owned", still_owned), RefTxnId{1, 2}); + writeSealAt(*backend, store->layout(), ns, RefTxnId{2, 2}); + writeSealAt(*backend, store->layout(), ns, RefTxnId{3, 1}, RefTxnId{2, 2}); + writeSealAt(*backend, store->layout(), ns, RefTxnId{4, 1}, RefTxnId{3, 1}); + writeSealAt(*backend, store->layout(), ns, RefTxnId{5, 1}, RefTxnId{4, 1}); + writeSealAt(*backend, store->layout(), ns, RefTxnId{6, 1}, RefTxnId{5, 1}); + for (uint64_t epoch = 3; epoch <= 6; ++epoch) + ASSERT_TRUE(backend->head(store->layout().refLogKey(life, RefTxnId{epoch, 1})).exists) + << "fixture must deposit every intermediate exact successor in the catalog life"; + writeTxnAt(*backend, store->layout(), ns, RefTxnId{7, 1}, + {ownerTransitionOp(RefOwnerBinding{RefOwnerKind::Committed, "dropped", removed}, std::nullopt)}, + RefTxnId{6, 1}); + writeSealAt(*backend, store->layout(), ns, RefTxnId{7, 2}); + writeManifestRaw(*backend, store->layout(), ns, unowned, {blobEntryFor("unowned", DB::UInt128(0xA2))}); + ASSERT_EQ(backend->putIfAbsent(store->layout().refCkptKey(life), encodeRefCkpt(RefCkpt{ + .life_epoch = 1, + .committed_through = RefTxnId{7, 2}, + .checkpoint_snapshot_id = std::nullopt, + .last_epoch_seal = RefTxnId{7, 2}})).outcome, PutOutcome::Done); + setWatermarkMinActive(*backend, store->layout(), kServerRoot, kWriterEpoch, 7); + seedFoldCursorForTest(*backend, store->layout(), ns, RefTxnId{2, 2}); + + const ManifestSweepResult result = sweepManifestCursorPageForTest(*store, "", /*list_budget=*/100, /*delete_budget=*/10); + + EXPECT_EQ(result.deleted, 1u); + EXPECT_TRUE(backend->head(store->layout().manifestKey(ManifestId{ns, removed})).exists) + << "the exact successor of the folded epoch seal contains this body's unconsumed -1"; + EXPECT_FALSE(backend->head(store->layout().manifestKey(ManifestId{ns, unowned})).exists) + << "an unrelated eligible body must still drain; retaining it would mask a geometry failure"; +} + +/// A cleaned inherited cursor does not let a later epoch backlink skip the mandatory immediately-next +/// global epoch. `{3,1}` is missing here, so the direct `{7,1} -> {2,2}` link cannot authorize a tail +/// scan; the whole namespace must fail closed and retain even an otherwise unowned eligible body. +TEST(CASOrphanManifestSweep, MissingImmediateEpochAfterCleanedCursorCannotBeSkippedByLaterBacklink) +{ + auto backend = std::make_shared(); + auto store = openPoolForTest(backend); + const RootNamespace ns{"00/missing-next-epoch@cas@"}; + fixture::admitLive(*backend, store->layout(), ns); + const CatalogEntry entry = CasRefCatalog::read(*backend, store->layout()).catalog.entries.front(); + const NamespaceLifeId life = NamespaceLifeId::fromCatalogEntry(entry.ns, entry.incarnation); + + const RefTxnId cursor{2, 2}; + writeSealAt(*backend, store->layout(), ns, cursor); + const HeadResult cursor_head = backend->head(store->layout().refLogKey(life, cursor)); + ASSERT_TRUE(cursor_head.exists); + ASSERT_EQ(classifyDeleteOutcome( + backend->deleteExact(store->layout().refLogKey(life, cursor), cursor_head.token)), DeleteClass::Deleted); + + const ManifestRef phantom{.writer_epoch = 7, .build_sequence = 1, .manifest_ordinal = 1}; + /// The codec refuses this skipped predecessor when a writer tries to create it. Inject the malformed + /// physical lure explicitly: a reader must still not enumerate forward to it when `{3,1}` is absent. + RefLogTxn direct_later_link{ + .ns = ns.string(), + .txn_id = RefTxnId{7, 1}, + .ops = publishCommittedOps("phantom", phantom), + .prev_epoch_seal = RefTxnId{6, 1}}; + String malformed_later_link = encodeRefLogTxn(direct_later_link); + const String encoded_predecessor{R"("!pse":"6")"}; + const size_t predecessor_pos = malformed_later_link.find(encoded_predecessor); + ASSERT_NE(predecessor_pos, String::npos); + malformed_later_link.replace( + predecessor_pos, encoded_predecessor.size(), R"("!pse":"2")"); + ASSERT_EQ(backend->putIfAbsent( + store->layout().refLogKey(life, RefTxnId{7, 1}), sealObject(FormatId::RefLog, malformed_later_link)).outcome, + PutOutcome::Done); + writeRefSnapshotRaw(*backend, store->layout(), RefTableSnapshot{ + .ns = ns.string(), + .snapshot_id = RefTxnId{7, 1}, + .committed = {committedRow("phantom", phantom)}, + .precommits = {}}); + ASSERT_EQ(backend->putIfAbsent(store->layout().refCkptKey(life), encodeRefCkpt(RefCkpt{ + .life_epoch = 1, + .committed_through = RefTxnId{7, 1}, + .checkpoint_snapshot_id = RefTxnId{7, 1}, + .last_epoch_seal = RefTxnId{6, 1}})).outcome, PutOutcome::Done); + + const ManifestRef victim{.writer_epoch = 1, .build_sequence = 5, .manifest_ordinal = 1}; + writeManifestRaw(*backend, store->layout(), ns, victim, {blobEntryFor("victim", DB::UInt128(0xC1))}); + setWatermarkMinActive(*backend, store->layout(), kServerRoot, kWriterEpoch, 6); + seedFoldCursorForTest(*backend, store->layout(), ns, cursor); + + const ManifestSweepResult result = sweepManifestCursorPageForTest(*store, "", /*list_budget=*/100, /*delete_budget=*/10); + + EXPECT_EQ(result.deleted, 0u); + EXPECT_TRUE(backend->head(store->layout().manifestKey(ManifestId{ns, victim})).exists); +} + +/// Control for the cleaned-cursor path: the exact immediately-next epoch head exists and names the +/// deleted seal, so the tail is readable. Its `-1` protects the removed body while an unrelated eligible +/// body proves the namespace was scanned rather than retained wholesale. The checkpoint base is the +/// following same-epoch transaction: recovery therefore has a retained exact anchor without turning the +/// deliberately cleaned predecessor seal into part of that anchor's proof. +TEST(CASOrphanManifestSweep, CleanedCursorCrossesOnlyThroughExactImmediateEpochHead) +{ + auto backend = std::make_shared(); + auto store = openPoolForTest(backend); + const RootNamespace ns{"00/exact-next-epoch@cas@"}; + fixture::admitLive(*backend, store->layout(), ns); + const CatalogEntry entry = CasRefCatalog::read(*backend, store->layout()).catalog.entries.front(); + const NamespaceLifeId life = NamespaceLifeId::fromCatalogEntry(entry.ns, entry.incarnation); + + const RefTxnId cursor{2, 2}; + writeSealAt(*backend, store->layout(), ns, cursor); + const HeadResult cursor_head = backend->head(store->layout().refLogKey(life, cursor)); + ASSERT_TRUE(cursor_head.exists); + ASSERT_EQ(classifyDeleteOutcome( + backend->deleteExact(store->layout().refLogKey(life, cursor), cursor_head.token)), DeleteClass::Deleted); + + const ManifestRef removed{.writer_epoch = 1, .build_sequence = 5, .manifest_ordinal = 1}; + writeTxnAt(*backend, store->layout(), ns, RefTxnId{3, 1}, + {ownerTransitionOp(RefOwnerBinding{RefOwnerKind::Committed, "removed", removed}, std::nullopt)}, cursor); + const ManifestRef absent_anchor{.writer_epoch = 1, .build_sequence = 7, .manifest_ordinal = 1}; + writeTxnAt(*backend, store->layout(), ns, RefTxnId{3, 2}, + {ownerTransitionOp(RefOwnerBinding{RefOwnerKind::Committed, "absent-anchor", absent_anchor}, std::nullopt)}); + writeRefSnapshotRaw(*backend, store->layout(), RefTableSnapshot{ + .ns = ns.string(), .snapshot_id = RefTxnId{3, 2}, .committed = {}, .precommits = {}}); + ASSERT_EQ(backend->putIfAbsent(store->layout().refCkptKey(life), encodeRefCkpt(RefCkpt{ + .life_epoch = 1, + .committed_through = RefTxnId{3, 2}, + .checkpoint_snapshot_id = RefTxnId{3, 2}, + .last_epoch_seal = cursor})).outcome, PutOutcome::Done); + + const ManifestRef unowned{.writer_epoch = 1, .build_sequence = 6, .manifest_ordinal = 1}; + writeManifestRaw(*backend, store->layout(), ns, removed, {blobEntryFor("removed", DB::UInt128(0xC2))}); + writeManifestRaw(*backend, store->layout(), ns, unowned, {blobEntryFor("unowned", DB::UInt128(0xC3))}); + setWatermarkMinActive(*backend, store->layout(), kServerRoot, kWriterEpoch, 7); + seedFoldCursorForTest(*backend, store->layout(), ns, cursor); + + const ManifestSweepResult result = sweepManifestCursorPageForTest(*store, "", /*list_budget=*/100, /*delete_budget=*/10); + + EXPECT_EQ(result.deleted, 1u); + EXPECT_TRUE(backend->head(store->layout().manifestKey(ManifestId{ns, removed})).exists); + EXPECT_FALSE(backend->head(store->layout().manifestKey(ManifestId{ns, unowned})).exists); +} + +/// The catalog row used to obtain coverage and the life used to recover ownership must be ONE frozen +/// authority cut. A later catalog row for the same name may not make the old life's committed manifest +/// look orphaned and therefore deletable. +TEST(CASOrphanManifestSweep, LaterCatalogCutCannotSpliceOwnershipAuthority) +{ + auto backend = std::make_shared(); + auto store = openPoolForTest(backend); + const RootNamespace ns{"00/frozen-catalog-cut@cas@"}; + fixture::admitLive(*backend, store->layout(), ns); + const CatalogEntry predecessor = CasRefCatalog::read(*backend, store->layout()).catalog.entries.front(); + + const ManifestRef r = ref(5, 0xB1); + writeManifestRaw(*backend, store->layout(), ns, r, {blobEntryFor("a", DB::UInt128(1))}); + ASSERT_EQ(publishCommittedTransition(*backend, store->layout(), ns, "live", std::nullopt, r), 1u); + setWatermarkMinActive(*backend, store->layout(), kServerRoot, kWriterEpoch, 6); + seedConsumedSealCursor(*backend, store->layout(), ns); + + CatalogEntry successor = predecessor; + successor.incarnation = DB::UInt128(0xBEEF); + backend->arm(store->layout(), predecessor, successor); + + sweepNamespace(*store, ns, BuildPrefix{.writer_epoch = kWriterEpoch, .build_sequence = 5}); + + EXPECT_FALSE(backend->didSwitch()) + << "the sweep must not resolve a second catalog cut after it starts using the frozen entry"; + EXPECT_TRUE(backend->head(store->layout().manifestKey(ManifestId{ns, r})).exists) + << "the committed predecessor manifest must remain protected by the same frozen authority cut"; +} + +/// The LIST-based late-log detector that lived here is RETIRED with the sentinel seal, and it is worth +/// recording why rather than leaving a hole in this file's coverage story. +/// +/// It existed because the old seal was a SNAPSHOT at a synthetic `{E-1, UINT64_MAX}` id: that object +/// occupied no `_log` key, so a dying predecessor's in-flight PUT could still land in the dead epoch and +/// the only possible response was to notice it afterwards and report it. INV-2's seal is a +/// TRANSACTION at exactly `{E, T+1}` -- the key that ghost would take -- so the store's own write-once +/// create refuses it. There is nothing left to detect at that shape: an id above the seal cannot be +/// minted either, because ids are state-derived and a writer that could derive `{E, T+2}` would have had +/// to observe the seal first. +/// +/// `CasEventType::RefLateLogDetected` is retired WITH the detector -- pre-release, so a vocabulary entry +/// nothing can emit is just dead surface. Soak scenario S38 (`s38_late_put_injection.py`) keeps its +/// injection and FLIPS its assertion: from "the detection fired" to "the fence held" -- the late PUT's +/// conditional create must LOSE to the occupied slot, with zero data loss and the namespace folding +/// normally. diff --git a/src/Disks/tests/gtest_cas_orphan_nomination.cpp b/src/Disks/tests/gtest_cas_orphan_nomination.cpp new file mode 100644 index 000000000000..039d113cb134 --- /dev/null +++ b/src/Disks/tests/gtest_cas_orphan_nomination.cpp @@ -0,0 +1,319 @@ +#include + +#include +#include +#include +#include +#include +#include "cas_test_helpers.h" + +namespace DB::ErrorCodes +{ +extern const int CORRUPTED_DATA; +} + +using namespace DB::Cas; +using namespace DB::Cas::tests; + +namespace +{ + +constexpr uint64_t kCandidateEpoch = 1; +constexpr uint64_t kCandidateBuild = 5; +const UInt128 kGcId = hexToU128("000000000000000000000000000000d8"); + +ManifestRef candidateRef() +{ + return ManifestRef{.writer_epoch = kCandidateEpoch, .build_sequence = kCandidateBuild, .manifest_ordinal = 1}; +} + +bool manifestExists(Backend & backend, const Layout & layout, const ManifestId & id) +{ + return backend.head(layout.manifestKey(id)).exists; +} + +bool activeSourceExists(Backend & backend, const Layout & layout, const UInt128 & source_id) +{ + const auto state_got = backend.get(layout.gcStateKey()); + if (!state_got) + return false; + const GcState state = decodeGcState(state_got->bytes); + const auto seal_got = backend.get(layout.foldSealKey(state.snap_generation, state.snap_attempt)); + if (!seal_got) + return false; + const CasFoldSeal seal = decodeFoldSeal(seal_got->bytes); + for (const RunRef & run : seal.blob_target_runs) + { + SourceEdgeRunView view = openSourceEdgeRun(backend, run.key); + String key; + String payload; + while (view.next(key, payload)) + { + if (payload.empty() || payload[0] != kEdgeActive) + continue; + BlobRef ref; + UInt128 row_source{}; + SourceEdgeKeyCodec::parse(key, ref, row_source); + if (row_source == source_id) + return true; + } + view.verifyAgainst(run.checksum); + } + return false; +} + +size_t condemnedCount(Backend & backend, const Layout & layout) +{ + size_t count = 0; + const GcState state = decodeGcState(backend.get(layout.gcStateKey())->bytes); + const CasFoldSeal seal = decodeFoldSeal( + backend.get(layout.foldSealKey(state.snap_generation, state.snap_attempt))->bytes); + for (const RunRef & run : seal.blob_target_runs) + { + SourceEdgeRunView view = openSourceEdgeRun(backend, run.key); + String key; + String payload; + while (view.next(key, payload)) + count += !payload.empty() && payload[0] == kCondemned; + view.verifyAgainst(run.checksum); + } + return count; +} + +class NominationBackend : public InMemoryBackend +{ +public: + using Backend::deleteExact; + using Backend::get; + using Backend::putOverwrite; + + DeleteOutcome deleteExact(const String & key, const Token & token) override + { + if (key == watched_manifest_key) + { + source_absent_when_delete_started = !activeSourceExists(*this, layout, watched_source_id); + if (replace_manifest_before_delete) + { + const auto got = get(key); + if (got) + putOverwrite(key, got->bytes, got->token); + } + } + return InMemoryBackend::deleteExact(key, token); + } + + Layout layout{"p"}; + String watched_manifest_key; + UInt128 watched_source_id{}; + bool source_absent_when_delete_started = false; + bool replace_manifest_before_delete = false; +}; + +struct ReadyFixture +{ + std::shared_ptr backend; + PoolPtr store; + std::unique_ptr gc; + RootNamespace ns{"test/aa@cas@"}; + ManifestId candidate{ns, candidateRef()}; + std::vector blobs; +}; + +ReadyFixture makeReadyFixture() +{ + ReadyFixture f; + f.backend = std::make_shared(); + PoolConfig config; + config.pool_prefix = "p"; + config.server_root_id = "gc-runner"; + config.manifest_sweep_list_budget_keys = 100; + config.manifest_sweep_delete_budget_keys = 100; + config.gc_fold_max_defer_rounds = 0; + f.store = Pool::open(f.backend, config); + f.backend->layout = f.store->layout(); + f.gc = std::make_unique(f.store, kGcId); + + /// Establish a real catalog life and fold its cursor across epoch 1 before introducing the orphan. + publishAt(*f.backend, f.store->layout(), f.ns, RefTxnId{1, 1}, "live-a", /*build_sequence=*/7, + UInt128(0x7001), /*birth=*/true); + EXPECT_TRUE(runRegularRoundReclaiming(*f.gc).acquired_lease); + writeSealAt(*f.backend, f.store->layout(), f.ns, RefTxnId{1, 2}); + publishAt(*f.backend, f.store->layout(), f.ns, RefTxnId{2, 1}, "live-b", /*build_sequence=*/7, + UInt128(0x7002), /*birth=*/false, /*prev_epoch_seal=*/RefTxnId{1, 2}); + /// Raw log helpers intentionally do not manufacture lifecycle authority. This fixture's durable + /// frontier includes the predecessor seal and the epoch-2 start, so nomination is exercised rather + /// than being (correctly) skipped for a missing `_ckpt`. + writeRecoverableCkptForRawFixture(*f.backend, f.store->layout(), f.ns, RefCkpt{ + .life_epoch = 1, + .committed_through = RefTxnId{2, 1}, + .checkpoint_snapshot_id = std::nullopt, + .last_epoch_seal = RefTxnId{1, 2}, + }); + EXPECT_TRUE(runRegularRoundReclaiming(*f.gc).acquired_lease); + setWatermarkMinActive(*f.backend, f.store->layout(), "test", kCandidateEpoch, /*min_active=*/6); + + std::vector entries; + std::vector seeded_edges; + for (uint64_t i = 0; i < 6; ++i) + { + const UInt128 digest = UInt128(0x8000 + i); + const BlobRef blob = legacyMetaTestRef(digest); + f.blobs.push_back(blob); + writeBlobBody(*f.backend, f.store->layout(), digest); + const String path = "blob-" + std::to_string(i); + entries.push_back(blobEntryFor(path, digest)); + seeded_edges.push_back(BlobDelta{ + .ref = blob, + .source_id = sourceEdgeId(f.candidate, path), + .remove = false}); + if (i < 4) + seeded_edges.push_back(BlobDelta{ + .ref = blob, + .source_id = UInt128(0x9000 + i), + .remove = false}); + } + writeManifestRaw(*f.backend, f.store->layout(), f.ns, f.candidate.ref, entries); + + /// Seed the exact S42 precondition: the candidate manifest's `+1` edges are already in the adopted + /// run, yet the recovered owner view does not name the body. Four blobs also have another source. + const auto state_got = f.backend->get(f.store->layout().gcStateKey()); + EXPECT_TRUE(state_got.has_value()); + GcState state = decodeGcState(state_got->bytes); + const auto parent_got = f.backend->get( + f.store->layout().foldSealKey(state.snap_generation, state.snap_attempt)); + EXPECT_TRUE(parent_got.has_value()); + CasFoldSeal seal = decodeFoldSeal(parent_got->bytes); + const uint64_t new_generation = state.snap_generation + 1; + const uint64_t new_attempt = state.snap_attempt + 1000; + std::vector runs; + RetiredMergeResult retired; + foldDeltasIntoGeneration( + *f.backend, f.store->layout(), seal.blob_target_runs, + new_generation, new_attempt, /*shard=*/0, std::move(seeded_edges), runs, + /*current_round=*/state.round, /*condemn_round=*/state.round, + {}, {}, {}, &retired, /*suppress_destructive=*/false, nullptr); + seal.parent_generation = state.snap_generation; + seal.generation = new_generation; + seal.blob_target_runs = std::move(runs); + seal.condemned_summary[0] = CondemnedSummary{}; + putDeterministicArtifact( + *f.backend, f.store->layout().foldSealKey(new_generation, new_attempt), encodeFoldSeal(seal)); + state.snap_generation = new_generation; + state.snap_attempt = new_attempt; + f.backend->putOverwrite(f.store->layout().gcStateKey(), encodeGcState(state), state_got->token); + + f.backend->watched_manifest_key = f.store->layout().manifestKey(f.candidate); + f.backend->watched_source_id = sourceEdgeId(f.candidate, "blob-0"); + return f; +} + +} + +/// S42: sweeping an aborted precommit must retire that manifest's exact source edges before deleting +/// the body. Other sources stay intact, and only the two uniquely-owned blobs enter retirement. +TEST(CASOrphanNomination, RetiresExactManifestSourcesBeforeDelete) +{ + ReadyFixture f = makeReadyFixture(); + + /// The nominating round's own `fold_reduce` phase carries probe B1/B2's per-round verdict; capture + /// it so the orphan-sourced retirement can be proven accounting-neutral on the real end-to-end path, + /// not only on the synthetic `foldDeltasIntoGeneration` call `SourceRetirementIsAccountingNeutral` + /// drives below. + std::optional fold_reduce; + f.gc->setPhaseSink([&](const GcPhaseRecord & rec) { if (rec.phase == "fold_reduce") fold_reduce = rec; }); + + ASSERT_TRUE(runRegularRoundReclaiming(*f.gc).acquired_lease); + + EXPECT_FALSE(manifestExists(*f.backend, f.store->layout(), f.candidate)); + EXPECT_TRUE(f.backend->source_absent_when_delete_started) + << "the adopted in-degree run must retire the manifest source before exact deletion begins"; + for (size_t i = 0; i < f.blobs.size(); ++i) + { + EXPECT_FALSE(activeSourceExists( + *f.backend, f.store->layout(), sourceEdgeId(f.candidate, "blob-" + std::to_string(i)))); + EXPECT_EQ(inDegreeInRuns(*f.backend, runsForShard(*f.backend, f.store->layout(), 0), f.blobs[i]), + i < 4 ? 1 : 0); + } + EXPECT_EQ(condemnedCount(*f.backend, f.store->layout()), 2u); + + ASSERT_TRUE(fold_reduce.has_value()); + EXPECT_EQ(fold_reduce->metrics.at("unmatched_removes"), 0u) + << "the orphan source retirements are exact removes against a present edge, never an unmatched one"; + EXPECT_EQ(fold_reduce->metrics.at("transactions_unapplied"), 0u) + << "the retirement input rides the reducer alongside ordinary deltas without stranding a " + "committed+produced ref transaction unapplied"; +} + +/// A nomination must exact-GET and decode the manifest before it can derive any source-edge identity. +TEST(CASOrphanNomination, CorruptManifestIsRetainedAndSurfaced) +{ + ReadyFixture f = makeReadyFixture(); + const auto got = f.backend->get(f.backend->watched_manifest_key); + ASSERT_TRUE(got.has_value()); + f.backend->putOverwrite(f.backend->watched_manifest_key, "not a sealed manifest", got->token); + + expectThrowsCode(DB::ErrorCodes::CORRUPTED_DATA, [&] { runRegularRoundReclaiming(*f.gc); }); + EXPECT_TRUE(f.backend->head(f.backend->watched_manifest_key).exists); +} + +/// Manifest identities are immutable. A changed token at the same key is illegal ABA, not an ordinary +/// exact-delete race that may be silently treated as spared. +TEST(CASOrphanNomination, TokenAbaIsRetainedAndSurfaced) +{ + ReadyFixture f = makeReadyFixture(); + f.backend->replace_manifest_before_delete = true; + + expectThrowsCode(DB::ErrorCodes::CORRUPTED_DATA, [&] { runRegularRoundReclaiming(*f.gc); }); + EXPECT_TRUE(f.backend->head(f.backend->watched_manifest_key).exists); +} + +/// Nomination PLANNING itself is gated on `!suppress_destructive` +/// (`Gc::fold`'s orphan_sweep call site), not merely its eventual delete -- a suppressed pass must +/// never even LIST candidates. The suppressed universe is selected explicitly, because that is the +/// subject: a round on the production default would open the gate and sweep. +TEST(CASOrphanNomination, SuppressedRoundNominatesNothing) +{ + ReadyFixture f = makeReadyFixture(); + + std::optional orphan_sweep; + f.gc->setPhaseSink([&](const GcPhaseRecord & rec) { if (rec.phase == "orphan_sweep") orphan_sweep = rec; }); + + ASSERT_TRUE(f.gc->runRegularRound({}, /*allow_steal*/true, + UniversePolicy::StageA_Suppressed).acquired_lease); + + ASSERT_TRUE(orphan_sweep.has_value()); + EXPECT_EQ(orphan_sweep->metrics.at("suppressed"), 1u); + EXPECT_EQ(orphan_sweep->metrics.at("listed"), 0u) + << "planning is gated on !suppress_destructive; a suppressed pass must not even LIST candidates"; + EXPECT_EQ(orphan_sweep->metrics.at("deleted"), 0u); + EXPECT_TRUE(manifestExists(*f.backend, f.store->layout(), f.candidate)) + << "the orphan body must survive a suppressed round"; +} + +/// The retirement input is deliberately outside both ref-transaction accounting mechanisms: a +/// matching edge disappears, an already-absent one stays an idempotent no-op, and neither can alter B2. +TEST(CASOrphanNomination, SourceRetirementIsAccountingNeutral) +{ + InMemoryBackend backend; + const Layout layout{"p"}; + const BlobRef blob = legacyMetaTestRef(UInt128(0xA001)); + const UInt128 source = UInt128(0xA002); + std::vector parent_runs; + foldDeltasIntoGeneration( + backend, layout, {}, /*new_generation=*/1, /*attempt=*/1, /*shard=*/0, + {BlobDelta{.ref = blob, .source_id = source, .remove = false}}, parent_runs); + + std::vector next_runs; + RetiredMergeResult retired; + std::vector applied{0x5A}; + foldDeltasIntoGeneration( + backend, layout, parent_runs, /*new_generation=*/2, /*attempt=*/2, /*shard=*/0, + {}, next_runs, /*current_round=*/1, /*condemn_round=*/1, + {}, {}, {}, &retired, /*suppress_destructive=*/false, &applied, + {BlobSourceRetirement{.ref = blob, .source_id = source}, + BlobSourceRetirement{.ref = blob, .source_id = UInt128(0xA003)}}); + + EXPECT_EQ(inDegreeInRuns(backend, next_runs, blob), 0); + EXPECT_EQ(retired.unmatched_removes, 0u); + EXPECT_EQ(applied, (std::vector{0x5A})); +} diff --git a/src/Disks/tests/gtest_cas_parallel_commit.cpp b/src/Disks/tests/gtest_cas_parallel_commit.cpp new file mode 100644 index 000000000000..7ae5ab676712 --- /dev/null +++ b/src/Disks/tests/gtest_cas_parallel_commit.cpp @@ -0,0 +1,308 @@ +#include +#include +#include +#include +#include +#include +#include +#include +#include + +/// Task 2 of the CAS parallel-write-path plan (docs/superpowers/sdd): `promoteBuild`/`repointRef` +/// return an exact, in-lane-derived `Cas::CommitOutcome` instead of `void`/`bool`, and +/// `dropRefIfMatches` gives a future rollback a conditional drop keyed on that exact outcome instead +/// of the unsafe-under-concurrency `dropRef` (which removes whatever manifest currently occupies the +/// ref name). This suite grows across the later parallel-commit tasks; here it only proves the +/// outcome is exact and that the conditional drop is a true guard -- still single-threaded commit, no +/// concurrency yet. +/// +/// Task 3 reworks `ContentAddressedTransaction::commit()`'s rollback to be EXACT (per-part +/// `Cas::CommitOutcome` slots + `dropRefIfMatches`) while the commit loop stays single-threaded -- +/// correctness-first, before Task 5 adds concurrency. `CasCommitRollback` below drives real +/// `ContentAddressedTransaction`s (not the bare pool primitives `CaWiringFixture` above exercises) +/// through the exact `publishStaging` call path production `commit()` uses, so the fault seams +/// (`armPromoteFailure`/`armAfterPromoteHook`) fire from the real thing. + +using namespace DB; +using namespace DB::Cas::tests; + +namespace +{ + +/// Fixture mirroring `gtest_cas_part_folder_access.cpp`'s `publishPart`/`cacheOn` helpers: a fresh +/// in-memory pool + a `CachedPartFolderAccess` facade over it, plus the minimal staging helpers this +/// suite's tests need (stage a simple one-file part without promoting it; stage-and-promote it in one +/// call; repoint an already-committed ref onto a fresh manifest, modeling a later writer). +struct CaWiringFixture +{ + std::shared_ptr backend = std::make_shared(); + Cas::PoolPtr store = openPoolForTest(backend); + Cas::CachedPartFolderAccess access{store}; + Cas::RootNamespace namespace_{"srv/t1"}; + int content_counter = 0; + + const Cas::RootNamespace & ns() const { return namespace_; } + Cas::CachedPartFolderAccess & partAccess() { return access; } + + static Cas::ManifestEntry inlineEntry(const String & path, const String & bytes) + { + Cas::ManifestEntry e; + e.path = path; + e.placement = Cas::EntryPlacement::Inline; + e.ref = Cas::BlobRef{Cas::BlobHashAlgo::CityHash128, Cas::BlobDigest::fromU128(u128Of(bytes))}; + e.blob_size = bytes.size(); + e.inline_bytes = bytes; + return e; + } + + struct Staged + { + Cas::PartWriteTxnPtr build; + Cas::ManifestId id; + }; + + /// Stages a fresh build (manifest + precommit) for `key` over `blobs` inline entries, WITHOUT + /// promoting it -- the caller drives `promoteBuild` itself so it can observe the exact + /// `CommitOutcome` the promote primitive derives. + Staged stageSimplePart(const Cas::PartRefKey & key, int blobs) const + { + std::vector entries; + for (int i = 0; i < blobs; ++i) + entries.push_back(inlineEntry(fmt::format("f{}", i), fmt::format("payload-{}-{}", key.ref, i))); + auto build = store->beginPartWrite(Cas::PartWriteInfo{ + .intended_ref = key.ns.string() + "/" + key.ref, .intended_namespace = key.ns, .op = Cas::ProvenanceOp::Insert}); + const Cas::ManifestId id = build->stageManifest(entries); + build->precommitAdd(key.ns, key.ref, id); + return {std::move(build), id}; + } + + /// Stages and promotes one simple part end-to-end, returning the exact `CommitOutcome`. + Cas::CommitOutcome commitSimplePart(const Cas::PartRefKey & key, int blobs) + { + auto staged = stageSimplePart(key, blobs); + return access.promoteBuild(*staged.build, key, staged.build->buildId(), staged.id); + } + + /// Repoints an already-committed `key` onto a fresh manifest (different content), through the + /// public `repointRef` primitive -- models "another writer" rebinding the ref after this + /// fixture's own `commitSimplePart`. + Cas::CommitOutcome repointToFreshManifest(const Cas::PartRefKey & key) + { + return access.repointRef(key, {inlineEntry("f0", fmt::format("repoint-{}", ++content_counter))}, + Cas::ProvenanceOp::Other); + } +}; + +} + +TEST(CASCommitOutcome, PromoteReportsCreatedAndManifest) +{ + CaWiringFixture fx; + const Cas::PartRefKey key{fx.ns(), "20260101_1_1_0"}; + auto staged = fx.stageSimplePart(key, /*blobs=*/1); + + const Cas::CommitOutcome oc = fx.partAccess().promoteBuild(*staged.build, key, staged.build->buildId(), staged.id); + + EXPECT_TRUE(oc.created); + EXPECT_EQ(oc.ns.string(), key.ns.string()); + EXPECT_EQ(oc.ref, key.ref); + EXPECT_EQ(oc.manifest_ref, staged.id.ref); +} + +TEST(CASCommitOutcome, DropRefIfMatchesRemovesOnlyExact) +{ + CaWiringFixture fx; + const Cas::PartRefKey key{fx.ns(), "20260101_2_2_0"}; + const Cas::CommitOutcome oc1 = fx.commitSimplePart(key, /*blobs=*/1); + EXPECT_TRUE(oc1.created); + + /// Rebind key -> M2 (a legitimate repoint by "another writer"). + const Cas::CommitOutcome oc2 = fx.repointToFreshManifest(key); + EXPECT_FALSE(oc2.created); + ASSERT_NE(oc1.manifest_ref, oc2.manifest_ref); + + /// Conditional drop keyed on the STALE M1 must NOT remove the current M2 binding. + EXPECT_FALSE(fx.partAccess().dropRefIfMatches(key, oc1.manifest_ref)); + EXPECT_TRUE(fx.partAccess().existsRef(key, Cas::Freshness::ForceFresh)); + + /// Conditional drop keyed on the CURRENT M2 removes it. + EXPECT_TRUE(fx.partAccess().dropRefIfMatches(key, oc2.manifest_ref)); + EXPECT_FALSE(fx.partAccess().existsRef(key, Cas::Freshness::ForceFresh)); +} + +TEST(CASCommitOutcome, DropRefIfMatchesOnAbsentRefIsANoOp) +{ + CaWiringFixture fx; + const Cas::PartRefKey key{fx.ns(), "20260101_3_3_0"}; + Cas::ManifestRef bogus; + EXPECT_FALSE(fx.partAccess().dropRefIfMatches(key, bogus)) << "no committed ref at all: nothing to match"; + EXPECT_FALSE(fx.partAccess().existsRef(key, Cas::Freshness::ForceFresh)); +} + +/// `repointRef`'s byte-equal candidate is a documented ZERO-pool-mutation no-op (it must not mint a +/// fresh manifest just to compare it). The returned `CommitOutcome` must still describe reality: the +/// CURRENTLY committed manifest, unchanged, `created=false`. +TEST(CASCommitOutcome, RepointRefByteEqualNoOpReportsCurrentManifestNotCreated) +{ + CaWiringFixture fx; + const Cas::PartRefKey key{fx.ns(), "20260101_4_4_0"}; + const Cas::CommitOutcome oc1 = fx.commitSimplePart(key, /*blobs=*/1); + + const Cas::CommitOutcome oc_noop = fx.partAccess().repointRef( + key, {CaWiringFixture::inlineEntry("f0", fmt::format("payload-{}-0", key.ref))}, Cas::ProvenanceOp::Other); + EXPECT_FALSE(oc_noop.created); + EXPECT_EQ(oc_noop.manifest_ref, oc1.manifest_ref); +} + +namespace +{ + +/// Fixture for the `CasCommitRollback` suite: wraps a real `ContentAddressedMetadataStorage` and +/// drives ordinary `ContentAddressedTransaction`s through disk paths, so the fault seams under test +/// (`ContentAddressedMetadataStorage::armPromoteFailureForTest`/`setAfterPromoteHookForTest`, the +/// minimal test-only hooks this task adds) fire from the SAME `publishStaging` call path production +/// `commit()` uses -- unlike `CaWiringFixture` above, which pokes the bare pool primitives directly. +/// Every part in one fixture instance shares ONE fixed table uuid (and therefore one `RootNamespace`), +/// matching every test's single `fx.ns()`. +struct CaTxnRollbackFixture +{ + static constexpr const char * kTableUuid = "c3c3c3c3-0000-4000-8000-c3c3c3c3c3c3"; + + std::shared_ptr storage; + Cas::RootNamespace namespace_; + Cas::ManifestRef last_repoint_manifest; + int content_counter = 0; + + static std::string tablePrefix() + { + return std::string(kTableUuid).substr(0, 3) + "/" + kTableUuid; + } + + const Cas::RootNamespace & ns() const { return namespace_; } + Cas::CachedPartFolderAccess & partAccess() const { return *storage->partAccess(); } + + DB::MetadataTransactionPtr beginTxn() const { return storage->createTransaction(); } + + /// Stages `blobs` small distinct files for `key` under a tmp build dir and re-keys them to the + /// final ref name -- the standard MergeTree-insert shape (`gtest_ca_transaction.cpp`'s + /// `writeFileTx` + `moveDirectory` idiom) this storage's routing expects; `key.ns` must be `ns()`. + void stageInto(const DB::MetadataTransactionPtr & txn, const Cas::PartRefKey & key, int blobs) + { + auto & ca_tx = dynamic_cast(*txn); + const std::string tmp_dir = tablePrefix() + "/tmp_insert_" + key.ref; + for (int i = 0; i < blobs; ++i) + { + auto buf = ca_tx.writeFile(fmt::format("{}/f{}.bin", tmp_dir, i), 65536, DB::WriteMode::Rewrite, {}); + const std::string bytes = fmt::format("payload-{}-{}", key.ref, i); + buf->write(bytes.data(), bytes.size()); + buf->finalize(); + } + txn->moveDirectory(tmp_dir, tablePrefix() + "/" + key.ref); + } + + /// Stages and commits one part end-to-end in its own transaction -- sets up a pre-existing + /// committed ref before the transaction under test begins. + void commitSimplePart(const Cas::PartRefKey & key, int blobs) + { + auto txn = beginTxn(); + stageInto(txn, key, blobs); + txn->commit(DB::NoCommitOptions{}); + } + + /// Repoints an already-committed `key` onto a fresh manifest through the public `repointRef` + /// primitive directly -- models "another writer" rebinding the ref concurrently with the + /// transaction under test. Records the manifest for `lastRepointManifest()`. + void repointToFreshManifest(const Cas::PartRefKey & key) + { + const std::string bytes = fmt::format("repoint-{}", ++content_counter); + Cas::ManifestEntry e; + e.path = "f0.bin"; + e.placement = Cas::EntryPlacement::Inline; + e.ref = Cas::BlobRef{Cas::BlobHashAlgo::CityHash128, Cas::BlobDigest::fromU128(u128Of(bytes))}; + e.blob_size = bytes.size(); + e.inline_bytes = bytes; + const auto oc = partAccess().repointRef(key, {e}, Cas::ProvenanceOp::Other); + last_repoint_manifest = oc.manifest_ref; + } + + /// The manifest CURRENTLY bound to `key`, or a default-constructed (zero) `ManifestRef` when `key` + /// has no committed ref at all. + Cas::ManifestRef currentManifest(const Cas::PartRefKey & key) const + { + auto view = partAccess().getView(key, Cas::Freshness::ForceFresh); + return view ? view->manifestId().ref : Cas::ManifestRef{}; + } + + Cas::ManifestRef lastRepointManifest() const { return last_repoint_manifest; } + + /// Test-only fault seam (see `ContentAddressedMetadataStorage::armPromoteFailureForTest`): the + /// NEXT `publishStaging` promote/repoint for `key` (the full `(ns, ref)` routed identity) throws + /// instead of committing. + void armPromoteFailure(const Cas::PartRefKey & key) const { storage->armPromoteFailureForTest(key); } + /// Test-only hook (see `ContentAddressedMetadataStorage::setAfterPromoteHookForTest`): runs once, + /// synchronously, immediately after `key`'s promote/repoint confirms. + void armAfterPromoteHook(const Cas::PartRefKey & key, std::function hook) const + { + storage->setAfterPromoteHookForTest(key, std::move(hook)); + } +}; + +CaTxnRollbackFixture makeCaWiringFixture() +{ + static std::atomic counter{0}; + const auto scratch = std::filesystem::temp_directory_path() + / fmt::format("ca_commit_rollback_scratch_{}_{}", ::getpid(), counter.fetch_add(1)); + auto settings = DB::Cas::tests::makeSettingsForTest("test", scratch); + auto storage = std::make_shared( + DB::Cas::tests::makeLocalObjectStorageForTest(), "pool", "srv1", "", nullptr, settings); + storage->startup(); + + CaTxnRollbackFixture fx; + fx.storage = storage; + fx.namespace_ = storage->liveNamespace(CaTxnRollbackFixture::kTableUuid); + return fx; +} + +} + +/// [TXN-ONE-PIPELINE] Task 3: `commit()` publishes `new_a` (created=true) then fails on `new_b`'s +/// promote. The rollback must drop the just-created `new_a` (absent afterward) but never touch the +/// unrelated `pre_existing` ref committed by an EARLIER, already-finished transaction. +TEST(CASCommitRollback, AbsentBeforeDroppedPreExistingUntouched) +{ + auto fx = makeCaWiringFixture(); + const Cas::PartRefKey pre{fx.ns(), "pre_existing_1_1_0"}; + fx.commitSimplePart(pre, 1); // a pre-existing ref, must survive + // A transaction that commits one NEW part then fails on a second part's promote. + auto txn = fx.beginTxn(); + fx.stageInto(txn, {fx.ns(), "new_a_1_1_0"}, 1); + fx.stageInto(txn, {fx.ns(), "new_b_1_1_0"}, 1); + fx.armPromoteFailure({fx.ns(), "new_b_1_1_0"}); // fault injection in publishStaging's promote + EXPECT_ANY_THROW(txn->commit({})); + EXPECT_FALSE(fx.partAccess().existsRef({fx.ns(), "new_a_1_1_0"}, Cas::Freshness::ForceFresh)); // rolled back + EXPECT_TRUE (fx.partAccess().existsRef(pre, Cas::Freshness::ForceFresh)); // untouched +} + +/// [TXN-ONE-PIPELINE] Task 3: T1 (this transaction) promotes `shared` (M1), then a concurrent writer +/// (modeled by the after-promote hook) repoints it to M2 BEFORE T1's own commit later fails on +/// `poison`'s promote. Rollback must use `dropRefIfMatches(M1)`: M1 != the now-current M2, so the +/// conditional drop must leave `shared` bound to M2 untouched. +/// +/// `commit()` publishes `parts` in the map's own (ns, ref) sort order -- so the "shared" part is named +/// `a_shared_...` and the "poison" part `z_poison_...` here purely so `'a' < 'z'` makes "shared" +/// publish (and get repointed by the hook) deterministically BEFORE "poison" fails; this is a test +/// naming choice, not a production ordering guarantee. +TEST(CASCommitRollback, RepointByOtherWriterSurvivesRollback) +{ + auto fx = makeCaWiringFixture(); + const Cas::PartRefKey key{fx.ns(), "a_shared_1_1_0"}; + auto txn = fx.beginTxn(); + fx.stageInto(txn, key, 1); // T1 will create R -> M1 + fx.armAfterPromoteHook(key, [&]{ fx.repointToFreshManifest(key); }); // T2 repoints R -> M2 right after T1's promote + fx.stageInto(txn, {fx.ns(), "z_poison_1_1_0"}, 1); + fx.armPromoteFailure({fx.ns(), "z_poison_1_1_0"}); + EXPECT_ANY_THROW(txn->commit({})); + // T1's rollback used dropRefIfMatches(M1); M2 != M1 so it must survive. + EXPECT_TRUE(fx.partAccess().existsRef(key, Cas::Freshness::ForceFresh)); + EXPECT_EQ(fx.currentManifest(key), fx.lastRepointManifest()); +} diff --git a/src/Disks/tests/gtest_cas_part_folder_access.cpp b/src/Disks/tests/gtest_cas_part_folder_access.cpp new file mode 100644 index 000000000000..e3948b39cdd3 --- /dev/null +++ b/src/Disks/tests/gtest_cas_part_folder_access.cpp @@ -0,0 +1,1291 @@ +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace DB::ErrorCodes +{ + extern const int FILE_DOESNT_EXIST; + extern const int ABORTED; + extern const int BAD_ARGUMENTS; + extern const int CORRUPTED_DATA; + extern const int LOGICAL_ERROR; + extern const int MEMORY_LIMIT_EXCEEDED; + extern const int NETWORK_ERROR; +} + +namespace ProfileEvents +{ +extern const Event CASRefRollbackBestEffortDropFailed; +extern const Event CASPartFolderValidateSkipped; +} + +using namespace DB; +using namespace DB::Cas::tests; + +namespace +{ + +Cas::ManifestEntry inlineEntry(const String & path, const String & bytes) +{ + Cas::ManifestEntry e; + e.path = path; + e.placement = Cas::EntryPlacement::Inline; + e.ref = DB::Cas::BlobRef{DB::Cas::BlobHashAlgo::CityHash128, DB::Cas::BlobDigest::fromU128(u128Of(bytes))}; + + e.blob_size = bytes.size(); + e.inline_bytes = bytes; + return e; +} + +/// Publish `entries` as committed ref `ns/ref` through the real writer protocol. +Cas::ManifestId publishPart(const Cas::PoolPtr & store, const Cas::RootNamespace & ns, + const String & ref, std::vector entries) +{ + auto build = store->beginPartWrite(Cas::PartWriteInfo{.intended_ref = ns.string() + "/" + ref, + .intended_namespace = ns, .op = Cas::ProvenanceOp::Insert}); + const Cas::ManifestId id = build->stageManifest(entries); + build->precommitAdd(ns, ref, id); + build->promote(ns, ref, build->buildId(), id); + return id; +} + +Cas::CachedPartFolderAccess::CacheParams cacheOn() +{ + return {.cache_bytes = 64ULL << 20, .max_entries = 10000, .max_entry_bytes = 16ULL << 20, + .explain_enabled = true, .validate = {}}; +} + +/// Mirrors gtest_cas_s3_staging.cpp's helper of the same shape: the shape a real CAS disk config +/// has under `storage_configuration.disks.`, so `config_prefix = "disk"` reads exactly like +/// the disk factory's `config_prefix`. Used to unit-test `parsePartFolderValidate` standalone. +Poco::AutoPtr configWithDiskSection(const std::string & inner_xml) +{ + std::istringstream xml_stream( // STYLE_CHECK_ALLOW_STD_STRING_STREAM + "" + inner_xml + ""); + return new Poco::Util::XMLConfiguration(xml_stream); +} + +/// Every mutating backend op throws once armed — models a correlated backend outage during the +/// transaction's compensating rollback (dropRef must append a removal, which mutates the backend). +class RollbackFaultBackend final : public Cas::InMemoryBackend +{ +public: + std::atomic armed{false}; + + Cas::PutResult putIfAbsent(const String & k, const String & b, const Cas::ObjectMeta & m) override + { + failIfArmed(); + return InMemoryBackend::putIfAbsent(k, b, m); + } + + Cas::WriteSinkPtr putIfAbsentStream(const String & k, const Cas::ObjectMeta & m) override + { + failIfArmed(); + return InMemoryBackend::putIfAbsentStream(k, m); + } + + Cas::PutResult putOverwrite(const String & k, const String & b, const Cas::Token & e, const Cas::ObjectMeta & m) override + { + failIfArmed(); + return InMemoryBackend::putOverwrite(k, b, e, m); + } + + Cas::CasResult casPut(const String & k, const String & b, const std::optional & e, const Cas::ObjectMeta & m) override + { + failIfArmed(); + return InMemoryBackend::casPut(k, b, e, m); + } + + Cas::DeleteOutcome deleteExact(const String & k, const Cas::Token & t) override + { + failIfArmed(); + return InMemoryBackend::deleteExact(k, t); + } + +private: + void failIfArmed() + { + if (armed.load()) + throw Exception(ErrorCodes::ABORTED, "injected backend outage"); + } +}; + +/// Task 7 (`publishEntries` abandons its build on exception): forces publishEntries's PROMOTE step +/// specifically -- not the earlier stageManifest/precommitAdd writes -- to observe a proven ref-log +/// conflict. `skip` lets the FIRST matching '_log/' PUT (precommitAdd's OwnerTransition-to-Precommit) +/// land normally; the fault then fires on the SECOND (promote's atomic precommit->committed move). +/// Mirrors `RefWriterTestBackend::corrupt_key_substr` (gtest_cas_ref_writer.cpp, reproduced locally +/// because that class lives in a different translation unit): landing a DIFFERENT object at the +/// intended key makes `putIfAbsentControlled`'s resolve-before-reissue observe a proven conflict +/// (CORRUPTED_DATA) rather than the ambiguous-timeout shape, which would instead wedge the whole +/// table's append lane. +class PromoteConflictOnceBackend final : public Cas::InMemoryBackend +{ +public: + String fault_key_substr; + int skip = 0; + int fault_count = 0; + /// Every create ATTEMPTED at a matching key, faulted or not. It is how a test observes that a + /// cleanup path ran its ref-log append at all, on a table where that append can no longer succeed. + int matching_put_attempts = 0; + + Cas::PutResult putIfAbsent(const String & key, const String & bytes, const Cas::ObjectMeta & meta) override + { + if (!fault_key_substr.empty() && key.find(fault_key_substr) != String::npos) + { + ++matching_put_attempts; + if (skip > 0) + --skip; + else if (fault_count > 0) + { + --fault_count; + /// The 3-arg qualified call bypasses virtual dispatch entirely (unlike a 2-arg + /// convenience overload, which would re-enter this very override through the vtable). + InMemoryBackend::putIfAbsent(key, bytes + String("\x01_FOREIGN_DIFFERENT"), meta); + throw Poco::TimeoutException("PromoteConflictOnceBackend: a foreign different object landed; response lost"); + } + } + return InMemoryBackend::putIfAbsent(key, bytes, meta); + } +}; + +/// The same shape as `PromoteConflictOnceBackend`, except its fault is a whitelisted SYNCHRONOUS +/// REJECTION -- an S3-classified malformed request, which `classifyConditionalWriteResult` proves was +/// never applied. That distinction is the whole reason this second backend exists: a proven DIFFERENT +/// OBJECT is a breach of mount write-exclusivity and fences the whole mount closed, so every cleanup +/// append after it is refused at the gate and becomes unobservable. A definite rejection is an ordinary +/// failed write -- nothing is fenced, nothing is wedged, the table stays usable -- so the cleanup +/// appends that follow DO reach the store and can be counted. +class PromoteDefiniteFailureBackend final : public Cas::InMemoryBackend +{ +public: + String fault_key_substr; + int skip = 0; + int fault_count = 0; + int matching_put_attempts = 0; + + Cas::PutResult putIfAbsent(const String & key, const String & bytes, const Cas::ObjectMeta & meta) override + { + if (!fault_key_substr.empty() && key.find(fault_key_substr) != String::npos) + { + ++matching_put_attempts; + if (skip > 0) + --skip; + else if (fault_count > 0) + { + --fault_count; + throw DB::S3Exception("PromoteDefiniteFailureBackend: simulated malformed request", + Aws::S3::S3Errors::UNKNOWN, "MalformedXML"); + } + } + return InMemoryBackend::putIfAbsent(key, bytes, meta); + } +}; + +} + +TEST(CASPartFolderAccess, RetainedHitSkipsManifestHead) +{ + auto backend = std::make_shared(); + auto store = openPoolForTest(backend); + const Cas::Layout layout("p"); + const Cas::RootNamespace ns{"srv/t1"}; + Cas::CachedPartFolderAccess access(store, cacheOn()); + const auto id = publishPart(store, ns, "part_1", {inlineEntry("checksums.txt", "cs")}); + const Cas::PartRefKey key{ns, "part_1"}; + const String manifest_key = layout.manifestKey(id); + + backend->resetCounts(); + for (int i = 0; i < 5; ++i) + ASSERT_NE(access.getView(key, Cas::Freshness::CachedForLoad), nullptr); + + /// The one-GET goal (spec acceptance 4): ONE body GET, ONE mandatory HEAD (the cold build); + /// every subsequent CachedForLoad call is a validated hit — zero manifest ops. + EXPECT_EQ(backend->getCount(manifest_key), 1u); + EXPECT_EQ(backend->headCount(manifest_key), 1u); + EXPECT_TRUE(access.explain(key).retained); + EXPECT_EQ(access.explain(key).last_decision, + Cas::CachedPartFolderAccess::LastDecision::Hit); +} + +TEST(CASPartFolderAccess, HitPathJournalEmptyAndCheapWhenExplainDisabled) +{ + auto backend = std::make_shared(); + auto store = openPoolForTest(backend); + const Cas::Layout layout("p"); + const Cas::RootNamespace ns{"srv/t1"}; + /// Retention ON, explain journal OFF (the production default): the hit path must take neither the + /// per-disk explain mutex nor write a journal entry (B2). + Cas::CachedPartFolderAccess access(store, + {.cache_bytes = 64ULL << 20, .max_entries = 10000, .max_entry_bytes = 16ULL << 20, + .explain_enabled = false, .validate = {}}); + const auto id = publishPart(store, ns, "part_1", {inlineEntry("checksums.txt", "cs")}); + const Cas::PartRefKey key{ns, "part_1"}; + const String manifest_key = layout.manifestKey(id); + + backend->resetCounts(); + for (int i = 0; i < 5; ++i) + ASSERT_NE(access.getView(key, Cas::Freshness::CachedForLoad), nullptr); + + /// Same request oracle as RetainedHitSkipsManifestHead — one cold build, then validated hits. + EXPECT_EQ(backend->getCount(manifest_key), 1u); + EXPECT_EQ(backend->headCount(manifest_key), 1u); + /// The journal is never written when disabled. + EXPECT_EQ(access.explainJournalSizeForTest(), 0u); + /// explain() still reports live retention truthfully, but the decision defaults to Miss (unwritten). + EXPECT_TRUE(access.explain(key).retained); + EXPECT_EQ(access.explain(key).last_decision, + Cas::CachedPartFolderAccess::LastDecision::Miss); +} + +TEST(CASPartFolderAccess, GetViewServesCommittedFolder) +{ + auto backend = std::make_shared(); + auto store = openPoolForTest(backend); + const Cas::RootNamespace ns{"srv/t1"}; + publishPart(store, ns, "part_1", + {inlineEntry("checksums.txt", "cs"), inlineEntry("count.txt", "1"), inlineEntry("txn_version.txt", "v1")}); + + Cas::CachedPartFolderAccess access(store); + const Cas::PartRefKey key{ns, "part_1"}; + + auto view = access.getView(key, Cas::Freshness::CachedForLoad); + ASSERT_NE(view, nullptr); + EXPECT_NE(view->findFile("checksums.txt"), nullptr); + EXPECT_EQ(view->inlineBytes("txn_version.txt"), std::optional("v1")); + + /// Absent ref => nullptr, never an exception, never retained (nothing to retain in Phase 2). + EXPECT_EQ(access.getView({ns, "absent"}, Cas::Freshness::CachedForLoad), nullptr); + EXPECT_TRUE(access.existsRef(key, Cas::Freshness::CachedForLoad)); + EXPECT_FALSE(access.existsRef({ns, "absent"}, Cas::Freshness::ForceFresh)); + ASSERT_TRUE(access.resolve(key, Cas::Freshness::ForceFresh).has_value()); +} + +TEST(CASPartFolderAccess, GetViewFailsClosedOnMissingBody) +{ + auto backend = std::make_shared(); + auto store = openPoolForTest(backend); + const Cas::Layout layout("p"); + const Cas::RootNamespace ns{"srv/t1"}; + const auto id = publishPart(store, ns, "part_1", {inlineEntry("checksums.txt", "cs")}); + + /// Physically delete the live manifest body (a protocol violation) — every getView mode must + /// surface INV-NO-DANGLE as FILE_DOESNT_EXIST in Phase 2 (there is no retained view to hit). + /// Retention is off (the single-arg ctor below), so this is the `always` (default) part_folder_validate + /// mode under test regardless — the `never`/`age` skip is proven by the ValidateNever/ValidateAge + /// tests further down, which turn retention ON. + deleteManifestBody(*backend, layout, id); + + Cas::CachedPartFolderAccess access(store); + const Cas::PartRefKey key{ns, "part_1"}; + for (auto freshness : {Cas::Freshness::CachedForLoad, + Cas::Freshness::ForceFresh, + Cas::Freshness::StrictValidate}) + expectThrowsCode(ErrorCodes::FILE_DOESNT_EXIST, [&] { access.getView(key, freshness); }); +} + +TEST(CASPartFolderAccess, WritePrimitivesRoundTrip) +{ + auto backend = std::make_shared(); + auto store = openPoolForTest(backend); + const Cas::RootNamespace ns{"srv/t1"}; + Cas::CachedPartFolderAccess access(store); + const Cas::PartRefKey key{ns, "part_1"}; + + /// promoteBuild: the transaction's terminal publish step, through the facade. + auto build = store->beginPartWrite(Cas::PartWriteInfo{.intended_ref = ns.string() + "/part_1", + .intended_namespace = ns, .op = Cas::ProvenanceOp::Insert}); + const Cas::ManifestId id = build->stageManifest({inlineEntry("checksums.txt", "cs")}); + build->precommitAdd(ns, "part_1", id); + access.promoteBuild(*build, key, build->buildId(), id); + ASSERT_TRUE(access.existsRef(key, Cas::Freshness::ForceFresh)); + + /// dropRefIfPresent: replay-safe (absent ref is success, not failure). + access.dropRefIfPresent(key); + EXPECT_FALSE(access.existsRef(key, Cas::Freshness::ForceFresh)); + access.dropRefIfPresent(key); /// second drop: no-op, no throw + access.dropRefBestEffort(key); /// noexcept even when absent + + /// dropNamespace clears the whole namespace. + publishPart(store, ns, "part_2", {inlineEntry("checksums.txt", "cs")}); + access.dropNamespace(ns); + EXPECT_FALSE(access.existsRef({ns, "part_2"}, Cas::Freshness::ForceFresh)); +} + +TEST(CASPartFolderAccess, RepublishRefMovesCommittedRef) +{ + auto backend = std::make_shared(); + auto store = openPoolForTest(backend); + const Cas::RootNamespace ns{"srv/t1"}; + Cas::CachedPartFolderAccess access(store); + publishPart(store, ns, "src_part", {inlineEntry("checksums.txt", "cs"), inlineEntry("txn_version.txt", "v1")}); + + EXPECT_FALSE(access.republishRef({ns, "absent"}, {ns, "dst"})); /// absent source: nothing written + + ASSERT_TRUE(access.republishRef({ns, "src_part"}, {ns, "dst_part"})); + EXPECT_FALSE(access.existsRef({ns, "src_part"}, Cas::Freshness::ForceFresh)); + auto view = access.getView({ns, "dst_part"}, Cas::Freshness::ForceFresh); + ASSERT_NE(view, nullptr); + EXPECT_NE(view->findFile("checksums.txt"), nullptr); + EXPECT_EQ(view->inlineBytes("txn_version.txt"), std::optional("v1")); /// carried over +} + +TEST(CASPartFolderAccess, RepublishRefIdempotentRedriveAndConflict) +{ + auto backend = std::make_shared(); + auto store = openPoolForTest(backend); + const Cas::RootNamespace ns{"srv/t1"}; + Cas::CachedPartFolderAccess access(store); + + /// Re-drive: dst already committed with the SAME content (a prior attempt's promote landed, + /// only dropRef(src) was interrupted) -- idempotent-skip: drop src, dst's manifest is untouched + /// (all-tree-part-files Task 9: there is no separate mutable payload left to drift/re-sync -- + /// identical `entries` is the whole idempotency contract now). + publishPart(store, ns, "src", {inlineEntry("f", "same")}); + publishPart(store, ns, "dst", {inlineEntry("f", "same")}); + const auto dst_id_before = access.resolve({ns, "dst"}, Cas::Freshness::ForceFresh)->manifest_id; + ASSERT_TRUE(access.republishRef({ns, "src"}, {ns, "dst"})); + EXPECT_FALSE(access.existsRef({ns, "src"}, Cas::Freshness::ForceFresh)); + auto resolved = access.resolve({ns, "dst"}, Cas::Freshness::ForceFresh); + EXPECT_EQ(resolved->manifest_id, dst_id_before) << "idempotent re-drive must not mint a fresh manifest"; + + /// Conflict: dst committed with DIFFERENT content — fail closed, src untouched. + publishPart(store, ns, "src2", {inlineEntry("f", "one")}); + publishPart(store, ns, "dst2", {inlineEntry("f", "two")}); + expectThrowsCode(ErrorCodes::ABORTED, [&] { access.republishRef({ns, "src2"}, {ns, "dst2"}); }); + EXPECT_TRUE(access.existsRef({ns, "src2"}, Cas::Freshness::ForceFresh)); +} + +/// Task 7: `publishEntries`'s `catch (...) { build->abandon(); throw; }` must leave no live-epoch +/// precommit binding behind when its promote fails -- only `abandon()` removes it (the build +/// destructor merely retires the build seq; GC never touches a live precommit). Drives the failure +/// through `republishRef` -> `publishEntries`, with the fault isolated to promote's own ref-log +/// append (precommitAdd's own append is let through first via `skip`). +TEST(CASPartFolderAccess, PublishEntriesAbandonsBuildOnPromoteFailure) +{ + auto backend = std::make_shared(); + auto store = Cas::Pool::open(backend, Cas::PoolConfig{.pool_prefix = "p", .server_root_id = "test"}); + const Cas::RootNamespace ns{"srv/t1"}; + DB::Cas::tests::casAdmitRecoverableEntry(*backend, store->layout(), ns, store->liveWriterEpoch()); + Cas::CachedPartFolderAccess access(store); + + publishPart(store, ns, "src", {inlineEntry("f", "same")}); + + backend->fault_key_substr = store->layout().namespaceStreamPrefix(fixture::fixtureLife(ns)) + "_log/"; + backend->skip = 1; /// let precommitAdd's own ref-log append land normally + backend->fault_count = 1; /// fault exactly promote's ref-log append + const int attempts_before = backend->matching_put_attempts; + + /// republishRef(src, dst) drives publishEntries(dst, ...): precommitAdd succeeds, promote's + /// appendRefOps observes a proven conflict and throws CORRUPTED_DATA -- publishEntries's catch must + /// abandon() the build before rethrowing. + expectThrowsCode(ErrorCodes::CORRUPTED_DATA, [&] { access.republishRef({ns, "src"}, {ns, "dst"}); }); + /// The anomaly fenced this runtime, so a post-fence `ForceFresh` read must refuse rather than + /// authorizing its stale generation. The backend assertions below prove directly that `dst` never + /// committed and that no append skipped around the damaged slot. + expectThrowsCode(ErrorCodes::NETWORK_ERROR, + [&] { (void)access.existsRef({ns, "dst"}, Cas::Freshness::ForceFresh); }); + + /// EXACTLY two ref-log create attempts reach the store: precommitAdd's own append and the promote's + /// faulted one. Both of the cleanup appends that follow -- `promote`'s catch-abandon and the handle + /// destructor's backstop -- are refused at the mount-fence gate before they reach the store, because + /// proving a different object at our own key now fences the mount closed and schedules a remount + /// (review I5: the append site self-heals like the wedge-resolve site instead of leaving this table + /// blocked until a manual remount). + /// + /// COVERAGE NOTE, deliberately explicit: this count no longer DISCRIMINATES whether the catch-abandon + /// ran. It used to (four attempts with it, three without), and that only worked because the + /// catch-abandon could still reach the store and fail there, making the destructor retry. With the + /// fence closed both cleanups are refused identically and unobservably, so the assertion below is a + /// shape check, not the regression guard it was. The guard cannot be restored in THIS scenario -- + /// nothing the cleanup does is observable once the mount is fenced -- and it is not silently + /// dropped: the property it protected is stated here, and reclaiming the binding is now the + /// scheduled remount's job (a fresh incarnation re-derives the table and the stale-precommit sweep + /// reclaims), not this best-effort abandon's. + EXPECT_EQ(backend->matching_put_attempts, attempts_before + 2) + << "only precommitAdd's append and the promote's faulted one may reach the store; every cleanup " + "append after the anomaly is refused at the fence"; + EXPECT_FALSE(store->mayMutate()) << "the proven conflict must fence this mount closed"; + EXPECT_EQ(store->scheduleRemountCallCountForTest(), 1u) + << "and must schedule exactly one remount -- the self-heal that replaces the manual one"; + + /// And nothing was written ABOVE the damage: the occupant is the GREATEST log id in the namespace + /// (keys render the id in fixed-width hex, so lexical order is id order). An append that carved a + /// fresh id to get past the foreign object would sort above it. + String greatest_key; + size_t foreign_objects = 0; + for (String cursor;;) + { + const Cas::ListPage page = backend->list(backend->fault_key_substr, cursor, 1000); + for (const auto & listed : page.keys) + { + if (listed.key > greatest_key) + greatest_key = listed.key; + const auto body = backend->get(listed.key); + if (body && body->bytes.find("_FOREIGN_DIFFERENT") != String::npos) + ++foreign_objects; + } + if (page.next_cursor.empty()) + break; + cursor = page.next_cursor; + } + EXPECT_EQ(foreign_objects, 1u) << "the foreign object must still own the key it took"; + ASSERT_FALSE(greatest_key.empty()); + const auto greatest_body = backend->get(greatest_key); + ASSERT_TRUE(greatest_body.has_value()); + EXPECT_NE(greatest_body->bytes.find("_FOREIGN_DIFFERENT"), String::npos) + << "the foreign occupant must still be the highest id in this table's stream: a log object above " + "it would mean an append carved a fresh id around the damage instead of failing closed"; +} + +/// The DISCRIMINATING guard for the same duty, on the path where it can still be observed: a promote +/// failure that is an ordinary failed write rather than a breach of mount write-exclusivity. Nothing is +/// fenced and nothing is wedged, so both cleanup appends reach the store and the two worlds separate. +/// +/// The fault covers TWO appends, and that is the whole construction: +/// with `promote`'s catch-abandon -- precommitAdd lands (skipped), promote's append is refused, +/// the catch-abandon's append is refused too, and the handle DESTRUCTOR's backstop retries and lands: +/// FOUR attempts, and no binding is left behind; +/// without it -- precommitAdd lands, promote's append is refused, and the destructor's backstop takes +/// the second fault and is refused: THREE attempts, and the precommit binding LEAKS. +/// So the count and the end state disagree between the two worlds, which is what makes this a guard +/// rather than a shape check. `livePrecommitsForTest` is the direct statement of the property -- +/// `publishEntries` must not walk away from a live precommit binding -- and the count is what pins +/// WHERE the cleanup came from. +TEST(CASPartFolderAccess, PublishEntriesAbandonsBuildOnARetryablePromoteFailure) +{ + auto backend = std::make_shared(); + auto store = Cas::Pool::open(backend, Cas::PoolConfig{.pool_prefix = "p", .server_root_id = "test"}); + const Cas::RootNamespace ns{"srv/t1"}; + DB::Cas::tests::casAdmitRecoverableEntry(*backend, store->layout(), ns, store->liveWriterEpoch()); + Cas::CachedPartFolderAccess access(store); + + publishPart(store, ns, "src", {inlineEntry("f", "same")}); + + backend->fault_key_substr = store->layout().namespaceStreamPrefix(fixture::fixtureLife(ns)) + "_log/"; + backend->skip = 1; /// let precommitAdd's own ref-log append land normally + backend->fault_count = 2; /// fault promote's append AND the cleanup append that follows it + const int attempts_before = backend->matching_put_attempts; + + /// A definite rejection is reported to the caller as a retry-later failure, not as corruption. + expectThrowsCode(ErrorCodes::NETWORK_ERROR, [&] { access.republishRef({ns, "src"}, {ns, "dst"}); }); + EXPECT_FALSE(access.existsRef({ns, "dst"}, Cas::Freshness::ForceFresh)) << "the failed promote never committed dst"; + + EXPECT_TRUE(store->mayMutate()) << "an ordinary failed write must not fence the mount"; + EXPECT_EQ(store->scheduleRemountCallCountForTest(), 0u) << "and must not schedule a remount"; + EXPECT_FALSE(store->refLaneWedgedForTest(ns)) << "a definite rejection is proven non-durable: no wedge"; + + EXPECT_EQ(backend->matching_put_attempts, attempts_before + 4) + << "three attempts means only the destructor backstop ran -- publishEntries stopped abandoning " + "the build at the promote site"; + EXPECT_TRUE(store->livePrecommitsForTest(ns).empty()) + << "publishEntries must not walk away from a live precommit binding"; +} + + +/// ==== Task 12: the prepared-part-write handle (spec §relink-handle) ==== +/// `prepareEntries` stops after `precommitAdd`, so the durable-but-unpromoted state -- the window the +/// relink confirm round-trip has to sit inside -- becomes an OWNED object instead of an interval inside +/// one call. Every test below pins one half of that ownership contract. + +/// Prepare-then-promote must be indistinguishable from today's atomic `publishEntries`, and the state +/// BETWEEN the two halves must be exactly one live precommit and no committed ref. +TEST(CASPartFolderAccess, PrepareThenPromoteMatchesPublishEntries) +{ + auto backend = std::make_shared(); + auto store = openPoolForTest(backend); + const Cas::RootNamespace ns{"srv/t1"}; + Cas::CachedPartFolderAccess access(store, cacheOn()); + + const std::vector entries{inlineEntry("f", "one"), inlineEntry("g", "two")}; + const Cas::CommitOutcome published = access.publishEntries({ns, "via_publish"}, entries, Cas::ProvenanceOp::Insert); + + auto prepared = access.prepareEntries({ns, "via_prepare"}, entries, Cas::ProvenanceOp::Insert); + + /// The interposition point: the manifest is durable and owned by a LIVE precommit, but nothing is + /// committed yet. This is precisely the state the confirm round-trip runs in. + EXPECT_TRUE(store->livePrecommitsForTest(ns).contains({"via_prepare", prepared.manifestId().ref})) + << "prepareEntries must leave the precommit binding live -- it is the durable `+1`"; + EXPECT_FALSE(access.existsRef({ns, "via_prepare"}, Cas::Freshness::ForceFresh)) + << "prepareEntries must not commit the ref"; + + const Cas::CommitOutcome promoted = prepared.promote(); + EXPECT_EQ(promoted.ns.string(), ns.string()); + EXPECT_EQ(promoted.ref, "via_prepare"); + EXPECT_EQ(promoted.manifest_ref, prepared.manifestId().ref); + EXPECT_TRUE(promoted.created); + EXPECT_EQ(promoted.created, published.created) << "the split must reproduce publishEntries's outcome shape"; + EXPECT_TRUE(store->livePrecommitsForTest(ns).empty()) << "promote moves the binding out of the precommit view"; + + auto view = access.getView({ns, "via_prepare"}, Cas::Freshness::ForceFresh); + ASSERT_NE(view, nullptr); + EXPECT_EQ(view->inlineBytes("f"), std::optional("one")); + EXPECT_EQ(view->inlineBytes("g"), std::optional("two")); +} + +/// Abort is not "drop the handle": it must APPEND the exact precommit removal. An abandoned precommit +/// that keeps its `+1` is the retention-leak class (`BACKLOG {#unmatched-minus-one-retention-leak}`), +/// and the stale-precommit sweep is prior-epoch-scoped, so a same-epoch leak is never reclaimed. +/// Asserted through the ledger's own precommit view rather than inferred from a later `precommitAdd`. +TEST(CASPartFolderAccess, PrepareThenAbortAppendsThePrecommitRemoval) +{ + auto backend = std::make_shared(); + auto store = openPoolForTest(backend); + const Cas::RootNamespace ns{"srv/t1"}; + Cas::CachedPartFolderAccess access(store, cacheOn()); + + auto prepared = access.prepareEntries({ns, "part_1"}, {inlineEntry("f", "one")}, Cas::ProvenanceOp::Insert); + const Cas::ManifestId id = prepared.manifestId(); + ASSERT_TRUE(store->livePrecommitsForTest(ns).contains({"part_1", id.ref})); + + prepared.abort(); + + EXPECT_FALSE(access.existsRef({ns, "part_1"}, Cas::Freshness::ForceFresh)) << "an aborted prepare commits nothing"; + EXPECT_FALSE(store->livePrecommitsForTest(ns).contains({"part_1", id.ref})) + << "abort must append the EXACT precommit removal; a same-epoch precommit left behind retains its " + "blobs forever (the prior-epoch-scoped stale sweep never reclaims it)"; + EXPECT_TRUE(store->livePrecommitsForTest(ns).empty()); + /// The precommit BODY survives (delete-after-sealed-decrements) -- the removal queues GC's `-1`, + /// it does not writer-delete the manifest. Mirrors + /// `CASPartWriteTxn.AbandonAppendsPrecommitRemovalAndKeepsLivePrecommitBody`. + EXPECT_TRUE(backend->head(store->layout().manifestKey(id)).exists); +} + +/// A forgotten terminal must be impossible, not merely discouraged: `~PartWriteTxn` only retires the +/// build sequence, so the handle's own destructor is the last-resort owner of the precommit removal. +TEST(CASPartFolderAccess, DestroyingAnUnfinishedPreparedPartWriteAborts) +{ + auto backend = std::make_shared(); + auto store = openPoolForTest(backend); + const Cas::RootNamespace ns{"srv/t1"}; + Cas::CachedPartFolderAccess access(store, cacheOn()); + + std::optional id; + { + auto prepared = access.prepareEntries({ns, "part_1"}, {inlineEntry("f", "one")}, Cas::ProvenanceOp::Insert); + id = prepared.manifestId(); + ASSERT_TRUE(store->livePrecommitsForTest(ns).contains({"part_1", id->ref})); + } /// neither promoted nor aborted + + EXPECT_TRUE(store->livePrecommitsForTest(ns).empty()) + << "destruction without a terminal must still append the precommit removal"; + EXPECT_FALSE(access.existsRef({ns, "part_1"}, Cas::Freshness::ForceFresh)); +} + +/// The terminal flag is explicit and one-shot: a second `promote`/`abort` is a caller bug, not an +/// idempotent no-op, and must never re-drive the (already dead) transaction. +/// +/// The rejection throws LOGICAL_ERROR, which aborts the whole process in debug/sanitizer builds +/// (Exception.cpp's handle_error_code) instead of behaving like a catchable exception -- so the +/// expectThrowsCode form only makes sense in a plain release build, and the DeathTest variant below +/// proves the SAME rejections positively abort under debug/sanitizer builds instead (same pattern as +/// CASWiringOpsDeathTest in gtest_ca_wiring.cpp). +#ifndef DEBUG_OR_SANITIZER_BUILD +TEST(CASPartFolderAccess, PreparedPartWriteRejectsASecondTerminal) +{ + auto backend = std::make_shared(); + auto store = openPoolForTest(backend); + const Cas::RootNamespace ns{"srv/t1"}; + Cas::CachedPartFolderAccess access(store, cacheOn()); + + auto promoted = access.prepareEntries({ns, "promoted"}, {inlineEntry("f", "one")}, Cas::ProvenanceOp::Insert); + EXPECT_FALSE(promoted.isTerminal()); + promoted.promote(); + EXPECT_TRUE(promoted.isTerminal()); + expectThrowsCode(ErrorCodes::LOGICAL_ERROR, [&] { promoted.promote(); }); + expectThrowsCode(ErrorCodes::LOGICAL_ERROR, [&] { promoted.abort(); }); + + auto aborted = access.prepareEntries({ns, "aborted"}, {inlineEntry("f", "one")}, Cas::ProvenanceOp::Insert); + aborted.abort(); + EXPECT_TRUE(aborted.isTerminal()); + expectThrowsCode(ErrorCodes::LOGICAL_ERROR, [&] { aborted.abort(); }); + expectThrowsCode(ErrorCodes::LOGICAL_ERROR, [&] { aborted.promote(); }); + + EXPECT_TRUE(access.existsRef({ns, "promoted"}, Cas::Freshness::ForceFresh)); + EXPECT_FALSE(access.existsRef({ns, "aborted"}, Cas::Freshness::ForceFresh)); + EXPECT_TRUE(store->livePrecommitsForTest(ns).empty()); +} +#else +TEST(CASPartFolderAccessDeathTest, PreparedPartWriteRejectsASecondTerminalAborts) +{ + auto backend = std::make_shared(); + auto store = openPoolForTest(backend); + const Cas::RootNamespace ns{"srv/t1"}; + Cas::CachedPartFolderAccess access(store, cacheOn()); + + auto promoted = access.prepareEntries({ns, "promoted"}, {inlineEntry("f", "one")}, Cas::ProvenanceOp::Insert); + promoted.promote(); + EXPECT_TRUE(promoted.isTerminal()); + EXPECT_DEATH(promoted.promote(), "owes exactly one terminal operation"); + EXPECT_DEATH(promoted.abort(), "owes exactly one terminal operation"); + + auto aborted = access.prepareEntries({ns, "aborted"}, {inlineEntry("f", "one")}, Cas::ProvenanceOp::Insert); + aborted.abort(); + EXPECT_TRUE(aborted.isTerminal()); + EXPECT_DEATH(aborted.abort(), "owes exactly one terminal operation"); + EXPECT_DEATH(aborted.promote(), "owes exactly one terminal operation"); + + EXPECT_TRUE(access.existsRef({ns, "promoted"}, Cas::Freshness::ForceFresh)); + EXPECT_FALSE(access.existsRef({ns, "aborted"}, Cas::Freshness::ForceFresh)); + EXPECT_TRUE(store->livePrecommitsForTest(ns).empty()); +} +#endif + +/// Move-only, and the move transfers the terminal duty in full: the moved-from handle is already +/// terminal (its destructor must not re-abort a transaction the destination now owns), while the +/// destination still owes exactly one terminal. +TEST(CASPartFolderAccess, PreparedPartWriteMoveTransfersTheTerminalDuty) +{ + static_assert(!std::is_copy_constructible_v); + static_assert(!std::is_copy_assignable_v); + + auto backend = std::make_shared(); + auto store = openPoolForTest(backend); + const Cas::RootNamespace ns{"srv/t1"}; + Cas::CachedPartFolderAccess access(store, cacheOn()); + + auto source = access.prepareEntries({ns, "part_1"}, {inlineEntry("f", "one")}, Cas::ProvenanceOp::Insert); + const Cas::ManifestId id = source.manifestId(); + { + Cas::PreparedPartWrite moved = std::move(source); + /// NOLINTNEXTLINE(bugprone-use-after-move,clang-analyzer-cplusplus.Move,hicpp-invalid-access-moved) + EXPECT_TRUE(source.isTerminal()) << "a moved-from handle owes nothing"; +#ifndef DEBUG_OR_SANITIZER_BUILD + expectThrowsCode(ErrorCodes::LOGICAL_ERROR, [&] { source.abort(); }); +#else + /// LOGICAL_ERROR aborts the process in debug/sanitizer builds; EXPECT_DEATH forks, so the + /// parent's state (and the rest of this test) is unaffected. + EXPECT_DEATH(source.abort(), "owes exactly one terminal operation"); +#endif + EXPECT_TRUE(store->livePrecommitsForTest(ns).contains({"part_1", id.ref})) + << "the moved-from handle must not have aborted the transaction it handed over"; + EXPECT_EQ(moved.manifestId().ref, id.ref); + moved.promote(); + } /// the moved-from handle's destructor also runs here: it must be a no-op, not a second abort + + EXPECT_TRUE(access.existsRef({ns, "part_1"}, Cas::Freshness::ForceFresh)); + EXPECT_TRUE(store->livePrecommitsForTest(ns).empty()); +} + +TEST(CASPartFolderAccess, ExplainRecordsDecisions) +{ + auto backend = std::make_shared(); + auto store = openPoolForTest(backend); + const Cas::RootNamespace ns{"srv/t1"}; + Cas::CachedPartFolderAccess access(store, {.explain_enabled = true, .validate = {}}); + publishPart(store, ns, "part_1", {inlineEntry("checksums.txt", "cs")}); + const Cas::PartRefKey key{ns, "part_1"}; + + access.getView(key, Cas::Freshness::CachedForLoad); + EXPECT_EQ(access.explain(key).last_decision, + Cas::CachedPartFolderAccess::LastDecision::Miss); /// cold build + EXPECT_FALSE(access.explain(key).retained); /// Phase 3: never + + access.getView(key, Cas::Freshness::ForceFresh); + EXPECT_EQ(access.explain(key).last_decision, + Cas::CachedPartFolderAccess::LastDecision::ForceFreshRead); + + access.getView(key, Cas::Freshness::StrictValidate); + EXPECT_EQ(access.explain(key).last_decision, + Cas::CachedPartFolderAccess::LastDecision::StrictBypass); + + access.dropRef(key); + EXPECT_EQ(access.explain(key).last_decision, + Cas::CachedPartFolderAccess::LastDecision::Invalidated); + EXPECT_GT(access.explain(key).estimated_bytes, 0u); +} + +TEST(CASPartFolderAccess, BaselineRequestCountsWithoutRetention) +{ + auto backend = std::make_shared(); + auto store = openPoolForTest(backend); + const Cas::Layout layout("p"); + const Cas::RootNamespace ns{"srv/t1"}; + Cas::CachedPartFolderAccess access(store); + const auto id = publishPart(store, ns, "part_1", {inlineEntry("checksums.txt", "cs")}); + const Cas::PartRefKey key{ns, "part_1"}; + const String manifest_key = layout.manifestKey(id); + + backend->resetCounts(); + constexpr int n = 5; + for (int i = 0; i < n; ++i) + ASSERT_NE(access.getView(key, Cas::Freshness::CachedForLoad), nullptr); + + /// The Phase-3 baseline (retention off): one manifest-body GET (the decode cache absorbs the + /// rest) but a mandatory manifest HEAD per call. Phase 4's validated hits remove the HEADs; + /// this test pins the numbers Phase 4 improves. + EXPECT_EQ(backend->getCount(manifest_key), 1u); + EXPECT_EQ(backend->headCount(manifest_key), static_cast(n)); +} + +/// ==== Phase 4 (retention) semantics battery: spec §Testing acceptance criteria ==== + +/// REMOVED (all-tree-part-files Task 9): +/// `MutableRefreshWithoutManifestRead` and `WriteThroughEraseThenRebuild` proved the cache facade's +/// `LastDecision::MutableRefresh` fast path -- a cheap re-check that could serve a retained view whose +/// manifest was unchanged but whose separate mutable payload had drifted, without a manifest re-read. +/// That whole two-tier freshness model is gone: every per-part file is an ordinary manifest entry now, +/// so ANY content change is a manifest change (`repointRef`) and the existing manifest-id staleness +/// check (`getView`'s `cached->manifestId() == resolved->manifest_id` compare) is the only freshness +/// check left -- there is no cheaper "payload-only" path to test separately. Coverage that remains +/// valid: `MismatchRebuildAfterRepublish` below proves the cache correctly rebuilds when the manifest +/// id changes under a retained view (the one case the deleted tests' "erase => cold rebuild" half also +/// exercised); `gtest_cas_repoint.cpp` (Task 3) proves `repointRef` erases the affected view on success. +TEST(CASPartFolderAccess, MismatchRebuildAfterRepublish) +{ + auto backend = std::make_shared(); + auto store = openPoolForTest(backend); + const Cas::Layout layout("p"); + const Cas::RootNamespace ns{"srv/t1"}; + Cas::CachedPartFolderAccess access(store, cacheOn()); + publishPart(store, ns, "part_1", {inlineEntry("f", "orig")}); + const Cas::PartRefKey key{ns, "part_1"}; + + ASSERT_NE(access.getView(key, Cas::Freshness::CachedForLoad), nullptr); /// retained + + /// Drop + republish the SAME ref name with DIFFERENT content through the raw Core protocol (no + /// facade => no write-through erase): the retained entry survives with a manifest_id that no + /// longer resolves — the next CachedForLoad hits the manifest-changed compare (step 2c). + store->dropRef(ns, "part_1"); + const auto id2 = publishPart(store, ns, "part_1", {inlineEntry("f", "DIFFERENT")}); + const String manifest_key2 = layout.manifestKey(id2); + backend->resetCounts(); + + auto view = access.getView(key, Cas::Freshness::CachedForLoad); + ASSERT_NE(view, nullptr); + EXPECT_NE(view->findFile("f"), nullptr); + EXPECT_EQ(view->findFile("f")->inline_bytes, "DIFFERENT"); /// never the stale view + EXPECT_EQ(backend->getCount(manifest_key2), 1u); /// one new manifest GET + EXPECT_EQ(access.explain(key).last_decision, + Cas::CachedPartFolderAccess::LastDecision::Miss); /// rebuilt, now retained + EXPECT_TRUE(access.explain(key).retained); +} + +TEST(CASPartFolderAccess, ForceFreshFailsClosedWhileRetainedViewExists) +{ + auto backend = std::make_shared(); + auto store = openPoolForTest(backend); + const Cas::Layout layout("p"); + const Cas::RootNamespace ns{"srv/t1"}; + Cas::CachedPartFolderAccess access(store, cacheOn()); + const auto id = publishPart(store, ns, "part_1", {inlineEntry("f", "x")}); + const Cas::PartRefKey key{ns, "part_1"}; + + ASSERT_NE(access.getView(key, Cas::Freshness::CachedForLoad), nullptr); /// retained + deleteManifestBody(*backend, layout, id); /// protocol violation: live body vanishes + + /// Write-evidence and strict paths surface INV-NO-DANGLE immediately (mandatory HEAD)... + expectThrowsCode(ErrorCodes::FILE_DOESNT_EXIST, + [&] { access.getView(key, Cas::Freshness::ForceFresh); }); + expectThrowsCode(ErrorCodes::FILE_DOESNT_EXIST, + [&] { access.getView(key, Cas::Freshness::StrictValidate); }); + + /// ...while a validated CachedForLoad hit still serves the immutable decode — the documented + /// residual delta (spec §Staleness Equivalence): detection deferred, never for write evidence. + EXPECT_NE(access.getView(key, Cas::Freshness::CachedForLoad), nullptr); +} + +/// ==== §3 (part_folder_validate): the ForceFresh body re-proof HEAD is configurable ==== + +TEST(CASPartFolderAccess, ValidateNeverServesRetainedViewWithoutBodyHead) +{ + auto backend = std::make_shared(); + auto store = openPoolForTest(backend); + const Cas::Layout layout("p"); + const Cas::RootNamespace ns{"srv/t1"}; + const auto id = publishPart(store, ns, "part_1", {inlineEntry("checksums.txt", "cs")}); + + auto params = cacheOn(); + params.validate = {Cas::PartFolderValidate::Mode::Never, 0}; + Cas::CachedPartFolderAccess access(store, params); + const Cas::PartRefKey key{ns, "part_1"}; + + /// Prime the retained view (pays the HEAD once). + ASSERT_NE(access.getView(key, Cas::Freshness::ForceFresh), nullptr); + /// Body vanishes (a protocol violation the net would normally catch)... + deleteManifestBody(*backend, layout, id); + const auto skips_before = ProfileEvents::global_counters[ProfileEvents::CASPartFolderValidateSkipped].load(); + /// ...but `never` serves the retained view, no HEAD, no throw. + EXPECT_NO_THROW(access.getView(key, Cas::Freshness::ForceFresh)); + EXPECT_EQ(ProfileEvents::global_counters[ProfileEvents::CASPartFolderValidateSkipped].load() - skips_before, 1); +} + +TEST(CASPartFolderAccess, ValidateAlwaysStillHeadsEveryForceFresh) +{ + auto backend = std::make_shared(); + auto store = openPoolForTest(backend); + const Cas::Layout layout("p"); + const Cas::RootNamespace ns{"srv/t1"}; + const auto id = publishPart(store, ns, "part_1", {inlineEntry("checksums.txt", "cs")}); + + Cas::CachedPartFolderAccess access(store, cacheOn()); /// default = Always + const Cas::PartRefKey key{ns, "part_1"}; + ASSERT_NE(access.getView(key, Cas::Freshness::ForceFresh), nullptr); + deleteManifestBody(*backend, layout, id); + /// `always` re-proves the body every ForceFresh — the deleted body surfaces as FILE_DOESNT_EXIST. + expectThrowsCode(ErrorCodes::FILE_DOESNT_EXIST, + [&] { access.getView(key, Cas::Freshness::ForceFresh); }); +} + +TEST(CASPartFolderAccess, ValidateAgeSkipsWithinWindowThenHeadsAfter) +{ + auto backend = std::make_shared(); + auto store = openPoolForTest(backend); + const Cas::Layout layout("p"); + const Cas::RootNamespace ns{"srv/t1"}; + const auto id = publishPart(store, ns, "part_1", {inlineEntry("checksums.txt", "cs")}); + + auto params = cacheOn(); + params.validate = {Cas::PartFolderValidate::Mode::Age, /*age_seconds=*/5}; + /// An injected clock (spec §3 TDD requirement): the SAME function stamps the retained view's + /// validated_at_ms (buildView) and drives the age-window comparison (getView), so the test controls + /// both sides of the comparison deterministically -- no real sleep. + std::atomic fake_now_ms{1'000'000}; + Cas::CachedPartFolderAccess access(store, params, [&] { return fake_now_ms.load(); }); + const Cas::PartRefKey key{ns, "part_1"}; + const String manifest_key = layout.manifestKey(id); + + /// Prime the retained view (pays the HEAD once) at fake_now_ms. + ASSERT_NE(access.getView(key, Cas::Freshness::ForceFresh), nullptr); + const uint64_t heads_after_prime = backend->headCount(manifest_key); + + /// +2s: still inside the 5s window — served from the retained view, no new HEAD. + fake_now_ms += 2000; + ASSERT_NE(access.getView(key, Cas::Freshness::ForceFresh), nullptr); + EXPECT_EQ(backend->headCount(manifest_key), heads_after_prime); + + /// +6s from the ORIGINAL stamp (past the 5s window): re-proves the body via a fresh HEAD. + fake_now_ms += 4000; + ASSERT_NE(access.getView(key, Cas::Freshness::ForceFresh), nullptr); + EXPECT_GT(backend->headCount(manifest_key), heads_after_prime); +} + +/// ==== §3: `parsePartFolderValidate` config parsing, standalone (mirrors CASS3Staging's +/// parseStagingBackend coverage) -- review finding: std::stoull silently accepted a leading '-' +/// (unsigned wraparound), so a malformed `age -5` never hit the parser's own fail-closed throw. +/// These pin the fixed `std::from_chars`-based parsing directly, with no disk/store needed. ==== + +TEST(CASPartFolderValidateParse, DefaultConfigParsesToAlways) +{ + /// No `part_folder_validate` key at all -- the byte-for-byte-pre-§3-behavior default. + auto config = configWithDiskSection("/tmp/whatever"); + const auto v = ContentAddressedMetadataStorage::parsePartFolderValidate(*config, "disk"); + EXPECT_EQ(v.mode, Cas::PartFolderValidate::Mode::Always); +} + +TEST(CASPartFolderValidateParse, ParsesAlways) +{ + auto config = configWithDiskSection("always"); + const auto v = ContentAddressedMetadataStorage::parsePartFolderValidate(*config, "disk"); + EXPECT_EQ(v.mode, Cas::PartFolderValidate::Mode::Always); +} + +TEST(CASPartFolderValidateParse, ParsesNever) +{ + auto config = configWithDiskSection("never"); + const auto v = ContentAddressedMetadataStorage::parsePartFolderValidate(*config, "disk"); + EXPECT_EQ(v.mode, Cas::PartFolderValidate::Mode::Never); +} + +TEST(CASPartFolderValidateParse, ParsesPositiveAge) +{ + auto config = configWithDiskSection("age 5"); + const auto v = ContentAddressedMetadataStorage::parsePartFolderValidate(*config, "disk"); + EXPECT_EQ(v.mode, Cas::PartFolderValidate::Mode::Age); + EXPECT_EQ(v.age_seconds, 5u); +} + +TEST(CASPartFolderValidateParse, AcceptsAgeZeroAsADegenerateButValidWindow) +{ + /// `age 0` is accepted, not rejected: it is a well-formed (if degenerate -- effectively an + /// almost-always-expired window) configuration, not malformed input. Only genuinely malformed + /// suffixes (negative, non-digit, empty, trailing garbage) fail closed below. + auto config = configWithDiskSection("age 0"); + const auto v = ContentAddressedMetadataStorage::parsePartFolderValidate(*config, "disk"); + EXPECT_EQ(v.mode, Cas::PartFolderValidate::Mode::Age); + EXPECT_EQ(v.age_seconds, 0u); +} + +TEST(CASPartFolderValidateParse, NegativeAgeThrows) +{ + /// The bug this regression-guards: std::stoull("-5") used to return 18446744073709551611 + /// (unsigned wraparound) instead of rejecting the leading '-'. + auto config = configWithDiskSection("age -5"); + expectThrowsCode(ErrorCodes::BAD_ARGUMENTS, + [&] { ContentAddressedMetadataStorage::parsePartFolderValidate(*config, "disk"); }); +} + +TEST(CASPartFolderValidateParse, NonDigitAgeThrows) +{ + auto config = configWithDiskSection("age abc"); + expectThrowsCode(ErrorCodes::BAD_ARGUMENTS, + [&] { ContentAddressedMetadataStorage::parsePartFolderValidate(*config, "disk"); }); +} + +TEST(CASPartFolderValidateParse, TrailingGarbageAfterAgeThrows) +{ + auto config = configWithDiskSection("age 5abc"); + expectThrowsCode(ErrorCodes::BAD_ARGUMENTS, + [&] { ContentAddressedMetadataStorage::parsePartFolderValidate(*config, "disk"); }); +} + +TEST(CASPartFolderValidateParse, EmptyAgeSuffixThrows) +{ + auto config = configWithDiskSection("age "); + expectThrowsCode(ErrorCodes::BAD_ARGUMENTS, + [&] { ContentAddressedMetadataStorage::parsePartFolderValidate(*config, "disk"); }); +} + +TEST(CASPartFolderValidateParse, UnknownValueThrows) +{ + /// Fail-closed: an unrecognized value must NEVER silently become `never`/`always`. + auto config = configWithDiskSection("sometimes"); + expectThrowsCode(ErrorCodes::BAD_ARGUMENTS, + [&] { ContentAddressedMetadataStorage::parsePartFolderValidate(*config, "disk"); }); +} + +TEST(CASPartFolderAccess, AbsenceIsNeverRetained) +{ + auto backend = std::make_shared(); + auto store = openPoolForTest(backend); + const Cas::RootNamespace ns{"srv/t1"}; + Cas::CachedPartFolderAccess access(store, cacheOn()); + publishPart(store, ns, "part_1", {inlineEntry("f", "x")}); + const Cas::PartRefKey key{ns, "part_1"}; + + ASSERT_NE(access.getView(key, Cas::Freshness::CachedForLoad), nullptr); /// retained + access.dropRef(key); + EXPECT_EQ(access.getView(key, Cas::Freshness::CachedForLoad), nullptr); /// absent: nullptr, never retained + + /// Re-publish under the SAME ref name: immediately visible, no stale absence remembered. + publishPart(store, ns, "part_1", {inlineEntry("f", "y")}); + auto view = access.getView(key, Cas::Freshness::CachedForLoad); + ASSERT_NE(view, nullptr); + EXPECT_EQ(view->inlineBytes("f"), std::optional("y")); +} + +/// Task 23 (URF plan phase 7): `getView` emits a `RefResolve` audit event only when the access does +/// real resolve work -- a warm `CachedForLoad` hit whose retained view already matches the fresh +/// resolve serves the call with no new information, so it must add no row. `resolveRef` itself defers +/// the emit on this call path (`ResolveAudit::Deferred`, `CachedPartFolderAccess::resolve`), and +/// `getView` re-emits the identical event on every OTHER path -- cold builds and `ForceFresh`. +TEST(CASPartFolderAccess, GetViewEmitsRefResolveOnlyOnRealResolveWork) +{ + auto backend = std::make_shared(); + auto store = openPoolForTest(backend); + const Cas::RootNamespace ns{"srv/t1"}; + publishPart(store, ns, "part_1", {inlineEntry("checksums.txt", "cs")}); + const Cas::PartRefKey key{ns, "part_1"}; + + std::vector seen; + store->setEventSink([&](const Cas::CasEvent & e) { seen.push_back(e); }); + Cas::CachedPartFolderAccess access(store, cacheOn()); /// retention on, validate == Always (default) + + const auto refResolveCount = [&] + { + return std::count_if(seen.begin(), seen.end(), + [](const Cas::CasEvent & e) { return e.type == Cas::CasEventType::RefResolve; }); + }; + + /// Cold CachedForLoad build: real resolve work -> exactly one RefResolve. + ASSERT_NE(access.getView(key, Cas::Freshness::CachedForLoad), nullptr); + EXPECT_EQ(refResolveCount(), 1); + + /// Warm hit: the retained view still matches the fresh resolve, so this call serves the SAME + /// manifest with no new information -- before this fix it would emit a SECOND RefResolve + /// (resolveRef emitted unconditionally); after the fix it must add none. + ASSERT_NE(access.getView(key, Cas::Freshness::CachedForLoad), nullptr); + EXPECT_EQ(refResolveCount(), 1) << "a warm view-cache hit must not add a RefResolve row"; + + /// ForceFresh always re-proves the manifest body under the default Always validation policy, so + /// this is real resolve work again -> +1. + ASSERT_NE(access.getView(key, Cas::Freshness::ForceFresh), nullptr); + EXPECT_EQ(refResolveCount(), 2); + + store->setEventSink(nullptr); +} + +TEST(CASPartFolderAccess, OversizedViewServedNotRetained) +{ + auto backend = std::make_shared(); + auto store = openPoolForTest(backend); + const Cas::Layout layout("p"); + const Cas::RootNamespace ns{"srv/t1"}; + /// max_entry_bytes = 1: every real view (>= the 256-byte fixed overhead alone) is oversized. + Cas::CachedPartFolderAccess access(store, + Cas::CachedPartFolderAccess::CacheParams{ + .cache_bytes = 64ULL << 20, .max_entries = 10000, .max_entry_bytes = 1, + .explain_enabled = true, .validate = {}}); + const auto id = publishPart(store, ns, "part_1", {inlineEntry("f", "x")}); + const Cas::PartRefKey key{ns, "part_1"}; + const String manifest_key = layout.manifestKey(id); + + auto view1 = access.getView(key, Cas::Freshness::CachedForLoad); + ASSERT_NE(view1, nullptr); + EXPECT_FALSE(access.explain(key).retained); + EXPECT_EQ(access.explain(key).last_decision, + Cas::CachedPartFolderAccess::LastDecision::OversizedBypass); + + const uint64_t head_before = backend->headCount(manifest_key); + auto view2 = access.getView(key, Cas::Freshness::CachedForLoad); + ASSERT_NE(view2, nullptr); + EXPECT_GT(backend->headCount(manifest_key), head_before); /// not retained: re-HEADs every call + EXPECT_FALSE(access.explain(key).retained); +} + +TEST(CASPartFolderAccess, DisabledModeKeepsBaseline) +{ + auto backend = std::make_shared(); + auto store = openPoolForTest(backend); + const Cas::Layout layout("p"); + const Cas::RootNamespace ns{"srv/t1"}; + /// CacheParams{} (cache_bytes == 0): the explicit disable switch, same as the single-arg ctor. + Cas::CachedPartFolderAccess access(store, Cas::CachedPartFolderAccess::CacheParams{}); + const auto id = publishPart(store, ns, "part_1", {inlineEntry("checksums.txt", "cs")}); + const Cas::PartRefKey key{ns, "part_1"}; + const String manifest_key = layout.manifestKey(id); + + backend->resetCounts(); + constexpr int n = 5; + for (int i = 0; i < n; ++i) + ASSERT_NE(access.getView(key, Cas::Freshness::CachedForLoad), nullptr); + + /// Exactly the Phase-3 baseline: bytes=0 restores the no-retention call graph byte-for-byte. + EXPECT_EQ(backend->getCount(manifest_key), 1u); + EXPECT_EQ(backend->headCount(manifest_key), static_cast(n)); + EXPECT_FALSE(access.explain(key).retained); +} + +TEST(CASPartFolderAccess, SingleFlightColdBuild) +{ + auto backend = std::make_shared(); + auto store = openPoolForTest(backend); + const Cas::Layout layout("p"); + const Cas::RootNamespace ns{"srv/t1"}; + Cas::CachedPartFolderAccess access(store, cacheOn()); + const auto id = publishPart(store, ns, "part_1", {inlineEntry("f", "x")}); + const Cas::PartRefKey key{ns, "part_1"}; + const String manifest_key = layout.manifestKey(id); + + backend->resetCounts(); + constexpr int k = 8; + std::latch start_gate(k); + std::vector threads; + std::vector> results(k); + for (int i = 0; i < k; ++i) + threads.emplace_back([&, i] + { + start_gate.arrive_and_wait(); + results[i] = access.getView(key, Cas::Freshness::CachedForLoad); + }); + for (auto & t : threads) + t.join(); + + for (const auto & r : results) + EXPECT_NE(r, nullptr); + EXPECT_EQ(backend->getCount(manifest_key), 1u); /// single-flight: ONE body GET for the burst +} + +TEST(CASPartFolderAccess, DropNamespaceErasesAllViews) +{ + auto backend = std::make_shared(); + auto store = openPoolForTest(backend); + const Cas::RootNamespace ns{"srv/t1"}; + /// Review C2: deliberately NOT pinned -- `ns` gets a REAL, random catalog incarnation from + /// `publishPart` below, which is what this test drives production's namespace-drop/recreate + /// terminal snapshot or retirement checkpoint at. Pinning + /// it to the sentinel would make production's real-incarnation path untested by the one test that + /// exercises it end-to-end (the exact gap C2 named). + Cas::CachedPartFolderAccess access(store, cacheOn()); + publishPart(store, ns, "part_1", {inlineEntry("f", "x")}); + publishPart(store, ns, "part_2", {inlineEntry("f", "y")}); + const Cas::PartRefKey key1{ns, "part_1"}; + const Cas::PartRefKey key2{ns, "part_2"}; + + ASSERT_NE(access.getView(key1, Cas::Freshness::CachedForLoad), nullptr); /// retained + ASSERT_NE(access.getView(key2, Cas::Freshness::CachedForLoad), nullptr); /// retained + EXPECT_TRUE(access.explain(key1).retained); + EXPECT_TRUE(access.explain(key2).retained); + + access.dropNamespace(ns); + + /// dropNamespace removes the namespace via the ref-log `remove_namespace` transaction AND erases every + /// cached view: the dropped entries must not masquerade as "retained", and no stale key1/key2 view may + /// be served. + EXPECT_FALSE(access.explain(key1).retained); + EXPECT_FALSE(access.explain(key2).retained); /// dropped too, even though never re-touched + + /// A fresh getView on the removed namespace is a COLD MISS (nullptr) -- never a stale hit on the + /// dropped manifest. A residual retained entry would instead be served here without ever going through + /// validate-on-hit, exactly the masquerade this guards against. + EXPECT_EQ(access.getView(key1, Cas::Freshness::CachedForLoad), nullptr); + EXPECT_EQ(access.getView(key2, Cas::Freshness::CachedForLoad), nullptr); + +} + +TEST(CASPartFolderAccess, BestEffortRollbackDropCountsAndSurvivesABackendOutage) +{ + auto backend = std::make_shared(); + auto store = openPoolForTest(backend); + Cas::CachedPartFolderAccess access(store, cacheOn()); + + const Cas::RootNamespace ns_a{"srv/ta"}; + const Cas::RootNamespace ns_b{"srv/tb"}; + publishPart(store, ns_a, "part_a", {inlineEntry("checksums.txt", "cs")}); + publishPart(store, ns_b, "part_b", {inlineEntry("checksums.txt", "cs")}); + + backend->armed = true; + /// Sanity: with the backend armed, a real dropRef propagates (so the fault reaches the catch). + EXPECT_ANY_THROW(store->dropRef(ns_a, "part_a")); + + using ProfileEvents::global_counters; + const auto before = global_counters[ProfileEvents::CASRefRollbackBestEffortDropFailed].load(); + /// The compensating-rollback path must NOT throw (noexcept) and MUST record the swallowed failure. + access.dropRefBestEffort(Cas::PartRefKey{ns_b, "part_b"}); + const auto after = global_counters[ProfileEvents::CASRefRollbackBestEffortDropFailed].load(); + EXPECT_EQ(after, before + 1); + + backend->armed = false; /// let store teardown release its lease cleanly +} + +namespace +{ + +/// A pool whose ref lane makes ONE attempt per append. That is what turns a single lost-response fault +/// into a conclusive `Unresolved`: with retries allowed the controller's resolve-before-reissue would +/// settle the ambiguity inside the same attempt and the lane would never wedge. Same budget shape, and +/// the same reason, as `gtest_cas_ref_install_safety.cpp`'s `openPoolSingleAttempt`. +Cas::PoolPtr openPoolSingleAttempt(const std::shared_ptr & backend) +{ + Cas::PoolConfig cfg{.pool_prefix = "p", .server_root_id = "test"}; + Cas::CasRequestBudget budget; + budget.max_attempts = 1; + budget.attempt_timeout_ms = 100; + budget.operation_deadline_ms = 5000; /// strictly above attempt_timeout_ms: equality is a wall-clock race (validateCasRequestBudget) + budget.lease_safety_margin_ms = 100; + cfg.cas_request_budget = budget; + return Cas::Pool::open(backend, cfg); +} + +} + +/// Part B review, MAJOR 3a: a promote whose ref-log append did not resolve MUST NOT be reported as +/// "nothing was committed". +/// +/// `PreparedRelinkOverPartWrite::promote` maps a `NETWORK_ERROR` to `MechanismFallbackAllowed`, which +/// tells the interserver receiver to fetch the part's bytes from the same sender instead. That is sound +/// only when the promote is PROVEN not to have committed. It is not proven here: the promotion object +/// landed and only its acknowledgement was lost, so the ref below IS committed while `promote` reports +/// failure -- and a byte fetch on top of it is a sequential double publication of one logical fetch. +/// +/// The transaction therefore records the distinction where it is knowable (around its own append) +/// rather than leaving it to be guessed from an error code, which cannot carry it: the SAME +/// `NETWORK_ERROR` is raised by a promote rejected before the append (proof of the negative) and by one +/// whose append never resolved. +TEST(CASPartFolderAccess, AnUnresolvedPromoteIsNotReportedAsDefinitelyNotCommitted) +{ + auto backend = std::make_shared(); + auto store = openPoolSingleAttempt(backend); + const Cas::RootNamespace ns{"srv/t1"}; + DB::Cas::tests::casAdmitRecoverableEntry(*backend, store->layout(), ns, store->liveWriterEpoch()); + Cas::CachedPartFolderAccess access(store, cacheOn()); + const Cas::PartRefKey key{ns, "part_1"}; + + auto prepared = access.prepareEntries(key, {inlineEntry("f", "one")}, Cas::ProvenanceOp::Insert); + ASSERT_FALSE(prepared.commitIsUnresolved()) << "no promote has been attempted yet"; + + /// The promotion's own ref-log object lands; only the acknowledgement, and the controller's + /// verifying read, are lost. Scoped to this namespace's ref log so nothing else consumes the fault. + backend->fault_substr = store->layout().namespaceStreamPrefix(fixture::fixtureLife(ns)) + "_log/"; + backend->mode = Cas::tests::ChunkFaultBackend::Mode::LandedThenLost; + backend->fault_count = 1; + expectThrowsCode(ErrorCodes::NETWORK_ERROR, [&] { prepared.promote(); }); + + EXPECT_TRUE(prepared.commitIsUnresolved()) + << "a promote whose append may have landed must not be classified as a mechanism failure -- the " + "receiver would fetch the bytes and publish the same part a second time"; + + /// The hazard itself, stated as an assertion: the promote DID commit. Any further append into this + /// table resolves the wedge first, which is what makes the committed row visible. + backend->mode = Cas::tests::ChunkFaultBackend::Mode::None; + access.prepareEntries({ns, "flush_driver"}, {inlineEntry("f", "two")}, Cas::ProvenanceOp::Insert).abort(); + EXPECT_TRUE(access.existsRef(key, Cas::Freshness::ForceFresh)) + << "the promotion object landed, so 'the promote failed' says nothing about the ref"; +} + +/// Part B review, MAJOR 3b: nothing after a durable commit may throw before the handle records it. +/// +/// `promoteBuild` used to assemble its `CommitOutcome` -- two `String` copies -- and invalidate the +/// cached view AFTER the durable append and BEFORE `PreparedPartWrite::promote` set `terminal`. An +/// allocation failure in that window therefore entered the failed-promote catch with the ref already +/// committed, where the handle abandons its build and reports the promote as failed. The outcome's +/// strings are now copied BEFORE the append and the commit is recorded in an allocation-free region +/// immediately after it, so the window is empty by construction; the probe below fires just past it. +TEST(CASPartFolderAccess, APostCommitFailureLeavesTheHandleTerminal) +{ + auto backend = std::make_shared(); + auto store = openPoolForTest(backend); + const Cas::RootNamespace ns{"srv/t1"}; + Cas::CachedPartFolderAccess access(store, cacheOn()); + const Cas::PartRefKey key{ns, "part_1"}; + + auto prepared = access.prepareEntries(key, {inlineEntry("f", "one")}, Cas::ProvenanceOp::Insert); + + std::vector seen; + store->setEventSink([&](const Cas::CasEvent & e) { seen.push_back(e); }); + + /// `MEMORY_LIMIT_EXCEEDED` -- what a tracked allocation failure actually raises -- and deliberately + /// not `LOGICAL_ERROR`, which aborts at construction in debug/sanitizer builds. + access.setPostCommitProbeForTest([] + { + throw Exception(ErrorCodes::MEMORY_LIMIT_EXCEEDED, + "simulated allocation failure in the post-commit work of promoteBuild"); + }); + expectThrowsCode(ErrorCodes::MEMORY_LIMIT_EXCEEDED, [&] { prepared.promote(); }); + access.setPostCommitProbeForTest(nullptr); + store->setEventSink(nullptr); + + EXPECT_TRUE(prepared.isTerminal()) + << "the commit is durable, so the handle owes nothing"; + EXPECT_TRUE(access.existsRef(key, Cas::Freshness::ForceFresh)) << "the promote really did commit"; + EXPECT_TRUE(store->livePrecommitsForTest(ns).empty()); + + /// The discriminating assertion. `isTerminal` alone is not one: the old code reached the catch, + /// abandoned an ALREADY PROMOTED build -- which succeeds, because a promoted build no longer owes a + /// precommit removal -- and so ended up terminal too, by accident. What the abandon leaves behind is + /// the audit trail of a publish that is reported as thrown away while its ref is committed. + const auto build_aborts = std::count_if(seen.begin(), seen.end(), + [](const Cas::CasEvent & e) { return e.type == Cas::CasEventType::BuildAbort; }); + EXPECT_EQ(build_aborts, 0) + << "a build whose promote is DURABLE was abandoned by the failed-promote catch: the handle had " + "not yet recorded the commit when the post-commit work threw"; + EXPECT_EQ(std::count_if(seen.begin(), seen.end(), + [](const Cas::CasEvent & e) { return e.type == Cas::CasEventType::BuildPublish; }), 1); +} + +/// Part B review, MAJOR 4: move ASSIGNMENT is deleted rather than implemented. +/// +/// It cannot be implemented correctly. Overwriting a handle that still owes a terminal must first +/// discharge that duty, and `abandon` appends through the ref lane, so it can FAIL -- which a move +/// assignment has no way to report. The old implementation overwrote the destination's build even when +/// `abandonBuildBestEffort` returned false, permanently dropping a cleanup owner: a live-epoch precommit +/// that no sweep and no GC ever reclaims. Nothing needs the operator (the interserver relink's handle is +/// move CONSTRUCTED into place), and a contract that cannot be relied on is worse than none. +TEST(CASPartFolderAccess, PreparedPartWriteIsNotMoveAssignable) +{ + EXPECT_FALSE(std::is_move_assignable_v) + << "a move assignment cannot discharge a terminal duty that may fail to be discharged"; + EXPECT_TRUE(std::is_move_constructible_v); +} diff --git a/src/Disks/tests/gtest_cas_part_folder_view.cpp b/src/Disks/tests/gtest_cas_part_folder_view.cpp new file mode 100644 index 000000000000..be9b9e91de91 --- /dev/null +++ b/src/Disks/tests/gtest_cas_part_folder_view.cpp @@ -0,0 +1,105 @@ +#include +#include + +using namespace DB; + +TEST(CASPartRefKey, CacheKeyIsUnambiguous) +{ + /// Refs may contain '/' (the `detached/` fold, B181); the '\0' join keeps + /// (ns="a", ref="b/c") distinct from (ns="a/b", ref="c"). + const Cas::PartRefKey k1{Cas::RootNamespace{"a"}, "b/c"}; + const Cas::PartRefKey k2{Cas::RootNamespace{"a/b"}, "c"}; + EXPECT_NE(k1.cacheKey(), k2.cacheKey()); + EXPECT_FALSE(k1 == k2); + EXPECT_TRUE((k1 == Cas::PartRefKey{Cas::RootNamespace{"a"}, "b/c"})); +} + +#include +#include + +namespace +{ + +using namespace DB; + +std::shared_ptr makeView() +{ + auto manifest = std::make_shared(); + auto add = [&](const char * path, Cas::EntryPlacement placement, const char * bytes, uint64_t blob_size) + { + Cas::ManifestEntry e; + e.path = path; + e.placement = placement; + e.ref = DB::Cas::BlobRef{DB::Cas::BlobHashAlgo::CityHash128, DB::Cas::BlobDigest::fromU128(UInt128(manifest->entries.size() + 1))}; + + e.blob_size = blob_size; + e.inline_bytes = bytes; + manifest->entries.push_back(e); + }; + /// Canonical (sorted) order — the ctor chasserts it. All-tree-part-files Task 9: `txn_version.txt` + /// is an ordinary Inline entry now, not a separate mutable payload. + add("checksums.txt", Cas::EntryPlacement::Inline, "cs", 2); + add("data.bin", Cas::EntryPlacement::Blob, "", 100); + add("p.proj/checksums.txt", Cas::EntryPlacement::Inline, "pc", 2); + add("p.proj/data.bin", Cas::EntryPlacement::Blob, "", 50); + add("txn_version.txt", Cas::EntryPlacement::Inline, "ver", 3); + + return std::make_shared( + Cas::PartRefKey{Cas::RootNamespace{"srv/t"}, "part_1"}, + Cas::ManifestId{Cas::RootNamespace{"srv/t"}, Cas::ManifestRef{1, 2, 3}}, + /*manifest_size=*/1000, manifest, + /*validated_at_ms=*/42); +} + +std::vector sorted(std::vector v) { std::sort(v.begin(), v.end()); return v; } + +} + +TEST(CASPartFolderView, FindFileAndHasFile) +{ + auto v = makeView(); + ASSERT_NE(v->findFile("data.bin"), nullptr); + EXPECT_EQ(v->findFile("data.bin")->blob_size, 100u); + EXPECT_EQ(v->findFile("absent.bin"), nullptr); + EXPECT_TRUE(v->hasFile("p.proj/data.bin")); + EXPECT_TRUE(v->hasFile("txn_version.txt")); /// an ordinary Inline entry + EXPECT_FALSE(v->hasFile("p.proj")); /// a directory, not a file +} + +TEST(CASPartFolderView, ListChildrenCollapsesFirstComponent) +{ + auto v = makeView(); + EXPECT_EQ(sorted(v->listChildren("")), + sorted({"checksums.txt", "data.bin", "p.proj", "txn_version.txt"})); + EXPECT_EQ(sorted(v->listChildren("p.proj/")), sorted({"checksums.txt", "data.bin"})); + EXPECT_TRUE(v->listChildren("q.proj/").empty()); +} + +TEST(CASPartFolderView, HasDirectory) +{ + auto v = makeView(); + EXPECT_TRUE(v->hasDirectory("p.proj/")); + EXPECT_FALSE(v->hasDirectory("q.proj/")); +} + +TEST(CASPartFolderView, SizesAndBytes) +{ + auto v = makeView(); + EXPECT_EQ(v->fileSize("checksums.txt"), std::optional(2)); /// inline: bytes size + EXPECT_EQ(v->fileSize("data.bin"), std::optional(100)); /// blob: blob_size + EXPECT_EQ(v->fileSize("txn_version.txt"), std::optional(3)); /// inline: bytes size + EXPECT_EQ(v->fileSize("absent"), std::nullopt); + EXPECT_EQ(v->inlineBytes("checksums.txt"), std::optional("cs")); + EXPECT_EQ(v->inlineBytes("data.bin"), std::nullopt); /// blob has no inline bytes + EXPECT_EQ(v->inlineBytes("txn_version.txt"), std::optional("ver")); + EXPECT_GE(v->estimatedBytes(), 1000u); /// >= manifest_size +} + +TEST(CASPartFolderView, ProjectionDirPrefixRecognizer) +{ + using V = Cas::PartFolderView; + EXPECT_EQ(V::projectionDirPrefix("p.proj"), std::optional("p.proj/")); + EXPECT_EQ(V::projectionDirPrefix("a/b.tmp_proj"), std::optional("a/b.tmp_proj/")); + EXPECT_EQ(V::projectionDirPrefix("data.bin"), std::nullopt); + EXPECT_EQ(V::projectionDirPrefix(""), std::nullopt); +} diff --git a/src/Disks/tests/gtest_cas_part_manifest_format.cpp b/src/Disks/tests/gtest_cas_part_manifest_format.cpp new file mode 100644 index 000000000000..5d64b0b91bd9 --- /dev/null +++ b/src/Disks/tests/gtest_cas_part_manifest_format.cpp @@ -0,0 +1,472 @@ +#include "cas_format_test_battery.h" +#include +#include +#include +#include + +using namespace DB::Cas; + +namespace +{ + +/// NOT `Disks/tests/cas_test_helpers.h`'s `DB::Cas::tests::expectThrowsCode`: pulling in that header +/// drags along a large chunk of the CAS backend/store machinery this file has no other need for, so it +/// stays clear of `cas_test_helpers.h` entirely and inlines its own copy of the same tiny assertion +/// instead. +template +void expectThrowsCode(int expected_code, F && fn) +{ + try + { + fn(); + FAIL() << "expected DB::Exception"; + } + catch (const DB::Exception & e) + { + EXPECT_EQ(e.code(), expected_code); + } +} + +/// One Blob + one Inline entry, matching the plan's §text-shape illustration verbatim (codecs-v3 +/// phase 6): deliberately NOT path-sorted on input, so the round trip also exercises canonical +/// path-order encoding. +PartManifest sample() +{ + PartManifest m; + m.ref = ManifestRef{5, 15, 1}; + m.root_namespace_id = RootNamespace("00/aa@cas@"); + + ManifestEntry inl; + inl.path = "c/small.txt"; + inl.placement = EntryPlacement::Inline; + inl.inline_bytes = "hello world!"; /// 12 raw bytes, no embedded '\n' + + ManifestEntry blob; + blob.path = "a/b.bin"; + blob.placement = EntryPlacement::Blob; + blob.ref = BlobRef{BlobHashAlgo::CityHash128, codecFor(BlobHashAlgo::CityHash128).fromHex("00112233445566778899aabbccddeeff")}; + blob.blob_size = 4096; + + m.entries = {inl, blob}; /// deliberately out of canonical order + /// Set LAST, after all other fields (matches gtest_cas_manifest_codec.cpp's + /// makeTwoEntryManifestForOrderTest): decode now recomputes + verifies this, so a placeholder + /// value here would make every test that round-trips `sample()` through decode fail closed. + m.payload_digest = computePayloadDigest(m); + return m; +} + +} + +TEST(CASFormatBattery, PartManifest) +{ + const PartManifest m = sample(); + /// Interpolate the REAL digest (never hand-compute a CityHash128 hex by hand) so the golden text + /// stays self-consistent with whatever sample() produces, now that decode verifies payload_digest. + const String golden = + currentFormatHeader("cas_part_manifest") + + "{\"me\":\"5\",\"mb\":\"15\",\"mo\":1,\"ns\":\"00/aa@cas@\",\"pd\":\"" + u128ToHex(m.payload_digest) + "\"}\n" // NOLINT(modernize-raw-string-literal): mixes '\"' quoting with '\n' line endings across this concatenated literal; a raw string can't hold the newline as-is. + "{\"p\":\"a/b.bin\",\"pm\":\"blob\",\"ha\":\"ch128\",\"h\":\"00112233445566778899aabbccddeeff\",\"sz\":4096}\n" + "{\"p\":\"c/small.txt\",\"pm\":\"inline\",\"il\":12}\n" + "{\"n\":2}\n" + "==> c/small.txt il=12 <==\n" + "hello world!\n"; + runFormatBattery({FormatId::PartManifest, + [&] { return sealObject(FormatId::PartManifest, encodePartManifest(m)); }, + [](std::string_view d) { decodePartManifest(std::string(openObject(FormatId::PartManifest, d))); }, + golden}); +} + +TEST(CASPartManifestFormat, RoundTripDescriptorAndEntries) +{ + const PartManifest m = sample(); + const PartManifest got = decodePartManifest(encodePartManifest(m)); + EXPECT_EQ(got.ref, m.ref); + EXPECT_EQ(got.root_namespace_id, m.root_namespace_id); + EXPECT_EQ(got.payload_digest, m.payload_digest); + ASSERT_EQ(got.entries.size(), 2u); + + /// canonical path order: "a/b.bin" < "c/small.txt" + EXPECT_EQ(got.entries[0].path, "a/b.bin"); + EXPECT_EQ(got.entries[0].placement, EntryPlacement::Blob); + EXPECT_EQ(got.entries[0].ref, m.entries[1].ref); + EXPECT_EQ(got.entries[0].blob_size, 4096u); + + EXPECT_EQ(got.entries[1].path, "c/small.txt"); + EXPECT_EQ(got.entries[1].placement, EntryPlacement::Inline); + /// The payload-zone round trip: exact raw bytes recovered from the banner+bytes+'\n' zone. + EXPECT_EQ(got.entries[1].inline_bytes, "hello world!"); +} + +TEST(CASPartManifestFormat, EmptyEntriesRoundTrips) +{ + PartManifest m = sample(); + m.entries.clear(); + m.payload_digest = computePayloadDigest(m); /// recompute: content changed, sample()'s digest is stale + const PartManifest got = decodePartManifest(encodePartManifest(m)); + EXPECT_TRUE(got.entries.empty()); + EXPECT_EQ(got.ref, m.ref); + /// No payload zone at all when there are no Inline entries. + EXPECT_FALSE(encodePartManifest(m).contains("==>")); +} + +TEST(CASPartManifestFormat, PlacementWordsRenderAndRejectUnknown) +{ + const String text = encodePartManifest(sample()); + EXPECT_NE(text.find("\"pm\":\"blob\""), String::npos); + EXPECT_NE(text.find("\"pm\":\"inline\""), String::npos); + + /// An unknown placement word fails closed. + String bad = text; + const size_t pos = bad.find(R"("pm":"blob")"); + ASSERT_NE(pos, String::npos); + bad.replace(pos, String(R"("pm":"blob")").size(), R"("pm":"bogus")"); + expectThrowsCode(DB::ErrorCodes::CORRUPTED_DATA, [&] { decodePartManifest(bad); }); +} + +/// Proves the payload zone, not JSON-string escaping: an Inline entry whose bytes contain an +/// embedded '\n', a NUL byte, and a '"' character round-trip byte-faithfully. If this content were +/// carried as a JSON string value it would need escaping (or would be flatly invalid for the NUL +/// byte); the payload zone instead carries it as raw length-delimited bytes. +TEST(CASPartManifestFormat, InlineBytesWithEmbeddedSpecialCharsRoundTripByteFaithfully) +{ + PartManifest m; + m.ref = ManifestRef{7, 21, 2}; + m.root_namespace_id = RootNamespace("00/bb@cas@"); + + ManifestEntry e; + e.path = "weird.bin"; + e.placement = EntryPlacement::Inline; + e.inline_bytes = "line1\nline2"; + e.inline_bytes.push_back('\0'); + e.inline_bytes += "after-nul\"quoted\"end"; + m.entries = {e}; + m.payload_digest = computePayloadDigest(m); + + const PartManifest got = decodePartManifest(encodePartManifest(m)); + ASSERT_EQ(got.entries.size(), 1u); + EXPECT_EQ(got.entries[0].inline_bytes, m.entries[0].inline_bytes); + EXPECT_EQ(got.entries[0].inline_bytes.size(), e.inline_bytes.size()); +} + +TEST(CASPartManifestFormat, ByteDeterminism) +{ + const PartManifest m = sample(); + /// Encode twice -> identical bytes. Also encode a copy with entries pre-shuffled into the other + /// order -> still identical, because the encoder sorts canonically. + PartManifest m2 = m; + std::swap(m2.entries[0], m2.entries[1]); + EXPECT_EQ(encodePartManifest(m), encodePartManifest(m)); + EXPECT_EQ(encodePartManifest(m), encodePartManifest(m2)); +} + +TEST(CASPartManifestFormat, MixedAlgoEntriesRoundTrip) +{ + PartManifest m; + m.ref = ManifestRef{9, 33, 4}; + m.root_namespace_id = RootNamespace("00/cc@cas@"); + + ManifestEntry e16; + e16.path = "a/ch128.bin"; + e16.placement = EntryPlacement::Blob; + e16.ref = BlobRef{BlobHashAlgo::CityHash128, + codecFor(BlobHashAlgo::CityHash128).fromHex("00112233445566778899aabbccddeeff")}; + e16.blob_size = 100; + + ManifestEntry e32; + e32.path = "b/sha256.bin"; + e32.placement = EntryPlacement::Blob; + e32.ref = BlobRef{BlobHashAlgo::Sha256, codecFor(BlobHashAlgo::Sha256).fromHex(String(64, 'a'))}; + e32.blob_size = 200; + + m.entries = {e16, e32}; + m.payload_digest = computePayloadDigest(m); + + const PartManifest got = decodePartManifest(encodePartManifest(m)); + ASSERT_EQ(got.entries.size(), 2u); + EXPECT_EQ(got.entries[0].path, "a/ch128.bin"); + EXPECT_EQ(got.entries[0].ref, e16.ref); + EXPECT_EQ(got.entries[0].blob_size, 100u); + EXPECT_EQ(got.entries[1].path, "b/sha256.bin"); + EXPECT_EQ(got.entries[1].ref, e32.ref); + EXPECT_EQ(got.entries[1].blob_size, 200u); +} + +/// Builds a single-Blob-entry manifest whose entry path is exactly `path` -- `encodePartManifest` +/// itself does not validate path shape (only ordering/duplicates), so this lets the negative cases +/// below reach `decodePartManifest`'s shape check unobstructed. +static PartManifest manifestWithSinglePath(std::string_view path) +{ + PartManifest m; + m.ref = ManifestRef{17, 66, 7}; + m.root_namespace_id = RootNamespace("00/ff@cas@"); + + ManifestEntry e; + e.path = String(path); + e.placement = EntryPlacement::Blob; + e.ref = BlobRef{BlobHashAlgo::CityHash128, + codecFor(BlobHashAlgo::CityHash128).fromHex("00112233445566778899aabbccddeeff")}; + e.blob_size = 10; + m.entries = {e}; + m.payload_digest = computePayloadDigest(m); + return m; +} + +/// T11: manifest bytes arrive over the interserver relink channel, so decode enforces the same path +/// hygiene as CasLayout::checkNamespace -- relative, no empty/'.'/'..' segments, no leading '/'. +/// `encodePartManifest` does not itself reject these (see `manifestWithSinglePath`), so each case +/// must fail closed at decode time instead. +TEST(CASPartManifestFormat, DecodeRejectsMalformedEntryPaths) +{ + for (const char * path : {"../evil", "/abs", "", "a//b", "a/./b"}) + { + SCOPED_TRACE(path); + const String encoded = encodePartManifest(manifestWithSinglePath(path)); + expectThrowsCode(DB::ErrorCodes::CORRUPTED_DATA, [&] { decodePartManifest(encoded); }); + } +} + +/// Legal projection subdirectories (`.proj/`) must not be caught by the shape +/// check above -- it is syntactic only, not a directory-depth restriction. +TEST(CASPartManifestFormat, DecodeAcceptsLegalProjectionSubdirPath) +{ + const PartManifest m = manifestWithSinglePath("proj.proj/data.bin"); + const PartManifest got = decodePartManifest(encodePartManifest(m)); + ASSERT_EQ(got.entries.size(), 1u); + EXPECT_EQ(got.entries[0].path, "proj.proj/data.bin"); +} + +TEST(CASPartManifestFormat, DuplicatePathRejectedOnEncode) +{ + PartManifest m = sample(); + ManifestEntry dup = m.entries[0]; /// same path as an existing entry + m.entries.push_back(dup); + expectThrowsCode(DB::ErrorCodes::CORRUPTED_DATA, [&] { encodePartManifest(m); }); +} + +/// Hand-forge two valid entry-record LINES swapped out of canonical order (no CRC-patching forge +/// helpers needed - this is a text format, lines carry no per-line checksum). Both entries are Blob +/// (no payload-zone bytes), so the swap cannot disturb payload-zone alignment - it isolates exactly +/// the ordering check. +TEST(CASPartManifestFormat, DecodeRejectsOutOfOrderEntries) +{ + PartManifest m; + m.ref = ManifestRef{11, 44, 5}; + m.root_namespace_id = RootNamespace("00/dd@cas@"); + + auto mkBlob = [](std::string_view path) + { + ManifestEntry e; + e.path = String(path); + e.placement = EntryPlacement::Blob; + e.ref = BlobRef{BlobHashAlgo::CityHash128, + codecFor(BlobHashAlgo::CityHash128).fromHex("00112233445566778899aabbccddeeff")}; + e.blob_size = 10; + return e; + }; + /// "a/one.bin" and "b/two.bin" are the same length, so swapping their record lines in place + /// does not shift any other byte offset in the text. + m.entries = {mkBlob("a/one.bin"), mkBlob("b/two.bin"), mkBlob("c/three.bin")}; + m.payload_digest = computePayloadDigest(m); + + const String text = encodePartManifest(m); + const size_t pos_a = text.find(R"("p":"a/one.bin")"); + const size_t pos_b = text.find(R"("p":"b/two.bin")"); + ASSERT_NE(pos_a, String::npos); + ASSERT_NE(pos_b, String::npos); + + const size_t a_start = text.rfind('\n', pos_a) + 1; + const size_t a_end = text.find('\n', pos_a) + 1; + const size_t b_start = text.rfind('\n', pos_b) + 1; + const size_t b_end = text.find('\n', pos_b) + 1; + const String a_line = text.substr(a_start, a_end - a_start); + const String b_line = text.substr(b_start, b_end - b_start); + ASSERT_EQ(a_line.size(), b_line.size()); + + String forged = text; + forged.replace(a_start, a_line.size(), b_line); + forged.replace(b_start, b_line.size(), a_line); + + expectThrowsCode(DB::ErrorCodes::CORRUPTED_DATA, [&] { decodePartManifest(forged); }); +} + +/// a < b < c in canonical order; forge entry c's path to equal entry a's path. A naive "only check +/// adjacent pairs" implementation would miss this (c is only ever compared against b, never against +/// a); requiring strict ascending order against just the immediately-preceding entry still catches +/// it, because the forged c(=a's path) is no longer greater than b either. +TEST(CASPartManifestFormat, DecodeRejectsNonAdjacentDuplicatePath) +{ + PartManifest m; + m.ref = ManifestRef{13, 55, 6}; + m.root_namespace_id = RootNamespace("00/ee@cas@"); + + auto mkBlob = [](std::string_view path) + { + ManifestEntry e; + e.path = String(path); + e.placement = EntryPlacement::Blob; + e.ref = BlobRef{BlobHashAlgo::CityHash128, + codecFor(BlobHashAlgo::CityHash128).fromHex("00112233445566778899aabbccddeeff")}; + e.blob_size = 10; + return e; + }; + m.entries = {mkBlob("aaa/one.bin"), mkBlob("bbb/two.bin"), mkBlob("ccc/three.bin")}; + m.payload_digest = computePayloadDigest(m); + + String forged = encodePartManifest(m); + const String needle = R"("p":"ccc/three.bin")"; + const size_t pos = forged.find(needle); + ASSERT_NE(pos, String::npos); + forged.replace(pos, needle.size(), R"("p":"aaa/one.bin")"); + + expectThrowsCode(DB::ErrorCodes::CORRUPTED_DATA, [&] { decodePartManifest(forged); }); +} + +TEST(CASPartManifestFormat, UnknownEntryAlgoFailsClosed) +{ + String bad = encodePartManifest(sample()); + const String needle = R"("ha":"ch128")"; + const size_t pos = bad.find(needle); + ASSERT_NE(pos, String::npos); + bad.replace(pos, needle.size(), R"("ha":"bogus")"); + expectThrowsCode(DB::ErrorCodes::CORRUPTED_DATA, [&] { decodePartManifest(bad); }); +} + +/// `DigestCodec::fromHex` throws BAD_ARGUMENTS (not CORRUPTED_DATA) on a width mismatch; decode must +/// check the width itself first so this fails closed with the same code every other decode error +/// here uses. +TEST(CASPartManifestFormat, DigestHexWidthMismatchFailsClosedNotBadArguments) +{ + String bad = encodePartManifest(sample()); + const String key = R"("h":")"; + const size_t key_pos = bad.find(key); + ASSERT_NE(key_pos, String::npos); + const size_t hex_start = key_pos + key.size(); + const size_t hex_end = bad.find('"', hex_start); + ASSERT_NE(hex_end, String::npos); + ASSERT_EQ(hex_end - hex_start, 32u); /// ch128: 16-byte digest -> 32 hex chars + bad.erase(hex_start, 1); /// drop one hex char -> width mismatch (31 chars) + expectThrowsCode(DB::ErrorCodes::CORRUPTED_DATA, [&] { decodePartManifest(bad); }); +} + +/// Pure-function properties of computePayloadDigest, independent of decode-time verification: stable +/// across calls for identical content, independent of the payload_digest field's own value, and +/// content-sensitive (changes when real content changes). +TEST(CASPartManifestFormat, PayloadDigestStableAndContentSensitive) +{ + const PartManifest m = sample(); + PartManifest with_different_stored_digest = m; + with_different_stored_digest.payload_digest = UInt128(0x1234); + EXPECT_EQ(computePayloadDigest(m), computePayloadDigest(m)); + EXPECT_EQ(computePayloadDigest(m), computePayloadDigest(with_different_stored_digest)); + + /// m.entries[1] is the Blob entry (m.entries[0] is Inline, whose blob_size is unused on the + /// wire) - changing its blob_size changes the canonical encoding and therefore the digest. + ASSERT_EQ(m.entries[1].placement, EntryPlacement::Blob); + PartManifest changed = m; + changed.entries[1].blob_size += 1; + EXPECT_NE(computePayloadDigest(m), computePayloadDigest(changed)); +} + +/// No-smuggling: one extra trailing byte after the last payload-zone segment (or after the trailer, +/// when there are no Inline entries) must be rejected - exercises the final `!in.eof()` check. +TEST(CASPartManifestFormat, TrailingByteAfterPayloadZoneFailsClosed) +{ + String bad = encodePartManifest(sample()); + bad += "X"; + expectThrowsCode(DB::ErrorCodes::CORRUPTED_DATA, [&] { decodePartManifest(bad); }); +} + +/// An Inline entry's record "il" disagrees with what the payload zone's banner+bytes actually +/// declare (the banner and bytes are left as originally written; only the record line's "il" is +/// edited). The record's declared `il` is what decode uses both to build the expected banner text +/// and to know how many bytes to read from the zone, so this must fail closed rather than silently +/// reading the wrong byte count. +TEST(CASPartManifestFormat, InlineRecordIlMismatchWithPayloadZoneBannerFailsClosed) +{ + String bad = encodePartManifest(sample()); + const String needle = "\"il\":12"; + const size_t pos = bad.find(needle); + ASSERT_NE(pos, String::npos); + bad.replace(pos, needle.size(), "\"il\":13"); + expectThrowsCode(DB::ErrorCodes::CORRUPTED_DATA, [&] { decodePartManifest(bad); }); +} + +/// ==== migrated from gtest_cas_manifest_codec.cpp (deleted in the phase-6 binary->text cutover, +/// Task 3): these exercise refMatchesBody/manifestNamespaceMatches/findEntry/entryRange, pure +/// functions carried over verbatim from the retired binary codec (untouched by the wire-shape +/// migration) — reusing this file's own sample() fixture instead of reintroducing a second one. ==== + +TEST(CASPartManifestFormat, RefMatchesBodyAcceptsExactRef) +{ + const PartManifest m = sample(); + /// The journal ref equals the body ref -> true. + EXPECT_TRUE(refMatchesBody(m.ref, m)); +} + +TEST(CASPartManifestFormat, RefMatchesBodyRejectsEachFieldMismatch) +{ + const PartManifest m = sample(); + ManifestRef wrong_writer = m.ref; wrong_writer.writer_epoch = m.ref.writer_epoch + 1; + ManifestRef wrong_seq = m.ref; wrong_seq.build_sequence = m.ref.build_sequence + 1; + ManifestRef wrong_inst = m.ref; wrong_inst.manifest_ordinal = m.ref.manifest_ordinal + 1; + EXPECT_FALSE(refMatchesBody(wrong_writer, m)); + EXPECT_FALSE(refMatchesBody(wrong_seq, m)); + EXPECT_FALSE(refMatchesBody(wrong_inst, m)); +} + +TEST(CASPartManifestFormat, ManifestNamespaceMatchesAcceptsOwningNs) +{ + const PartManifest m = sample(); + EXPECT_TRUE(manifestNamespaceMatches(m.root_namespace_id, m)); +} + +TEST(CASPartManifestFormat, ManifestNamespaceMatchesRejectsForeignNs) +{ + const PartManifest m = sample(); + /// sample()'s namespace is "00/aa@cas@" — pick a genuinely foreign one and a strict-prefix one. + EXPECT_FALSE(manifestNamespaceMatches(RootNamespace("00/bb@cas@"), m)); + /// A namespace that is a prefix but not equal is still a mismatch (no loose comparison). + EXPECT_FALSE(manifestNamespaceMatches(RootNamespace("00/aa"), m)); +} + +TEST(CASPartManifestFormat, FindEntryBinarySearch) +{ + std::vector entries; + for (const char * p : {"a.txt", "b/inner.txt", "b/z.txt", "c.txt"}) + { + ManifestEntry e; + e.path = p; + e.placement = EntryPlacement::Inline; + e.inline_bytes = "v"; + entries.push_back(e); + } + EXPECT_NE(findEntry(entries, "a.txt"), nullptr); + EXPECT_EQ(findEntry(entries, "a.txt")->path, "a.txt"); + EXPECT_NE(findEntry(entries, "c.txt"), nullptr); /// last element + EXPECT_EQ(findEntry(entries, "b"), nullptr); /// prefix of a path, not a path + EXPECT_EQ(findEntry(entries, "zzz"), nullptr); /// past the end + EXPECT_EQ(findEntry({}, "a"), nullptr); /// empty +} + +TEST(CASPartManifestFormat, EntryRangeContiguousPrefix) +{ + std::vector entries; + for (const char * p : {"a.txt", "p.proj/data.bin", "p.proj/x.txt", "q.txt"}) + { + ManifestEntry e; + e.path = p; + e.placement = EntryPlacement::Inline; + e.inline_bytes = "v"; + entries.push_back(e); + } + auto [first, last] = entryRange(entries, "p.proj/"); + ASSERT_EQ(last - first, 2); + EXPECT_EQ(first->path, "p.proj/data.bin"); + EXPECT_EQ((last - 1)->path, "p.proj/x.txt"); + + auto [w1, w2] = entryRange(entries, ""); /// empty prefix = whole span + EXPECT_EQ(w2 - w1, 4); + + auto [n1, n2] = entryRange(entries, "zzz/"); /// no match + EXPECT_EQ(n1, n2); +} diff --git a/src/Disks/tests/gtest_cas_part_write.cpp b/src/Disks/tests/gtest_cas_part_write.cpp new file mode 100644 index 000000000000..2fe5db215b5c --- /dev/null +++ b/src/Disks/tests/gtest_cas_part_write.cpp @@ -0,0 +1,2517 @@ +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace ProfileEvents +{ +extern const Event CASMetaPut; +extern const Event CASMetaCompareSwap; +extern const Event CASMetaCreateClean; +extern const Event CASMetaAdoptBackfill; +extern const Event CASMetaResurrectClean; +extern const Event CASBlobAdoptTrusted; +} + +namespace DB::ErrorCodes +{ +extern const int BAD_ARGUMENTS; +extern const int FILE_DOESNT_EXIST; +extern const int INVALID_STATE; +extern const int LOGICAL_ERROR; +extern const int NOT_IMPLEMENTED; +extern const int ABORTED; +extern const int CORRUPTED_DATA; +extern const int LIMIT_EXCEEDED; +extern const int NETWORK_ERROR; +extern const int UNKNOWN_EXCEPTION; +} + +using namespace DB::Cas; +using DB::Cas::tests::condemnMeta; +using DB::Cas::tests::expectThrowsCode; +using DB::Cas::tests::idOf; +using DB::Cas::tests::injectRetire; +using DB::Cas::tests::loadMetaForTest; +using DB::Cas::tests::streamingHexOf; +using DB::Cas::tests::u128Of; +using DB::Cas::tests::writeMetaClean; +using DB::Cas::tests::writeRawBlobBody; + +namespace +{ + +PoolPtr openPool(const std::shared_ptr & b) +{ + return Pool::open(b, PoolConfig{.pool_prefix = "p", .server_root_id = "test"}); +} + +/// Start a build whose owning manifest namespace + final ref name are `ns`/`ref` (promote/stageManifest +/// derive the manifest namespace by splitting PartWriteInfo::intended_ref on the LAST '/'). +PartWriteTxnPtr startBuildFor(const PoolPtr & s, const RootNamespace & ns, const String & ref) +{ + PartWriteInfo info; + info.intended_ref = ns.string() + "/" + ref; + return s->beginPartWrite(info); +} + +/// A one-entry Blob ManifestEntry for `payload` at `path` (the build's stageManifest entry). +ManifestEntry blobManifestEntry(const String & path, const String & payload) +{ + ManifestEntry e; + e.path = path; + e.placement = EntryPlacement::Blob; + e.ref = BlobRef{BlobHashAlgo::CityHash128, DB::Cas::BlobDigest::fromU128(u128Of(payload))}; + + e.blob_size = payload.size(); + return e; +} + +/// The streaming (production-convention) `BlobRef` of `payload` — CityHash128 at the write width. +BlobRef streamRefOf(const String & payload) +{ + return BlobRef{BlobHashAlgo::CityHash128, DB::Cas::BlobDigest::fromU128(hexToU128(streamingHexOf(payload)))}; +} + +ManifestEntry blobManifestEntryStreaming(const String & path, const String & payload) +{ + ManifestEntry e; + e.path = path; + e.placement = EntryPlacement::Blob; + e.ref = streamRefOf(payload); + + e.blob_size = payload.size(); + return e; +} + +/// The full single-blob write flow (EDGE-BEFORE-OBSERVE wiring order): +/// stageManifest(one entry) -> precommitAdd -> putBlob -> promote. Returns the committed ManifestId. +ManifestId publishOneBlobPart( + const PoolPtr & s, const RootNamespace & ns, const String & ref, const String & path, const String & payload) +{ + auto build = startBuildFor(s, ns, ref); + const ManifestId id = build->stageManifest({blobManifestEntry(path, payload)}); + build->precommitAdd(ns, ref, id); + build->putBlob(idOf(payload), BlobSource::fromString(payload)); + build->promote(ns, ref, build->buildId(), id); + return id; +} + +/// A one-shot backend hook (mirrors the WriteCountingBackend delegation pattern in gtest_cas_pool.cpp): +/// it delegates every op to a wrapped Backend, but the FIRST time head(target_key) is called it fires a +/// deleteExact(target_key, condemned_token) AFTER computing the (present) HEAD result and BEFORE returning +/// it — simulating GC's exact-token content delete landing in the writer's HEAD->GET window (B136). +class HeadThenDeleteOnceBackend final : public DB::Cas::Backend +{ +public: + HeadThenDeleteOnceBackend(BackendPtr inner_, String target_key_, DB::Cas::Token condemned_) + : inner(std::move(inner_)), target_key(std::move(target_key_)), condemned(condemned_) {} + + DB::Cas::HeadResult head(const String & k) override + { + const DB::Cas::HeadResult hr = inner->head(k); + if (k == target_key && !fired) + { + fired = true; + /// GC's single content-delete site, landing in the HEAD->GET window. + inner->deleteExact(target_key, condemned); + } + return hr; + } + + std::optional get(const String & k, DB::Cas::Range r) override { return inner->get(k, r); } + std::optional getStream(const String & k, DB::Cas::Range r) override { return inner->getStream(k, r); } + DB::Cas::ListPage list(const String & p, const String & c, size_t l) override { return inner->list(p, c, l); } + DB::Cas::PutResult putIfAbsent(const String & k, const String & b, const DB::Cas::ObjectMeta & meta) override { return inner->putIfAbsent(k, b, meta); } + DB::Cas::WriteSinkPtr putIfAbsentStream(const String & k, const DB::Cas::ObjectMeta & meta) override { return inner->putIfAbsentStream(k, meta); } + DB::Cas::PutResult putOverwrite(const String & k, const String & b, const DB::Cas::Token & e, const DB::Cas::ObjectMeta & meta) override { return inner->putOverwrite(k, b, e, meta); } + DB::Cas::Token resurrect(DB::ReadBuffer & payload, uint64_t payload_size, const String & k, const String & fresh_header) override { return inner->resurrect(payload, payload_size, k, fresh_header); } + DB::Cas::CasResult casPut(const String & k, const String & b, const std::optional & e, const DB::Cas::ObjectMeta & meta) override { return inner->casPut(k, b, e, meta); } + DB::Cas::DeleteOutcome deleteExact(const String & k, const DB::Cas::Token & t) override { return inner->deleteExact(k, t); } + bool supportsListTokens() const override { return inner->supportsListTokens(); } + +private: + BackendPtr inner; + String target_key; + DB::Cas::Token condemned; + bool fired = false; +}; + +/// A delegating backend that counts head()/get() calls per key. Lets a test assert the promote gate +/// performs ZERO per-file probes on a TRUSTED adopted leaf (§4 manifest-trust): no presence HEAD on +/// the blob key, no loadMeta GET on the blob-meta key. +class KeyCountingBackend final : public DB::Cas::Backend +{ +public: + explicit KeyCountingBackend(BackendPtr inner_) : inner(std::move(inner_)) {} + + size_t headCountFor(const String & k) const { auto it = head_counts.find(k); return it == head_counts.end() ? 0 : it->second; } + size_t getCountFor(const String & k) const { auto it = get_counts.find(k); return it == get_counts.end() ? 0 : it->second; } + + DB::Cas::HeadResult head(const String & k) override { ++head_counts[k]; return inner->head(k); } + std::optional get(const String & k, DB::Cas::Range r) override { ++get_counts[k]; return inner->get(k, r); } + std::optional getStream(const String & k, DB::Cas::Range r) override { return inner->getStream(k, r); } + DB::Cas::ListPage list(const String & pfx, const String & c, size_t l) override { return inner->list(pfx, c, l); } + DB::Cas::PutResult putIfAbsent(const String & k, const String & b, const DB::Cas::ObjectMeta & meta) override { return inner->putIfAbsent(k, b, meta); } + DB::Cas::WriteSinkPtr putIfAbsentStream(const String & k, const DB::Cas::ObjectMeta & meta) override { return inner->putIfAbsentStream(k, meta); } + DB::Cas::PutResult putOverwrite(const String & k, const String & b, const DB::Cas::Token & e, const DB::Cas::ObjectMeta & meta) override { return inner->putOverwrite(k, b, e, meta); } + DB::Cas::Token resurrect(DB::ReadBuffer & payload, uint64_t payload_size, const String & k, const String & fresh_header) override { return inner->resurrect(payload, payload_size, k, fresh_header); } + DB::Cas::CasResult casPut(const String & k, const String & b, const std::optional & e, const DB::Cas::ObjectMeta & meta) override { return inner->casPut(k, b, e, meta); } + DB::Cas::DeleteOutcome deleteExact(const String & k, const DB::Cas::Token & t) override { return inner->deleteExact(k, t); } + bool supportsListTokens() const override { return inner->supportsListTokens(); } + +private: + BackendPtr inner; + std::map head_counts; + std::map get_counts; +}; + +} + +TEST(CASPartWriteTxn, PutBlobWritesEnvelopeWithFixedHeader) +{ + auto b = std::make_shared(); + auto s = openPool(b); + auto build = s->beginPartWrite({}); + auto ref = build->putBlob(idOf("hello world"), BlobSource::fromString("hello world")); + EXPECT_EQ(ref.size, 11u); + + auto raw = b->get(s->layout().blobKey(ref.ref)); + ASSERT_TRUE(raw.has_value()); + auto h = decodeEnvelopeHeader(raw->bytes, raw->bytes.size(), ObjectKind::Blob); + EXPECT_EQ(h.header_len, s->poolMeta().blob_header_len); /// 256 + /// `logical_size`/`logical_hash` were dropped 2026-07-11, and `domain_id` in codecs-v3 phase 7 + /// (the pool id no longer travels in the envelope) — identity is the content key and the payload + /// starts at the fixed offset `header_len`. + EXPECT_EQ(h.build_id, build->buildId()); + EXPECT_NE(h.incarnation_tag, UInt128{}); + EXPECT_EQ(raw->bytes.substr(h.header_len), "hello world"); +} + +TEST(CASPartWriteTxn, StageManifestUsesPerBuildOrdinals) +{ + auto b = std::make_shared(); + auto s = openPool(b); + const RootNamespace ns{"test/tbl@cas@"}; + + auto build = startBuildFor(s, ns, "all_1_1_0"); + const ManifestId first = build->stageManifest({blobManifestEntry("a.bin", "a")}); + const ManifestId second = build->stageManifest({blobManifestEntry("b.bin", "b")}); + + EXPECT_EQ(first.ref.writer_epoch, s->writerEpoch()); + EXPECT_EQ(first.ref.build_sequence, build->buildSeq()); + EXPECT_EQ(first.ref.manifest_ordinal, 1u); + EXPECT_EQ(second.ref.writer_epoch, first.ref.writer_epoch); + EXPECT_EQ(second.ref.build_sequence, first.ref.build_sequence); + EXPECT_EQ(second.ref.manifest_ordinal, 2u); + /// Canonical hex build directory (spec §Manifest Identifier): `-/`. + const String build_segment = renderRefTxnId(RefTxnId{s->writerEpoch(), build->buildSeq()}); + EXPECT_EQ(s->layout().manifestKey(first), "p/cas/manifests/test/tbl@cas@/" + build_segment + "/000001.zst"); + EXPECT_EQ(s->layout().manifestKey(second), "p/cas/manifests/test/tbl@cas@/" + build_segment + "/000002.zst"); + + auto next_build = startBuildFor(s, ns, "all_2_2_0"); + const ManifestId next = next_build->stageManifest({blobManifestEntry("c.bin", "c")}); + EXPECT_EQ(next.ref.writer_epoch, first.ref.writer_epoch); + EXPECT_NE(next.ref.build_sequence, first.ref.build_sequence); + EXPECT_EQ(next.ref.manifest_ordinal, 1u); +} + +/// B171: the `cas_owner` owner-triple stamping (`PartWriteTxn::ownerMeta`) was DELETED — protection is now +/// the build-root precommit edge (reachability), not revocable object metadata GC reads per-candidate. +/// The old `CASPartWriteTxn.BlobCarriesOwnerTripleInMetadata` asserted that stamping; its coverage is replaced +/// by the build-root precommit/reclaim tests (`CASPartWriteTxnRoot*`, `CASPartWriteTxnRootDangle*`), which prove a +/// written-but-unreferenced object is protected by a live precommit and collectable once it is abandoned. + +TEST(CASPartWriteTxn, PutBlobDedupSecondWriterAdopts) +{ + auto b = std::make_shared(); + auto s = openPool(b); + + /// First writer FRESH-uploads (legal pre-precommit — newborn-debris watermark). + auto build_a = s->beginPartWrite({}); + auto ref_a = build_a->putBlob(idOf("dup"), BlobSource::fromString("dup")); + const Token token_a = b->head(s->layout().blobKey(ref_a.ref)).token; + + /// Second writer ADOPTS — the adopt must happen under a durable precommit edge (EDGE-BEFORE-OBSERVE: + /// stageManifest -> precommitAdd -> putBlob), so give build_b the wiring order. + const RootNamespace ns_b{"srv/tbl"}; + auto build_b = startBuildFor(s, ns_b, "ref_b"); + const ManifestId id_b = build_b->stageManifest({blobManifestEntry("data.bin", "dup")}); + build_b->precommitAdd(ns_b, "ref_b", id_b); + auto ref_b = build_b->putBlob(idOf("dup"), BlobSource::fromString("dup")); + + EXPECT_EQ(ref_b.ref, ref_a.ref); + /// A's incarnation survives — the second writer adopts, nothing was overwritten. + EXPECT_EQ(b->head(s->layout().blobKey(ref_a.ref)).token, token_a); +} + +/// Task 3 (spec §meta-protocols v3): the writer's dedup gate no longer consults the RetireView for the +/// condemned decision — it point-reads the per-hash freshness meta instead. A fresh (absent -> present) +/// upload must WRITE that meta as Clean so future point-readers (other writers, GC) can see it. +TEST(CASPartWriteTxn, PutBlobFreshUploadWritesCleanMeta) +{ + auto b = std::make_shared(); + auto s = openPool(b); + auto build = s->beginPartWrite({}); + + const String payload = "fresh-meta-payload"; + auto ref = build->putBlob(idOf(payload), BlobSource::fromString(payload)); + EXPECT_EQ(ref.size, payload.size()); + + const auto lm = loadMetaForTest(*b, s->layout(), u128Of(payload)); + ASSERT_TRUE(lm.has_value()) << "a fresh upload must write a Clean meta descriptor (writer point-read protocol)"; + EXPECT_EQ(lm->meta.state, MetaState::Clean); + EXPECT_EQ(lm->meta.size, payload.size()); +} + +/// §0 introspection: a fresh body upload writes the Clean meta exactly once through the +/// `putMetaIfAbsent` choke point (`CASMetaPut`), tagged with its reason (`CASMetaCreateClean`). +TEST(CASPartWriteTxnMetaCounters, CreateCleanAndChokePointCountOnFreshBody) +{ + /// Fresh body upload writes the Clean meta exactly once: CASMetaPut +1 (choke point) + /// and CASMetaCreateClean +1 (reason). Reuse the fixture of the nearest putBlob test. + const auto put_before = ProfileEvents::global_counters[ProfileEvents::CASMetaPut].load(); + const auto reason_before = ProfileEvents::global_counters[ProfileEvents::CASMetaCreateClean].load(); + + auto b = std::make_shared(); + auto s = openPool(b); + auto build = s->beginPartWrite({}); + + const String payload = "fresh-meta-payload-counters"; + auto ref = build->putBlob(idOf(payload), BlobSource::fromString(payload)); + EXPECT_EQ(ref.size, payload.size()); + + EXPECT_EQ(ProfileEvents::global_counters[ProfileEvents::CASMetaPut].load() - put_before, 1); + EXPECT_EQ(ProfileEvents::global_counters[ProfileEvents::CASMetaCreateClean].load() - reason_before, 1); +} + +/// §0 introspection: an adopt of a pre-existing body that has NO meta at all (a pre-protocol blob, or a +/// lost race with a concurrent fresh-uploader's own meta write) backfills a Clean meta through the +/// `putMetaIfAbsent` choke point (`CASMetaPut`), tagged with its reason (`CASMetaAdoptBackfill`). No +/// existing test elsewhere in the suite drives this branch: every other pre-seeded raw body in this file +/// pairs `writeRawBlobBody` with `writeMetaClean`, which skips the `!lm` backfill branch entirely. +TEST(CASPartWriteTxnMetaCounters, AdoptBackfillCountsChokePointAndReason) +{ + const auto put_before = ProfileEvents::global_counters[ProfileEvents::CASMetaPut].load(); + const auto reason_before = ProfileEvents::global_counters[ProfileEvents::CASMetaAdoptBackfill].load(); + + auto b = std::make_shared(); + auto s = openPool(b); + + const String payload = "adopt-backfill-payload-counters"; + const UInt128 hash = u128Of(payload); + const BlobRef id = idOf(payload); + + /// Pre-seed a present body big enough that observeAndAdmit's logical-size guard does not underflow — + /// deliberately WITHOUT any meta (unlike PutBlobAdoptsWhenMetaCleanNoRetireView), so the adopt reaches + /// the `!lm` backfill branch. + const uint64_t header_len = s->poolMeta().blob_header_len; + String raw_body(header_len, '\0'); + raw_body += payload; + writeRawBlobBody(*b, s->layout(), hash, raw_body); + + /// Adopt must happen under a durable precommit edge (EDGE-BEFORE-OBSERVE). + const RootNamespace ns{"srv/tbl"}; + auto build = startBuildFor(s, ns, "ref_adopt_backfill"); + const ManifestId manifest_id = build->stageManifest({blobManifestEntry("data.bin", payload)}); + build->precommitAdd(ns, "ref_adopt_backfill", manifest_id); + auto ref = build->putBlob(id, BlobSource::fromString(payload)); + EXPECT_EQ(ref.ref, id); + + EXPECT_EQ(ProfileEvents::global_counters[ProfileEvents::CASMetaPut].load() - put_before, 1); + EXPECT_EQ(ProfileEvents::global_counters[ProfileEvents::CASMetaAdoptBackfill].load() - reason_before, 1); + + const auto lm = loadMetaForTest(*b, s->layout(), hash); + ASSERT_TRUE(lm.has_value()) << "the adopt-backfill must leave a Clean meta for future point-readers"; + EXPECT_EQ(lm->meta.state, MetaState::Clean); +} + +/// The adopt decision is driven PURELY by the meta point-read — no RetireView is ever seeded in this +/// test. A pre-existing body plus an independent Clean meta must be adopted (no putOverwrite/re-upload: +/// the pre-seeded incarnation's token survives untouched), and the meta stays Clean. +TEST(CASPartWriteTxn, PutBlobAdoptsWhenMetaCleanNoRetireView) +{ + auto b = std::make_shared(); + auto s = openPool(b); + + const String payload = "adopt-meta-payload"; + const UInt128 hash = u128Of(payload); + const BlobRef id = idOf(payload); + const String blob_key = s->layout().blobKey(id); + + /// Pre-seed a body big enough that observeAndAdmit's logical-size guard (hr.size - header_len) + /// does not underflow, plus an INDEPENDENT Clean meta — deliberately NOT via a real putBlob (so the + /// adopt decision below cannot be riding on THIS task's own fresh-upload meta write). + const uint64_t header_len = s->poolMeta().blob_header_len; + String raw_body(header_len, '\0'); + raw_body += payload; + writeRawBlobBody(*b, s->layout(), hash, raw_body); + writeMetaClean(*b, s->layout(), hash, payload.size()); + const Token t0 = b->head(blob_key).token; + + /// Adopt must happen under a durable precommit edge (EDGE-BEFORE-OBSERVE), mirroring + /// PutBlobDedupSecondWriterAdopts above. + const RootNamespace ns{"srv/tbl"}; + auto build = startBuildFor(s, ns, "ref_adopt"); + const ManifestId manifest_id = build->stageManifest({blobManifestEntry("data.bin", payload)}); + build->precommitAdd(ns, "ref_adopt", manifest_id); + auto ref = build->putBlob(id, BlobSource::fromString(payload)); + + EXPECT_EQ(ref.ref, id); + /// Adopted: the pre-seeded incarnation survives untouched — no putOverwrite/re-upload happened. + EXPECT_EQ(b->head(blob_key).token, t0); + + const auto lm = loadMetaForTest(*b, s->layout(), hash); + ASSERT_TRUE(lm.has_value()); + EXPECT_EQ(lm->meta.state, MetaState::Clean) << "an adopt must leave the meta Clean"; +} + +/// A4 (negative): observeAndAdmit's EDGE-BEFORE-OBSERVE invariant — adopting an EXISTING incarnation is +/// safe ONLY under this build's durable precommit closure — was guarded only by chassert(precommitted), +/// which is compiled out in release. A putBlob that reaches the adopt path with NO precommit (the wiring +/// order stageManifest -> precommitAdd -> putBlob violated) must fail closed with a real LOGICAL_ERROR, +/// not silently adopt a blob the newborn-debris watermark does not cover. +TEST(CASPartWriteTxn, AdoptBeforePrecommitFailsClosed) +{ + auto b = std::make_shared(); + auto s = openPool(b); + + const String payload = "adopt-before-precommit-payload"; + const UInt128 hash = u128Of(payload); + const BlobRef id = idOf(payload); + + /// Pre-seed a present body (padded past the pool header so the logical-size guard does not + /// underflow) + an independent Clean meta, so putBlob's upload conflicts on the present object and + /// takes the ADOPT branch of observeAndAdmit — mirroring PutBlobAdoptsWhenMetaCleanNoRetireView. + const uint64_t header_len = s->poolMeta().blob_header_len; + String raw_body(header_len, '\0'); + raw_body += payload; + writeRawBlobBody(*b, s->layout(), hash, raw_body); + writeMetaClean(*b, s->layout(), hash, payload.size()); + + /// Start a build but DO NOT call precommitAdd: the adopt runs with `precommitted == false`. + const RootNamespace ns{"srv/tbl"}; + auto build = startBuildFor(s, ns, "ref_adopt"); + + EXPECT_DEATH( + { + DB::abort_on_logical_error.store(true, std::memory_order_relaxed); + build->putBlob(id, BlobSource::fromString(payload)); + }, + "EDGE-BEFORE-OBSERVE invariant violated"); +} + +/// The resurrect decision (displace a condemned body) is likewise driven PURELY by the meta point-read +/// — again, no RetireView is seeded. A condemned meta must cause putBlob to displace the body (a fresh +/// token, the old one never returns — INV-NO-RETURN, unchanged body mechanics) AND flip the meta back +/// to Clean. +TEST(CASPartWriteTxn, PutBlobResurrectsWhenMetaCondemned) +{ + auto b = std::make_shared(); + auto s = openPool(b); + + const String payload = "resurrect-meta-payload"; + const UInt128 hash = u128Of(payload); + const BlobRef id = idOf(payload); + const String blob_key = s->layout().blobKey(id); + + const uint64_t header_len = s->poolMeta().blob_header_len; + String raw_body(header_len, '\0'); + raw_body += payload; + writeRawBlobBody(*b, s->layout(), hash, raw_body); + writeMetaClean(*b, s->layout(), hash, payload.size()); + condemnMeta(*b, s->layout(), hash, /*condemn_round*/ 1); + const Token t0 = b->head(blob_key).token; + + /// NO retire-view seeding anywhere: the resurrect must be decided purely from the meta point-read. + auto build = s->beginPartWrite({}); + auto ref = build->putBlob(id, BlobSource::fromString(payload)); + EXPECT_EQ(ref.ref, id); + + /// Resurrected: the condemned incarnation was displaced by a fresh one. + const HeadResult hr = b->head(blob_key); + ASSERT_TRUE(hr.exists); + EXPECT_NE(hr.token, t0) << "a condemned incarnation must be displaced by a fresh one (resurrect)"; + EXPECT_EQ(b->deleteExact(blob_key, t0).kind, DeleteOutcome::Kind::TokenMismatch) + << "the condemned token must never return (INV-NO-RETURN)"; + + const auto lm = loadMetaForTest(*b, s->layout(), hash); + ASSERT_TRUE(lm.has_value()); + EXPECT_EQ(lm->meta.state, MetaState::Clean) << "a resurrect must flip the meta back to Clean"; +} + +/// §0 introspection: the resurrect (condemned-displacement) meta flip goes through the `casMeta` +/// choke point (`CASMetaCompareSwap`), tagged with its reason (`CASMetaResurrectClean`). +TEST(CASPartWriteTxnMetaCounters, ResurrectCountsCasAndReason) +{ + const auto cas_before = ProfileEvents::global_counters[ProfileEvents::CASMetaCompareSwap].load(); + const auto reason_before = ProfileEvents::global_counters[ProfileEvents::CASMetaResurrectClean].load(); + + auto b = std::make_shared(); + auto s = openPool(b); + + const String payload = "resurrect-meta-payload-counters"; + const UInt128 hash = u128Of(payload); + const BlobRef id = idOf(payload); + + const uint64_t header_len = s->poolMeta().blob_header_len; + String raw_body(header_len, '\0'); + raw_body += payload; + writeRawBlobBody(*b, s->layout(), hash, raw_body); + writeMetaClean(*b, s->layout(), hash, payload.size()); + condemnMeta(*b, s->layout(), hash, /*condemn_round*/ 1); + + auto build = s->beginPartWrite({}); + auto ref = build->putBlob(id, BlobSource::fromString(payload)); + EXPECT_EQ(ref.ref, id); + + EXPECT_GE(ProfileEvents::global_counters[ProfileEvents::CASMetaCompareSwap].load() - cas_before, 1); + EXPECT_GE(ProfileEvents::global_counters[ProfileEvents::CASMetaResurrectClean].load() - reason_before, 1); +} + +TEST(CASPartWriteTxn, PutBlobWrongSizeFailsClosed) +{ + auto b = std::make_shared(); + auto s = openPool(b); + auto build = s->beginPartWrite({}); + + BlobSource lying; + lying.size = 11; /// declares 11 but writes 5 + lying.open = []() -> std::unique_ptr + { return std::make_unique(String("short")); }; + + const BlobRef id = idOf("does-not-matter"); + EXPECT_DEATH( + { + DB::abort_on_logical_error.store(true, std::memory_order_relaxed); + build->putBlob(id, std::move(lying)); + }, + "source streamed"); + /// The cancelled stream created nothing. + EXPECT_FALSE(b->head(s->layout().blobKey(id)).exists); +} + +/// The happy-path upload STREAMS the source directly into the put sink — it does NOT pre-materialize the +/// whole blob into an in-memory String before the I/O. We assert this by counting `open` +/// invocations: a single fresh upload must invoke it EXACTLY ONCE (streamed straight into the sink). The +/// previous implementation buffered the whole blob into a `String source_bytes` first (a full in-memory +/// copy whose peak grew ~linearly with the blob size — the OOM); that pass would invoke `open` +/// before the sink write. One invocation here is the streaming-not-materializing guarantee. +TEST(CASPartWriteTxn, PutBlobStreamsSourceOnceNoFullMaterialization) +{ + auto b = std::make_shared(); + auto s = openPool(b); + auto build = s->beginPartWrite({}); + + const String payload = "streamed-not-materialized"; + int invocations = 0; + BlobSource source; + source.size = payload.size(); + source.open = [&invocations, &payload]() -> std::unique_ptr + { + ++invocations; + return std::make_unique(payload); + }; + + auto ref = build->putBlob(idOf(payload), std::move(source)); + EXPECT_EQ(ref.size, payload.size()); + EXPECT_EQ(invocations, 1) << "happy-path upload must stream the source exactly once (no pre-materialization pass)"; + + /// And the object really landed with the streamed payload (at the fixed header offset). + auto raw = b->get(s->layout().blobKey(ref.ref)); + ASSERT_TRUE(raw.has_value()); + auto h = decodeEnvelopeHeader(raw->bytes, raw->bytes.size(), ObjectKind::Blob); + EXPECT_EQ(raw->bytes.substr(h.header_len), payload); +} + +/// B190: reuseBlob is removed (it had no production callers post-B188). Its behaviors are now covered by: +/// - trusted adopted leaf at gate: PromoteTrustsAdoptedLeafNoProbeManifestTrust (CasPartWriteTxn) — §4 +/// manifest-trust: a committed-source adopted leaf publishes with NO per-file probe; a tokened leaf +/// is edge-protected (Phase A) and never re-observed at the gate. +/// - absent adopted leaf trusted: PromoteTrustsAdoptedLeafEvenIfBackendRaced (CasPartWriteTxn) — the D4 +/// trade-off (a genuinely-absent adopted blob is caught by fsck, not the promote gate). +/// - evidence tokenless vs tokened: DepIsTokenedDiscriminatesPutBlobVsAdopt (CASPartWriteTxnReuseBlob). +/// - no-dep / staging-bug fail-closed: PromoteCondemnedLeafWithoutDepAbortsFailClosed (CasPartWriteTxn). + +TEST(CASPartWriteTxnReuseBlob, DepIsTokenedDiscriminatesPutBlobVsAdopt) +{ + /// B156b discriminator unit: putBlob records a TOKENED dep (token observed at upload time), + /// adoptEvidence records a TOKENLESS W-EVIDENCE dep (no token; liveness from the source ref). + auto b = std::make_shared(); + auto s = openPool(b); + + auto build = s->beginPartWrite({}); + + /// putBlob'd hash ⇒ tokened. + build->putBlob(idOf("written"), BlobSource::fromString("written")); + EXPECT_TRUE(build->depIsTokened(BlobRef{BlobHashAlgo::CityHash128, BlobDigest::fromU128(u128Of("written"))})); + + /// Adopted hash ⇒ tokenless. adoptEvidence records the dep directly from a resolved ManifestEntry + /// (the source manifest's entry); no body needs to be in hand for the dep to be recorded. + build->adoptEvidence(blobManifestEntry("f", "adopted")); + EXPECT_FALSE(build->depIsTokened(BlobRef{BlobHashAlgo::CityHash128, BlobDigest::fromU128(u128Of("adopted"))})); + + /// Unknown hash ⇒ no dep, not tokened. + EXPECT_FALSE(build->depIsTokened(BlobRef{BlobHashAlgo::CityHash128, BlobDigest::fromU128(u128Of("unknown"))})); +} + +/// B190: ReuseBlobCondemnedThrowsAbortedRetryable is removed (reuseBlob is gone). §4 manifest-trust: a +/// committed-source adopted leaf is TRUSTED at the promote gate (no HEAD/loadMeta probe), so a condemned +/// pool blob no longer surfaces at promote — covered by PromoteTrustsAdoptedLeafNoProbeManifestTrust. + +TEST(CASPartWriteTxn, PutBlobResurrectVanishedReUploadsHeldBody) +{ + auto b = std::make_shared(); + + /// 1. Write payload-X via a throwaway build to create the blob; capture its token t0. + BlobRef id; + Token t0; + { + auto s0 = openPool(b); + auto build0 = s0->beginPartWrite({}); + id = build0->putBlob(idOf("payload-X"), BlobSource::fromString("payload-X")).ref; + t0 = b->head(s0->layout().blobKey(id)).token; + } + + /// 2. Condemn (Blob, hash(X), t0) in the retire view. + DB::Cas::Layout layout("p"); + const String blob_key = layout.blobKey(id); + /// v3: the writer's condemned decision is a per-hash meta point-read (not the retire-view). Condemn the + /// meta; t0 stays as the body token the delete-hook below fires with. + condemnMeta(*b, layout, u128Of("payload-X"), /*condemn_round*/ 1); + + /// 3. Wrap the backend so the NEXT head(blob_key) returns the (present) result and THEN fires + /// deleteExact(blob_key, t0) exactly once — GC's delete in the HEAD->GET window. Open a FRESH + /// Pool over the hook so its retire view (refreshed at open) sees the condemnation. + auto hook = std::make_shared(b, blob_key, t0); + auto s = Pool::open(hook, PoolConfig{.pool_prefix = "p", .server_root_id = "test"}); + auto build = s->beginPartWrite({}); + + /// 4. putBlob with a re-invokable body. + /// BEFORE fix: putIfAbsent -> PreconditionFailed -> observeAndAdmit HEAD (present, condemned) + /// -> resurrect GET (vanished, deleted in the window) -> throws FILE_DOESNT_EXIST. + /// AFTER fix: condemned dedup → uploadFromSource directly from held body; the object is + /// recreated under a FRESH token. NO GET of the condemned object (INV-1). + auto ref = build->putBlob(idOf("payload-X"), BlobSource::fromString("payload-X")); + EXPECT_EQ(ref.ref, id); + + /// 5. The blob is present again under a FRESH token, with the same payload; and the condemned token + /// never returns (INV-NO-RETURN). + const HeadResult hr = b->head(blob_key); + ASSERT_TRUE(hr.exists); + EXPECT_NE(hr.token, t0); + + auto raw = b->get(blob_key); + ASSERT_TRUE(raw.has_value()); + auto h = decodeEnvelopeHeader(raw->bytes, raw->bytes.size(), ObjectKind::Blob); + EXPECT_EQ(h.header_len, s->poolMeta().blob_header_len); + EXPECT_EQ(raw->bytes.substr(h.header_len), "payload-X"); + + EXPECT_EQ(b->deleteExact(blob_key, t0).kind, DeleteOutcome::Kind::TokenMismatch); + + /// The freshness meta must be reconciled to Clean too, not left stale at Condemned: the fresh + /// re-upload's meta write (writeFreshMetaClean) must find and fix the pre-existing Condemned + /// marker via the SAME reload-and-reconcile path a resurrect uses, not silently discard the + /// conflict (a stale Condemned marker would otherwise mislead every future point-reader). + const auto lm = loadMetaForTest(*b, s->layout(), u128Of("payload-X")); + ASSERT_TRUE(lm.has_value()); + EXPECT_EQ(lm->meta.state, MetaState::Clean) + << "a fresh re-upload over a stale Condemned marker must reconcile it back to Clean"; +} + +/// A persistently-failing freshness-meta write (every attempt of every outer reload-retry) must +/// surface as a controlled retry-later signal, not silently succeed with the marker left stale +/// (S22 RCA). The blob body PUT +/// itself is unaffected (MetaWriteFaultBackend only faults `.meta` keys) -- only the meta write +/// exhausts, and that exhaustion must reach putBlob's caller as NETWORK_ERROR. +TEST(CASPartWriteTxn, PutBlobFreshMetaExhaustionThrowsRetryLater) +{ + /// Short budget + zero backoff: keep the test fast. Each of writeResurrectMetaClean's 8 outer + /// attempts calls putMetaIfAbsent, which itself retries up to max_attempts times internally — + /// with max_attempts=1 the controller gives up on the first faulted attempt each time. + CasRequestBudget budget; + budget.max_attempts = 1; + budget.retry_initial_backoff_ms = 0; + auto b = std::make_shared(); + auto s = Pool::open(b, PoolConfig{.pool_prefix = "p", .server_root_id = "test", .cas_request_budget = budget}); + auto build = s->beginPartWrite({}); + + const String payload = "fresh-meta-exhaustion-payload"; + DB::Cas::tests::expectThrowsCode(DB::ErrorCodes::NETWORK_ERROR, [&] + { + build->putBlob(idOf(payload), BlobSource::fromString(payload)); + }); + + /// The body itself landed (only .meta writes are faulted) -- confirming the failure is + /// specifically the freshness marker, not the blob body. + const HeadResult hr = b->head(s->layout().blobKey(idOf(payload))); + EXPECT_TRUE(hr.exists) << "the body PUT is unaffected by the meta-only fault"; +} + +/// INV-1 (revival-from-source): a condemned blob is NEVER read via GET to revive it. +/// putBlob on a condemned-dedup hit must re-upload from its OWN source bytes — never calling +/// backend().get(blob_key). This test counts backend GETs on the blob key and asserts zero. +TEST(CASPartWriteTxn, PutBlobCondemnedDedupNeverGetsTheDyingObject) +{ + /// A delegating backend that counts get() calls on a specific key to assert INV-1. + struct GetCountingBackend final : public DB::Cas::Backend + { + explicit GetCountingBackend(BackendPtr inner_, String watched_key_) + : inner(std::move(inner_)), watched_key(std::move(watched_key_)) {} + size_t get_count = 0; + + DB::Cas::HeadResult head(const String & k) override { return inner->head(k); } + std::optional get(const String & k, DB::Cas::Range r) override + { + if (k == watched_key) + ++get_count; + return inner->get(k, r); + } + std::optional getStream(const String & k, DB::Cas::Range r) override { return inner->getStream(k, r); } + DB::Cas::ListPage list(const String & p, const String & c, size_t l) override { return inner->list(p, c, l); } + DB::Cas::PutResult putIfAbsent(const String & k, const String & bts, const DB::Cas::ObjectMeta & m) override { return inner->putIfAbsent(k, bts, m); } + DB::Cas::WriteSinkPtr putIfAbsentStream(const String & k, const DB::Cas::ObjectMeta & m) override { return inner->putIfAbsentStream(k, m); } + DB::Cas::PutResult putOverwrite(const String & k, const String & bts, const DB::Cas::Token & e, const DB::Cas::ObjectMeta & m) override { return inner->putOverwrite(k, bts, e, m); } + DB::Cas::Token resurrect(DB::ReadBuffer & payload, uint64_t payload_size, const String & k, const String & fresh_header) override { return inner->resurrect(payload, payload_size, k, fresh_header); } + DB::Cas::CasResult casPut(const String & k, const String & bts, const std::optional & e, const DB::Cas::ObjectMeta & m) override { return inner->casPut(k, bts, e, m); } + DB::Cas::DeleteOutcome deleteExact(const String & k, const DB::Cas::Token & tok) override { return inner->deleteExact(k, tok); } + bool supportsListTokens() const override { return inner->supportsListTokens(); } + private: + BackendPtr inner; + String watched_key; + }; + + auto b = std::make_shared(); + + /// 1. Upload blob Y via a throwaway build; capture the token t0. + BlobRef id; + Token t0; + { + auto s0 = openPool(b); + auto build0 = s0->beginPartWrite({}); + id = build0->putBlob(idOf("payload-Y"), BlobSource::fromString("payload-Y")).ref; + t0 = b->head(s0->layout().blobKey(id)).token; + } + + /// 2. Condemn (Blob, hash(Y), t0) in the retire view, then GC-delete the object so it is absent + /// (simulates GC completing the delete before the writer's dedup hit). + DB::Cas::Layout layout("p"); + const String blob_key = layout.blobKey(id); + injectRetire(*b, layout, /*round*/ 1, /*shard*/ 0, + {RetiredEntry{.kind = ObjectKind::Blob, .ref = DB::Cas::BlobRef{DB::Cas::BlobHashAlgo::CityHash128, DB::Cas::BlobDigest::fromU128(u128Of("payload-Y"))}, .token = t0, .size = 9}}); + b->deleteExact(blob_key, t0); + ASSERT_FALSE(b->head(blob_key).exists); + + /// 3. Open a fresh Pool over a GET-counting wrapper; the retire view sees the condemnation at open. + auto counting = std::make_shared(b, blob_key); + auto s = Pool::open(counting, PoolConfig{.pool_prefix = "p", .server_root_id = "test"}); + auto build = s->beginPartWrite({}); + + /// 4. putBlob Y — the object is absent (was deleted). The dedup-hit (PreconditionFailed) path + /// won't fire (object is gone, so putIfAbsentStream → Done on the first attempt). + /// However, even if a racing re-creation happens between the check and the upload, the + /// condemned branch must NEVER call backend().get(blob_key). + auto ref = build->putBlob(idOf("payload-Y"), BlobSource::fromString("payload-Y")); + EXPECT_EQ(ref.ref, id); + EXPECT_EQ(counting->get_count, 0u) << "INV-1: putBlob must not GET the dying object to revive it"; + + const HeadResult hr = b->head(blob_key); + ASSERT_TRUE(hr.exists); + EXPECT_NE(hr.token, t0) << "a fresh incarnation must have a new token"; + const auto raw = b->get(blob_key); + ASSERT_TRUE(raw.has_value()); + const auto hdr = decodeEnvelopeHeader(raw->bytes, raw->bytes.size(), ObjectKind::Blob); + EXPECT_EQ(raw->bytes.substr(hdr.header_len), "payload-Y"); +} + +/// INV-1 variant: blob is PRESENT and condemned (GC hasn't fired the delete yet). putBlob dedup-hits +/// it via PreconditionFailed, sees condemned token, and must re-upload from source — NEVER GET. +TEST(CASPartWriteTxn, PutBlobCondemnedDedupPresentNeverGetsTheDyingObject) +{ + struct GetCountingBackend final : public DB::Cas::Backend + { + explicit GetCountingBackend(BackendPtr inner_, String watched_key_) + : inner(std::move(inner_)), watched_key(std::move(watched_key_)) {} + size_t get_count = 0; + + DB::Cas::HeadResult head(const String & k) override { return inner->head(k); } + std::optional get(const String & k, DB::Cas::Range r) override + { + if (k == watched_key) + ++get_count; + return inner->get(k, r); + } + std::optional getStream(const String & k, DB::Cas::Range r) override { return inner->getStream(k, r); } + DB::Cas::ListPage list(const String & p, const String & c, size_t l) override { return inner->list(p, c, l); } + DB::Cas::PutResult putIfAbsent(const String & k, const String & bts, const DB::Cas::ObjectMeta & m) override { return inner->putIfAbsent(k, bts, m); } + DB::Cas::WriteSinkPtr putIfAbsentStream(const String & k, const DB::Cas::ObjectMeta & m) override { return inner->putIfAbsentStream(k, m); } + DB::Cas::PutResult putOverwrite(const String & k, const String & bts, const DB::Cas::Token & e, const DB::Cas::ObjectMeta & m) override { return inner->putOverwrite(k, bts, e, m); } + DB::Cas::Token resurrect(DB::ReadBuffer & payload, uint64_t payload_size, const String & k, const String & fresh_header) override { return inner->resurrect(payload, payload_size, k, fresh_header); } + DB::Cas::CasResult casPut(const String & k, const String & bts, const std::optional & e, const DB::Cas::ObjectMeta & m) override { return inner->casPut(k, bts, e, m); } + DB::Cas::DeleteOutcome deleteExact(const String & k, const DB::Cas::Token & tok) override { return inner->deleteExact(k, tok); } + bool supportsListTokens() const override { return inner->supportsListTokens(); } + private: + BackendPtr inner; + String watched_key; + }; + + auto b = std::make_shared(); + + /// 1. Upload blob Z via a throwaway build; capture the token t0. + BlobRef id; + Token t0; + { + auto s0 = openPool(b); + auto build0 = s0->beginPartWrite({}); + id = build0->putBlob(idOf("payload-Z"), BlobSource::fromString("payload-Z")).ref; + t0 = b->head(s0->layout().blobKey(id)).token; + } + + /// 2. Condemn (Blob, hash(Z), t0) — object still PRESENT (GC condemned but not yet deleted). + DB::Cas::Layout layout("p"); + const String blob_key = layout.blobKey(id); + /// v3: condemn via the per-hash meta (the writer's freshness point-read), object still PRESENT. + condemnMeta(*b, layout, u128Of("payload-Z"), /*condemn_round*/ 1); + ASSERT_TRUE(b->head(blob_key).exists) << "blob must be PRESENT for the condemned-present path"; + + /// 3. Open a fresh Pool over a GET-counting wrapper. + auto counting = std::make_shared(b, blob_key); + auto s = Pool::open(counting, PoolConfig{.pool_prefix = "p", .server_root_id = "test"}); + auto build = s->beginPartWrite({}); + + /// 4. putBlob Z: putIfAbsentStream → PreconditionFailed (object present) → observeAndAdmit → + /// sees condemned token → must call uploadFromSource (NOT resurrect/GET). + auto ref = build->putBlob(idOf("payload-Z"), BlobSource::fromString("payload-Z")); + EXPECT_EQ(ref.ref, id); + EXPECT_EQ(counting->get_count, 0u) << "INV-1: putBlob must not GET the condemned object"; + + const HeadResult hr = b->head(blob_key); + ASSERT_TRUE(hr.exists); + EXPECT_NE(hr.token, t0) << "condemned incarnation must be displaced by a fresh token"; + const auto raw = b->get(blob_key); + ASSERT_TRUE(raw.has_value()); + const auto hdr = decodeEnvelopeHeader(raw->bytes, raw->bytes.size(), ObjectKind::Blob); + EXPECT_EQ(raw->bytes.substr(hdr.header_len), "payload-Z"); +} + +TEST(CASPartWriteTxn, PutBlobVanishDuringRevivalReUploadsNotFatal) +{ + /// B190 sibling (INV-3): inside uploadFromSource the post-412 path re-observes via the 3-arg + /// observeAndAdmit. If the object is GC-deleted in the window (present at the conditional PUT + /// → 412, but gone at the subsequent HEAD), the 3-arg overload throws FILE_DOESNT_EXIST. Before + /// the fix that escaped putBlob's ABORTED-only retry catch as a FATAL INSERT failure. putBlob + /// HOLDS the source bytes, so a vanish here must RE-UPLOAD from those bytes within the bounded + /// retry loop — never fatal. The fix wraps uploadFromSource's two 3-arg observeAndAdmit calls so + /// FILE_DOESNT_EXIST becomes the retryable ABORTED putBlob already handles. + struct ScriptedVanishBackend final : public DB::Cas::Backend + { + ScriptedVanishBackend(BackendPtr inner_, String watched_key_) + : inner(std::move(inner_)), watched_key(std::move(watched_key_)) {} + + size_t head_absent_budget = 2; /// first N head(watched) calls report absent + size_t finalize_412_budget = 2; /// first N finalize() on watched return PreconditionFailed + + /// A sink wrapper that forces PreconditionFailed for the scripted budget, else delegates. + struct ScriptedSink final : public DB::Cas::WriteSink + { + ScriptedSink(WriteSinkPtr inner_, bool force_412_) + : inner(std::move(inner_)), force_412(force_412_) {} + DB::WriteBuffer & buffer() override { return inner->buffer(); } + DB::Cas::PutResult finalize() override + { + if (force_412) + { + /// Abandon the underlying upload so the key is never created by it, and report 412. + inner->cancel(); + return {DB::Cas::PutOutcome::PreconditionFailed, {}}; + } + return inner->finalize(); + } + void cancel() noexcept override { inner->cancel(); } + WriteSinkPtr inner; + bool force_412; + }; + + DB::Cas::HeadResult head(const String & k) override + { + if (k == watched_key && head_absent_budget > 0) + { + --head_absent_budget; + return DB::Cas::HeadResult{}; /// exists == false + } + return inner->head(k); + } + DB::Cas::WriteSinkPtr putIfAbsentStream(const String & k, const DB::Cas::ObjectMeta & meta) override + { + const bool force_412 = (k == watched_key && finalize_412_budget > 0); + if (force_412) + --finalize_412_budget; + return std::make_unique(inner->putIfAbsentStream(k, meta), force_412); + } + std::optional get(const String & k, DB::Cas::Range r) override { return inner->get(k, r); } + std::optional getStream(const String & k, DB::Cas::Range r) override { return inner->getStream(k, r); } + DB::Cas::ListPage list(const String & p, const String & c, size_t l) override { return inner->list(p, c, l); } + DB::Cas::PutResult putIfAbsent(const String & k, const String & bts, const DB::Cas::ObjectMeta & m) override { return inner->putIfAbsent(k, bts, m); } + DB::Cas::PutResult putOverwrite(const String & k, const String & bts, const DB::Cas::Token & e, const DB::Cas::ObjectMeta & m) override { return inner->putOverwrite(k, bts, e, m); } + DB::Cas::Token resurrect(DB::ReadBuffer & payload, uint64_t payload_size, const String & k, const String & fresh_header) override { return inner->resurrect(payload, payload_size, k, fresh_header); } + DB::Cas::CasResult casPut(const String & k, const String & bts, const std::optional & e, const DB::Cas::ObjectMeta & m) override { return inner->casPut(k, bts, e, m); } + DB::Cas::DeleteOutcome deleteExact(const String & k, const DB::Cas::Token & t) override { return inner->deleteExact(k, t); } + bool supportsListTokens() const override { return inner->supportsListTokens(); } + + BackendPtr inner; + String watched_key; + }; + + auto raw = std::make_shared(); + DB::Cas::Layout layout("p"); + const String blob_key = layout.blobKey(idOf("payload-V")); + + /// The blob does NOT need to pre-exist: the scripted backend models the conditional-PUT 412 + /// (object present at PUT time) independently of the inner store, then reports absent on HEAD. + auto scripted = std::make_shared(raw, blob_key); + auto s = Pool::open(scripted, PoolConfig{.pool_prefix = "p", .server_root_id = "test"}); + /// Wiring order (EDGE-BEFORE-OBSERVE): the revive re-observes via observeAndAdmit, which requires a + /// durable precommit edge — stageManifest -> precommitAdd before putBlob. + const RootNamespace ns_v{"srv/tbl"}; + auto build = startBuildFor(s, ns_v, "part_v"); + const ManifestId id_v = build->stageManifest({blobManifestEntry("data.bin", "payload-V")}); + build->precommitAdd(ns_v, "part_v", id_v); + + /// putBlob holds "payload-V" as source bytes. The vanish-during-revival must NOT be fatal: + /// BEFORE fix: observeAndAdmit throws FILE_DOESNT_EXIST, escapes putBlob's ABORTED-only catch → fatal. + /// AFTER fix: wrapped to ABORTED → putBlob retries → re-uploads from held bytes → succeeds. + PutBlobResult ref; + EXPECT_NO_THROW(ref = build->putBlob(idOf("payload-V"), BlobSource::fromString("payload-V"))); + EXPECT_EQ(ref.ref, idOf("payload-V")); + + /// The blob is present with a fresh incarnation and the exact payload — re-uploaded from source. + const HeadResult hr = raw->head(blob_key); + ASSERT_TRUE(hr.exists) << "putBlob must have re-uploaded the vanished blob from its held source bytes"; + const auto stored = raw->get(blob_key); + ASSERT_TRUE(stored.has_value()); + const auto h = decodeEnvelopeHeader(stored->bytes, stored->bytes.size(), ObjectKind::Blob); + EXPECT_EQ(stored->bytes.substr(h.header_len), "payload-V"); +} + +TEST(CASPartWriteTxn, PromoteTrustsAdoptedLeafNoProbeManifestTrust) +{ + /// §4 manifest-trust: a committed-source adoptEvidence leaf is TRUSTED at the promote gate — the live + /// source pins the blob (in-degree >= 1, not condemnable) and this build's precommit edge is durable, + /// so promote publishes with NO per-file HEAD (presence) and NO loadMeta GET (the condemned point-read) + /// and NO copy-forward. The durable manifest edge is the liveness evidence. `CASBlobAdoptTrusted` counts + /// the trusted leaf. A KeyCountingBackend proves zero probes on the blob key and the blob-meta key. + auto raw = std::make_shared(); + auto counting = std::make_shared(raw); + auto s = Pool::open(counting, PoolConfig{.pool_prefix = "p", .server_root_id = "test"}); + const RootNamespace ns{"srv1/tbl"}; + + /// A committed-source blob lives in the shared pool (seeded via a throwaway build on the same store). + { + auto seed = s->beginPartWrite({}); + seed->putBlob(streamRefOf("payload-TR"), BlobSource::fromString("payload-TR")); + } + const String blob_key = s->layout().blobKey(streamRefOf("payload-TR")); + const String meta_key = s->layout().blobMetaKey(streamRefOf("payload-TR")); + + auto build = startBuildFor(s, ns, "part_1"); + const ManifestEntry entry = blobManifestEntryStreaming("data.bin", "payload-TR"); + build->adoptEvidence(entry); + const ManifestId id = build->stageManifest({entry}); + build->precommitAdd(ns, "part_1", id); + + const auto trusted_before = ProfileEvents::global_counters[ProfileEvents::CASBlobAdoptTrusted].load(); + const size_t head_before = counting->headCountFor(blob_key); + const size_t meta_get_before = counting->getCountFor(meta_key); + + build->promote(ns, "part_1", build->buildId(), id); + + EXPECT_TRUE(s->resolveRef(ns, "part_1").has_value()); + EXPECT_EQ(ProfileEvents::global_counters[ProfileEvents::CASBlobAdoptTrusted].load() - trusted_before, 1); + EXPECT_EQ(counting->headCountFor(blob_key) - head_before, 0u) << "trust must not HEAD the adopted blob"; + EXPECT_EQ(counting->getCountFor(meta_key) - meta_get_before, 0u) << "trust must not loadMeta the adopted blob"; +} + +TEST(CASPartWriteTxn, PromoteTrustsAdoptedLeafEvenIfBackendRaced) +{ + /// §4 manifest-trust trade-off (D4 relink interserver-trust model): a committed-source adopted leaf is + /// published WITHOUT a presence probe. Even if the pool object raced to absent between adopt and + /// promote, promote does NOT re-observe it — the ref publishes. A genuinely-absent adopted blob is an + /// invariant violation detected by fsck (or an actual body GET on read), not caught at the promote gate. + /// This is the deliberate reduction from the pre-§4 "absent adopted leaf => ABORTED at gate". + auto b = std::make_shared(); + auto s = openPool(b); + const RootNamespace ns{"srv1/tbl"}; + + /// Seed X, adopt it, then delete it out from under the build (a landed GC delete in the adopt->promote + /// window). The pre-§4 gate HEADed X, found it absent, and threw ABORTED; §4 trusts the durable edge. + { + auto seed = s->beginPartWrite({}); + seed->putBlob(streamRefOf("payload-RACE"), BlobSource::fromString("payload-RACE")); + } + const String blob_key = s->layout().blobKey(streamRefOf("payload-RACE")); + const Token t0 = b->head(blob_key).token; + + auto build = startBuildFor(s, ns, "part_1"); + const ManifestEntry entry = blobManifestEntryStreaming("data.bin", "payload-RACE"); + build->adoptEvidence(entry); + const ManifestId id = build->stageManifest({entry}); + build->precommitAdd(ns, "part_1", id); + + ASSERT_EQ(b->deleteExact(blob_key, t0).kind, DeleteOutcome::Kind::Deleted); + ASSERT_FALSE(b->head(blob_key).exists); + + EXPECT_NO_THROW(build->promote(ns, "part_1", build->buildId(), id)); + EXPECT_TRUE(s->resolveRef(ns, "part_1").has_value()); +} + +TEST(CASPartWriteTxn, PromoteSwallowsPostDurableEventSinkFailure) +{ + auto b = std::make_shared(); + auto s = openPool(b); + const RootNamespace ns{"srv1/tbl"}; + auto build = startBuildFor(s, ns, "part_1"); + + ManifestEntry entry; + entry.path = "data.bin"; + entry.placement = EntryPlacement::Inline; + entry.ref = idOf("payload"); + entry.blob_size = 7; + entry.inline_bytes = "payload"; + const ManifestId id = build->stageManifest({entry}); + build->precommitAdd(ns, "part_1", id); + + /// UNKNOWN_EXCEPTION (not LOGICAL_ERROR): this simulates an arbitrary observer/sink callback + /// failing, not a CAS invariant violation -- LOGICAL_ERROR would abort the whole process under + /// debug/sanitizer builds instead of behaving like a catchable exception. + s->setEventSink([](const CasEvent & e) + { + if (e.type == CasEventType::BuildPublish) + throw DB::Exception(DB::ErrorCodes::UNKNOWN_EXCEPTION, "injected post-durable event sink failure"); + }); + + EXPECT_NO_THROW(build->promote(ns, "part_1", build->buildId(), id)); + const auto resolved = s->resolveRef(ns, "part_1"); + ASSERT_TRUE(resolved); + EXPECT_EQ(resolved->manifest_id, id); + s->setEventSink(nullptr); +} + +TEST(CASPartWriteTxn, PromoteCondemnedLeafWithoutDepAbortsFailClosed) +{ + /// A manifest blob leaf with NO recorded dep (a staging-bug shape: neither putBlob nor adoptEvidence + /// recorded it) must fail closed at the promote gate — isTrustedAdopt is false (no tokened dep and no + /// committed-source adopt), so §4 never silently publishes it. Under manifest-trust there is NO per-file + /// probe, so the fail-closed is a LOGICAL_ERROR decided from the dep set alone — it fires regardless of + /// the pool blob's presence/condemnation (here the blob is even condemned, but that is never observed). + /// The no-dep shape is reachable through the public build API (stageManifest names the leaf without any + /// dep having been recorded), so no test accessor for the private predicate is needed. + auto b = std::make_shared(); + auto s = openPool(b); + const RootNamespace ns{"srv1/tbl"}; + + /// X exists (streaming-keyed) but THIS build records NO dep for it (no putBlob, no adoptEvidence). + { + auto seed = s->beginPartWrite({}); + seed->putBlob(streamRefOf("payload-NODEP"), BlobSource::fromString("payload-NODEP")); + } + const String blob_key = s->layout().blobKey(streamRefOf("payload-NODEP")); + const Token t0 = b->head(blob_key).token; + + auto build = startBuildFor(s, ns, "part_1"); + const ManifestEntry entry = blobManifestEntryStreaming("data.bin", "payload-NODEP"); + /// NB: NO adoptEvidence(entry) — deps stays empty for this hash, so isTrustedAdopt is false. + const ManifestId id = build->stageManifest({entry}); + build->precommitAdd(ns, "part_1", id); + + /// Condemn X (present) via the meta — under §4 the gate never point-reads it (no probe on a non-trusted + /// leaf), so this only confirms the fail-closed does not depend on the leaf being clean. + condemnMeta(*b, s->layout(), hexToU128(streamingHexOf("payload-NODEP")), /*condemn_round*/ 1); + + EXPECT_DEATH( + { + DB::abort_on_logical_error.store(true, std::memory_order_relaxed); + build->promote(ns, "part_1", build->buildId(), id); + }, + "no tokened and no adopted dep"); + EXPECT_FALSE(s->resolveRef(ns, "part_1").has_value()); + /// The pool blob was never touched (no probe, no displacement). + EXPECT_EQ(b->head(blob_key).token, t0); +} + +TEST(CASPartWriteTxn, PromoteRevalidatesBlobPresenceFailClosed) +{ + /// Port of the old W-TREE-BUILD bottom-up enforcement (PutTreeEnforcesBottomUp): the surviving + /// "a committed ref never names a missing dependency" invariant. In the part-manifest model + /// stageManifest does not validate its entries' bodies. §4 manifest-trust: the fail-closed authority at + /// the promote gate is now the DEP SET, not a backend HEAD — a leaf named by the manifest with NO + /// tokened dep (never putBlob'd) and NO adopted dep (never adoptEvidence'd) is a staging bug and fails + /// closed with LOGICAL_ERROR (a real write always records a dep for every leaf). No per-file probe. + auto b = std::make_shared(); + auto s = openPool(b); + const RootNamespace ns{"srv1/tbl"}; + + /// Stage + precommit a manifest naming a blob hash that was NEVER uploaded (no dep recorded). + auto build = startBuildFor(s, ns, "part_1"); + const ManifestId mid = build->stageManifest({blobManifestEntry("data.bin", "never-uploaded")}); + build->precommitAdd(ns, "part_1", mid); + + /// promote must fail closed: the leaf has no tokened and no adopted dep ⇒ LOGICAL_ERROR. No ref committed. + EXPECT_DEATH( + { + DB::abort_on_logical_error.store(true, std::memory_order_relaxed); + build->promote(ns, "part_1", build->buildId(), mid); + }, + "no tokened and no adopted dep"); + EXPECT_FALSE(s->resolveRef(ns, "part_1").has_value()); + + /// After uploading the blob, a fresh build's promote succeeds — the same manifest content is now + /// fully present. + auto build2 = startBuildFor(s, ns, "part_1"); + build2->putBlob(idOf("never-uploaded"), BlobSource::fromString("never-uploaded")); + const ManifestId mid2 = build2->stageManifest({blobManifestEntry("data.bin", "never-uploaded")}); + build2->precommitAdd(ns, "part_1", mid2); + EXPECT_NO_THROW(build2->promote(ns, "part_1", build2->buildId(), mid2)); + EXPECT_TRUE(s->resolveRef(ns, "part_1").has_value()); +} + +TEST(CASPartWriteTxn, AdoptEvidenceRecordsTokenlessDep) +{ + /// Port of AdoptFromTreeRecordsEvidence. adoptEvidence records a TOKENLESS W-EVIDENCE dep directly + /// from a resolved ManifestEntry — a Blob entry is tokenless (depIsTokened false), an Inline entry + /// records nothing. §4: whether the dep is a committed-source adopt vs absent (adopted vs no-dep) is + /// asserted end-to-end at the promote gate by PromoteTrustsAdoptedLeafNoProbeManifestTrust (positive: + /// adopted leaf ⇒ trusted, no probe) and PromoteCondemnedLeafWithoutDepAbortsFailClosed (negative + /// control: no dep ⇒ fail closed, LOGICAL_ERROR). + auto b = std::make_shared(); + auto s = openPool(b); + auto build = s->beginPartWrite({}); + + const ManifestEntry adopted = blobManifestEntry("data.bin", "source-blob"); + build->adoptEvidence(adopted); + EXPECT_FALSE(build->depIsTokened(BlobRef{BlobHashAlgo::CityHash128, BlobDigest::fromU128(u128Of("source-blob"))})); + + /// An Inline entry references no standalone object → records nothing (never tokened). + ManifestEntry inline_entry; + inline_entry.path = "small"; + inline_entry.placement = EntryPlacement::Inline; + inline_entry.inline_bytes = "abc"; + build->adoptEvidence(inline_entry); + EXPECT_FALSE(build->depIsTokened(BlobRef{BlobHashAlgo::CityHash128, BlobDigest::fromU128(u128Of("abc"))})); +} + +TEST(CASPartWriteTxn, AbandonRemovesStagedDebrisAndDisables) +{ + /// Port of AbandonLeavesDebrisAndDisables to the new abandon semantics (CasPartWriteTxn.cpp abandon): + /// abandon best-effort exact-token-DELETEs this build's STAGED manifest debris, leaves blob bodies + /// (full GC's job via min_active), and disables the build (further ops throw via requireAlive). + auto b = std::make_shared(); + auto s = openPool(b); + const RootNamespace ns{"srv1/tbl"}; + auto build = startBuildFor(s, ns, "ref"); + + auto blob_ref = build->putBlob(idOf("kept"), BlobSource::fromString("kept")); + const ManifestId mid = build->stageManifest({blobManifestEntry("f", "kept")}); + + /// The staged manifest body and the blob are present before abandon. + EXPECT_TRUE(b->head(s->layout().blobKey(blob_ref.ref)).exists); + EXPECT_TRUE(b->head(s->layout().manifestKey(mid)).exists); + + build->abandon(); + + /// Blob stays (debris — full GC reclaims it). The staged manifest debris is best-effort cleaned now. + EXPECT_TRUE(b->head(s->layout().blobKey(blob_ref.ref)).exists); + EXPECT_FALSE(b->head(s->layout().manifestKey(mid)).exists) + << "abandon must best-effort delete this build's staged manifest debris"; + + /// Further operations throw via requireAlive. + EXPECT_DEATH( + { + DB::abort_on_logical_error.store(true, std::memory_order_relaxed); + build->putBlob(idOf("after"), BlobSource::fromString("after")); + }, + "has been abandoned"); + EXPECT_DEATH( + { + DB::abort_on_logical_error.store(true, std::memory_order_relaxed); + build->stageManifest({blobManifestEntry("g", "kept")}); + }, + "has been abandoned"); + EXPECT_DEATH( + { + DB::abort_on_logical_error.store(true, std::memory_order_relaxed); + build->precommitAdd(ns, "ref", mid); + }, + "has been abandoned"); +} + +TEST(CASPartWriteTxn, PublishHappyPathRoundTrip) +{ + auto b = std::make_shared(); + auto s = openPool(b); + const RootNamespace ns{"srv1/tbl"}; + auto build = startBuildFor(s, ns, "part_1"); + + auto blob = build->putBlob(idOf("hello world"), BlobSource::fromString("hello world")); + EXPECT_EQ(blob.size, 11u); + + const ManifestId id = build->stageManifest({blobManifestEntry("data.bin", "hello world")}); + build->precommitAdd(ns, "part_1", id); + build->promote(ns, "part_1", build->buildId(), id); + + auto r = s->resolveRef(ns, "part_1"); + ASSERT_TRUE(r.has_value()); + EXPECT_EQ(r->manifest_id, id); + + /// Read the manifest back and locate its single blob leaf. + const PartManifest manifest = s->readManifest(id); + ASSERT_EQ(manifest.entries.size(), 1u); + const auto * entry = findEntry(manifest.entries, "data.bin"); + ASSERT_TRUE(entry != nullptr); + const auto loc = s->locate(*entry); + auto got = b->get(loc.key, Range{loc.offset, loc.length}); + ASSERT_TRUE(got.has_value()); + EXPECT_EQ(got->bytes, "hello world"); +} + +TEST(CASPartWriteTxn, PromoteCrossNamespaceManifestFailsClosed) +{ + /// Port of PublishRequiresTreeInDepSet. The W-DEP-SET "root must be a built/adopted dep" authority + /// is gone (the tree object model it guarded is gone); the surviving fail-closed authority that + /// refuses an inconsistent commit target is the namespace consistency check in precommitAdd/promote + /// (CasPartWriteTxn.cpp): a manifest whose root_namespace != the target namespace is a bug ⇒ LOGICAL_ERROR. + auto b = std::make_shared(); + auto s = openPool(b); + const RootNamespace ns{"srv1/tbl"}; + const RootNamespace other_ns{"srv1/other"}; + + auto build = startBuildFor(s, ns, "part_1"); + build->putBlob(idOf("hello world"), BlobSource::fromString("hello world")); + /// The manifest is minted in `ns` (derived from intended_ref). Promoting/precommitting it into a + /// DIFFERENT namespace must fail closed. + const ManifestId id = build->stageManifest({blobManifestEntry("data.bin", "hello world")}); + + EXPECT_DEATH( + { + DB::abort_on_logical_error.store(true, std::memory_order_relaxed); + build->precommitAdd(other_ns, "part_1", id); + }, + "precommitAdd: manifest namespace"); + EXPECT_DEATH( + { + DB::abort_on_logical_error.store(true, std::memory_order_relaxed); + build->promote(other_ns, "part_1", build->buildId(), id); + }, + "promote: manifest namespace"); +} + +/// (CASPartWriteTxn.PublishOwnThreadConflictRetries was removed with the legacy mutable ref-shard lane: it +/// injected a Conflict on the promote's shard `casPut` and asserted the shard re-read/retry. The ref model +/// has no shard CAS -- promote appends a write-once ref-log object via `putIfAbsentControlled`, and an +/// uncertain create is resolved by exact-key observation, covered by the ref-writer uncertain-result tests +/// (`gtest_cas_ref_writer.cpp`), not a CAS retry.) + +TEST(CASPartWriteTxn, PublishIntoSecondNamespaceSameBlob) +{ + /// Port of PublishIntoSecondNamespaceSameTree. A part manifest is single-owner and namespace-qualified + /// (precommitAdd/promote enforce id.root_namespace == target_ns), so the SAME ManifestId cannot be + /// published into two namespaces — each namespace gets its OWN manifest. The invariant the original + /// test protected is preserved at the BLOB plane: the shared blob is uploaded ONCE and adopted by the + /// second build (its token is unchanged after the second publish). + auto b = std::make_shared(); + auto s = openPool(b); + const RootNamespace ns1{"srv1/tbl"}; + const RootNamespace ns2{"srv1/tbl/detached"}; + + /// First build publishes part_1 in ns1, uploading the blob. + auto build1 = startBuildFor(s, ns1, "part_1"); + auto blob = build1->putBlob(idOf("hello world"), BlobSource::fromString("hello world")); + const String blob_key = s->layout().blobKey(blob.ref); + const Token blob_token = b->head(blob_key).token; + const ManifestId id1 = build1->stageManifest({blobManifestEntry("data.bin", "hello world")}); + build1->precommitAdd(ns1, "part_1", id1); + build1->promote(ns1, "part_1", build1->buildId(), id1); + + /// Second build publishes part_1 in ns2 referencing the SAME blob: putBlob dedup-hits and ADOPTS the + /// present incarnation (no re-upload), so the blob token is unchanged. Wiring order + /// (EDGE-BEFORE-OBSERVE): stageManifest -> precommitAdd -> putBlob -> promote. + auto build2 = startBuildFor(s, ns2, "part_1"); + const ManifestId id2 = build2->stageManifest({blobManifestEntry("data.bin", "hello world")}); + build2->precommitAdd(ns2, "part_1", id2); + build2->putBlob(idOf("hello world"), BlobSource::fromString("hello world")); + build2->promote(ns2, "part_1", build2->buildId(), id2); + + auto r1 = s->resolveRef(ns1, "part_1"); + auto r2 = s->resolveRef(ns2, "part_1"); + ASSERT_TRUE(r1.has_value()); + ASSERT_TRUE(r2.has_value()); + EXPECT_EQ(r1->manifest_id, id1); + EXPECT_EQ(r2->manifest_id, id2); + + /// The blob object was uploaded once: its token is unchanged after both publishes. + EXPECT_EQ(b->head(blob_key).token, blob_token); +} + +/// Task 10: refs are no longer sharded (one whole-table cache per namespace, spec §Table State), so +/// there is no more "same shard" CAS-conflict-retry to force — two builds publishing into the SAME +/// TABLE now serialize through the append lane's per-namespace batching queue instead (exercised by +/// gtest_cas_ref_writer.cpp's co-batch/queue tests). What remains a real regression to guard is the +/// end-to-end outcome: two builds publishing distinct refs into one namespace both land correctly. +TEST(CASPartWriteTxn, TwoBuildsPublishToSameNamespaceBothLand) +{ + auto b = std::make_shared(); + auto s = openPool(b); + const RootNamespace ns{"srv1/tbl"}; + const String ref1 = "a"; + const String ref2 = "b"; + + auto build_a = startBuildFor(s, ns, ref1); + build_a->putBlob(idOf("content-a"), BlobSource::fromString("content-a")); + const ManifestId id_a = build_a->stageManifest({blobManifestEntry("data.bin", "content-a")}); + build_a->precommitAdd(ns, ref1, id_a); + build_a->promote(ns, ref1, build_a->buildId(), id_a); + + auto build_b = startBuildFor(s, ns, ref2); + build_b->putBlob(idOf("content-b"), BlobSource::fromString("content-b")); + const ManifestId id_b = build_b->stageManifest({blobManifestEntry("data.bin", "content-b")}); + build_b->precommitAdd(ns, ref2, id_b); + build_b->promote(ns, ref2, build_b->buildId(), id_b); + + auto r1 = s->resolveRef(ns, ref1); + auto r2 = s->resolveRef(ns, ref2); + ASSERT_TRUE(r1.has_value()); + ASSERT_TRUE(r2.has_value()); + EXPECT_EQ(r1->manifest_id, id_a); + EXPECT_EQ(r2->manifest_id, id_b); + EXPECT_EQ(s->listRefs(ns).size(), 2u); +} + +TEST(CASPartWriteTxn, FirstPublishMakesNamespaceDiscoverable) +{ + /// After Task 4 the registry is deleted; the first publication admits the namespace to the + /// authoritative catalog before appending its stream record. + auto b = std::make_shared(); + auto s = Pool::open(b, PoolConfig{.pool_prefix = "p", .server_root_id = "test"}); + const RootNamespace ns{"srv9/fresh"}; + + EXPECT_TRUE(s->listNamespaces("").namespaces.empty()); + publishOneBlobPart(s, ns, "part_1", "f", "reg-payload"); + + /// The namespace is now discoverable from the catalog -- no registry write needed. + const auto all = s->listNamespaces("").namespaces; + ASSERT_EQ(all.size(), 1u); + EXPECT_EQ(all[0], "srv9/fresh"); +} + +TEST(CASPartWriteTxn, AdoptEvidenceNoBackendOp) +{ + /// B188: adoptEvidence records a TOKENLESS W-EVIDENCE dep from an already-resolved ManifestEntry + /// WITHOUT any backend call (no HEAD, no GET, no PUT). + /// + /// Two behavioural assertions: + /// 1. No backend op fires during adoptEvidence (counted via a delegating wrapper). + /// 2. The recorded dep is tokenless: after adoptEvidence(entry), depIsTokened is false (the + /// W-EVIDENCE dep is what the promote gate later revalidates). + + /// A delegating wrapper that counts every backend access path including the streaming write path. + struct LocalCountingBackend final : public Backend + { + explicit LocalCountingBackend(BackendPtr inner_) : inner(std::move(inner_)) {} + size_t heads = 0; + size_t stream_puts = 0; + size_t gets = 0; + + HeadResult head(const String & k) override { ++heads; return inner->head(k); } + WriteSinkPtr putIfAbsentStream(const String & k, const ObjectMeta & meta) override + { + ++stream_puts; + return inner->putIfAbsentStream(k, meta); + } + std::optional get(const String & k, Range r) override { ++gets; return inner->get(k, r); } + std::optional getStream(const String & k, Range r) override { return inner->getStream(k, r); } + ListPage list(const String & p, const String & c, size_t l) override { return inner->list(p, c, l); } + PutResult putIfAbsent(const String & k, const String & bts, const ObjectMeta & m) override { return inner->putIfAbsent(k, bts, m); } + PutResult putOverwrite(const String & k, const String & bts, const Token & e, const ObjectMeta & m) override { return inner->putOverwrite(k, bts, e, m); } + Token resurrect(DB::ReadBuffer & payload, uint64_t payload_size, const String & k, const String & fresh_header) override { return inner->resurrect(payload, payload_size, k, fresh_header); } + CasResult casPut(const String & k, const String & bts, const std::optional & e, const ObjectMeta & m) override { return inner->casPut(k, bts, e, m); } + DeleteOutcome deleteExact(const String & k, const Token & t) override { return inner->deleteExact(k, t); } + bool supportsListTokens() const override { return inner->supportsListTokens(); } + private: + BackendPtr inner; + }; + + auto raw = std::make_shared(); + auto counting = std::make_shared(raw); + auto s = Pool::open(counting, PoolConfig{.pool_prefix = "p", .server_root_id = "test"}); + auto build = s->beginPartWrite({}); + + /// A Blob ManifestEntry. adoptEvidence is called on a hand-crafted entry — that IS the B188 interface. + const ManifestEntry entry = blobManifestEntry("b188.bin", "b188-content"); + + /// Reset the counters after Pool::open (which may HEAD/GET gc/server-roots etc. during startup). + counting->heads = 0; + counting->stream_puts = 0; + counting->gets = 0; + + /// adoptEvidence — must record the dep WITHOUT touching the backend. + EXPECT_NO_THROW(build->adoptEvidence(entry)); + EXPECT_EQ(counting->heads, 0u) << "adoptEvidence must not HEAD the backend"; + EXPECT_EQ(counting->stream_puts, 0u) << "adoptEvidence must not PUT to the backend"; + EXPECT_EQ(counting->gets, 0u) << "adoptEvidence must not GET from the backend"; + + /// The dep is recorded — a tokenless W-EVIDENCE dep (depIsTokened false) that the promote gate later + /// revalidates; the no-backend-op counts above are the B188 contract's primary guard. + EXPECT_FALSE(build->depIsTokened(BlobRef{BlobHashAlgo::CityHash128, BlobDigest::fromU128(u128Of("b188-content"))})); + + /// Inline entry: adoptEvidence records nothing (Inline has no standalone object) and no backend op. + ManifestEntry inline_entry; + inline_entry.path = "small"; + inline_entry.placement = EntryPlacement::Inline; + inline_entry.inline_bytes = "xy"; + EXPECT_NO_THROW(build->adoptEvidence(inline_entry)); + EXPECT_EQ(counting->heads, 0u); + EXPECT_EQ(counting->stream_puts, 0u); + EXPECT_EQ(counting->gets, 0u); + EXPECT_FALSE(build->depIsTokened(BlobRef{BlobHashAlgo::CityHash128, BlobDigest::fromU128(u128Of("xy"))})); +} + +TEST(CASPartWriteTxn, ConvergesUnderProductiveGc) +{ + /// B167/B171 LIVENESS — the re-upload/condemn livelock, now closed by the build-root precommit edge. + /// + /// THE BUG (before the fix): a blob H was referenced, dropped, and GC-condemned (everEdged ∧ InDeg=0, + /// condemned in the retire view). A NEW build dedup-HITS H by content and must re-upload it from + /// source — it re-streams a FRESH incarnation of H. But the productive GC, re-deriving H as a + /// zero-in-degree candidate every round, kept RE-CONDEMNING and exact-token-DELETING that fresh + /// incarnation in the build's upload→commit window. The build never converged → livelock. + /// + /// THE FIX (B171): protection is the build-root PRECOMMIT EDGE. PartWriteTxn B precommits its manifest (naming + /// H) BEFORE the adversarial loop, so the GC fold lifts H to in-degree ≥ 1 — H is never even a + /// zero-in-degree candidate and is SPARED every round until B promotes (the committed ref then pins H). + /// + /// FORM: full adversarial loop. A real Gc drives complete runRegularRound rounds against the same + /// pool while build B holds an active watermark covering H's incarnation. We assert H is SPARED + /// every round and that B promotes within a BOUNDED number of GC rounds, after which H reads back. + auto b = std::make_shared(); + const RootNamespace ns{"srv1/tbl"}; + + PoolConfig cfg; + cfg.pool_prefix = "p"; + cfg.server_root_id = "test"; + cfg.server_id = UInt128(0xAB); + cfg.background_watermark = false; + const String content = "shared-content"; + + /// 1. PartWriteTxn A creates H ("shared-content"), publishes a part referencing it, then drops the ref. + /// Capture H's first incarnation token so we can condemn exactly it. + BlobRef h; + Token h_token0; + { + auto s0 = Pool::open(b, cfg); + publishOneBlobPart(s0, ns, "part_1", "f", content); + h = idOf(content); + h_token0 = b->head(s0->layout().blobKey(h)).token; + s0->dropRef(ns, "part_1"); + } + + /// 2. Condemn (Blob, H, h_token0): `injectRetire` seeds the LEDGER (a real round's later settle/spare + /// of h_token0 rides this entry — the adversarial loop below still exercises that), and v3's + /// `condemnMeta` seeds the per-hash META (the writer's condemned decision is now a point-read of + /// it, not the retire-view — Task 3). `publishOneBlobPart` already created H's meta as Clean, so + /// condemnMeta's read-modify-CAS finds it. Together these reproduce exactly what a real GC condemn + /// now writes (Task 5), without driving a full round just to observe H at in-degree 0. + DB::Cas::Layout layout("p"); + injectRetire(*b, layout, /*round*/ 1, /*shard*/ 0, + {RetiredEntry{.kind = ObjectKind::Blob, .ref = DB::Cas::BlobRef{DB::Cas::BlobHashAlgo::CityHash128, DB::Cas::BlobDigest::fromU128(u128Of(content))}, .token = h_token0, + .size = content.size()}}); + condemnMeta(*b, layout, u128Of(content), /*condemn_round*/ 1); + + /// 3. Open the live Pool and start build B. B dedup-hits the condemned H and re-uploads from + /// source (uploadFromSource via putBlob): a fresh incarnation, a NEW token. B stays ACTIVE for + /// the whole adversarial loop — its build_seq is never retired below. + auto s = Pool::open(b, cfg); + const String blob_key = s->layout().blobKey(h); + auto build_b = startBuildFor(s, ns, "part_2"); + + /// B190: use putBlob (holds source bytes). putBlob detects the condemned dedup hit and calls + /// uploadFromSource — no GET of dying object. + const auto ref_b = build_b->putBlob(h, BlobSource::fromString(content)); + ASSERT_EQ(ref_b.ref, h); + + const HeadResult after_reupload = b->head(blob_key); + ASSERT_TRUE(after_reupload.exists); + EXPECT_NE(after_reupload.token, h_token0); /// a genuinely fresh incarnation + + /// 4. PartWriteTxn B stages its manifest referencing H and PRECOMMITS it (build-root edge). H is now + /// protected by reachability: the GC fold lifts H to in-degree ≥ 1 from the precommit. + const ManifestId mid_b = build_b->stageManifest({blobManifestEntry("f", content)}); + build_b->precommitAdd(ns, "part_2", mid_b); + + /// 5. THE ADVERSARIAL LOOP. A real, productive GC keeps trying to reclaim. It reclaims the now- + /// unreferenced part_1 manifest (build A's, UNprotected) but H stays pinned by B's PRECOMMIT edge + /// (in-degree ≥ 1), so H is never even a zero-in-degree candidate. We drive far more rounds than B + /// needs to promote; H must survive ALL of them. Each round renews B's watermark so the crash + /// detector keeps judging B live. + Gc gc(s, hexToU128("00000000000000000000000000000001")); + constexpr int MAX_GC_ROUNDS = 8; + + const auto driveRoundAndAssertHSpared = [&](int round_no) + { + /// A LIVE server renews its watermark continuously. Renew once per GC round so B's watermark seq + /// ADVANCES between rounds — that is precisely what distinguishes a live server from a crashed one + /// (a frozen B would have its precommit reclaimed; an advancing seq keeps it). + s->renewWatermarkOnce(); + gc.runRegularRound(); + const HeadResult hr = b->head(blob_key); + ASSERT_TRUE(hr.exists) << "H was deleted by GC at round " << round_no + << " despite being pinned by the live build B's precommit (B167 livelock would do this)"; + const auto raw = b->get(blob_key); + ASSERT_TRUE(raw.has_value()); + const auto hdr = decodeEnvelopeHeader(raw->bytes, raw->bytes.size(), ObjectKind::Blob); + EXPECT_EQ(raw->bytes.substr(hdr.header_len), content) + << "H's content was lost/corrupted at round " << round_no; + }; + + /// Phase 1 — the livelock window. H is referenced by NO committed TABLE ref (B has not promoted yet) + /// but IS named by B's precommit, so the build-root fold lifts it to in-degree ≥ 1. Drive several + /// full rounds; the precommit edge must SPARE H's fresh incarnation every round. + int rounds_run = 0; + constexpr int PRE_PUBLISH_ROUNDS = 4; + for (int i = 0; i < PRE_PUBLISH_ROUNDS; ++i) + { + driveRoundAndAssertHSpared(++rounds_run); + if (::testing::Test::HasFatalFailure()) + return; + } + + /// Phase 2 — converge. With H still alive (spared through the whole window), build B promotes a part + /// referencing it. The promote gate sees H present + live (fresh incarnation uploaded above), so it + /// commits. This MUST succeed — the build converges in bounded steps. + build_b->promote(ns, "part_2", build_b->buildId(), mid_b); + const bool published = true; + + /// Phase 3 — keep the GC hammering after promote. H is now pinned by the committed ref's manifest + /// edge; the GC must keep sparing it as a genuinely-reachable node. + while (rounds_run < MAX_GC_ROUNDS) + { + driveRoundAndAssertHSpared(++rounds_run); + if (::testing::Test::HasFatalFailure()) + return; + } + + /// 6. ASSERT convergence: promote SUCCEEDED within the bounded budget, and H reads back intact. + ASSERT_TRUE(published) << "build B never published — the B167 livelock is back"; + EXPECT_LE(rounds_run, MAX_GC_ROUNDS); + + const auto resolved = s->resolveRef(ns, "part_2"); + ASSERT_TRUE(resolved.has_value()); + EXPECT_EQ(resolved->manifest_id, mid_b); + + const PartManifest manifest = s->readManifest(mid_b); + ASSERT_EQ(manifest.entries.size(), 1u); + const auto * entry = findEntry(manifest.entries, "f"); + ASSERT_TRUE(entry != nullptr); + const auto loc = s->locate(*entry); + const auto got = b->get(loc.key, Range{loc.offset, loc.length}); + ASSERT_TRUE(got.has_value()); + EXPECT_EQ(got->bytes, content); +} + +/// BUG 1 (WPromote owner==bld): promote is a PURE owner MOVE (Δ=0 — it restores no blob in-degree). The +/// TLA+ `WPromote` requires the precommit to STILL be the live owner of the ref before the move (`owner[m] +/// = bld`). If the precommit was removed/reclaimed (an abandon or GC reclaim appended a removal event), a +/// Δ=0 move would re-publish a committed ref over blobs whose in-degree was already decremented to 0 — GC +/// then deletes them ⇒ a reachable committed manifest with dangling blobs (INV_NO_DANGLE violation). +/// promote MUST fail closed (ABORTED) unless the precommit is the current live owner binding of the ref. +TEST(CASPartWriteTxn, PromoteFailsClosedWhenPrecommitNoLongerLiveOwner) +{ + auto b = std::make_shared(); + auto s = openPool(b); + const RootNamespace ns{"srv1/tbl"}; + auto build = startBuildFor(s, ns, "part_1"); + + build->putBlob(idOf("hello world"), BlobSource::fromString("hello world")); + const ManifestId id = build->stageManifest({blobManifestEntry("data.bin", "hello world")}); + build->precommitAdd(ns, "part_1", id); + + /// Make the precommit NO LONGER the live owner: append an exact precommit-removal ref-log + /// transaction exactly as an abandon / GC reclaim would (spec §Remove Precommit) -- via the SAME + /// public append lane a real abandon/reclaim would use, simulating an external actor this build + /// object does not know about (not this build's own `abandon()`, which would also retire it and + /// mask the "precommit no longer live" guard behind requireAlive()'s own rejection). + s->appendRefOps(ns, MutationScope::ref("part_1"), + [&](const RefTableState &) -> std::vector + { + RefOp op; + op.kind = RefOpKind::OwnerTransition; + op.old_binding = RefOwnerBinding{RefOwnerKind::Precommit, "part_1", id.ref}; + return {op}; + }, + RootMutationOrigin::Writer, RootMutationKind::Abandon); + + /// promote must fail closed: the precommit is no longer the live owner, so a Δ=0 move would dangle. + expectThrowsCode(DB::ErrorCodes::NETWORK_ERROR, + [&] { build->promote(ns, "part_1", build->buildId(), id); }); + /// No ref committed. + EXPECT_FALSE(s->resolveRef(ns, "part_1").has_value()); +} + +/// BUG 1 happy path: a promote whose precommit is STILL the live owner succeeds (the guard must not +/// reject the normal commit). Distinct from PublishHappyPathRoundTrip in that it pins the WPromote guard. +TEST(CASPartWriteTxn, PromoteSucceedsWhenPrecommitIsLiveOwner) +{ + auto b = std::make_shared(); + auto s = openPool(b); + const RootNamespace ns{"srv1/tbl"}; + auto build = startBuildFor(s, ns, "part_1"); + + build->putBlob(idOf("hello world"), BlobSource::fromString("hello world")); + const ManifestId id = build->stageManifest({blobManifestEntry("data.bin", "hello world")}); + build->precommitAdd(ns, "part_1", id); + + EXPECT_NO_THROW(build->promote(ns, "part_1", build->buildId(), id)); + ASSERT_TRUE(s->resolveRef(ns, "part_1").has_value()); + EXPECT_EQ(s->resolveRef(ns, "part_1")->manifest_id, id); +} + +/// all-tree-part-files Task 2 (TLA+ `WRepoint`): +/// `promote`'s existing unique-ref guard (BUG 1a) refuses to overwrite a committed ref naming a +/// DIFFERENT manifest -- correct for an ACCIDENTAL double-publish, but there is no way to perform an +/// INTENDED repoint (a standalone write/remove on an already-committed part) without it. `allow_repoint` +/// opts into exactly that: the guard's throw is skipped, and the committed-transition RefOp (old = +/// the currently-committed manifest, new = the incoming one) is appended in the SAME ref-log record as +/// the ordinary precommit->committed promotion -- the C++ realization of `WRepoint`'s one-event +/// old-binding/new-binding shape (Phase 0, task-1 gate). Without the flag, behavior is BYTE-IDENTICAL +/// to today (BUG 1a still fires). +TEST(CASPartWriteTxnRepoint, PromoteRepointsCommittedRef) +{ + auto b = std::make_shared(); + /// The sink target must outlive the Pool: `~Pool` emits terminate events into the sink. + std::vector events; + auto s = openPool(b); + const RootNamespace ns{"srv1/tbl"}; + + /// Publish ref "part_1" -> M1 through the normal build path. + auto build1 = startBuildFor(s, ns, "part_1"); + build1->putBlob(idOf("m1"), BlobSource::fromString("m1")); + const ManifestId m1_id = build1->stageManifest({blobManifestEntry("data.bin", "m1")}); + build1->precommitAdd(ns, "part_1", m1_id); + build1->promote(ns, "part_1", build1->buildId(), m1_id); + ASSERT_TRUE(s->resolveRef(ns, "part_1").has_value()); + EXPECT_EQ(s->resolveRef(ns, "part_1")->manifest_id, m1_id); + + /// A second build stages M2 (one extra entry) onto the SAME ref. + auto build2 = startBuildFor(s, ns, "part_1"); + build2->putBlob(idOf("m2"), BlobSource::fromString("m2")); + build2->putBlob(idOf("m2x"), BlobSource::fromString("m2x")); + const ManifestId m2_id = build2->stageManifest({blobManifestEntry("data.bin", "m2"), blobManifestEntry("extra.bin", "m2x")}); + build2->precommitAdd(ns, "part_1", m2_id); + + /// allow_repoint = false (the default) -> NETWORK_ERROR (CAS write-retry-later), existing invariant + /// untouched; M1 still resolves. + expectThrowsCode(DB::ErrorCodes::NETWORK_ERROR, + [&] { build2->promote(ns, "part_1", build2->buildId(), m2_id); }); + EXPECT_EQ(s->resolveRef(ns, "part_1")->manifest_id, m1_id); + + /// The failed no-flag attempt threw BEFORE appendRefOps returned, so build2's precommit is still the + /// live owner (no removal was appended) -- the SAME build/manifest can be retried with the flag. + s->setEventSink([&](const CasEvent & e) { events.push_back(e); }); + EXPECT_NO_THROW(build2->promote(ns, "part_1", build2->buildId(), m2_id, /*allow_repoint=*/true)); + auto resolved = s->resolveRef(ns, "part_1"); + ASSERT_TRUE(resolved); + EXPECT_EQ(resolved->manifest_id.ref, m2_id.ref); + + /// Every effective repoint is loud (spec §4): exactly one RefRepoint event, naming the ref and the + /// old manifest it replaced. + size_t repoint_events = 0; + for (const CasEvent & e : events) + if (e.type == CasEventType::RefRepoint) + { + ++repoint_events; + EXPECT_EQ(e.ref_name, "part_1"); + EXPECT_EQ(e.detail.at("old_manifest"), manifestRefDebugString(m1_id.ref)); + } + EXPECT_EQ(repoint_events, 1u); +} + +/// BUG 2 (WAbandonPrecommit; delete-after-sealed-decrements): once `precommitAdd` has made a manifest a +/// LIVE precommit owner input, `abandon` must NOT writer-delete its body. The TLA+ `WAbandonPrecommit` +/// appends a REMOVAL event (`old = precommit(build_id, final_ref, T)`, `new = none`) and NEVER deletes +/// the body — GC decrements the precommit's blob edges and deletes the body only after the decrement is +/// sealed. Writer-deleting a live precommit body strands GC's fold barrier (live precommit, missing body +/// → clamp forever) or loses the activating +1. +TEST(CASPartWriteTxn, AbandonAppendsPrecommitRemovalAndKeepsLivePrecommitBody) +{ + auto b = std::make_shared(); + auto s = openPool(b); + const RootNamespace ns{"srv1/tbl"}; + auto build = startBuildFor(s, ns, "part_1"); + + build->putBlob(idOf("kept"), BlobSource::fromString("kept")); + const ManifestId mid = build->stageManifest({blobManifestEntry("data.bin", "kept")}); + const String manifest_key = s->layout().manifestKey(mid); + const UInt128 abandoned_build_id = build->buildId(); + build->precommitAdd(ns, "part_1", mid); + + /// The precommit manifest body is present before abandon. + ASSERT_TRUE(b->head(manifest_key).exists); + + build->abandon(); + + /// (a) the LIVE precommit body must SURVIVE abandon (left for GC after the sealed decrement). + EXPECT_TRUE(b->head(manifest_key).exists) + << "abandon must NOT writer-delete a live precommit body (delete-after-sealed-decrements)"; + + /// (b) the exact precommit binding is gone (spec §Remove Precommit: an exact owner_transition + /// removal, old=Precommit new=none). Proven black-box: a FRESH precommitAdd for the ref must + /// succeed -- if abandon had failed to remove the exact binding, this would instead throw + /// CORRUPTED_DATA ("add precommit ... already exists"). The probe manifest must be freshly staged + /// BY `rebuild` itself rather than re-precommitting `mid` (which `build`, a different transaction, + /// staged): A3 mint-tightening now refuses an unowned `ManifestId` from any transaction other than + /// the one that minted it, regardless of whether abandon's removal landed, so re-using `mid` here + /// would no longer distinguish the property under test. Content identity is irrelevant to the + /// ref-level owner-slot check this proves, so a fresh manifest is just as conclusive a probe. + (void)abandoned_build_id; + auto rebuild = startBuildFor(s, ns, "part_1"); + const ManifestId rebuild_mid = rebuild->stageManifest({blobManifestEntry("data.bin", "kept")}); + EXPECT_NO_THROW(rebuild->precommitAdd(ns, "part_1", rebuild_mid)); +} + +/// BUG 2 regression for the never-precommitted path: a manifest that was STAGED but never precommitted is +/// still best-effort writer-deleted by abandon (pre-precommit debris) — only a LIVE precommit body is +/// spared. Confirms the fix narrows the skip to the precommitted manifest exactly. +TEST(CASPartWriteTxn, AbandonStillDeletesNeverPrecommittedStagedDebris) +{ + auto b = std::make_shared(); + auto s = openPool(b); + const RootNamespace ns{"srv1/tbl"}; + auto build = startBuildFor(s, ns, "part_1"); + + build->putBlob(idOf("kept"), BlobSource::fromString("kept")); + /// Two staged manifests: one becomes the precommit, the other is pure pre-precommit debris. + const ManifestId debris = build->stageManifest({blobManifestEntry("debris.bin", "kept")}); + const ManifestId precommitted = build->stageManifest({blobManifestEntry("data.bin", "kept")}); + build->precommitAdd(ns, "part_1", precommitted); + + build->abandon(); + + /// The never-precommitted debris is best-effort deleted; the live precommit body survives. + EXPECT_FALSE(b->head(s->layout().manifestKey(debris)).exists) + << "never-precommitted staged debris must still be best-effort deleted by abandon"; + EXPECT_TRUE(b->head(s->layout().manifestKey(precommitted)).exists) + << "the live precommit body must be spared"; +} + +/// Task 6 (review finding 2): `abandon()`'s three audit `EventEmitter{*store}.emit(...)` calls are each +/// wrapped `try { ... } catch (...) { tryLogCurrentException(...); }`, mirroring `promote`'s own +/// post-durable emit guard -- a throwing sink (e.g. a bad_alloc growing the system-log queue, or a +/// Context/log-shutdown edge) must never turn an otherwise-successful abandon into a reported failure. +TEST(CASPartWriteTxn, AbandonSwallowsThrowingEventSink) +{ + auto b = std::make_shared(); + auto s = openPool(b); + const RootNamespace ns{"srv1/tbl_abandon_sink"}; + auto build = startBuildFor(s, ns, "part_1"); + + build->putBlob(idOf("kept"), BlobSource::fromString("kept")); + const ManifestId mid = build->stageManifest({blobManifestEntry("data.bin", "kept")}); + build->precommitAdd(ns, "part_1", mid); + + /// UNKNOWN_EXCEPTION (not LOGICAL_ERROR): mirrors `PromoteSwallowsPostDurableEventSinkFailure` + /// above -- this simulates an arbitrary observer/sink callback failing, not a CAS invariant + /// violation. LOGICAL_ERROR would abort the whole process under debug/sanitizer builds instead of + /// behaving like a catchable exception. + s->setEventSink([](const CasEvent &) + { + throw DB::Exception(DB::ErrorCodes::UNKNOWN_EXCEPTION, "injected event sink failure"); + }); + + EXPECT_NO_THROW(build->abandon()); + s->setEventSink(nullptr); + + /// The precommit binding is gone despite the sink failure -- proven black-box exactly like + /// `AbandonAppendsPrecommitRemovalAndKeepsLivePrecommitBody` above: a FRESH precommitAdd for the + /// ref must succeed (it would instead throw CORRUPTED_DATA "add precommit ... already exists" had + /// the throwing sink aborted the removal). The probe manifest is freshly staged BY `rebuild` + /// itself, not `mid` (staged by `build`, a different transaction) -- A3 mint-tightening now refuses + /// a foreign id unconditionally, so re-using `mid` would no longer isolate the property under test. + auto rebuild = startBuildFor(s, ns, "part_1"); + const ManifestId rebuild_mid = rebuild->stageManifest({blobManifestEntry("data.bin", "kept")}); + EXPECT_NO_THROW(rebuild->precommitAdd(ns, "part_1", rebuild_mid)); +} + +namespace +{ + +/// Forces the SINGLE ref-log ('_log/' key) PUT that `abandon()`'s precommit-removal `appendRefOps` +/// issues to observe a PROVEN conflict instead of a genuine ambiguity. Mirrors +/// `RefWriterTestBackend::corrupt_key_substr` (gtest_cas_ref_writer.cpp, reproduced locally because +/// that class lives in a different translation unit): landing a DIFFERENT object at the intended key +/// makes `putIfAbsentControlled`'s resolve-before-reissue observe a real conflict and throw +/// CORRUPTED_DATA -- a CONCLUSIVE rejection ("do NOT wedge: the cache is unchanged and nothing of ours +/// is durable", CasRefLedger.cpp's `commitRefChunk`), unlike a genuinely-ambiguous timeout, which would +/// instead WEDGE the whole table's append lane (`rt->append_attempt`) until the SAME key resolves durable -- a +/// state a one-shot fault can never itself clear, since wedge resolution only re-GETs the intended key +/// and never re-PUTs it (proven by +/// `CASRefWriterAppendLane.WedgedLaneBlocksSameTableWhileOtherTableProceeds`). A conflict leaves the cached +/// ref-table state untouched, so the SAME logical retry reaches its append again -- and under INV-1 that +/// retry re-derives the SAME id from that unchanged state, so it meets the same occupant rather than +/// carving a fresh id around it. +class RefLogConflictOnceBackend final : public InMemoryBackend +{ +public: + String corrupt_key_substr; + int corrupt_count = 0; + + PutResult putIfAbsent(const String & key, const String & bytes, const ObjectMeta & meta) override + { + if (corrupt_count > 0 && !corrupt_key_substr.empty() && key.find(corrupt_key_substr) != String::npos) + { + --corrupt_count; + /// The 3-arg qualified call bypasses virtual dispatch entirely (unlike the 2-arg + /// convenience overload, which would re-enter this very override through the vtable). + InMemoryBackend::putIfAbsent(key, bytes + String("\x01_FOREIGN_DIFFERENT"), meta); + throw Poco::TimeoutException("RefLogConflictOnceBackend: a foreign different object landed; response lost"); + } + return InMemoryBackend::putIfAbsent(key, bytes, meta); + } +}; + +} + +/// Task 6 (review finding 2): `alive` now flips to false only AFTER the correctness-bearing precommit +/// removal's `appendRefOps` succeeds, so a caller that catches an append failure can retry `abandon()` +/// on the SAME object. Before the fix, `alive = false` ran unconditionally before that append, so a +/// retry would hit `requireAlive`'s "has been abandoned" LOGICAL_ERROR instead. +TEST(CASPartWriteTxn, AbandonRetryableAfterAppendFailure) +{ + auto b = std::make_shared(); + auto s = Pool::open(b, PoolConfig{.pool_prefix = "p", .server_root_id = "test"}); + const RootNamespace ns{"srv1/tbl_abandon_retry"}; + /// Stage B (Task 4-C): pin `ns`'s real incarnation to the Stage-A sentinel BEFORE the first real + /// append, so the corruption injected below (at a key computed from that sentinel) actually lands + /// on the path production writes to -- otherwise `precommitAdd` mints an unrelated random + /// incarnation and the corruption below misses it entirely. + DB::Cas::tests::casAdmitRecoverableEntry(*b, s->layout(), ns, s->liveWriterEpoch()); + auto build = startBuildFor(s, ns, "part_1"); + + build->putBlob(idOf("kept"), BlobSource::fromString("kept")); + const ManifestId mid = build->stageManifest({blobManifestEntry("data.bin", "kept")}); + build->precommitAdd(ns, "part_1", mid); + + b->corrupt_key_substr = s->layout().namespaceStreamPrefix(DB::Cas::tests::fixture::fixtureLife(ns)) + "_log/"; + b->corrupt_count = 1; + + /// First abandon(): the precommit-removal appendRefOps' single PUT observes a foreign object at its + /// exact key (a proven conflict) -> CORRUPTED_DATA propagates. + expectThrowsCode(DB::ErrorCodes::CORRUPTED_DATA, [&] { build->abandon(); }); + + /// The proven conflict fences the mount closed and schedules a remount (the append site routes + /// through the anomaly policy exactly as the wedge-resolve site does). Re-arming only the test + /// fence does not replace this runtime, so its immutable admitted generation remains stale and its + /// terminal `Faulted` lane remains blocked behind that outer refusal. + DB::Cas::tests::rearmMountFenceAfterAnomalyForTest(s); + + /// The retryability under test: the SAME object accepts a second abandon() -- `alive` was not + /// flipped by the failed append, so this is not the "has been abandoned" condition the unfixed code + /// produced. It reaches immutable-runtime admission and is refused by the stale generation; only a + /// real remount may replace that runtime and reach a fresh lane. + expectThrowsCode(DB::ErrorCodes::NETWORK_ERROR, [&] { build->abandon(); }); + + /// The removal never landed, and nothing was written around the occupant: the precommit binding this + /// build owns is still live, exactly where the failed abandon left it. That is the honest end state + /// under the fail-closed contract -- the old proof (a fresh `precommitAdd` for the same ref + /// succeeding, which showed the binding gone) needed one more successful append on a table that can + /// no longer take one. + String greatest_key; + size_t foreign_objects = 0; + for (String cursor;;) + { + const ListPage page = b->list(b->corrupt_key_substr, cursor, 1000); + for (const auto & listed : page.keys) + { + if (listed.key > greatest_key) + greatest_key = listed.key; + const auto body = b->get(listed.key); + if (body && body->bytes.find("_FOREIGN_DIFFERENT") != String::npos) + ++foreign_objects; + } + if (page.next_cursor.empty()) + break; + cursor = page.next_cursor; + } + EXPECT_EQ(foreign_objects, 1u) << "the foreign object must still own the key it took"; + ASSERT_FALSE(greatest_key.empty()); + const auto greatest_body = b->get(greatest_key); + ASSERT_TRUE(greatest_body.has_value()); + EXPECT_NE(greatest_body->bytes.find("_FOREIGN_DIFFERENT"), String::npos) + << "the foreign occupant must still be the highest id in this table's stream: a log object above " + "it would mean an append carved a fresh id around the damage instead of failing closed"; +} + +/// ------------------------------------------------------------------------------------------------ +/// OQ7 manifest-cap fail-close (S07): the scenario suite tried to reach `stageManifest`'s encoded-bytes +/// cap through a wide-column SQL `INSERT`, but the cap sits 3+ orders of magnitude above what dev SQL +/// can reach in reasonable time (confirmed: even a 20000-column full-scale insert cannot get there). So +/// this P0 safety path is not scenario-testable and is exercised directly here instead. +/// ------------------------------------------------------------------------------------------------ + +namespace +{ + +/// Mirrors `CasPartWriteTxn.cpp`'s private `kMaxManifestEncodedBytes` (256 MiB). There is no way to read a +/// file-local `constexpr` from a different translation unit, so this is kept in sync by hand — if that +/// cap ever changes, update this one to match. +constexpr uint64_t kExpectedManifestEncodedCap = 256ULL << 20; + +/// The exact encoded size `PartWriteTxn::stageManifest` would compute for a single Blob-placement entry whose +/// path is `path_len` bytes long, staged under `ns` — measured through the SAME `encodePartManifest` +/// codec `stageManifest` calls, so this is an exact reproduction rather than a hand-derived estimate. +/// `ref` and `payload_digest` are fixed-width fields (20 and 16 bytes respectively): their VALUES don't +/// affect the encoded size, only their presence does, so the zero-valued placeholders here reproduce +/// the exact same byte count `stageManifest` would produce with its real (non-zero) values. +size_t manifestEncodedSizeForPathLen(const RootNamespace & ns, size_t path_len) +{ + PartManifest probe; + probe.root_namespace_id = ns; + ManifestEntry e; + e.path = String(path_len, 'a'); + e.placement = EntryPlacement::Blob; + e.ref = DB::Cas::BlobRef{DB::Cas::BlobHashAlgo::CityHash128, DB::Cas::BlobDigest::fromU128(UInt128{})}; + + e.blob_size = 12345; + probe.entries = {std::move(e)}; + return encodePartManifest(probe).size(); +} + +/// Finds the exact boundary: the SMALLEST `path_len` whose single-entry manifest encodes to MORE than +/// `kExpectedManifestEncodedCap` bytes under `ns`. `path_len - 1` therefore encodes to AT MOST the cap +/// (the encoding is monotonic in `path_len` — a longer path can only grow the encoded size). Starts +/// from a linear estimate (the encoding is affine in `path_len`: fixed framing overhead plus a constant +/// number of bytes per path byte) and walks to the exact crossing, so this stays correct even if the +/// framing overhead changes, without needing a full binary search over a ~256 MiB range. +size_t findManifestEncodedCapBoundaryPathLen(const RootNamespace & ns) +{ + constexpr size_t probe_lo = 1000; + constexpr size_t probe_hi = 2'000'000; + const size_t size_lo = manifestEncodedSizeForPathLen(ns, probe_lo); + const size_t size_hi = manifestEncodedSizeForPathLen(ns, probe_hi); + const double slope = static_cast(size_hi - size_lo) / static_cast(probe_hi - probe_lo); + const double intercept = static_cast(size_lo) - slope * static_cast(probe_lo); + + size_t path_len = static_cast(std::ceil( + (static_cast(kExpectedManifestEncodedCap) - intercept) / slope)) + 1; + + while (manifestEncodedSizeForPathLen(ns, path_len) <= kExpectedManifestEncodedCap) + ++path_len; + while (path_len > 1 && manifestEncodedSizeForPathLen(ns, path_len - 1) > kExpectedManifestEncodedCap) + --path_len; + return path_len; +} + +/// A one-entry Blob ManifestEntry with a synthetic `path_len`-byte path (used only to inflate the +/// encoded manifest size towards the OQ7 cap). +ManifestEntry wideBlobManifestEntry(size_t path_len) +{ + ManifestEntry e; + e.path = String(path_len, 'a'); + e.placement = EntryPlacement::Blob; + e.ref = DB::Cas::BlobRef{DB::Cas::BlobHashAlgo::CityHash128, DB::Cas::BlobDigest::fromU128(UInt128{0x42})}; + + e.blob_size = 12345; + return e; +} + +} + +/// Boundary case 1/2: a manifest whose encoded size is the LARGEST that still fits under the cap stages +/// successfully. Proves the cap enforcement isn't overly conservative — a real just-under-the-limit +/// manifest is not mistakenly rejected. +TEST(CASPartWriteTxn, ManifestCapEncodedBytesJustUnderStagesSuccessfully) +{ + auto b = std::make_shared(); + /// A frozen boot_ms_fn (not the shared openPool helper): this test's manifest sits just under the + /// 256 MiB cap, so encodePartManifest/sealObject do real, sizeable CPU work before the single + /// InMemoryBackend put (which always succeeds deterministically, no faults). Under heavy + /// instrumentation (TSan) that encode+seal step alone can take long enough in real wall-clock time + /// to cross the mount lease's fence margin (CasMountRuntime::refAppendFenceOk) and the CAS request + /// controller's own deadline (both consult the SAME injected clock, CasRefLedger.cpp) before the + /// attempt even resolves -- a sanitizer-speed artifact unrelated to what this test verifies. Freezing + /// the clock decouples the outcome from real execution speed: the single attempt now succeeds or + /// fails purely on the backend's own (deterministic) behavior, on any build. + auto s = Pool::open(b, PoolConfig{.pool_prefix = "p", .server_root_id = "test", .boot_ms_fn = [] { return uint64_t{0}; }}); + const RootNamespace ns{"srv1/tbl"}; + + const size_t path_len_over = findManifestEncodedCapBoundaryPathLen(ns); + ASSERT_GT(path_len_over, 1u); + const size_t path_len_under = path_len_over - 1; + ASSERT_LE(manifestEncodedSizeForPathLen(ns, path_len_under), kExpectedManifestEncodedCap); + + auto build = startBuildFor(s, ns, "wide_part"); + const ManifestId id = build->stageManifest({wideBlobManifestEntry(path_len_under)}); + EXPECT_EQ(id.root_namespace, ns); + EXPECT_TRUE(b->head(s->layout().manifestKey(id)).exists) + << "a just-under-cap manifest must actually be written"; +} + +/// Boundary case 2/2: a manifest whose encoded size exceeds the cap by the smallest possible margin +/// (one more path byte than the passing case above) throws `LIMIT_EXCEEDED` fail-closed, BEFORE the body +/// write — no manifest object lands in the backend for the rejected attempt. +TEST(CASPartWriteTxn, ManifestCapEncodedBytesOverThrowsBeforeBodyWrite) +{ + auto b = std::make_shared(); + auto s = openPool(b); + const RootNamespace ns{"srv1/tbl"}; + + const size_t path_len_over = findManifestEncodedCapBoundaryPathLen(ns); + ASSERT_GT(manifestEncodedSizeForPathLen(ns, path_len_over), kExpectedManifestEncodedCap); + + auto build = startBuildFor(s, ns, "wide_part"); + + const size_t keys_before = b->list("", "", 100).keys.size(); + bool threw = false; + try + { + build->stageManifest({wideBlobManifestEntry(path_len_over)}); + } + catch (const DB::Exception & e) + { + threw = true; + EXPECT_EQ(e.code(), DB::ErrorCodes::LIMIT_EXCEEDED); + EXPECT_NE(e.message().find("exceeds cap"), String::npos) << e.message(); + } + EXPECT_TRUE(threw) << "an over-cap manifest must throw, not silently truncate or accept"; + + /// Fail-closed BEFORE the body write: the over-cap attempt must not have created ANY new object + /// (no partial state, no orphaned blob/manifest debris for a manifest that was never accepted). + const size_t keys_after = b->list("", "", 100).keys.size(); + EXPECT_EQ(keys_before, keys_after) + << "stageManifest must fail closed before writing the manifest body, leaving no new objects"; +} + +/// spec §9.9 (mixed-algo pools, Phase 3 T2) — the W-DEP-SET cross-satisfaction crux: a manifest with +/// two entries carrying the SAME digest VALUE under TWO DIFFERENT algos (`ch128:X` / `xxh3:X`). Only +/// `ch128:X`'s body is ever putBlob'd; `xxh3:X`'s body never lands anywhere. Promote MUST fail closed — +/// the tokened `ch128:X` dep must NEVER be read as satisfying the non-tokened `xxh3:X` leaf. +/// This test is RED (wrongly passes / silently promotes) if `PartWriteTxn::deps` (the W-DEP-SET) were keyed on +/// a bare digest instead of the full `BlobRef` pair: both entries would collapse to the SAME map key +/// (the digest alone), so `depIsTokened` would report the xxh3 leaf as edge-protected via the ch128 +/// entry's putBlob and promote would skip its revalidation (and hence its absence) entirely. +TEST(CASPartWriteTxn, WDepSetCrossAlgoSatisfactionFailsClosed) +{ + auto b = std::make_shared(); + auto s = openPool(b); + const RootNamespace ns{"srv/tbl"}; + + const BlobDigest shared_digest = BlobDigest::fromU128(u128Of("shared-digest-value")); + + ManifestEntry e_ch128; + e_ch128.path = "a.bin"; + e_ch128.placement = EntryPlacement::Blob; + e_ch128.ref = BlobRef{BlobHashAlgo::CityHash128, shared_digest}; + e_ch128.blob_size = 3; + + ManifestEntry e_xxh3; + e_xxh3.path = "b.bin"; + e_xxh3.placement = EntryPlacement::Blob; + e_xxh3.ref = BlobRef{BlobHashAlgo::XXH3_128, shared_digest}; /// SAME digest bytes, DIFFERENT algo + e_xxh3.blob_size = 3; + + auto build = startBuildFor(s, ns, "part_mixed"); + const ManifestId id = build->stageManifest({e_ch128, e_xxh3}); + build->precommitAdd(ns, "part_mixed", id); + + /// Only the ch128 leaf's body is ever uploaded — its BlobId hex is the digest at the ch128 width, + /// which addresses EXACTLY `e_ch128`'s object key (`blobs/ch128/...`), a DISTINCT key from + /// `e_xxh3`'s (`blobs/xxh3/...`), even though the raw digest bytes are identical. + build->putBlob(BlobRef{BlobHashAlgo::CityHash128, shared_digest}, BlobSource::fromString("abc")); + + /// promote must fail closed: the xxh3:X leaf has NO tokened dep and NO adopted dep — never silently + /// satisfied by the ch128:X entry's tokened dep (same digest bytes, distinct object key). §4 + /// manifest-trust: an unsatisfied leaf is caught by the dep set (isTrustedAdopt false, not tokened) and + /// fails closed with LOGICAL_ERROR — a staging bug — without any backend probe on the xxh3 key. + EXPECT_DEATH( + { + DB::abort_on_logical_error.store(true, std::memory_order_relaxed); + build->promote(ns, "part_mixed", build->buildId(), id); + }, + "no tokened and no adopted dep"); + + /// No committed ref appears — the promote aborted before installing one. + EXPECT_FALSE(s->resolveRef(ns, "part_mixed").has_value()); +} + +/// ===================================================================================== +/// Task B (chaos-tolerance-report §Task B): stageManifest's part-manifest conditional PUT rides the +/// shared CasRequestController — budgeted attempts + resolve-before-reissue — instead of the old +/// single bare attempt (which a 19s object-store pause killed while every read path survived). +/// ===================================================================================== + +namespace +{ + +/// Faults the part-manifest body PUT (`/cas/manifests/` keys) with an ambiguous +/// (Unresolved-classified) timeout a bounded number of times, mirroring RefWriterTestBackend's fault +/// seam (gtest_cas_ref_writer.cpp). Covers BOTH write primitives — `putIfAbsent` (the controller +/// path) and `putIfAbsentStream` (the pre-controller path) — with ONE shared `fault_count` and the +/// same land/plant side effects, so the assertions are flip-proof against either implementation of +/// the stage write. +class ManifestPutFaultBackend final : public InMemoryBackend +{ +public: + int fault_count = 0; /// remaining ambiguous faults on matching body PUTs + bool land_despite_fault = false; /// the faulted attempt's own write actually lands (response lost) + String plant_different_on_fault; /// a FOREIGN different body lands at the key before the fault + int put_attempts = 0; /// matching body-PUT attempts observed (both primitives) + + PutResult putIfAbsent(const String & key, const String & bytes, const ObjectMeta & meta) override + { + if (!isManifestBodyKey(key)) + return InMemoryBackend::putIfAbsent(key, bytes, meta); + ++put_attempts; + maybeFault(key, bytes); + return InMemoryBackend::putIfAbsent(key, bytes, meta); + } + + WriteSinkPtr putIfAbsentStream(const String & key, const ObjectMeta & meta) override + { + if (!isManifestBodyKey(key)) + return InMemoryBackend::putIfAbsentStream(key, meta); + + /// Counts/faults at finalize — the moment the old single-attempt path observed its timeout. + struct CountingOrFaultingSink final : WriteSink + { + ManifestPutFaultBackend & parent; + String key; + ObjectMeta meta; + DB::WriteBufferFromOwnString buf; + + CountingOrFaultingSink(ManifestPutFaultBackend & parent_, String key_, ObjectMeta meta_) + : parent(parent_), key(std::move(key_)), meta(std::move(meta_)) {} + + DB::WriteBuffer & buffer() override { return buf; } + PutResult finalize() override + { + ++parent.put_attempts; + const String & bytes = buf.str(); + parent.maybeFault(key, bytes); + return parent.InMemoryBackend::putIfAbsent(key, bytes, meta); + } + void cancel() noexcept override {} + }; + return std::make_unique(*this, key, meta); + } + +private: + static bool isManifestBodyKey(const String & key) { return key.find("/cas/manifests/") != String::npos; } + + /// One fault: apply the configured server-side effect, then lose the response. + void maybeFault(const String & key, const String & bytes) + { + if (fault_count <= 0) + return; + --fault_count; + if (!plant_different_on_fault.empty()) + InMemoryBackend::putIfAbsent(key, plant_different_on_fault, {}); + else if (land_despite_fault) + InMemoryBackend::putIfAbsent(key, bytes, {}); + throw Poco::TimeoutException("ManifestPutFaultBackend: simulated ambiguous result (response lost)"); + } +}; + +} + +/// The Task B core: two consecutive ambiguous timeouts on the part-manifest body PUT (each resolved +/// to "absent" by the controller's exact-GET), then a clean third attempt. The old single-attempt +/// path fails the whole stage on the FIRST timeout (the observed 19s-pause INSERT kill); the +/// controller path must ride its attempt budget and succeed. +TEST(CASPartWriteTxnStageManifestRetry, AmbiguousTimeoutsThenCommitSucceedsWithinBudget) +{ + /// Zero backoff: the retry semantics are under test here, not the (controller-level-tested) + /// inter-attempt sleep schedule — keep the suite free of real sleeps. + CasRequestBudget budget; + budget.retry_initial_backoff_ms = 0; + auto b = std::make_shared(); + auto s = Pool::open(b, PoolConfig{.pool_prefix = "p", .server_root_id = "test", .cas_request_budget = budget}); + const RootNamespace ns{"srv/tbl"}; + + auto build = startBuildFor(s, ns, "part_retry"); + b->fault_count = 2; + const ManifestId id = build->stageManifest({blobManifestEntry("a.bin", "a")}); + + EXPECT_EQ(b->put_attempts, 3) << "two faulted attempts + the committing third"; + const auto got = b->get(s->layout().manifestKey(id)); + ASSERT_TRUE(got.has_value()) << "the staged manifest body must be durable"; + EXPECT_EQ(decodePartManifest(openObject(FormatId::PartManifest, got->bytes)).ref, id.ref); +} + +/// Ambiguous-but-landed: the FIRST attempt's response is lost AFTER the write actually landed +/// server-side. Resolve-before-reissue's exact-GET observes the identical bytes and reports +/// Committed — the stage succeeds WITHOUT a reissue (no duplicate PUT of the object), and the +/// `ManifestPut` audit event carries the landed incarnation's token (from the resolve GET). +TEST(CASPartWriteTxnStageManifestRetry, AmbiguousLandedWriteResolvesToCommittedWithoutReissue) +{ + auto b = std::make_shared(); + /// The sink target must outlive the Pool: `~Pool` emits terminate events into the sink. + std::vector events; + auto s = openPool(b); + const RootNamespace ns{"srv/tbl"}; + + s->setEventSink([&](const CasEvent & e) { events.push_back(e); }); + + auto build = startBuildFor(s, ns, "part_landed"); + b->fault_count = 1; + b->land_despite_fault = true; + const ManifestId id = build->stageManifest({blobManifestEntry("a.bin", "a")}); + + EXPECT_EQ(b->put_attempts, 1) << "a landed ambiguous attempt must be resolved, never reissued"; + const String key = s->layout().manifestKey(id); + ASSERT_TRUE(b->get(key).has_value()); + + const auto ev = std::find_if(events.begin(), events.end(), + [](const CasEvent & e) { return e.type == CasEventType::ManifestPut; }); + ASSERT_NE(ev, events.end()) << "the stage must still emit its ManifestPut audit event"; + EXPECT_EQ(ev->token, b->head(key).token.value) + << "the audit token must be the landed incarnation's token"; +} + +/// A DIFFERENT object at the exact staged key (a foreign body ahead of our ambiguous attempt) is a +/// proven conflict — the NoManifestIdReuse invariant broke — and must stay the loud CORRUPTED_DATA +/// class: never a retry signal, never silently adopted. +TEST(CASPartWriteTxnStageManifestRetry, DifferentObjectAtKeyStaysLoudConflict) +{ + auto b = std::make_shared(); + auto s = openPool(b); + const RootNamespace ns{"srv/tbl"}; + + auto build = startBuildFor(s, ns, "part_conflict"); + b->fault_count = 1; + b->plant_different_on_fault = "a-foreign-different-manifest-body"; + expectThrowsCode(DB::ErrorCodes::CORRUPTED_DATA, [&] + { + build->stageManifest({blobManifestEntry("a.bin", "a")}); + }); + EXPECT_EQ(b->put_attempts, 1) << "a proven conflict is never retried"; +} + +/// Budget exhaustion: EVERY attempt is ambiguous and nothing ever lands. The controller reports +/// Unresolved after `max_attempts` and stageManifest maps it to NETWORK_ERROR (fix #37 phase 2) — +/// the same retryable abort class the ref-log lane's exhausted budget maps to. Nothing was durably +/// named: the caller re-stages with a fresh ManifestId. +TEST(CASPartWriteTxnStageManifestRetry, BudgetExhaustionMapsToNetworkError) +{ + CasRequestBudget budget; + budget.max_attempts = 3; + budget.retry_initial_backoff_ms = 0; /// no real sleeps; the backoff schedule has its own tests + auto b = std::make_shared(); + auto s = Pool::open(b, PoolConfig{.pool_prefix = "p", .server_root_id = "test", .cas_request_budget = budget}); + const RootNamespace ns{"srv/tbl"}; + + auto build = startBuildFor(s, ns, "part_exhausted"); + b->fault_count = 1000; + expectThrowsCode(DB::ErrorCodes::NETWORK_ERROR, [&] + { + build->stageManifest({blobManifestEntry("a.bin", "a")}); + }); + EXPECT_EQ(b->put_attempts, 3) << "attempts must be bounded by the configured budget"; +} + +/// ===================================================================================== +/// Task B follow-up (availfix): the two conditional-create paths that still bypassed the request +/// controller — the BLOB body `putIfAbsentStream` create and `promoteStaged`'s conditional +/// server-side copy (both issued inside `PartWriteTxn::uploadFromSource`'s streamIfAbsent) — now ride the +/// same budgeted-attempts machinery. Reissue re-streams from the writer's REPLAYABLE source +/// (`BlobSource::open` re-reads the staged temp file / re-issues the copy from the intact +/// staging object — INV-1, never a GET-revive); ambiguity resolves by exact-key OCCUPANCY (the key +/// embeds the content hash, so any occupant IS the intended content — the same trust model as the +/// plain 412-adopt path). +/// ===================================================================================== + +namespace +{ + +/// Faults blob-body conditional creates — BOTH primitives `uploadFromSource` can issue: the streaming +/// `putIfAbsentStream` (local staging) and `promoteStaged`'s conditional server-side copy (S3-native +/// staging) — with an ambiguous (Unresolved-classified) timeout a bounded number of times, mirroring +/// ManifestPutFaultBackend above. Blob META writes (`.meta` keys, plain putIfAbsent) are never faulted. +class BlobPutFaultBackend final : public InMemoryBackend +{ +public: + int fault_count = 0; /// remaining ambiguous faults on matching create attempts + bool land_despite_fault = false; /// the faulted attempt's own write actually lands (response lost) + int stream_attempts = 0; /// blob-body streaming-PUT finalize attempts observed + int copy_attempts = 0; /// promoteStaged conditional-copy attempts observed + + WriteSinkPtr putIfAbsentStream(const String & key, const ObjectMeta & meta) override + { + if (!isBlobBodyKey(key)) + return InMemoryBackend::putIfAbsentStream(key, meta); + + /// Counts/faults at finalize — the moment the old single-attempt path observed its timeout. + struct CountingOrFaultingSink final : WriteSink + { + BlobPutFaultBackend & parent; + String key; + ObjectMeta meta; + DB::WriteBufferFromOwnString buf; + + CountingOrFaultingSink(BlobPutFaultBackend & parent_, String key_, ObjectMeta meta_) + : parent(parent_), key(std::move(key_)), meta(std::move(meta_)) {} + + DB::WriteBuffer & buffer() override { return buf; } + PutResult finalize() override + { + ++parent.stream_attempts; + const String & bytes = buf.str(); + parent.maybeFault(key, bytes); + return parent.InMemoryBackend::putIfAbsent(key, bytes, meta); + } + void cancel() noexcept override {} + }; + return std::make_unique(*this, key, meta); + } + + PutResult promoteStaged(const String & staging_key, const String & blob_key) override + { + ++copy_attempts; + if (fault_count > 0) + { + --fault_count; + if (land_despite_fault) + InMemoryBackend::promoteStaged(staging_key, blob_key); + throw Poco::TimeoutException("BlobPutFaultBackend: simulated ambiguous copy (response lost)"); + } + return InMemoryBackend::promoteStaged(staging_key, blob_key); + } + +private: + static bool isBlobBodyKey(const String & key) + { + return key.find("/blobs/") != String::npos && !key.ends_with(".meta"); + } + + /// One fault: apply the configured server-side effect, then lose the response. + void maybeFault(const String & key, const String & bytes) + { + if (fault_count <= 0) + return; + --fault_count; + if (land_despite_fault) + InMemoryBackend::putIfAbsent(key, bytes, {}); + throw Poco::TimeoutException("BlobPutFaultBackend: simulated ambiguous result (response lost)"); + } +}; + +/// Zero-backoff store over a BlobPutFaultBackend: the sleep schedule has its own controller-level +/// tests; these Pool-level tests pin the retry/resolve/abort semantics without real sleeps. +PoolPtr openBlobFaultPool(const std::shared_ptr & b, uint32_t max_attempts = CasRequestBudget{}.max_attempts) +{ + CasRequestBudget budget; + budget.max_attempts = max_attempts; + budget.retry_initial_backoff_ms = 0; + return Pool::open(b, PoolConfig{.pool_prefix = "p", .server_root_id = "test", .cas_request_budget = budget}); +} + +/// A replayable BlobSource that COUNTS its own re-streams — pins INV-1's "retry = fresh re-stream +/// from the writer's own source" (never a GET of the dying/failed object). +BlobSource countingSource(const String & payload, int & payload_streams) +{ + BlobSource source; + source.size = payload.size(); + source.open = [payload, &payload_streams]() -> std::unique_ptr + { + ++payload_streams; + return std::make_unique(payload); + }; + return source; +} + +} + +/// The core ride: two consecutive ambiguous timeouts on the blob-body streaming PUT (each resolved +/// "absent" by the controller's occupancy HEAD), then a clean third attempt. The old single-attempt +/// path failed the whole INSERT on the FIRST timeout (the raw Poco::TimeoutException escaped +/// putBlob); the controller path rides its budget, RE-STREAMING the payload from the writer's own +/// replayable source on every attempt. +TEST(CASPartWriteTxnBlobPutRetry, AmbiguousTimeoutsThenCommitRestreamsFromSource) +{ + auto b = std::make_shared(); + auto s = openBlobFaultPool(b); + const RootNamespace ns{"srv/tbl"}; + const String payload = "blob-payload-A"; + + auto build = startBuildFor(s, ns, "part_blob_retry"); + const ManifestId id = build->stageManifest({blobManifestEntry("a.bin", payload)}); + build->precommitAdd(ns, "part_blob_retry", id); + + int payload_streams = 0; + b->fault_count = 2; + const PutBlobResult res = build->putBlob(idOf(payload), countingSource(payload, payload_streams)); + EXPECT_EQ(res.size, payload.size()); + + EXPECT_EQ(b->stream_attempts, 3) << "two faulted attempts + the committing third"; + EXPECT_EQ(payload_streams, 3) << "every reissue must RE-STREAM from the writer's own source (INV-1)"; + EXPECT_TRUE(b->head(s->layout().blobKey(idOf(payload))).exists) << "the blob body must be durable"; +} + +/// Ambiguous-but-landed: the FIRST attempt's response is lost AFTER the write actually landed +/// server-side. The occupancy resolve observes the key present and the existing 412 machinery takes +/// over — the occupant is ADOPTED (content-addressed identity: any occupant of this key IS the +/// content), with NO reissue and NO second body upload. +TEST(CASPartWriteTxnBlobPutRetry, AmbiguousLandedWriteAdoptsOccupantWithoutReupload) +{ + auto b = std::make_shared(); + /// The sink target must outlive the Pool: `~Pool` emits terminate events into the sink. + std::vector events; + auto s = openBlobFaultPool(b); + const RootNamespace ns{"srv/tbl"}; + const String payload = "blob-payload-B"; + + s->setEventSink([&](const CasEvent & e) { events.push_back(e); }); + + auto build = startBuildFor(s, ns, "part_blob_landed"); + const ManifestId id = build->stageManifest({blobManifestEntry("a.bin", payload)}); + build->precommitAdd(ns, "part_blob_landed", id); + + int payload_streams = 0; + b->fault_count = 1; + b->land_despite_fault = true; + const PutBlobResult res = build->putBlob(idOf(payload), countingSource(payload, payload_streams)); + EXPECT_EQ(res.size, payload.size()); + + EXPECT_EQ(b->stream_attempts, 1) << "a landed ambiguous attempt must be resolved, never reissued"; + EXPECT_EQ(payload_streams, 1); + + const String key = s->layout().blobKey(idOf(payload)); + const auto adopt = std::find_if(events.begin(), events.end(), + [](const CasEvent & e) { return e.type == CasEventType::BlobReuseAdopt; }); + ASSERT_NE(adopt, events.end()) << "the landed occupant must be ADOPTED (the standard dedup leg)"; + EXPECT_EQ(adopt->token, b->head(key).token.value) << "the adopted token must be the landed incarnation's"; + EXPECT_EQ(std::count_if(events.begin(), events.end(), + [](const CasEvent & e) { return e.type == CasEventType::BlobPut; }), 0) + << "no fresh-upload event: the body was never re-uploaded"; +} + +/// Budget exhaustion: EVERY attempt is ambiguous and nothing ever lands. The controller reports the +/// uncertainty and uploadFromSource maps it to NETWORK_ERROR (fix #37 phase 2) -- the same retryable +/// abort class stageManifest and the ref-log lane map their exhausted budgets to. Unlike the OLD +/// ABORTED mapping, putBlob's bounded condemned-churn loop (8 rounds) does NOT re-drive this: it only +/// catches ABORTED, so a NETWORK_ERROR escapes on the FIRST attempt -- desirable (no point hammering a +/// lost fence locally 8 times; the caller's own backoff, e.g. the merge queue's, is what should retry). +TEST(CASPartWriteTxnBlobPutRetry, BudgetExhaustionMapsToNetworkErrorAndEscapesImmediately) +{ + auto b = std::make_shared(); + auto s = openBlobFaultPool(b, /*max_attempts=*/3); + const RootNamespace ns{"srv/tbl"}; + const String payload = "blob-payload-C"; + + auto build = startBuildFor(s, ns, "part_blob_exhausted"); + const ManifestId id = build->stageManifest({blobManifestEntry("a.bin", payload)}); + build->precommitAdd(ns, "part_blob_exhausted", id); + + int payload_streams = 0; + b->fault_count = 1000000; + bool threw = false; + try + { + build->putBlob(idOf(payload), countingSource(payload, payload_streams)); + } + catch (const DB::Exception & e) + { + threw = true; + EXPECT_EQ(e.code(), DB::ErrorCodes::NETWORK_ERROR); + EXPECT_NE(e.message().find("UNCERTAIN"), String::npos) << e.message(); + } + EXPECT_TRUE(threw); + EXPECT_EQ(b->stream_attempts, 3) << "the 3-attempt controller budget for ONE outer attempt -- " + "putBlob's outer condemned-churn loop must NOT re-drive a NETWORK_ERROR"; +} + +/// promoteStaged conditional copy, ambiguous-but-landed: the copy's response is lost AFTER the +/// destination was created. The occupancy resolve observes the destination present and the occupant +/// is adopted — Committed-in-effect WITHOUT a re-copy. +TEST(CASPartWriteTxnPromoteStagedRetry, AmbiguousCopyLandedAdoptsDestinationWithoutRecopy) +{ + auto b = std::make_shared(); + /// The sink target must outlive the Pool: `~Pool` emits terminate events into the sink. + std::vector events; + auto s = openBlobFaultPool(b); + const RootNamespace ns{"srv/tbl"}; + const String payload = "staged-payload-A"; + /// The staging object: [pool-fixed-length envelope header][payload], promoted VERBATIM by the copy. + const String staging_key = "p/staging/test/blob-a"; + const String staging_bytes = String(s->poolMeta().blob_header_len, 'h') + payload; + ASSERT_EQ(b->putIfAbsent(staging_key, staging_bytes).outcome, PutOutcome::Done); + + s->setEventSink([&](const CasEvent & e) { events.push_back(e); }); + + auto build = startBuildFor(s, ns, "part_copy_landed"); + const ManifestId id = build->stageManifest({blobManifestEntry("a.bin", payload)}); + build->precommitAdd(ns, "part_copy_landed", id); + + BlobSource source; + source.size = payload.size(); + source.server_side_copy_from = staging_key; + b->fault_count = 1; + b->land_despite_fault = true; + const PutBlobResult res = build->putBlob(idOf(payload), std::move(source)); + EXPECT_EQ(res.size, payload.size()); + + EXPECT_EQ(b->copy_attempts, 1) << "a landed ambiguous copy must be resolved, never re-copied"; + const String key = s->layout().blobKey(idOf(payload)); + const auto got = b->get(key); + ASSERT_TRUE(got.has_value()); + EXPECT_EQ(got->bytes, staging_bytes) << "the destination is the staging object's verbatim copy"; + EXPECT_NE(std::find_if(events.begin(), events.end(), + [](const CasEvent & e) { return e.type == CasEventType::BlobReuseAdopt; }), + events.end()) << "the landed destination must be ADOPTED"; +} + +/// promoteStaged conditional copy, ambiguous-and-absent: the first copy attempt times out with +/// nothing landing; the resolve observes the destination absent and the copy is REISSUED from the +/// (intact, still-staged) source object — the second attempt commits. +TEST(CASPartWriteTxnPromoteStagedRetry, AmbiguousCopyAbsentReattemptsAndCommits) +{ + auto b = std::make_shared(); + auto s = openBlobFaultPool(b); + const RootNamespace ns{"srv/tbl"}; + const String payload = "staged-payload-B"; + const String staging_key = "p/staging/test/blob-b"; + const String staging_bytes = String(s->poolMeta().blob_header_len, 'h') + payload; + ASSERT_EQ(b->putIfAbsent(staging_key, staging_bytes).outcome, PutOutcome::Done); + + auto build = startBuildFor(s, ns, "part_copy_retry"); + const ManifestId id = build->stageManifest({blobManifestEntry("a.bin", payload)}); + build->precommitAdd(ns, "part_copy_retry", id); + + BlobSource source; + source.size = payload.size(); + source.server_side_copy_from = staging_key; + b->fault_count = 1; + const PutBlobResult res = build->putBlob(idOf(payload), std::move(source)); + EXPECT_EQ(res.size, payload.size()); + + EXPECT_EQ(b->copy_attempts, 2) << "the faulted attempt + the committing reissue"; + const String key = s->layout().blobKey(idOf(payload)); + const auto got = b->get(key); + ASSERT_TRUE(got.has_value()); + EXPECT_EQ(got->bytes, staging_bytes); +} diff --git a/src/Disks/tests/gtest_cas_part_write_root_dangle.cpp b/src/Disks/tests/gtest_cas_part_write_root_dangle.cpp new file mode 100644 index 000000000000..22d0efe45227 --- /dev/null +++ b/src/Disks/tests/gtest_cas_part_write_root_dangle.cpp @@ -0,0 +1,256 @@ +#include +#include +#include +#include +#include +#include +#include + +#include + +using namespace DB::Cas; +using DB::Cas::tests::idOf; +using DB::Cas::tests::u128Of; + +namespace +{ + +/// Mirrors the B140 repro. +PoolPtr openTestPool(std::shared_ptr & out_backend) +{ + out_backend = std::make_shared(); + return Pool::open(out_backend, PoolConfig{.pool_prefix = "p", .server_root_id = "test"}); +} + +size_t runGcToFixpoint(Gc & gc, size_t max_rounds = 64) +{ + size_t rounds = 0; + for (; rounds < max_rounds; ++rounds) + { + RoundReport rep; + try + { + rep = gc.runRegularRound(); + } + catch (const DB::Exception &) + { + break; + } + if (!rep.acquired_lease) + continue; + if (rep.candidates == 0 && rep.deleted == 0 && rep.absent == 0 + && rep.replaced == 0 && rep.spared == 0) + break; + } + return rounds; +} + +ManifestEntry blobEntry(const String & name, const String & payload) +{ + ManifestEntry e; + e.path = name; + e.placement = EntryPlacement::Blob; + e.ref = DB::Cas::BlobRef{DB::Cas::BlobHashAlgo::CityHash128, DB::Cas::BlobDigest::fromU128(u128Of(payload))}; + + e.blob_size = payload.size(); + return e; +} + +} + +/// B171 build-root / precommit, RED repro of the B140-dangle at unit level driven entirely through the +/// public PartWriteTxn/Pool/Gc API (no snap injection): +/// +/// PartWriteTxn A uploads blob P and publishes refA -> t1 -> { data.bin: P }. A is then RELEASED (dtor), +/// retiring its build_seq so the GC watermark `min_active` advances PAST A. P now carries A's +/// `cas_owner` and is no longer protected by any in-flight build. +/// +/// PartWriteTxn B starts and ADOPTS the same blob P via tokenless evidence (adoptEvidence — the cross-node +/// adopt case), assembles t2 -> { other.bin: P }, and `precommit(t2)` — which publishes a durable +/// build-root ref so GC's fold lifts the in-degree of P's closure. +/// +/// refA is dropped + watermark renewed; GC runs to fixpoint. P is protected by B's precommit edge, +/// so GC must NOT delete it. PartWriteTxn B then publishes refB -> t2 successfully. +/// +/// THE POSITIVE INVARIANT: the whole flow must succeed AND P must survive, because B's precommit pins +/// P's closure across A's retire + GC (B171 two-phase commit; `checkAndResolveDeps` proves closure +/// present at publish time). +TEST(CASPartWriteTxnRootDangle, SharedBlobSurvivesSourceDropDuringBuild) +{ + std::shared_ptr backend; + auto s = openTestPool(backend); + const RootNamespace ns{"test/tbl"}; + const String P = "shared-blob-payload-P"; + + /// PartWriteTxn A: upload P, publish refA -> manifest -> { data.bin: P }, then release A so its build_seq + /// retires and min_active advances past it. + { + PartWriteInfo info; + info.intended_ref = ns.string() + "/refA"; + auto a = s->beginPartWrite(info); + a->putBlob(idOf(P), BlobSource::fromString(P)); + const ManifestId id = a->stageManifest({blobEntry("data.bin", P)}); + a->precommitAdd(ns, "refA", id); + a->promote(ns, "refA", a->buildId(), id); + } + s->renewWatermarkOnce(); /// A is gone; min_active now advances past A's build_seq + + /// PartWriteTxn B: adopt the SAME blob P (cross-node adopt — tokenless evidence via adoptEvidence), assemble + /// its manifest, and precommitAdd it. The precommit pins P's closure (fold +1 edge) for the build. + PartWriteInfo binfo; + binfo.intended_ref = ns.string() + "/refB"; + auto b = s->beginPartWrite(binfo); + const ManifestEntry pe = blobEntry("other.bin", P); + b->adoptEvidence(pe); + const ManifestId t2 = b->stageManifest({pe}); + b->precommitAdd(ns, "refB", t2); + + /// The source ref disappears, and the watermark is renewed so the closure looks collectable. + s->dropRef(ns, "refA"); + s->renewWatermarkOnce(); + + /// GC to fixpoint. P must survive: the live precommit binding for refB activates a +1 blob edge on + /// P during the fold, so P never reaches in-degree 0 (B171 two-phase commit). + Gc gc(s, u128Of("gc-b171")); + runGcToFixpoint(gc); + + /// PartWriteTxn B commits refB by promoting its precommit. Should succeed end-to-end; if it throws (e.g. + /// ABORTED because the blob is gone) that is itself the RED outcome. + ASSERT_NO_THROW(b->promote(ns, "refB", b->buildId(), t2)) + << "B171: PartWriteTxn B's promote must succeed — the precommit should have kept P alive"; + + /// The blob B references must still be present (no dangle), and refB must resolve. + ASSERT_TRUE(backend->head(s->layout().blobKey(idOf(P))).exists) + << "B171-dangle: GC deleted the shared blob P that PartWriteTxn B adopted — its cas_owner was the " + << "retired PartWriteTxn A and the stub precommit published no build-root edge, so inDeg(P) hit 0 " + << "and the single content-delete site removed it. refB now dangles."; + ASSERT_TRUE(s->resolveRef(ns, "refB").has_value()) + << "B171: refB must resolve to its committed manifest"; +} + +/// B171 INV-COMMIT-FAILCLOSED: even if the build-root precommit is PREMATURELY RECLAIMED mid-build +/// (e.g. a live build whose watermark renewer froze and was falsely judged dead), the real commit must +/// NEVER publish a table ref over a missing dependency. It must fail closed — abort — never dangle. +/// +/// Setup mirrors the primary repro: PartWriteTxn A publishes refA -> t1 -> { data.bin: P } then retires; PartWriteTxn +/// B adopts P, assembles t2 -> { other.bin: P }, and precommits t2 (a real build-root edge now protects +/// P). We then SIMULATE the premature reclaim by manually dropping the build-root ref (as GC's reclaim +/// would) AND dropping refA, then renew the watermark and run GC to fixpoint. With P's only protection +/// (the precommit edge) gone and its owner retired, GC deletes P. PartWriteTxn B's publish must now ABORT +/// (`checkAndResolveDeps` finds the adopted blob absent and not re-creatable) instead of committing a dangle. +TEST(CASPartWriteTxnRootDangle, PrematureReclaimCommitFailsClosed) +{ + std::shared_ptr backend; + auto s = openTestPool(backend); + const RootNamespace ns{"test/tbl"}; + const String P = "shared-blob-payload-P-reclaim"; + + /// PartWriteTxn A: upload P, publish refA -> manifest, retire A so min_active advances past it. + { + PartWriteInfo info; + info.intended_ref = ns.string() + "/refA"; + auto a = s->beginPartWrite(info); + a->putBlob(idOf(P), BlobSource::fromString(P)); + const ManifestId id = a->stageManifest({blobEntry("data.bin", P)}); + a->precommitAdd(ns, "refA", id); + a->promote(ns, "refA", a->buildId(), id); + } + s->renewWatermarkOnce(); + + /// PartWriteTxn B: adopt P via tokenless evidence, assemble its manifest, precommitAdd it (the precommit + /// owner binding for refB now protects P with a +1 fold edge). + PartWriteInfo binfo; + binfo.intended_ref = ns.string() + "/refB"; + auto b = s->beginPartWrite(binfo); + const ManifestEntry pe2 = blobEntry("other.bin", P); + b->adoptEvidence(pe2); + const ManifestId t2 = b->stageManifest({pe2}); + b->precommitAdd(ns, "refB", t2); + + /// SIMULATE a premature reclaim having already collected P: had the precommit binding been wrongly + /// reclaimed with no other owner, GC would condemn+delete P's closure. Reproduce that END STATE + /// directly by deleting P's blob object. (The durable ref-log stream is owned by the live writer, so a + /// RAW removal append would collide with the writer's own `RefTxnId` sequence allocation on the next + /// flush; the property under test is the COMMIT gate's fail-closed behavior against a missing + /// dependency, not the reclaim mechanics -- so we go straight to the reclaimed state.) + { + const String pkey = s->layout().blobKey(idOf(P)); + const HeadResult h = backend->head(pkey); + ASSERT_TRUE(h.exists) << "P must be present before the simulated reclaim"; + ASSERT_EQ(backend->deleteExact(pkey, h.token).kind, DeleteOutcome::Kind::Deleted); + } + /// Drop the source ref too (the state a real premature reclaim leaves: P unprotected and gone). + s->dropRef(ns, "refA"); + s->renewWatermarkOnce(); + + /// The shared blob must be GONE (the premature reclaim collected it). + ASSERT_FALSE(backend->head(s->layout().blobKey(idOf(P))).exists) + << "premature-reclaim setup invalid: P should have been collected after losing its precommit"; + + /// §4 manifest-trust (test name is legacy — B171 INV-COMMIT-FAILCLOSED for an ADOPTED leaf now moves to + /// fsck): P is a committed-source adopted leaf, so PartWriteTxn B's promote TRUSTS it (no HEAD/loadMeta probe) + /// and COMMITS refB. On the real reuse/relink path this dangle is UNREACHABLE: precommitAdd durably + /// appended refB's Precommit OwnerTransition (CasPartWriteTxn.cpp precommitAdd) BEFORE promote, and promote + /// re-proves that edge is the LIVE owner (WPromote owner==bld) BEFORE trusting P — so P has in-degree + /// >= 1 and GC (the sole deleter) cannot collect it. This test injects the collection DIRECTLY (a raw + /// deleteExact while refB's precommit is still live), which the live-precommit invariant excludes. So + /// promote SUCCEEDS; the dangle is not prevented at promote but DETECTED by fsck (the backstop). + ASSERT_NO_THROW(b->promote(ns, "refB", b->buildId(), t2)) + << "§4: an adopted leaf is trusted at promote — a missing dependency is not re-observed here"; + + /// Trust never fabricates the missing blob (it never touches P); refB IS committed (naming absent P). + ASSERT_FALSE(backend->head(s->layout().blobKey(idOf(P))).exists) + << "trust never fabricates the missing blob — P stays absent"; + ASSERT_TRUE(s->resolveRef(ns, "refB").has_value()) + << "§4: refB commits under trust (the D4 trade-off); the dangle is caught by fsck, below"; + + /// THE BACKSTOP (INV-NO-DANGLE-via-fsck): fsck's reachable-but-absent scan reports refB's absent P as + /// dangling — this is where the B171 guarantee lives under §4. Detection moved, it did not disappear. + const FsckReport rep = runFsck(*s, /*detail=*/true); + EXPECT_GE(rep.dangling, 1u) + << "§4 D4 backstop: refB committed over the deleted P; fsck must report it dangling (dangling=" + << rep.dangling << ", reachable=" << rep.reachable << ")"; +} + +/// (The GC-reclaim test `CASPartWriteTxnRoot.AbandonedPrecommitReclaimed` -- which asserted GC AUTOMATICALLY +/// reclaims an abandoned precommit of a judged-dead build and then collects its closure -- was removed +/// with the snapshot+log ref model. Per spec §Responsibility Boundary, reclaiming an abandoned precommit +/// is now the WRITER's job (it appends the exact `owner_transition` removal on recovery); GC never scans +/// for or removes precommit bindings, and there is no mutable shard journal to append a `PrecommitRemove` +/// into. The `precommitRemovalAppended` shard-journal probe it shared with `LivePrecommitNotReclaimed` +/// went with it.) + +/// B8 CONSERVATISM (liveness-correctness guard): a live in-flight build's precommit binding (and its +/// pinned blobs) must survive a full GC run, and the build must still be able to promote it. In the +/// snapshot+log model GC never reclaims a precommit at all, so this is purely a liveness pin: the live +/// precommit's `+1` fold edge keeps its exclusively-owned blob alive across GC. +TEST(CASPartWriteTxnRoot, LivePrecommitNotReclaimed) +{ + std::shared_ptr backend; + auto s = openTestPool(backend); + const RootNamespace ns{"test/tbl"}; + const String Q = "live-build-blob-payload-Q"; + + /// PartWriteTxn B stays ALIVE: upload Q, assemble, precommitAdd — and we DO NOT retire its seq. So + /// `min_active <= build_seq` (B is in-flight) and the watermark keeps a live, advancing seq. + PartWriteInfo binfo; + binfo.intended_ref = ns.string() + "/refLive"; + auto b = s->beginPartWrite(binfo); + b->putBlob(idOf(Q), BlobSource::fromString(Q)); + const ManifestId t = b->stageManifest({blobEntry("data.bin", Q)}); + b->precommitAdd(ns, "refLive", t); + s->renewWatermarkOnce(); + ASSERT_LE(s->minActive(), b->buildSeq()) << "precondition: B must be in-flight (min_active <= seq)"; + + /// GC to fixpoint while B is live. + Gc gc(s, u128Of("gc-b8-live")); + runGcToFixpoint(gc); + + /// Q must still be present (the live precommit's +1 edge pins it across GC). + ASSERT_TRUE(backend->head(s->layout().blobKey(idOf(Q))).exists) + << "B8 conservatism: the live precommit must keep its blob alive across GC"; + + /// B can still commit (the precommit is intact). + ASSERT_NO_THROW(b->promote(ns, "refLive", b->buildId(), t)) + << "B8 conservatism: a live build must still be able to promote its untouched precommit"; +} diff --git a/src/Disks/tests/gtest_cas_pluggable_hash.cpp b/src/Disks/tests/gtest_cas_pluggable_hash.cpp new file mode 100644 index 000000000000..a4146ddf65c1 --- /dev/null +++ b/src/Disks/tests/gtest_cas_pluggable_hash.cpp @@ -0,0 +1,939 @@ +#include + +/// P1-T2 (CAS pluggable-blob-hash Phase 1): +/// `PoolMeta` records the pool-wide `blob_hash_algo` and `PoolMeta::createOrValidate` fail-closes on a +/// disk config that disagrees with an existing pool's recorded algo -- the pool-wide durability +/// invariant (never silently re-hash an existing pool). +/// +/// Phase 3 T4 RELAXES that single fail-closed +/// value into `PoolMeta::algos_used` (sorted, append-only): a config algo already a MEMBER is +/// accepted with no write (steady state); a non-member is admitted via a CAS-union ONLY when the +/// disk opts in (`blob_hash_allow_new`), and refused (`BAD_ARGUMENTS`, same as before) otherwise -- +/// a changed config alone must never silently turn a pool mixed. See `AdmissionIsFlagGated` and +/// `ConcurrentAdmissionUnions` below. +/// +/// P1-T3a (this file, extended): the pool's `blob_hash_algo` is threaded into the three hash sites +/// (spec §5/§6) -- `Cas::CaContentWriteBuffer` (streaming blob-body hash), +/// `PartWriteTxn`'s envelope `hash_algo` field, and (transitively, via `Cas::blobHashHexOneShot`) the +/// `poolContentHash` content-key mint on the write path. `poolContentHash` itself is a static +/// helper in `CasPartWriteTxn.cpp` and not directly reachable from a gtest; its production callers already +/// exercise the default `CityHash128` path, and it delegates to the SAME `Cas::blobHashHexOneShot` +/// this file tests directly below. + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include "cas_test_helpers.h" + +#include + + +#include +#include +#include +#include + +namespace DB::ErrorCodes +{ +extern const int BAD_ARGUMENTS; +extern const int UNKNOWN_FORMAT_VERSION; +} + +using namespace DB::Cas; +using namespace DB::Cas::tests; + + +namespace +{ + +/// A deterministic, non-repeating-byte payload spanning several `DBMS_DEFAULT_HASHING_BLOCK_SIZE` +/// (2048 B) blocks, so a chunked-vs-one-shot divergence (the CityHash128 pitfall documented on +/// `poolContentHash`) would not accidentally go unnoticed. +std::string makeMultiBlockPayload(size_t size = 5000) +{ + std::string s; + s.reserve(size); + for (size_t i = 0; i < size; ++i) + s.push_back(static_cast('a' + (i % 23))); + return s; +} + +/// A blob written at its OWN algo's content key, plus the key it landed at. +struct SeededBlob +{ + BlobRef ref; + String key; +}; + +/// Write a blob body of `algo` at its content key, reference it from a committed ref, and DROP that +/// ref — so the blob reaches a folded in-degree of zero and the ORDINARY pipeline condemns it by +/// transition-to-zero. The caller then runs the rounds that fold the `+1` and the `-1`. +/// +/// These tests used to seed a blob no manifest ever named and lean on `rebuildBaseline`'s LIST/HEAD +/// sweep, which was the only path that could condemn such a blob. That sweep is GONE (spec §7: a +/// rebuild condemns nothing — it was the r5-finding-4 data-loss vector), and a blob nothing names is +/// now retained by design. What these tests actually guard — that a blob is recognized under its OWN +/// `` path segment by the fold's key codec, `previewDeletes`, the exact-token delete and fsck, +/// rather than silently skipped as foreign — is unaffected, and lives on the PRODUCTION path, which is +/// where it is now exercised. Reverting either per-algo port still turns these red. +SeededBlob seedReferencedBlob(Pool & store, Backend & backend, const RootNamespace & ns, BlobHashAlgo algo, + uint64_t build_sequence, size_t payload_size, const String & ref_name) +{ + const std::string payload = makeMultiBlockPayload(payload_size); + const BlobRef ref{algo, codecFor(algo).fromHex(blobHashHexOneShot(algo, payload))}; + const String key = store.layout().blobKey(ref); + + EnvelopeHeader header; + header.kind = ObjectKind::Blob; + header.incarnation_tag = UInt128(0x1234); + header.build_id = UInt128(0x5678); + backend.putIfAbsent(key, encodeEnvelopeHeader(header, static_cast(store.poolMeta().blob_header_len)) + payload); + + ManifestEntry entry; + entry.path = "data_" + std::to_string(build_sequence) + ".bin"; + entry.placement = EntryPlacement::Blob; + entry.ref = ref; /// the entry carries the blob's OWN algo, not the pool's write algo + entry.blob_size = 1; + + const ManifestRef mref{.writer_epoch = 1, .build_sequence = build_sequence, .manifest_ordinal = 1}; + writeManifestRaw(backend, store.layout(), ns, mref, {entry}); + publishCommittedTransition(backend, store.layout(), ns, ref_name, std::nullopt, mref); + return SeededBlob{ref, key}; +} + +/// Drop the committed ref `seedReferencedBlob` published, so the blob's only edge disappears. +void dropSeededRef(Pool & store, Backend & backend, const RootNamespace & ns, uint64_t build_sequence, + const String & ref_name) +{ + const ManifestRef mref{.writer_epoch = 1, .build_sequence = build_sequence, .manifest_ordinal = 1}; + dropRefTransition(backend, store.layout(), ns, ref_name, mref); +} + +} + +TEST(CASPluggableHash, PoolMetaRoundTripsAlgosUsed) +{ + PoolMeta pm; + pm.pool_id = u128Of("pool-a"); + pm.blob_header_len = 256; + pm.algos_used = {static_cast(BlobHashAlgo::CityHash128), static_cast(BlobHashAlgo::XXH3_128)}; + + const PoolMeta back = decodePoolMeta(encodePoolMeta(pm)); + EXPECT_EQ(back.algos_used, pm.algos_used); + EXPECT_EQ(back.blob_header_len, 256u); +} + +TEST(CASPluggableHash, CreateOrValidateRecordsConfigAlgoOnFreshPool) +{ + auto backend = std::make_shared(); + const Layout layout("p"); + + const PoolMeta pm = PoolMeta::createOrValidate(*backend, layout, /*blob_header_len*/ 256, BlobHashAlgo::XXH3_128, /*allow_new*/ false, /*allow_mint*/ true); + EXPECT_EQ(pm.algos_used, (std::vector{static_cast(BlobHashAlgo::XXH3_128)})); + + /// Reopening with the SAME algo is a no-op reopen: the recorded value comes back unchanged. + const PoolMeta reopened = PoolMeta::createOrValidate(*backend, layout, 256, BlobHashAlgo::XXH3_128); + EXPECT_EQ(reopened.algos_used, (std::vector{static_cast(BlobHashAlgo::XXH3_128)})); + EXPECT_EQ(reopened.pool_id, pm.pool_id); +} + +TEST(CASPluggableHash, CreateOrValidateDefaultsToCityHash128) +{ + auto backend = std::make_shared(); + const Layout layout("p"); + + const PoolMeta pm = PoolMeta::createOrValidate(*backend, layout, 256, BlobHashAlgo::CityHash128, /*allow_new*/ false, /*allow_mint*/ true); + EXPECT_EQ(pm.algos_used, (std::vector{static_cast(BlobHashAlgo::CityHash128)})); +} + +/// Phase 3 T4 (spec §5, replaces the Phase 1/2 unconditional-fail-close test of the same shape): +/// admission of a NEW algo is EXPLICIT OPT-IN -- the default reopen with a non-member algo still +/// fails closed (BAD_ARGUMENTS), but the message now names `` and the pool +/// is truly extensible with the flag set. See `AdmissionIsFlagGated` below for the full flow. +TEST(CASPluggableHash, CreateOrValidateFailsClosedOnAlgoMismatchWithoutFlag) +{ + auto backend = std::make_shared(); + const Layout layout("p"); + + PoolMeta::createOrValidate(*backend, layout, 256, BlobHashAlgo::CityHash128, /*allow_new*/ false, /*allow_mint*/ true); + + expectThrowsCode(DB::ErrorCodes::BAD_ARGUMENTS, [&] + { + PoolMeta::createOrValidate(*backend, layout, 256, BlobHashAlgo::XXH3_128, /*allow_new*/ false); + }); + + /// The pool is untouched by the refused reopen: a subsequent open with the ORIGINAL algo still + /// succeeds and returns the same pool_id. + const PoolMeta reopened = PoolMeta::createOrValidate(*backend, layout, 256, BlobHashAlgo::CityHash128); + EXPECT_EQ(reopened.algos_used, (std::vector{static_cast(BlobHashAlgo::CityHash128)})); +} + +/// spec §9.1 at the unit level: admission of a new algo requires the flag; once admitted, membership +/// alone is the steady-state check (the flag is not needed again for the same algo). +TEST(CASPluggableHash, AdmissionIsFlagGated) +{ + auto backend = std::make_shared(); + const Layout layout("p"); + PoolMeta::createOrValidate(*backend, layout, 256, BlobHashAlgo::CityHash128, /*allow_new*/ false, /*allow_mint*/ true); + + /// without the flag: refuse, pool untouched + expectThrowsCode(DB::ErrorCodes::BAD_ARGUMENTS, [&] + { PoolMeta::createOrValidate(*backend, layout, 256, BlobHashAlgo::Sha256, false); }); + + /// with the flag: admitted + const PoolMeta admitted = PoolMeta::createOrValidate(*backend, layout, 256, BlobHashAlgo::Sha256, true); + EXPECT_EQ(admitted.algos_used, (std::vector{1, 3})); + + /// steady state: admitted algo reopens WITHOUT the flag + const PoolMeta steady = PoolMeta::createOrValidate(*backend, layout, 256, BlobHashAlgo::Sha256, false); + EXPECT_EQ(steady.algos_used, (std::vector{1, 3})); +} + +TEST(CASPluggableHash, ConcurrentAdmissionUnions) +{ + auto backend = std::make_shared(); + const Layout layout("p"); + PoolMeta::createOrValidate(*backend, layout, 256, BlobHashAlgo::CityHash128, false, /*allow_mint*/ true); + PoolMeta::createOrValidate(*backend, layout, 256, BlobHashAlgo::XXH3_128, true); + PoolMeta::createOrValidate(*backend, layout, 256, BlobHashAlgo::Sha256, true); + const PoolMeta final_pm = PoolMeta::createOrValidate(*backend, layout, 256, BlobHashAlgo::CityHash128, false); + EXPECT_EQ(final_pm.algos_used, (std::vector{1, 2, 3})); /// union, sorted, nothing lost +} + +/// ---- P1-T3a: the pool's blob_hash_algo threaded into the streaming write-buffer hash site ---- + +/// `Cas::CaContentWriteBuffer`'s LOCAL-staging constructor (the everyday spill-to-temp-file +/// mode `ContentAddressedTransaction::writeFile` uses), built with `BlobHashAlgo::XXH3_128`, must hash +/// the streamed payload with xxh3 -- agreeing with the standalone `blobHashHexOneShot` one-shot helper +/// (the same convention `poolContentHash`'s re-hash uses). +TEST(CASPluggableHash, ContentWriteBufferLocalModeHashesWithSelectedAlgoXxh3) +{ + const std::string payload = makeMultiBlockPayload(); + const auto temp_dir = (std::filesystem::temp_directory_path() / "cas_pluggable_hash_xxh3_local").string(); + + std::string got_hash_hex; + size_t got_size = 0; + auto buf = std::make_unique( + temp_dir, + BlobHashAlgo::XXH3_128, + /*buf_size=*/8192, + /*use_adaptive_buffer_size=*/false, + /*adaptive_buffer_initial_size=*/0, + [&](const std::string & hash_hex, size_t size, const std::string &) + { + got_hash_hex = hash_hex; + got_size = size; + }); + + /// Write in two chunks so more than one nextImpl flush happens (exercises the streaming state, not + /// just a single call). + buf->write(payload.data(), 1234); + buf->write(payload.data() + 1234, payload.size() - 1234); + buf->finalize(); + + EXPECT_EQ(got_size, payload.size()); + EXPECT_EQ(got_hash_hex, blobHashHexOneShot(BlobHashAlgo::XXH3_128, payload)); + /// A wrong-but-plausible result (e.g. accidentally still hashing with cityHash128) would silently + /// produce a DIFFERENT hex string -- pin that the two algos disagree on this payload, so the + /// assertion above is actually discriminating. + EXPECT_NE(got_hash_hex, blobHashHexOneShot(BlobHashAlgo::CityHash128, payload)); +} + +/// The DEFAULT algo (`CityHash128`) through the SAME write buffer must stay byte-for-byte unchanged -- +/// the CAS pluggable-blob-hash invariant (spec §8). Compares against `blobHashHexOneShot`, which +/// `gtest_cas_blob_hasher.cpp`'s `CityHash128ByteIdenticalToHashingWriteBuffer` already proves is +/// byte-identical to the pre-existing plain `HashingWriteBuffer` convention. +TEST(CASPluggableHash, ContentWriteBufferLocalModeCityHash128Unchanged) +{ + const std::string payload = makeMultiBlockPayload(); + const auto temp_dir = (std::filesystem::temp_directory_path() / "cas_pluggable_hash_ch128_local").string(); + + std::string got_hash_hex; + auto buf = std::make_unique( + temp_dir, + BlobHashAlgo::CityHash128, + /*buf_size=*/8192, + /*use_adaptive_buffer_size=*/false, + /*adaptive_buffer_initial_size=*/0, + [&](const std::string & hash_hex, size_t, const std::string &) + { + got_hash_hex = hash_hex; + }); + + buf->write(payload.data(), payload.size()); + buf->finalize(); + + EXPECT_EQ(got_hash_hex, blobHashHexOneShot(BlobHashAlgo::CityHash128, payload)); +} + +/// (codecs-v3 phase 7) The two former `Pool...StampsEnvelopeHashAlgo...` tests were REMOVED: the v3 +/// blob envelope no longer carries a `hash_algo` field (the algo identity lives in the blob KEY, spec +/// §blob-envelope). Algo correctness for the write path is covered by the P1-T3b blob-body-PATH-key +/// tests below (they assert the blob key uses the pool's algo), which is the surviving source of truth. + +/// ---- P1-T3b: the pool's blob_hash_algo threaded into blob-body PATH keys (spec §3/§10) ---- + +/// A blob written and promoted through a live ref on an xxh3-128 pool lands under the +/// `blobs/xxh3//` path segment (not the bare `blobs//` shape), is readable at +/// that key, and `runFsck`'s LIST-based discovery (`Layout::blobsPrefix`, deliberately algo-agnostic) +/// finds it reachable and clean -- proving the GC/fsck key-parse (which takes only the LAST path +/// component as the hex digest, `CasGc.cpp`/`CasFsck.cpp`) still works with the extra segment. +TEST(CASPluggableHash, Xxh3BlobLandsUnderAlgoSegmentAndIsDiscoveredCleanByFsck) +{ + auto backend = std::make_shared(); + auto store = Pool::open(backend, + PoolConfig{.pool_prefix = "p", .server_root_id = "test", + .blob_hash_algo = BlobHashAlgo::XXH3_128}); + + const RootNamespace ns{"srv1/tbl"}; + const std::string payload = makeMultiBlockPayload(); + const BlobRef id{BlobHashAlgo::XXH3_128, codecFor(BlobHashAlgo::XXH3_128).fromHex(blobHashHexOneShot(BlobHashAlgo::XXH3_128, payload))}; + + PartWriteInfo info; + info.intended_ref = ns.string() + "/rb"; + auto build = store->beginPartWrite(info); + build->putBlob(id, BlobSource::fromString(payload)); + + /// The blob body landed under the algo-segmented path -- readable there, not at the legacy + /// no-segment shape. + const String blob_key = store->layout().blobKey(id); + EXPECT_NE(blob_key.find("/blobs/xxh3/"), String::npos) << blob_key; + EXPECT_EQ(blob_key.find("/blobs/ch128/"), String::npos) << blob_key; + EXPECT_TRUE(backend->head(blob_key).exists); + + ManifestEntry e; + e.path = "data.bin"; + e.placement = EntryPlacement::Blob; + e.ref = id; + + e.blob_size = payload.size(); + const ManifestId mid = build->stageManifest({e}); + build->precommitAdd(ns, "rb", mid); + build->promote(ns, "rb", build->buildId(), mid); + store->renewWatermarkOnce(); + + const FsckReport rep = runFsck(*store, /*detail=*/true); + EXPECT_TRUE(rep.clean()); + EXPECT_EQ(rep.dangling, 0u); + EXPECT_GE(rep.reachable, 1u); + + /// Not merely "clean by omission" (e.g. a bug that silently LISTed nothing): the physical listing + /// actually walked the algo-segmented key. + const bool found = std::any_of(rep.objects.begin(), rep.objects.end(), + [](const FsckObject & o) { return o.key.find("/blobs/xxh3/") != String::npos; }); + EXPECT_TRUE(found); +} + +/// ============================================================================================ +/// CAS pluggable-blob-hash Phase 2 Task 5 -- THE CRUX (anti-silent-leak regression gate). +/// +/// Two sites classify a blob by parsing its object-key hex into a hash set: `CasGc.cpp`'s condemn +/// path (the fold's transition-to-zero, and — until spec §7 removed it — `Gc::rebuildBaseline`'s +/// LIST/HEAD sweep) and `CasFsck.cpp`'s +/// present-but-unreferenced classification. Both used to route through the bare, fixed-width +/// `hexToU128` (32-hex-only) inside a `catch(...) continue` / no-catch-at-all — so a 64-hex `sha256` +/// key either (a) fell into the "foreign key shape — not ours" catch and was silently treated as +/// debris (the condemn sweep: the blob is NEVER condemned — a permanent GC leak), or (b) threw +/// uncaught out of fsck's present-but-unreferenced loop (a hard fsck failure on a live sha256 pool). +/// Phase 2 Task 5 ports both to the pool-scoped `DigestCodec::fromHex`, which parses a CORRECT-WIDTH +/// key (16 OR 32 bytes) — a genuinely foreign key shape (e.g. a `.meta` sibling) still falls into +/// the catch, but a real sha256 blob no longer does. +/// +/// This test constructs a `sha256`-algo pool DIRECTLY via `PoolConfig` (this bypasses only the +/// disk-config *factory* guard in `MetadataStorageFactory.cpp`, which Task 6 removes — `Pool::open` +/// itself has never gated on algo) and writes a blob body straight at its 64-hex content-addressed key +/// (bypassing `PartWriteTxn::putBlob`, whose OWN internal `logical_hash` stays a fixed 128-bit +/// representation until a later task — see the Task 5 report), references it, and drops the reference +/// so the fold condemns it. It then drives BOTH crux sites and asserts the blob is CLASSIFIED, not +/// silently skipped as foreign. +/// +/// MUST GO RED if either port is reverted to `hexToU128`: reverting `CasGc.cpp`'s fold leaves +/// `condemned_total == 0` (never condemned) and `previewDeletes()` empty; reverting `CasFsck.cpp`'s +/// sites either throws out of `runFsck` or leaves the blob unclassified/absent from `unreachable`. +TEST(CASPluggableHash, Sha256BlobSeenByCondemnSweepAndFsckNotSilentlySkipped) +{ + auto backend = std::make_shared(); + auto store = Pool::open(backend, + PoolConfig{.pool_prefix = "p", .server_root_id = "test", + .blob_hash_algo = BlobHashAlgo::Sha256, .gc_fold_max_defer_rounds = 0}); + ASSERT_EQ(blobHashLenFor(store->writeAlgo()), 32u) << "sha256 must derive a 32-byte digest width"; + + const DigestCodec codec = codecFor(store->writeAlgo()); + const std::string payload = makeMultiBlockPayload(); + const std::string hex = blobHashHexOneShot(BlobHashAlgo::Sha256, payload); + ASSERT_EQ(hex.size(), 64u) << "sha256 renders 64 lowercase hex chars"; + const BlobDigest digest = codec.fromHex(hex); // round-trip sanity: must not throw at width 32 + + /// Reference the blob from a committed ref, then drop that ref: the fold sees `+1` then `-1`, the + /// blob transitions to in-degree zero, and the ORDINARY condemn path claims it. Every per-algo + /// parse this test guards sits on that path. + const RootNamespace ns{"00/aa@cas@"}; + Gc gc(store, UInt128(1)); + const SeededBlob seeded = seedReferencedBlob(*store, *backend, ns, BlobHashAlgo::Sha256, + /*build_sequence=*/1, /*payload_size=*/5000, "tbl_sha"); + const BlobRef id = seeded.ref; + const String blob_key = seeded.key; + EXPECT_NE(blob_key.find("/blobs/sha256/"), String::npos) << blob_key; + ASSERT_TRUE(backend->head(blob_key).exists) << "the sha256 blob body must be present before the fold"; + ASSERT_EQ(codecFor(store->writeAlgo()).fromHex(hex), digest) << "fixture sanity: the seeded digest is ours"; + + /// ---- Site 1: the fold's condemn path ---- + runRegularRoundReclaiming(gc); /// folds the +1 + dropSeededRef(*store, *backend, ns, /*build_sequence=*/1, "tbl_sha"); + runRegularRoundReclaiming(gc); /// folds the -1: transition to zero => condemned + + const auto state_bytes = backend->get(store->layout().gcStateKey()); + ASSERT_TRUE(state_bytes.has_value()); + const GcState state = decodeGcState(state_bytes->bytes); + ASSERT_GT(state.snap_generation, 0u); + const auto seal_bytes = backend->get(store->layout().foldSealKey(state.snap_generation, state.snap_attempt)); + ASSERT_TRUE(seal_bytes.has_value()); + const CasFoldSeal seal = decodeFoldSeal(seal_bytes->bytes); + ASSERT_TRUE(seal.condemned_summary.contains(0)) << "the seal's condemned_summary must be total over gc_shards"; + EXPECT_EQ(seal.condemned_summary.at(0).condemned_total, 1u) + << "THE CRUX: the sha256 blob must be condemned by the fold -- a silent-leak regression (a " + "reverted CasGc.cpp codec.fromHex port) leaves this at 0"; + + /// previewDeletes streams the SAME adopted seal via the run's own SourceEdgeKeyCodec (never pool + /// meta) and must report exactly our blob, at its real 32-byte digest. + const std::vector preview = gc.previewDeletes(); + ASSERT_EQ(preview.size(), 1u) << "THE CRUX: previewDeletes must surface the condemned sha256 blob"; + EXPECT_EQ(preview[0].ref, id); + EXPECT_EQ(preview[0].key, blob_key); + + /// ---- Site 2: fsck's present-but-unreferenced classification ---- + /// Must complete without throwing (a reverted port either throws BAD_ARGUMENTS out of the + /// no-try/catch parse sites, or silently drops the blob from every classified set) and must + /// physically account for the blob. + FsckReport frep; + ASSERT_NO_THROW(frep = runFsck(*store, /*detail=*/true)); + EXPECT_GE(frep.unreachable, 1u) + << "THE CRUX: fsck's physical listing must count the sha256 blob as unreachable-but-present, " + "not silently omit it"; + const auto oit = std::find_if(frep.objects.begin(), frep.objects.end(), + [&](const FsckObject & o) { return o.key == blob_key; }); + ASSERT_NE(oit, frep.objects.end()) << "the sha256 blob must appear in fsck's detailed object list"; + /// The fold above already condemned it into the GC snapshot, so fsck's GC-pipeline-view + /// classification (not the generic Unaccounted bucket -- reachable only by width-correctly pairing + /// the fsck-side hash against the run's kCondemned row hash) must recognize it as known-to-GC. + EXPECT_EQ(oit->cls, FsckClass::PendingGc) + << "THE CRUX: fsck must pair the sha256 blob against the GC snapshot's kCondemned row (a " + "silent-leak regression in CasFsck.cpp's unref_hashes/in_run_hashes/retired_by_hash port " + "leaves this as the generic Unaccounted bucket instead)"; +} + +/// ============================================================================================ +/// CAS pluggable-blob-hash Phase 2 Task 6 -- end-to-end sha256 WRITE path (in-memory; the real +/// wiring-level integration + soak is Task 7). +/// +/// Before this task, `PartWriteTxn`'s OWN write-path internals stayed a fixed 128-bit representation +/// downstream of the mint (`poolContentHash`/`PartWriteTxn::putBlob`'s `logical_hash`, the `deps` map key, the +/// event-log `object_hash` render, and `objectKey`) -- safe only because the disk-config factory guard +/// (`MetadataStorageFactory.cpp`) blocked any real sha256 pool from reaching `PartWriteTxn` at all (see the +/// Task 5 report and the "Task 6+" comments this task removes). Task 6 finishes those sites AND lifts +/// the guard in the SAME commit. This test drives a REAL `PartWriteTxn` (`putBlob` -> `stageManifest` -> +/// `precommitAdd` -> `promote`) on a `Sha256` pool and asserts: +/// 1. the blob lands under `blobs/sha256/<64-hex>` and the manifest entry's `blob_hash`, read back via +/// `decodePartManifest`, is the FULL 32-byte digest (bytes beyond 16 are non-zero for a real sha256 +/// digest, i.e. NOT truncated to `.toU128()`'s low 16 bytes); +/// 2. an inline file and a standalone blob of IDENTICAL content get the SAME 32-byte `file_hash` under +/// sha256 -- mirroring the (fixed) `ContentAddressedTransaction.cpp` inline-candidate formula +/// (`blobHashHexOneShot(pool_algo, bytes)` -> pool-scoped `DigestCodec::fromHex`) directly at the +/// Core level, since exercising the wiring itself is Task 7's job; +/// 3. `runFsck` on the pool is clean (no dangling, no foreign) -- the whole write -> GC -> fsck loop +/// agrees on the 64-hex key. +TEST(CASPluggableHash, Sha256BuildWritesFullWidthDigestAndInlineEqualsBlob) +{ + auto backend = std::make_shared(); + auto store = Pool::open(backend, + PoolConfig{.pool_prefix = "p", .server_root_id = "test", + .blob_hash_algo = BlobHashAlgo::Sha256}); + ASSERT_EQ(blobHashLenFor(store->writeAlgo()), 32u) << "sha256 must derive a 32-byte digest width"; + const DigestCodec codec = codecFor(store->writeAlgo()); + + const RootNamespace ns{"srv1/tbl"}; + const std::string payload = makeMultiBlockPayload(); + const std::string hex = blobHashHexOneShot(BlobHashAlgo::Sha256, payload); + ASSERT_EQ(hex.size(), 64u) << "sha256 renders 64 lowercase hex chars"; + const BlobRef id{BlobHashAlgo::Sha256, codec.fromHex(hex)}; + + PartWriteInfo info; + info.intended_ref = ns.string() + "/part1"; + auto build = store->beginPartWrite(info); + const PutBlobResult ref = build->putBlob(id, BlobSource::fromString(payload)); + EXPECT_EQ(ref.size, payload.size()); + + /// THE CRUX (blob side): the blob body lands under the sha256-segmented path, addressed by the + /// FULL 64-hex key -- `PartWriteTxn::putBlob`'s internal `logical_hash` must not have silently narrowed it + /// to a 32-hex (128-bit) key before this task. + const String blob_key = store->layout().blobKey(id); + EXPECT_NE(blob_key.find("/blobs/sha256/"), String::npos) << blob_key; + ASSERT_TRUE(backend->head(blob_key).exists); + + /// Mirror the (fixed) inline-candidate hash site directly: same content, same pool algo, via the + /// SAME public formula ContentAddressedTransaction.cpp's writeFile now uses -- NOT the old hardcoded + /// CityHash128 (which would produce a DIFFERENT, 128-bit-then-zero-padded value here). + const BlobDigest inline_hash = codec.fromHex(blobHashHexOneShot(BlobHashAlgo::Sha256, payload)); + const BlobDigest blob_hash = codec.fromHex(hex); + EXPECT_EQ(inline_hash, blob_hash) << "inline == blob: identical content must hash identically under sha256"; + + /// THE CRUX (width): a genuine 32-byte sha256 digest must NOT be zero-padded past byte 16 -- the + /// shape `BlobDigest::fromU128` (or a reverted hardcoded-CityHash128 inline site) would produce. + const bool tail_nonzero = std::any_of(blob_hash.bytes.begin() + 16, blob_hash.bytes.end(), + [](uint8_t b) { return b != 0; }); + EXPECT_TRUE(tail_nonzero) << "a genuine sha256 digest must not be zero-padded past byte 16"; + + ManifestEntry blob_entry; + blob_entry.path = "data.bin"; + blob_entry.placement = EntryPlacement::Blob; + blob_entry.ref = BlobRef{BlobHashAlgo::Sha256, blob_hash}; + blob_entry.blob_size = payload.size(); + + ManifestEntry inline_entry; + inline_entry.path = "checksums.txt"; + inline_entry.placement = EntryPlacement::Inline; + inline_entry.ref = BlobRef{BlobHashAlgo::Sha256, inline_hash}; + inline_entry.blob_size = payload.size(); + inline_entry.inline_bytes = payload; + + const ManifestId mid = build->stageManifest({blob_entry, inline_entry}); + build->precommitAdd(ns, "part1", mid); + build->promote(ns, "part1", build->buildId(), mid); + store->renewWatermarkOnce(); + + /// Read the committed manifest back -- the on-disk `blob_hash` must be the FULL 32-byte digest, not + /// truncated by the manifest codec or by anything upstream of `stageManifest`. + const auto manifest_bytes = backend->get(store->layout().manifestKey(mid)); + ASSERT_TRUE(manifest_bytes.has_value()); + const PartManifest read_back = decodePartManifest(openObject(FormatId::PartManifest, manifest_bytes->bytes)); + ASSERT_EQ(read_back.entries.size(), 2u); + const auto read_blob_it = std::find_if(read_back.entries.begin(), read_back.entries.end(), + [](const ManifestEntry & e) { return e.placement == EntryPlacement::Blob; }); + ASSERT_NE(read_blob_it, read_back.entries.end()); + EXPECT_EQ(read_blob_it->ref.digest, blob_hash); + const bool read_tail_nonzero = std::any_of(read_blob_it->ref.digest.bytes.begin() + 16, + read_blob_it->ref.digest.bytes.end(), [](uint8_t b) { return b != 0; }); + EXPECT_TRUE(read_tail_nonzero) << "the manifest's on-disk blob_hash must not be truncated either"; + + /// The write -> GC -> fsck loop must agree end-to-end on the 64-hex key: clean, no dangling. + const FsckReport rep = runFsck(*store, /*detail=*/true); + EXPECT_TRUE(rep.clean()); + EXPECT_EQ(rep.dangling, 0u); + EXPECT_GE(rep.reachable, 1u); +} + +/// ============================================================================================ +/// CAS mixed-algo pools Phase 3 T5: +/// path-derived `BlobRef` in the sweep/fsck (`Layout::parseBlobKey`) and per-entry admission +/// validation at `foldManifestEdges` with refresh-on-miss. +/// ============================================================================================ + +/// spec §9.8 -- THE race regression this task exists to close. Each `Pool`'s `admitted_algos` cache +/// is a MONOTONE snapshot seeded once at `Pool::open` and never re-read on its own; if node A admits +/// a brand-new algo and publishes a manifest naming it, node B's stale cache must NOT fail the fold +/// closed forever -- `foldManifestEdges` must refresh `_pool_meta` on the very first miss and accept +/// once the fresh read proves the algo genuinely admitted. Node B is opened BEFORE node A performs the +/// admission on purpose: constructing B afterward would seed its cache already-fresh and never +/// exercise the race the fix targets. +TEST(CASPluggableHash, StaleAlgoRegistryRefreshOnMiss) +{ + auto backend = std::make_shared(); + + /// Node B opens FIRST -- its admitted-cache seeds at {ch128} only, before sha256 exists anywhere. + auto store_b = Pool::open(backend, + PoolConfig{.pool_prefix = "p", .server_root_id = "b", + .blob_hash_algo = BlobHashAlgo::CityHash128}); + ASSERT_TRUE(store_b->isAlgoAdmitted(BlobHashAlgo::CityHash128)); + ASSERT_FALSE(store_b->isAlgoAdmitted(BlobHashAlgo::Sha256)); + + /// Node A opens SECOND, admits sha256 via the opt-in flag, and publishes a manifest naming a + /// sha256 blob through the real PartWriteTxn path (putBlob -> stageManifest -> precommitAdd -> promote). + auto store_a = Pool::open(backend, + PoolConfig{.pool_prefix = "p", .server_root_id = "a", + .blob_hash_algo = BlobHashAlgo::Sha256, .blob_hash_allow_new = true}); + ASSERT_TRUE(store_a->isAlgoAdmitted(BlobHashAlgo::Sha256)); + + const RootNamespace ns{"srv1/tbl"}; + const std::string payload = makeMultiBlockPayload(); + const BlobRef id{BlobHashAlgo::Sha256, codecFor(BlobHashAlgo::Sha256).fromHex(blobHashHexOneShot(BlobHashAlgo::Sha256, payload))}; + + PartWriteInfo info; + info.intended_ref = ns.string() + "/part1"; + auto build = store_a->beginPartWrite(info); + build->putBlob(id, BlobSource::fromString(payload)); + + ManifestEntry e; + e.path = "data.bin"; + e.placement = EntryPlacement::Blob; + e.ref = id; + e.blob_size = payload.size(); + const ManifestId mid = build->stageManifest({e}); + build->precommitAdd(ns, "part1", mid); + build->promote(ns, "part1", build->buildId(), mid); + store_a->renewWatermarkOnce(); + + /// B's cache is STILL stale here -- it has never re-read `_pool_meta` since open. + ASSERT_FALSE(store_b->isAlgoAdmitted(BlobHashAlgo::Sha256)); + + /// B folds the committed ref naming the sha256 entry: without refresh-on-miss this throws + /// CORRUPTED_DATA ("manifest entry algo sha256 not admitted"); with it, the miss triggers exactly + /// one `refreshAdmittedAlgos()` and the fold proceeds. + Gc gc(store_b, UInt128(1)); + const RebuildReport rep = gc.rebuildBaseline(/*force*/ true); + ASSERT_TRUE(rep.performed) << rep.refusal; + EXPECT_EQ(rep.committed_refs, 1u); + EXPECT_TRUE(store_b->isAlgoAdmitted(BlobHashAlgo::Sha256)) << "the miss must have unioned B's cache"; +} + +/// spec §9.4 half: an object whose key names an algo THIS BUILD has never heard of (a genuinely +/// foreign top-level segment, e.g. planted by a different/future tool) must never be treated as one +/// of ours -- the GC must skip it (never condemn or delete it) and fsck must classify it into the +/// generic `Unaccounted` bucket (never throw, never silently drop it from the physical listing). +/// In the SAME pass, a 2-algo pool's OWN blobs under `blobs/ch128/` and `blobs/sha256/` must both +/// still be classified normally -- the foreign segment must not make the fold/fsck narrow to one +/// algo or blind them to the others. +TEST(CASPluggableHash, ForeignAlgoSegmentIsDebrisNotOurs) +{ + auto backend = std::make_shared(); + auto store = Pool::open(backend, + PoolConfig{.pool_prefix = "p", .server_root_id = "test", + .blob_hash_algo = BlobHashAlgo::CityHash128, .gc_fold_max_defer_rounds = 0}); + /// Admit sha256 into the SAME pool from a second mount, then pull the union into `store`'s cache. + Pool::open(backend, + PoolConfig{.pool_prefix = "p", .server_root_id = "test2", + .blob_hash_algo = BlobHashAlgo::Sha256, .blob_hash_allow_new = true}); + store->refreshAdmittedAlgos(); + ASSERT_TRUE(store->isAlgoAdmitted(BlobHashAlgo::Sha256)); + + /// Two of the pool's OWN blobs -- one per algo -- each referenced by a committed ref that is then + /// DROPPED, so the fold condemns both by transition-to-zero. + const RootNamespace ns{"00/aa@cas@"}; + Gc gc(store, UInt128(1)); + const SeededBlob ch = seedReferencedBlob(*store, *backend, ns, BlobHashAlgo::CityHash128, + /*build_sequence=*/1, /*payload_size=*/5001, "tbl_ch"); + const SeededBlob sh = seedReferencedBlob(*store, *backend, ns, BlobHashAlgo::Sha256, + /*build_sequence=*/2, /*payload_size=*/5002, "tbl_sh"); + const BlobRef ch_ref = ch.ref; + const BlobRef sh_ref = sh.ref; + const String ch_key = ch.key; + const String sh_key = sh.key; + + /// A FOREIGN object under an algo segment `blobHashAlgoName` never renders ("md5") -- not one of + /// ours under any circumstance. + const String foreign_key = store->layout().blobsPrefix() + "md5/aa/" + std::string(32, 'a'); + backend->putIfAbsent(foreign_key, std::string("not a real envelope")); + + runRegularRoundReclaiming(gc); /// folds both +1s + dropSeededRef(*store, *backend, ns, /*build_sequence=*/1, "tbl_ch"); + dropSeededRef(*store, *backend, ns, /*build_sequence=*/2, "tbl_sh"); + runRegularRoundReclaiming(gc); /// folds both -1s: both transition to zero + + /// The fold condemns exactly the two OWN blobs -- never the foreign object. + const std::vector preview = gc.previewDeletes(); + ASSERT_EQ(preview.size(), 2u); + std::unordered_set condemned_refs; + for (const auto & p : preview) + { + condemned_refs.insert(p.ref); + EXPECT_NE(p.key, foreign_key); + } + EXPECT_TRUE(condemned_refs.count(ch_ref)); + EXPECT_TRUE(condemned_refs.count(sh_ref)); + EXPECT_TRUE(backend->head(foreign_key).exists) << "the foreign object must never be touched by the fold"; + + const FsckReport frep = runFsck(*store, /*detail=*/true); + /// The physical listing counts all THREE unreferenced objects (two ours + one foreign). + EXPECT_EQ(frep.unreachable, 3u); + const auto foreign_obj = std::find_if(frep.objects.begin(), frep.objects.end(), + [&](const FsckObject & o) { return o.key == foreign_key; }); + ASSERT_NE(foreign_obj, frep.objects.end()) << "the foreign object must still appear in the physical listing"; + /// ... but classified as generic Unaccounted -- it can never pair against the GC snapshot, which + /// only ever knows about OUR two algo-segmented refs. + EXPECT_EQ(foreign_obj->cls, FsckClass::Unaccounted); + + /// The two OWN blobs are recognized under their OWN algo segment in the SAME pass. + const auto ch_obj = std::find_if(frep.objects.begin(), frep.objects.end(), + [&](const FsckObject & o) { return o.key == ch_key; }); + const auto sh_obj = std::find_if(frep.objects.begin(), frep.objects.end(), + [&](const FsckObject & o) { return o.key == sh_key; }); + ASSERT_NE(ch_obj, frep.objects.end()); + ASSERT_NE(sh_obj, frep.objects.end()); + EXPECT_EQ(ch_obj->cls, FsckClass::PendingGc); + EXPECT_EQ(sh_obj->cls, FsckClass::PendingGc); +} + +/// ============================================================================================ +/// CAS reader-generation gate (`Core/Formats/CasFormat.h`'s `G_BUILD`) was raised to 4 for +/// per-namespace contiguous ref-log ids (INV-1) and has since moved again, to 5, for Stage B's +/// namespace-life-keyed ref layer ("format bump B", `kNamespaceLifeKeyedGeneration`) -- this test's +/// assertions read `G_BUILD` itself rather than a hardcoded generation number for exactly that reason, +/// so a THIRD bump does not silently make them false. `PoolMeta::createOrValidate`'s open-time +/// CAS-raise targets `G_BUILD`, and `decodePoolMeta` fail-closes BOTH on a FUTURE +/// `min_reader_generation` AND on a BACKWARD pool whose header `compatibility_version` is below +/// `kNamespaceLifeKeyedGeneration` (which, being the LATER of the two historical breaking-change +/// floors, subsumes `kContiguousRefStreamsGeneration` -- see `CasPoolMetaFormat.cpp`). +/// ============================================================================================ + +TEST(CASPluggableHash, ReaderGenerationIsRaisedToGBuild) +{ + EXPECT_GE(G_BUILD, kNamespaceLifeKeyedGeneration) + << "the reader-generation gate must be at least the namespace-life-keyed floor it enforces"; + + /// A freshly opened/created pool records `min_reader_generation == G_BUILD` (the open-time + /// CAS-raise, `PoolMeta::createOrValidate`, always targets this build's own floor). + { + auto backend = std::make_shared(); + auto store = Pool::open(backend, PoolConfig{.pool_prefix = "p", .server_root_id = "test"}); + EXPECT_EQ(store->poolMeta().min_reader_generation, G_BUILD); + + const auto meta_bytes = backend->get(store->layout().poolMetaKey()); + ASSERT_TRUE(meta_bytes.has_value()); + EXPECT_EQ(decodePoolMeta(meta_bytes->bytes).min_reader_generation, G_BUILD); + } + + /// FORWARD gate: a pool-meta carrying `min_reader_generation == G_BUILD + 1` (one generation past + /// THIS build's floor) still fails closed at open -- the startup gate (`decodePoolMeta`) rejects it + /// even though generation 4 is now understood. + { + auto backend = std::make_shared(); + const Layout layout("p"); + PoolMeta pm = PoolMeta::createOrValidate(*backend, layout, /*blob_header_len*/ 256, BlobHashAlgo::CityHash128, /*allow_new*/ false, /*allow_mint*/ true); + pm.min_reader_generation = G_BUILD + 1; + ASSERT_TRUE(backend->casPut(layout.poolMetaKey(), encodePoolMeta(pm), backend->get(layout.poolMetaKey())->token).outcome == CasOutcome::Committed); + + expectThrowsCode(DB::ErrorCodes::UNKNOWN_FORMAT_VERSION, [&] + { Pool::open(backend, PoolConfig{.pool_prefix = "p", .server_root_id = "test"}); }); + } + + /// BACKWARD floor: a pool whose header `v` (compatibility_version) is BELOW `G_BUILD` was written + /// by an older build this reader can no longer trust -- today that is one generation short of + /// `kNamespaceLifeKeyedGeneration`, a pool whose ref-object keys carry no incarnation segment, + /// which this build's parsers refuse as corruption rather than read. Craft it at the text layer: + /// take a fresh pool-meta and rewrite its line-1 version gate down to `G_BUILD - 1` (an older + /// build would have stamped exactly that). + { + auto backend = std::make_shared(); + const Layout layout("p"); + PoolMeta pm = PoolMeta::createOrValidate(*backend, layout, /*blob_header_len*/ 256, BlobHashAlgo::CityHash128, /*allow_new*/ false, /*allow_mint*/ true); + const String fresh_bytes = encodePoolMeta(pm); + + const String from = "\"v\":" + std::to_string(G_BUILD); + const String to = "\"v\":" + std::to_string(G_BUILD - 1); + const auto pos = fresh_bytes.find(from); + ASSERT_NE(pos, String::npos); // sanity: a fresh pool stamps the header at the floor + String downgraded = fresh_bytes; + downgraded.replace(pos, from.size(), to); + ASSERT_TRUE(backend->casPut(layout.poolMetaKey(), downgraded, backend->get(layout.poolMetaKey())->token).outcome == CasOutcome::Committed); + + /// `decodePoolMeta`'s backward floor rejects the downgraded bytes directly... + expectThrowsCode(DB::ErrorCodes::UNKNOWN_FORMAT_VERSION, [&] { decodePoolMeta(downgraded); }); + /// ...and so does a full `Pool::open` (decoding the pool-meta is its first fail-closed step). + expectThrowsCode(DB::ErrorCodes::UNKNOWN_FORMAT_VERSION, [&] + { Pool::open(backend, PoolConfig{.pool_prefix = "p", .server_root_id = "test"}); }); + } +} + +/// ============================================================================================ +/// CAS mixed-algo pools Phase 3 T6: +/// cross-cutting cruxes over a pool that genuinely mixes algos end-to-end (reclaim + distinctness). +/// The no-bare-digest grep gates (design Step 3) are run separately, not as gtest bodies. +/// ============================================================================================ + +/// spec §9.3 -- THE reclaim crux. A pool admits BOTH `ch128` and `sha256`; a blob body is +/// planted directly under EACH algo's segment (mirrors `Sha256BlobSeenByCondemnSweepAndFsckNotSilentlySkipped`'s +/// fixture, widened to two algos). The fold must condemn BOTH into the SAME baseline +/// (`previewDeletes` surfaces both refs), and driving the round-paced pipeline to completion (graduate, +/// then the exact-token delete) must reclaim BOTH bodies -- the backend ends up holding ZERO blob +/// bytes of EITHER algo, and fsck reports clean. +/// +/// MUST GO RED if any settlement/graduation/delete path silently narrows to one algo -- e.g. a fold +/// that only accounts `blobs/ch128/`, a graduation/delete loop that iterates a digest-only set and +/// coalesces the two algos' entries, or an fsck reachability check that stops after the first algo it +/// sees. +TEST(CASPluggableHash, TwoAlgoBlobsBothFullyReclaimed) +{ + auto backend = std::make_shared(); + auto store = Pool::open(backend, + PoolConfig{.pool_prefix = "p", .server_root_id = "test", + .blob_hash_algo = BlobHashAlgo::CityHash128, .gc_fold_max_defer_rounds = 0}); + /// Admit sha256 into the SAME pool from a second mount, then pull the union into `store`'s cache + /// (mirrors `ForeignAlgoSegmentIsDebrisNotOurs`'s admission fixture). + Pool::open(backend, + PoolConfig{.pool_prefix = "p", .server_root_id = "test2", + .blob_hash_algo = BlobHashAlgo::Sha256, .blob_hash_allow_new = true}); + store->refreshAdmittedAlgos(); + ASSERT_TRUE(store->isAlgoAdmitted(BlobHashAlgo::Sha256)); + + /// Two of the pool's OWN blobs -- one per algo -- referenced then dropped, so the fold condemns + /// both by transition-to-zero. + const RootNamespace ns{"00/aa@cas@"}; + Gc gc(store, UInt128(1)); + const SeededBlob ch = seedReferencedBlob(*store, *backend, ns, BlobHashAlgo::CityHash128, + /*build_sequence=*/1, /*payload_size=*/5001, "tbl_ch"); + const SeededBlob sh = seedReferencedBlob(*store, *backend, ns, BlobHashAlgo::Sha256, + /*build_sequence=*/2, /*payload_size=*/5002, "tbl_sh"); + const String ch_key = ch.key; + const String sh_key = sh.key; + ASSERT_TRUE(backend->head(ch_key).exists); + ASSERT_TRUE(backend->head(sh_key).exists); + + runRegularRoundReclaiming(gc); /// folds both +1s + dropSeededRef(*store, *backend, ns, /*build_sequence=*/1, "tbl_ch"); + dropSeededRef(*store, *backend, ns, /*build_sequence=*/2, "tbl_sh"); + runRegularRoundReclaiming(gc); /// folds both -1s: both condemned in the same round + + /// previewDeletes covers BOTH refs from the adopted seal -- never just one algo. + { + const std::vector preview = gc.previewDeletes(); + ASSERT_EQ(preview.size(), 2u); + std::unordered_set refs; + for (const auto & p : preview) + refs.insert(p.ref); + EXPECT_TRUE(refs.count(ch.ref)); + EXPECT_TRUE(refs.count(sh.ref)); + } + + /// Drive the round-paced pipeline to actual physical deletion: the fold condemned both at its + /// round; the VERY NEXT round graduates them (unconditionally, round-paced); the round after that + /// executes the exact-token delete for both. + { + const RoundReport rep1 = runRegularRoundReclaiming(gc); + EXPECT_EQ(rep1.graduated, 2u) << "both algos' blobs must graduate together in one round"; + EXPECT_TRUE(backend->head(ch_key).exists); // pending: still present this pass + EXPECT_TRUE(backend->head(sh_key).exists); + } + { + const RoundReport rep2 = runRegularRoundReclaiming(gc); + EXPECT_EQ(rep2.redeleted, 2u) << "both algos' pending deletes must execute together in one round"; + } + + /// THE CRUX: after graduation the backend holds ZERO blob bodies of EITHER algo. + EXPECT_FALSE(backend->head(ch_key).exists) << "the ch128 blob must be physically reclaimed"; + EXPECT_FALSE(backend->head(sh_key).exists) << "the sha256 blob must be physically reclaimed"; + + const FsckReport frep = runFsck(*store, /*detail=*/true); + EXPECT_TRUE(frep.clean()); + EXPECT_EQ(frep.dangling, 0u); +} + +/// spec §9.5 -- same-digest-different-algo end-to-end. `ch128:X` and `xxh3:X` share the SAME 16-byte +/// digest VALUE but are DISTINCT blob identities (`BlobRef` is the pair): distinct object keys, distinct +/// `.meta`, distinct bodies, distinct settlement rows (fold both -> distinct in-degree per ref), and +/// dropping ONE ref's committed manifest reclaims ONLY that algo's blob -- the other stays fully +/// readable throughout. +/// +/// MUST GO RED if anything upstream of `BlobRef` ever collapses identity to the bare digest (e.g. a +/// settlement/meta/condemn site keyed on `BlobDigest` alone) -- the two blobs would alias into one row +/// and dropping one ref would (wrongly) reclaim or corrupt the other. +TEST(CASPluggableHash, SameDigestDifferentAlgoDistinctBodiesAndSettlement) +{ + auto backend = std::make_shared(); + auto store = Pool::open(backend, + PoolConfig{.pool_prefix = "p", .server_root_id = "test", + .blob_hash_algo = BlobHashAlgo::CityHash128, .gc_fold_max_defer_rounds = 0}); + Pool::open(backend, + PoolConfig{.pool_prefix = "p", .server_root_id = "test2", + .blob_hash_algo = BlobHashAlgo::XXH3_128, .blob_hash_allow_new = true}); + store->refreshAdmittedAlgos(); + ASSERT_TRUE(store->isAlgoAdmitted(BlobHashAlgo::XXH3_128)); + + /// SAME 16-byte digest VALUE under two different algos -- deliberately NOT derived from either + /// body's real content hash: the crux under test is identity distinctness (the pair), not hash + /// correctness (already covered by the sha256/xxh3 write-path tests above). + const BlobDigest shared_digest = BlobDigest::fromU128(UInt128(0xC0FFEE)); + const BlobRef ref_ch{BlobHashAlgo::CityHash128, shared_digest}; + const BlobRef ref_xx{BlobHashAlgo::XXH3_128, shared_digest}; + /// Distinct content, not merely distinct length: `makeMultiBlockPayload` at two different sizes + /// would make the shorter body a byte-for-byte PREFIX of the longer one (same repeating pattern + /// from the same phase), which would defeat the "must not contain" assertions below. + const std::string body_ch = makeMultiBlockPayload(4001); + std::string body_xx = makeMultiBlockPayload(4002); + std::reverse(body_xx.begin(), body_xx.end()); + ASSERT_NE(body_ch, body_xx); + + const RootNamespace ns{"srv1/tbl"}; + + PartWriteInfo info_a; + info_a.intended_ref = ns.string() + "/part_a"; + auto build_a = store->beginPartWrite(info_a); + build_a->putBlob(ref_ch, BlobSource::fromString(body_ch)); + ManifestEntry e_a; + e_a.path = "a.bin"; e_a.placement = EntryPlacement::Blob; e_a.ref = ref_ch; e_a.blob_size = body_ch.size(); + const ManifestId mid_a = build_a->stageManifest({e_a}); + build_a->precommitAdd(ns, "part_a", mid_a); + build_a->promote(ns, "part_a", build_a->buildId(), mid_a); + + PartWriteInfo info_b; + info_b.intended_ref = ns.string() + "/part_b"; + auto build_b = store->beginPartWrite(info_b); + build_b->putBlob(ref_xx, BlobSource::fromString(body_xx)); + ManifestEntry e_b; + e_b.path = "b.bin"; e_b.placement = EntryPlacement::Blob; e_b.ref = ref_xx; e_b.blob_size = body_xx.size(); + const ManifestId mid_b = build_b->stageManifest({e_b}); + build_b->precommitAdd(ns, "part_b", mid_b); + build_b->promote(ns, "part_b", build_b->buildId(), mid_b); + store->renewWatermarkOnce(); + + /// Distinct object keys and distinct bodies despite the SAME digest value. + const String key_ch = store->layout().blobKey(ref_ch); + const String key_xx = store->layout().blobKey(ref_xx); + EXPECT_NE(key_ch, key_xx); + const auto raw_ch = backend->get(key_ch); + const auto raw_xx = backend->get(key_xx); + ASSERT_TRUE(raw_ch.has_value()); + ASSERT_TRUE(raw_xx.has_value()); + EXPECT_NE(raw_ch->bytes.find(body_ch), String::npos); + EXPECT_NE(raw_xx->bytes.find(body_xx), String::npos); + EXPECT_EQ(raw_ch->bytes.find(body_xx), String::npos) << "the ch128 body must not contain the xxh3 payload"; + EXPECT_EQ(raw_xx->bytes.find(body_ch), String::npos) << "the xxh3 body must not contain the ch128 payload"; + + /// Distinct `.meta` objects. + const String meta_ch = store->layout().blobMetaKey(ref_ch); + const String meta_xx = store->layout().blobMetaKey(ref_xx); + EXPECT_NE(meta_ch, meta_xx); + EXPECT_TRUE(backend->head(meta_ch).exists); + EXPECT_TRUE(backend->head(meta_xx).exists); + + /// Distinct settlement (in-degree per ref, keyed on the FULL `BlobRef` pair -- never the shared + /// bare digest, which would alias the two rows into one). + Gc gc(store, UInt128(1)); + runRegularRoundReclaiming(gc); + { + const GcState st = decodeGcState(backend->get(store->layout().gcStateKey())->bytes); + const CasFoldSeal seal = decodeFoldSeal( + backend->get(store->layout().foldSealKey(st.snap_generation, st.snap_attempt))->bytes); + EXPECT_EQ(inDegreeInRuns(*backend, seal.blob_target_runs, ref_ch), 1); + EXPECT_EQ(inDegreeInRuns(*backend, seal.blob_target_runs, ref_xx), 1); + } + + /// Dropping ONLY `part_a`'s committed ref condemns+reclaims ONLY `ch128:X`; `xxh3:X` (the SAME + /// digest value, a DIFFERENT algo) stays referenced and fully readable throughout. + store->dropRef(ns, "part_a"); + runRegularRoundReclaiming(gc); // condemns ch128:X (in-degree drops to 0); xxh3:X is untouched (still ref'd) + runRegularRoundReclaiming(gc); // graduates ch128:X + runRegularRoundReclaiming(gc); // executes the exact-token delete for ch128:X + + EXPECT_FALSE(backend->head(key_ch).exists) << "ch128:X must be reclaimed once its ref is dropped"; + EXPECT_TRUE(backend->head(key_xx).exists) + << "THE CRUX: xxh3:X (same digest value, different algo) must remain readable after ch128:X " + "is reclaimed -- a digest-only settlement would have condemned/deleted both together"; + const auto still_readable = backend->get(key_xx); + ASSERT_TRUE(still_readable.has_value()); + EXPECT_NE(still_readable->bytes.find(body_xx), String::npos); + + const FsckReport frep = runFsck(*store, /*detail=*/true); + EXPECT_TRUE(frep.clean()); + EXPECT_EQ(frep.dangling, 0u); +} diff --git a/src/Disks/tests/gtest_cas_pool.cpp b/src/Disks/tests/gtest_cas_pool.cpp new file mode 100644 index 000000000000..53f19ed32738 --- /dev/null +++ b/src/Disks/tests/gtest_cas_pool.cpp @@ -0,0 +1,2079 @@ +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace DB::ErrorCodes +{ +extern const int ABORTED; +extern const int BAD_ARGUMENTS; +extern const int CORRUPTED_DATA; +extern const int NOT_IMPLEMENTED; +extern const int UNKNOWN_FORMAT_VERSION; +extern const int FILE_DOESNT_EXIST; +extern const int UNKNOWN_EXCEPTION; +extern const int NETWORK_ERROR; +} + +namespace ProfileEvents +{ +extern const Event CASRefRecoveryEpochSealed; +extern const Event CASMountExclusivityViolation; +} + +using namespace DB::Cas; +using DB::Cas::tests::blobEntryFor; +using DB::Cas::tests::expectThrowsCode; +using DB::Cas::tests::idOf; +using DB::Cas::tests::u128Of; + +namespace +{ +/// Counts mutating backend calls so a test can assert an open path is write-free. +class WriteCountingBackend final : public DB::Cas::Backend +{ +public: + explicit WriteCountingBackend(std::shared_ptr inner_) : inner(std::move(inner_)) {} + size_t writes = 0; + + std::optional get(const String & k, DB::Cas::Range r) override { return inner->get(k, r); } + std::optional getStream(const String & k, DB::Cas::Range r) override { return inner->getStream(k, r); } + DB::Cas::HeadResult head(const String & k) override { return inner->head(k); } + DB::Cas::ListPage list(const String & p, const String & c, size_t l) override { return inner->list(p, c, l); } + DB::Cas::PutResult putIfAbsent(const String & k, const String & b, const DB::Cas::ObjectMeta & meta) override { ++writes; return inner->putIfAbsent(k, b, meta); } + DB::Cas::WriteSinkPtr putIfAbsentStream(const String & k, const DB::Cas::ObjectMeta & meta) override { ++writes; return inner->putIfAbsentStream(k, meta); } + DB::Cas::PutResult putOverwrite(const String & k, const String & b, const DB::Cas::Token & e, const DB::Cas::ObjectMeta & meta) override { ++writes; return inner->putOverwrite(k, b, e, meta); } + DB::Cas::CasResult casPut(const String & k, const String & b, const std::optional & e, const DB::Cas::ObjectMeta & meta) override { ++writes; return inner->casPut(k, b, e, meta); } + DB::Cas::DeleteOutcome deleteExact(const String & k, const DB::Cas::Token & t) override { ++writes; return inner->deleteExact(k, t); } + bool supportsListTokens() const override { return inner->supportsListTokens(); } +private: + std::shared_ptr inner; +}; + +/// Publish one part `ref` through the REAL PartWriteTxn write path: stage a manifest holding a single content +/// blob whose payload is `payload`, precommit-add into the owning shard, then promote precommit -> +/// committed. Returns the published ManifestId. This is the canonical write-side fixture for the +/// read-path tests (the same shape as `publishPart` in gtest_cas_gc_log.cpp). The manifest entry path +/// is `data.bin` unless `entry_path` overrides it. +ManifestId publishPart( + const PoolPtr & s, const String & ns, const String & ref, const String & payload, + const String & entry_path = "data.bin") +{ + const RootNamespace nsr{ns}; + PartWriteInfo info; + info.intended_ref = ns + "/" + ref; + auto build = s->beginPartWrite(info); + build->putBlob(idOf(payload), BlobSource::fromString(payload)); + + ManifestEntry e; + e.path = entry_path; + e.placement = EntryPlacement::Blob; + e.ref = DB::Cas::BlobRef{DB::Cas::BlobHashAlgo::CityHash128, DB::Cas::BlobDigest::fromU128(u128Of(payload))}; + + e.blob_size = payload.size(); + + const ManifestId id = build->stageManifest({e}); + build->precommitAdd(nsr, ref, id); + build->promote(nsr, ref, build->buildId(), id); + return id; +} + +/// A ManifestRef carrying a unique instance id derived from `tag` (all fields explicit so the +/// missing-designated-field-initializer warning never fires). The writer/build fields are stable test +/// constants — the read path keys identity by the full ref, so any consistent choice works here. +ManifestRef manifestRefFor(const String & tag) +{ + uint32_t ordinal = 1; + for (char c : tag) + ordinal = ordinal * 131 + static_cast(c); + ordinal = ordinal % 999999 + 1; + return ManifestRef{ + .writer_epoch = 1, + .build_sequence = 1, + .manifest_ordinal = ordinal}; +} + +/// Publish a part holding the given manifest entries verbatim through the real PartWriteTxn. Used by read-path +/// lookup/list tests that want a precise multi-entry manifest. Each Blob entry's body MUST be present at +/// promote: the promote gate revalidates EVERY blob leaf with a HEAD and fails closed on an absent body. +/// So write a blob body for each Blob entry (addressed by its hash) and record it as W-EVIDENCE before +/// staging. Inline entries need no body. Returns the published ManifestId. +ManifestId publishPartWithEntries( + const PoolPtr & s, const String & ns, const String & ref, std::vector entries) +{ + const RootNamespace nsr{ns}; + PartWriteInfo info; + info.intended_ref = ns + "/" + ref; + auto build = s->beginPartWrite(info); + for (const auto & e : entries) + if (e.placement == EntryPlacement::Blob) + { + /// Materialize the blob body so the promote-time HEAD revalidation succeeds, then record the + /// tokenless W-EVIDENCE dep (the gate re-observes the current token at promote). + DB::Cas::tests::writeBlobBody(s->backend(), s->layout(), e.ref.digest.toU128()); + build->adoptEvidence(e); + } + const ManifestId id = build->stageManifest(std::move(entries)); + build->precommitAdd(nsr, ref, id); + build->promote(nsr, ref, build->buildId(), id); + return id; +} +} + +TEST(CASPool, ReadOnlyOpenSkipsProbe) +{ + auto shared = std::make_shared(); + + DB::Cas::PoolConfig cfg; + cfg.pool_prefix = "pool"; + cfg.server_id = DB::UInt128(1); + cfg.server_root_id = "test"; + /// Writable open: creates _pool_meta and runs the probe (which writes+cleans up). + DB::Cas::Pool::open(std::make_shared(shared), cfg); + + /// Read-only re-open over the SAME data must perform ZERO writes (no probe, meta already present). + auto counter = std::make_shared(shared); + DB::Cas::PoolConfig ro = cfg; + ro.read_only = true; + auto store = DB::Cas::Pool::open(counter, ro); + EXPECT_EQ(counter->writes, 0u); + ASSERT_NE(store, nullptr); +} + +namespace +{ +/// Records whether any MUTATING op touched a `_probe/` key, so a test can assert an open ran (or +/// skipped) the capability probe. Mirrors WriteCountingBackend above but keys on the probe subtree. +class ProbeWatchingBackend final : public DB::Cas::Backend +{ +public: + explicit ProbeWatchingBackend(std::shared_ptr inner_) : inner(std::move(inner_)) {} + bool probe_touched = false; + + std::optional get(const String & k, DB::Cas::Range r) override { return inner->get(k, r); } + std::optional getStream(const String & k, DB::Cas::Range r) override { return inner->getStream(k, r); } + DB::Cas::HeadResult head(const String & k) override { return inner->head(k); } + DB::Cas::ListPage list(const String & p, const String & c, size_t l) override { return inner->list(p, c, l); } + DB::Cas::PutResult putIfAbsent(const String & k, const String & b, const DB::Cas::ObjectMeta & m) override { note(k); return inner->putIfAbsent(k, b, m); } + DB::Cas::WriteSinkPtr putIfAbsentStream(const String & k, const DB::Cas::ObjectMeta & m) override { note(k); return inner->putIfAbsentStream(k, m); } + DB::Cas::PutResult putOverwrite(const String & k, const String & b, const DB::Cas::Token & e, const DB::Cas::ObjectMeta & m) override { note(k); return inner->putOverwrite(k, b, e, m); } + DB::Cas::CasResult casPut(const String & k, const String & b, const std::optional & e, const DB::Cas::ObjectMeta & m) override { note(k); return inner->casPut(k, b, e, m); } + DB::Cas::DeleteOutcome deleteExact(const String & k, const DB::Cas::Token & t) override { note(k); return inner->deleteExact(k, t); } + bool supportsListTokens() const override { return inner->supportsListTokens(); } +private: + void note(const String & k) { if (k.find("/_probe/") != String::npos) probe_touched = true; } + std::shared_ptr inner; +}; +} + +TEST(CASPool, SkipAccessCheckOpenSkipsProbeButStaysWritable) +{ + auto shared = std::make_shared(); + + DB::Cas::PoolConfig cfg; + cfg.pool_prefix = "pool"; + cfg.server_id = DB::UInt128(1); + cfg.server_root_id = "srv-1"; + + /// Baseline: a normal writable open runs the capability probe (PUT+delete of `_probe/` keys). + { + auto watch = std::make_shared(shared); + auto s = DB::Cas::Pool::open(watch, cfg); + ASSERT_NE(s, nullptr); + EXPECT_TRUE(watch->probe_touched) << "the probe must run by default"; + } + + /// skip_access_check open ("start now, fix later"): NO probe I/O, yet still a WRITABLE mount + /// (owner/epoch/mount/watermark bootstrap writes still happen — unlike a read_only open, which is + /// a total no-op). Distinct root over the same (now-created) pool. + { + auto watch = std::make_shared(shared); + DB::Cas::PoolConfig sac = cfg; + sac.server_id = DB::UInt128(2); + sac.server_root_id = "srv-2"; + sac.skip_access_check = true; + auto s = DB::Cas::Pool::open(watch, sac); + ASSERT_NE(s, nullptr); + EXPECT_FALSE(watch->probe_touched) << "skip_access_check must perform no probe I/O"; + + /// Prove the mount is genuinely WRITABLE, not merely non-null — a read_only open would also + /// satisfy the two assertions above. Publish a part through the real PartWriteTxn write path + /// (beginPartWrite/putBlob/stageManifest/precommitAdd/promote) and read it back. + publishPart(s, "srv-2/tbl", "part_1", "payload-x"); + const auto r = s->resolveRef(DB::Cas::RootNamespace{"srv-2/tbl"}, "part_1"); + ASSERT_TRUE(r.has_value()) << "skip_access_check open must accept real writes, not just open"; + } +} + +namespace +{ +/// A backend whose checkConditionalWriteSingleAttemptSupport ALWAYS throws — a stand-in for a +/// Native-mode backend with no working single-attempt client (see +/// ObjectStorageBackend::checkConditionalWriteSingleAttemptSupport). Pins that skip_access_check does +/// NOT bypass this gate: the regression this guards is reverting Pool::open's skip_access_check +/// branch back to the naive "wrap the whole probe" shape, which would silently skip this check too. +class ThrowingSingleAttemptBackend final : public DB::Cas::Backend +{ +public: + explicit ThrowingSingleAttemptBackend(std::shared_ptr inner_) : inner(std::move(inner_)) {} + + std::optional get(const String & k, DB::Cas::Range r) override { return inner->get(k, r); } + std::optional getStream(const String & k, DB::Cas::Range r) override { return inner->getStream(k, r); } + DB::Cas::HeadResult head(const String & k) override { return inner->head(k); } + DB::Cas::ListPage list(const String & p, const String & c, size_t l) override { return inner->list(p, c, l); } + DB::Cas::PutResult putIfAbsent(const String & k, const String & b, const DB::Cas::ObjectMeta & m) override { return inner->putIfAbsent(k, b, m); } + DB::Cas::WriteSinkPtr putIfAbsentStream(const String & k, const DB::Cas::ObjectMeta & m) override { return inner->putIfAbsentStream(k, m); } + DB::Cas::PutResult putOverwrite(const String & k, const String & b, const DB::Cas::Token & e, const DB::Cas::ObjectMeta & m) override { return inner->putOverwrite(k, b, e, m); } + DB::Cas::CasResult casPut(const String & k, const String & b, const std::optional & e, const DB::Cas::ObjectMeta & m) override { return inner->casPut(k, b, e, m); } + DB::Cas::DeleteOutcome deleteExact(const String & k, const DB::Cas::Token & t) override { return inner->deleteExact(k, t); } + bool supportsListTokens() const override { return inner->supportsListTokens(); } + void checkConditionalWriteSingleAttemptSupport() override + { + throw DB::Exception(DB::ErrorCodes::NOT_IMPLEMENTED, "test: no single-attempt client"); + } +private: + std::shared_ptr inner; +}; +} + +TEST(CASPool, SkipAccessCheckStillEnforcesSingleAttemptGate) +{ + auto backend = std::make_shared(std::make_shared()); + + DB::Cas::PoolConfig cfg; + cfg.pool_prefix = "pool"; + cfg.server_id = DB::UInt128(1); + cfg.server_root_id = "test"; + cfg.skip_access_check = true; + + /// skip_access_check must NOT bypass checkConditionalWriteSingleAttemptSupport (RFC + /// cas-s3-timeout-retry-control): a writable open still refuses to mount on a backend that cannot + /// prove single-attempt conditional-write support, exactly as it does without skip_access_check. + EXPECT_THROW(DB::Cas::Pool::open(backend, cfg), DB::Exception); +} + +TEST(CASPool, MinActiveTracksInFlightBuilds) +{ + auto backend = std::make_shared(); + DB::Cas::PoolConfig cfg; + cfg.pool_prefix = "pool"; + cfg.server_id = DB::UInt128(1); + cfg.server_root_id = "test"; + cfg.background_watermark = false; + auto store = DB::Cas::Pool::open(backend, cfg); + + ASSERT_EQ(store->minActive(), store->peekNextBuildSeq()); /// no builds: floor == next seq + auto b1 = store->beginPartWrite({}); /// seq 1 + auto b2 = store->beginPartWrite({}); /// seq 2 + ASSERT_EQ(store->minActive(), 1u); + b1->abandon(); /// finishes seq 1 + ASSERT_EQ(store->minActive(), 2u); /// floor advances + b2->abandon(); + ASSERT_EQ(store->minActive(), store->peekNextBuildSeq()); /// empty again +} + +/// A throwing audit sink must NOT break a storage operation. The single reentrancy-safe event +/// dispatcher (stage-1 §1, Task 2) CONTAINS sink exceptions ("never throws through"), so an arbitrary +/// observer/sink callback failing during `beginPartWrite` is swallowed and construction succeeds -- +/// consistent with `CASPartWriteTxn.AbandonSwallowsThrowingEventSink` and +/// `PromoteSwallowsPostDurableEventSinkFailure`, which already establish that an audit-sink failure +/// never aborts the operation. Before Task 2 the sink was invoked directly and its exception +/// propagated out of construction (audit-log backpressure breaking a write); the dispatcher removes +/// that. The build_seq lifecycle is still exercised: the in-flight build holds the `minActive` GC +/// floor and is retired on `abandon`. +TEST(CASPool, BeginPartWriteSwallowsThrowingEventSink) +{ + auto backend = std::make_shared(); + DB::Cas::PoolConfig cfg; + cfg.pool_prefix = "pool"; + cfg.server_id = DB::UInt128(1); + cfg.server_root_id = "test"; + cfg.background_watermark = false; + auto store = DB::Cas::Pool::open(backend, cfg); + + const uint64_t next_seq = store->peekNextBuildSeq(); + /// UNKNOWN_EXCEPTION (not LOGICAL_ERROR): this simulates an arbitrary observer/sink callback + /// failing, not a CAS invariant violation -- LOGICAL_ERROR would abort the whole process under + /// debug/sanitizer builds instead of behaving like a catchable exception. + store->setEventSink([](const CasEvent & e) + { + if (e.type == CasEventType::BuildStart) + throw DB::Exception(DB::ErrorCodes::UNKNOWN_EXCEPTION, "injected audit sink failure"); + }); + + PartWriteTxnPtr build; + ASSERT_NO_THROW({ build = store->beginPartWrite({}); }) + << "a throwing audit sink must be contained by the dispatcher, not fail construction"; + store->setEventSink(nullptr); + + EXPECT_EQ(build->buildSeq(), next_seq); + EXPECT_EQ(store->peekNextBuildSeq(), next_seq + 1); + EXPECT_EQ(store->minActive(), build->buildSeq()); /// the in-flight build holds the floor + build->abandon(); + EXPECT_EQ(store->minActive(), store->peekNextBuildSeq()); /// retired on abandon +} + +TEST(CASPool, BuildSeqIsStrictlyMonotone) +{ + auto backend = std::make_shared(); + DB::Cas::PoolConfig cfg; + cfg.pool_prefix = "pool"; + cfg.server_id = DB::UInt128(1); + cfg.server_root_id = "test"; + cfg.background_watermark = false; + auto store = DB::Cas::Pool::open(backend, cfg); + auto a = store->beginPartWrite({}); + auto sa = a->buildSeq(); + a->abandon(); + auto b = store->beginPartWrite({}); + ASSERT_GT(b->buildSeq(), sa); /// never reused, never lower +} + +TEST(CASPoolMeta, CreateThenReopen) +{ + auto b = std::make_shared(); + Layout layout("p"); + PoolMeta created = PoolMeta::createOrValidate(*b, layout, /*blob_header_len*/ 256, + BlobHashAlgo::CityHash128, /*allow_new*/ false, /*allow_mint*/ true); + EXPECT_NE(created.pool_id, UInt128{}); + PoolMeta reopened = PoolMeta::createOrValidate(*b, layout, /*blob_header_len*/ 512); + EXPECT_EQ(reopened.pool_id, created.pool_id); /// pool is authoritative — config ignored on reopen + EXPECT_EQ(reopened.blob_header_len, 256u); +} + +TEST(CASPoolMeta, FailClosed) +{ + Layout layout("p"); + /// Garbage bytes are not a valid cas_pool_meta text object => CORRUPTED_DATA at the header line + /// (createOrValidate path). The future-version fail-closed (v > G_BUILD => UNKNOWN_FORMAT_VERSION) + /// is exercised at the codec level by the battery's per-row v+1 gate. + auto b2 = std::make_shared(); + b2->putIfAbsent(layout.poolMetaKey(), "garbage"); + expectThrowsCode(DB::ErrorCodes::CORRUPTED_DATA, + [&] { PoolMeta::createOrValidate(*b2, layout, 256); }); +} + +TEST(CASPoolMeta, RoundTripAndReadability) +{ + PoolMeta pm; + pm.pool_id = hexToU128("0123456789abcdeffedcba9876543210"); + pm.blob_header_len = 256; + pm.algos_used = {static_cast(BlobHashAlgo::CityHash128)}; + + const String encoded = encodePoolMeta(pm); + /// v3 text form: a header line + one JSON body object, human-readable (jq/less friendly). No binary + /// magic; the object starts with '{' and names its type so a reader can identify it by eye. + ASSERT_GE(encoded.size(), 8u); + EXPECT_EQ(encoded.front(), '{'); + EXPECT_NE(encoded.find(String("cas_pool_meta")), String::npos); + EXPECT_EQ(encoded.find(String("CAPM")), String::npos); + + PoolMeta decoded = decodePoolMeta(encoded); + EXPECT_EQ(decoded.pool_id, pm.pool_id); + EXPECT_EQ(decoded.blob_header_len, pm.blob_header_len); +} + +TEST(CASPoolMeta, RejectsBadConstantsAtCreation) +{ + auto b = std::make_shared(); + Layout layout("p"); + + /// not 8-aligned (above the floor, so it is the alignment rule that rejects it) + expectThrowsCode(DB::ErrorCodes::BAD_ARGUMENTS, + [&] { PoolMeta::createOrValidate(*b, layout, 250); }); + /// below the v3 envelope floor (240) but 8-aligned: rejected by the floor, not the alignment rule. + /// Without the raised floor this pool would pass creation and LOGICAL_ERROR on the first blob write. + expectThrowsCode(DB::ErrorCodes::BAD_ARGUMENTS, + [&] { PoolMeta::createOrValidate(*b, layout, 128); }); + /// well below the floor + expectThrowsCode(DB::ErrorCodes::BAD_ARGUMENTS, + [&] { PoolMeta::createOrValidate(*b, layout, 64); }); + /// above the 16 KiB ceiling + expectThrowsCode(DB::ErrorCodes::BAD_ARGUMENTS, + [&] { PoolMeta::createOrValidate(*b, layout, 17 * 1024); }); + + /// A creation that fails config validation must not have written anything. + EXPECT_FALSE(b->get(layout.poolMetaKey()).has_value()); +} + +TEST(CASPoolMeta, RejectsBadConstantsOnDecode) +{ + auto b = std::make_shared(); + Layout layout("p"); + /// Encode a PoolMeta with blob_header_len=100 (not 8-aligned); decode must reject it as CORRUPTED_DATA. + PoolMeta bad_pm; + bad_pm.pool_id = hexToU128("00000000000000000000000000000001"); + bad_pm.blob_header_len = 100; /// violates 8-alignment invariant + b->putIfAbsent(layout.poolMetaKey(), encodePoolMeta(bad_pm)); + expectThrowsCode(DB::ErrorCodes::CORRUPTED_DATA, + [&] { PoolMeta::createOrValidate(*b, layout, 256); }); +} + +TEST(CASPoolMeta, DecodeGarbageFails) +{ + /// Any non-CAPM framing byte sequence => CORRUPTED_DATA. + expectThrowsCode(DB::ErrorCodes::CORRUPTED_DATA, [] { decodePoolMeta(String("garbage")); }); + expectThrowsCode(DB::ErrorCodes::CORRUPTED_DATA, [] { decodePoolMeta(String("")); }); +} + +TEST(CASPoolMeta, ConcurrentCreateRace) +{ + auto b = std::make_shared(); + Layout layout("p"); + + /// A racing creator already wrote a valid foreign pool_id. createOrValidate must NOT overwrite it: + /// it re-reads (after losing the create-if-absent CAS, or seeing it present) and returns the + /// foreign pool_id, validated like a reopen. + const UInt128 foreign = hexToU128("0123456789abcdeffedcba9876543210"); + PoolMeta foreign_pm; + foreign_pm.pool_id = foreign; + foreign_pm.blob_header_len = 256; + foreign_pm.algos_used = {static_cast(BlobHashAlgo::CityHash128)}; + b->putIfAbsent(layout.poolMetaKey(), encodePoolMeta(foreign_pm)); + + PoolMeta result = PoolMeta::createOrValidate(*b, layout, /*blob_header_len*/ 512); + EXPECT_EQ(result.pool_id, foreign); + EXPECT_EQ(result.blob_header_len, 256u); /// the foreign pool's constants win +} + +TEST(CASPoolMeta, CasConflictReReadsWinner) +{ + /// The subtlest branch: the initial GET sees ABSENT, so createOrValidate proceeds to the + /// create-if-absent casPut — and loses, because a racing creator committed in between. The loser + /// must then re-read and return the WINNER's pool identity, not LOGICAL_ERROR. A single-threaded + /// `failNextCasPut` alone cannot exercise this: it returns Conflict without leaving the object + /// readable, so the re-read would fire the LOGICAL_ERROR guard. We model the real interleaving + /// with a backend whose casPut commits the winner's object (via the public putIfAbsent) and THEN + /// reports Conflict — exactly what the loser observes. + class RacingBackend : public InMemoryBackend + { + public: + String winner_bytes; + CasResult casPut(const String & key, const String & bytes, + const std::optional & expected, const ObjectMeta & meta) override + { + if (!winner_committed) + { + winner_committed = true; + /// The winner lands first; our create-if-absent now necessarily conflicts. + putIfAbsent(key, winner_bytes); + return {CasOutcome::Conflict, {}}; + } + return InMemoryBackend::casPut(key, bytes, expected, meta); + } + private: + bool winner_committed = false; + }; + + const UInt128 winner = hexToU128("0123456789abcdeffedcba9876543210"); + PoolMeta winner_pm; + winner_pm.pool_id = winner; + winner_pm.blob_header_len = 256; + winner_pm.algos_used = {static_cast(BlobHashAlgo::CityHash128)}; + + auto b = std::make_shared(); + b->winner_bytes = encodePoolMeta(winner_pm); + Layout layout("p"); + + /// Our config (512) is what we WOULD have minted, but we lose the race and inherit the winner. + PoolMeta result = PoolMeta::createOrValidate(*b, layout, /*blob_header_len*/ 512, + BlobHashAlgo::CityHash128, /*allow_new*/ false, /*allow_mint*/ true); + EXPECT_EQ(result.pool_id, winner); + EXPECT_EQ(result.blob_header_len, 256u); +} + +TEST(CASPool, OpenFailsClosedOnNonEnforcingBackend) +{ + auto b = std::make_shared(); + b->setEnforceTokens(false); + expectThrowsCode(DB::ErrorCodes::NOT_IMPLEMENTED, + [&] { Pool::open(b, PoolConfig{.pool_prefix = "p", .server_root_id = "test"}); }); /// the probe error contract +} + +TEST(CASPool, OpenCreatesPoolMetaAndReopens) +{ + auto b = std::make_shared(); + /// Two CONCURRENT opens over the same POOL: a shared pool is the multi-server model, so each + /// mounts a DISTINCT server_root_id (and a distinct server_id) — same-root same-uuid co-mounting + /// is correctly fail-closed by the mount-safety protocol. This test only asserts that pool-meta is + /// pool-authoritative and shared across opens. + auto s1 = Pool::open(b, PoolConfig{ + .pool_prefix = "p", .server_id = UInt128(1), .server_root_id = "srv-1"}); + auto s2 = Pool::open(b, PoolConfig{ + .pool_prefix = "p", .server_id = UInt128(2), .server_root_id = "srv-2"}); + EXPECT_EQ(s1->poolMeta().pool_id, s2->poolMeta().pool_id); /// pool authoritative +} + +TEST(CASPool, OpenWithExplicitConstantsCreatesThem) +{ + auto b = std::make_shared(); + auto s = Pool::open(b, PoolConfig{.pool_prefix = "p", .server_root_id = "test", .blob_header_len = 512}); + EXPECT_EQ(s->poolMeta().blob_header_len, 512u); /// config applies at creation +} + +TEST(CASPool, VerbatimFilesLifecycle) +{ + auto b = std::make_shared(); + auto s = Pool::open(b, PoolConfig{.pool_prefix = "p", .server_root_id = "test"}); + RootNamespace ns{"srv1/tbl"}; + s->putNamespaceFile(DB::Cas::tests::fixture::fixtureLife(ns), "format_version.txt", "1\n"); + s->putNamespaceFile(DB::Cas::tests::fixture::fixtureLife(ns), "uuid.txt", "abc"); + EXPECT_EQ(s->getNamespaceFile(DB::Cas::tests::fixture::fixtureLife(ns), "format_version.txt"), String("1\n")); + EXPECT_FALSE(s->getNamespaceFile(DB::Cas::tests::fixture::fixtureLife(ns), "absent").has_value()); + auto names = s->listNamespaceFiles(DB::Cas::tests::fixture::fixtureLife(ns)); + EXPECT_EQ(names, (std::vector{"format_version.txt", "uuid.txt"})); + s->putNamespaceFile(DB::Cas::tests::fixture::fixtureLife(ns), "uuid.txt", "def"); /// overwrite allowed (head + putOverwrite) + EXPECT_EQ(s->getNamespaceFile(DB::Cas::tests::fixture::fixtureLife(ns), "uuid.txt"), String("def")); +} + +TEST(CASPool, ListNamespaceFilesEmpty) +{ + auto b = std::make_shared(); + auto s = Pool::open(b, PoolConfig{.pool_prefix = "p", .server_root_id = "test"}); + RootNamespace ns{"srv1/tbl"}; + EXPECT_TRUE(s->listNamespaceFiles(DB::Cas::tests::fixture::fixtureLife(ns)).empty()); +} + +/// ---------- read side (spec §6): resolveRef / readManifest / findEntry / entryRange / listRefs ---------- + +/// Phase 1c read path: a published ref resolves to a ManifestId; readManifest returns the immutable +/// body; locate yields a ranged blob read; an Inline entry has no location. Replaces the old +/// resolveRef().tree_id / readTree round trip (the tree model is gone — a part is a single ManifestId). +TEST(CASPool, ResolveReturnsManifestId) +{ + auto b = std::make_shared(); + auto s = Pool::open(b, PoolConfig{.pool_prefix = "p", .server_root_id = "test"}); + const RootNamespace ns{"srv1/tbl"}; + + /// blob "hello world" + an inline file, published through the real PartWriteTxn write path. + const String payload = "hello world"; + PartWriteInfo info; + info.intended_ref = ns.string() + "/part_1"; + auto build = s->beginPartWrite(info); + build->putBlob(idOf(payload), BlobSource::fromString(payload)); + + ManifestEntry blob_entry; + blob_entry.path = "data.bin"; + blob_entry.placement = EntryPlacement::Blob; + blob_entry.ref = DB::Cas::BlobRef{DB::Cas::BlobHashAlgo::CityHash128, DB::Cas::BlobDigest::fromU128(u128Of(payload))}; + + blob_entry.blob_size = payload.size(); + ManifestEntry inline_entry; + inline_entry.path = "small.txt"; + inline_entry.placement = EntryPlacement::Inline; + inline_entry.inline_bytes = "tiny\n"; + + const ManifestId id = build->stageManifest({blob_entry, inline_entry}); + build->precommitAdd(ns, "part_1", id); + build->promote(ns, "part_1", build->buildId(), id); + + auto r = s->resolveRef(ns, "part_1"); + ASSERT_TRUE(r.has_value()); + EXPECT_EQ(r->manifest_id, id); /// resolve yields the published ManifestId + + auto manifest = s->readManifest(r->manifest_id); + ASSERT_EQ(manifest.entries.size(), 2u); + + /// "data.bin" sorts before "small.txt" (canonical path order). + const auto * data = findEntry(manifest.entries, "data.bin"); + ASSERT_TRUE(data != nullptr); + auto loc = s->locate(*data); + EXPECT_EQ(loc.offset, s->poolMeta().blob_header_len); + EXPECT_EQ(loc.length, payload.size()); + + auto bytes = b->get(loc.key, Range{loc.offset, loc.length}); + ASSERT_TRUE(bytes.has_value()); + EXPECT_EQ(bytes->bytes, payload); /// ranged read, no header touch + + const auto * small = findEntry(manifest.entries, "small.txt"); + ASSERT_TRUE(small != nullptr); + EXPECT_THROW(s->locate(*small), DB::Exception); /// Inline has no location +} + +/// readManifest fail-closes on a body whose self-described `ref`/`root_namespace_id` does NOT match the +/// resolved ManifestId — the ref is addressing the wrong object / a cross-namespace dangle. We stage a +/// body raw (writeManifestRaw, the on-storage write fixture) at a ManifestId, then resolve through a +/// committed binding that names a DIFFERENT ManifestRef pointing at the SAME object key — so the head +/// succeeds, the body decodes, but refMatchesBody fails => CORRUPTED_DATA. +TEST(CASPool, ReadManifestValidatesBodyAndFailsClosed) +{ + auto b = std::make_shared(); + auto s = Pool::open(b, PoolConfig{.pool_prefix = "p", .server_root_id = "test"}); + const RootNamespace ns{"srv1/tbl"}; + Layout layout("p"); + + /// (1) ref/namespace mismatch: the BODY self-describes namespace `srv1/other`, but it is addressed + /// as a manifest of `srv1/tbl` => manifestNamespaceMatches fails => CORRUPTED_DATA. We craft an id + /// whose key lives under `srv1/tbl` but whose body carries the foreign namespace. + { + const ManifestRef ref = manifestRefFor("mismatch-ns"); + const ManifestId addressed{.root_namespace = ns, .ref = ref}; + /// Encode a body that claims a DIFFERENT namespace than `addressed.root_namespace`. + PartManifest body; + body.ref = ref; /// ref matches + body.root_namespace_id = RootNamespace{"srv1/other"}; /// namespace does NOT + body.entries = {blobEntryFor("f", u128Of("x"), 1)}; + body.payload_digest = computePayloadDigest(body); + b->putIfAbsent(layout.manifestKey(addressed), encodePartManifest(body)); + + expectThrowsCode(DB::ErrorCodes::CORRUPTED_DATA, [&] { s->readManifest(addressed); }); + } + + /// (2) ref mismatch: the body self-describes a DIFFERENT ManifestRef than the id addressing it => + /// refMatchesBody fails => CORRUPTED_DATA. + { + const ManifestRef addressed_ref = manifestRefFor("addressed-ref"); + const ManifestRef body_ref = manifestRefFor("body-ref-other"); + const ManifestId addressed{.root_namespace = ns, .ref = addressed_ref}; + PartManifest body; + body.ref = body_ref; /// ref does NOT match `addressed` + body.root_namespace_id = ns; /// namespace matches + body.entries = {blobEntryFor("f", u128Of("y"), 1)}; + body.payload_digest = computePayloadDigest(body); + b->putIfAbsent(layout.manifestKey(addressed), encodePartManifest(body)); + + expectThrowsCode(DB::ErrorCodes::CORRUPTED_DATA, [&] { s->readManifest(addressed); }); + } + + /// (3) a committed ref naming a manifest with NO body present => readManifest throws + /// FILE_DOESNT_EXIST (INV-NO-DANGLE surfaced on the read path). resolveRef itself SUCCEEDS — refs + /// are pure manifest state. A raw ref-log fixture (not the real PartWriteTxn path, which validates the + /// body exists at promote) is the only way to construct this state. + { + const ManifestRef missing_ref = manifestRefFor("never-staged"); + DB::Cas::tests::fixture::writeRefLogRaw(*b, layout, RefLogTxn{ns.string(), RefTxnId{1, 1}, + {DB::Cas::tests::namespaceBirthOp(), DB::Cas::tests::publishCommittedOps("part_dangle", missing_ref)[0], + DB::Cas::tests::publishCommittedOps("part_dangle", missing_ref)[1]}, std::nullopt}); + DB::Cas::tests::writeRecoverableCkptForRawFixture(*b, layout, ns, RefCkpt{ + .life_epoch = 1, + .committed_through = RefTxnId{1, 1}, + .checkpoint_snapshot_id = std::nullopt, + .last_epoch_seal = std::nullopt, + }); + + auto r = s->resolveRef(ns, "part_dangle"); + ASSERT_TRUE(r.has_value()); + expectThrowsCode(DB::ErrorCodes::FILE_DOESNT_EXIST, [&] { s->readManifest(r->manifest_id); }); + } +} + +/// findEntry and entryRange over a decoded part manifest's canonical-path-ordered entries. +TEST(CASPool, LookupAndListOverManifestEntries) +{ + auto b = std::make_shared(); + auto s = Pool::open(b, PoolConfig{.pool_prefix = "p", .server_root_id = "test"}); + const RootNamespace ns{"srv1/tbl"}; + + /// A multi-file/multi-directory part: top-level + a projection subdir. + std::vector entries; + entries.push_back(blobEntryFor("columns.txt", u128Of("cols"), 4)); + entries.push_back(blobEntryFor("data.bin", u128Of("data"), 8)); + entries.push_back(blobEntryFor("p.proj/data.bin", u128Of("proj-data"), 6)); + entries.push_back(blobEntryFor("p.proj/columns.txt", u128Of("proj-cols"), 5)); + const ManifestId id = publishPartWithEntries(s, ns.string(), "all_1_1_0", entries); + + auto r = s->resolveRef(ns, "all_1_1_0"); + ASSERT_TRUE(r.has_value()); + EXPECT_EQ(r->manifest_id, id); + auto manifest = s->readManifest(r->manifest_id); + ASSERT_EQ(manifest.entries.size(), 4u); + + /// findEntry: exact-path hit + miss. + const auto * hit = findEntry(manifest.entries, "data.bin"); + ASSERT_TRUE(hit != nullptr); + EXPECT_EQ(hit->ref.digest.toU128(), u128Of("data")); + EXPECT_TRUE(findEntry(manifest.entries, "no_such_file") == nullptr); + + /// entryRange under "p.proj/" yields exactly the two projection files, in canonical order. + auto [proj_first, proj_last] = entryRange(manifest.entries, "p.proj/"); + std::vector proj(proj_first, proj_last); + ASSERT_EQ(proj.size(), 2u); + EXPECT_EQ(proj[0].path, "p.proj/columns.txt"); + EXPECT_EQ(proj[1].path, "p.proj/data.bin"); + + /// The empty prefix lists everything (all four), still in canonical order. + auto [all_first, all_last] = entryRange(manifest.entries, ""); + std::vector all(all_first, all_last); + ASSERT_EQ(all.size(), 4u); + EXPECT_EQ(all[0].path, "columns.txt"); + EXPECT_EQ(all[3].path, "p.proj/data.bin"); +} + +/// The Phase 1c manifest decode cache is keyed by (ManifestId, Token). Resolve+read the same ref twice: +/// the second readManifest must be served from the cache (no second GET of the body). A fresh publish +/// under a DIFFERENT ref name mints a NEW ManifestId (and a new shard token), so the cache misses and +/// the body is fetched again. A CountingBackend asserts the body GET count. +TEST(CASPool, ManifestCacheIsKeyedByIdAndToken) +{ + auto b = std::make_shared(); + auto s = Pool::open(b, PoolConfig{.pool_prefix = "p", .server_root_id = "test"}); + const RootNamespace ns{"srv1/tbl"}; + Layout layout("p"); + + const ManifestId id1 = publishPart(s, ns.string(), "part_1", "payload-1"); + const String key1 = layout.manifestKey(id1); + + /// First read: a body GET populates the (id1, token) cache entry. + { + auto r = s->resolveRef(ns, "part_1"); + ASSERT_TRUE(r.has_value()); + auto m = s->readManifest(r->manifest_id); + ASSERT_EQ(m.entries.size(), 1u); + } + const uint64_t gets_after_first = b->getCount(key1); + ASSERT_GE(gets_after_first, 1u); /// the first read DID fetch the body + + /// Second read of the SAME id: the (id, token) cache must serve it — NO additional body GET. + { + auto r = s->resolveRef(ns, "part_1"); + ASSERT_TRUE(r.has_value()); + EXPECT_EQ(r->manifest_id, id1); + auto m = s->readManifest(r->manifest_id); + ASSERT_EQ(m.entries.size(), 1u); + } + EXPECT_EQ(b->getCount(key1), gets_after_first) + << "second readManifest re-GET the body for the same (ManifestId, Token) — cache miss"; + + /// A fresh publish under a DIFFERENT ref name mints a NEW ManifestId: the cache (keyed by id) misses. + /// (Promoting a different manifest over the SAME committed ref is a distinct promote-over-committed + /// leak that `PartWriteTxn::promote` now forbids — see the CASPromoteRepublish tests.) + const ManifestId id2 = publishPart(s, ns.string(), "part_2", "payload-2"); + EXPECT_FALSE(id2 == id1); /// a new publish never reuses a ManifestId + const String key2 = layout.manifestKey(id2); + + auto r2 = s->resolveRef(ns, "part_2"); + ASSERT_TRUE(r2.has_value()); + EXPECT_EQ(r2->manifest_id, id2); /// resolve now sees the new manifest + auto m2 = s->readManifest(r2->manifest_id); + ASSERT_EQ(m2.entries.size(), 1u); + EXPECT_GE(b->getCount(key2), 1u) /// the new id's body WAS fetched (cache miss) + << "fresh publish (new ManifestId) should miss the id-keyed manifest cache"; +} + +/// Phase 5 (part-folder cache spec): manifest_cache is now a byte-weighted CacheBase LRU instead of a +/// count-only bound, since decoded manifests carry inline bytes and can each be megabytes. +TEST(CASPool, ManifestDecodeCacheIsByteBounded) +{ + auto backend = std::make_shared(); + const DB::Cas::Layout layout("p"); + DB::Cas::tests::seedPoolMetaForRestart(*backend); + const DB::Cas::RootNamespace ns{"srv/t1"}; + + /// 8 manifests x ~1 MiB of inline bytes; a 2 MiB decode-cache bound must hold while every + /// read stays correct (evicted decodes just re-GET + re-decode). + std::vector ids; + std::vector birth_ops{DB::Cas::tests::namespaceBirthOp()}; + for (int i = 0; i < 8; ++i) + { + const DB::Cas::ManifestRef ref{.writer_epoch = 1, .build_sequence = static_cast(i + 1), + .manifest_ordinal = 1}; + DB::Cas::ManifestEntry e; + e.path = "big.txt"; + e.placement = DB::Cas::EntryPlacement::Inline; + e.ref = DB::Cas::BlobRef{DB::Cas::BlobHashAlgo::CityHash128, DB::Cas::BlobDigest::fromU128(DB::UInt128(i + 1))}; + + e.inline_bytes = String(1 << 20, static_cast('a' + i)); + e.blob_size = e.inline_bytes.size(); + ids.push_back(DB::Cas::tests::writeManifestRaw(*backend, layout, ns, ref, {e})); + + const String ref_name = "part_" + std::to_string(i); + std::vector ops = i == 0 ? birth_ops : std::vector{}; + const auto committed_ops = DB::Cas::tests::publishCommittedOps(ref_name, ref); + ops.insert(ops.end(), committed_ops.begin(), committed_ops.end()); + DB::Cas::tests::fixture::writeRefLogRaw(*backend, layout, RefLogTxn{ns.string(), RefTxnId{1, static_cast(i + 1)}, ops, std::nullopt}); + } + DB::Cas::tests::writeRecoverableCkptForRawFixture(*backend, layout, ns, RefCkpt{ + .life_epoch = 1, + .committed_through = RefTxnId{1, 8}, + .checkpoint_snapshot_id = std::nullopt, + .last_epoch_seal = std::nullopt, + }); + + DB::Cas::PoolConfig config{.pool_prefix = "p", .server_root_id = "test"}; + config.manifest_decode_cache_bytes = 2ULL << 20; + auto store = DB::Cas::Pool::open(backend, std::move(config)); + + uint64_t total_gets = 0; + for (int round = 0; round < 2; ++round) + for (int i = 0; i < 8; ++i) + { + auto resolved = store->resolveRef(ns, "part_" + std::to_string(i)); + ASSERT_TRUE(resolved.has_value()); + auto m = store->readManifestShared(resolved->manifest_id); + ASSERT_EQ(m->entries.size(), 1u); + EXPECT_EQ(m->entries[0].inline_bytes[0], static_cast('a' + i)); /// always correct + } + for (const auto & id : ids) + total_gets += backend->getCount(layout.manifestKey(id)); + + /// The bound forces re-GETs (16 reads over a 2 MiB window of ~1 MiB decodes cannot all hit), + /// proving eviction actually happens... + EXPECT_GT(total_gets, 8u); + /// ...and the cache reports an in-bound retained size. + EXPECT_LE(store->manifestDecodeCacheBytesForTest(), 2ULL << 20); +} + +TEST(CASPool, ResolveDecodeCacheInvalidatesOnWrite) +{ + /// B113: resolveRef uses a token-validated shard-manifest decode cache. A write to the shard + /// mints a new token, so a subsequent resolve must observe the change (cache must NOT serve a + /// stale decoded manifest). Without token invalidation this would still see the dropped ref. + auto b = std::make_shared(); + auto s = Pool::open(b, PoolConfig{.pool_prefix = "p", .server_root_id = "test"}); + RootNamespace ns{"srv1/tbl"}; + + publishPart(s, ns.string(), "part_1", "payload-1"); + + /// First resolve decodes + caches; second is a cache hit — both must see part_1. + ASSERT_TRUE(s->resolveRef(ns, "part_1").has_value()); + ASSERT_TRUE(s->resolveRef(ns, "part_1").has_value()); + + /// Write through the Pool (mutateShard => new shard token), removing part_1. + s->dropRef(ns, "part_1"); + + /// The cache must invalidate on the token change: resolve now reflects the drop. + EXPECT_FALSE(s->resolveRef(ns, "part_1").has_value()); + EXPECT_TRUE(s->listRefs(ns).empty()); +} + +TEST(CASPool, ResolveAbsentRefAndAbsentNamespace) +{ + auto b = std::make_shared(); + auto s = Pool::open(b, PoolConfig{.pool_prefix = "p", .server_root_id = "test"}); + RootNamespace ns{"srv1/tbl"}; + + /// A freshly-opened pool has no shard manifests: an absent shard is an empty manifest, so resolve + /// yields nullopt and listRefs is empty (NOT an error). + EXPECT_FALSE(s->resolveRef(ns, "anything").has_value()); + EXPECT_TRUE(s->listRefs(ns).empty()); +} + +TEST(CASPool, ListRefsMergesAllShards) +{ + /// Task 10: refs are no longer sharded (the snapshot+log protocol caches one coherent table state + /// per namespace, not one manifest per shard) -- this now proves listRefs returns every committed + /// ref of a table built from a single multi-owner transaction, the closest surviving analogue of + /// the old "merges refs spread across shards" contract. + auto b = std::make_shared(); + auto s = Pool::open(b, PoolConfig{.pool_prefix = "p", .server_root_id = "test"}); + Layout layout("p"); + RootNamespace ns{"srv1/tbl"}; + + std::vector ops{DB::Cas::tests::namespaceBirthOp()}; + for (char c = 'a'; c <= 'h'; ++c) + { + const String ref(1, c); + const auto committed_ops = DB::Cas::tests::publishCommittedOps(ref, manifestRefFor("manifest-" + ref)); + ops.insert(ops.end(), committed_ops.begin(), committed_ops.end()); + } + DB::Cas::tests::fixture::writeRefLogRaw(*b, layout, RefLogTxn{ns.string(), RefTxnId{1, 1}, ops, std::nullopt}); + DB::Cas::tests::writeRecoverableCkptForRawFixture(*b, layout, ns, RefCkpt{ + .life_epoch = 1, + .committed_through = RefTxnId{1, 1}, + .checkpoint_snapshot_id = std::nullopt, + .last_epoch_seal = std::nullopt, + }); + + auto refs = s->listRefs(ns); + ASSERT_EQ(refs.size(), 8u); + for (char c = 'a'; c <= 'h'; ++c) + { + const String ref(1, c); + ASSERT_TRUE(refs.count(ref)); + EXPECT_EQ(refs.at(ref).manifest_id.ref, manifestRefFor("manifest-" + ref)); + EXPECT_EQ(refs.at(ref).manifest_id.root_namespace.string(), ns.string()); + } +} + +/// An empty namespace recovers from its exact `_ckpt` authority and exact successor GET. It performs +/// ZERO LISTs and ZERO HEADs: recovery no longer enumerates the stream, and it never probes a shard +/// fan-out. Measure deltas around `listRefs`; `Pool::open` and fixture admission have their own metadata +/// traffic. +TEST(CASPool, ListRefsEmptyNamespaceCostsZeroListsAndHeads) +{ + auto b = std::make_shared(); + auto s = Pool::open(b, PoolConfig{.pool_prefix = "p", .server_root_id = "test"}); + RootNamespace ns{"srv1/tbl"}; + /// EMPTY, but EXISTING and recoverable. A namespace the catalog does not name is answered from the + /// catalog and never reaches recovery; that separate shape is measured by the case below. + DB::Cas::tests::casAdmitRecoverableEntry(*b, Layout("p"), ns); + + const uint64_t heads_before = b->headTotal(); + const uint64_t lists_before = b->listTotal(); + + auto refs = s->listRefs(ns); + + EXPECT_TRUE(refs.empty()); + EXPECT_EQ(b->headTotal() - heads_before, 0u) + << "empty-namespace listRefs must not HEAD any shard"; + EXPECT_EQ(b->listTotal() - lists_before, 0u) + << "checkpoint-grounded recovery reads exact keys and must not LIST the ref stream"; +} + +/// The other shape: a namespace that was never born. A read must not be what brings one into existence, +/// so the answer comes from the catalog alone -- no recovery, and therefore not even the one LIST the +/// case above pins. +TEST(CASPool, ListRefsOnANeverBornNamespaceCostsNoListAndNoHead) +{ + auto b = std::make_shared(); + auto s = Pool::open(b, PoolConfig{.pool_prefix = "p", .server_root_id = "test"}); + RootNamespace ns{"srv1/tbl"}; + + const uint64_t heads_before = b->headTotal(); + const uint64_t lists_before = b->listTotal(); + const uint64_t gets_before = b->getTotal(); + + auto refs = s->listRefs(ns); + + EXPECT_TRUE(refs.empty()); + EXPECT_EQ(b->listTotal() - lists_before, 0u) + << "a never-born namespace has no ref stream to LIST"; + EXPECT_EQ(b->headTotal() - heads_before, 0u); + /// Positive control: the zeros above are the answer coming from the catalog, not from a call that + /// did nothing at all. + EXPECT_GT(b->getTotal() - gets_before, 0u) + << "the answer must come from a catalog read"; +} + +/// listRefs must return every committed ref of a table, correctly, regardless of how many refs the +/// table holds (Task 10: there is no more shard fan-out to discover -- see the comment inside). +TEST(CASPool, ListRefsReturnsSameContentAsBefore) +{ + /// Task 10: there is no more per-shard HEAD fan-out to bound (a warm listRefs costs ZERO requests; + /// a cold empty one costs zero LISTs and HEADs, already covered by + /// `ListRefsEmptyNamespaceCostsZeroListsAndHeads`) -- this now just proves the returned content is + /// correct for a multi-ref table built from a single raw ref-log fixture. + auto b = std::make_shared(); + auto s = Pool::open(b, PoolConfig{.pool_prefix = "p", .server_root_id = "test"}); + Layout layout("p"); + RootNamespace ns{"srv1/tbl"}; + + std::vector ops{DB::Cas::tests::namespaceBirthOp()}; + for (const String & ref : {String("a"), String("m"), String("z")}) + { + const auto committed_ops = DB::Cas::tests::publishCommittedOps(ref, manifestRefFor("manifest-" + ref)); + ops.insert(ops.end(), committed_ops.begin(), committed_ops.end()); + } + DB::Cas::tests::fixture::writeRefLogRaw(*b, layout, RefLogTxn{ns.string(), RefTxnId{1, 1}, ops, std::nullopt}); + DB::Cas::tests::writeRecoverableCkptForRawFixture(*b, layout, ns, RefCkpt{ + .life_epoch = 1, + .committed_through = RefTxnId{1, 1}, + .checkpoint_snapshot_id = std::nullopt, + .last_epoch_seal = std::nullopt, + }); + + auto refs = s->listRefs(ns); + + ASSERT_EQ(refs.size(), 3u); + for (const String & ref : {String("a"), String("m"), String("z")}) + { + ASSERT_TRUE(refs.count(ref)); + EXPECT_EQ(refs.at(ref).manifest_id.ref, manifestRefFor("manifest-" + ref)); + EXPECT_EQ(refs.at(ref).manifest_id.root_namespace.string(), ns.string()); + } +} + +/// A stray key under the namespace's ref-object prefix that does not parse as one of Task 10's +/// `_log`/`_snap` kinds (a foreign/corrupt object) must not break listRefs — it is skipped +/// defensively, listRefs still returns the legit refs and never throws. +TEST(CASPool, ListRefsSkipsForeignKeys) +{ + auto b = std::make_shared(); + auto s = Pool::open(b, PoolConfig{.pool_prefix = "p", .server_root_id = "test"}); + Layout layout("p"); + RootNamespace ns{"srv1/tbl"}; + + const String ref = "legit"; + const ManifestRef mref = manifestRefFor("manifest-" + ref); + DB::Cas::tests::fixture::writeRefLogRaw(*b, layout, RefLogTxn{ns.string(), RefTxnId{1, 1}, + {DB::Cas::tests::namespaceBirthOp(), DB::Cas::tests::publishCommittedOps(ref, mref)[0], + DB::Cas::tests::publishCommittedOps(ref, mref)[1]}, std::nullopt}); + DB::Cas::tests::writeRecoverableCkptForRawFixture(*b, layout, ns, RefCkpt{ + .life_epoch = 1, + .committed_through = RefTxnId{1, 1}, + .checkpoint_snapshot_id = std::nullopt, + .last_epoch_seal = std::nullopt, + }); + + /// A stray key directly under the namespace's ref-object prefix that is not `_log`/ + /// `_snap` shaped (also covers the legacy shard-number layout GC/dropNamespace still write). + b->putIfAbsent(layout.namespaceStreamPrefix(DB::Cas::tests::fixture::fixtureLife(ns)) + "garbage", "not-a-ref-object"); + + std::map refs; + EXPECT_NO_THROW(refs = s->listRefs(ns)); + ASSERT_EQ(refs.size(), 1u); + ASSERT_TRUE(refs.count(ref)); + EXPECT_EQ(refs.at(ref).manifest_id.ref, mref); +} + +/// readManifest fails CLOSED on a corrupt or kind-mismatched manifest body addressed by a live id. +TEST(CASPool, ReadManifestFailsClosed) +{ + auto b = std::make_shared(); + auto s = Pool::open(b, PoolConfig{.pool_prefix = "p", .server_root_id = "test"}); + Layout layout("p"); + const RootNamespace ns{"srv1/tbl"}; + + /// (1) Garbage bytes at the manifest key => decodePartManifest throws CORRUPTED_DATA. + { + const ManifestRef ref = manifestRefFor("garbage-body"); + const ManifestId id{.root_namespace = ns, .ref = ref}; + b->putIfAbsent(layout.manifestKey(id), "not a valid manifest body"); + expectThrowsCode(DB::ErrorCodes::CORRUPTED_DATA, [&] { s->readManifest(id); }); + } + + /// (2) A ref naming a manifest id with NO object present => readManifest throws FILE_DOESNT_EXIST + /// (INV-NO-DANGLE), carrying the manifest key. + { + const ManifestRef ref = manifestRefFor("absent-body"); + const ManifestId id{.root_namespace = ns, .ref = ref}; + expectThrowsCode(DB::ErrorCodes::FILE_DOESNT_EXIST, [&] { s->readManifest(id); }); + } +} + +/// ---------- ref lifecycle: dropRef / updateRefPublishedAt / dropNamespace ---------- + +TEST(CASPool, DropRefAppendsJournalAtomically) +{ + /// Task 10: the OLD shared-journal record assertions are gone (there is no shared mutable journal + /// object anymore — dropRef appends its OWN immutable ref-log transaction); the surviving + /// behavioral contract is: the drop is atomic (visible to resolveRef only once durable), and + /// dropping a missing ref is fail-closed, never a silent no-op. + auto b = std::make_shared(); + auto s = Pool::open(b, PoolConfig{.pool_prefix = "p", .server_root_id = "test"}); + RootNamespace ns{"srv1/tbl"}; + + publishPart(s, ns.string(), "part_1", "payload-1"); + ASSERT_TRUE(s->resolveRef(ns, "part_1").has_value()); + + s->dropRef(ns, "part_1"); + EXPECT_FALSE(s->resolveRef(ns, "part_1").has_value()); + EXPECT_TRUE(s->listRefs(ns).empty()); + + /// Dropping a missing ref is fail-closed, never a silent no-op. + expectThrowsCode(DB::ErrorCodes::FILE_DOESNT_EXIST, [&] { s->dropRef(ns, "no_such_ref"); }); +} + +/// Task 10 renamed this from "...WithoutJournal": updateRefPublishedAt now DOES append an immutable +/// `set_published_at` ref-log transaction (spec §Update Payload) -- the old journal-free in-place field +/// mutation had no equivalent once persistence is an append-only log; every change, even timestamp-only, +/// must be a logged operation to be part of the ordered history. All-tree-part-files Task 9: the +/// carrier's mutable-file map is gone -- `published_at_ms` is the only field left to mutate. The +/// surviving contract is the user-visible one: a `published_at_ms` update is observable through +/// resolveRef and the manifest edge cannot change on this path -- the `RefPublishedAtUpdate` carrier +/// deliberately has no `manifest_ref` field, so a reachability change is structurally impossible here +/// (it goes through publish/drop/repoint instead). +TEST(CASPool, UpdateRefPublishedAtUpdatesPublishedAtMs) +{ + auto b = std::make_shared(); + auto s = Pool::open(b, PoolConfig{.pool_prefix = "p", .server_root_id = "test"}); + RootNamespace ns{"srv1/tbl"}; + + const ManifestId id = publishPart(s, ns.string(), "part_1", "payload-1"); + const ManifestRef manifest_ref = id.ref; + + s->updateRefPublishedAt(ns, "part_1", [](RefPublishedAtUpdate & r) { r.published_at_ms = 1; }); + s->updateRefPublishedAt(ns, "part_1", [](RefPublishedAtUpdate & r) { r.published_at_ms = 7; }); + + auto after = s->resolveRef(ns, "part_1"); + ASSERT_TRUE(after.has_value()); + EXPECT_EQ(after->published_at_ms, 7u); + EXPECT_EQ(after->manifest_id.ref, manifest_ref); +} + +/// Task 11: dropNamespace removes every owner through the ref-log `remove_namespace` transaction and +/// performs NO physical deletion at all -- verbatim files survive until GC's perpetual janitor +/// reclaims the dead life. So after the drop every ref resolves away and +/// `listRefs` is empty, but the verbatim files remain readable. +TEST(CASPool, DropNamespaceRemovesEveryOwnerButLeavesFilesForGc) +{ + auto b = std::make_shared(); + auto s = Pool::open(b, PoolConfig{.pool_prefix = "p", .server_root_id = "test"}); + RootNamespace ns{"srv1/tbl"}; + + const std::vector ref_names{"alpha", "bravo", "charlie"}; + for (const String & name : ref_names) + publishPart(s, ns.string(), name, "payload-" + name); + for (const String & name : ref_names) + ASSERT_TRUE(s->resolveRef(ns, name).has_value()); + + s->putNamespaceFile(DB::Cas::tests::fixture::fixtureLife(ns), "format_version.txt", "1\n"); + s->putNamespaceFile(DB::Cas::tests::fixture::fixtureLife(ns), "uuid.txt", "abc"); + + s->dropNamespace(ns); + + for (const String & name : ref_names) + EXPECT_FALSE(s->resolveRef(ns, name).has_value()); + EXPECT_TRUE(s->listRefs(ns).empty()); + + /// The writer performs NO physical deletion; verbatim files survive until the perpetual janitor + /// reclaims the dead life. + EXPECT_TRUE(s->getNamespaceFile(DB::Cas::tests::fixture::fixtureLife(ns), "format_version.txt").has_value()); + EXPECT_TRUE(s->getNamespaceFile(DB::Cas::tests::fixture::fixtureLife(ns), "uuid.txt").has_value()); + + /// Repeated drop is idempotent: no throw, no second transaction (nothing left to observe changing). + EXPECT_NO_THROW(s->dropNamespace(ns)); + + /// Ordinary mutations on a cataloged `Removing` life are rejected with typed retry-later until + /// the terminal fold and catalog-only drain complete. + expectThrowsCode(DB::ErrorCodes::NETWORK_ERROR, [&] { s->dropRef(ns, "alpha"); }); +} + +TEST(CASPool, ListNamespacesFromCatalog) +{ + /// `listNamespaces` projects logical names from the authoritative catalog. Physical life keys + /// contain no namespace spelling and therefore cannot participate in this enumeration. + auto b = std::make_shared(); + auto s = Pool::open(b, PoolConfig{.pool_prefix = "p", .server_root_id = "test"}); + + EXPECT_TRUE(s->listNamespaces("").namespaces.empty()); /// fresh pool: empty catalog + + /// The real publication path admits each namespace before writing its stream. + DB::Cas::tests::publishCommittedTransition(*b, s->layout(), RootNamespace{"srv1/tbl"}, + "ref1", std::nullopt, DB::Cas::ManifestRef{.writer_epoch = 1, .build_sequence = 1, .manifest_ordinal = 1}); + DB::Cas::tests::publishCommittedTransition(*b, s->layout(), RootNamespace{"shadow/bk1/tbl"}, + "ref1", std::nullopt, DB::Cas::ManifestRef{.writer_epoch = 1, .build_sequence = 1, .manifest_ordinal = 1}); + DB::Cas::tests::publishCommittedTransition(*b, s->layout(), RootNamespace{"shadow/bk2/tbl"}, + "ref1", std::nullopt, DB::Cas::ManifestRef{.writer_epoch = 1, .build_sequence = 1, .manifest_ordinal = 1}); + + const auto all = s->listNamespaces("").namespaces; + EXPECT_EQ(all.size(), 3u); + const auto shadows = s->listNamespaces("shadow/").namespaces; + ASSERT_EQ(shadows.size(), 2u); + /// listNamespaces returns results from an unordered_set; sort for deterministic comparison. + auto sorted_shadows = shadows; + std::sort(sorted_shadows.begin(), sorted_shadows.end()); + EXPECT_EQ(sorted_shadows[0], "shadow/bk1/tbl"); + EXPECT_EQ(sorted_shadows[1], "shadow/bk2/tbl"); + EXPECT_TRUE(s->listNamespaces("nope/").namespaces.empty()); +} + +/// Physical namespace files carry only an opaque life id and cannot mint a logical catalog row. +TEST(CASPool, ListNamespacesDoesNotMintLogicalNamesFromFileKeys) +{ + auto b = std::make_shared(); + auto s = Pool::open(b, PoolConfig{.pool_prefix = "p", .server_root_id = "test"}); + const RootNamespace ns{"test/tbl@cas@"}; + + s->putNamespaceFile(DB::Cas::tests::fixture::fixtureLife(ns), "format_version.txt", "1\n"); + /// A second life of the SAME name, written by exact key because no helper mints two lives yet. + const NamespaceLifeId other = NamespaceLifeId::fromCatalogEntry(ns, DB::UInt128(0x5eed)); + ASSERT_EQ(b->putIfAbsent(s->layout().namespaceFileKey(other, "format_version.txt"), "1\n").outcome, + PutOutcome::Done); + + const NamespaceListing listing = s->listNamespaces(""); + EXPECT_TRUE(listing.skipped.empty()); + EXPECT_TRUE(listing.namespaces.empty()); +} + +/// Catalog discovery neither adopts nor reports malformed physical debris. Diagnostic ownership-tree +/// scans, not ordinary logical enumeration, classify those keys. +TEST(CASPool, ListNamespacesDoesNotTreatPhysicalDebrisAsCatalogAuthority) +{ + auto b = std::make_shared(); + auto s = Pool::open(b, PoolConfig{.pool_prefix = "p", .server_root_id = "test"}); + const RootNamespace ns{"test/tbl@cas@"}; + + /// One well-formed key per family, so the namespace is attributable either way. + DB::Cas::tests::publishCommittedTransition(*b, s->layout(), ns, + "ref1", std::nullopt, DB::Cas::ManifestRef{.writer_epoch = 1, .build_sequence = 1, .manifest_ordinal = 1}); + s->putNamespaceFile(DB::Cas::tests::fixture::fixtureLife(ns), "format_version.txt", "1\n"); + + /// Hand-built un-incarnated keys: no helper can mint either shape any more. + const String lifeless_ref = s->layout().casRefsPrefix() + ns.string() + "/_log/" + + renderRefTxnId(RefTxnId{1, 1}) + ".zst"; + const String lifeless_file = s->layout().rootsPrefix() + ns.string() + "/_files/format_version.txt"; + ASSERT_EQ(b->putIfAbsent(lifeless_ref, "garbage").outcome, PutOutcome::Done); + ASSERT_EQ(b->putIfAbsent(lifeless_file, "garbage").outcome, PutOutcome::Done); + + NamespaceListing listing; + ASSERT_NO_THROW(listing = s->listNamespaces("")) + << "one un-attributable key must not abort the enumeration for every consumer of it"; + + /// The healthy namespace is still listed -- attribution is per key, so a namespace disappears only + /// when every key that would name it is unattributable. + ASSERT_EQ(listing.namespaces.size(), 1u); + EXPECT_EQ(listing.namespaces[0], ns.string()); + + EXPECT_TRUE(listing.skipped.empty()); + EXPECT_TRUE(b->head(lifeless_ref).exists); + EXPECT_TRUE(b->head(lifeless_file).exists); +} + +TEST(CASPool, ListMirroredChildren) +{ + using namespace DB::Cas; + auto b = std::make_shared(); + auto store = Pool::open(b, PoolConfig{.pool_prefix = "p", .server_root_id = "test"}); + /// Seed two catalog-authoritative shadow archives; physical files alone carry no logical path. + DB::Cas::tests::fixture::admitLive(*b, store->layout(), RootNamespace{"shadow/bk1/store/3f2/3f2a-uuid@cas@"}); + DB::Cas::tests::fixture::admitLive(*b, store->layout(), RootNamespace{"shadow/bk2/store/3f2/3f2a-uuid@cas@"}); + auto children = store->listMirroredChildren("shadow/"); + std::sort(children.begin(), children.end()); + ASSERT_EQ(children.size(), 2u); + EXPECT_EQ(children[0], "bk1"); + EXPECT_EQ(children[1], "bk2"); +} + +namespace +{ + +/// Delegating backend that fences the mount slot IN PLACE the first time a `get` returns a present +/// body for the armed key — reproducing the S13 window: the GC's token-guarded fence-out lands +/// between the keeper adopt's GET and its CAS. The caller's subsequent token-guarded `putOverwrite` +/// then fails `PreconditionFailed`, the adopt re-reads, sees `gc_fenced`, and throws +/// `MountFencedException` — which `Pool::open`'s fence-recovery loop must turn into a fresh-epoch +/// retry rather than a permanent wedge (P3.1 vector C). +class FenceInAdoptWindowBackend final : public DB::Cas::Backend +{ +public: + explicit FenceInAdoptWindowBackend(std::shared_ptr inner_) : inner(std::move(inner_)) {} + String fence_key; /// empty = fault disarmed; set to the mount key to arm the one-shot fence + + std::optional get(const String & k, DB::Cas::Range r) override + { + auto got = inner->get(k, r); + if (!fence_key.empty() && k == fence_key && got.has_value()) + { + /// One-shot: fence the slot in place exactly as `computeHeartbeatFloor` does (preserve the + /// body, gc_fenced = true, seq + 1, token-guarded against the value we just read), then + /// disarm so the retry can adopt cleanly. + DB::Cas::MountLease fenced = DB::Cas::decodeMountLease(got->bytes); + fenced.gc_fenced = true; + fenced.seq += 1; + inner->putOverwrite(k, DB::Cas::encodeMountLease(fenced), got->token); + fence_key.clear(); + } + return got; + } + std::optional getStream(const String & k, DB::Cas::Range r) override { return inner->getStream(k, r); } + DB::Cas::HeadResult head(const String & k) override { return inner->head(k); } + DB::Cas::ListPage list(const String & p, const String & c, size_t l) override { return inner->list(p, c, l); } + DB::Cas::PutResult putIfAbsent(const String & k, const String & b, const DB::Cas::ObjectMeta & m) override { return inner->putIfAbsent(k, b, m); } + DB::Cas::WriteSinkPtr putIfAbsentStream(const String & k, const DB::Cas::ObjectMeta & m) override { return inner->putIfAbsentStream(k, m); } + DB::Cas::PutResult putOverwrite(const String & k, const String & b, const DB::Cas::Token & e, const DB::Cas::ObjectMeta & m) override { return inner->putOverwrite(k, b, e, m); } + DB::Cas::CasResult casPut(const String & k, const String & b, const std::optional & e, const DB::Cas::ObjectMeta & m) override { return inner->casPut(k, b, e, m); } + DB::Cas::DeleteOutcome deleteExact(const String & k, const DB::Cas::Token & t) override { return inner->deleteExact(k, t); } + bool supportsListTokens() const override { return inner->supportsListTokens(); } + +private: + std::shared_ptr inner; +}; + +} + +TEST(CASPoolMountFence, OpenRecoversFromFenceInAdoptWindowWithFreshEpoch) +{ + auto inner = std::make_shared(); + auto fencing = std::make_shared(inner); + /// Arm the one-shot fence on the mount slot. Pool::open first claims the mount (fresh mint), then + /// the keeper adopts it — the adopt's GET trips the fence, its CAS fails, and open must recover. + const DB::Cas::Layout layout("p"); + fencing->fence_key = layout.mountKey("test"); + + /// The retry that recovers from the fence reclaims a same-uuid, different-epoch, `gc_fenced` body + /// -> `MountPriorState::Fenced` (a fenced prior is reclaimed on the first attempt, with no + /// observation polling -- see `CASMountOpenWaits.FencedPriorReclaimsWithoutAnyWait`). The injected + /// `boot_ms_fn`/`wait_sleep_fn` below keep this test off the real clock regardless. + uint64_t fake_boot = 0; + DB::Cas::PoolPtr store; + ASSERT_NO_THROW( + store = DB::Cas::Pool::open(fencing, + DB::Cas::PoolConfig{.pool_prefix = "p", .server_root_id = "test", + .boot_ms_fn = [&fake_boot] { return fake_boot; }, + .wait_sleep_fn = [&fake_boot](uint64_t ms) { fake_boot += ms; }})) + << "open must recover from a fence in the adopt window, not wedge (exit-49 S13 bug)"; + ASSERT_TRUE(store); + + /// The final live lease is unfenced and at a HIGHER writer_epoch than the first attempt (a fence + /// costs an epoch): the first claim took epoch 1, got fenced, the retry took epoch 2 and mounted. + const auto got = inner->get(layout.mountKey("test")); + ASSERT_TRUE(got.has_value()); + const MountLease final_lease = decodeMountLease(got->bytes); + EXPECT_FALSE(final_lease.gc_fenced); + EXPECT_GT(final_lease.writer_epoch, 1u) << "recovery must draw a fresh writer_epoch"; + EXPECT_TRUE(fencing->fence_key.empty()) << "the one-shot fence must have fired"; +} + +/// Task 12: the write-fence deadline is a CLOCK_BOOTTIME instant (boottime includes VM-suspend time, +/// so a resumed sleeper sees its fence expired — unlike CLOCK_MONOTONIC, which freezes across suspend). +/// A CLOCK_MONOTONIC freeze cannot be simulated in a unit test, so we exercise the injected-fn seam: a +/// fake boot clock that we advance past the ttl must flip mayMutate to false and make a gated mutate +/// fail closed with ABORTED. +TEST(CASPool, WriteFenceUsesInjectedBootClock) +{ + auto backend = std::make_shared(); + uint64_t fake_boot = 1'000'000; /// arbitrary boottime origin (ms) + auto store = DB::Cas::Pool::open(backend, DB::Cas::PoolConfig{ + .pool_prefix = "p", + .server_root_id = "test", + .mount_lease_ttl_ms = std::chrono::milliseconds(30000), + .boot_ms_fn = [&] { return fake_boot; }, + }); + + /// Freshly armed at open (deadline = fake_boot + ttl): well within the ttl, mutations are allowed. + EXPECT_TRUE(store->mayMutate()); + + /// Advance the boot clock just short of the deadline — still armed. + fake_boot += 29999; + EXPECT_TRUE(store->mayMutate()); + + /// Cross the deadline (ttl elapsed with no renew — a resumed sleeper's view). The fence must expire. + /// (The "a gated mutate then fails closed with ABORTED" leg used `mutateShardForTest` -- the held + /// Phase-E shard lane -- and moves there; here we pin the boot-clock fence flip itself.) + fake_boot += 2; /// now fake_boot = origin + 30001 > origin + 30000 + EXPECT_FALSE(store->mayMutate()); +} + +/// ==== self-remount after GC fence-out (liveness counterpart of the fence-out safety rule) ==== + +namespace +{ + +/// GC's fence-out, applied directly: preserve the body, set gc_fenced, bump seq (token-guarded). +void fenceOutMount(DB::Cas::Backend & backend, const String & mount_key) +{ + const auto got = backend.get(mount_key); + ASSERT_TRUE(got.has_value()); + MountLease m = decodeMountLease(got->bytes); + m.gc_fenced = true; + m.seq += 1; + ASSERT_EQ(backend.putOverwrite(mount_key, encodeMountLease(m), got->token).outcome, + DB::Cas::PutOutcome::Done); +} + +} + +TEST(CASPoolRemount, FenceOutThenSelfRemountRestoresWrites) +{ + auto backend = std::make_shared(); + auto store = DB::Cas::tests::openPoolForTest(backend); + const String mount_key = store->layout().mountKey("test"); + const uint64_t epoch_before = decodeMountLease(backend->get(mount_key)->bytes).writer_epoch; + EXPECT_EQ(store->liveWriterEpoch(), epoch_before); + + fenceOutMount(*backend, mount_key); + + /// The keeper's next renewal fails closed (foreign touch — never re-mint). + EXPECT_THROW(store->renewWatermarkOnce(), DB::Exception); + + /// Self-remount claims a FRESH incarnation: epoch bumped, gc_fenced cleared, writes restored. + ASSERT_TRUE(store->tryRemountOnce()); + const MountLease after = decodeMountLease(backend->get(mount_key)->bytes); + EXPECT_EQ(after.writer_epoch, epoch_before + 1); + EXPECT_FALSE(after.gc_fenced); + EXPECT_EQ(store->liveWriterEpoch(), epoch_before + 1); + + /// The renewal path works again (the new keeper owns the slot). (The follow-on "...and so does a + /// ref-shard mutation" check used `mutateShardForTest` -- the held Phase-E shard lane -- and moves + /// to Phase E's own tests; the self-remount liveness assertion above is the point of this test.) + EXPECT_NO_THROW(store->renewWatermarkOnce()); +} + +TEST(CASPoolRemount, OldEpochBuildFailsClosedAfterRemount) +{ + auto backend = std::make_shared(); + auto store = DB::Cas::tests::openPoolForTest(backend); + auto build = store->beginPartWrite({}); + + fenceOutMount(*backend, store->layout().mountKey("test")); + ASSERT_TRUE(store->tryRemountOnce()); + + /// The build was minted under the superseded incarnation — every further step fails closed. + expectThrowsCode(DB::ErrorCodes::NETWORK_ERROR, + [&] { build->putBlob(DB::Cas::tests::idOf("x"), DB::Cas::BlobSource::fromString("x")); }); + + /// A FRESH build under the live incarnation works. + auto fresh = store->beginPartWrite({}); + EXPECT_NO_THROW(fresh->putBlob(DB::Cas::tests::idOf("y"), DB::Cas::BlobSource::fromString("y"))); +} + +TEST(CASPoolRemount, ForeignOwnerIsNeverTakenOver) +{ + auto backend = std::make_shared(); + auto store = DB::Cas::tests::openPoolForTest(backend); + const String mount_key = store->layout().mountKey("test"); + + /// A genuinely foreign uuid holds the mount (live or not — foreign is terminal for the claim). + const auto got = backend->get(mount_key); + MountLease foreign = decodeMountLease(got->bytes); + foreign.server_uuid = foreign.server_uuid + DB::UInt128(1); + foreign.seq += 1; + ASSERT_EQ(backend->putOverwrite(mount_key, encodeMountLease(foreign), got->token).outcome, + DB::Cas::PutOutcome::Done); + + EXPECT_FALSE(store->tryRemountOnce()); + /// The foreign body is untouched (no takeover, ever). + EXPECT_EQ(decodeMountLease(backend->get(mount_key)->bytes).server_uuid, foreign.server_uuid); + + /// Move the parent fixture to the production-recognized fenced terminal state before explicitly + /// destroying its superseded keeper. The unfenced foreign-release guard is covered separately below. + fenceOutMount(*backend, mount_key); + store.reset(); + + /// A foreign owner is never taken over — at remount OR at release. This was an `EXPECT_DEATH` + /// pinning a `LOGICAL_ERROR` abort on the release half; the abort fired from `~Pool` and defeated + /// `finishTeardown`'s own catch by aborting at exception construction. The runtime never observed a + /// deposition (the slot was overwritten out of band), so the release takes the + /// exclusivity-violation arm: refuse, leave the foreign occupant untouched, and SURVIVE teardown. + auto foreign_backend = std::make_shared(); + auto invalid_store = DB::Cas::tests::openPoolForTest(foreign_backend); + const String foreign_mount_key = invalid_store->layout().mountKey("test"); + const auto foreign_got = foreign_backend->get(foreign_mount_key); + ASSERT_TRUE(foreign_got.has_value()); + MountLease foreign_lease = decodeMountLease(foreign_got->bytes); + foreign_lease.server_uuid = foreign_lease.server_uuid + DB::UInt128(1); + foreign_lease.seq += 1; + ASSERT_EQ( + foreign_backend->putOverwrite(foreign_mount_key, encodeMountLease(foreign_lease), foreign_got->token).outcome, + DB::Cas::PutOutcome::Done); + const auto occupant_before = foreign_backend->get(foreign_mount_key); + ASSERT_TRUE(occupant_before.has_value()); + + EXPECT_FALSE(invalid_store->tryRemountOnce()) << "a foreign owner is never taken over at remount"; + + const uint64_t violations_before + = ProfileEvents::global_counters[ProfileEvents::CASMountExclusivityViolation].load(); + invalid_store.reset(); /// must not abort, must not terminate + + EXPECT_EQ(ProfileEvents::global_counters[ProfileEvents::CASMountExclusivityViolation].load(), + violations_before + 1) + << "the release must report the broken single-writer guarantee rather than dying on it"; + const auto occupant_after = foreign_backend->get(foreign_mount_key); + ASSERT_TRUE(occupant_after.has_value()) << "nor is it taken over at release"; + EXPECT_EQ(occupant_after->bytes, occupant_before->bytes) + << "the slot must be left byte-for-byte as the foreign owner wrote it"; +} + +TEST(CASPoolRemount, ShutdownGuardRefusesToArmRemount) +{ + auto backend = std::make_shared(); + /// background_watermark = true so scheduleRemount actually arms a recovery thread in production mode + /// (the same gate every background thread checks). + auto store = DB::Cas::Pool::open(backend, + DB::Cas::PoolConfig{.pool_prefix = "p", .server_root_id = "test", .background_watermark = true}); + + /// Teardown has begun: ~Pool() latches this at its very top, BEFORE its only remount-thread join. + store->beginShutdownForTest(); + + /// A lease-renewal failure firing DURING teardown re-enters scheduleRemount (the keeper's on_lost + /// callback). With the guard it must refuse to spawn; without it, it arms remount_thread AFTER + /// ~Pool()'s join — the leftover joinable ThreadFromGlobalPool handle then abort()s the process at + /// member destruction (std::terminate). Reading joinable() immediately after the synchronous call is + /// race-free: the armed thread never touches the handle. + EXPECT_FALSE(store->scheduleRemountForTest()) + << "scheduleRemount must not arm a recovery thread once teardown has begun"; +} + +namespace +{ +/// A sequenced fake boot clock: the first N `bootMsNow()` calls return the values queued via +/// `.queue`, in order; every call after the queue drains returns `.steady`. `CasMountRuntime::bootMsNow` +/// re-invokes `PoolConfig::boot_ms_fn` on EVERY call, with zero memoization -- so a plain call-counter +/// deterministically distinguishes an early (anchor) reading from a later (response-time) one, with no +/// real sleep and no threads. +struct SequencedBootClock +{ + std::vector queue; + size_t next = 0; + uint64_t steady = 0; + + uint64_t operator()() + { + if (next < queue.size()) + return queue[next++]; + return steady; + } +}; +} + +/// Phase B addendum 2 (task 5b review, reviewer's probe): the self-remount arm must anchor at the +/// claim attempt's pre-I/O instant (`remount_anchor_boot_ms`, captured right after `installKeeper` +/// and right before `keeperStart()` in `Pool::tryRemountOnce`), never at a later reading taken after +/// `keeperStart`/`quiesceRefTablesForRemount` have already run. +/// +/// The two `bootMsNow()` calls of interest, in the ORDER each code version issues them: +/// - FIXED code: call #1 = the new anchor (`remount_anchor_boot_ms`, before `keeperStart`); +/// call #2 = `MountLeaseKeeper::prepareRenew`'s own internal boot read inside `keeperStart`'s +/// `doStart` (feeds only the keeper's OWN internal `confirmed_deadline_ms` -- unrelated to the +/// Pool-level arm -- so its value is irrelevant to the arm post-fix). +/// - PRE-FIX code (no anchor line): call #1 = that SAME `prepareRenew` read (now the first boot +/// call of the attempt, since nothing reads the clock before `keeperStart`); call #2 = the +/// arm-site's own `mount_runtime.bootMsNow()`, read AFTER `keeperStart` returns -- the stale, +/// response-time reading this whole fix exists to stop using. +/// A sequenced clock returning 10000 then 999999 (an inflated, much-later reading) therefore arms +/// the FIXED code from 10000 and the PRE-FIX code from 999999, regardless of which call site reads +/// which value -- letting a single deterministic probe (`mayMutate()` at boot == 10000+ttl) tell +/// them apart with no sleep and no thread. (TDD evidence for both branches is recorded in the task-5 +/// report, not re-asserted here: this test body only encodes the FIXED expectation.) +TEST(CASPoolRemount, RemountArmAnchorsAtClaimAttemptNotResponseTime) +{ + SequencedBootClock clock; + auto backend = std::make_shared(); + auto store = DB::Cas::Pool::open(backend, DB::Cas::PoolConfig{ + .pool_prefix = "p", .server_root_id = "test", + .mount_lease_ttl_ms = std::chrono::milliseconds(30'000), + .boot_ms_fn = [&] { return clock(); }, + }); + ASSERT_TRUE(store); + + /// Trip the fence exactly as every other remount test in this file does. + fenceOutMount(*backend, store->layout().mountKey("test")); + + /// Arm the sequence for the upcoming remount attempt: the initial `open` above already drained + /// an unrelated number of `bootMsNow()` calls (all served from `.steady = 0` -- irrelevant, since + /// nothing probes the resulting arm before this point). Reset the counter so the FIRST call from + /// here on is the remount attempt's own call #1. + clock.queue = {10000, 999999}; + clock.next = 0; + + ASSERT_TRUE(store->tryRemountOnce()); + + /// Probe at boot == anchor + ttl (10000 + 30000 = 40000): the fixed code armed from the anchor + /// (10000), so the fence has JUST expired here -- `mayMutate` must be false. (The pre-fix code + /// would still read `mayMutate` as true here, armed from 999999 + 30000 -- see the TDD run in the + /// report.) + clock.steady = 40000; + EXPECT_FALSE(store->mayMutate()) + << "the remount arm must anchor at the claim attempt's pre-I/O instant, not a later " + "response-time reading taken after keeperStart/quiesceRefTablesForRemount"; +} + +/// ==== rev.6 Task 5: clean-release drain gates the farewell marker ==== + +namespace +{ +/// Forces the FIRST `putIfAbsent` whose key contains `fault_key_substr` to throw an ambiguous +/// (Unresolved-classified) exception, `fault_count` times -- the minimal one-shot subset of +/// `RefWriterTestBackend`'s fault injection (gtest_cas_ref_writer.cpp) this file's shutdown test needs +/// to drive a ref-log append into the `Unresolved`/wedge outcome, with `max_attempts = 1` in the budget +/// so the single failed attempt exhausts the retry budget immediately. +class UnresolvedPutBackend final : public DB::Cas::tests::CountingBackend +{ +public: + String fault_key_substr; + int fault_count = 0; + + DB::Cas::PutResult putIfAbsent(const String & key, const String & bytes, const DB::Cas::ObjectMeta & meta) override + { + if (fault_count > 0 && !fault_key_substr.empty() && key.find(fault_key_substr) != String::npos) + { + --fault_count; + throw Poco::TimeoutException("UnresolvedPutBackend: simulated ambiguous result (response lost)"); + } + return DB::Cas::tests::CountingBackend::putIfAbsent(key, bytes, meta); + } +}; +} + +TEST(CASPoolShutdown, CleanStopDrainsAndWritesFarewell) +{ + auto backend = std::make_shared(); + auto store = DB::Cas::Pool::open(backend, DB::Cas::PoolConfig{.pool_prefix = "p", .server_root_id = "test"}); + publishPart(store, "srv/clean_stop", "x", "payload"); + + const String mount_key = store->layout().mountKey("test"); + store.reset(); /// drives ~Pool(): with no in-flight ref-log PUT, the drain must succeed. + + const auto got = backend->get(mount_key); + ASSERT_TRUE(got.has_value()); + const MountLease lease = decodeMountLease(got->bytes); + EXPECT_EQ(lease.min_active, std::numeric_limits::max()) + << "a clean drain (no in-flight ref-log PUT) must write the farewell marker"; +} + +TEST(CASPoolShutdown, UnresolvedWedgeSkipsFarewell) +{ + CasRequestBudget budget; + budget.max_attempts = 1; + budget.attempt_timeout_ms = 100; + budget.operation_deadline_ms = 5000; /// strictly above attempt_timeout_ms: equality is a wall-clock race (validateCasRequestBudget) + budget.lease_safety_margin_ms = 100; + + auto backend = std::make_shared(); + auto store = DB::Cas::Pool::open(backend, DB::Cas::PoolConfig{ + .pool_prefix = "p", .server_root_id = "test", .cas_request_budget = budget}); + /// By value: `layout` is used after `store.reset()` below, a reference would dangle. + const Layout layout = store->layout(); + const RootNamespace ns{"srv/wedge_shutdown"}; + /// Stage B (Task 4-C): pin `ns` to the Stage-A sentinel BEFORE its first real touch, so the fault + /// injected below (computed from that same sentinel) lands on the key production actually writes + /// to -- otherwise the real append mints an unrelated random incarnation and the fault misses. + DB::Cas::tests::casAdmitRecoverableEntry(*backend, layout, ns, store->liveWriterEpoch()); + publishPart(store, ns.string(), "x", "payload"); + + /// Force the ref-log append the drop below performs into the Unresolved/wedge outcome (as in the + /// wedge tests in gtest_cas_ref_writer.cpp): the single attempt the budget allows fails ambiguously. + backend->fault_key_substr = layout.namespaceStreamPrefix(DB::Cas::tests::fixture::fixtureLife(ns)) + "_log/"; + backend->fault_count = 1; + expectThrowsCode(DB::ErrorCodes::NETWORK_ERROR, [&] { store->dropRef(ns, "x"); }); + ASSERT_TRUE(store->refLaneWedgedForTest(ns)); + + const String mount_key = store->layout().mountKey("test"); + store.reset(); /// drives ~Pool(): the still-wedged lane must skip the farewell marker. + + const auto got = backend->get(mount_key); + ASSERT_TRUE(got.has_value()); + const MountLease lease = decodeMountLease(got->bytes); + EXPECT_NE(lease.min_active, std::numeric_limits::max()) + << "an unresolved ref-log PUT must skip the clean-release farewell marker"; + EXPECT_FALSE(lease.gc_fenced); + + /// A successor claimMount on this body must return LiveDoubleStart (unclean path): no certificate of + /// death (not fenced, not the clean farewell marker, no proven-dead observation) justifies a + /// same-uuid, different-epoch reclaim. + const MountClaimResult claim = claimMount(*backend, layout, "test", lease.server_uuid, + lease.writer_epoch + 1, /*now_ms=*/1, /*ttl_ms=*/30000); + EXPECT_EQ(claim.kind, MountClaimResult::LiveDoubleStart); +} + +/// ==== What a writable mount open may block on ==== +/// +/// Exactly one thing: the token-stability observation window, and only when the predecessor's death +/// has to be OBSERVED rather than certified. The post-reclaim materialization grace (`T_mat`) that +/// used to run beside it is retired -- it existed so a straggler conditional `PUT` from the dying +/// epoch would settle before the successor trusted its recovery LISTINGS, and recovery does not trust +/// listings any more (it walks arithmetically and fences the straggler with an in-band `EpochSeal`). +/// These three tests pin the surviving shape from all three directions: observed-dead, certified-dead, +/// and cleanly departed. + +TEST(CASMountOpenWaits, UncleanOpenPaysOnlyTheObservationWindow) +{ + auto b = std::make_shared(); + Layout l{"p"}; + DB::Cas::tests::seedPoolMetaForRestart(*b); + /// Predecessor: claim epoch 7, no farewell (simulate crash: just drop the keeper) -- a bare + /// `claimMount` plants the lease directly, with no clean-farewell `min_active` marker and no + /// `gc_fenced`, so the successor below has no certificate of death until it observes one itself. + ASSERT_EQ(claimMount(*b, l, "test", UInt128(1), /*epoch*/ 7, /*now_ms*/ 1000, /*ttl_ms*/ 500).kind, + MountClaimResult::Claimed); + /// A real predecessor at epoch 7 durably minted it first (`allocateWriterEpoch` always runs + /// before the mount claim); seed that durable epoch object here too, or the successor's own + /// `allocateWriterEpoch` trips the Phase C guard (epoch absent, mount present -> fail closed). + b->putIfAbsent(l.epochKey("test"), encodeServerEpoch(ServerEpoch{.next_writer_epoch = 8})); + + /// A 500ms lease TTL is far below the default `cas_request_budget` (RFC + /// cas-s3-timeout-retry-control §required-timeout-model requires attempt_timeout + safety_margin < + /// lease TTL), so scale the budget down to fit -- mirrors `CasMountStartup::StaleSelfMountReclaimedAfterWait`. + const CasRequestBudget tiny_budget{ + .attempt_timeout_ms = 50, .operation_deadline_ms = 500, .max_attempts = 1, .lease_safety_margin_ms = 50}; + + uint64_t fake_boot = 0; + std::vector waits; + PoolPtr store; + ASSERT_NO_THROW( + store = Pool::open(b, PoolConfig{ + .pool_prefix = "p", .server_id = UInt128(1), .server_root_id = "test", + .mount_lease_ttl_ms = std::chrono::milliseconds(500), + .mount_renew_period = std::chrono::milliseconds(100), + .cas_request_budget = tiny_budget, + .boot_ms_fn = [&] { return fake_boot; }, + .wait_sleep_fn = [&](uint64_t ms) { fake_boot += ms; waits.push_back(ms); }, + })); + ASSERT_TRUE(store); + + /// The token-stability observation window (>= the 500ms ttl) is paid, because this predecessor's + /// death was never certified -- only observed. + uint64_t total = 0; + for (uint64_t w : waits) + total += w; + EXPECT_GE(total, 500u) << "the observation window must have been paid"; + /// And NOTHING is paid on top of it. Every recorded wait is a poll of that window, bounded by the + /// lease TTL; a wait longer than the whole window can only be a reintroduced grace period. + for (uint64_t w : waits) + EXPECT_LE(w, 500u) + << "an unclean reclaim must not block on any wait beyond the observation poll -- the " + "straggler it used to wait out is fenced by the recovery seal instead"; +} + +TEST(CASMountOpenWaits, CleanOpenSkipsAllWaits) +{ + auto b = std::make_shared(); + /// Predecessor released cleanly (drain + farewell from Task 5): open, then reset() drives ~Pool(), + /// which -- with nothing in flight -- writes the farewell marker (min_active == UINT64_MAX). + auto predecessor = Pool::open(b, PoolConfig{ + .pool_prefix = "p", .server_id = UInt128(1), .server_root_id = "test"}); + predecessor.reset(); + + std::vector waits; + PoolPtr successor; + ASSERT_NO_THROW( + successor = Pool::open(b, PoolConfig{ + .pool_prefix = "p", .server_id = UInt128(1), .server_root_id = "test", + .wait_sleep_fn = [&](uint64_t ms) { waits.push_back(ms); }, + })); + ASSERT_TRUE(successor); + + EXPECT_TRUE(waits.empty()) + << "a clean farewell (Task 5) needs no observation window"; +} + +TEST(CASMountOpenWaits, FencedPriorReclaimsWithoutAnyWait) +{ + auto b = std::make_shared(); + Layout l{"p"}; + DB::Cas::tests::seedPoolMetaForRestart(*b); + ASSERT_EQ(claimMount(*b, l, "test", UInt128(1), /*epoch*/ 7, /*now_ms*/ 1000, /*ttl_ms*/ 500).kind, + MountClaimResult::Claimed); + /// A real predecessor at epoch 7 durably minted it first (`allocateWriterEpoch` always runs + /// before the mount claim); seed that durable epoch object here too, or the successor's own + /// `allocateWriterEpoch` trips the Phase C guard (epoch absent, mount present -> fail closed). + b->putIfAbsent(l.epochKey("test"), encodeServerEpoch(ServerEpoch{.next_writer_epoch = 8})); + /// Predecessor lease carries gc_fenced=true: fence it directly, exactly as `computeHeartbeatFloor`'s + /// fence-out does (preserve the body, gc_fenced = true, seq + 1, token-guarded). + fenceOutMount(*b, l.mountKey("test")); + + /// See UncleanOpenPaysOnlyTheObservationWindow above: a 500ms TTL needs a scaled-down budget too. + const CasRequestBudget tiny_budget{ + .attempt_timeout_ms = 50, .operation_deadline_ms = 500, .max_attempts = 1, .lease_safety_margin_ms = 50}; + + std::vector waits; + PoolPtr store; + ASSERT_NO_THROW( + store = Pool::open(b, PoolConfig{ + .pool_prefix = "p", .server_id = UInt128(1), .server_root_id = "test", + .mount_lease_ttl_ms = std::chrono::milliseconds(500), + .cas_request_budget = tiny_budget, + .wait_sleep_fn = [&](uint64_t ms) { waits.push_back(ms); }, + })); + ASSERT_TRUE(store); + + /// A GC-fenced prior is a terminal, already-threshold-gated certificate of death -- reclaimed on the + /// FIRST attempt, with no observation polling. It is also an UNCLEAN prior, which used to mean it + /// paid the materialization grace; nothing is owed now, so this open blocks on nothing at all. + EXPECT_TRUE(waits.empty()) + << "a certified-dead predecessor needs neither the observation window nor any grace period"; +} + +namespace +{ +/// Stalls the CLAIM ITSELF past the lease TTL, and counts what the open writes afterwards. +/// +/// The mount key is written twice before the write fence arms: once by `claimMount`'s reclaim, then +/// once by the keeper's adopt -- and the fence's anchor is taken BETWEEN them. So advancing the +/// injected boot clock on the SECOND write models exactly the thing the Phase B redo exists for: the +/// claim's own I/O outliving the lease it is about to arm a fence under. (This used to be modelled by +/// a materialization grace long enough to consume the TTL; that wait is retired, and the guard it +/// motivated is not -- a stalled socket can still outlive a validated request budget.) +class StalledMountClaimBackend final : public DB::Cas::InMemoryBackend +{ +public: + String mount_key; + std::function on_second_mount_write; + std::atomic mount_writes{0}; + std::atomic mount_writes_after_stall{0}; + + DB::Cas::PutResult putOverwrite(const String & k, const String & b, const DB::Cas::Token & e, + const DB::Cas::ObjectMeta & m) override + { + if (k == mount_key) + { + const int n = ++mount_writes; + if (n == 2 && on_second_mount_write) + on_second_mount_write(); + else if (n > 2) + ++mount_writes_after_stall; + } + return InMemoryBackend::putOverwrite(k, b, e, m); + } +}; +} + +/// Phase B startup-arm (spec rev.4, codex round-3 finding 2): a claim path that consumed the lease TTL +/// must force ONE fresh conditional lease write before arming — the fence must never arm from an anchor +/// that has already expired (a successor could have legally reclaimed meanwhile). +TEST(CASPool, StartupArmRedoesLeaseWriteWhenTheClaimConsumesTtl) +{ + auto backend = std::make_shared(); + DB::Cas::Layout layout("pool"); + DB::Cas::tests::seedPoolMetaForRestart(*backend, "pool"); + const String srid = "s"; + const DB::UInt128 uuid(0x42); + backend->mount_key = layout.mountKey(srid); + + /// Seed a FENCED, expired predecessor body under a DIFFERENT epoch (7, matching + /// `FencedPriorPaysOnlyTmat`'s convention). The durable epoch object seeded a few lines below + /// carries `next_writer_epoch = 8`, so THIS pool's own first-allocated `writer_epoch` is 8 -- + /// non-colliding with the seeded epoch-7 prior by construction. With no collision the first + /// (and only) claim attempt reclaims directly with MountPriorState::Fenced, with no silent + /// FencedSelf fence-recovery detour to account for -- so the mount key is written exactly twice + /// before the arm, which is what the stall hook counts on. + { + DB::Cas::MountLease prior; + prior.server_uuid = uuid; + prior.writer_epoch = 7; + prior.seq = 7; + prior.expires_at_ms = 1; /// long expired + prior.gc_fenced = true; + backend->putIfAbsent(layout.mountKey(srid), DB::Cas::encodeMountLease(prior)); + } + /// A real predecessor at epoch 7 durably minted it first (`allocateWriterEpoch` always runs + /// before the mount claim); seed that durable epoch object here too, or `Pool::open`'s own + /// `allocateWriterEpoch` trips the Phase C guard (epoch absent, mount present -> fail closed). + backend->putIfAbsent(layout.epochKey(srid), DB::Cas::encodeServerEpoch(DB::Cas::ServerEpoch{.next_writer_epoch = 8})); + uint64_t fake_boot_ms = 10'000; + DB::Cas::PoolConfig cfg; + cfg.pool_prefix = "pool"; + cfg.server_id = uuid; + cfg.server_root_id = srid; + cfg.mount_lease_ttl_ms = std::chrono::milliseconds(30'000); + cfg.boot_ms_fn = [&] { return fake_boot_ms; }; + /// The keeper's adopt write stalls for 40 s of boot clock -- past the 30 s TTL the anchor a few + /// microseconds earlier was taken against. + backend->on_second_mount_write = [&] { fake_boot_ms += 40'000; }; + + auto store = DB::Cas::Pool::open(backend, cfg); + ASSERT_NE(store, nullptr); + + ASSERT_EQ(backend->mount_writes.load(), 3) + << "the fixture assumes exactly two mount writes before the redo (the reclaim and the keeper's " + "adopt, with the fence anchor between them); a different sequence would make the stall land " + "somewhere else and this test would stop testing the redo"; + EXPECT_EQ(backend->mount_writes_after_stall.load(), 1) + << "a TTL-consuming claim must be followed by exactly ONE fresh conditional lease write " + "(the re-anchoring redo) before the write fence arms"; +} + +/// ==== What a self-remount may block on ==== +/// +/// Nothing an operator configures. The remount used to consult `refLanesSettledForRemount` and pay the +/// materialization grace whenever a ref lane still held an undecided `PUT`; both are retired, because +/// the undecided `PUT` is settled by the protocol rather than waited out — recovery closes the dead +/// epoch with an in-band `EpochSeal` written as a conditional create, and the straggler's own create +/// loses to it. `gtest_cas_retirement_sweep.cpp` proves that conflict directly; these two pin that the +/// wait is gone from both the drained and the still-wedged path. + +TEST(CASRemountWaits, DrainedRemountPaysNoWait) +{ + auto backend = std::make_shared(); + uint64_t fake_boot = 1'000'000; + std::vector waits; + auto store = Pool::open(backend, PoolConfig{ + .pool_prefix = "p", .server_root_id = "test", + .mount_lease_ttl_ms = std::chrono::milliseconds(30000), + .boot_ms_fn = [&] { return fake_boot; }, + .wait_sleep_fn = [&](uint64_t ms) { fake_boot += ms; waits.push_back(ms); }, + }); + ASSERT_TRUE(store); + EXPECT_TRUE(waits.empty()) << "a fresh mount (no predecessor) pays no wait at open"; + + /// Trip the fence: advance the local boot clock past the deadline (as in `WriteFenceUsesInjectedBootClock` + /// above) and mark the durable lease `gc_fenced` (the certificate `claimMountAwaitingExpiry` reclaims + /// on its FIRST attempt, no observation polling -- avoids a real sleep in this test). + fake_boot += 30001; + fenceOutMount(*backend, store->layout().mountKey("test")); + + /// No in-flight ref-log PUT at all -- the easy direction. + ASSERT_TRUE(store->tryRemountOnce()); + + EXPECT_TRUE(waits.empty()) + << "a drained self-remount must pay no wait"; +} + +TEST(CASRemountWaits, UnresolvedWedgeRemountPaysNoWaitEither) +{ + CasRequestBudget budget; + budget.max_attempts = 1; + budget.attempt_timeout_ms = 100; + budget.operation_deadline_ms = 5000; /// strictly above attempt_timeout_ms: equality is a wall-clock race (validateCasRequestBudget) + budget.lease_safety_margin_ms = 100; + + auto backend = std::make_shared(); + uint64_t fake_boot = 1'000'000; + std::vector waits; + auto store = Pool::open(backend, PoolConfig{ + .pool_prefix = "p", .server_root_id = "test", + .mount_lease_ttl_ms = std::chrono::milliseconds(30000), + .cas_request_budget = budget, + .boot_ms_fn = [&] { return fake_boot; }, + .wait_sleep_fn = [&](uint64_t ms) { fake_boot += ms; waits.push_back(ms); }, + }); + ASSERT_TRUE(store); + EXPECT_TRUE(waits.empty()) << "a fresh mount (no predecessor) pays no wait at open"; + + const Layout & layout = store->layout(); + const RootNamespace ns{"srv/remount_wedge"}; + /// Stage B (Task 4-C): see `CASPoolShutdown.UnresolvedWedgeSkipsFarewell`'s identical comment. + DB::Cas::tests::casAdmitRecoverableEntry(*backend, layout, ns, store->liveWriterEpoch()); + publishPart(store, ns.string(), "x", "payload"); + + /// Force the ref-log append `dropRef` below performs into the Unresolved/wedge outcome (as in + /// `CASPoolShutdown.UnresolvedWedgeSkipsFarewell`): the single attempt the budget allows fails + /// ambiguously. + backend->fault_key_substr = layout.namespaceStreamPrefix(DB::Cas::tests::fixture::fixtureLife(ns)) + "_log/"; + backend->fault_count = 1; + expectThrowsCode(DB::ErrorCodes::NETWORK_ERROR, [&] { store->dropRef(ns, "x"); }); + ASSERT_TRUE(store->refLaneWedgedForTest(ns)); + + /// Trip the fence exactly as in `DrainedRemountSkipsGrace` above. + fake_boot += 30001; + fenceOutMount(*backend, store->layout().mountKey("test")); + + /// THE HARD DIRECTION, and the one the retired wait existed for: a ref lane that still holds an + /// UNDECIDED conditional PUT when the fence trips. It used to buy a 30 s grace. It buys nothing now + /// -- the remount proceeds straight through, and the undecided PUT is decided by the seal the next + /// recovery writes into its slot. + ASSERT_TRUE(store->tryRemountOnce()); + + EXPECT_TRUE(waits.empty()) + << "an unresolved ref-lane wedge must not make the remount block: the straggler it describes is " + "fenced by the recovery seal, not waited out"; +} + +/// Sealing is decided by ARITHMETIC -- `epoch < live_epoch` -- and by nothing else. This test used to +/// pin the opposite ("a table recovered under a later CLEAN boundary must not seal"), which was the +/// right rule while a seal was a synthetic SNAPSHOT published only to close an unclean handover: such a +/// seal after a clean shutdown was pure parasitic cost, so it was gated on the per-epoch unclean flag. +/// +/// INV-2's seal is not that object. It is the chain link that makes a MISSING epoch detectable across a +/// transition, and a chain that skips every epoch whose mount happened to shut down cleanly is not a +/// chain -- the next sequence-1 transaction would have no `prev_epoch_seal` to name, and no reader could +/// tell "epoch 2 was empty" from "epoch 2's records are gone". So a late-touched table now closes EVERY +/// dead epoch below the live one, however its predecessors died, and this test pins that plus the two +/// things that must still be true: the seals land IN-BAND (at log keys, at the slot a straggler would +/// have taken) and no synthetic seal SNAPSHOT is written anywhere. +TEST(CASRemountWaits, ALateTouchedTableClosesEveryDeadEpochInBandHoweverItsPredecessorsDied) +{ + CasRequestBudget budget; + budget.max_attempts = 1; + budget.attempt_timeout_ms = 100; + budget.operation_deadline_ms = 5000; /// strictly above attempt_timeout_ms: equality is a wall-clock race (validateCasRequestBudget) + budget.lease_safety_margin_ms = 100; + + auto backend = std::make_shared(); + uint64_t fake_boot = 1'000'000; + auto store = Pool::open(backend, PoolConfig{ + .pool_prefix = "p", .server_root_id = "test", + .mount_lease_ttl_ms = std::chrono::milliseconds(30000), + .cas_request_budget = budget, + .boot_ms_fn = [&] { return fake_boot; }, + .wait_sleep_fn = [&](uint64_t ms) { fake_boot += ms; }, + }); + ASSERT_TRUE(store); + + const Layout & layout = store->layout(); + const RootNamespace ns1{"srv/table_a"}; + const RootNamespace ns2{"srv/table_b"}; + /// Stage B (Task 4-C): `ns1` is pinned because the fault below targets its key by exact sentinel + /// match. `ns2` must ALSO be pinned: the epoch-close assertions further down read its ref-log keys + /// directly at `DB::Cas::tests::fixture::fixtureLife(ns2)`. + DB::Cas::tests::casAdmitRecoverableEntry(*backend, layout, ns1, store->liveWriterEpoch()); + DB::Cas::tests::casAdmitRecoverableEntry(*backend, layout, ns2, store->liveWriterEpoch()); + publishPart(store, ns1.string(), "x", "payload-a"); + /// ns2's epoch-1 data: never touched again by this incarnation until the final check below, well + /// after both remounts -- the "table recovered for the first time, late" the fix must not over-seal. + /// Distinct content from ns1's part: identical payloads collide on the same blob and race + /// `PartWriteTxn::observeAndAdmit`'s newborn-debris watermark, unrelated to what this test is about. + publishPart(store, ns2.string(), "y", "payload-b"); + + /// Force ns1's ref-log append into the Unresolved/wedge outcome (mirrors + /// `UnresolvedWedgeRemountPaysNoWaitEither` above). + backend->fault_key_substr = layout.namespaceStreamPrefix(DB::Cas::tests::fixture::fixtureLife(ns1)) + "_log/"; + backend->fault_count = 1; + expectThrowsCode(DB::ErrorCodes::NETWORK_ERROR, [&] { store->dropRef(ns1, "x"); }); + ASSERT_TRUE(store->refLaneWedgedForTest(ns1)); + + /// Self-remount #1: UNCLEAN (the wedge above). Epoch 1 -> 2. + fake_boot += 30001; + fenceOutMount(*backend, store->layout().mountKey("test")); + ASSERT_TRUE(store->tryRemountOnce()); + ASSERT_EQ(store->liveWriterEpoch(), 2u); + + /// Self-remount #2: CLEAN (no wedge left behind -- `quiesceRefTablesForRemount` already cleared the + /// cache). Epoch 2 -> 3. + fake_boot += 30001; + fenceOutMount(*backend, store->layout().mountKey("test")); + ASSERT_TRUE(store->tryRemountOnce()); + ASSERT_EQ(store->liveWriterEpoch(), 3u); + + using ProfileEvents::global_counters; + const auto sealed_before = global_counters[ProfileEvents::CASRefRecoveryEpochSealed].load(); + + /// ns2's FIRST recovery under this incarnation happens now, at epoch 3 -- strictly after both + /// remounts. Its only data is at epoch 1, so epochs 1 and 2 are both dead for it. + EXPECT_EQ(store->listRefs(ns2).size(), 1u); + + EXPECT_EQ(global_counters[ProfileEvents::CASRefRecoveryEpochSealed].load(), sealed_before + 2) + << "both dead epochs must be closed -- the chain link is what a later reader needs to tell an " + "EMPTY epoch from a LOST one, and that is independent of how each mount ended"; + EXPECT_TRUE(backend->get(layout.refLogKey(DB::Cas::tests::fixture::fixtureLife(ns2), RefTxnId{1, 2})).has_value()) + << "epoch 1 closes at the slot right after its last durable id, in-band"; + EXPECT_TRUE(backend->get(layout.refLogKey(DB::Cas::tests::fixture::fixtureLife(ns2), RefTxnId{2, 1})).has_value()) + << "empty epoch 2 closes at its own sequence 1, chained to the epoch-1 seal"; + const RefTxnId retired_sentinel_id{2, std::numeric_limits::max()}; + EXPECT_FALSE(backend->get(layout.refSnapshotKey(DB::Cas::tests::fixture::fixtureLife(ns2), retired_sentinel_id)).has_value()) + << "and NO synthetic seal snapshot is written: that shape is retired"; +} + +TEST(CASPool, ReadManifestSharedReturnsSharedDecodeWithoutCopy) +{ + auto backend = std::make_shared(); + const DB::Cas::Layout layout("p"); + DB::Cas::tests::seedPoolMetaForRestart(*backend); + const DB::Cas::RootNamespace ns{"srv/t1"}; + const DB::Cas::ManifestRef ref{.writer_epoch = 1, .build_sequence = 1, .manifest_ordinal = 1}; + const auto id = DB::Cas::tests::writeManifestRaw(*backend, layout, ns, ref, + {DB::Cas::tests::blobEntryFor("data.bin", DB::UInt128(7))}); + DB::Cas::tests::fixture::writeRefLogRaw(*backend, layout, RefLogTxn{ns.string(), RefTxnId{1, 1}, + {DB::Cas::tests::namespaceBirthOp(), DB::Cas::tests::publishCommittedOps("part_1", ref)[0], + DB::Cas::tests::publishCommittedOps("part_1", ref)[1]}, std::nullopt}); + DB::Cas::tests::writeRecoverableCkptForRawFixture(*backend, layout, ns, RefCkpt{ + .life_epoch = 1, + .committed_through = RefTxnId{1, 1}, + .checkpoint_snapshot_id = std::nullopt, + .last_epoch_seal = std::nullopt, + }); + + auto store = DB::Cas::Pool::open(backend, + DB::Cas::PoolConfig{.pool_prefix = "p", .server_root_id = "test"}); + const auto resolved = store->resolveRef(ns, "part_1"); + ASSERT_TRUE(resolved.has_value()); + + const String manifest_key = layout.manifestKey(id); + backend->resetCounts(); + + auto m1 = store->readManifestShared(resolved->manifest_id); + auto m2 = store->readManifestShared(resolved->manifest_id); + EXPECT_EQ(m1.get(), m2.get()); /// the SAME shared decode, no copy + EXPECT_EQ(backend->getCount(manifest_key), 1u); /// one body GET + EXPECT_EQ(backend->headCount(manifest_key), 2u); /// mandatory HEAD per call (unchanged) + ASSERT_EQ(m1->entries.size(), 1u); + EXPECT_EQ(m1->entries[0].path, "data.bin"); +} + +/// Coverage gap (Task 13a): restores the get/exists/remove roundtrip for the mount access-check probe +/// object. The old `CASPool.MountpointObjectRoundTrip` was dropped in the refactor; the wiring test only +/// exercises `putMountpointObject` + `existsFile`, leaving `getMountpointObject`'s value round-trip and +/// `removeMountpointObject` unasserted even though both `Pool` methods remain live. +TEST(CASPool, MountpointObjectRoundTrip) +{ + auto b = std::make_shared(); + auto store = DB::Cas::Pool::open(b, DB::Cas::PoolConfig{.pool_prefix = "p", .server_root_id = "test"}); + const String key = "srv1/clickhouse_access_check_abc"; + EXPECT_FALSE(store->getMountpointObject(key).has_value()); + EXPECT_FALSE(store->mountpointObjectExists(key)); + store->putMountpointObject(key, "probe-bytes"); + EXPECT_TRUE(store->mountpointObjectExists(key)); + auto got = store->getMountpointObject(key); + ASSERT_TRUE(got.has_value()); + EXPECT_EQ(*got, "probe-bytes"); + store->removeMountpointObject(key); + EXPECT_FALSE(store->getMountpointObject(key).has_value()); + EXPECT_FALSE(store->mountpointObjectExists(key)); +} diff --git a/src/Disks/tests/gtest_cas_probe.cpp b/src/Disks/tests/gtest_cas_probe.cpp new file mode 100644 index 000000000000..f2362b9a1bec --- /dev/null +++ b/src/Disks/tests/gtest_cas_probe.cpp @@ -0,0 +1,309 @@ +#include +#include +#include +#include +#include +#include +#include +#include + +namespace DB +{ +namespace ErrorCodes +{ + extern const int NOT_IMPLEMENTED; +} +} + +using namespace DB::Cas; + +TEST(CASProbe, PassesOnEnforcingBackend) +{ + auto b = std::make_shared(); + EXPECT_NO_THROW(runCapabilityProbe(*b, "p/.cas_probe")); + EXPECT_TRUE(b->list("p/.cas_probe", "", 10).keys.empty()); // probe cleans up after itself +} + +/// AWS S3 answers 400 InvalidArgument to a conditional DELETE with an EMPTY If-Match, and the +/// probe's exit cleanup used to issue exactly that (deleteExact with the absent HeadResult's empty +/// token) after step 8 had already deleted the probe keys — two scary AWSClient log lines +/// on every real-S3 mount. The cleanup must HEAD-gate the delete instead of firing blindly. +class EmptyTokenDeleteRecorder : public InMemoryBackend +{ +public: + size_t empty_token_deletes = 0; + + DeleteOutcome deleteExact(const String & key, const Token & token) override + { + if (token.empty()) + ++empty_token_deletes; + return InMemoryBackend::deleteExact(key, token); + } +}; + +TEST(CASProbe, CleanupNeverDeletesWithEmptyToken) +{ + auto b = std::make_shared(); + EXPECT_NO_THROW(runCapabilityProbe(*b, "p/.cas_probe")); + EXPECT_EQ(b->empty_token_deletes, 0u); +} + +TEST(CASProbe, FailsClosedOnNonEnforcingDelete) +{ + auto b = std::make_shared(); + b->setEnforceTokens(false); // the MinIO-OSS failure mode + EXPECT_THROW(runCapabilityProbe(*b, "p/.cas_probe"), DB::Exception); +} + +TEST(CASProbe, FailsClosedOnDeleteMarkers) +{ + auto b = std::make_shared(); + b->setSimulateDeleteMarkers(true); // versioning enabled on the prefix + EXPECT_THROW(runCapabilityProbe(*b, "p/.cas_probe"), DB::Exception); +} + +TEST(CASProbe, PassesOnEmulatedLocal) +{ + auto b = std::make_shared( + DB::Cas::tests::makeLocalObjectStorageForTest(), ObjectStorageBackend::Mode::EmulatedSingleProcess); + EXPECT_NO_THROW(runCapabilityProbe(*b, "p/.cas_probe")); +} + +/// B135: two servers mounting the SAME shared CA pool concurrently must not race on the probe keys. +/// We simulate "a concurrent mounter's probe is in flight" by PRE-SEEDING the fixed-name probe key +/// `/_probe/token` over a shared backend, then opening the Pool. With the OLD fixed-key probe +/// the open's `putIfAbsent("/_probe/token", …)` returns PreconditionFailed and `Pool::open` +/// throws NOT_IMPLEMENTED ("putIfAbsent on a fresh key returned PreconditionFailed"). With the +/// per-mount unique probe prefix `/_probe//token`, the seeded key does not collide and +/// the open succeeds — exactly the concurrent-shared-pool-mount behaviour we need. +TEST(CASProbe, ConcurrentMountsDoNotCollide) +{ + auto b = std::make_shared(); + + /// Simulate a concurrent mounter whose probe object under the legacy fixed key is still present. + ASSERT_EQ(b->putIfAbsent("p/_probe/token", "concurrent-mounter-in-flight").outcome, PutOutcome::Done); + + /// A real (second) mount over the same shared pool must still succeed — its probe runs under a + /// fresh per-mount-unique prefix and never touches the seeded fixed key. + EXPECT_NO_THROW(Pool::open(b, PoolConfig{.pool_prefix = "p", .server_root_id = "test"})); + + /// And two genuinely-concurrent mounts (distinct unique prefixes) both succeed over one backend. + EXPECT_NO_THROW(Pool::open(b, PoolConfig{.pool_prefix = "p", .server_root_id = "test"})); + + /// The seeded fixed-key artifact is untouched (the probe never collided with it). + EXPECT_TRUE(b->get("p/_probe/token").has_value()); +} + +/// The probe must consult the backend's store-preconditions hook BEFORE the op battery: a +/// generation-dialect store on a VERSIONED bucket passes every conditional-op check, but its +/// token-exact DELETEs archive noncurrent generations instead of reclaiming storage — only the +/// hook can see that, so a throwing hook must fail the probe closed. +class PreconditionRefusingBackend : public InMemoryBackend +{ +public: + void checkPoolPreconditions() override + { + throw DB::Exception(DB::ErrorCodes::NOT_IMPLEMENTED, + "test: store precondition violated (e.g. bucket versioning enabled)"); + } +}; + +TEST(CASProbe, FailsClosedOnPoolPreconditions) +{ + auto b = std::make_shared(); + EXPECT_THROW(runCapabilityProbe(*b, "p/.cas_probe"), DB::Exception); + /// The hook fires FIRST: no probe keys may have been written. + EXPECT_TRUE(b->list("p/.cas_probe", "", 10).keys.empty()); +} + +/// `Pool::open` wraps the pool backend in `InstrumentedBackend` BEFORE calling `runCapabilityProbe` +/// (see CasPool.cpp), so the hook must actually fire THROUGH the wrapper on the real mount path — +/// not just on a raw backend, which `FailsClosedOnPoolPreconditions` above already covers. +TEST(CASProbe, PoolPreconditionsFireThroughInstrumentedWrapper) +{ + auto inner = std::make_shared(); + InstrumentedBackend wrapped(inner); + EXPECT_THROW(runCapabilityProbe(wrapped, "p/.cas_probe"), DB::Exception); + /// The hook fires FIRST: no probe keys may have been written to the inner backend. + EXPECT_TRUE(inner->list("p/.cas_probe", "", 10).keys.empty()); +} + +/// RFC cas-s3-timeout-retry-control: a Native-mode mount over an object storage that does not support +/// the SingleAttempt retry profile must never silently proceed under the disk's default (~500-attempt) +/// transparent retry policy — see Backend::checkConditionalWriteSingleAttemptSupport. +/// LocalObjectStorage never supports the profile (IObjectStorage::supportsRetryProfile's default +/// implementation only answers true for Default), so Native mode over it is exactly the case this must +/// refuse. EmulatedSingleProcess is exempt: it never claims single-attempt S3 semantics in the first +/// place (PassesOnEmulatedLocal above). +TEST(CASProbe, FailsClosedOnUnsupportedSingleAttemptProfile) +{ + auto native = std::make_shared( + DB::Cas::tests::makeLocalObjectStorageForTest(), ObjectStorageBackend::Mode::Native); + EXPECT_THROW(native->checkConditionalWriteSingleAttemptSupport(), DB::Exception); + + auto emulated = std::make_shared( + DB::Cas::tests::makeLocalObjectStorageForTest(), ObjectStorageBackend::Mode::EmulatedSingleProcess); + EXPECT_NO_THROW(emulated->checkConditionalWriteSingleAttemptSupport()); +} + +/// The same fail-closed refusal through the actual capability probe (Step 0b) — the real gate a +/// writable Pool::open goes through, not just the hook in isolation above. +TEST(CASProbe, MissingSingleAttemptClientFailsCapabilityProbe) +{ + auto storage = DB::Cas::tests::makeLocalObjectStorageForTest(); + /// Native mode passes the key to the object storage verbatim, so the probe prefix must be anchored + /// under this storage's own root: a bare prefix lands beside the test process, where an object left + /// by another run answers the LIST below and an unrooted LIST answers "no keys" for free. + const String probe_prefix = DB::Cas::tests::nativeKeyUnder(storage, "p/.cas_probe"); + + auto b = std::make_shared(storage, ObjectStorageBackend::Mode::Native); + EXPECT_THROW(runCapabilityProbe(*b, probe_prefix), DB::Exception); + /// The hook fires before the op battery: no probe keys may have been written. + EXPECT_TRUE(b->list(probe_prefix, "", 10).keys.empty()); + + /// The same LIST can see a key that IS under the prefix — otherwise the emptiness above would be + /// indistinguishable from a prefix this backend can never enumerate. + ASSERT_EQ(b->putIfAbsent(probe_prefix + "/token", "probe-v1").outcome, PutOutcome::Done); + EXPECT_FALSE(b->list(probe_prefix, "", 10).keys.empty()); +} + +/// Mirrors PoolPreconditionsFireThroughInstrumentedWrapper: the real mount path wraps the backend in +/// InstrumentedBackend BEFORE calling runCapabilityProbe, so this check must fire through it too. +TEST(CASProbe, MissingSingleAttemptClientFiresThroughInstrumentedWrapper) +{ + auto storage = DB::Cas::tests::makeLocalObjectStorageForTest(); + auto inner = std::make_shared(storage, ObjectStorageBackend::Mode::Native); + InstrumentedBackend wrapped(inner); + EXPECT_THROW(runCapabilityProbe(wrapped, DB::Cas::tests::nativeKeyUnder(storage, "p/.cas_probe")), DB::Exception); +} + +namespace +{ + +/// Models the exact shape of the trust-flip this suite must catch a regression of +/// (codex-review-triage §3.18, Critical): like the production `ObjectStorageBackend` in Native mode, +/// this backend mints and expects tokens under a dialect (`TokenType::ETag`) OTHER than +/// `TokenType::Emulated`, and rejects a foreign-dialect `expected`/`token` argument LOCALLY -- +/// before the value it carries ever reaches the real conditional-compare beneath the gate (`inner`, +/// a genuinely enforcing `InMemoryBackend`, standing in for "the wire"). Every gated method counts +/// how many times it actually delegated to `inner`, so a test can tell "rejected by the dialect +/// gate" apart from "rejected by the real enforcement" -- the exact distinction `Cas::Probe` exists +/// to prove, and the one the №19 hardening risked collapsing (see CasProbe.cpp step 3/5c/6). +class DialectGatedCountingBackend final : public Backend +{ +public: + std::optional get(const String & key, Range range) override { return inner.get(key, range); } + + std::optional getStream(const String & key, Range range) override { return inner.getStream(key, range); } + + HeadResult head(const String & key) override + { + HeadResult r = inner.head(key); + if (r.exists) + r.token.type = TokenType::ETag; + return r; + } + + bool supportsListTokens() const override { return inner.supportsListTokens(); } + + PutResult putIfAbsent(const String & key, const String & bytes, const ObjectMeta & meta) override + { + /// No `expected` token to gate -- matches production (ObjectStorageBackend::putIfAbsent has + /// no dialect check either). + PutResult r = inner.putIfAbsent(key, bytes, meta); + if (r.outcome == PutOutcome::Done) + r.token.type = TokenType::ETag; + return r; + } + + WriteSinkPtr putIfAbsentStream(const String & key, const ObjectMeta & meta) override { return inner.putIfAbsentStream(key, meta); } + + PutResult putOverwrite(const String & key, const String & bytes, const Token & expected, const ObjectMeta & meta) override + { + if (expected.type != TokenType::ETag) + return {PutOutcome::PreconditionFailed, {}}; /// dialect-gated: never reaches `inner` + ++overwrite_reached; + PutResult r = inner.putOverwrite(key, bytes, Token{expected.value, TokenType::Emulated}, meta); + if (r.outcome == PutOutcome::Done) + r.token.type = TokenType::ETag; + return r; + } + + CasResult casPut(const String & key, const String & bytes, const std::optional & expected, const ObjectMeta & meta) override + { + if (expected.has_value() && expected->type != TokenType::ETag) + return {CasOutcome::Conflict, {}}; /// dialect-gated: never reaches `inner` + ++casput_reached; + std::optional retyped; + if (expected.has_value()) + retyped = Token{expected->value, TokenType::Emulated}; + CasResult r = inner.casPut(key, bytes, retyped, meta); + if (r.outcome == CasOutcome::Committed) + r.token.type = TokenType::ETag; + return r; + } + + DeleteOutcome deleteExact(const String & key, const Token & token) override + { + if (token.type != TokenType::ETag) + { + DeleteOutcome d; + d.kind = DeleteOutcome::Kind::TokenMismatch; /// dialect-gated: never reaches `inner` + return d; + } + ++delete_reached; + return inner.deleteExact(key, Token{token.value, TokenType::Emulated}); + } + + ListPage list(const String & prefix, const String & cursor, size_t limit) override + { + ListPage p = inner.list(prefix, cursor, limit); + for (auto & k : p.keys) + if (k.token) + k.token->type = TokenType::ETag; + return p; + } + + /// Number of times putOverwrite/casPut(with expected)/deleteExact actually delegated to `inner` + /// (i.e. reached the real enforcement) rather than being short-circuited by the dialect gate. + int overwrite_reached = 0; + int casput_reached = 0; + int delete_reached = 0; + +private: + InMemoryBackend inner; +}; + +} + +/// codex-review-triage §3.18, Critical: `runCapabilityProbe`'s three wrong-token sites (step 3 +/// putOverwrite, step 5c casPut, step 6 deleteExact) must send a token in the LIVE dialect this +/// backend mints (t1.type / ct1.type / t2.type), not a hardcoded `TokenType::Emulated`. A backend +/// whose native dialect differs from Emulated -- exactly what `ObjectStorageBackend` mints in Native +/// mode -- would otherwise reject the old hardcoded tokens LOCALLY via a dialect gate, never +/// exercising the real conditional enforcement those three steps exist to validate; the probe would +/// still report success (the outcome enums match either way), so a regression here is invisible +/// unless something counts whether the real enforcement was ever reached. `DialectGatedCountingBackend` +/// enforces real (correct) conditional semantics AND gates on dialect exactly like the production +/// risk, so `runCapabilityProbe` runs to completion (unlike a real Native-mode ObjectStorageBackend +/// over LocalObjectStorage, which cannot even reach this point -- see +/// MissingSingleAttemptClientFailsCapabilityProbe and the fact that LocalObjectStorage does not honor +/// WriteSettings conditions at all); the exact reached-counts below pin down that every wrong-token +/// site got past the gate: a probe that regressed to the hardcoded-Emulated construction would still +/// pass (no throw) but under-count here by exactly one at each of the three sites, since the dialect +/// gate would swallow that one call before `inner` ever saw it. +TEST(CASProbe, WrongTokenAttemptsReachTheBackendPastTheDialectGate) +{ + DialectGatedCountingBackend b; + EXPECT_NO_THROW(runCapabilityProbe(b, "p/.cas_probe")); + + /// putOverwrite: step 3 (wrong token) + step 4 (correct token) -- both live-dialect, both gated + /// through to `inner`. + EXPECT_EQ(b.overwrite_reached, 2); + /// casPut: 5a (create), 5b (conflict-on-exists, no expected token to gate), 5c (wrong token, + /// live-dialect), 5d (correct token) -- all four reach `inner`. + EXPECT_EQ(b.casput_reached, 4); + /// deleteExact: step 6 (wrong token, live-dialect) + step 8 (correct token) + step 9 cleanup + /// (correct token for cas_key) -- all three reach `inner`. + EXPECT_EQ(b.delete_reached, 3); +} diff --git a/src/Disks/tests/gtest_cas_promote_republish.cpp b/src/Disks/tests/gtest_cas_promote_republish.cpp new file mode 100644 index 000000000000..8c6b504535f4 --- /dev/null +++ b/src/Disks/tests/gtest_cas_promote_republish.cpp @@ -0,0 +1,402 @@ +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +/// RED/characterization tests for the promote-over-committed leak fix: +/// BUG 1a (PROMOTE-OVER-COMMITTED-LEAK): `PartWriteTxn::promote` silently overwrites `refs[final_ref_name]` +/// when it already names a DIFFERENT committed manifest, orphaning the old manifest (leak). The fix +/// (Task 2) makes this throw `ABORTED` instead. +/// BUG 1c: `republishRef`'s only idempotency gate is "source absent" -- a re-drive after a crash +/// between `promote(dst)` and `dropRef(src)` finds dst ALREADY committed with the (same) content it is +/// about to re-publish, but re-stages+re-promotes anyway, minting a fresh manifest and orphaning the +/// first attempt's manifest. The fix (Task 3) makes the re-drive idempotent (content-keyed, not +/// ManifestId-keyed) when dst matches, and fail-closed (`ABORTED`) when dst holds different content. +/// +/// These tests are EXPECTED TO FAIL pre-fix -- that failure IS the bug reproducing. They must not be +/// weakened to pass; Tasks 2/3 make them pass. + +namespace DB::ErrorCodes +{ +extern const int ABORTED; +extern const int NETWORK_ERROR; +extern const int LOGICAL_ERROR; +} + +using namespace DB::Cas; + +namespace +{ + +PoolPtr openPool(const std::shared_ptr & b) +{ + return Pool::open(b, PoolConfig{.pool_prefix = "p", .server_root_id = "test"}); +} + +/// One inline-entry manifest naming `path` with content `bytes` (distinct bytes => distinct content). +/// EntryPlacement::Inline means `promote`'s blob-leaf revalidation skips it entirely -- no real blob +/// objects are needed for these tests. +std::vector inlineEntries(const String & path, const String & bytes) +{ + ManifestEntry e; + e.path = path; + e.placement = EntryPlacement::Inline; + e.inline_bytes = bytes; + return {e}; +} + +/// The full write flow for an INLINE-only manifest: stageManifest -> precommitAdd -> promote. Returns +/// the committed ManifestId. +ManifestId publishCommitted(const PoolPtr & s, const RootNamespace & ns, const String & ref, + const std::vector & entries) +{ + auto build = s->beginPartWrite(PartWriteInfo{.intended_ref = ns.string() + "/" + ref, .intended_namespace = ns}); + const ManifestId id = build->stageManifest(entries); + build->precommitAdd(ns, ref, id); + build->promote(ns, ref, build->buildId(), id); + return id; +} + +/// The ContentAddressedTransaction fixture (mirrors gtest_ca_transaction.cpp's openTxStorage / +/// writeFileTx): a real disk-layer storage + transaction, used to drive `republishRef` through its +/// ONLY caller (`ContentAddressedTransaction::moveDirectory`'s committed-source-ref-move branch), +/// since `republishRef` itself is private. +std::shared_ptr openTxStorage() +{ + auto settings = DB::Cas::tests::makeSettingsForTest( + "test", std::filesystem::temp_directory_path() / "ca_tx_promote_republish_scratch"); + auto storage = std::make_shared( + DB::Cas::tests::makeLocalObjectStorageForTest(), "pool", "srv1", "", nullptr, settings); + storage->startup(); + return storage; +} + +void writeFileTx(DB::IMetadataTransaction & tx, const std::string & path, const std::string & bytes) +{ + auto & ca_tx = dynamic_cast(tx); + auto buf = ca_tx.writeFile(path, 65536, DB::WriteMode::Rewrite, {}); + buf->write(bytes.data(), bytes.size()); + buf->finalize(); +} + +} + +/// BUG 1a: promoting a DIFFERENT manifest onto an already-committed ref must fail closed (ABORTED), +/// not silently overwrite (which orphans the old manifest, PROMOTE-OVER-COMMITTED-LEAK). +/// PRE-FIX: promote() does not throw -- this test FAILS (RED), which IS the leak reproducing. +TEST(CASPromoteRepublish, PromoteOverDifferentCommittedRefFailsClosed) +{ + auto b = std::make_shared(); + auto s = openPool(b); + const RootNamespace ns{"srv/tbl@cas@"}; + const String ref = "all_0_0_0"; + + publishCommitted(s, ns, ref, inlineEntries("f", "AAA")); // committed T_old + + auto build2 = s->beginPartWrite(PartWriteInfo{.intended_ref = ns.string() + "/" + ref, .intended_namespace = ns}); + const ManifestId id2 = build2->stageManifest(inlineEntries("f", "BBB")); // DIFFERENT content + build2->precommitAdd(ns, ref, id2); + + try + { + build2->promote(ns, ref, build2->buildId(), id2); + FAIL() << "PRE-FIX: promote silently overwrote a committed ref (PROMOTE-OVER-COMMITTED-LEAK); " + "POST-FIX must throw a CAS write-retry-later NETWORK_ERROR"; + } + catch (const DB::Exception & e) + { + EXPECT_EQ(e.code(), DB::ErrorCodes::NETWORK_ERROR); + } +} + +/// Re-promoting the SAME manifest_ref onto its own committed ref must NOT throw (idempotent +/// re-promote): the fix's guard keys on a DIFFERENT manifest_ref, not merely "ref already committed". +/// This is expected to pass BOTH pre- and post-fix (it is not part of the bug). +TEST(CASPromoteRepublish, PromoteSameManifestIsIdempotent) +{ + auto b = std::make_shared(); + auto s = openPool(b); + const RootNamespace ns{"srv/tbl@cas@"}; + const String ref = "all_0_0_0"; + const ManifestId id = publishCommitted(s, ns, ref, inlineEntries("f", "AAA")); + + /// Re-precommit + re-promote the SAME id onto the same ref: allowed (same manifest_ref). + auto build2 = s->beginPartWrite(PartWriteInfo{.intended_ref = ns.string() + "/" + ref, .intended_namespace = ns}); + build2->precommitAdd(ns, ref, id); + EXPECT_NO_THROW(build2->promote(ns, ref, build2->buildId(), id)); +} + +/// Sanity companion to BUG 1a: promote over an ABSENT ref (the normal insert path) must succeed +/// unconditionally -- the fail-close guard must only fire for an EXISTING different committed ref. +TEST(CASPromoteRepublish, PromoteOverAbsentRefSucceeds) +{ + auto b = std::make_shared(); + auto s = openPool(b); + const RootNamespace ns{"srv/tbl@cas@"}; + const String ref = "all_0_0_0"; + + auto build = s->beginPartWrite(PartWriteInfo{.intended_ref = ns.string() + "/" + ref, .intended_namespace = ns}); + const ManifestId id = build->stageManifest(inlineEntries("f", "AAA")); + build->precommitAdd(ns, ref, id); + EXPECT_NO_THROW(build->promote(ns, ref, build->buildId(), id)); +} + +/// BUG 1c: a `republishRef` re-drive where the destination is ALREADY committed with the SAME content +/// (the crash-before-`dropRef(src)` state) must be idempotent: skip the re-stage/re-promote, drop src, +/// and leave dst's manifest UNCHANGED (no fresh manifest minted for identical content). +/// +/// PRE-FIX: republishRef's only idempotency gate is "source absent" -- it re-stages+re-promotes +/// unconditionally, minting a FRESH manifest id at dst even though the content is identical, orphaning +/// the first attempt's manifest. This test asserts dst's ManifestId is UNCHANGED across the re-drive -- +/// PRE-FIX this FAILS (RED: the id changes, proving the orphaning leak). +TEST(CASPromoteRepublish, RepublishReDriveOverCommittedDstIsIdempotent) +{ + auto storage = openTxStorage(); + const auto ns = storage->liveNamespace("b09b09b0-0909-4909-8909-090909090909"); + const String src_ref = "all_1_1_0"; + const String dst_ref = "detached_all_1_1_0"; + const String src_path = "b09/b09b09b0-0909-4909-8909-090909090909/" + src_ref; + const String dst_path = "b09/b09b09b0-0909-4909-8909-090909090909/" + dst_ref; + + /// 1. Publish a committed src part via the normal write flow (tmp -> final rename, B151 + /// publish-at-rename), exactly as gtest_ca_transaction.cpp's fixtures do. + { + auto tx = storage->createTransaction(); + writeFileTx(*tx, "b09/b09b09b0-0909-4909-8909-090909090909/tmp_insert_" + src_ref + "/data.bin", "payload-A"); + tx->moveDirectory("b09/b09b09b0-0909-4909-8909-090909090909/tmp_insert_" + src_ref, src_path); + tx->commit(DB::NoCommitOptions{}); + } + ASSERT_TRUE(storage->store()->resolveRef(ns, src_ref).has_value()); + + /// 2. Construct the "crash-before-dropRef(src)" state of a PRIOR republishRef drive by replaying + /// its exact body (resolve src -> adoptEvidence every entry -> stageManifest(same entries) -> + /// precommitAdd -> promote) WITHOUT the trailing dropRef(src). This leaves BOTH src and dst + /// committed, with dst holding the SAME content as src -- precisely the state a re-driven + /// republishRef must handle idempotently (ContentAddressedTransaction.cpp:143-169). + const auto resolved_src = storage->store()->resolveRef(ns, src_ref); + ASSERT_TRUE(resolved_src.has_value()); + const PartManifest src_manifest = storage->store()->readManifest(resolved_src->manifest_id); + { + auto build = storage->store()->beginPartWrite( + PartWriteInfo{.intended_ref = ns.string() + "/" + dst_ref, .intended_namespace = ns}); + for (const auto & entry : src_manifest.entries) + build->adoptEvidence(entry); + const ManifestId id = build->stageManifest(src_manifest.entries); + build->precommitAdd(ns, dst_ref, id); + build->promote(ns, dst_ref, build->buildId(), id); + /// Deliberately NO dropRef(ns, src_ref) here -- this is the simulated crash. + } + ASSERT_TRUE(storage->store()->resolveRef(ns, src_ref).has_value()) + << "src must still be committed (the simulated crash happened before dropRef)"; + const auto resolved_dst_before = storage->store()->resolveRef(ns, dst_ref); + ASSERT_TRUE(resolved_dst_before.has_value()); + const ManifestId dst_id_before = resolved_dst_before->manifest_id; + + /// 3. RE-DRIVE the same rename through the real transaction path: both endpoints are already + /// committed-ref part paths (not a table-level rename, no staged source in this fresh + /// transaction) -- moveDirectory's "move any COMMITTED source ref" branch calls + /// republishRef(src, dst) for real (the only way to reach the private method). + { + auto tx = storage->createTransaction(); + tx->moveDirectory(src_path, dst_path); + tx->commit(DB::NoCommitOptions{}); + } + + /// 4. Idempotency: src dropped, dst unchanged (SAME ManifestId -- no second manifest minted for + /// identical content, so nothing orphaned). + EXPECT_FALSE(storage->store()->resolveRef(ns, src_ref).has_value()) + << "src ref must be dropped by the re-drive"; + const auto resolved_dst_after = storage->store()->resolveRef(ns, dst_ref); + ASSERT_TRUE(resolved_dst_after.has_value()); + EXPECT_EQ(resolved_dst_after->manifest_id, dst_id_before) + << "PRE-FIX: republishRef re-drive mints a FRESH manifest for identical content, orphaning the " + "first attempt's manifest (BUG 1c leak). POST-FIX: idempotent no-op, same manifest."; + EXPECT_EQ(storage->getFileSize(dst_path + "/data.bin"), 9u); +} + +/// REMOVED (all-tree-part-files Task 9): +/// `RepublishReDriveResyncsDriftedMutableFiles` proved that `republishRef`'s idempotent-skip path +/// re-synced dst's `mutable_files` from src's CURRENT resolve when src's mutable payload drifted +/// between the crashed attempt and the re-drive. That side channel is gone -- `metadata_version.txt` +/// etc. are ordinary manifest entries now, so a src drift of that kind changes `entries`, and +/// `republishRef`'s idempotency check (`dst_manifest->entries != src_manifest->entries`) now correctly +/// treats it as a genuine content conflict (ABORTED) rather than silently resyncing a side payload -- +/// there is no longer a "same content, drifted sidecar" state to re-sync. `RepublishReDriveOver- +/// CommittedDstIsIdempotent` above remains the live coverage for the idempotent-skip path itself. + +/// Companion conflict case: a re-drive where dst is committed to DIFFERENT content than src is a +/// genuine conflict (an ATTACH-onto-existing-name collision), not a re-drive -- it must fail closed +/// (ABORTED), never silently drop src (which would lose src's content) nor silently overwrite dst. +/// This scenario reaches the SAME `promote`-over-different-committed-ref guard as BUG 1a, so pre-fix it +/// behaves the same way BUG 1a does: no throw (silent overwrite), which is also a leak/data-loss risk. +TEST(CASPromoteRepublish, RepublishReDriveOverDifferentContentDstFailsClosed) +{ + auto storage = openTxStorage(); + const auto ns = storage->liveNamespace("b0ab0ab0-0a0a-4a0a-8a0a-0a0a0a0a0a0a"); + const String src_ref = "all_2_2_0"; + const String dst_ref = "detached_all_2_2_0"; + const String src_path = "b0a/b0ab0ab0-0a0a-4a0a-8a0a-0a0a0a0a0a0a/" + src_ref; + const String dst_path = "b0a/b0ab0ab0-0a0a-4a0a-8a0a-0a0a0a0a0a0a/" + dst_ref; + + /// src committed with content "payload-SRC". + { + auto tx = storage->createTransaction(); + writeFileTx(*tx, "b0a/b0ab0ab0-0a0a-4a0a-8a0a-0a0a0a0a0a0a/tmp_insert_" + src_ref + "/data.bin", "payload-SRC"); + tx->moveDirectory("b0a/b0ab0ab0-0a0a-4a0a-8a0a-0a0a0a0a0a0a/tmp_insert_" + src_ref, src_path); + tx->commit(DB::NoCommitOptions{}); + } + /// dst ALREADY committed with genuinely DIFFERENT content (not a re-drive artifact -- a real + /// name collision), via a completely independent build. + publishCommitted(storage->store(), ns, dst_ref, inlineEntries("data.bin", "different-content")); + ASSERT_TRUE(storage->store()->resolveRef(ns, src_ref).has_value()); + ASSERT_TRUE(storage->store()->resolveRef(ns, dst_ref).has_value()); + + try + { + auto tx = storage->createTransaction(); + tx->moveDirectory(src_path, dst_path); + tx->commit(DB::NoCommitOptions{}); + FAIL() << "PRE-FIX: republishRef silently overwrote dst's different content " + "(promote-over-committed leak); POST-FIX must throw ABORTED and leave src intact"; + } + catch (const DB::Exception & e) + { + EXPECT_EQ(e.code(), DB::ErrorCodes::ABORTED); + } +} + +/// BUG 2: `abandon` must emit its precommit removal (an exact `owner_transition`) BEFORE retiring the +/// build_seq, so the build stays active until that removal is durable and no freshness-window consumer +/// judges the manifest build-dead while an un-removed precommit still names it (GC no longer reclaims +/// abandoned precommits — the writer removes them itself). +/// +/// A3 mint-tightening INVERTS this +/// test's original tail assertion. Before A3, this test's black-box PROOF that `abandon()` had really +/// removed the exact precommit binding was that a FRESH `precommitAdd` for the SAME (ref_name, +/// manifest_ref) succeeded -- a still-live binding would instead throw CORRUPTED_DATA ("add precommit +/// ... already exists"). That proof mechanism no longer works: `rebuild` is a DIFFERENT `PartWriteTxn` +/// from `build` and never staged `id` itself (`build` did), so `precommitAdd` now refuses it +/// UNCONDITIONALLY under A3 -- regardless of whether abandon's removal ever landed. Re-owning a +/// dropped identity from a transaction that did not mint it would let a later relink confirm's exact +/// `ManifestRef` equality (Part B of the same design) compare true against a token whose blobs may +/// already be reclaimed -- an ABA the whole publish-confirm design depends on being structurally +/// impossible. The removal-before-retire property this test used to prove is unaffected by A3 and +/// stays covered by the TLA+ `WAbandonPrecommit` model; `PrecommitAddRejectsAnIdThisTxnDidNotStage` +/// below is the dedicated A3 regression pin. +/// +/// `rebuild->precommitAdd(ns, ref, id)` below throws `LOGICAL_ERROR`, which aborts the whole process in +/// debug/sanitizer builds instead of behaving like a catchable exception -- `CASPromoteRepublishDeathTest. +/// AbandonEmitsRemovalBeforeRetireAborts` below proves the abort positively in those builds instead. +#ifndef DEBUG_OR_SANITIZER_BUILD +TEST(CASPromoteRepublish, AbandonEmitsRemovalBeforeRetire) +{ + auto b = std::make_shared(); + auto s = openPool(b); + const RootNamespace ns{"srv/tbl@cas@"}; + const String ref = "all_0_0_0"; + auto build = s->beginPartWrite(PartWriteInfo{.intended_ref = ns.string() + "/" + ref, .intended_namespace = ns}); + const ManifestId id = build->stageManifest(inlineEntries("f", "AAA")); + build->precommitAdd(ns, ref, id); + build->abandon(); + + /// `rebuild` never staged `id` -- A3 refuses it on that basis alone, before ever reaching the + /// ledger-state check that would otherwise distinguish "removed" from "still live". + auto rebuild = s->beginPartWrite(PartWriteInfo{.intended_ref = ns.string() + "/" + ref, .intended_namespace = ns}); + try + { + rebuild->precommitAdd(ns, ref, id); + FAIL() << "A3 mint-tightening: precommitAdd must refuse an id 'rebuild' never staged, even one " + "'build' legitimately staged and precommitted before dropping it via abandon()"; + } + catch (const DB::Exception & e) + { + EXPECT_EQ(e.code(), DB::ErrorCodes::LOGICAL_ERROR); + } +} +#endif + +#if defined(DEBUG_OR_SANITIZER_BUILD) +TEST(CASPromoteRepublishDeathTest, AbandonEmitsRemovalBeforeRetireAborts) +{ + auto b = std::make_shared(); + auto s = openPool(b); + const RootNamespace ns{"srv/tbl@cas@"}; + const String ref = "all_0_0_0"; + auto build = s->beginPartWrite(PartWriteInfo{.intended_ref = ns.string() + "/" + ref, .intended_namespace = ns}); + const ManifestId id = build->stageManifest(inlineEntries("f", "AAA")); + build->precommitAdd(ns, ref, id); + build->abandon(); + + auto rebuild = s->beginPartWrite(PartWriteInfo{.intended_ref = ns.string() + "/" + ref, .intended_namespace = ns}); + EXPECT_DEATH({ rebuild->precommitAdd(ns, ref, id); }, ""); +} +#endif + +/// A3 mint-tightening's dedicated regression pin: an unowned `ManifestId` may enter ownership ONLY +/// from the transaction that freshly staged it. Without this, a dropped identity could be re-owned +/// later, which would make the relink confirm's exact-`ManifestRef` equality an ABA (the same token +/// could then name a manifest whose blobs were already reclaimed). No production path performs this +/// transition -- every real caller precommits an id it JUST staged itself, on the SAME `PartWriteTxn` +/// (`ContentAddressedTransaction.cpp:358,412`, `PartFolderAccess.cpp:352`). +/// +/// `txn2->precommitAdd(ns, ref, id)` below throws `LOGICAL_ERROR`, which aborts the whole process in +/// debug/sanitizer builds instead of behaving like a catchable exception -- `CASPromoteRepublishDeathTest. +/// PrecommitAddRejectsAnIdThisTxnDidNotStageAborts` below proves the abort positively in those builds +/// instead (it cannot also re-check the post-throw ref-log-tail/resolveRef state this test verifies, +/// since there IS no post-abort state in a real debug/sanitizer build). +#ifndef DEBUG_OR_SANITIZER_BUILD +TEST(CASPromoteRepublish, PrecommitAddRejectsAnIdThisTxnDidNotStage) +{ + auto b = std::make_shared(); + auto s = openPool(b); + const RootNamespace ns{"srv/tbl_mint_tighten@cas@"}; + const String ref = "all_0_0_0"; + + /// txn1 mints `id` and abandons before ever precommitting it -- a genuinely unowned identity (it + /// was never even a live precommit), the simplest form A3 must still refuse for a foreign txn. + auto txn1 = s->beginPartWrite(PartWriteInfo{.intended_ref = ns.string() + "/" + ref, .intended_namespace = ns}); + const ManifestId id = txn1->stageManifest(inlineEntries("f", "AAA")); + txn1->abandon(); + + const size_t tail_before = s->tailSinceSnapshotCountForTest(ns); + + /// txn2 never staged `id` -- only the transaction that minted an id may precommit it. + auto txn2 = s->beginPartWrite(PartWriteInfo{.intended_ref = ns.string() + "/" + ref, .intended_namespace = ns}); + try + { + txn2->precommitAdd(ns, ref, id); + FAIL() << "A3 mint-tightening: precommitAdd must refuse an id staged by a DIFFERENT transaction"; + } + catch (const DB::Exception & e) + { + EXPECT_EQ(e.code(), DB::ErrorCodes::LOGICAL_ERROR); + } + + /// Nothing was appended: the ref-log tail is unchanged and `ref` still has no owner at all. + EXPECT_EQ(s->tailSinceSnapshotCountForTest(ns), tail_before); + EXPECT_FALSE(s->resolveRef(ns, ref).has_value()); +} +#endif + +#if defined(DEBUG_OR_SANITIZER_BUILD) +TEST(CASPromoteRepublishDeathTest, PrecommitAddRejectsAnIdThisTxnDidNotStageAborts) +{ + auto b = std::make_shared(); + auto s = openPool(b); + const RootNamespace ns{"srv/tbl_mint_tighten@cas@"}; + const String ref = "all_0_0_0"; + + auto txn1 = s->beginPartWrite(PartWriteInfo{.intended_ref = ns.string() + "/" + ref, .intended_namespace = ns}); + const ManifestId id = txn1->stageManifest(inlineEntries("f", "AAA")); + txn1->abandon(); + + auto txn2 = s->beginPartWrite(PartWriteInfo{.intended_ref = ns.string() + "/" + ref, .intended_namespace = ns}); + EXPECT_DEATH({ txn2->precommitAdd(ns, ref, id); }, ""); +} +#endif diff --git a/src/Disks/tests/gtest_cas_protocol_scenarios.cpp b/src/Disks/tests/gtest_cas_protocol_scenarios.cpp new file mode 100644 index 000000000000..574f85d743eb --- /dev/null +++ b/src/Disks/tests/gtest_cas_protocol_scenarios.cpp @@ -0,0 +1,622 @@ +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +/// Multi-actor protocol scenarios for the root-local part-manifest model (CA GC redesign rev. 15). +/// Ported from the removed tree/closure model. The single-call `publish(ns, ref, tree, RefPayload{})` +/// gate is gone; a write is now the four-step flow (EDGE-BEFORE-OBSERVE order): +/// stageManifest(entries) -> precommitAdd(ns, ref, id) -> putBlob(...) -> promote(ns, ref, build_id, id) +/// The fail-closed publish gate that those scenarios exercise now lives in TWO places (Phase A of spec +/// 2026-07-09-cas-writer-gc-simplification): +/// • putBlob: INV-1 condemned-dedup re-upload from the writer's OWN source bytes (never GETs the +/// dying object); +/// • promote: TOKENED leaves (this build putBlob'd them) are EDGE-PROTECTED and NOT re-validated — the +/// precommit closure named them before putBlob observed them, so a condemnation in the +/// putBlob→promote window is doomed (the next fold spares it). promote commits with the tokened +/// blob's token UNCHANGED. Only NON-tokened leaves get the single mandatory presence observation: a +/// tokenless W-EVIDENCE adopt that is condemned-but-present is displaced by a verified copy-forward +/// (the committed ref names a FRESH incarnation); absent, or condemned + no-dep, fails closed +/// (ABORTED). promote refreshes the retire view when the fence is ahead. +/// These scenarios assert the no-dangle / no-loss / fail-closed protocol properties faithfully on that +/// flow. The strong safety assertions are preserved. +/// +/// DELETED (Phase A): `RevalidateAbsentTokenedBlobResurrectsFromSource`. Its premise — a putBlob'd +/// (tokened) blob body hand-deleted before the gate, then resurrected — is protocol-unreachable under +/// EDGE-BEFORE-OBSERVE: a tokened leaf under a durable precommit closure cannot be GC-deleted in the +/// putBlob→promote window, and promote no longer re-validates tokened leaves at all. Deleting a +/// putBlob'd body out-of-band is corruption, which is `cas-fsck`'s domain, not the promote gate's. + +namespace DB::ErrorCodes +{ +extern const int ABORTED; +extern const int FILE_DOESNT_EXIST; +extern const int LOGICAL_ERROR; +} + +using namespace DB::Cas; +using DB::Cas::tests::blobEntryFor; +using DB::Cas::tests::condemnMeta; +using DB::Cas::tests::displaceBlobToken; +using DB::Cas::tests::idOf; +using DB::Cas::tests::injectRetire; +using DB::Cas::tests::loadMetaForTest; +using DB::Cas::tests::streamingHexOf; +using DB::Cas::tests::u128Of; +using DB::Cas::tests::writeBlobRaw; + +namespace +{ + +PoolPtr openPool(const std::shared_ptr & b) +{ + return Pool::open(b, PoolConfig{.pool_prefix = "p", .server_root_id = "test"}); +} + +/// A single-blob manifest entry naming `payload` at `path` (the entry the part's manifest carries). +ManifestEntry blobEntry(const String & path, const String & payload) +{ + return blobEntryFor(path, u128Of(payload), payload.size()); +} + +/// Start a build whose `intended_ref` is "ns/ref" — REQUIRED: stageManifest derives the manifest's +/// owning namespace by splitting intended_ref on the LAST '/'. (See PartWriteTxn::manifestNamespace.) +PartWriteTxnPtr startBuildFor(const PoolPtr & s, const RootNamespace & ns, const String & ref) +{ + PartWriteInfo info; + info.intended_ref = ns.string() + "/" + ref; + return s->beginPartWrite(info); +} + +/// The full write flow for a part whose only file is `payload` at `path` (blob placement). Uploads the +/// blob via putBlob, stages the manifest, precommits, then promotes. Returns the committed ManifestId. +/// Mirrors what the old single-call `publish` did on the tree model. +ManifestId publishBlobPart( + const PoolPtr & s, const RootNamespace & ns, const String & ref, const String & path, const String & payload) +{ + auto build = startBuildFor(s, ns, ref); + /// Wiring order (EDGE-BEFORE-OBSERVE): stageManifest -> precommitAdd -> putBlob -> promote. + const ManifestId id = build->stageManifest({blobEntry(path, payload)}); + build->precommitAdd(ns, ref, id); + build->putBlob(idOf(payload), BlobSource::fromString(payload)); + build->promote(ns, ref, build->buildId(), id); + return id; +} + +/// Read the part's blob back through the full read stack (resolveRef → readManifest → findEntry → +/// locate → ranged GET) and assert it returns `payload`. This is the INV-NO-DANGLE check: every named +/// object resolves and reads. +void assertPartReads( + const std::shared_ptr & b, const PoolPtr & s, + const RootNamespace & ns, const String & ref, const String & path, const String & payload) +{ + auto r = s->resolveRef(ns, ref); + ASSERT_TRUE(r.has_value()); + + const PartManifest manifest = s->readManifest(r->manifest_id); + const auto * entry = findEntry(manifest.entries, path); + ASSERT_TRUE(entry != nullptr); + auto loc = s->locate(*entry); + auto got = b->get(loc.key, Range{loc.offset, loc.length}); + ASSERT_TRUE(got.has_value()); + EXPECT_EQ(got->bytes, payload); +} + +} + +TEST(CASProtocol, FenceConflictCondemnedTokenedBlobCommitsWithTokenUnchanged) +{ + /// EDGE-BEFORE-OBSERVE (spec 2026-07-09-cas-writer-gc-simplification, Phase A): a blob leaf whose + /// CURRENT token is condemned at the promote gate, but which THIS build putBlob'd (tokened dep under + /// the durable precommit closure), is EDGE-PROTECTED — the condemnation is doomed (the next fold spares + /// it) and promote does NOT re-validate or re-upload the tokened leaf. promote COMMITS with the blob's + /// token UNCHANGED; the premature condemn is invisible to the client. + auto b = std::make_shared(); + auto s = openPool(b); + const RootNamespace ns{"srv1/tbl"}; + + /// Wiring order: stage + precommit (durable edge) BEFORE putBlob observes X (records token t0). + auto build = startBuildFor(s, ns, "part_1"); + const ManifestId id = build->stageManifest({blobEntry("data.bin", "payload-X")}); + build->precommitAdd(ns, "part_1", id); + build->putBlob(idOf("payload-X"), BlobSource::fromString("payload-X")); + + const String blob_key = s->layout().blobKey(idOf("payload-X")); + const Token t0 = b->head(blob_key).token; + + /// GC condemns X at t0 in round 1 and fences the namespace to round 1. + injectRetire(*b, s->layout(), /*round*/ 1, /*shard*/ 0, + {RetiredEntry{.kind = ObjectKind::Blob, .ref = DB::Cas::BlobRef{DB::Cas::BlobHashAlgo::CityHash128, DB::Cas::BlobDigest::fromU128(u128Of("payload-X"))}, .token = t0, .size = 9}}); + + /// promote: mutateShard refreshes the view (fence_round 1 > view round 0), but the tokened leaf is + /// edge-protected — skipped, not re-validated ⇒ commit, token unchanged. + build->promote(ns, "part_1", build->buildId(), id); + + /// The ref is committed and reads back; the blob still rides t0 (no re-upload). + assertPartReads(b, s, ns, "part_1", "data.bin", "payload-X"); + EXPECT_EQ(b->head(blob_key).token, t0); +} + +TEST(CASProtocol, RevalidateReObservesStaleTokenKeepsWhenUnchanged) +{ + /// A blob dedup-adopted (tokened dep) under the precommit closure; an EMPTY retire set at round 1. + /// Under EDGE-BEFORE-OBSERVE the tokened leaf is NOT re-observed at the promote gate at all — it is + /// edge-protected — so promote commits in place with the token UNCHANGED (no HEAD, no rewrite). + auto b = std::make_shared(); + auto s = openPool(b); + const RootNamespace ns{"srv1/tbl"}; + + /// X pre-exists out-of-band; the build dedup-adopts it via putBlob (records the current token t0). + writeBlobRaw(*b, s->layout(), "payload-X", s->poolMeta().blob_header_len, s->poolMeta().pool_id); + const String blob_key = s->layout().blobKey(idOf("payload-X")); + const Token t0 = b->head(blob_key).token; + + /// Wiring order: stage + precommit (durable edge) BEFORE the adopting putBlob. + auto build = startBuildFor(s, ns, "part_1"); + const ManifestId id = build->stageManifest({blobEntry("data.bin", "payload-X")}); + build->precommitAdd(ns, "part_1", id); + build->putBlob(idOf("payload-X"), BlobSource::fromString("payload-X")); /// dedup → adopts t0 + + /// GC advanced the round to 1 with an EMPTY retired set; fence to 1. X is NOT condemned and its + /// token is unchanged. + injectRetire(*b, s->layout(), /*round*/ 1, /*shard*/ 0, {}); + + /// promote: the tokened leaf is edge-protected (not re-observed) ⇒ commit in place (KEEP). + build->promote(ns, "part_1", build->buildId(), id); + + assertPartReads(b, s, ns, "part_1", "data.bin", "payload-X"); + /// No rewrite happened — the tokened leaf was never touched, so its token stays at t0. + EXPECT_EQ(b->head(blob_key).token, t0); +} + +TEST(CASProtocol, RevalidateReObservesStaleTokenAdoptsWhenDisplaced) +{ + /// A blob displaced out-of-band to a fresh live token t1 before promote. Phase-A contract: the leaf is + /// TOKENED (putBlob-adopted), so promote SKIPS it entirely (edge-protected — EDGE-BEFORE-OBSERVE); no + /// re-HEAD happens. The commit still rides the displaced object correctly because the manifest names + /// the HASH, not a token — this is the black-box "displaced object still reads by content key" check. + auto b = std::make_shared(); + auto s = openPool(b); + const RootNamespace ns{"srv1/tbl"}; + + writeBlobRaw(*b, s->layout(), "payload-X", s->poolMeta().blob_header_len, s->poolMeta().pool_id); + const String blob_key = s->layout().blobKey(idOf("payload-X")); + const Token t0 = b->head(blob_key).token; + + auto build = startBuildFor(s, ns, "part_1"); + /// Wiring order (EDGE-BEFORE-OBSERVE): stageManifest -> precommitAdd -> putBlob. + const ManifestId id = build->stageManifest({blobEntry("data.bin", "payload-X")}); + build->precommitAdd(ns, "part_1", id); + build->putBlob(idOf("payload-X"), BlobSource::fromString("payload-X")); /// dedup → adopts t0 + + /// Another writer displaces X out-of-band ⇒ a new current token t1 (same payload, fresh tag). + const Token t1 = displaceBlobToken(*b, s->layout(), idOf("payload-X")); + EXPECT_NE(t1, t0); + + /// GC advanced to round 1 with an EMPTY retired set; fence to 1. + injectRetire(*b, s->layout(), /*round*/ 1, /*shard*/ 0, {}); + + /// promote refreshes ⇒ revalidate X ⇒ HEAD current t1 not condemned ⇒ commit. The dep rides t1. + build->promote(ns, "part_1", build->buildId(), id); + assertPartReads(b, s, ns, "part_1", "data.bin", "payload-X"); + EXPECT_EQ(b->head(blob_key).token, t1); + + /// Black-box proof the part reads the t1 incarnation: re-publish the same blob into a SECOND + /// namespace with NO new GC injection. The blob is already present at t1; nothing is re-uploaded. + publishBlobPart(s, RootNamespace{"srv1/tbl/copy"}, "part_2", "data.bin", "payload-X"); + EXPECT_EQ(b->head(blob_key).token, t1); + assertPartReads(b, s, RootNamespace{"srv1/tbl/copy"}, "part_2", "data.bin", "payload-X"); + + /// Independent discriminator that the blob rides t1, not the stale t0: t0 is DEAD. A deleteExact + /// against t0 must TokenMismatch (INV-NO-RETURN — t0 was displaced and can never be current again). + EXPECT_EQ(b->deleteExact(blob_key, t0).kind, DeleteOutcome::Kind::TokenMismatch); +} + +TEST(CASProtocol, RevalidateAdoptsLiveTokenWhenOnlyPhantomCondemnedAtDifferentToken) +{ + /// A blob whose OWN current token t0 is LIVE, but a DIFFERENT phantom token t_other for the same + /// hash IS condemned. The build putBlob-adopts t0 (tokened dep), so promote does not re-observe it + /// (edge-protected) and commits in place: the blob keeps t0 (no upload, no displacement). The phantom + /// condemnation is for a different incarnation and never touches t0. + auto b = std::make_shared(); + const RootNamespace ns{"srv1/tbl"}; + + DB::Cas::Layout layout("p"); + { + auto s0 = Pool::open(b, PoolConfig{.pool_prefix = "p", .server_root_id = "test"}); + writeBlobRaw(*b, s0->layout(), "payload-X", s0->poolMeta().blob_header_len, s0->poolMeta().pool_id); + } + const String blob_key = layout.blobKey(idOf("payload-X")); + const Token t0 = b->head(blob_key).token; + const Token t_other{"emulated-phantom", DB::Cas::TokenType::Emulated}; + ASSERT_NE(t_other, t0); + + injectRetire(*b, layout, /*round*/ 1, /*shard*/ 0, + {RetiredEntry{.kind = ObjectKind::Blob, .ref = DB::Cas::BlobRef{DB::Cas::BlobHashAlgo::CityHash128, DB::Cas::BlobDigest::fromU128(u128Of("payload-X"))}, .token = t_other, .size = 9}}); + /// Fence to round 1 BEFORE opening the store, so the store's open-time refresh lands the view at + /// round 1 already populated. + + auto s = Pool::open(b, PoolConfig{.pool_prefix = "p", .server_root_id = "test"}); /// open-time refresh ⇒ view round 1 + /// Wiring order: stage + precommit (durable edge) BEFORE the adopting putBlob. + auto build = startBuildFor(s, ns, "part_1"); + const ManifestId id = build->stageManifest({blobEntry("data.bin", "payload-X")}); + build->precommitAdd(ns, "part_1", id); + build->putBlob(idOf("payload-X"), BlobSource::fromString("payload-X")); /// dedup → adopts t0 + + /// promote: the tokened leaf is edge-protected (not re-observed) ⇒ commit. Lands. t0 untouched. + build->promote(ns, "part_1", build->buildId(), id); + + assertPartReads(b, s, ns, "part_1", "data.bin", "payload-X"); + + /// The object was NOT displaced — it STAYS at t0 (no re-upload, only re-validated). + EXPECT_EQ(b->head(blob_key).token, t0); +} + +/// (DELETED, Phase A) RevalidateAbsentTokenedBlobResurrectsFromSource — see the file-header note: a +/// hand-deleted putBlob'd (tokened) body is protocol-unreachable under EDGE-BEFORE-OBSERVE (a tokened +/// leaf under a durable precommit closure cannot be GC-deleted in the putBlob→promote window, and promote +/// no longer re-validates tokened leaves). Out-of-band body deletion is `cas-fsck`'s domain. + +TEST(CASProtocol, EvidenceHitCondemnedPresentBlobCopiesForwardInClosure) +{ + /// W-EVIDENCE (tokenless adopted dep) on a blob X whose hash is condemned-but-PRESENT. §4 manifest-trust + /// (test name is legacy — there is no copy-forward any more): a committed-source adopted leaf is TRUSTED + /// at the promote gate. The gate does NOT observe X — no HEAD, no meta point-read, no displacement — it + /// publishes on the strength of the durable manifest edge (D4 relink trust). So promote SUCCEEDS and X's + /// existing incarnation is left EXACTLY as-is: the token is UNCHANGED (never displaced) and the condemned + /// meta is NOT flipped (the gate never reads or writes it). A non-tokened leaf is the only leaf promote + /// still decides on; here it is trusted (tokened leaves are edge-protected). + auto b = std::make_shared(); + auto s = openPool(b); + const RootNamespace ns{"srv1/tbl"}; + + /// X pre-exists with token t0; the manifest names it as a tokenless adopted leaf. + const String hex = streamingHexOf("payload-X"); + { + auto seed = s->beginPartWrite({}); + seed->putBlob(BlobRef{BlobHashAlgo::CityHash128, BlobDigest::fromU128(hexToU128(hex))}, BlobSource::fromString("payload-X")); + } + const String blob_key = s->layout().blobKey(BlobRef{BlobHashAlgo::CityHash128, BlobDigest::fromU128(hexToU128(hex))}); + const Token t0 = b->head(blob_key).token; + + auto build = startBuildFor(s, ns, "part_1"); + ManifestEntry entry = blobEntry("data.bin", "payload-X"); + entry.ref = DB::Cas::BlobRef{DB::Cas::BlobHashAlgo::CityHash128, DB::Cas::BlobDigest::fromU128(hexToU128(hex))}; /// streaming-convention id (matches the minted blob) + build->adoptEvidence(entry); /// tokenless W-EVIDENCE dep on X (no HEAD, no upload) + const ManifestId id = build->stageManifest({entry}); + build->precommitAdd(ns, "part_1", id); + + /// GC condemns X's hash in round 1 via the meta — under §4 the promote gate never reads it. + condemnMeta(*b, s->layout(), hexToU128(hex), /*condemn_round*/ 1); + + /// promote: the adopted leaf is trusted ⇒ commit, no probe, no displacement. + EXPECT_NO_THROW(build->promote(ns, "part_1", build->buildId(), id)); + + /// The ref stands; X rides its ORIGINAL token t0 (trust never displaces a trusted leaf). + EXPECT_TRUE(s->resolveRef(ns, "part_1").has_value()); + EXPECT_EQ(b->head(blob_key).token, t0) << "trust must not displace the adopted blob"; + + /// The meta is untouched — still Condemned (the gate never reads or flips it under trust). + const auto lm_after = loadMetaForTest(*b, s->layout(), hexToU128(hex)); + ASSERT_TRUE(lm_after.has_value()); + EXPECT_EQ(lm_after->meta.state, MetaState::Condemned) << "trust must not flip the meta"; +} + +TEST(CASProtocol, WedgedHeartbeatCondemnedTokenedBlobCommitsWithTokenUnchanged) +{ + /// A build whose watermark never renews finds its OWN putBlob'd upload condemned by full GC while its + /// precommit is STILL the live owner (this setup injects only the retire set + fence, no owner-removal + /// — the false-positive-freeze window BEFORE any GC reclaim). The tokened leaf is EDGE-PROTECTED: the + /// precommit closure named it before putBlob observed it, so the condemnation is doomed and promote + /// does NOT re-validate it — promote COMMITS with the token UNCHANGED, closing the window invisibly. + /// The genuine dead-build case (precommit reclaimed ⇒ owner check aborts, NO re-upload) is covered + /// separately by CaWiringResurrect.PromoteAbandonedPrecommitAbortsWithoutResurrect. + auto b = std::make_shared(); + auto s = openPool(b); + const RootNamespace ns{"srv1/tbl"}; + + /// Wiring order: stage + precommit (durable edge) BEFORE putBlob observes X. + auto build = startBuildFor(s, ns, "part_1"); + const ManifestId id = build->stageManifest({blobEntry("data.bin", "payload-X")}); + build->precommitAdd(ns, "part_1", id); + build->putBlob(idOf("payload-X"), BlobSource::fromString("payload-X")); + + const String blob_key = s->layout().blobKey(idOf("payload-X")); + const Token t0 = b->head(blob_key).token; + + /// Full GC condemned the build's OWN upload. + injectRetire(*b, s->layout(), /*round*/ 1, /*shard*/ 0, + {RetiredEntry{.kind = ObjectKind::Blob, .ref = DB::Cas::BlobRef{DB::Cas::BlobHashAlgo::CityHash128, DB::Cas::BlobDigest::fromU128(u128Of("payload-X"))}, .token = t0, .size = 9}}); + + /// promote: the tokened leaf is edge-protected — skipped, not re-validated ⇒ commit, token unchanged. + build->promote(ns, "part_1", build->buildId(), id); + assertPartReads(b, s, ns, "part_1", "data.bin", "payload-X"); + EXPECT_EQ(b->head(blob_key).token, t0); +} + +TEST(CASProtocol, AbandonLeavesDebrisAndDisables) +{ + /// abandon leaves the uploaded blob + staged manifest body as debris (reaped by the orphan sweep); + /// no owner transition is touched, and further build ops fail LOGICAL_ERROR (requireAlive). + auto b = std::make_shared(); + auto s = openPool(b); + const RootNamespace ns{"srv1/tbl"}; + + auto build = startBuildFor(s, ns, "part_1"); + auto blob = build->putBlob(idOf("payload-X"), BlobSource::fromString("payload-X")); + const ManifestId id = build->stageManifest({blobEntry("data.bin", "payload-X")}); + + build->abandon(); + + /// The uploaded blob remains as debris; the staged manifest body is best-effort deleted by abandon. + EXPECT_TRUE(b->head(s->layout().blobKey(blob.ref)).exists); + EXPECT_FALSE(b->head(s->layout().manifestKey(id)).exists); /// best-effort cleanup ran + EXPECT_TRUE(s->listRefs(ns).empty()); + + /// Further build ops ⇒ LOGICAL_ERROR (requireAlive). + EXPECT_DEATH( + { + DB::abort_on_logical_error.store(true, std::memory_order_relaxed); + build->stageManifest({blobEntry("data.bin", "payload-X")}); + }, + "PartWriteTxn has been abandoned"); +} + +TEST(CASProtocol, DropReattachThroughDetachedNamespace) +{ + /// ATTACH choreography (design §4): publish part_1 in ns; re-publish into ns/detached + drop part_1 + /// from ns; then re-publish part_1 back in ns + drop from detached. The BLOB is never re-uploaded + /// (its token is stable throughout); each namespace gets its own single-owner manifest. + auto b = std::make_shared(); + auto s = openPool(b); + const RootNamespace ns{"srv1/tbl"}; + const RootNamespace detached{"srv1/tbl/detached"}; + + publishBlobPart(s, ns, "part_1", "data.bin", "payload-X"); + + const String blob_key = s->layout().blobKey(idOf("payload-X")); + const Token blob_tok = b->head(blob_key).token; + + EXPECT_TRUE(s->listRefs(ns).contains("part_1")); + EXPECT_TRUE(s->listRefs(detached).empty()); + + /// Move to detached: re-publish into detached (adopting the live blob), drop from ns. + publishBlobPart(s, detached, "part_1", "data.bin", "payload-X"); + s->dropRef(ns, "part_1"); + + EXPECT_TRUE(s->listRefs(ns).empty()); + ASSERT_TRUE(s->listRefs(detached).contains("part_1")); + assertPartReads(b, s, detached, "part_1", "data.bin", "payload-X"); + + /// Re-attach: re-publish part_1 back in ns, drop from detached. + publishBlobPart(s, ns, "part_1", "data.bin", "payload-X"); + s->dropRef(detached, "part_1"); + + ASSERT_TRUE(s->listRefs(ns).contains("part_1")); + assertPartReads(b, s, ns, "part_1", "data.bin", "payload-X"); + EXPECT_TRUE(s->listRefs(detached).empty()); + + /// The blob was never re-uploaded (token stable throughout — every publish dedup-adopted it). + EXPECT_EQ(b->head(blob_key).token, blob_tok); +} + +TEST(CASProtocol, FreezeIntoShadowNamespace) +{ + /// FREEZE survives the table's part lifecycle (design §4): a shadow ref is a reachability root that + /// outlives the dropped live ref. + auto b = std::make_shared(); + auto s = openPool(b); + const RootNamespace ns{"srv1/tbl"}; + const RootNamespace shadow{"shadow/backup1/tbl"}; + + publishBlobPart(s, ns, "part_1", "data.bin", "payload-X"); + + /// Freeze into the shadow namespace (adopting the live blob), then drop the live ref. + publishBlobPart(s, shadow, "part_1", "data.bin", "payload-X"); + s->dropRef(ns, "part_1"); + + EXPECT_TRUE(s->listRefs(ns).empty()); + /// The shadow ref still resolves and reads after the live ref is gone. + assertPartReads(b, s, shadow, "part_1", "data.bin", "payload-X"); +} + +TEST(CASProtocol, DisplacedToLiveTokenCommitsAtCurrentIncarnation) +{ + /// (Ported from the former ResurrectLosesRace scenario.) A blob displaced to a LIVE t1 (while its old + /// t0 is condemned for a now-defunct incarnation) is SAFE to commit: the committed manifest names a + /// blob HASH, the live t1 incarnation backs it, and GC's exact-token delete of t0 only TokenMismatches. + /// Phase-A contract: the leaf is TOKENED, so promote does not re-HEAD it at all (edge-protected — + /// EDGE-BEFORE-OBSERVE); the commit is correct by content addressing, not by revalidation. The old + /// conservative ABORTED has no manifest-model analog. + auto b = std::make_shared(); + auto s = openPool(b); + const RootNamespace ns{"srv1/tbl"}; + + writeBlobRaw(*b, s->layout(), "payload-X", s->poolMeta().blob_header_len, s->poolMeta().pool_id); + const String blob_key = s->layout().blobKey(idOf("payload-X")); + const Token t0 = b->head(blob_key).token; + + auto build = startBuildFor(s, ns, "part_1"); + /// Wiring order (EDGE-BEFORE-OBSERVE): stageManifest -> precommitAdd -> putBlob. + const ManifestId id = build->stageManifest({blobEntry("data.bin", "payload-X")}); + build->precommitAdd(ns, "part_1", id); + build->putBlob(idOf("payload-X"), BlobSource::fromString("payload-X")); /// dedup → adopts t0 + + /// Another writer displaces X to t1 (uncondemned) before our gate runs. + const Token t1 = displaceBlobToken(*b, s->layout(), idOf("payload-X")); + ASSERT_NE(t1, t0); + + /// The view still condemns the OLD t0 at round 1, fenced. + injectRetire(*b, s->layout(), /*round*/ 1, /*shard*/ 0, + {RetiredEntry{.kind = ObjectKind::Blob, .ref = DB::Cas::BlobRef{DB::Cas::BlobHashAlgo::CityHash128, DB::Cas::BlobDigest::fromU128(u128Of("payload-X"))}, .token = t0, .size = 9}}); + + /// promote: revalidate X ⇒ HEAD current t1 (NOT condemned; only the defunct t0 is) ⇒ commit. + build->promote(ns, "part_1", build->buildId(), id); + + /// The blob lives at t1 (the displacing writer's incarnation) and the part reads. + EXPECT_EQ(b->head(blob_key).token, t1); + assertPartReads(b, s, ns, "part_1", "data.bin", "payload-X"); + + /// NO-LOSS / NO-RETURN: t0 is dead — a deleteExact against it TokenMismatches (the GC delete of the + /// condemned t0 spares the live t1). + EXPECT_EQ(b->deleteExact(blob_key, t0).kind, DeleteOutcome::Kind::TokenMismatch); +} + +TEST(CASProtocol, NewNamespacePublishGatedByShardFenceFloor) +{ + /// Regression test (test name is legacy — the fence machinery is gone): build B adopts a blob, the + /// ack-floor GC pipeline retires + deletes it, then B publishes into a fresh namespace. §4 manifest- + /// trust: B's leaf is a committed-source adopted leaf, so promote TRUSTS it (no HEAD/loadMeta probe) and + /// COMMITS. On the real path this dangle is UNREACHABLE — B's precommit edge pins the blob at in-degree + /// >= 1 through promote (CasPartWriteTxn.cpp precommitAdd → promote's WPromote owner==bld re-proof precedes the + /// trust), so GC cannot delete it; here the test drives GC to delete the blob while B has NOT yet + /// precommitted, which the live-precommit invariant excludes. The dangle is DETECTED by fsck's + /// reachable-but-absent scan (the backstop), not prevented at promote. + auto b = std::make_shared(); + auto s = openPool(b); + + /// 1. part_1 → a blob in namespace A, through the real PartWriteTxn. + const RootNamespace ns_a{"srv1/tbl"}; + auto build_a = startBuildFor(s, ns_a, "part_1"); + build_a->putBlob(idOf("floor-payload"), BlobSource::fromString("floor-payload")); + const ManifestId id_a = build_a->stageManifest({blobEntry("data.bin", "floor-payload")}); + build_a->precommitAdd(ns_a, "part_1", id_a); + build_a->promote(ns_a, "part_1", build_a->buildId(), id_a); + const String blob_key = s->layout().blobKey(idOf("floor-payload")); + + /// 2. build B adopts the blob (tokenless W-EVIDENCE) while the view is still at round 0. + auto build_b = startBuildFor(s, RootNamespace{"srv2/new"}, "part_x"); + build_b->adoptEvidence(blobEntry("data.bin", "floor-payload")); + + /// 3. drop part_1 from A; the ack-floor GC pipeline retires the blob at t0 and deletes it. build_a + /// finished, so advancing the watermark floor condemns the blob. Drive rounds advancing the store's + /// own mount ack after each (so the floor graduates the condemned entry and the delete lands). + s->dropRef(ns_a, "part_1"); + build_a.reset(); + s->renewWatermarkOnce(); + Gc gc(s, hexToU128("00000000000000000000000000000001")); + for (size_t r = 0; r < 16; ++r) + { + const RoundReport rep = DB::Cas::tests::runRegularRoundReclaiming(gc); + s->renewWatermarkOnce(); + if (!b->head(blob_key).exists) + break; + } + /// The blob (unreachable) was deleted at t0. + EXPECT_FALSE(b->head(blob_key).exists); + + /// 4. build B publishes into a BRAND-NEW namespace. §4 manifest-trust: the adopted leaf is trusted at + /// promote (no probe) ⇒ promote SUCCEEDS and commits a manifest naming the deleted blob (the dangle). + const ManifestId id_b = build_b->stageManifest({blobEntry("data.bin", "floor-payload")}); + build_b->precommitAdd(RootNamespace{"srv2/new"}, "part_x", id_b); + EXPECT_NO_THROW(build_b->promote(RootNamespace{"srv2/new"}, "part_x", build_b->buildId(), id_b)); + + /// The ref committed over the deleted blob (the D4 trade-off); the backstop is fsck's reachable-but- + /// absent scan (INV-NO-DANGLE-via-fsck). + EXPECT_TRUE(s->resolveRef(RootNamespace{"srv2/new"}, "part_x").has_value()); + const FsckReport rep = runFsck(*s, /*detail=*/true); + EXPECT_GE(rep.dangling, 1u) << "§4 D4 backstop: part_x committed over the GC-deleted blob; fsck must " + "report it dangling (dangling=" << rep.dangling << ")"; +} + +TEST(CASProtocol, FreshEvidenceDepWithViewHitIsResolvedByGate) +{ + /// §4 manifest-trust (test name is legacy — the gate no longer "resolves" a tokenless leaf by observing + /// it): a committed-source adopted leaf whose blob is condemned-but-PRESENT is TRUSTED at the promote + /// gate. There is NO per-file probe (no HEAD, no meta point-read) and NO copy-forward — the durable + /// manifest edge is the liveness evidence (D4 relink trust). promote SUCCEEDS and X keeps its ORIGINAL + /// incarnation: the token t0 is UNCHANGED (never displaced). A tokened leaf is edge-protected; only a + /// non-tokened leaf is decided here, and a committed-source adopt is trusted. + auto b = std::make_shared(); + + DB::Cas::Layout layout("p"); + const String hex = streamingHexOf("payload-fresh-ev"); + { + auto s0 = openPool(b); + auto build0 = s0->beginPartWrite({}); + build0->putBlob(BlobRef{BlobHashAlgo::CityHash128, BlobDigest::fromU128(hexToU128(hex))}, BlobSource::fromString("payload-fresh-ev")); + } + const String blob_key = layout.blobKey(BlobRef{BlobHashAlgo::CityHash128, BlobDigest::fromU128(hexToU128(hex))}); + const Token t0 = b->head(blob_key).token; + condemnMeta(*b, layout, hexToU128(hex), /*condemn_round*/ 1); + + auto s = openPool(b); + + const RootNamespace ns{"srv1/tbl"}; + auto build = startBuildFor(s, ns, "part_1"); + /// adoptEvidence records a TOKENLESS dep. + ManifestEntry entry = blobEntry("data.bin", "payload-fresh-ev"); + entry.ref = DB::Cas::BlobRef{DB::Cas::BlobHashAlgo::CityHash128, DB::Cas::BlobDigest::fromU128(hexToU128(hex))}; /// streaming-convention id (matches the minted blob) + build->adoptEvidence(entry); + const ManifestId id = build->stageManifest({entry}); + build->precommitAdd(ns, "part_1", id); + + /// promote trusts the adopted leaf ⇒ commit, no probe, no displacement. + EXPECT_NO_THROW(build->promote(ns, "part_1", build->buildId(), id)); + + EXPECT_EQ(b->head(blob_key).token, t0) << "trust must not displace the adopted blob"; + EXPECT_TRUE(s->resolveRef(ns, "part_1").has_value()); +} + +TEST(CASProtocol, AdoptedLeafCarriesRealBlobSize) +{ + /// B92 round-trip (re-expressed on the manifest model): an adopted leaf must carry its real + /// blob_size, NOT 0. PartWriteTxn A publishes a blob; build B adopts that leaf into a second ref. The + /// adopted manifest's entry must report the same non-zero blob_size as the original. + auto b = std::make_shared(); + auto s = openPool(b); + const RootNamespace ns{"srv1/tbl"}; + + /// PartWriteTxn A: a blob with a real payload so blob_size > 0. + const ManifestId id_a = publishBlobPart(s, ns, "ref_a", "data.bin", "payload-B92"); + + const PartManifest manifest_a = s->readManifest(id_a); + const auto * entry_a = findEntry(manifest_a.entries, "data.bin"); + ASSERT_TRUE(entry_a != nullptr); + const uint64_t size_a = entry_a->blob_size; + EXPECT_NE(size_a, 0u) << "ref A blob_size must be non-zero"; + EXPECT_EQ(size_a, String("payload-B92").size()); + + /// PartWriteTxn B: adopt the same leaf, publish as ref_b (no re-upload). + auto build_b = startBuildFor(s, ns, "ref_b"); + ASSERT_TRUE(entry_a != nullptr); + build_b->adoptEvidence(*entry_a); + const ManifestId id_b = build_b->stageManifest({*entry_a}); + build_b->precommitAdd(ns, "ref_b", id_b); + build_b->promote(ns, "ref_b", build_b->buildId(), id_b); + + /// Resolve ref B: the adopted leaf's blob_size must match ref A (round-trip invariant for B92). + const PartManifest manifest_b = s->readManifest(s->resolveRef(ns, "ref_b")->manifest_id); + const auto * entry_b = findEntry(manifest_b.entries, "data.bin"); + ASSERT_TRUE(entry_b != nullptr); + EXPECT_NE(entry_b->blob_size, 0u) << "adopted leaf blob_size must not be 0 (B92)"; + EXPECT_EQ(entry_b->blob_size, size_a) << "adopted-leaf blob_size mismatch (B92 round-trip)"; +} + +/// ---- Genuinely-obsolete pure-tree-model scenarios (no manifest analog) ---- + +TEST(CASProtocol, DISABLED_RevalidateAbsentTreeDepRecreates) +{ + GTEST_SKIP() << "Obsolete (tree model). The gate's 'absent tree dep recreated from retained " + "payload' behavior has no manifest analog: a part manifest body is staged ONCE by " + "stageManifest and promote never re-creates it — an absent/invalid body at promote " + "fails closed (ABORTED). The blob-leaf absent-recreate case is covered by putBlob's " + "INV-1 re-upload-from-source path, not by the publish gate."; +} + +TEST(CASProtocol, DISABLED_AdoptTreeOfReclaimedTreeFailsClosedAtAdoptTime) +{ + GTEST_SKIP() << "Obsolete (tree model). adoptTree's fail-closed observe-at-adopt-time (one HEAD, " + "FILE_DOESNT_EXIST on an absent detached tree) has no manifest analog: the manifest " + "model's adoptEvidence is deliberately TOKENLESS and performs NO backend call — the " + "no-dangle guarantee for an adopted-but-reclaimed leaf is enforced at the promote " + "gate (unconditional blob revalidation ⇒ ABORTED), covered by " + "NewNamespacePublishGatedByShardFenceFloor and FreshEvidenceDepWithViewHitIsResolvedByGate."; +} diff --git a/src/Disks/tests/gtest_cas_rebuild_condemn_nothing.cpp b/src/Disks/tests/gtest_cas_rebuild_condemn_nothing.cpp new file mode 100644 index 000000000000..afe5ed26fb44 --- /dev/null +++ b/src/Disks/tests/gtest_cas_rebuild_condemn_nothing.cpp @@ -0,0 +1,636 @@ +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include "cas_test_helpers.h" + +#include +#include +#include +#include +#include + +/// REBUILD CONDEMNS NOTHING, AND fsck WALKS STREAMS BY ARITHMETIC (spec 2026-07-27 "ref chain complete +/// cut" §7). +/// +/// REBUILD used to end with a LIST of `blobs/` and condemn every listed body its traversal had not +/// reached. That is the r5-finding-4 data-loss vector: the traversal itself is listing-driven, so a +/// store that omits a durable ref-log or manifest key from a LIST hides a LIVE owner, and the very same +/// pass then condemns the blob that owner pins. One lying enumeration, and acked data is scheduled for +/// deletion. The condemnation is GONE — REBUILD rebuilds cursors and edges and reclaims nothing. +/// +/// The NAMED residual that removal creates (Stage-A staging contract, register R4): a blob whose +/// manifest no longer exists anywhere is unreclaimable until the build/upload registry can enumerate +/// in-flight uploads. No substitute reclamation is added in its place — a quiet one would be the same +/// vector wearing a different hat (Constraint 3: no fallback). +/// +/// fsck's half is the other side of the same rule: it may not rest a verdict on a listing either. It +/// walks each namespace's ref stream by ARITHMETIC from `_ckpt.checkpoint` upward, reading every id by +/// exact key, and reports one verdict per namespace — `chain-broken` (a 404 below a CONFIRMED durable +/// same-epoch id: a hole, fatal in the summary AND in the exit code), `unchecked` (could not prove it +/// either way), or nothing at all. A finding is RECORDED, never thrown: an fsck that dies on the first +/// bad namespace says nothing about the ones it never reached. + +using namespace DB::Cas; +using namespace DB::Cas::tests; + +namespace +{ + +const UInt128 kGc = hexToU128("00000000000000000000000000000001"); + +const RootNamespace kNsA{"00/aa@cas@"}; +const RootNamespace kNsB{"00/zz@cas@"}; + +/// Makes a second REBUILD catalog GET observe a different authority set. The command must take one +/// immutable cut at entry, so a correct implementation never triggers the mutation. +class CatalogChangesOnSecondReadBackend : public CountingBackend +{ +public: + using Backend::get; + + void armCatalogMutation(const String & key) + { + catalog_key = key; + catalog_reads = 0; + armed = true; + } + + size_t catalogReads() const { return catalog_reads; } + + std::optional get(const String & key, Range range) override + { + auto got = CountingBackend::get(key, range); + if (!armed || key != catalog_key) + return got; + + ++catalog_reads; + if (catalog_reads != 2) + return got; + if (!got) + throw std::runtime_error("catalog mutation fixture: second catalog read found absence"); + + const PutResult put = CountingBackend::putOverwrite( + key, encodeRefCatalog(RefCatalog{}), got->token, {}); + if (put.outcome != PutOutcome::Done) + throw std::runtime_error("catalog mutation fixture: catalog rewrite conflicted"); + return CountingBackend::get(key, range); + } + +private: + String catalog_key; + size_t catalog_reads = 0; + bool armed = false; +}; + +BlobRef blobRefOf(const DB::UInt128 & hash) +{ + return BlobRef{BlobHashAlgo::CityHash128, BlobDigest::fromU128(hash)}; +} + +bool blobPresent(Backend & backend, const Layout & layout, const DB::UInt128 & hash) +{ + return backend.head(layout.blobKey(blobRefOf(hash))).exists; +} + +/// Whether ANY run the newest fold seal references carries a `kCondemned` row for `hash`. This is where +/// a rebuild used to put its zero-edge condemnations, so "nothing was condemned" is checked HERE rather +/// than by watching for a deletion several rounds later. +bool condemnedInSealedRuns(Backend & backend, const Layout & layout, const DB::UInt128 & hash) +{ + const GcState st = decodeGcState(backend.get(layout.gcStateKey())->bytes); + const auto sealed = backend.get(layout.foldSealKey(st.snap_generation, st.snap_attempt)); + if (!sealed) + return false; + const CasFoldSeal seal = decodeFoldSeal(sealed->bytes); + for (const RunRef & r : seal.blob_target_runs) + { + auto reader = openSourceEdgeRun(backend, r.key); + String k; + String p; + while (reader.next(k, p)) + { + BlobRef ref; + UInt128 sid; + SourceEdgeKeyCodec::parse(k, ref, sid); + if (p.empty() || p[0] != kCondemned) + continue; + if (ref.digest.toU128() == hash) + return true; + } + } + return false; +} + +/// Publish `ref_name` -> a fresh manifest pinning `blob` at exactly `id`, and return the manifest's key +/// so a test can hide it from the listing. +String publishAtReturningManifestKey( + Backend & backend, const Layout & layout, const RootNamespace & ns, const RefTxnId & id, + const String & ref_name, uint64_t build_sequence, const DB::UInt128 & blob, bool birth = false, + std::optional prev_epoch_seal = std::nullopt) +{ + publishAt(backend, layout, ns, id, ref_name, build_sequence, blob, birth, prev_epoch_seal); + return layout.manifestKey(ManifestId{ns, ManifestRef{.writer_epoch = id.writer_epoch, + .build_sequence = build_sequence, + .manifest_ordinal = 1}}); +} + +/// Publish the exact `_ckpt` that makes a raw fixture recoverable. The real writers go through +/// `publishCkpt`, which merges by semantic maximum and additionally refuses a `life_epoch` below the +/// durable one; this helper instead makes the fixture's one admissible recovery frontier explicit. +void writeCkptRaw(Backend & backend, const Layout & layout, const RootNamespace & ns, const RefCkpt & ckpt) +{ + writeRecoverableCkptForRawFixture(backend, layout, ns, ckpt); +} + +/// The table state after applying exactly `ids`, through the same builder as recovery — so a snapshot +/// built from it is what the codec itself would have published. +RefTableState stateAfter(Backend & backend, const Layout & layout, const RootNamespace & ns, + const std::vector & ids) +{ + RefReplayBuilder builder(std::nullopt); + for (const RefTxnId & id : ids) + { + const auto got = backend.get(layout.refLogKey(fixture::fixtureLife(ns), id)); + if (!got) + throw std::runtime_error("stateAfter: fixture log " + std::to_string(id.writer_epoch) + "-" + + std::to_string(id.ref_sequence) + " is missing"); + builder.applyOne(decodeRefLogTxn(openObject(FormatId::RefLog, got->bytes), ns.string(), id), + got->bytes.size()); + } + return std::move(builder).finish().state; +} + +/// Two writer epochs joined by a real seal: `{1,1} {1,2}` then the `{1,3}` seal, then `{2,1}` naming it +/// as its `prev_epoch_seal` and `{2,2}` after it. The exact-authority walk must handle this crossing. +void seedSealedTwoEpochStream(Backend & backend, const Layout & layout, const RootNamespace & ns) +{ + publishAt(backend, layout, ns, RefTxnId{1, 1}, "ref_a", 1, DB::UInt128(1), /*birth=*/true); + publishAt(backend, layout, ns, RefTxnId{1, 2}, "ref_b", 2, DB::UInt128(2)); + writeSealAt(backend, layout, ns, RefTxnId{1, 3}); + publishAt(backend, layout, ns, RefTxnId{2, 1}, "ref_c", 1, DB::UInt128(3), /*birth=*/false, + /*prev_epoch_seal=*/RefTxnId{1, 3}); + publishAt(backend, layout, ns, RefTxnId{2, 2}, "ref_d", 2, DB::UInt128(4)); +} + +/// The number of rows in `cls` whose note mentions `needle`, over the whole report. +size_t rowsMentioning(const FsckReport & rep, FsckClass cls, const String & needle) +{ + size_t n = 0; + for (const FsckObject & o : rep.objects) + { + if (o.cls != cls) + continue; + for (const String & note : o.reachable_from) + if (note.find(needle) != String::npos) + ++n; + } + return n; +} + +} + +/// ---- REBUILD condemns nothing ---- + +/// THE REGRESSION TEST FOR r5-finding-4. A blob pinned by a COMMITTED ref, whose ref-log record and +/// whose manifest body the store both omit from every LIST while serving them perfectly by exact key. +/// The catalog row plus `_ckpt` frontier make the ref-log record authoritative, so REBUILD must recover +/// both owners despite the lying hint. The hidden manifest LIST still cannot justify condemnation: an +/// omitted object costs retention and never data. +TEST(CASRebuildCondemnNothing, HiddenLiveManifestBlobIsNotCondemned) +{ + auto backend = std::make_shared(); + auto store = openPoolForTest(backend, /*gc_fold_max_defer_rounds=*/0); + const Layout & layout = store->layout(); + + /// Visible owner: ref_a pins blob 1. + publishAt(*backend, layout, kNsA, RefTxnId{1, 1}, "ref_a", /*build_sequence=*/1, DB::UInt128(1), /*birth=*/true); + /// HIDDEN owner: ref_b pins blob 2, and neither its record nor its manifest is ever listed. + const String hidden_manifest = + publishAtReturningManifestKey(*backend, layout, kNsA, RefTxnId{1, 2}, "ref_b", /*build_sequence=*/2, DB::UInt128(2)); + writeCkptRaw(*backend, layout, kNsA, + RefCkpt{.life_epoch = 1, .committed_through = RefTxnId{1, 2}, + .checkpoint_snapshot_id = std::nullopt, .last_epoch_seal = std::nullopt}); + backend->hide(layout.refLogKey(fixture::fixtureLife(kNsA), RefTxnId{1, 2})); + backend->hide(hidden_manifest); + + /// Precondition: both objects really are durable and really are hidden. + ASSERT_TRUE(backend->get(layout.refLogKey(fixture::fixtureLife(kNsA), RefTxnId{1, 2})).has_value()); + ASSERT_TRUE(backend->get(hidden_manifest).has_value()); + + Gc gc(store, kGc); + const RebuildReport rep = gc.rebuildBaseline(/*force=*/true); + ASSERT_TRUE(rep.performed) << rep.refusal; + ASSERT_GT(backend->holesServed(), 0u) << "the hidden keys were never actually omitted from a LIST"; + EXPECT_EQ(rep.committed_refs, 2u) + << "the immutable checkpoint frontier, not the lying LIST, defines both committed owners"; + + EXPECT_FALSE(condemnedInSealedRuns(*backend, layout, DB::UInt128(2))) + << "the hidden owner's blob was condemned — that is acked data scheduled for deletion"; + EXPECT_FALSE(loadMetaForTest(*backend, layout, DB::UInt128(2)).has_value()) + << "a rebuild condemns nothing, so it publishes no condemn marker"; + + /// And it survives the pipeline: rounds run, nothing reclaims it. + for (int i = 0; i < 4; ++i) + { + gc.runRegularRound(); + store->renewWatermarkOnce(); + } + EXPECT_TRUE(blobPresent(*backend, layout, DB::UInt128(2))) << "acked data was deleted after a rebuild"; + EXPECT_TRUE(blobPresent(*backend, layout, DB::UInt128(1))); +} + +/// An ORPHAN blob — one no manifest anywhere names — is likewise left alone. This is the NAMED residual +/// (register R4) stated as a test rather than as prose: until the build/upload registry can enumerate +/// in-flight uploads, a manifest-less blob is unreclaimable, and the rebuild does NOT get to guess. The +/// blob a live ref pins and the blob nothing pins are indistinguishable from a LIST, which is exactly +/// why the old pass could not tell them apart either. +TEST(CASRebuildCondemnNothing, OrphanBlobIsRetainedNotCondemned) +{ + auto backend = std::make_shared(); + auto store = openPoolForTest(backend, /*gc_fold_max_defer_rounds=*/0); + const Layout & layout = store->layout(); + + publishAt(*backend, layout, kNsA, RefTxnId{1, 1}, "ref_a", /*build_sequence=*/1, DB::UInt128(1), /*birth=*/true); + writeCkptRaw(*backend, layout, kNsA, + RefCkpt{.life_epoch = 1, .committed_through = RefTxnId{1, 1}, + .checkpoint_snapshot_id = std::nullopt, .last_epoch_seal = std::nullopt}); + writeBlobBody(*backend, layout, DB::UInt128(2)); /// orphan: present, named by nothing + + Gc gc(store, kGc); + const RebuildReport rep = gc.rebuildBaseline(/*force=*/true); + ASSERT_TRUE(rep.performed) << rep.refusal; + + EXPECT_FALSE(condemnedInSealedRuns(*backend, layout, DB::UInt128(2))); + const GcState st = decodeGcState(backend->get(layout.gcStateKey())->bytes); + const CasFoldSeal seal = decodeFoldSeal(backend->get(layout.foldSealKey(st.snap_generation, st.snap_attempt))->bytes); + ASSERT_TRUE(seal.condemned_summary.contains(0)) << "the summary stays TOTAL over gc_shards"; + EXPECT_EQ(seal.condemned_summary.at(0).condemned_total, 0u) << "a rebuild condemns nothing"; + + for (int i = 0; i < 4; ++i) + { + gc.runRegularRound(); + store->renewWatermarkOnce(); + } + EXPECT_TRUE(blobPresent(*backend, layout, DB::UInt128(2))) + << "the residual is RETENTION: an orphan is kept, not quietly reclaimed by a substitute pass"; +} + +/// The rebuild is the `gc/state` disaster-recovery command, so it is the LAST thing that may refuse to +/// run over a pool holding one bad key. A name-bearing segment under the opaque stream root is not a +/// canonical physical life id. The key must be skipped -- no catalog entry can claim it -- and the +/// rebuild must continue over unrelated cataloged lives. +TEST(CASRebuildCondemnNothing, NonCanonicalLifeKeyDoesNotAbortTheRebuild) +{ + auto backend = std::make_shared(); + auto store = openPoolForTest(backend, /*gc_fold_max_defer_rounds=*/0); + const Layout & layout = store->layout(); + + publishAt(*backend, layout, kNsA, RefTxnId{1, 1}, "ref_a", /*build_sequence=*/1, DB::UInt128(1), /*birth=*/true); + writeCkptRaw(*backend, layout, kNsA, + RefCkpt{.life_epoch = 1, .committed_through = RefTxnId{1, 1}, + .checkpoint_snapshot_id = std::nullopt, .last_epoch_seal = std::nullopt}); + + /// Hand-built: no helper can mint this shape any more. + const String noncanonical_life = + layout.casRefsPrefix() + kNsA.string() + "/_log/" + renderRefTxnId(RefTxnId{1, 1}) + ".zst"; + ASSERT_EQ(backend->putIfAbsent(noncanonical_life, "garbage").outcome, PutOutcome::Done); + + Gc gc(store, kGc); + RebuildReport rep; + ASSERT_NO_THROW(rep = gc.rebuildBaseline(/*force=*/true)) + << "the recovery command must not be taken out by the damage it exists to recover from"; + ASSERT_TRUE(rep.performed) << rep.refusal; + + /// The live namespace was still discovered and folded -- the bad key was skipped, not the pool. + EXPECT_EQ(rep.committed_refs, 1u) << "the malformed key must not hide the live namespace"; +} + +/// The SECOND way the same damage can reach the rebuild, and it is a different code path from the one +/// above: the gen-0 health check LISTs each namespace's own life prefix and groups those keys to decide +/// whether any table proves cleaned logs. `groupRefKeys` refuses a key that names no life, so a NESTED +/// shape under the life prefix (`/x/_log/.zst`) reaches the refusal there instead of at +/// `discoverUniverse`, which absorbs it. Same rule, same reason: the recovery command must not be taken +/// out by the damage it exists to recover from. +/// +/// A decodable `gc/state` at generation 0 is what makes that branch run at all -- with no state object +/// the health check never reaches it. The state here comes from one real round (so the lease belongs to +/// this identity) with `snap_generation` written back to 0. +TEST(CASRebuildCondemnNothing, NestedLifelessKeyUnderTheLifePrefixDoesNotAbortTheRebuild) +{ + auto backend = std::make_shared(); + auto store = openPoolForTest(backend, /*gc_fold_max_defer_rounds=*/0); + const Layout & layout = store->layout(); + + publishAt(*backend, layout, kNsA, RefTxnId{1, 1}, "ref_a", /*build_sequence=*/1, DB::UInt128(1), /*birth=*/true); + writeCkptRaw(*backend, layout, kNsA, + RefCkpt{.life_epoch = 1, .committed_through = RefTxnId{1, 1}, + .checkpoint_snapshot_id = std::nullopt, .last_epoch_seal = std::nullopt}); + + Gc gc(store, kGc); + ASSERT_TRUE(gc.runRegularRound().acquired_lease); + { + const auto got = backend->get(layout.gcStateKey()); + ASSERT_TRUE(got.has_value()); + GcState st = decodeGcState(got->bytes); + st.snap_generation = 0; + ASSERT_EQ(backend->putOverwrite(layout.gcStateKey(), encodeGcState(st), got->token).outcome, + PutOutcome::Done); + } + + /// Hand-built, and planted AFTER the round so the round itself is clean: one segment too deep under + /// the life prefix, so the segment where the incarnation belongs holds `x`. No helper mints this. + const String nested = layout.namespaceStreamPrefix(fixture::fixtureLife(kNsA)) + + "x/_log/" + renderRefTxnId(RefTxnId{1, 1}) + ".zst"; + ASSERT_EQ(backend->putIfAbsent(nested, "garbage").outcome, PutOutcome::Done); + + RebuildReport rep; + ASSERT_NO_THROW(rep = gc.rebuildBaseline(/*force=*/false)) + << "the recovery command must not be taken out by the damage it exists to recover from"; + /// FORCE is deliberately NOT passed: a listing the check could not group proves nothing about the ref + /// baseline, so the pool cannot be declared healthy, and the un-forced rebuild must therefore RUN + /// rather than refuse. + ASSERT_TRUE(rep.performed) << rep.refusal; + EXPECT_EQ(rep.committed_refs, 1u) << "the live namespace must still be folded"; +} + +TEST(CASRebuildCondemnNothing, OneCatalogCutDrivesHealthCheckAndRebuild) +{ + auto backend = std::make_shared(); + auto store = openPoolForTest(backend, /*gc_fold_max_defer_rounds=*/0); + const Layout & layout = store->layout(); + + publishAt(*backend, layout, kNsA, RefTxnId{1, 1}, "ref_a", /*build_sequence=*/1, + DB::UInt128(1), /*birth=*/true); + writeCkptRaw(*backend, layout, kNsA, + RefCkpt{.life_epoch = 1, .committed_through = RefTxnId{1, 1}, + .checkpoint_snapshot_id = std::nullopt, .last_epoch_seal = std::nullopt}); + + /// A decoded generation-0 state exercises the health check's namespace walk before the rebuild + /// universe is consumed. The backend would erase the authority set on a second catalog GET. + Gc gc(store, kGc); + ASSERT_TRUE(gc.runRegularRound().acquired_lease); + { + const auto got = backend->get(layout.gcStateKey()); + ASSERT_TRUE(got); + GcState state = decodeGcState(got->bytes); + state.snap_generation = 0; + ASSERT_EQ(backend->putOverwrite( + layout.gcStateKey(), encodeGcState(state), got->token).outcome, PutOutcome::Done); + } + + backend->armCatalogMutation(layout.refCatalogKey()); + const RebuildReport rep = gc.rebuildBaseline(/*force=*/true); + EXPECT_EQ(backend->catalogReads(), 1u) + << "REBUILD must use its entry cut for both the generation-0 health check and the rebuild"; + ASSERT_TRUE(rep.performed) << rep.refusal; + EXPECT_EQ(rep.committed_refs, 1u); +} + +/// Removing the condemnation must not disturb the other thing a rebuild owes: every hold in the prior +/// seal rides through VERBATIM (Task 8). Asserted together with the condemn-nothing rule because the +/// two used to be produced by the same pass, and a hold dropped here would hand back a baseline that +/// claims a frontier proof it does not have. +TEST(CASRebuildCondemnNothing, CarriesHoldsVerbatimWhileCondemningNothing) +{ + auto backend = std::make_shared(); + auto store = openPoolForTest(backend, /*gc_fold_max_defer_rounds=*/0); + const Layout & layout = store->layout(); + + publishAt(*backend, layout, kNsA, RefTxnId{1, 1}, "ref_a", /*build_sequence=*/1, DB::UInt128(1), /*birth=*/true); + writeCkptRaw(*backend, layout, kNsA, + RefCkpt{.life_epoch = 1, .committed_through = RefTxnId{1, 1}, + .checkpoint_snapshot_id = std::nullopt, .last_epoch_seal = std::nullopt}); + writeBlobBody(*backend, layout, DB::UInt128(2)); /// an orphan alongside the held namespace + + /// One real round first: it establishes the pool's `gc/state` and takes the lease under THIS + /// identity, so the rebuild below is the disaster-recovery path and not a lease conflict. + Gc gc(store, kGc); + ASSERT_TRUE(gc.runRegularRound().acquired_lease); + + const RefHold planted{.reason = HoldReason::GapBelowWitness, .offending_position = RefTxnId{4, 9}, + .retry_count = 17, .next_retry_round = 23}; + { + const GcState adopted = decodeGcState(backend->get(layout.gcStateKey())->bytes); + seedFoldCursorForTest(*backend, layout, kNsA, RefTxnId{1, 1}, planted, + adopted.snap_generation, adopted.snap_attempt); + } + + const RebuildReport rep = gc.rebuildBaseline(/*force=*/true); + ASSERT_TRUE(rep.performed) << rep.refusal; + + const GcState st = decodeGcState(backend->get(layout.gcStateKey())->bytes); + const CasFoldSeal seal = decodeFoldSeal(backend->get(layout.foldSealKey(st.snap_generation, st.snap_attempt))->bytes); + const auto it = seal.ref_lives.find(catalogLifeIdForTest(*backend, layout, kNsA)); + ASSERT_NE(it, seal.ref_lives.end()); + EXPECT_EQ(it->second.coverage.classification, 4); + ASSERT_TRUE(it->second.coverage.hold.has_value()); + EXPECT_EQ(*it->second.coverage.hold, planted) + << "a rebuild retried nothing, so it rewrites nothing about the hold"; + + EXPECT_FALSE(condemnedInSealedRuns(*backend, layout, DB::UInt128(2))); +} + +/// ---- fsck: arithmetic streams ---- + +/// A pool with nothing wrong reports nothing: no hole, no unproven namespace, a clean bill of health +/// and a zero exit. `unchecked` is not a resting state — it is a verdict a healthy pool never reaches. +TEST(CASRebuildCondemnNothingFsck, HealthyArithmeticPoolIsClean) +{ + auto backend = std::make_shared(); + auto store = openPoolForTest(backend, /*gc_fold_max_defer_rounds=*/0); + const Layout & layout = store->layout(); + + publishAt(*backend, layout, kNsA, RefTxnId{1, 1}, "ref_a", 1, DB::UInt128(1), /*birth=*/true); + publishAt(*backend, layout, kNsA, RefTxnId{1, 2}, "ref_b", 2, DB::UInt128(2)); + publishAt(*backend, layout, kNsA, RefTxnId{1, 3}, "ref_c", 3, DB::UInt128(3)); + writeCkptRaw(*backend, layout, kNsA, + RefCkpt{.life_epoch = 1, .committed_through = RefTxnId{1, 3}, + .checkpoint_snapshot_id = std::nullopt, .last_epoch_seal = std::nullopt}); + + const FsckReport rep = runFsck(*store, /*detail=*/true); + EXPECT_TRUE(rep.clean()) << formatFsckSummary(rep); + EXPECT_EQ(rep.chain_broken, 0u); + EXPECT_EQ(rep.unchecked, 0u) << "a healthy namespace is PROVEN, not merely uncomplained-about"; + EXPECT_EQ(rep.ref_records_walked, 3u); + EXPECT_EQ(rep.dangling, 0u); +} + +/// A 404 BELOW a durable same-epoch id. Ids are dense `1..T` within `(namespace, epoch)` (INV-1), so +/// this cannot be the end of a stream: a durable record is missing and every transaction above it is +/// unreachable. The verdict is FATAL — it appears in the machine-parseable summary line and it makes +/// the report unclean, which is what turns into the command's nonzero exit. +TEST(CASRebuildCondemnNothingFsck, MidChainHoleBelowAWitnessIsChainBroken) +{ + auto backend = std::make_shared(); + auto store = openPoolForTest(backend, /*gc_fold_max_defer_rounds=*/0); + const Layout & layout = store->layout(); + + publishAt(*backend, layout, kNsA, RefTxnId{1, 1}, "ref_a", 1, DB::UInt128(1), /*birth=*/true); + publishAt(*backend, layout, kNsA, RefTxnId{1, 2}, "ref_b", 2, DB::UInt128(2)); + publishAt(*backend, layout, kNsA, RefTxnId{1, 3}, "ref_c", 3, DB::UInt128(3)); + writeCkptRaw(*backend, layout, kNsA, + RefCkpt{.life_epoch = 1, .committed_through = RefTxnId{1, 3}, + .checkpoint_snapshot_id = std::nullopt, .last_epoch_seal = std::nullopt}); + + /// Punch the hole: {1,2} is gone while {1,3} stays durable and listed. + const String holed = layout.refLogKey(fixture::fixtureLife(kNsA), RefTxnId{1, 2}); + const HeadResult h = backend->head(holed); + ASSERT_TRUE(h.exists); + backend->deleteExact(holed, h.token); + + FsckReport rep; + ASSERT_NO_THROW(rep = runFsck(*store, /*detail=*/true)) + << "a finding is RECORDED, never thrown — an fsck that dies reports nothing"; + EXPECT_EQ(rep.chain_broken, 1u); + EXPECT_FALSE(rep.clean()) << "chain-broken is a hard finding"; + EXPECT_NE(formatFsckSummary(rep).find("chain_broken=1"), String::npos) << formatFsckSummary(rep); + + bool row = false; + for (const FsckObject & o : rep.objects) + if (o.cls == FsckClass::ChainBroken) + row = true; + EXPECT_TRUE(row) << "the fatal must name the position it was detected at"; +} + +/// The tail ABOVE `_ckpt.checkpoint` is WALKED, not assumed. Here the store lists neither of the two +/// records above the checkpoint, so a listing-driven audit would see an empty tail and report a clean +/// pool it never read. Arithmetic reads them by exact key: they are walked, counted, and the namespace +/// comes back PROVEN — not `unchecked`, which is reserved for what cannot be proved at all. +TEST(CASRebuildCondemnNothingFsck, TailAboveTheCheckpointIsWalkedNotUnchecked) +{ + auto backend = std::make_shared(); + auto store = openPoolForTest(backend, /*gc_fold_max_defer_rounds=*/0); + const Layout & layout = store->layout(); + + publishAt(*backend, layout, kNsA, RefTxnId{1, 1}, "ref_a", 1, DB::UInt128(1), /*birth=*/true); + publishAt(*backend, layout, kNsA, RefTxnId{1, 2}, "ref_b", 2, DB::UInt128(2)); + publishAt(*backend, layout, kNsA, RefTxnId{1, 3}, "ref_c", 3, DB::UInt128(3)); + publishAt(*backend, layout, kNsA, RefTxnId{1, 4}, "ref_d", 4, DB::UInt128(4)); + + /// A published snapshot at {1,2}, named by the checkpoint. Its bytes are the codec's own view of + /// the state at {1,2}, so exact checkpoint-base validation accepts it. + writeRefSnapshotRaw(*backend, layout, + snapshotOf(stateAfter(*backend, layout, kNsA, {RefTxnId{1, 1}, RefTxnId{1, 2}}), kNsA.string())); + writeCkptRaw(*backend, layout, kNsA, + RefCkpt{.life_epoch = 1, .committed_through = RefTxnId{1, 4}, + .checkpoint_snapshot_id = RefTxnId{1, 2}, .last_epoch_seal = std::nullopt}); + + /// The store stops listing the tail. It stays perfectly readable by exact key. + backend->hide(layout.refLogKey(fixture::fixtureLife(kNsA), RefTxnId{1, 3})); + backend->hide(layout.refLogKey(fixture::fixtureLife(kNsA), RefTxnId{1, 4})); + + const FsckReport rep = runFsck(*store, /*detail=*/true); + ASSERT_GT(backend->holesServed(), 0u) << "the tail was never actually hidden from a LIST"; + EXPECT_EQ(rep.ref_records_walked, 2u) << "the two records above the checkpoint must be read by exact key"; + EXPECT_EQ(rep.unchecked, 0u) << "a walked tail is PROVEN; `unchecked` is not a default"; + EXPECT_EQ(rep.chain_broken, 0u); + EXPECT_TRUE(rep.clean()) << formatFsckSummary(rep); +} + +/// A SEALED multi-epoch stream, walked. The epoch boundary is crossed the way the protocol proves it — +/// through the next epoch's `prev_epoch_seal` back-chain — never by guessing `epoch + 1`. The +/// checkpoint sits below the seal, so the tail the walk owes covers the boundary itself. +TEST(CASRebuildCondemnNothingFsck, SealedStreamIsWalkedAcrossTheEpochBoundary) +{ + auto backend = std::make_shared(); + auto store = openPoolForTest(backend, /*gc_fold_max_defer_rounds=*/0); + const Layout & layout = store->layout(); + + seedSealedTwoEpochStream(*backend, layout, kNsA); + writeRefSnapshotRaw(*backend, layout, + snapshotOf(stateAfter(*backend, layout, kNsA, {RefTxnId{1, 1}, RefTxnId{1, 2}}), kNsA.string())); + writeCkptRaw(*backend, layout, kNsA, + RefCkpt{.life_epoch = 1, .committed_through = RefTxnId{2, 2}, + .checkpoint_snapshot_id = RefTxnId{1, 2}, .last_epoch_seal = RefTxnId{1, 3}}); + + const FsckReport rep = runFsck(*store, /*detail=*/true); + EXPECT_TRUE(rep.clean()) << formatFsckSummary(rep); + EXPECT_EQ(rep.chain_broken, 0u); + EXPECT_EQ(rep.unchecked, 0u) << "a PROVED crossing is not an unproven one"; + EXPECT_EQ(rep.ref_records_walked, 3u) << "the seal plus both records of the epoch it opened"; +} + +/// An exact `_ckpt` frontier turns an impossible epoch crossing into a hard chain break. Here `ns_a`'s +/// epoch 1 ends at the PRESENT ordinary record `{1,3}`, while both `_ckpt` and `{2,1}` falsely claim +/// that position as the closing seal. The finite range is complete and proves the contradiction: this +/// is NOT `unchecked`, and there is no earlier missing record that could make the test pass instead. +/// The healthy `ns_b` in the same pool is unaffected — one broken namespace never spreads. +TEST(CASRebuildCondemnNothingFsck, ExactFrontierMakesAnUnsealedEpochCrossingChainBroken) +{ + auto backend = std::make_shared(); + auto store = openPoolForTest(backend, /*gc_fold_max_defer_rounds=*/0); + const Layout & layout = store->layout(); + + publishAt(*backend, layout, kNsA, RefTxnId{1, 1}, "ref_a", 1, DB::UInt128(1), /*birth=*/true); + publishAt(*backend, layout, kNsA, RefTxnId{1, 2}, "ref_b", 2, DB::UInt128(2)); + publishAt(*backend, layout, kNsA, RefTxnId{1, 3}, "ref_c", 3, DB::UInt128(3)); + /// `{1,3}` exists but is an ordinary owner transaction, not an `EpochSeal`. Claiming it as the + /// predecessor must not authorize the transition to epoch 2. + publishAt(*backend, layout, kNsA, RefTxnId{2, 1}, "ref_d", 1, DB::UInt128(4), /*birth=*/false, + /*prev_epoch_seal=*/RefTxnId{1, 3}); + + publishAt(*backend, layout, kNsB, RefTxnId{1, 1}, "ref_z", 1, DB::UInt128(9), /*birth=*/true); + writeCkptRaw(*backend, layout, kNsA, + RefCkpt{.life_epoch = 1, .committed_through = RefTxnId{2, 1}, + .checkpoint_snapshot_id = std::nullopt, .last_epoch_seal = RefTxnId{1, 3}}); + writeCkptRaw(*backend, layout, kNsB, + RefCkpt{.life_epoch = 1, .committed_through = RefTxnId{1, 1}, + .checkpoint_snapshot_id = std::nullopt, .last_epoch_seal = std::nullopt}); + + FsckReport rep; + ASSERT_NO_THROW(rep = runFsck(*store, /*detail=*/true)); + EXPECT_EQ(rep.unchecked, 0u) << "the exact frontier proves this crossing inconsistent"; + EXPECT_EQ(rep.chain_broken, 1u) << "exactly the malformed namespace must be reported"; + const String missing_key = layout.refLogKey(fixture::fixtureLife(kNsA), RefTxnId{1, 4}); + EXPECT_EQ(std::count_if(rep.objects.begin(), rep.objects.end(), [&](const FsckObject & object) + { + return object.cls == FsckClass::ChainBroken && object.key == missing_key; + }), 1u) << "the present ordinary 1-3 cannot close epoch 1, so arithmetic continuation requires 1-4"; + EXPECT_GE(rowsMentioning(rep, FsckClass::ChainBroken, "checkpoint requires id 1-4"), 1u) + << "the verdict must expose that the claimed ordinary predecessor did not authorize a crossing"; + EXPECT_GE(rowsMentioning(rep, FsckClass::ChainBroken, "inclusive frontier 2-1"), 1u) + << "the verdict must name the authority that made the absence a proven chain break"; +} + +/// W2 (Task-3 review): a holed namespace used to make the WHOLE scan throw — `applyOne` raises +/// `CORRUPTED_DATA` on a non-contiguous replay and nothing caught it, so one bad table aborted the +/// audit and every namespace after it went unexamined. For recovery, throwing is the correct +/// fail-close; for a read-only diagnostic it violates "record and continue, never wedge". +TEST(CASRebuildCondemnNothingFsck, OneBadNamespaceDoesNotAbortTheAudit) +{ + auto backend = std::make_shared(); + auto store = openPoolForTest(backend, /*gc_fold_max_defer_rounds=*/0); + const Layout & layout = store->layout(); + + /// `ns_a` sorts FIRST, so a scan that dies on it never reaches `ns_b`. + publishAt(*backend, layout, kNsA, RefTxnId{1, 1}, "ref_a", 1, DB::UInt128(1), /*birth=*/true); + publishAt(*backend, layout, kNsA, RefTxnId{1, 2}, "ref_b", 2, DB::UInt128(2)); + publishAt(*backend, layout, kNsA, RefTxnId{1, 3}, "ref_c", 3, DB::UInt128(3)); + writeCkptRaw(*backend, layout, kNsA, + RefCkpt{.life_epoch = 1, .committed_through = RefTxnId{1, 3}, + .checkpoint_snapshot_id = std::nullopt, .last_epoch_seal = std::nullopt}); + const String holed = layout.refLogKey(fixture::fixtureLife(kNsA), RefTxnId{1, 2}); + const HeadResult h = backend->head(holed); + ASSERT_TRUE(h.exists); + backend->deleteExact(holed, h.token); + + publishAt(*backend, layout, kNsB, RefTxnId{1, 1}, "ref_z", 1, DB::UInt128(9), /*birth=*/true); + writeCkptRaw(*backend, layout, kNsB, + RefCkpt{.life_epoch = 1, .committed_through = RefTxnId{1, 1}, + .checkpoint_snapshot_id = std::nullopt, .last_epoch_seal = std::nullopt}); + + FsckReport rep; + ASSERT_NO_THROW(rep = runFsck(*store, /*detail=*/true)); + EXPECT_EQ(rep.chain_broken, 1u); + EXPECT_GE(rep.reachable, 1u) << "the namespace AFTER the broken one must still have been examined"; + EXPECT_EQ(rep.dangling, 0u) << "`ns_b` is healthy; a wedged scan would have reported nothing about it"; +} diff --git a/src/Disks/tests/gtest_cas_record_stream_format.cpp b/src/Disks/tests/gtest_cas_record_stream_format.cpp new file mode 100644 index 000000000000..42e44585c357 --- /dev/null +++ b/src/Disks/tests/gtest_cas_record_stream_format.cpp @@ -0,0 +1,255 @@ +#include +#include +#include +#include +#include +#include +#include + +using namespace DB; +using namespace DB::Cas; + +namespace DB::ErrorCodes +{ + extern const int CORRUPTED_DATA; + extern const int LOGICAL_ERROR; + extern const int UNKNOWN_FORMAT_VERSION; +} + +namespace +{ + +BlobRef chRef(uint64_t n) +{ + return BlobRef{BlobHashAlgo::CityHash128, BlobDigest::fromU128(UInt128(n))}; +} + +SourceEdgeRecord edge(const BlobRef & ref, uint64_t source_id) +{ + return SourceEdgeRecord{.ref = ref, .source_id = UInt128(source_id), .marker = kEdgeActive}; +} + +SourceEdgeRecord zero(const BlobRef & ref) +{ + return SourceEdgeRecord{.ref = ref, .source_id = UInt128(0), .marker = kZeroMarker}; +} + +SourceEdgeRecord condemned(const BlobRef & ref, const Token & token, uint64_t size, uint64_t round, bool pend) +{ + return SourceEdgeRecord{.ref = ref, .source_id = UInt128(0), .marker = kCondemned, + .delete_pending = pend, .token = token, .size = size, .condemn_round = round}; +} + +/// Encode a run from records already in (ref, source_id) order. +String encodeRun(const std::vector & recs) +{ + DB::WriteBufferFromOwnString out; + SourceEdgeRunWriter writer(out); + for (const auto & r : recs) + writer.append(r); + writer.finish(); + out.finalize(); + return out.str(); +} + +/// Stream a run back to records; verifies the trailer count as a side effect. +std::vector decodeRun(const String & bytes) +{ + ReadBufferFromMemory in(bytes.data(), bytes.size()); + SourceEdgeRunReader reader(in); + std::vector out; + SourceEdgeRecord r; + while (reader.next(r)) + out.push_back(r); + return out; +} + +} + +TEST(CASRecordStream, EmptyRunRoundTripsAndChecksumMatches) +{ + const String bytes = encodeRun({}); + EXPECT_EQ(bytes, fmt::format( + "{{\"type\":\"cas_run\",\"v\":{},\"kind\":\"source_edge\"}}\n{{\"n\":0}}\n", currentCompatibilityVersion())); + + ReadBufferFromMemory in(bytes.data(), bytes.size()); + SourceEdgeRunReader reader(in); + SourceEdgeRecord r; + EXPECT_FALSE(reader.next(r)); + /// The read-side accumulated hash equals the write-side helper over the same bytes. + reader.verifyAgainst(sourceEdgeRunChecksum(bytes)); +} + +TEST(CASRecordStream, EdgeZeroCondemnedRoundTrip) +{ + const BlobRef a = chRef(1); + const BlobRef b = chRef(2); + const BlobRef c = chRef(3); + /// Sorted by (ref, source_id): b's condemned sentinel is at source_id 0 (sorts first for b); a has + /// an edge; c has a zero marker. Blobs ascend a < b < c, so the sequence is already non-decreasing. + std::vector recs = { + edge(a, 10), + condemned(b, Token{"e-1", TokenType::ETag}, 4242, 7, /*pend*/ true), + zero(c), + }; + const String bytes = encodeRun(recs); + const std::vector back = decodeRun(bytes); + ASSERT_EQ(back.size(), 3u); + + EXPECT_EQ(back[0].ref, a); + EXPECT_EQ(back[0].source_id, UInt128(10)); + EXPECT_EQ(back[0].marker, kEdgeActive); + + EXPECT_EQ(back[1].ref, b); + EXPECT_EQ(back[1].source_id, UInt128(0)); + EXPECT_EQ(back[1].marker, kCondemned); + EXPECT_TRUE(back[1].delete_pending); + EXPECT_EQ(back[1].token, (Token{"e-1", TokenType::ETag})); + EXPECT_EQ(back[1].size, 4242u); + EXPECT_EQ(back[1].condemn_round, 7u); + + EXPECT_EQ(back[2].ref, c); + EXPECT_EQ(back[2].marker, kZeroMarker); +} + +TEST(CASRecordStream, WriterIsByteDeterministic) +{ + std::vector recs = { + edge(chRef(1), 5), + edge(chRef(1), 9), + condemned(chRef(2), Token{"t/with/slashes", TokenType::ETag}, 1, 2, false), + }; + EXPECT_EQ(encodeRun(recs), encodeRun(recs)); /// pure function of the sorted record set +} + +TEST(CASRecordStream, SortOrderAcrossAlgosFollowsAlgoByte) +{ + /// b = . The algo byte leads, so string-sorting b reproduces the + /// binary (algo, digest, source_id) order: ch128 (01) < xxh3 (02) < sha256 (03). + BlobDigest d16 = BlobDigest::fromU128(UInt128(7)); + BlobDigest d32{}; + d32.bytes[0] = 0x10; + const BlobRef ch{BlobHashAlgo::CityHash128, d16}; + const BlobRef xx{BlobHashAlgo::XXH3_128, d16}; + const BlobRef sha{BlobHashAlgo::Sha256, d32}; + + /// Accepted in algo-byte order without an out-of-order throw. + const String bytes = encodeRun({edge(ch, 1), edge(xx, 1), edge(sha, 1)}); + const std::vector back = decodeRun(bytes); + ASSERT_EQ(back.size(), 3u); + EXPECT_EQ(back[0].ref.algo, BlobHashAlgo::CityHash128); + EXPECT_EQ(back[1].ref.algo, BlobHashAlgo::XXH3_128); + EXPECT_EQ(back[2].ref.algo, BlobHashAlgo::Sha256); +} + +TEST(CASRecordStream, AppendOutOfOrderThrows) +{ + DB::WriteBufferFromOwnString out; + SourceEdgeRunWriter writer(out); + writer.append(edge(chRef(2), 1)); + EXPECT_DEATH( + { + DB::abort_on_logical_error.store(true, std::memory_order_relaxed); + writer.append(edge(chRef(1), 1)); + }, + "records appended out of"); /// ref regression +} + +TEST(CASRecordStream, SourceIdRendersAs32Hex) +{ + const String bytes = encodeRun({edge(chRef(1), 10)}); + /// The source id 10 is a 32-char lowercase hex string ending in 'a'. + EXPECT_NE(bytes.find("\"s\":\"0000000000000000000000000000000a\""), String::npos); + /// The record key `b` for a ch128 ref is the algo byte 01 + a 32-hex digest (34 chars total). + EXPECT_NE(bytes.find("\"b\":\"01"), String::npos); +} + +TEST(CASRecordStream, SealChecksumMismatchFailsClosed) +{ + const String bytes = encodeRun({edge(chRef(1), 10), edge(chRef(1), 20)}); + const UInt128 good = sourceEdgeRunChecksum(bytes); + + /// A correct verify passes. + { + ReadBufferFromMemory in(bytes.data(), bytes.size()); + SourceEdgeRunReader reader(in); + SourceEdgeRecord r; + while (reader.next(r)) {} + reader.verifyAgainst(good); + } + + /// Any byte flip either fails the parse or the whole-file checksum — never silently trusted. + String flipped = bytes; + flipped[flipped.size() / 2] ^= 0x20; + EXPECT_NE(sourceEdgeRunChecksum(flipped), good); + EXPECT_THROW({ + ReadBufferFromMemory in(flipped.data(), flipped.size()); + SourceEdgeRunReader reader(in); + SourceEdgeRecord r; + while (reader.next(r)) {} + reader.verifyAgainst(good); + }, DB::Exception); +} + +TEST(CASRecordStream, TrailerCountMismatchIsCorruptData) +{ + String bytes = encodeRun({edge(chRef(1), 10)}); + /// Rewrite the trailer count 1 -> 2. + const String from = "{\"n\":1}\n"; + const String to = "{\"n\":2}\n"; + const size_t at = bytes.rfind(from); + ASSERT_NE(at, String::npos); + bytes.replace(at, from.size(), to); + EXPECT_THROW(decodeRun(bytes), DB::Exception); +} + +TEST(CASRecordStream, TruncationAtLineBoundaryFailsClosed) +{ + const String bytes = encodeRun({edge(chRef(1), 10), edge(chRef(1), 20)}); + /// Drop the trailer line entirely (truncate after the last record's newline). + const size_t trailer = bytes.rfind("{\"n\":"); + ASSERT_NE(trailer, String::npos); + EXPECT_THROW(decodeRun(bytes.substr(0, trailer)), DB::Exception); +} + +TEST(CASRecordStream, HeaderGates) +{ + /// Wrong type. + { + const String s = "{\"type\":\"cas_pool_meta\",\"v\":3,\"kind\":\"source_edge\"}\n{\"n\":0}\n"; + EXPECT_THROW(decodeRun(s), DB::Exception); + } + /// Wrong kind. + { + const String s = "{\"type\":\"cas_run\",\"v\":3,\"kind\":\"blob_delta\"}\n{\"n\":0}\n"; + EXPECT_THROW(decodeRun(s), DB::Exception); + } + /// Future version -> UNKNOWN_FORMAT_VERSION. + { + const String s = fmt::format( + "{{\"type\":\"cas_run\",\"v\":{},\"kind\":\"source_edge\"}}\n{{\"n\":0}}\n", currentCompatibilityVersion() + 1); + ReadBufferFromMemory in(s.data(), s.size()); + try + { + SourceEdgeRunReader reader(in); + FAIL() << "expected UNKNOWN_FORMAT_VERSION"; + } + catch (const DB::Exception & e) + { + EXPECT_EQ(e.code(), DB::ErrorCodes::UNKNOWN_FORMAT_VERSION); + } + } + /// An out-of-range version must not narrow to a valid low u32 value. + { + const String s = "{\"type\":\"cas_run\",\"v\":4294967299,\"kind\":\"source_edge\"}\n{\"n\":0}\n"; + try + { + decodeRun(s); + FAIL() << "expected CORRUPTED_DATA"; + } + catch (const DB::Exception & e) + { + EXPECT_EQ(e.code(), DB::ErrorCodes::CORRUPTED_DATA); + } + } +} diff --git a/src/Disks/tests/gtest_cas_recovery_grounding.cpp b/src/Disks/tests/gtest_cas_recovery_grounding.cpp new file mode 100644 index 000000000000..5362813bdfa6 --- /dev/null +++ b/src/Disks/tests/gtest_cas_recovery_grounding.cpp @@ -0,0 +1,701 @@ +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace DB::ErrorCodes +{ +extern const int CORRUPTED_DATA; +extern const int INVALID_STATE; +} + +using namespace DB::Cas; + +namespace +{ + +using DB::Cas::tests::CountingBackend; +using DB::Cas::tests::minimalLiveSnapshot; +using DB::Cas::tests::namespaceBirthOp; +using DB::Cas::tests::publishCommittedOps; +using DB::Cas::tests::seedPoolMetaForRestart; +using DB::Cas::tests::writeRefSnapshotRaw; + +enum class ListingMode : uint8_t +{ + Full, + Empty, + Partial, + Reordered, +}; + +class RecoveryListingBackend : public CountingBackend +{ +public: + explicit RecoveryListingBackend(ListingMode mode_) : mode(mode_) { seedPoolMetaForRestart(*this); } + + size_t list_calls = 0; + + ListPage list(const String & prefix, const String & cursor, size_t limit) override + { + ++list_calls; + ListPage page = CountingBackend::list(prefix, cursor, limit); + if (mode == ListingMode::Empty) + page.keys.clear(); + else if (mode == ListingMode::Partial) + { + page.keys.erase(std::remove_if(page.keys.begin(), page.keys.end(), [](const ListedKey & key) + { + return key.key.find("/_log/") != String::npos; + }), page.keys.end()); + } + else if (mode == ListingMode::Reordered) + std::reverse(page.keys.begin(), page.keys.end()); + return page; + } + +private: + ListingMode mode; +}; + +RefLogTxn txn(const RootNamespace & ns, RefTxnId id, std::vector ops, + std::optional previous_seal = std::nullopt) +{ + return RefLogTxn{.ns = ns.string(), .txn_id = id, .ops = std::move(ops), .prev_epoch_seal = previous_seal}; +} + +std::map committedOf(const RefTableState & state) +{ + std::map result; + for (const auto [name, row] : state.getCommitted()) + result.emplace(name, row.manifest_ref); + return result; +} + +void seedAuthoritativeStream(Backend & backend, const Layout & layout, const RootNamespace & ns, + RefTxnId committed_through, bool include_f_plus_one = false) +{ + const ManifestRef first{1, 1, 1}; + std::vector birth{namespaceBirthOp()}; + const auto first_publish = publishCommittedOps("a", first); + birth.insert(birth.end(), first_publish.begin(), first_publish.end()); + const RefLogTxn first_txn = txn(ns, {1, 1}, std::move(birth)); + DB::Cas::tests::fixture::writeRefLogRaw(backend, layout, first_txn); + + if (committed_through > RefTxnId{1, 1}) + { + RefOp seal_op; + seal_op.kind = RefOpKind::EpochSeal; + DB::Cas::tests::fixture::writeRefLogRaw(backend, layout, txn(ns, {1, 2}, {std::move(seal_op)})); + const ManifestRef second{2, 1, 1}; + DB::Cas::tests::fixture::writeRefLogRaw(backend, layout, + txn(ns, {2, 1}, publishCommittedOps("b", second), RefTxnId{1, 2})); + } + if (include_f_plus_one) + { + const ManifestRef extra{1, 2, 1}; + DB::Cas::tests::fixture::writeRefLogRaw(backend, layout, txn(ns, {1, 2}, publishCommittedOps("uncommitted", extra))); + } + + RefTableState snapshot_state; + applyRefLogTxn(snapshot_state, first_txn); + writeRefSnapshotRaw(backend, layout, snapshotOf(snapshot_state, ns.string())); + + const NamespaceLifeId life = *CasRefCatalog::lifeIfCataloged(backend, layout, ns); + const RefCkpt authority{ + .life_epoch = 1, + .committed_through = committed_through, + .checkpoint_snapshot_id = std::nullopt, + .last_epoch_seal = committed_through.writer_epoch > 1 + ? std::optional{RefTxnId{1, 2}} : std::nullopt}; + backend.putIfAbsent(layout.refCkptKey(life), encodeRefCkpt(authority)); +} + +/// This is deliberately caller-side plumbing, not a convenience overload in `CasRefProtocol`: production +/// callers obtain `entry` from their frozen `RefPlan::catalogCut` and sample `_ckpt` in the same plan. +/// The API under test receives those exact values and performs no catalog or checkpoint resolution itself. +RecoveredRefTable recoverFromCurrentCatalogCut(Backend & backend, const Layout & layout, const RootNamespace & ns) +{ + const CasRefCatalog::Snapshot cut = CasRefCatalog::read(backend, layout); + std::optional entry; + for (const CatalogEntry & candidate : cut.catalog.entries) + { + if (candidate.ns == ns) + { + entry = candidate; + break; + } + } + std::optional checkpoint; + if (entry) + { + const NamespaceLifeId life = NamespaceLifeId::fromCatalogEntry(entry->ns, entry->incarnation); + if (const std::optional sample = readCkpt(backend, layout, life)) + checkpoint = sample->ckpt; + } + return recoverRefTableDetailedFromAuthority(backend, layout, entry, checkpoint); +} + +CatalogEntry catalog(NsState state) +{ + return CatalogEntry{.ns = RootNamespace{"srv1/recovery_grounding"}, .state = state, .incarnation = 1}; +} + +RefCkpt ckpt(uint64_t life_epoch, std::optional committed_through, + std::optional checkpoint_snapshot_id = std::nullopt, + std::optional last_epoch_seal = std::nullopt) +{ + return RefCkpt{.life_epoch = life_epoch, + .committed_through = committed_through, + .checkpoint_snapshot_id = checkpoint_snapshot_id, + .last_epoch_seal = last_epoch_seal}; +} + +void expectCode(const std::function & f, int code) +{ + try + { + f(); + FAIL() << "expected DB::Exception"; + } + catch (const DB::Exception & e) + { + EXPECT_EQ(e.code(), code); + } +} + +TEST(CASRecoveryGrounding, CreatingAndAbsentCatalogEntriesAreNotRecovered) +{ + expectCode([&] { chooseRecoveryGrounding(catalog(NsState::Creating), ckpt(7, RefTxnId{7, 3})); }, + DB::ErrorCodes::INVALID_STATE); + expectCode([&] { chooseRecoveryGrounding(std::nullopt, ckpt(7, RefTxnId{7, 3})); }, + DB::ErrorCodes::INVALID_STATE); +} + +TEST(CASRecoveryGrounding, LiveAndRemovingRequireCheckpointAndLifeEpoch) +{ + expectCode([&] { chooseRecoveryGrounding(catalog(NsState::Live), std::nullopt); }, + DB::ErrorCodes::CORRUPTED_DATA); + expectCode([&] { chooseRecoveryGrounding(catalog(NsState::Removing), RefCkpt{}); }, + DB::ErrorCodes::CORRUPTED_DATA); +} + +TEST(CASRecoveryGrounding, MissingFrontierMeansNoCommittedTransaction) +{ + const RecoveryGrounding grounding = chooseRecoveryGrounding(catalog(NsState::Live), ckpt(7, std::nullopt)); + EXPECT_FALSE(grounding.base); + EXPECT_FALSE(grounding.committed_through); +} + +TEST(CASRecoveryGrounding, ChoosesCheckpointBaseAndArithmeticWalkStart) +{ + const RecoveryGrounding grounding = chooseRecoveryGrounding( + catalog(NsState::Live), ckpt(7, RefTxnId{7, 8}, RefTxnId{7, 4})); + EXPECT_EQ(grounding.base, (RefTxnId{7, 4})); + EXPECT_EQ(grounding.walk_from, (RefTxnId{7, 5})); + EXPECT_EQ(grounding.committed_through, (RefTxnId{7, 8})); +} + +TEST(CASRecoveryGrounding, BaseAtFrontierStillStartsAtItsExactSuccessor) +{ + /// A writer recovery probes exactly this slot for its sole possible unfrontiered successor. The + /// grounding contract must supply the arithmetic start even when the committed replay tail is empty. + const RecoveryGrounding grounding = chooseRecoveryGrounding( + catalog(NsState::Live), ckpt(7, RefTxnId{7, 8}, RefTxnId{7, 8})); + + EXPECT_EQ(grounding.base, (RefTxnId{7, 8})); + EXPECT_EQ(grounding.walk_from, (RefTxnId{7, 9})); + EXPECT_EQ(grounding.committed_through, (RefTxnId{7, 8})); +} + +TEST(CASRecoveryGrounding, WalksFromLifeEpochWithoutCheckpointBase) +{ + const RecoveryGrounding grounding = chooseRecoveryGrounding(catalog(NsState::Removing), ckpt(9, RefTxnId{9, 3})); + EXPECT_EQ(grounding.walk_from, (RefTxnId{9, 1})); +} + +TEST(CASRecoveryGrounding, RejectsBaseWithoutARepresentableSuccessor) +{ + expectCode([&] + { + chooseRecoveryGrounding(catalog(NsState::Live), + ckpt(7, RefTxnId{8, 1}, RefTxnId{7, std::numeric_limits::max()}, RefTxnId{8, 1})); + }, DB::ErrorCodes::CORRUPTED_DATA); +} + +TEST(CASRecoveryGrounding, RejectsCheckpointFieldsAboveCommittedFrontier) +{ + expectCode([&] + { + chooseRecoveryGrounding(catalog(NsState::Live), ckpt(7, RefTxnId{7, 3}, RefTxnId{7, 4})); + }, DB::ErrorCodes::CORRUPTED_DATA); + expectCode([&] + { + chooseRecoveryGrounding(catalog(NsState::Live), ckpt(7, RefTxnId{7, 3}, std::nullopt, RefTxnId{7, 4})); + }, DB::ErrorCodes::CORRUPTED_DATA); +} + +TEST(CASRecoveryGrounding, RejectsIncoherentEpochBoundaryInCheckpointAuthority) +{ + expectCode([&] + { + chooseRecoveryGrounding(catalog(NsState::Live), ckpt(7, RefTxnId{10, 1}, std::nullopt, RefTxnId{7, 9})); + }, DB::ErrorCodes::CORRUPTED_DATA); + expectCode([&] + { + chooseRecoveryGrounding(catalog(NsState::Live), ckpt(7, RefTxnId{8, 5}, std::nullopt, RefTxnId{8, 1})); + }, DB::ErrorCodes::CORRUPTED_DATA); + expectCode([&] + { + chooseRecoveryGrounding(catalog(NsState::Live), ckpt(7, RefTxnId{8, 1})); + }, DB::ErrorCodes::CORRUPTED_DATA); +} + +/// A life starts in its own writer epoch. Letting it start after the checkpoint's writer epoch makes +/// `walk_from > committed_through`, so recovery silently returns an empty table instead of refusing the +/// impossible authority. The codec and pure grounding entry point must reject the same sabotage. +TEST(CASRecoveryGrounding, RejectsLifeEpochAboveCommittedFrontierOnDecodeAndGrounding) +{ + const RefCkpt invalid = ckpt(2, RefTxnId{1, 5}); + String encoded = encodeRefCkpt(ckpt(1, RefTxnId{1, 5})); + const size_t life_epoch = encoded.find(R"("le":"1")"); + ASSERT_NE(life_epoch, String::npos); + encoded.replace(life_epoch, String{R"("le":"1")"}.size(), R"("le":"2")"); + + expectCode([&] { (void)decodeRefCkpt(encoded); }, DB::ErrorCodes::CORRUPTED_DATA); + expectCode([&] { (void)chooseRecoveryGrounding(catalog(NsState::Live), invalid); }, + DB::ErrorCodes::CORRUPTED_DATA); +} + +TEST(CASRecoveryGrounding, RecoveryIsEquivalentUnderFullEmptyPartialAndReorderedList) +{ + struct Observation + { + std::map committed; + RefTxnId greatest_applied; + std::optional last_epoch_seal; + RefTxnId next_id; + uint64_t log_gets = 0; + uint64_t snapshot_gets = 0; + uint64_t list_calls = 0; + }; + + std::vector observations; + for (const ListingMode mode : {ListingMode::Full, ListingMode::Empty, ListingMode::Partial, ListingMode::Reordered}) + { + auto backend = std::make_shared(mode); + const Layout layout("p"); + const RootNamespace ns{"srv1/list_equivalence"}; + const RefTxnId frontier{2, 1}; + seedAuthoritativeStream(*backend, layout, ns, frontier); + const NamespaceLifeId life = *CasRefCatalog::lifeIfCataloged(*backend, layout, ns); + backend->resetCounts(); + backend->list_calls = 0; + + const RecoveredRefTable recovered = recoverFromCurrentCatalogCut(*backend, layout, ns); + const uint64_t log_gets = backend->getCount(layout.refLogKey(life, {1, 1})) + + backend->getCount(layout.refLogKey(life, {1, 2})) + + backend->getCount(layout.refLogKey(life, {2, 1})); + const uint64_t snapshot_gets = backend->getCount(layout.refSnapshotKey(life, {1, 1})); + observations.push_back(Observation{ + .committed = committedOf(recovered.state), + .greatest_applied = recovered.state.getGreatestApplied(), + .last_epoch_seal = recovered.last_epoch_seal, + .next_id = recovered.state.nextTxnId(/*live_epoch=*/3), + .log_gets = log_gets, + .snapshot_gets = snapshot_gets, + .list_calls = backend->list_calls}); + } + + ASSERT_EQ(observations.size(), 4u); + for (size_t i = 1; i < observations.size(); ++i) + { + EXPECT_EQ(observations[i].committed, observations[0].committed); + EXPECT_EQ(observations[i].greatest_applied, observations[0].greatest_applied); + EXPECT_EQ(observations[i].last_epoch_seal, observations[0].last_epoch_seal); + EXPECT_EQ(observations[i].next_id, observations[0].next_id); + } + for (const Observation & observation : observations) + EXPECT_EQ(observation.log_gets, 3u) + << "recovery must fetch every exact log in the checkpoint-bounded frontier"; + for (const Observation & observation : observations) + EXPECT_EQ(observation.snapshot_gets, 0u) + << "a snapshot not named by `_ckpt` is not a recovery base"; + for (const Observation & observation : observations) + EXPECT_EQ(observation.list_calls, 0u) + << "recovery must not enumerate a stream whose exact checkpoint already supplies its base and frontier"; +} + +TEST(CASRecoveryGrounding, CatalogLifecycleAndCheckpointAreMandatoryForReadOnlyRecovery) +{ + const Layout layout("p"); + const RootNamespace ns{"srv1/mandatory_authority"}; + + { + auto backend = std::make_shared(ListingMode::Full); + DB::Cas::tests::fixture::writeRefLogRaw(*backend, layout, txn(ns, {1, 1}, {namespaceBirthOp()})); + expectCode([&] { (void)recoverFromCurrentCatalogCut(*backend, layout, ns); }, DB::ErrorCodes::CORRUPTED_DATA); + } + { + auto backend = std::make_shared(ListingMode::Full); + CasRefCatalog::casAdmitEntry( + *backend, layout, 1, CatalogEntry{.ns = ns, .state = NsState::Live, .incarnation = 8}); + expectCode([&] { (void)recoverFromCurrentCatalogCut(*backend, layout, ns); }, DB::ErrorCodes::CORRUPTED_DATA); + } + { + auto backend = std::make_shared(ListingMode::Full); + const CatalogEntry live{.ns = ns, .state = NsState::Live, .incarnation = 9}; + CasRefCatalog::casAdmitEntry(*backend, layout, 1, live); + const NamespaceLifeId life = NamespaceLifeId::fromCatalogEntry(live.ns, live.incarnation); + ASSERT_EQ(backend->putIfAbsent(layout.refCkptKey(life), "not a sealed checkpoint").outcome, + PutOutcome::Done); + expectCode([&] { (void)recoverFromCurrentCatalogCut(*backend, layout, ns); }, DB::ErrorCodes::CORRUPTED_DATA); + } + { + auto backend = std::make_shared(ListingMode::Full); + CatalogEntry creating{.ns = ns, .state = NsState::Creating, .incarnation = 7, + .creator = CreatorFence{"srv1", 1, 1}}; + CasRefCatalog::casAdmitEntry(*backend, layout, 1, creating); + backend->putIfAbsent(layout.refCkptKey(NamespaceLifeId::fromCatalogEntry(creating.ns, creating.incarnation)), + encodeRefCkpt(RefCkpt{.life_epoch = 1, .committed_through = RefTxnId{1, 1}, + .checkpoint_snapshot_id = std::nullopt, .last_epoch_seal = std::nullopt})); + expectCode([&] { (void)recoverFromCurrentCatalogCut(*backend, layout, ns); }, DB::ErrorCodes::INVALID_STATE); + } + { + auto backend = std::make_shared(ListingMode::Full); + backend->putIfAbsent(layout.refCkptKey(DB::Cas::tests::fixture::fixtureLife(ns)), + encodeRefCkpt(RefCkpt{.life_epoch = 1, .committed_through = RefTxnId{1, 1}, + .checkpoint_snapshot_id = std::nullopt, .last_epoch_seal = std::nullopt})); + expectCode([&] { (void)recoverFromCurrentCatalogCut(*backend, layout, ns); }, DB::ErrorCodes::INVALID_STATE); + } +} + +TEST(CASRecoveryGrounding, NonrecoverableAuthorityPerformsNoBackendRecoveryIo) +{ + const Layout layout("p"); + const RootNamespace ns{"srv1/nonrecoverable_authority"}; + const RefCkpt valid_ckpt{ + .life_epoch = 1, + .committed_through = std::nullopt, + .checkpoint_snapshot_id = std::nullopt, + .last_epoch_seal = std::nullopt}; + + { + auto backend = std::make_shared(ListingMode::Full); + backend->resetCounts(); + const CatalogEntry creating{ + .ns = ns, .state = NsState::Creating, .incarnation = 1, .creator = CreatorFence{"srv1", 1, 1}}; + expectCode( + [&] { (void)recoverRefTableDetailedFromAuthority(*backend, layout, creating, valid_ckpt); }, + DB::ErrorCodes::INVALID_STATE); + EXPECT_EQ(backend->list_calls, 0u); + EXPECT_EQ(backend->getTotal(), 0u); + } + { + auto backend = std::make_shared(ListingMode::Full); + backend->resetCounts(); + const CatalogEntry live{.ns = ns, .state = NsState::Live, .incarnation = 2}; + expectCode( + [&] { (void)recoverRefTableDetailedFromAuthority(*backend, layout, live, std::nullopt); }, + DB::ErrorCodes::CORRUPTED_DATA); + EXPECT_EQ(backend->list_calls, 0u); + EXPECT_EQ(backend->getTotal(), 0u); + } + { + auto backend = std::make_shared(ListingMode::Full); + backend->resetCounts(); + expectCode( + [&] { (void)recoverRefTableDetailedFromAuthority(*backend, layout, std::nullopt, valid_ckpt); }, + DB::ErrorCodes::INVALID_STATE); + EXPECT_EQ(backend->list_calls, 0u); + EXPECT_EQ(backend->getTotal(), 0u); + } +} + +TEST(CASRecoveryGrounding, ReadOnlyRecoveryNeverAdoptsFPlusOne) +{ + auto backend = std::make_shared(ListingMode::Full); + const Layout layout("p"); + const RootNamespace ns{"srv1/read_only_excludes_f_plus_one"}; + seedAuthoritativeStream(*backend, layout, ns, RefTxnId{1, 1}, /*include_f_plus_one=*/true); + + const RecoveredRefTable recovered = recoverFromCurrentCatalogCut(*backend, layout, ns); + EXPECT_EQ(recovered.state.getGreatestApplied(), (RefTxnId{1, 1})); + EXPECT_TRUE(recovered.state.getCommitted().contains("a")); + EXPECT_FALSE(recovered.state.getCommitted().contains("uncommitted")); +} + +/// A well-formed snapshot can describe a real but uncommitted transaction. If recovery merely treated +/// `LIST` as a performance hint, it could still select this false base and skip the exact first log. +/// The checkpoint names no snapshot, so every listing behaviour must leave the forged object unread. +TEST(CASRecoveryGrounding, ForgedWellFormedListedSnapshotIsUnobservedAndRecoveryDoesNotList) +{ + for (const ListingMode mode : {ListingMode::Full, ListingMode::Empty, ListingMode::Partial, ListingMode::Reordered}) + { + auto backend = std::make_shared(mode); + const Layout layout("p"); + const RootNamespace ns{"srv1/forged_listed_snapshot"}; + seedAuthoritativeStream(*backend, layout, ns, RefTxnId{1, 1}, /*include_f_plus_one=*/true); + const NamespaceLifeId life = *CasRefCatalog::lifeIfCataloged(*backend, layout, ns); + + RefTableState forged_state; + std::vector birth{namespaceBirthOp()}; + const auto first_publish = publishCommittedOps("a", ManifestRef{1, 1, 1}); + birth.insert(birth.end(), first_publish.begin(), first_publish.end()); + applyRefLogTxn(forged_state, txn(ns, {1, 1}, std::move(birth))); + applyRefLogTxn(forged_state, txn(ns, {1, 2}, publishCommittedOps("uncommitted", ManifestRef{1, 2, 1}))); + writeRefSnapshotRaw(*backend, layout, snapshotOf(forged_state, ns.string())); + const String forged_key = layout.refSnapshotKey(life, {1, 2}); + + backend->resetCounts(); + backend->list_calls = 0; + const RecoveredRefTable recovered = recoverFromCurrentCatalogCut(*backend, layout, ns); + + EXPECT_EQ(backend->list_calls, 0u); + EXPECT_EQ(backend->getCount(forged_key), 0u); + EXPECT_EQ(recovered.state.getGreatestApplied(), (RefTxnId{1, 1})); + EXPECT_TRUE(recovered.state.getCommitted().contains("a")); + EXPECT_FALSE(recovered.state.getCommitted().contains("uncommitted")); + } +} + +/// A checkpoint-named snapshot is immutable lifecycle authority, not a list candidate. Its exact GET +/// and semantic decode must therefore fail closed rather than falling back to replaying the same log. +TEST(CASRecoveryGrounding, SemanticallyMalformedCheckpointSnapshotIsCorruptionAfterExactRead) +{ + auto backend = std::make_shared(ListingMode::Empty); + const Layout layout("p"); + const RootNamespace ns{"srv1/semantically_malformed_checkpoint"}; + const ManifestRef manifest{1, 1, 1}; + std::vector ops{namespaceBirthOp()}; + const auto publish = publishCommittedOps("committed", manifest); + ops.insert(ops.end(), publish.begin(), publish.end()); + DB::Cas::tests::fixture::writeRefLogRaw(*backend, layout, txn(ns, {1, 1}, std::move(ops))); + + RefTableSnapshot malformed = minimalLiveSnapshot( + ns.string(), {1, 1}, {DB::Cas::tests::committedRow("committed", manifest)}); + malformed.precommits.push_back(RefOwnerBinding{RefOwnerKind::Precommit, "precommit", manifest}); + writeRefSnapshotRaw(*backend, layout, malformed); + + const NamespaceLifeId life = *CasRefCatalog::lifeIfCataloged(*backend, layout, ns); + const String snapshot_key = layout.refSnapshotKey(life, {1, 1}); + ASSERT_EQ(backend->putIfAbsent(layout.refCkptKey(life), encodeRefCkpt(RefCkpt{ + .life_epoch = 1, + .committed_through = RefTxnId{1, 1}, + .checkpoint_snapshot_id = RefTxnId{1, 1}, + .last_epoch_seal = std::nullopt})).outcome, PutOutcome::Done); + + backend->resetCounts(); + try + { + (void)recoverFromCurrentCatalogCut(*backend, layout, ns); + FAIL() << "expected checkpoint-named malformed snapshot to fail closed"; + } + catch (const DB::Exception & e) + { + EXPECT_EQ(e.code(), DB::ErrorCodes::CORRUPTED_DATA); + EXPECT_NE(String(e.message()).find("stateFromSnapshot"), String::npos); + } + EXPECT_EQ(backend->getCount(snapshot_key), 1u) + << "the corruption must come from the checkpoint snapshot's exact decode"; +} + +TEST(CASRecoveryGrounding, CheckpointSnapshotEqualToLastEpochSealIsRejectedBeforeReadingItsLog) +{ + auto backend = std::make_shared(ListingMode::Full); + const Layout layout("p"); + const RootNamespace ns{"srv1/checkpoint_base_seal"}; + /// The checkpoint directly contradicts itself: its sole snapshot base names its terminal seal. + seedAuthoritativeStream(*backend, layout, ns, RefTxnId{1, 2}); + const NamespaceLifeId life = *CasRefCatalog::lifeIfCataloged(*backend, layout, ns); + + RefTableState through_seal; + std::vector birth{namespaceBirthOp()}; + const auto first_publish = publishCommittedOps("a", ManifestRef{1, 1, 1}); + birth.insert(birth.end(), first_publish.begin(), first_publish.end()); + applyRefLogTxn(through_seal, txn(ns, {1, 1}, std::move(birth))); + RefOp seal; + seal.kind = RefOpKind::EpochSeal; + applyRefLogTxn(through_seal, txn(ns, {1, 2}, {std::move(seal)})); + writeRefSnapshotRaw(*backend, layout, snapshotOf(through_seal, ns.string())); + + const CkptSample before = *readCkpt(*backend, layout, life); + const RefCkpt with_sealed_base{ + .life_epoch = 1, + .committed_through = RefTxnId{1, 2}, + .checkpoint_snapshot_id = RefTxnId{1, 2}, + .last_epoch_seal = RefTxnId{1, 2}}; + ASSERT_EQ(backend->casPut(layout.refCkptKey(life), encodeRefCkpt(with_sealed_base), before.token).outcome, + CasOutcome::Committed); + + backend->resetCounts(); + expectCode([&] { (void)recoverFromCurrentCatalogCut(*backend, layout, ns); }, DB::ErrorCodes::CORRUPTED_DATA); + EXPECT_EQ(backend->getCount(layout.refLogKey(life, {1, 2})), 0u) + << "the contradictory checkpoint metadata is rejected before any matching-log read"; + EXPECT_EQ(backend->getCount(layout.refSnapshotKey(life, {1, 2})), 0u) + << "the seal-kind witness must be checked before reading the forged same-id snapshot"; +} + +/// An `EpochSeal` terminates its numeric epoch. A checkpoint frontier one sequence later in that +/// same epoch is not an empty tail: no record can occupy that slot. Recovery must diagnose the +/// malformed authority instead of advancing to `{E+1,1}` and terminating because that id sorts above +/// the bogus same-epoch frontier. +TEST(CASRecoveryGrounding, SameEpochFrontierAfterDecodedEpochSealIsCorruption) +{ + auto backend = std::make_shared(ListingMode::Full); + const Layout layout("p"); + const RootNamespace ns{"srv1/frontier_after_seal"}; + + DB::Cas::tests::fixture::writeRefLogRaw(*backend, layout, txn(ns, {1, 1}, {namespaceBirthOp()})); + RefOp seal; + seal.kind = RefOpKind::EpochSeal; + DB::Cas::tests::fixture::writeRefLogRaw(*backend, layout, txn(ns, {1, 2}, {std::move(seal)})); + + const NamespaceLifeId life = *CasRefCatalog::lifeIfCataloged(*backend, layout, ns); + String malformed_ckpt = encodeRefCkpt(RefCkpt{ + .life_epoch = 1, + .committed_through = RefTxnId{1, 2}, + .checkpoint_snapshot_id = std::nullopt, + .last_epoch_seal = RefTxnId{1, 2}}); + const size_t frontier_sequence = malformed_ckpt.find(R"("cts":"2")"); + ASSERT_NE(frontier_sequence, String::npos); + malformed_ckpt.replace(frontier_sequence, String{R"("cts":"2")"}.size(), R"("cts":"3")"); + ASSERT_EQ(backend->putIfAbsent(layout.refCkptKey(life), malformed_ckpt).outcome, PutOutcome::Done); + + expectCode([&] { (void)recoverFromCurrentCatalogCut(*backend, layout, ns); }, DB::ErrorCodes::CORRUPTED_DATA); +} + +TEST(CASRecoveryGrounding, OlderCheckpointSnapshotAtSealIsCorruption) +{ + auto backend = std::make_shared(ListingMode::Full); + const Layout layout("p"); + const RootNamespace ns{"srv1/older_checkpoint_base_seal"}; + seedAuthoritativeStream(*backend, layout, ns, RefTxnId{2, 1}); + const NamespaceLifeId life = *CasRefCatalog::lifeIfCataloged(*backend, layout, ns); + + RefOp second_seal; + second_seal.kind = RefOpKind::EpochSeal; + DB::Cas::tests::fixture::writeRefLogRaw(*backend, layout, txn(ns, {2, 2}, {std::move(second_seal)})); + DB::Cas::tests::fixture::writeRefLogRaw(*backend, layout, + txn(ns, {3, 1}, publishCommittedOps("c", ManifestRef{3, 1, 1}), RefTxnId{2, 2})); + + RefTableState through_first_seal; + std::vector birth{namespaceBirthOp()}; + const auto first_publish = publishCommittedOps("a", ManifestRef{1, 1, 1}); + birth.insert(birth.end(), first_publish.begin(), first_publish.end()); + applyRefLogTxn(through_first_seal, txn(ns, {1, 1}, std::move(birth))); + RefOp first_seal; + first_seal.kind = RefOpKind::EpochSeal; + applyRefLogTxn(through_first_seal, txn(ns, {1, 2}, {std::move(first_seal)})); + writeRefSnapshotRaw(*backend, layout, snapshotOf(through_first_seal, ns.string())); + + const CkptSample before = *readCkpt(*backend, layout, life); + const RefCkpt with_old_sealed_base{ + .life_epoch = 1, + .committed_through = RefTxnId{3, 1}, + .checkpoint_snapshot_id = RefTxnId{1, 2}, + .last_epoch_seal = RefTxnId{2, 2}}; + ASSERT_EQ(backend->casPut(layout.refCkptKey(life), encodeRefCkpt(with_old_sealed_base), before.token).outcome, + CasOutcome::Committed); + + backend->resetCounts(); + expectCode([&] { (void)recoverFromCurrentCatalogCut(*backend, layout, ns); }, DB::ErrorCodes::CORRUPTED_DATA); + EXPECT_EQ(backend->getCount(layout.refLogKey(life, {1, 2})), 1u) + << "the old seal differs from `last_epoch_seal`, so only the matching-log proof can reject it"; + EXPECT_EQ(backend->getCount(layout.refSnapshotKey(life, {1, 2})), 0u) + << "the old seal must be rejected before the forged same-id snapshot is read"; +} + +TEST(CASRecoveryGrounding, TerminalGapBelowFrontierIsCorruptionNotARebirth) +{ + auto backend = std::make_shared(ListingMode::Full); + const Layout layout("p"); + const RootNamespace ns{"srv1/terminal_gap"}; + const RefLogTxn birth = txn(ns, {1, 1}, {namespaceBirthOp()}); + DB::Cas::tests::fixture::writeRefLogRaw(*backend, layout, birth); + RefOp remove; + remove.kind = RefOpKind::RemoveNamespace; + DB::Cas::tests::fixture::writeRefLogRaw(*backend, layout, txn(ns, {1, 2}, {std::move(remove)})); + DB::Cas::tests::fixture::writeRefLogRaw(*backend, layout, txn(ns, {2, 1}, {namespaceBirthOp()})); + + const NamespaceLifeId life = *CasRefCatalog::lifeIfCataloged(*backend, layout, ns); + String malformed_ckpt = encodeRefCkpt(RefCkpt{ + .life_epoch = 1, + .committed_through = RefTxnId{1, 1}, + .checkpoint_snapshot_id = std::nullopt, + .last_epoch_seal = std::nullopt}); + const size_t frontier_epoch = malformed_ckpt.find(R"("cte":"1")"); + ASSERT_NE(frontier_epoch, String::npos); + malformed_ckpt.replace(frontier_epoch, String{R"("cte":"1")"}.size(), R"("cte":"2")"); + ASSERT_EQ(backend->putIfAbsent(layout.refCkptKey(life), malformed_ckpt).outcome, PutOutcome::Done); + + expectCode([&] { (void)recoverFromCurrentCatalogCut(*backend, layout, ns); }, DB::ErrorCodes::CORRUPTED_DATA); +} + +TEST(CASRecoveryGrounding, LaterEpochCheckpointBaseRequiresItsContextualBacklink) +{ + auto backend = std::make_shared(ListingMode::Full); + const Layout layout("p"); + const RefTxnId seal_id{1, 2}; + const RefTxnId base_id{2, 1}; + + const auto expect_rejected = [&](const RootNamespace & ns, std::optional backlink) + { + DB::Cas::tests::fixture::writeRefLogRaw(*backend, layout, txn(ns, {1, 1}, {namespaceBirthOp()})); + RefOp seal; + seal.kind = RefOpKind::EpochSeal; + DB::Cas::tests::fixture::writeRefLogRaw(*backend, layout, txn(ns, seal_id, {std::move(seal)})); + DB::Cas::tests::fixture::writeRefLogRaw(*backend, layout, txn(ns, base_id, {}, backlink)); + writeRefSnapshotRaw(*backend, layout, minimalLiveSnapshot(ns.string(), base_id)); + + const NamespaceLifeId life = *CasRefCatalog::lifeIfCataloged(*backend, layout, ns); + ASSERT_EQ(backend->putIfAbsent(layout.refCkptKey(life), encodeRefCkpt(RefCkpt{ + .life_epoch = 1, + .committed_through = base_id, + .checkpoint_snapshot_id = base_id, + .last_epoch_seal = seal_id})).outcome, PutOutcome::Done); + + expectCode( + [&] { (void)recoverFromCurrentCatalogCut(*backend, layout, ns); }, + DB::ErrorCodes::CORRUPTED_DATA); + }; + + expect_rejected(RootNamespace{"srv1/base_missing_backlink"}, std::nullopt); + expect_rejected(RootNamespace{"srv1/base_wrong_backlink"}, RefTxnId{1, 99}); + + const auto expect_predecessor_rejected = [&](const RootNamespace & ns, bool write_ordinary_predecessor) + { + DB::Cas::tests::fixture::writeRefLogRaw(*backend, layout, txn(ns, {1, 1}, {namespaceBirthOp()})); + if (write_ordinary_predecessor) + DB::Cas::tests::fixture::writeRefLogRaw(*backend, layout, txn(ns, seal_id, {})); + DB::Cas::tests::fixture::writeRefLogRaw(*backend, layout, txn(ns, base_id, {}, seal_id)); + writeRefSnapshotRaw(*backend, layout, minimalLiveSnapshot(ns.string(), base_id)); + + const NamespaceLifeId life = *CasRefCatalog::lifeIfCataloged(*backend, layout, ns); + ASSERT_EQ(backend->putIfAbsent(layout.refCkptKey(life), encodeRefCkpt(RefCkpt{ + .life_epoch = 1, + .committed_through = base_id, + .checkpoint_snapshot_id = base_id, + .last_epoch_seal = seal_id})).outcome, PutOutcome::Done); + + expectCode( + [&] { (void)recoverFromCurrentCatalogCut(*backend, layout, ns); }, + DB::ErrorCodes::CORRUPTED_DATA); + }; + + expect_predecessor_rejected(RootNamespace{"srv1/base_predecessor_absent"}, false); + expect_predecessor_rejected(RootNamespace{"srv1/base_predecessor_not_seal"}, true); +} + +} diff --git a/src/Disks/tests/gtest_cas_recovery_streaming.cpp b/src/Disks/tests/gtest_cas_recovery_streaming.cpp new file mode 100644 index 000000000000..ad2dbbcb9342 --- /dev/null +++ b/src/Disks/tests/gtest_cas_recovery_streaming.cpp @@ -0,0 +1,647 @@ +#include + +#include +#include +#include +#include +#include +#include "cas_test_helpers.h" + +#include +#include + +#include +#include +#include +#include +#include + +namespace DB::ErrorCodes +{ +extern const int CORRUPTED_DATA; +extern const int S3_ERROR; +} + +using namespace DB::Cas; +using namespace DB::Cas::tests; + +namespace +{ + +/// A deterministic accountant for the streaming-recovery memory probe: each recovery loop reports +/// `+footprint` while one decoded transaction is resident and `-footprint` once it is discarded, so +/// `peak` is the maximum summed decoded-transaction footprint ever resident at one instant. Streaming +/// holds one transaction; the retired whole-tail materialiser -- and the test-local control that stands +/// in for it -- held the entire tail. Deterministic (it accounts the footprints the probe is handed, a +/// pure function of decoded content, not RSS), so it is stable under ASan quarantine noise. +struct PeakTracker +{ + std::atomic alive_bytes{0}; + std::atomic peak_bytes{0}; + + std::function probe() + { + return [this](int64_t delta) + { + const int64_t now = alive_bytes.fetch_add(delta, std::memory_order_relaxed) + delta; + int64_t prev = peak_bytes.load(std::memory_order_relaxed); + while (now > prev && !peak_bytes.compare_exchange_weak(prev, now, std::memory_order_relaxed)) + { + } + }; + } + + int64_t peak() const { return peak_bytes.load(std::memory_order_relaxed); } + int64_t alive() const { return alive_bytes.load(std::memory_order_relaxed); } +}; + +/// A distinct manifest per call: `build_sequence` carries the identity so every generated +/// `(ref_name, manifest_ref)` add-precommit is a legal transition (no manifest is owned twice). +ManifestRef mref(uint64_t seq) +{ + return ManifestRef{.writer_epoch = 1, .build_sequence = seq, .manifest_ordinal = 1}; +} + +/// One maximum-shaped ref-log transaction: `num_ops` add-precommit ops over distinct +/// `(ref_name, manifest_ref)` pairs (plus a leading `namespace_birth` for the first transaction of a +/// never-born table). Each pair is unique across the whole tail (the running `manifest_seq`), so the +/// tail replays cleanly and the candidate state simply grows -- the point is a large decoded body per +/// transaction, which is what makes the whole-tail vector's resident footprint N times a single +/// transaction's. +RefLogTxn makeBigTxn(const String & ns, RefTxnId id, size_t num_ops, uint64_t & manifest_seq, bool birth) +{ + RefLogTxn txn; + txn.ns = ns; + txn.txn_id = id; + if (birth) + txn.ops.push_back(namespaceBirthOp()); + for (size_t i = 0; i < num_ops; ++i) + { + const String ref_name = "rs_" + std::to_string(id.ref_sequence) + "_" + std::to_string(i); + txn.ops.push_back(ownerTransitionOp( + std::nullopt, RefOwnerBinding{RefOwnerKind::Precommit, ref_name, mref(manifest_seq)})); + ++manifest_seq; + } + return txn; +} + +/// Seed `num_txns` maximum-shaped transactions at ids {1,1}..{1,num_txns} directly into `ns`'s `_log/` +/// stream, and return the resident DECODED footprint (`decodedRefLogTxnFootprint`) of the largest single +/// transaction plus the total across all. The largest single footprint is the streaming peak (one +/// transaction resident at a time); the total is what a whole-tail materialiser holds resident at once. +/// Footprint -- not the compressed stored size -- is the bound's currency: it is what actually sits in +/// memory and what a materialising regression accumulates N-fold, and it is a deterministic function of +/// the decoded content (identical whether computed on the built or the decoded transaction). +struct SeededTail +{ + uint64_t max_single_footprint = 0; + uint64_t total_footprint = 0; +}; + +SeededTail seedBigTail( + InMemoryBackend & backend, const Layout & layout, const RootNamespace & ns, + size_t num_txns, size_t ops_per_txn, uint64_t & manifest_seq) +{ + SeededTail seeded; + for (size_t t = 0; t < num_txns; ++t) + { + const RefLogTxn txn = makeBigTxn(ns.string(), RefTxnId{1, t + 1}, ops_per_txn, manifest_seq, /*birth=*/t == 0); + const uint64_t footprint = decodedRefLogTxnFootprint(txn); + seeded.max_single_footprint = std::max(seeded.max_single_footprint, footprint); + seeded.total_footprint += footprint; + fixture::writeRefLogRaw(backend, layout, txn); + } + /// This helper always builds a recoverable `Live` life. Tests that need the distinct missing- + /// checkpoint corruption shape use the lower-level raw writers directly instead. + writeRecoverableCkptForRawFixture( + backend, layout, ns, + RefCkpt{.life_epoch = 1, .committed_through = RefTxnId{1, static_cast(num_txns)}, + .checkpoint_snapshot_id = std::nullopt, .last_epoch_seal = std::nullopt}); + return seeded; +} + +/// Bounded busy-wait on a test-observable predicate (the established `yield()`-poll idiom for recovery +/// waiters, see `CasPool::refRecoveryWaitersForTest`). Bound is a generous wall-clock ceiling that only +/// trips on a genuine hang, never in the normal fast path; returns false on timeout so the caller can +/// release any blocked threads before asserting. +template +bool pollUntil(Pred pred) +{ + const auto deadline = std::chrono::steady_clock::now() + std::chrono::seconds(30); + while (!pred()) + { + if (std::chrono::steady_clock::now() > deadline) + return false; + std::this_thread::yield(); + } + return true; +} + +/// Backend that drops one selected `_log/` object on its FIRST GET (a concurrent-cleanup vanish), +/// then serves it normally, and counts fresh (cursor-empty) LISTs of the ref prefix so a test can +/// prove a stable checkpoint verdict did not spin on the advisory listing. +class VanishMidTailOnceBackend : public InMemoryBackend +{ +public: + using InMemoryBackend::get; /// keep the one-arg convenience overload visible past our override + + String target_log_key; + String refs_prefix; + std::atomic armed{false}; + std::atomic vanished{false}; + std::atomic fresh_list_count{0}; + + std::optional get(const String & key, Range range) override + { + if (armed.load() && key == target_log_key && !vanished.exchange(true)) + return std::nullopt; /// selected object gone between LIST and GET; recovery must re-LIST + return InMemoryBackend::get(key, range); + } + + ListPage list(const String & prefix, const String & cursor, size_t limit) override + { + if (armed.load() && prefix == refs_prefix && cursor.empty()) + fresh_list_count.fetch_add(1, std::memory_order_relaxed); + return InMemoryBackend::list(prefix, cursor, limit); + } +}; + +/// Backend that replaces one selected `_log/` object's body with a valid-but-foreign ref-log object +/// (a different namespace in the body): decoding it fails with CORRUPTED_DATA (body/key mismatch), the +/// durable-corruption class recovery must fail fast on -- no re-LIST loop. +class CorruptLogOnGetBackend : public InMemoryBackend +{ +public: + using InMemoryBackend::get; /// keep the one-arg convenience overload visible past our override + + String target_log_key; + String corrupt_bytes; + String refs_prefix; + std::atomic armed{false}; + std::atomic refs_list_count{0}; + + std::optional get(const String & key, Range range) override + { + auto got = InMemoryBackend::get(key, range); + if (armed.load() && got && key == target_log_key) + got->bytes = corrupt_bytes; + return got; + } + + ListPage list(const String & prefix, const String & cursor, size_t limit) override + { + if (armed.load() && prefix == refs_prefix && cursor.empty()) + refs_list_count.fetch_add(1, std::memory_order_relaxed); + return InMemoryBackend::list(prefix, cursor, limit); + } +}; + +/// Backend that blocks the first exact log GET while recovery holds no state lock. A concurrent second +/// caller can then reach `recovery_cv`, while the LIST counter proves neither caller enumerates the +/// recovery stream. +class BlockingFirstLogGetBackend : public InMemoryBackend +{ +public: + using InMemoryBackend::get; + + String refs_prefix; + String target_log_key; + std::atomic armed{false}; + std::atomic blocked{false}; + std::atomic list_calls{0}; + std::function on_first_target_get; + + std::optional get(const String & key, Range range) override + { + if (armed.load() && key == target_log_key && !blocked.exchange(true)) + on_first_target_get(); + return InMemoryBackend::get(key, range); + } + + ListPage list(const String & prefix, const String & cursor, size_t limit) override + { + if (armed.load() && prefix == refs_prefix && cursor.empty()) + list_calls.fetch_add(1, std::memory_order_relaxed); + return InMemoryBackend::list(prefix, cursor, limit); + } +}; + +} + +/// Test 14 (load-bearing memory bound): a long tail of maximum-shaped transactions replays under a hard +/// peak bound (twice the largest single transaction's decoded footprint) that a whole-tail materialiser +/// -- which holds every decoded transaction resident at once -- provably exceeds. The bound is computed +/// from the fixture's own footprints and the whole-tail total is asserted to exceed it, so the bound is +/// a property of the fixture, not a lucky constant. Its materialising counterpart, +/// `MaterializingControlExceedsMemoryBound`, trips this same bound. +TEST(CASRecoveryStreaming, LongTailReplaysUnderMemoryBound) +{ + auto backend = std::make_shared(); + const Layout layout("p"); + seedPoolMetaForRestart(*backend); + const RootNamespace ns{"00/aa@cas@"}; + + constexpr size_t kTxns = 24; + constexpr size_t kOpsPerTxn = 250; + uint64_t manifest_seq = 1; + const SeededTail seeded = seedBigTail(*backend, layout, ns, kTxns, kOpsPerTxn, manifest_seq); + + const uint64_t bound = 2 * seeded.max_single_footprint; + ASSERT_GT(seeded.total_footprint, bound) + << "fixture must make the whole tail (" << seeded.total_footprint + << " B) provably exceed the bound (" << bound << " B)"; + + PeakTracker tracker; + setRecoveryReplayMemoryProbeForTest(tracker.probe()); + SCOPE_EXIT({ setRecoveryReplayMemoryProbeForTest({}); }); + + const CasRefCatalog::Snapshot catalog_cut = CasRefCatalog::read(*backend, layout); + const RefTableState state = recoverRefTableDetailedAtCatalogCutForTest(*backend, layout, catalog_cut, ns).state; + EXPECT_EQ(state.getPrecommits().size(), kTxns * kOpsPerTxn) << "the whole tail must have replayed"; + EXPECT_LE(tracker.peak(), static_cast(bound)) + << "streaming recovery must hold at most ~one decoded transaction (peak " << tracker.peak() + << " B) not the whole " << kTxns << "-transaction tail (" << seeded.total_footprint << " B)"; + /// Lower bound (the accountant's fail-close): the peak must reach at least one whole decoded + /// transaction's footprint. This couples the assertion to the production report calls -- delete them + /// and the peak collapses to zero, failing HERE instead of passing vacuously under the upper bound. + EXPECT_GE(tracker.peak(), static_cast(seeded.max_single_footprint)) + << "the probe must observe at least one whole decoded transaction resident (peak " << tracker.peak() + << " B, one transaction " << seeded.max_single_footprint + << " B) -- a zero peak means the production report calls were removed and the bound guards nothing"; + EXPECT_EQ(tracker.alive(), 0) << "every decoded transaction must be discarded after it is applied"; +} + +/// Test 14 (materialising RED control): the discriminating counterpart to the streaming bound above. A +/// control that GETs+decodes the WHOLE tail into a vector BEFORE applying it -- the retired whole-tail +/// shape -- holds every decoded transaction resident at once. Driven through the SAME memory probe as +/// streaming recovery, its peak must EXCEED the same bound the streaming path stays under. This is the +/// regression the memory guard exists to catch, and the guard discriminates precisely because the probe +/// now accounts the caller's whole resident set (each decoded transaction for the span it is held), not +/// one apply in isolation. Under the retired stored-byte-in-`applyOne` probe this control's peak stayed +/// at one transaction (see the RED capture in the round-2 fix report); it now correctly trips. +TEST(CASRecoveryStreaming, MaterializingControlExceedsMemoryBound) +{ + auto backend = std::make_shared(); + const Layout layout("p"); + seedPoolMetaForRestart(*backend); + const RootNamespace ns{"00/aa@cas@"}; + + constexpr size_t kTxns = 24; + constexpr size_t kOpsPerTxn = 250; + uint64_t manifest_seq = 1; + const SeededTail seeded = seedBigTail(*backend, layout, ns, kTxns, kOpsPerTxn, manifest_seq); + + const uint64_t bound = 2 * seeded.max_single_footprint; + ASSERT_GT(seeded.total_footprint, bound) + << "fixture must make the whole tail (" << seeded.total_footprint + << " B) provably exceed the bound (" << bound << " B)"; + + PeakTracker tracker; + setRecoveryReplayMemoryProbeForTest(tracker.probe()); + SCOPE_EXIT({ setRecoveryReplayMemoryProbeForTest({}); }); + + /// Materialise the WHOLE tail first (retired shape): every decoded transaction stays resident in + /// `resident_txns`, and its footprint is reported to the probe up front, released only AFTER the whole + /// tail has been applied -- exactly the memory profile streaming recovery replaced. + std::vector resident_txns; + int64_t held = 0; + for (size_t t = 1; t <= kTxns; ++t) + { + const auto got = backend->get(layout.refLogKey(fixture::fixtureLife(ns), RefTxnId{1, t})); + ASSERT_TRUE(got.has_value()); + RefLogTxn txn = decodeRefLogTxn(openObject(FormatId::RefLog, got->bytes), ns.string(), RefTxnId{1, t}); + const int64_t footprint = static_cast(decodedRefLogTxnFootprint(txn)); + reportReplayMemoryDelta(footprint); + held += footprint; + resident_txns.push_back(std::move(txn)); + } + + RefReplayBuilder builder(std::nullopt); + for (RefLogTxn & txn : resident_txns) + builder.applyOne(std::move(txn), 0); + const RecoveryResult result = std::move(builder).finish(); + EXPECT_EQ(result.state.getPrecommits().size(), kTxns * kOpsPerTxn) << "the whole tail must have applied"; + + EXPECT_GT(tracker.peak(), static_cast(bound)) + << "the materialising control holds the whole tail resident; the probe must exceed the " + "single-transaction bound (peak " << tracker.peak() << " B, bound " << bound << " B)"; + + reportReplayMemoryDelta(-held); /// release the whole tail + EXPECT_EQ(tracker.alive(), 0); +} + +/// Test 14 (vanished-selected-object leg): once the exact `_ckpt` commits a finite frontier, a missing +/// record inside it is not something a fresh LIST may reinterpret as a shorter stream. With the same +/// checkpoint token still durable, recovery fails closed immediately instead of accepting incomplete +/// state or spinning on an advisory enumeration. +TEST(CASRecoveryStreaming, MidTailVanishedObjectFailsClosedAgainstStableAuthority) +{ + auto backend = std::make_shared(); + seedPoolMetaForRestart(*backend); + const Layout layout("p"); + const RootNamespace ns{"00/aa@cas@"}; + + const uint64_t seq1 = publishCommittedTransition(*backend, layout, ns, "a", std::nullopt, mref(1)); + const uint64_t seq2 = publishCommittedTransition(*backend, layout, ns, "b", std::nullopt, mref(2)); + const uint64_t seq3 = publishCommittedTransition(*backend, layout, ns, "c", std::nullopt, mref(3)); + ASSERT_LT(seq1, seq2); + ASSERT_LT(seq2, seq3); + /// Semantic publication already durably advances the exact checkpoint frontier to `seq3`. + + auto store = openPoolForTest(backend); + backend->refs_prefix = layout.namespaceStreamPrefix(fixture::fixtureLife(ns)); + backend->target_log_key = layout.refLogKey(fixture::fixtureLife(ns), RefTxnId{1, seq2}); /// vanish a mid-tail object + backend->armed = true; + + expectThrowsCode(DB::ErrorCodes::CORRUPTED_DATA, [&] { (void)store->listRefs(ns); }); + EXPECT_TRUE(backend->vanished.load()) << "the selected committed object must actually have vanished"; + EXPECT_EQ(backend->fresh_list_count.load(), 0) + << "the exact checkpoint frontier makes recovery stream enumeration unnecessary"; +} + +/// Test 14 (durable-corruption leg): a `_log/` object whose body decodes to a foreign namespace is +/// durable corruption, not a transient vanish -- recovery discards the candidate and fails fast with +/// no re-LIST loop. Asserts the throw, zero restarts, and a single LIST. +TEST(CASRecoveryStreaming, CorruptObjectFailsFast) +{ + auto backend = std::make_shared(); + seedPoolMetaForRestart(*backend); + const Layout layout("p"); + const RootNamespace ns{"00/aa@cas@"}; + + publishCommittedTransition(*backend, layout, ns, "a", std::nullopt, mref(1)); + const uint64_t seq2 = publishCommittedTransition(*backend, layout, ns, "b", std::nullopt, mref(2)); + publishCommittedTransition(*backend, layout, ns, "c", std::nullopt, mref(3)); + /// Semantic publication already durably advances the exact checkpoint frontier. + + /// A structurally valid ref-log object for a DIFFERENT namespace: it decompresses and parses, but + /// its body namespace does not match the key, which `decodeRefLogTxn` rejects as CORRUPTED_DATA. + RefLogTxn foreign; + foreign.ns = "99/zz@cas@"; + foreign.txn_id = RefTxnId{1, seq2}; + foreign.ops = {namespaceBirthOp()}; + backend->corrupt_bytes = sealObject(FormatId::RefLog, encodeRefLogTxn(foreign)); + + auto store = openPoolForTest(backend); + backend->refs_prefix = layout.namespaceStreamPrefix(fixture::fixtureLife(ns)); + backend->target_log_key = layout.refLogKey(fixture::fixtureLife(ns), RefTxnId{1, seq2}); + backend->armed = true; + + expectThrowsCode(DB::ErrorCodes::CORRUPTED_DATA, [&] { store->resolveRef(ns, "a"); }); + /// The exact checkpoint frontier determines this GET. A corrupt committed object fails fast without + /// asking a stream enumeration to reinterpret the durable recovery boundary. + EXPECT_EQ(backend->refs_list_count.load(), 0) << "durable corruption must not trigger recovery LIST"; +} + +/// Test 14 (concurrent-waiter leg): while one caller is blocked in recovery's unlocked exact-log GET, +/// a second caller for the same table parks on `recovery_cv` and is woken exactly once when recovery +/// completes. Neither caller may race an independent stream LIST. +TEST(CASRecoveryStreaming, ConcurrentWaiterUnblockedOnce) +{ + auto backend = std::make_shared(); + seedPoolMetaForRestart(*backend); + const Layout layout("p"); + const RootNamespace ns{"00/aa@cas@"}; + + publishCommittedTransition(*backend, layout, ns, "x", std::nullopt, mref(1)); + publishCommittedTransition(*backend, layout, ns, "y", std::nullopt, mref(2)); + /// Semantic publication already durably advances the exact checkpoint frontier. + + auto store = openPoolForTest(backend); + backend->refs_prefix = layout.namespaceStreamPrefix(fixture::fixtureLife(ns)); + + /// Gate the first exact replay GET. The leader reaches it with `state_mutex` released, which is + /// the window in which a second caller must be able to park on `recovery_cv`. + std::atomic get_entered{false}; + std::promise entered_promise; + std::promise release_promise; + std::shared_future release_future = release_promise.get_future().share(); + backend->target_log_key = layout.refLogKey(fixture::fixtureLife(ns), RefTxnId{1, 1}); + backend->on_first_target_get = [&] + { + if (!get_entered.exchange(true)) + entered_promise.set_value(); + release_future.wait(); + }; + backend->armed = true; + + std::thread t1([&] { store->listRefs(ns); }); /// exact GET blocks with recovery unlocked + entered_promise.get_future().wait(); + + std::thread t2([&] { store->listRefs(ns); }); /// second caller must park on recovery_cv + const bool parked = pollUntil([&] { return store->refRecoveryWaitersForTest(ns) >= 1; }); + + release_promise.set_value(); + t1.join(); + t2.join(); + + EXPECT_TRUE(parked) << "the second caller must reach recovery_cv while the first is in the retry window"; + EXPECT_EQ(store->refRecoveryWaitersForTest(ns), 0u) << "no phantom waiter after recovery completes"; + EXPECT_TRUE(store->resolveRef(ns, "x").has_value()); + EXPECT_TRUE(store->resolveRef(ns, "y").has_value()); + EXPECT_EQ(backend->list_calls.load(), 0) + << "the leader and parked waiter must both recover without stream enumeration"; +} + +/// Test 14 (other materializers leg): the orphan-sweep recovery (`recoverRefTableDetailedFromAuthority`) +/// and fsck's exact-authority recovery stream through the SAME builder and hold under the SAME +/// per-transaction bound as primary recovery. +TEST(CASRecoveryStreaming, OrphanSweepAndFsckSameBound) +{ + auto backend = std::make_shared(); + seedPoolMetaForRestart(*backend); + const Layout layout("p"); + const RootNamespace ns_sweep{"00/sweep@cas@"}; + const RootNamespace ns_fsck{"00/fsck@cas@"}; + + constexpr size_t kTxns = 16; + constexpr size_t kOpsPerTxn = 250; + uint64_t manifest_seq = 1; + const SeededTail sweep_tail = seedBigTail(*backend, layout, ns_sweep, kTxns, kOpsPerTxn, manifest_seq); + const SeededTail fsck_tail = seedBigTail(*backend, layout, ns_fsck, kTxns, kOpsPerTxn, manifest_seq); + + const uint64_t max_single = std::max(sweep_tail.max_single_footprint, fsck_tail.max_single_footprint); + const uint64_t bound = 2 * max_single; + ASSERT_GT(sweep_tail.total_footprint, bound); + ASSERT_GT(fsck_tail.total_footprint, bound); + + auto store = openPoolForTest(backend); + + { + PeakTracker tracker; + setRecoveryReplayMemoryProbeForTest(tracker.probe()); + SCOPE_EXIT({ setRecoveryReplayMemoryProbeForTest({}); }); + const CasRefCatalog::Snapshot sweep_catalog_cut = CasRefCatalog::read(*backend, layout); + const RecoveredRefTable recovered = + recoverRefTableDetailedAtCatalogCutForTest(*backend, layout, sweep_catalog_cut, ns_sweep); + EXPECT_EQ(recovered.state.getPrecommits().size(), kTxns * kOpsPerTxn); + EXPECT_LE(tracker.peak(), static_cast(bound)) + << "orphan-sweep recovery must stream: peak " << tracker.peak() << " B, bound " << bound << " B"; + EXPECT_GE(tracker.peak(), static_cast(sweep_tail.max_single_footprint)) + << "the probe must observe at least one decoded sweep transaction (peak " << tracker.peak() + << " B) -- a zero peak means the orphan-sweep report call was silently removed"; + EXPECT_EQ(tracker.alive(), 0); + } + + { + PeakTracker tracker; + setRecoveryReplayMemoryProbeForTest(tracker.probe()); + SCOPE_EXIT({ setRecoveryReplayMemoryProbeForTest({}); }); + const FsckReport report = runFsck(*store, /*detail=*/true); + EXPECT_TRUE(report.clean()); + EXPECT_LE(tracker.peak(), static_cast(bound)) + << "fsck exact-authority recovery must stream: peak " << tracker.peak() << " B, bound " << bound << " B"; + EXPECT_GE(tracker.peak(), static_cast(fsck_tail.max_single_footprint)) + << "the probe must observe at least one decoded fsck-recovery transaction (peak " << tracker.peak() + << " B) -- a zero peak means fsck stopped recovering catalog-authoritative namespaces"; + EXPECT_EQ(tracker.alive(), 0); + } +} + +/// Test 14 (writer-ledger leg -- the production recovery path): the writer ledger's OWN recovery loop +/// (`CasRefLedger::ensureRefTableRecovered`, reached through any Pool touch) must stream the tail under +/// the SAME per-transaction bound the free recovery does. This is the exact production path the original +/// memory finding named; `LongTailReplaysUnderMemoryBound` above exercises the free authoritative recovery, +/// NOT the ledger loop, so the ledger could regress to whole-tail materialisation while every other +/// bound stayed green. Recovery is driven through the production non-minting namespace-file read path, +/// which does NOT dispatch the stale-precommit sweep `listRefs` would (that sweep +/// would append removals over the seeded epoch-1 precommit bindings and perturb both the count and the +/// probe). The whole tail sits above a never-born base, so the retained tail count equals the whole tail. +TEST(CASRecoveryStreaming, LedgerRecoveryReplaysUnderMemoryBound) +{ + auto backend = std::make_shared(); + seedPoolMetaForRestart(*backend); + const Layout layout("p"); + const RootNamespace ns{"00/ledger@cas@"}; + + constexpr size_t kTxns = 24; + constexpr size_t kOpsPerTxn = 250; + uint64_t manifest_seq = 1; + const SeededTail seeded = seedBigTail(*backend, layout, ns, kTxns, kOpsPerTxn, manifest_seq); + + const uint64_t bound = 2 * seeded.max_single_footprint; + ASSERT_GT(seeded.total_footprint, bound) + << "fixture must make the whole tail (" << seeded.total_footprint + << " B) provably exceed the bound (" << bound << " B)"; + + auto store = openPoolForTest(backend); + + PeakTracker tracker; + setRecoveryReplayMemoryProbeForTest(tracker.probe()); + SCOPE_EXIT({ setRecoveryReplayMemoryProbeForTest({}); }); + + /// The production Task 4b reader drives `CasRefLedger::ensureRefTableRecovered` without the + /// stale-precommit sweep; the resident-only observer then reads the retained tail count. + ASSERT_TRUE(store->namespaceFilesLifeIfReadable(ns)); + EXPECT_EQ(store->tailSinceSnapshotCountForTest(ns), kTxns) + << "the whole tail must have replayed through the ledger's own recovery loop"; + EXPECT_LE(tracker.peak(), static_cast(bound)) + << "ledger recovery must hold at most ~one decoded transaction (peak " << tracker.peak() + << " B) not the whole " << kTxns << "-transaction tail (" << seeded.total_footprint << " B)"; + /// Lower bound (the accountant's fail-close): a zero peak means the ledger loop's production report + /// call was removed and this bound would guard nothing -- the exact silent-decoupling this leg exists + /// to catch on the production path. + EXPECT_GE(tracker.peak(), static_cast(seeded.max_single_footprint)) + << "the probe must observe at least one whole decoded transaction resident (peak " << tracker.peak() + << " B, one transaction " << seeded.max_single_footprint + << " B) -- a zero peak means the ledger's production report call was removed and the bound guards nothing"; + EXPECT_EQ(tracker.alive(), 0) << "every decoded transaction must be discarded after it is applied"; +} + +/// Test 15 (publication inventory): after streaming recovery of a table with a non-trivial snapshot +/// base, precommit bindings, and a tail of committed transactions, EVERY field the +/// recovery publication seeds is asserted -- not just the two a prose inventory would keep. This is a +/// regression guard: streaming recovery must install exactly what the whole-tail recovery installed. +TEST(CASRecoveryStreaming, RecoveryResultInventoryComplete) +{ + auto backend = std::make_shared(); + seedPoolMetaForRestart(*backend); + const Layout layout("p"); + const RootNamespace ns{"00/inv@cas@"}; + + /// A non-trivial base snapshot: two committed rows plus a stale predecessor precommit binding. + RefTableSnapshot base; + base.ns = ns.string(); + base.snapshot_id = RefTxnId{1, 5}; + base.committed = {committedRow("c_one", mref(11)), committedRow("c_two", mref(12))}; + base.precommits = {RefOwnerBinding{RefOwnerKind::Precommit, "p_stale", mref(13)}}; + RefLogTxn base_txn; + base_txn.ns = ns.string(); + base_txn.txn_id = base.snapshot_id; + base_txn.ops = publishCommittedOps("c_two", mref(12)); + fixture::writeRefLogRaw(*backend, layout, base_txn); + writeRefSnapshotRaw(*backend, layout, base); + const auto base_got = backend->get(layout.refSnapshotKey(fixture::fixtureLife(ns), base.snapshot_id)); + ASSERT_TRUE(base_got.has_value()); + const uint64_t base_stored_bytes = base_got->bytes.size(); + + /// Two committed transactions strictly above the base -- the tail. + RefLogTxn t6; + t6.ns = ns.string(); + t6.txn_id = RefTxnId{1, 6}; + t6.ops = publishCommittedOps("c_three", mref(21)); + fixture::writeRefLogRaw(*backend, layout, t6); + RefLogTxn t7; + t7.ns = ns.string(); + t7.txn_id = RefTxnId{1, 7}; + t7.ops = publishCommittedOps("c_four", mref(22)); + fixture::writeRefLogRaw(*backend, layout, t7); + + writeRecoverableCkptForRawFixture( + *backend, layout, ns, RefCkpt{.life_epoch = 1, .committed_through = RefTxnId{1, 7}, + .checkpoint_snapshot_id = RefTxnId{1, 5}, + .last_epoch_seal = std::nullopt}); + + const uint64_t tail6 = backend->get(layout.refLogKey(fixture::fixtureLife(ns), RefTxnId{1, 6}))->bytes.size(); + const uint64_t tail7 = backend->get(layout.refLogKey(fixture::fixtureLife(ns), RefTxnId{1, 7}))->bytes.size(); + + backend->resetCounts(); + auto store = openPoolForTest(backend); + + /// Drive recovery via the production namespace-file reader WITHOUT the stale-precommit sweep that + /// `resolveRef`/`listRefs` dispatch (that + /// sweep would clear `needs_stale_precommit_sweep` before it could be observed). Every inventory + /// field below is then read straight off the seeded runtime, and the read-path state assertion is + /// left for LAST (after `needs_stale_precommit_sweep` has been observed). + + ASSERT_TRUE(store->namespaceFilesLifeIfReadable(ns)); + const NamespaceLifeId life = fixture::fixtureLife(ns); + EXPECT_EQ(backend->getCount(layout.refLogKey(life, base.snapshot_id)), 1u) + << "recovery must validate the selected base's matching ordinary log"; + EXPECT_EQ(backend->getCount(layout.refSnapshotKey(life, base.snapshot_id)), 1u) + << "the inventory must come from the selected snapshot, not a pre-snapshot failure"; + + /// newest snapshot identity: the recovered base id, no seal on this clean mount. + EXPECT_EQ(store->newestPublishedSnapshotIdForTest(ns), std::optional(base.snapshot_id)); + + /// last_epoch_seal: this mount's live epoch is the one the seeded stream was written in, so the + /// CAS-walk crossed no epoch transition and installed no chain link. Pins that the field IS part of + /// the published inventory (its non-empty counterpart lives in + /// `CASRefRecoveryCasWalk.DeadEpochIsClosedByOurOwnSealAtTPlusOne`). + EXPECT_EQ(store->lastEpochSealForTest(ns), std::nullopt); + + /// stale-precommit sweep: recovery always arms it (asserted BEFORE any read-side sweep runs). + EXPECT_TRUE(store->needsStalePrecommitSweepForTest(ns)); + + /// tail count / bytes: exactly the two transactions above the base and their stored sizes. + EXPECT_EQ(store->tailSinceSnapshotCountForTest(ns), 2u); + EXPECT_EQ(store->refTailBytesSinceSnapshotForTest(ns), tail6 + tail7); + + /// base snapshot bytes: the encoded body size of the recovered base snapshot. + EXPECT_EQ(store->refBaseSnapshotBytesForTest(ns), base_stored_bytes); + + /// admission budgets: the raw hard limits minus this table's wire overhead and the safety margin. + const uint64_t overhead = 4 + ns.string().size() + 4096; + const uint64_t expected_budget = 64ULL * 1024 * 1024 - overhead; + EXPECT_EQ(store->refSnapshotBudgetForTest(ns), expected_budget); + EXPECT_EQ(store->refRemovalBudgetForTest(ns), expected_budget); + + /// state: four committed rows (two from the base, two from the tail). This read dispatches the + /// read-side sweep, hence it comes last -- after the sweep flag has been observed above. + const auto refs = store->listRefs(ns); + EXPECT_EQ(refs.size(), 4u); + EXPECT_TRUE(store->resolveRef(ns, "c_one").has_value()); + EXPECT_TRUE(store->resolveRef(ns, "c_two").has_value()); + EXPECT_TRUE(store->resolveRef(ns, "c_three").has_value()); + EXPECT_TRUE(store->resolveRef(ns, "c_four").has_value()); +} diff --git a/src/Disks/tests/gtest_cas_ref_carve.cpp b/src/Disks/tests/gtest_cas_ref_carve.cpp new file mode 100644 index 000000000000..e12706d64f69 --- /dev/null +++ b/src/Disks/tests/gtest_cas_ref_carve.cpp @@ -0,0 +1,366 @@ +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +/// Task 7 (stage-1 §2): the ref-flush two-phase carve and the validation loop's publish ordering. +/// +/// Two exception-safety windows are pinned here, both driven through the `setCarveHookForTest` fault +/// seam (which fires `std::bad_alloc` at named carve/validation phase points): +/// +/// - The carve must PLAN (scan `pending` without popping; build the selection and every reservation) +/// and only then PUBLISH (pop + append under the same continuous `ref_queue_mutex` hold, using only +/// non-throwing moves/copies). A throw anywhere in the plan must leave the queue byte-for-byte intact +/// so no already-selected item is stranded (removed from `pending` yet never completed) and no waiter +/// hangs. The pre-fix carve interleaved pops with the allocating `seen_refs`/`batch` growth, so a +/// throw after the first pop stranded popped items and hung their waiters forever — the behavioural +/// signature this suite demonstrates. +/// - The per-item validation loop must reserve `final_ops`/`survivors` growth BEFORE applying the item +/// to `working`, and publish only past all throwing points. The pre-fix loop moved `working` before +/// those allocations, so a failure there left a failed item's effects in `working` and — when the +/// throw fell between the two accumulator writes — its ops already in the durably-committed +/// transaction while its own caller was told the append failed. +/// +/// The suite name is prefixed `RefWriter` so it is covered by the `RefWriter*` unit-test gate filter. + +using namespace DB::Cas; +using Phase = CasRefLedger::CarvePhaseForTest; + +namespace +{ + +PoolPtr openPool(const BackendPtr & backend) +{ + /// A fresh pool with no residue: `seedPoolMetaForRestart` is idempotent and a no-op here (mirrors the + /// ref-writer suite's `openPool`), it just lets `beginPartWrite` bootstrap over a valid `_pool_meta`. + DB::Cas::tests::seedPoolMetaForRestart(*backend); + return Pool::open(backend, PoolConfig{.pool_prefix = "p", .server_root_id = "test"}); +} + +/// A legal blob-free part: stage an empty manifest, precommit, promote — enough to leave one committed +/// ref in `ns` that a later `dropRef` can co-batch. Mirrors the ref-writer suite's `publishEmptyPart`. +/// +/// Stage B (Task 4-C): pin `ns` to the sentinel before the first real touch, mirroring the same fix in +/// `gtest_cas_ref_writer.cpp`'s `startBuildFor` and `gtest_cas_ref_chunked_flush.cpp`'s +/// `publishEmptyPart` -- every test in this file births its namespace here before any fault +/// injection/verification that separately computes a key via `DB::Cas::tests::fixture::fixtureLife(ns)`. +void publishEmptyPart(const PoolPtr & s, const RootNamespace & ns, const String & ref) +{ + DB::Cas::tests::casAdmitRecoverableEntry(s->backend(), s->layout(), ns, s->liveWriterEpoch()); + PartWriteInfo info; + info.intended_namespace = ns; + info.intended_ref = ns.string() + "/" + ref; + auto build = s->beginPartWrite(info); + const ManifestId id = build->stageManifest({}); + build->precommitAdd(ns, ref, id); + build->promote(ns, ref, build->buildId(), id); +} + +/// Shared, heap-backed synchronisation state for one case. Heap-backing (captured by `shared_ptr` into +/// the hooks and caller threads) is what makes a leaked/detached hung thread safe on the RED path: the +/// thread keeps its own references alive, so nothing it touches is destroyed underneath it. +struct CaseSync +{ + std::mutex m; + std::condition_variable cv; + bool entered = false; /// guarded by m: the first flush reached the pre-carve hook + /// Per-`CarvePhaseForTest` invocation counter, indexed by `static_cast(phase)`. Sized off the + /// enum's last enumerator rather than a literal: the array was already undersized once (it predates + /// `ChunkReseed`; `PostDurableInstall` and `PostInstallPreAck` followed), and it is out of bounds only + /// because every call site happens to filter to a lower-numbered phase first -- a trap for the next + /// phase added. + std::atomic phase_hits[static_cast(CasRefLedger::CarvePhaseForTest::PostInstallPreAck) + 1] = {}; +}; + +/// The newest `_log/` transaction currently present for `ns`, decoded from the backend directly (no Pool +/// cache). Used to inspect exactly what a flush durably committed. +std::optional newestLogTxn(DB::Cas::Backend & backend, const DB::Cas::Layout & layout, const RootNamespace & ns) +{ + std::optional newest; + String cursor; + for (;;) + { + const ListPage page = backend.list(layout.namespaceStreamPrefix(DB::Cas::tests::fixture::fixtureLife(ns)), cursor, 1000); + for (const ListedKey & lk : page.keys) + { + const auto parsed = layout.parseRefObjectKey(lk.key); + if (parsed && parsed->life_id == DB::Cas::tests::fixture::fixtureLife(ns).incarnation + && parsed->kind == RefObjectKind::Log + && (!newest || *newest < parsed->txn_id)) + newest = parsed->txn_id; + } + if (page.next_cursor.empty()) + break; + cursor = page.next_cursor; + } + if (!newest) + return std::nullopt; + const auto got = backend.get(layout.refLogKey(DB::Cas::tests::fixture::fixtureLife(ns), *newest)); + if (!got) + return std::nullopt; + return decodeRefLogTxn(openObject(FormatId::RefLog, got->bytes), ns.string(), *newest); +} + +/// Counts `OwnerTransition` removal ops (old binding present, no new binding) naming `ref_name` across +/// EVERY committed `_log/` transaction of `ns`. +size_t committedRemovalCountForRef(DB::Cas::Backend & backend, const DB::Cas::Layout & layout, + const RootNamespace & ns, const String & ref_name) +{ + size_t count = 0; + String cursor; + for (;;) + { + const ListPage page = backend.list(layout.namespaceStreamPrefix(DB::Cas::tests::fixture::fixtureLife(ns)), cursor, 1000); + for (const ListedKey & lk : page.keys) + { + const auto parsed = layout.parseRefObjectKey(lk.key); + if (!parsed || parsed->life_id != DB::Cas::tests::fixture::fixtureLife(ns).incarnation + || parsed->kind != RefObjectKind::Log) + continue; + const auto got = backend.get(layout.refLogKey(DB::Cas::tests::fixture::fixtureLife(ns), parsed->txn_id)); + if (!got) + continue; + const RefLogTxn txn = decodeRefLogTxn(openObject(FormatId::RefLog, got->bytes), ns.string(), parsed->txn_id); + for (const RefOp & op : txn.ops) + if (op.kind == RefOpKind::OwnerTransition && op.old_binding.has_value() + && !op.new_binding.has_value() && op.old_binding->ref_name == ref_name) + ++count; + } + if (page.next_cursor.empty()) + break; + cursor = page.next_cursor; + } + return count; +} + +/// One queued append driven on its own thread, with a future that becomes ready only when the caller's +/// `dropRef` RETURNS (normally or by throwing). A caller whose item was stranded never returns, so its +/// future stays not-ready — a bounded `wait_for` on it is the hung-waiter detector. +struct Caller +{ + std::thread t; + std::future fut; + String ref; +}; + +Caller launchDrop(const PoolPtr & store, const RootNamespace & ns, const String & ref) +{ + auto prom = std::make_shared>(); + std::future fut = prom->get_future(); + std::thread t([store, ns, ref, prom] + { + std::exception_ptr err; + try { store->dropRef(ns, ref); } + catch (...) { err = std::current_exception(); } + prom->set_value(err); + }); + return Caller{std::move(t), std::move(fut), ref}; +} + +/// Stages three compatible drops (leader "a" plus followers "b","c") into one carve, injects +/// `std::bad_alloc` at `target_phase` on its `target_ordinal`-th firing, then asserts that no caller +/// hangs and the queue drains. Returns true iff a caller hung (the stranded-item signature). +/// +/// On the fixed (two-phase) carve, a plan-phase throw pops nothing: the leader's own item is failed by +/// the leadership-exit guard and the untouched followers commit on the next leader's retry. On the +/// pre-fix interleaved carve, the same throw strands already-popped followers, whose waiters hang. +bool runPlanPointCase(Phase target_phase, int target_ordinal, const char * label) +{ + auto backend = std::make_shared(); + auto store = openPool(backend); + const RootNamespace ns{String("srv1/carve_") + label}; + publishEmptyPart(store, ns, "a"); + publishEmptyPart(store, ns, "b"); + publishEmptyPart(store, ns, "c"); + + auto sync = std::make_shared(); + /// Block the first flush's leader in the pre-carve window until all three items are queued, forcing a + /// deterministic three-item batch. Heap-backed captures (see `CaseSync`) keep this safe even if a + /// stranded follower later spins through it on the RED path. + store->setRefPreCarveHookForTest([sync, store, ns] + { + std::unique_lock lk(sync->m); + if (sync->entered) + return; /// only the first carve blocks; retries proceed straight through + sync->entered = true; + sync->cv.notify_all(); + /// Bounded (10s) so a staging bug bounds the wait instead of blocking the whole suite; the + /// predicate is normally satisfied well before the deadline. + sync->cv.wait_for(lk, std::chrono::seconds(10), [&] { return store->refQueuePendingForTest(ns) >= 3; }); + }); + store->setCarveHookForTest([sync, target_phase, target_ordinal](Phase ph) + { + if (ph != target_phase) + return; + if (sync->phase_hits[static_cast(ph)].fetch_add(1) + 1 == target_ordinal) + throw std::bad_alloc{}; + }); + + Caller ca = launchDrop(store, ns, "a"); + { + std::unique_lock lk(sync->m); + sync->cv.wait_for(lk, std::chrono::seconds(10), [&] { return sync->entered; }); + } + Caller cb = launchDrop(store, ns, "b"); + Caller cc = launchDrop(store, ns, "c"); + { + const auto deadline = std::chrono::steady_clock::now() + std::chrono::seconds(10); + while (store->refQueuePendingForTest(ns) < 3 && std::chrono::steady_clock::now() < deadline) + std::this_thread::yield(); + } + sync->cv.notify_all(); /// release the pre-carve hook now its (>=3 pending) predicate holds + + bool hung = false; + std::vector callers = {&ca, &cb, &cc}; + for (Caller * c : callers) + { + /// Bounded (5s): a stranded item never completes, so this is where the hang surfaces. + if (c->fut.wait_for(std::chrono::seconds(5)) != std::future_status::ready) + { + hung = true; + EXPECT_TRUE(false) << label << ": caller for ref '" << c->ref + << "' never returned within 5s — its item was stranded (removed from " + "pending, never completed) and the waiter hung"; + /// Cannot join a permanently-hung thread. Detach it; its heap-backed captures (including a + /// `store` copy) keep everything it touches alive until the process exits. Leave the hooks + /// installed — clearing them here would race the detached thread's `std::function` read. + c->t.detach(); + } + else + { + c->t.join(); + } + } + if (hung) + return true; + + /// GREEN: everything joined, so clearing the hooks now cannot race any live flush. + store->setRefPreCarveHookForTest(nullptr); + store->setCarveHookForTest(nullptr); + EXPECT_EQ(store->refQueuePendingForTest(ns), 0u) << label << ": the append queue did not fully drain"; + /// The leader ("a") is the item whose plan threw; the guard fails it, so its drop must NOT commit. + EXPECT_TRUE(store->resolveRef(ns, "a").has_value()) + << label << ": the failed leader's drop must not have committed"; + /// The untouched followers commit on retry. + EXPECT_FALSE(store->resolveRef(ns, "b").has_value()) << label << ": survivor 'b' must have been dropped"; + EXPECT_FALSE(store->resolveRef(ns, "c").has_value()) << label << ": survivor 'c' must have been dropped"; + return false; +} + +} + +/// Test 7: a throw at any plan-phase point of the carve must leave the queue intact and hang no waiter. +/// RED on the pre-fix interleaved carve (a plan-point throw after the first pop strands followers whose +/// waiters then hang, tripping the 5s bounded wait); GREEN on the two-phase carve. +TEST(CASRefWriterCarve, CarveThrowLeavesQueueIntact) +{ + /// `PlanSeenRefs`/`PlanBatchGrow` fire once per scanned item; injecting on the third firing strands a + /// popped follower under the old carve. `PlanReserveOwned` fires once, after the whole selection is + /// (under the old carve) already popped, stranding every follower. `ASSERT_FALSE` stops at the first + /// demonstrated hang so at most one case leaks a detached thread on the RED path. + ASSERT_FALSE(runPlanPointCase(Phase::PlanSeenRefs, 3, "PlanSeenRefs")); + ASSERT_FALSE(runPlanPointCase(Phase::PlanBatchGrow, 3, "PlanBatchGrow")); + ASSERT_FALSE(runPlanPointCase(Phase::PlanReserveOwned, 1, "PlanReserveOwned")); +} + +/// Test 8: an allocation failure at the per-item accumulation point must leave the failed item's effects +/// out of BOTH `working` (the in-memory committed state) and the durable transaction. RED on the pre-fix +/// loop (which moved `working` and appended `final_ops` before the throwing point, so the failed drop +/// committed while its caller was told it failed); GREEN on the reserve-before-publish loop. +TEST(CASRefWriterCarve, ValidationAllocFailureLeavesWorkingClean) +{ + auto backend = std::make_shared(); + auto store = openPool(backend); + const DB::Cas::Layout & layout = store->layout(); + const RootNamespace ns{"srv1/carve_validate"}; + publishEmptyPart(store, ns, "x"); + publishEmptyPart(store, ns, "y"); + ASSERT_TRUE(store->resolveRef(ns, "x").has_value()); + ASSERT_TRUE(store->resolveRef(ns, "y").has_value()); + + auto sync = std::make_shared(); + store->setRefPreCarveHookForTest([sync, store, ns] + { + std::unique_lock lk(sync->m); + if (sync->entered) + return; + sync->entered = true; + sync->cv.notify_all(); + sync->cv.wait_for(lk, std::chrono::seconds(10), [&] { return store->refQueuePendingForTest(ns) >= 2; }); + }); + /// Fail the FIRST admitted item (the leader's own drop of "x") at its accumulation point. + store->setCarveHookForTest([sync](Phase ph) + { + if (ph != Phase::ValidateFinalOps) + return; + if (sync->phase_hits[static_cast(ph)].fetch_add(1) + 1 == 1) + throw std::bad_alloc{}; + }); + + Caller cx = launchDrop(store, ns, "x"); + { + std::unique_lock lk(sync->m); + sync->cv.wait_for(lk, std::chrono::seconds(10), [&] { return sync->entered; }); + } + Caller cy = launchDrop(store, ns, "y"); + { + const auto deadline = std::chrono::steady_clock::now() + std::chrono::seconds(10); + while (store->refQueuePendingForTest(ns) < 2 && std::chrono::steady_clock::now() < deadline) + std::this_thread::yield(); + } + sync->cv.notify_all(); + + ASSERT_EQ(cx.fut.wait_for(std::chrono::seconds(10)), std::future_status::ready) << "drop x must not hang"; + ASSERT_EQ(cy.fut.wait_for(std::chrono::seconds(10)), std::future_status::ready) << "drop y must not hang"; + const std::exception_ptr x_err = cx.fut.get(); + const std::exception_ptr y_err = cy.fut.get(); + cx.t.join(); + cy.t.join(); + store->setRefPreCarveHookForTest(nullptr); + store->setCarveHookForTest(nullptr); + + /// The injected item's own caller was told the append failed. + ASSERT_TRUE(x_err != nullptr) << "drop x's caller must observe the injected allocation failure"; + /// The co-batched survivor committed cleanly. + EXPECT_TRUE(y_err == nullptr) << "the co-batched survivor drop y must commit"; + + /// (1) `working`/committed state stays clean: x's drop, whose caller failed, must NOT have taken + /// effect — x remains resolvable. + EXPECT_TRUE(store->resolveRef(ns, "x").has_value()) + << "the failed item's drop leaked into the committed state — `working` was not kept clean"; + EXPECT_FALSE(store->resolveRef(ns, "y").has_value()) << "survivor drop y must be committed"; + + /// (2) Decode the committed object: no committed ref-log transaction may carry x's removal op, and + /// exactly the survivor's removal must be present. + EXPECT_EQ(committedRemovalCountForRef(*backend, layout, ns, "x"), 0u) + << "the failed item's removal op leaked into a durably-committed ref-log object"; + EXPECT_EQ(committedRemovalCountForRef(*backend, layout, ns, "y"), 1u) + << "the survivor's removal op must be present in exactly one committed ref-log object"; + + const auto newest = newestLogTxn(*backend, layout, ns); + ASSERT_TRUE(newest.has_value()); + for (const RefOp & op : newest->ops) + if (op.kind == RefOpKind::OwnerTransition && op.old_binding.has_value() && !op.new_binding.has_value()) + EXPECT_NE(op.old_binding->ref_name, String("x")) + << "the newest committed transaction must not contain the failed item's removal"; +} diff --git a/src/Disks/tests/gtest_cas_ref_catalog.cpp b/src/Disks/tests/gtest_cas_ref_catalog.cpp new file mode 100644 index 000000000000..4a12eccb4a31 --- /dev/null +++ b/src/Disks/tests/gtest_cas_ref_catalog.cpp @@ -0,0 +1,1717 @@ +#include "cas_format_test_battery.h" +#include "cas_test_helpers.h" +#include +#include +#include +#include +/// Explicit rather than relying on a transitive path: `DEBUG_OR_SANITIZER_BUILD` (used below to gate +/// the `*DeathTest` split) must resolve in THIS translation unit. +#include +#include +#include +#include +#include +#include +#include +#include +#include + +using namespace DB::Cas; + +namespace ProfileEvents +{ + extern const Event CASGCUnmatchedAdoptedParentLives; + extern const Event CASGCStuckRemovals; +} + +namespace DB::Cas::tests +{ + +/// This friend-only compile pin is derived from the actual private production member pointers. It +/// fails if a raw round carrier becomes separately pairable with `fold`. +class GcRoundPlanSignatureAccess +{ +public: + using FoldSignature = decltype(&Gc::fold); + using ExpectedFoldSignature = Gc::FoldResult (Gc::*)( + GcState &, Token &, RoundReport &, uint64_t, const RefPlan &, UniversePolicy, GcRoundWorkBudget &); + using BuilderSignature = decltype(&buildRefWalkPlan); + using ExpectedBuilderSignature = RefPlan (*)(RoundInput &&); + + static_assert(std::is_same_v); + static_assert(std::is_same_v); +}; + +} + +namespace DB::ErrorCodes +{ + extern const int CORRUPTED_DATA; + extern const int LOGICAL_ERROR; + extern const int LIMIT_EXCEEDED; + extern const int NETWORK_ERROR; + extern const int BAD_ARGUMENTS; +} + +namespace +{ + +/// Hand-builds one raw "ent" line, bypassing `encodeRefCatalog` entirely -- used by the decode-side +/// rejection tests, which must exercise bytes the encoder itself would refuse to produce. +String rawEntLine(const String & ns, const String & state, const String & inc_hex, + std::optional> creator = std::nullopt) +{ + if (!creator) + return fmt::format(R"({{"k":"ent","ns":"{}","st":"{}","inc":"{}"}})", ns, state, inc_hex); + const auto & [srid, we, fg] = *creator; + return fmt::format(R"({{"k":"ent","ns":"{}","st":"{}","inc":"{}","csr":"{}","cwe":"{}","cfg":"{}"}})", + ns, state, inc_hex, srid, we, fg); +} + +/// Wraps `ent_lines` in the header/trailer a real `cas_ref_catalog` object carries. `v:1` always +/// passes the header gate (any version <= the build's `G_BUILD` does), matching the convention +/// `gtest_cas_fold_seal_format.cpp`'s `RejectsOutOfRangeNsCleanupState` uses for the same reason. +String rawCatalog(const std::vector & ent_lines) +{ + String out = R"({"type":"cas_ref_catalog","v":1})" "\n"; + for (const String & l : ent_lines) + out += l + "\n"; + out += fmt::format("{{\"n\":{}}}\n", ent_lines.size()); + return out; +} + +String withRemovalStartedRound(String line, uint64_t round) +{ + const size_t close = line.rfind('}'); + EXPECT_NE(close, String::npos); + line.insert(close, fmt::format(R"(,"rsr":"{}")", round)); + return line; +} + +CatalogEntry liveEntry(const String & ns, uint64_t inc) +{ + return CatalogEntry{.ns = RootNamespace{ns}, .state = NsState::Live, .incarnation = UInt128(inc)}; +} + +CatalogEntry entryInState(const String & ns, NsState state, uint64_t inc) +{ + CatalogEntry entry{.ns = RootNamespace{ns}, .state = state, .incarnation = UInt128(inc)}; + if (state == NsState::Creating) + entry.creator = CreatorFence{.server_root_id = "srv", .writer_epoch = 1, .fence_generation = 1}; + if (state == NsState::Removing) + entry.removal_started_round = 1; + return entry; +} + +class EraseWinnerBackend final : public DB::Cas::tests::CountingBackend +{ +public: + using CountingBackend::casPut; + using CountingBackend::get; + + void replaceOnNextCatalogCas(const String & key, std::optional replacement_) + { + catalog_key = key; + replacement = std::move(replacement_); + armed = true; + } + + bool fenceMoved() const { return fence_moved; } + + CasResult casPut( + const String & key, const String & bytes, const std::optional & expected, + const ObjectMeta & meta) override + { + if (armed && key == catalog_key) + { + armed = false; + const auto current = CountingBackend::get(key); + if (!current) + throw std::runtime_error("test fixture lost mandatory catalog"); + RefCatalog winner_catalog; + if (replacement) + winner_catalog.entries.push_back(*replacement); + const CasResult winner = CountingBackend::casPut( + key, encodeRefCatalog(winner_catalog), current->token, meta); + if (winner.outcome != CasOutcome::Committed) + throw std::runtime_error("test fixture winner failed to replace catalog"); + fence_moved = true; + } + return CountingBackend::casPut(key, bytes, expected, meta); + } + +private: + String catalog_key; + std::optional replacement; + bool armed = false; + bool fence_moved = false; +}; + +class CasPutThrowsOnceBackend final : public DB::Cas::tests::CountingBackend +{ +public: + using CountingBackend::casPut; + + void armCasPutThrow(const String & key) + { + throw_key = key; + armed = true; + } + + CasResult casPut( + const String & key, const String & bytes, const std::optional & expected, + const ObjectMeta & meta) override + { + if (armed && key == throw_key) + { + armed = false; + throw DB::Exception(DB::ErrorCodes::NETWORK_ERROR, + "injected casPut failure during completed-removal erase"); + } + return CountingBackend::casPut(key, bytes, expected, meta); + } + +private: + String throw_key; + bool armed = false; +}; + +class ScopedCasGcLogCapture +{ +public: + ScopedCasGcLogCapture() + : logger(getLogger("CasGc")) + , channel(new Poco::StreamChannel(stream)) + , old_channel(logger->getChannel()) + , old_level(logger->getLevel()) + { + logger->setChannel(channel.get()); + logger->setLevel("warning"); + } + + ~ScopedCasGcLogCapture() + { + logger->setChannel(old_channel); + logger->setLevel(old_level); + } + + String captured() const { return stream.str(); } + +private: + LoggerPtr logger; + std::ostringstream stream; // STYLE_CHECK_ALLOW_STD_STRING_STREAM + Poco::AutoPtr channel; + Poco::Channel * old_channel; + int old_level; +}; + +} + +/// ---------- format-battery registration ---------- + +TEST(CASFormatBattery, RefCatalog) +{ + RefCatalog c; + c.entries.push_back(CatalogEntry{.ns = RootNamespace{"a"}, .state = NsState::Creating, + .incarnation = UInt128(1), + .creator = CreatorFence{.server_root_id = "srv1", .writer_epoch = 5, .fence_generation = 2}}); + c.entries.push_back(liveEntry("b", 2)); + runFormatBattery({FormatId::RefCatalog, + [&] { return sealObject(FormatId::RefCatalog, encodeRefCatalog(c)); }, + [](std::string_view s) { decodeRefCatalog(std::string(openObject(FormatId::RefCatalog, s))); }, + currentFormatHeader("cas_ref_catalog") + + "{\"k\":\"ent\",\"ns\":\"a\",\"st\":\"creating\",\"inc\":\"00000000000000000000000000000001\"," + "\"csr\":\"srv1\",\"cwe\":\"5\",\"cfg\":\"2\"}\n" + "{\"k\":\"ent\",\"ns\":\"b\",\"st\":\"live\",\"inc\":\"00000000000000000000000000000002\"}\n" + "{\"n\":2}\n"}); +} + +/// ---------- codec round-trip ---------- + +TEST(CASRefCatalogFormat, RoundTripsAllThreeStates) +{ + RefCatalog in; + in.entries.push_back(CatalogEntry{.ns = RootNamespace{"a"}, .state = NsState::Creating, + .incarnation = UInt128(1), + .creator = CreatorFence{.server_root_id = "srv1", .writer_epoch = 5, .fence_generation = 2}}); + in.entries.push_back(liveEntry("b", 2)); + in.entries.push_back(CatalogEntry{ + .ns = RootNamespace{"c"}, + .state = NsState::Removing, + .incarnation = UInt128(3), + .removal_started_round = 11}); + + const RefCatalog out = decodeRefCatalog(encodeRefCatalog(in)); + EXPECT_EQ(out, in); + EXPECT_EQ(out.entries[0].state, NsState::Creating); + EXPECT_EQ(out.entries[1].state, NsState::Live); + EXPECT_EQ(out.entries[2].state, NsState::Removing); +} + +/// Mutation caught: making removal age caller-local or optional would let an adopted `Removing` row +/// lose the immutable round from which stuck-removal diagnostics measure. +TEST(CASRefCatalogFormat, RemovalStartedRoundIsRequiredExactlyForRemoving) +{ + CatalogEntry removing{ + .ns = RootNamespace{"removing"}, + .state = NsState::Removing, + .incarnation = UInt128{7}, + .removal_started_round = 19}; + const RefCatalog catalog{.entries = {removing}}; + const String encoded = encodeRefCatalog(catalog); + EXPECT_NE(encoded.find("\"rsr\":\"19\""), String::npos); + EXPECT_EQ(decodeRefCatalog(encoded), catalog); + + const String inc = "00000000000000000000000000000009"; + DB::Cas::tests::expectThrowsCode(DB::ErrorCodes::CORRUPTED_DATA, + [&] { (void)decodeRefCatalog(rawCatalog({rawEntLine("missing", "removing", inc)})); }); + DB::Cas::tests::expectThrowsCode(DB::ErrorCodes::CORRUPTED_DATA, [&] + { + (void)decodeRefCatalog(rawCatalog({withRemovalStartedRound(rawEntLine("forbidden", "live", inc), 21)})); + }); +} + +TEST(CASRefCatalogFormat, EmptyCatalogRoundTrips) +{ + EXPECT_EQ(decodeRefCatalog(encodeRefCatalog(RefCatalog{})), RefCatalog{}); +} + +/// Mutation caught: replacing the reverse index with `emplace`-and-ignore would make the first row +/// win. Every lifecycle state participates, both duplicate ids are unresolvable, and an unrelated +/// unique row remains usable by point resolution. +TEST(CASRefCatalogLifeIndex, DuplicatePhysicalIdsAreAmbiguousWithoutPoisoningUniquePointResolution) +{ + RefCatalog catalog; + catalog.entries = { + entryInState("a-creating", NsState::Creating, 7), + entryInState("b-live", NsState::Live, 7), + entryInState("c-removing", NsState::Removing, 8), + entryInState("d-live", NsState::Live, 8), + entryInState("e-unique", NsState::Live, 9), + }; + + const CatalogLifeIndex index(catalog); + EXPECT_TRUE(index.isAmbiguous(UInt128{7})); + EXPECT_TRUE(index.isAmbiguous(UInt128{8})); + EXPECT_THROW(index.resolve(UInt128{7}), DB::Exception); + EXPECT_THROW(index.resolve(UInt128{8}), DB::Exception); + const auto unique = index.resolve(UInt128{9}); + ASSERT_TRUE(unique); + EXPECT_EQ(unique->ns.string(), "e-unique"); +} + +/// Catalog mutation is destructive authority: any ambiguous current id stops the mutation before a +/// candidate can be written. An unrelated unique point lookup remains available from the same cut. +TEST(CASRefCatalogLifeIndex, AmbiguityStopsCatalogMutationButNotUnrelatedPointLookup) +{ + InMemoryBackend backend; + const Layout layout("p"); + RefCatalog catalog; + catalog.entries = { + entryInState("a", NsState::Live, 7), + entryInState("b", NsState::Removing, 7), + entryInState("c", NsState::Live, 9), + }; + ASSERT_EQ(backend.putIfAbsent(layout.refCatalogKey(), encodeRefCatalog(catalog)).outcome, PutOutcome::Done); + const auto before = backend.get(layout.refCatalogKey()); + ASSERT_TRUE(before); + + EXPECT_THROW(CasRefCatalog::casUpdate(backend, layout, [](const RefCatalog & current) { return current; }), DB::Exception); + const auto after = backend.get(layout.refCatalogKey()); + ASSERT_TRUE(after); + EXPECT_EQ(after->token, before->token); + EXPECT_EQ(after->bytes, before->bytes); + + const auto unique = CasRefCatalog::lifeIfCataloged(backend, layout, RootNamespace{"c"}); + ASSERT_TRUE(unique); + EXPECT_EQ(unique->incarnation, UInt128{9}); +} + +TEST(CASRefCatalogFormat, NamespaceAtExactByteBoundRoundTrips) +{ + RefCatalog c; + c.entries.push_back(liveEntry(String(kMaxNamespaceBytes, 'a'), 1)); + const RefCatalog out = decodeRefCatalog(encodeRefCatalog(c)); + EXPECT_EQ(out, c); +} + +/// ---------- strict rejections: encode side (LOGICAL_ERROR -- our own state, not yet durable) ---------- + +/// Every `expectThrowsCode(LOGICAL_ERROR, ...)` in this block aborts the process in debug/sanitizer +/// builds instead of behaving like a catchable exception (`Common/Exception.cpp`'s +/// `handle_error_code`), so each test is split: the throw-and-catch form below runs only on a plain +/// release build, and its `...DeathTest` counterpart (grouped after this block) proves the abort +/// positively on debug/sanitizer builds instead. +#ifndef DEBUG_OR_SANITIZER_BUILD + +TEST(CASRefCatalogFormat, EncodeRejectsDuplicateNamespace) +{ + RefCatalog c; + c.entries.push_back(liveEntry("a", 1)); + c.entries.push_back(liveEntry("a", 2)); + DB::Cas::tests::expectThrowsCode(DB::ErrorCodes::LOGICAL_ERROR, [&] { encodeRefCatalog(c); }); +} + +TEST(CASRefCatalogFormat, EncodeRejectsNonCanonicalOrder) +{ + RefCatalog c; + c.entries.push_back(liveEntry("b", 1)); + c.entries.push_back(liveEntry("a", 2)); + DB::Cas::tests::expectThrowsCode(DB::ErrorCodes::LOGICAL_ERROR, [&] { encodeRefCatalog(c); }); +} + +TEST(CASRefCatalogFormat, EncodeRejectsCreatorPresentOnLive) +{ + RefCatalog c; + c.entries.push_back(CatalogEntry{.ns = RootNamespace{"a"}, .state = NsState::Live, .incarnation = UInt128(1), + .creator = CreatorFence{.server_root_id = "srv", .writer_epoch = 1, .fence_generation = 1}}); + DB::Cas::tests::expectThrowsCode(DB::ErrorCodes::LOGICAL_ERROR, [&] { encodeRefCatalog(c); }); +} + +TEST(CASRefCatalogFormat, EncodeRejectsCreatorAbsentOnCreating) +{ + RefCatalog c; + c.entries.push_back(CatalogEntry{.ns = RootNamespace{"a"}, .state = NsState::Creating, .incarnation = UInt128(1)}); + DB::Cas::tests::expectThrowsCode(DB::ErrorCodes::LOGICAL_ERROR, [&] { encodeRefCatalog(c); }); +} + +TEST(CASRefCatalogFormat, EncodeRejectsZeroIncarnation) +{ + RefCatalog c; + c.entries.push_back(liveEntry("a", 0)); + DB::Cas::tests::expectThrowsCode(DB::ErrorCodes::LOGICAL_ERROR, [&] { encodeRefCatalog(c); }); +} + +TEST(CASRefCatalogFormat, EncodeRejectsNameOverByteBound) +{ + RefCatalog c; + c.entries.push_back(liveEntry(String(kMaxNamespaceBytes + 1, 'a'), 1)); + DB::Cas::tests::expectThrowsCode(DB::ErrorCodes::LOGICAL_ERROR, [&] { encodeRefCatalog(c); }); +} + +TEST(CASRefCatalogFormat, EncodeRejectsEmptyNamespace) +{ + RefCatalog c; + c.entries.push_back(liveEntry("", 1)); + DB::Cas::tests::expectThrowsCode(DB::ErrorCodes::LOGICAL_ERROR, [&] { encodeRefCatalog(c); }); +} + +/// Mutation caught: making removal age caller-local or optional would let an adopted `Removing` row +/// lose the immutable round from which stuck-removal diagnostics measure. +TEST(CASRefCatalogFormat, EncodeRejectsLiveWithRemovalStartedRound) +{ + CatalogEntry live_with_round = liveEntry("live", 8); + live_with_round.removal_started_round = 20; + DB::Cas::tests::expectThrowsCode(DB::ErrorCodes::LOGICAL_ERROR, + [&] { (void)encodeRefCatalog(RefCatalog{.entries = {live_with_round}}); }); +} + +#endif + +#if defined(DEBUG_OR_SANITIZER_BUILD) + +TEST(CASRefCatalogFormatDeathTest, EncodeRejectsDuplicateNamespaceAborts) +{ + RefCatalog c; + c.entries.push_back(liveEntry("a", 1)); + c.entries.push_back(liveEntry("a", 2)); + EXPECT_DEATH({ (void)encodeRefCatalog(c); }, "not canonically ordered"); +} + +TEST(CASRefCatalogFormatDeathTest, EncodeRejectsNonCanonicalOrderAborts) +{ + RefCatalog c; + c.entries.push_back(liveEntry("b", 1)); + c.entries.push_back(liveEntry("a", 2)); + EXPECT_DEATH({ (void)encodeRefCatalog(c); }, "not canonically ordered"); +} + +TEST(CASRefCatalogFormatDeathTest, EncodeRejectsCreatorPresentOnLiveAborts) +{ + RefCatalog c; + c.entries.push_back(CatalogEntry{.ns = RootNamespace{"a"}, .state = NsState::Live, .incarnation = UInt128(1), + .creator = CreatorFence{.server_root_id = "srv", .writer_epoch = 1, .fence_generation = 1}}); + EXPECT_DEATH({ (void)encodeRefCatalog(c); }, "carries a creator fence"); +} + +TEST(CASRefCatalogFormatDeathTest, EncodeRejectsCreatorAbsentOnCreatingAborts) +{ + RefCatalog c; + c.entries.push_back(CatalogEntry{.ns = RootNamespace{"a"}, .state = NsState::Creating, .incarnation = UInt128(1)}); + EXPECT_DEATH({ (void)encodeRefCatalog(c); }, "lacks a creator fence"); +} + +TEST(CASRefCatalogFormatDeathTest, EncodeRejectsZeroIncarnationAborts) +{ + RefCatalog c; + c.entries.push_back(liveEntry("a", 0)); + EXPECT_DEATH({ (void)encodeRefCatalog(c); }, "zero incarnation"); +} + +TEST(CASRefCatalogFormatDeathTest, EncodeRejectsNameOverByteBoundAborts) +{ + RefCatalog c; + c.entries.push_back(liveEntry(String(kMaxNamespaceBytes + 1, 'a'), 1)); + EXPECT_DEATH({ (void)encodeRefCatalog(c); }, "admission bound"); +} + +TEST(CASRefCatalogFormatDeathTest, EncodeRejectsEmptyNamespaceAborts) +{ + RefCatalog c; + c.entries.push_back(liveEntry("", 1)); + EXPECT_DEATH({ (void)encodeRefCatalog(c); }, "namespace must not be empty"); +} + +TEST(CASRefCatalogFormatDeathTest, EncodeRejectsLiveWithRemovalStartedRoundAborts) +{ + CatalogEntry live_with_round = liveEntry("live", 8); + live_with_round.removal_started_round = 20; + EXPECT_DEATH( + { (void)encodeRefCatalog(RefCatalog{.entries = {live_with_round}}); }, "removal_started_round"); +} + +#endif + +/// A namespace + creator server_root_id that both max out at their respective byte bounds (512 + +/// 255), escaped worst-case, land one "ent" line over the 4 KiB line cap (~4.7 KiB) -- reachable +/// because neither this codec nor `validateServerRootId` restricts the charset, only the length. +/// The refusal must be `LIMIT_EXCEEDED` (a capacity refusal), not `LOGICAL_ERROR` (a bug report) -- +/// `encodeFoldSeal`'s own `checkLineBytes` raises `LIMIT_EXCEEDED` for the identical shape of gate. +TEST(CASRefCatalogFormat, EncodeLineOverCapRaisesLimitExceeded) +{ + RefCatalog c; + c.entries.push_back(CatalogEntry{ + .ns = RootNamespace{String(kMaxNamespaceBytes, '\x01')}, + .state = NsState::Creating, + .incarnation = UInt128(1), + .creator = CreatorFence{.server_root_id = String(255, '\x01'), .writer_epoch = 1, .fence_generation = 1}}); + DB::Cas::tests::expectThrowsCode(DB::ErrorCodes::LIMIT_EXCEEDED, [&] { encodeRefCatalog(c); }); +} + +/// ---------- strict rejections: decode side (CORRUPTED_DATA -- bytes may have come from anywhere) ---------- + +TEST(CASRefCatalogFormat, DecodeRejectsDuplicateNamespace) +{ + const String bad = rawCatalog({rawEntLine("a", "live", u128ToHex(UInt128(1))), + rawEntLine("a", "live", u128ToHex(UInt128(2)))}); + DB::Cas::tests::expectThrowsCode(DB::ErrorCodes::CORRUPTED_DATA, [&] { decodeRefCatalog(bad); }); +} + +TEST(CASRefCatalogFormat, DecodeRejectsNonCanonicalOrder) +{ + const String bad = rawCatalog({rawEntLine("b", "live", u128ToHex(UInt128(1))), + rawEntLine("a", "live", u128ToHex(UInt128(2)))}); + DB::Cas::tests::expectThrowsCode(DB::ErrorCodes::CORRUPTED_DATA, [&] { decodeRefCatalog(bad); }); +} + +TEST(CASRefCatalogFormat, DecodeRejectsCreatorPresentOnLive) +{ + const String bad = rawCatalog({rawEntLine("a", "live", u128ToHex(UInt128(1)), + std::make_tuple(String("srv"), uint64_t(1), uint64_t(1)))}); + DB::Cas::tests::expectThrowsCode(DB::ErrorCodes::CORRUPTED_DATA, [&] { decodeRefCatalog(bad); }); +} + +TEST(CASRefCatalogFormat, DecodeRejectsCreatorAbsentOnCreating) +{ + const String bad = rawCatalog({rawEntLine("a", "creating", u128ToHex(UInt128(1)))}); + DB::Cas::tests::expectThrowsCode(DB::ErrorCodes::CORRUPTED_DATA, [&] { decodeRefCatalog(bad); }); +} + +TEST(CASRefCatalogFormat, DecodeRejectsZeroIncarnation) +{ + const String bad = rawCatalog({rawEntLine("a", "live", u128ToHex(UInt128(0)))}); + DB::Cas::tests::expectThrowsCode(DB::ErrorCodes::CORRUPTED_DATA, [&] { decodeRefCatalog(bad); }); +} + +TEST(CASRefCatalogFormat, DecodeRejectsNameOverByteBound) +{ + const String too_long_ns(kMaxNamespaceBytes + 1, 'a'); + const String bad = rawCatalog({rawEntLine(too_long_ns, "live", u128ToHex(UInt128(1)))}); + DB::Cas::tests::expectThrowsCode(DB::ErrorCodes::CORRUPTED_DATA, [&] { decodeRefCatalog(bad); }); +} + +TEST(CASRefCatalogFormat, DecodeRejectsUnknownState) +{ + const String bad = rawCatalog({rawEntLine("a", "bogus", u128ToHex(UInt128(1)))}); + DB::Cas::tests::expectThrowsCode(DB::ErrorCodes::CORRUPTED_DATA, [&] { decodeRefCatalog(bad); }); +} + +TEST(CASRefCatalogFormat, DecodeRejectsEmptyNamespace) +{ + const String bad = rawCatalog({rawEntLine("", "live", u128ToHex(UInt128(1)))}); + DB::Cas::tests::expectThrowsCode(DB::ErrorCodes::CORRUPTED_DATA, [&] { decodeRefCatalog(bad); }); +} + +TEST(CASRefCatalogFormat, DecodeRejectsMissingNamespaceKey) +{ + /// No "ns" key at all -- must be refused exactly like an explicit empty one, not read as "". + const String bad = rawCatalog({R"({"k":"ent","st":"live","inc":")" + u128ToHex(UInt128(1)) + "\"}"}); + DB::Cas::tests::expectThrowsCode(DB::ErrorCodes::CORRUPTED_DATA, [&] { decodeRefCatalog(bad); }); +} + +/// `nsStateToWord`'s only reachable input is either a live `NsState` or one `nsStateFromWord` already +/// validated on decode, so an unrecognized value is a bug in THIS process -- `LOGICAL_ERROR`, matching +/// this file's own stated taxonomy for the encode-side helper it (indirectly, via `creatorPairingOk`'s +/// error message) serves. Aborts under debug/sanitizer builds -- split like the block above; +/// `CASRefCatalogFormatDeathTest.NsStateToWordRaisesLogicalErrorOnImpossibleValueAborts` covers it there. +#ifndef DEBUG_OR_SANITIZER_BUILD +TEST(CASRefCatalogFormat, NsStateToWordRaisesLogicalErrorOnImpossibleValue) +{ + DB::Cas::tests::expectThrowsCode(DB::ErrorCodes::LOGICAL_ERROR, + [&] { nsStateToWord(static_cast(99)); }); +} +#endif + +#if defined(DEBUG_OR_SANITIZER_BUILD) +TEST(CASRefCatalogFormatDeathTest, NsStateToWordRaisesLogicalErrorOnImpossibleValueAborts) +{ + EXPECT_DEATH({ (void)nsStateToWord(static_cast(99)); }, "unknown ns state"); // NOLINT(clang-analyzer-optin.core.EnumCastOutOfRange): the whole point of this test is an impossible enum value +} +#endif + +/// ---------- registry row / raw-storage tripwire ---------- + +/// The registry row is part of the contract, mirroring `gtest_cas_ref_ckpt.cpp`'s +/// `RegistryRowIsControlStrictWithTightCaps`: Control/Strict decides how the decoder treats unknown +/// keys, and the caps are the first thing that fires if a foreign object ever lands at the key. +TEST(CASRefCatalogFormat, RegistryRowIsControlStrictWithRawStorage) +{ + const FormatTraits & traits = traitsFor(FormatId::RefCatalog); + EXPECT_EQ(traits.type, "cas_ref_catalog"); + EXPECT_EQ(traits.family, TextFamily::Control); + EXPECT_EQ(traits.strictness, KeyStrictness::Strict); + EXPECT_EQ(traits.object_cap, 256u * 1024u * 1024u); + EXPECT_EQ(traits.line_cap, 4u * 1024u); + EXPECT_EQ(traitsForType("cas_ref_catalog"), &traits); + /// Raw, so the key has no suffix: `Pool/CasRefCatalog.cpp` hands bytes to/from the backend + /// directly, bypassing `sealObject`/`openObject` because both are the identity under + /// `CompressionPolicy::Never`. This line is the TRIPWIRE for that shortcut -- a policy flip to + /// `Always` would silently write uncompressed bodies under a `.zst` key, which this assertion + /// catches first (see `CasRefCatalogFormat.h`'s comment on `encodeRefCatalog`). + EXPECT_EQ(storedSuffix(FormatId::RefCatalog), ""); + EXPECT_EQ(traits.compression, CompressionPolicy::Never); +} + +/// ---------- capacity admission: per-predicate boundary tests [codex r2/r3 finding 9] ---------- + +TEST(CASRefCatalogAdmission, Predicate1AcceptsEqualityRefusesCapPlusOne) +{ + const uint64_t cap = traitsFor(FormatId::RefCatalog).object_cap; + const RootNamespace ns{"admitted"}; + EXPECT_NO_THROW(checkCatalogObjectBytes(cap, ns)); + try + { + checkCatalogObjectBytes(cap + 1, ns); + FAIL() << "expected LIMIT_EXCEEDED"; + } + catch (const DB::Exception & e) + { + EXPECT_EQ(e.code(), DB::ErrorCodes::LIMIT_EXCEEDED); + EXPECT_NE(e.message().find("predicate 1"), String::npos) << e.message(); + EXPECT_NE(e.message().find(ns.string()), String::npos) << e.message(); + } +} + +TEST(CASRefCatalogAdmission, Predicate2AcceptsEqualityRefusesOneEntryOver) +{ + const Layout layout("p"); + constexpr uint64_t gc_shards = 1; + /// The exact boundary is expressed in ENTRIES (predicate (2) is a sum over admitted entries), so + /// the boundary count is derived from the real registry constants rather than assumed. + const uint64_t cap = foldSealCaps().object_cap; + const uint64_t fixed = foldSealFixedBytes(); + const uint64_t reservation = worstCaseEntryFoldReservationBytes(); + ASSERT_GT(reservation, 0u); + const uint64_t nonentry = widestBlobTargetRunReservationBytes(layout, gc_shards) + + widestCondemnedSummaryReservationBytes(gc_shards); + const uint64_t max_entries = (cap - fixed - nonentry) / reservation; + + const RootNamespace ns{"admitted"}; + EXPECT_NO_THROW(checkFoldSealReservation(max_entries, gc_shards, layout, ns)); + try + { + checkFoldSealReservation(max_entries + 1, gc_shards, layout, ns); + FAIL() << "expected LIMIT_EXCEEDED"; + } + catch (const DB::Exception & e) + { + EXPECT_EQ(e.code(), DB::ErrorCodes::LIMIT_EXCEEDED); + EXPECT_NE(e.message().find("predicate 2"), String::npos) << e.message(); + EXPECT_NE(e.message().find(ns.string()), String::npos) << e.message(); + } +} + +/// `entry_count * worstCaseEntryFoldReservationBytes()` must saturate, not wrap: choosing +/// `entry_count` as the SMALLEST value whose true (unbounded) product with `reservation` crosses +/// 2^64, an unsaturated `uint64_t` multiplication wraps to a remainder SMALLER than `reservation` +/// itself (a few KiB) -- which reads as trivially "fits" a 256 MiB cap even though the real +/// reservation this many entries demands is astronomically larger. A saturating multiply refuses it +/// regardless of the wraparound arithmetic underneath. +TEST(CASRefCatalogAdmission, Predicate2SaturatesEntryCountReservationInsteadOfWrapping) +{ + const Layout layout("p"); + const uint64_t reservation = worstCaseEntryFoldReservationBytes(); + const uint64_t entry_count = std::numeric_limits::max() / reservation + 1; + DB::Cas::tests::expectThrowsCode(DB::ErrorCodes::LIMIT_EXCEEDED, + [&] { checkFoldSealReservation(entry_count, 1, layout, RootNamespace{"huge"}); }); +} + +TEST(CASRefCatalogAdmission, CombinedAdmissionPropagatesCandidateEntryCount) +{ + /// `checkCatalogAdmission` runs predicate (1) then predicate (2) against the SAME candidate; for + /// an ordinary small catalog both hold slack and it returns the exact bytes `encodeRefCatalog` + /// would produce. + RefCatalog candidate; + candidate.entries.push_back(liveEntry("a", 1)); + candidate.entries.push_back(liveEntry("b", 2)); + const Layout layout("p"); + const String encoded = checkCatalogAdmission(candidate, 1, layout, RootNamespace{"b"}); + EXPECT_EQ(encoded, encodeRefCatalog(candidate)); +} + +TEST(CASRefCatalogAdmission, ReservationCoversActualWidestLegalRowsAcrossDecimalTransitions) +{ + const Layout layout("p/quoted-\"prefix"); + constexpr uint64_t gc_shards = 100; + constexpr uint64_t max = std::numeric_limits::max(); + + for (const uint64_t entry_count : {9, 10, 99, 100}) + { + CasFoldSeal seal; + seal.generation = max; + seal.parent_generation = max; + for (uint64_t i = 0; i < entry_count; ++i) + { + seal.ref_lives.emplace(std::numeric_limits::max() - i, RefLifeFoldState{ + .coverage = RefCoverage{ + .classification = 4, + .last_folded_ref_id = RefTxnId{max, max}, + .hold = RefHold{ + .reason = HoldReason::UnconsumedSealCrossing, + .offending_position = RefTxnId{max, max}, + .retry_count = std::numeric_limits::max(), + .next_retry_round = max}}, + .cleanup_evidence = RefCleanupEvidence{.remove_txn_id = RefTxnId{max, max}}}); + } + for (uint64_t shard = 0; shard < gc_shards; ++shard) + { + /// Predicate 2 charges exactly `gc_shards` widest `btr` rows. This fixture is the maximum + /// legal cardinality, not an optimistic producer convention: authoritative fold-seal + /// grammar permits at most one run per shard and requires its canonical key to use seq 0. + seal.blob_target_runs.push_back(RunRef{ + .key = layout.blobTargetRunKey(max, max, shard, 0), + .checksum = std::numeric_limits::max(), + .shard = shard, + .generation = max}); + seal.condemned_summary.emplace(shard, CondemnedSummary{ + .condemned_total = max, + .pending_total = max, + .oldest_nonpending_condemn_round = max}); + } + + ASSERT_EQ(seal.blob_target_runs.size(), gc_shards); + EXPECT_NO_THROW(validateFoldSealForWrite(seal, layout, gc_shards)); + + const uint64_t bound = foldSealFixedBytes() + + entry_count * worstCaseEntryFoldReservationBytes() + + gc_shards * widestBlobTargetRunReservationBytes(layout, gc_shards) + + gc_shards * widestCondemnedSummaryReservationBytes(gc_shards); + EXPECT_LE(encodeFoldSeal(seal).size(), bound) << "entry_count=" << entry_count; + } +} + +/// ---------- Constraint 13: removal is never refused, even at the admission boundary ---------- + +TEST(CASRefCatalogAdmission, RemovalNeverRefusedEvenAtCapacity) +{ + const Layout layout("p"); + constexpr uint64_t gc_shards = 1; + const uint64_t cap = foldSealCaps().object_cap; + const uint64_t fixed = foldSealFixedBytes(); + const uint64_t reservation = worstCaseEntryFoldReservationBytes(); + const uint64_t nonentry = widestBlobTargetRunReservationBytes(layout, gc_shards) + + widestCondemnedSummaryReservationBytes(gc_shards); + const uint64_t max_entries = (cap - fixed - nonentry) / reservation; + + /// Confirm the boundary is real: one entry beyond it is refused through admission. + DB::Cas::tests::expectThrowsCode(DB::ErrorCodes::LIMIT_EXCEEDED, + [&] { checkFoldSealReservation(max_entries + 1, gc_shards, layout, RootNamespace{"z"}); }); + + /// Build a catalog carrying exactly `max_entries` Live entries -- as full as admission ever + /// permits -- directly (a fixture, not itself an admission call). + RefCatalog full; + full.entries.reserve(max_entries); + for (uint64_t i = 0; i < max_entries; ++i) + full.entries.push_back(liveEntry(fmt::format("ns{:012}", i), i + 1)); + + InMemoryBackend backend; + backend.putIfAbsent(layout.refCatalogKey(), encodeRefCatalog(full)); + + /// The removal transition (Live -> Removing) on one entry goes through the PLAIN update path + /// (`casUpdate`, which runs no admission check at all) and succeeds even though the catalog is + /// already at the point where ANY growth would be refused. + const RefCatalog after = CasRefCatalog::casUpdate(backend, layout, [](const RefCatalog & cur) + { + RefCatalog next = cur; + next.entries[0].state = NsState::Removing; + next.entries[0].removal_started_round = 1; + return next; + }); + EXPECT_EQ(after.entries.size(), max_entries); + EXPECT_EQ(after.entries[0].state, NsState::Removing); +} + +/// ---------- Pool/CasRefCatalog: token-CAS read / create / update / conflict-retry ---------- + +TEST(CASRefCatalog, ReadAbsentFailsClosed) +{ + InMemoryBackend backend; + Layout layout("p"); + DB::Cas::tests::expectThrowsCode( + DB::ErrorCodes::CORRUPTED_DATA, [&] { (void)CasRefCatalog::read(backend, layout); }); +} + +TEST(CASRefCatalog, CasUpdateRefusesWhenAbsent) +{ + InMemoryBackend backend; + Layout layout("p"); + + DB::Cas::tests::expectThrowsCode(DB::ErrorCodes::CORRUPTED_DATA, [&] + { + CasRefCatalog::casUpdate(backend, layout, [](const RefCatalog & cur) { return cur; }); + }); + EXPECT_FALSE(backend.head(layout.refCatalogKey()).exists); +} + +TEST(CASRefCatalog, CasUpdateAppliesOnTopOfExistingState) +{ + InMemoryBackend backend; + Layout layout("p"); + CasRefCatalog::initializeEmptyForNewPool(backend, layout); + CasRefCatalog::casAdmitEntry(backend, layout, 1, liveEntry("a", 1)); + + const RefCatalog updated = CasRefCatalog::casUpdate(backend, layout, [](const RefCatalog & cur) + { + RefCatalog next = cur; + next.entries[0].state = NsState::Removing; + next.entries[0].removal_started_round = 1; + return next; + }); + ASSERT_EQ(updated.entries.size(), 1u); + EXPECT_EQ(updated.entries[0].ns.string(), "a"); + EXPECT_EQ(updated.entries[0].state, NsState::Removing); +} + +/// `CasRefCatalog::casUpdate`'s identity-preserving refusal throws `LOGICAL_ERROR`, which aborts the +/// whole process in debug/sanitizer builds (`Common/Exception.cpp`'s `handle_error_code`) instead of +/// behaving like a catchable exception -- so the throw-and-catch form below runs only on a plain +/// release build, and `CASRefCatalogDeathTest.GenericCasUpdateCannotDeleteOrReplaceCatalogIdentityAborts` +/// proves the abort positively on debug/sanitizer builds instead. +#ifndef DEBUG_OR_SANITIZER_BUILD +TEST(CASRefCatalog, GenericCasUpdateCannotDeleteOrReplaceCatalogIdentity) +{ + const Layout layout("p"); + { + InMemoryBackend backend; + CasRefCatalog::initializeEmptyForNewPool(backend, layout); + CasRefCatalog::casAdmitEntry(backend, layout, 1, liveEntry("a", 1)); + DB::Cas::tests::expectThrowsCode(DB::ErrorCodes::LOGICAL_ERROR, [&] + { + (void)CasRefCatalog::casUpdate(backend, layout, [](const RefCatalog &) { return RefCatalog{}; }); + }); + } + + { + InMemoryBackend backend; + CasRefCatalog::initializeEmptyForNewPool(backend, layout); + CasRefCatalog::casAdmitEntry(backend, layout, 1, liveEntry("a", 1)); + DB::Cas::tests::expectThrowsCode(DB::ErrorCodes::LOGICAL_ERROR, [&] + { + (void)CasRefCatalog::casUpdate(backend, layout, [](const RefCatalog & current) + { + RefCatalog next = current; + next.entries[0] = liveEntry("b", 2); + return next; + }); + }); + } +} +#endif + +#if defined(DEBUG_OR_SANITIZER_BUILD) +TEST(CASRefCatalogDeathTest, GenericCasUpdateCannotDeleteOrReplaceCatalogIdentityAborts) +{ + const Layout layout("p"); + { + InMemoryBackend backend; + CasRefCatalog::initializeEmptyForNewPool(backend, layout); + CasRefCatalog::casAdmitEntry(backend, layout, 1, liveEntry("a", 1)); + EXPECT_DEATH( + { (void)CasRefCatalog::casUpdate(backend, layout, [](const RefCatalog &) { return RefCatalog{}; }); }, + "cannot add or delete catalog entries"); + } + + { + InMemoryBackend backend; + CasRefCatalog::initializeEmptyForNewPool(backend, layout); + CasRefCatalog::casAdmitEntry(backend, layout, 1, liveEntry("a", 1)); + EXPECT_DEATH( + { + (void)CasRefCatalog::casUpdate(backend, layout, [](const RefCatalog & current) + { + RefCatalog next = current; + next.entries[0] = liveEntry("b", 2); + return next; + }); + }, + "cannot replace catalog identity"); + } +} +#endif + +TEST(CASRefCatalog, CasUpdateRetriesOnConflictAgainstFreshState) +{ + InMemoryBackend backend; + Layout layout("p"); + CasRefCatalog::initializeEmptyForNewPool(backend, layout); + CasRefCatalog::casAdmitEntry(backend, layout, 1, liveEntry("a", 1)); + + backend.failNextCasPut(layout.refCatalogKey()); /// one-shot artificial Conflict on the next write + + int mutate_calls = 0; + const RefCatalog result = CasRefCatalog::casUpdate(backend, layout, [&](const RefCatalog & cur) + { + ++mutate_calls; + RefCatalog next = cur; + next.entries[0].state = NsState::Removing; + next.entries[0].removal_started_round = 1; + return next; + }); + + EXPECT_EQ(mutate_calls, 2); /// first attempt hit the injected conflict; the retry succeeded + ASSERT_EQ(result.entries.size(), 1u); + EXPECT_EQ(result.entries[0].state, NsState::Removing); + + const CasRefCatalog::Snapshot snap = CasRefCatalog::read(backend, layout); + EXPECT_EQ(snap.catalog, result); +} + +TEST(CASRefCatalog, BeginRemovingRechecksFenceAfterCatalogCasConflict) +{ + InMemoryBackend backend; + const Layout layout("p"); + const CatalogEntry observed = liveEntry("a", 1); + CasRefCatalog::initializeEmptyForNewPool(backend, layout); + CasRefCatalog::casAdmitEntry(backend, layout, 1, observed); + + uint64_t current_fence_generation = 7; + size_t fence_checks = 0; + const auto outcome = CasRefCatalog::beginRemoving( + backend, layout, observed, /*removal_started_round*/ 13, /*admitted_generation*/ 7, + [&](uint64_t admitted_generation) + { + ++fence_checks; + if (admitted_generation != current_fence_generation) + throw std::runtime_error("stale catalog mutation fence"); + if (fence_checks == 1) + { + /// Move the caller fence after the first admission check and force that attempt's + /// catalog CAS to conflict. The next attempt must check the fence again before writing. + current_fence_generation = 8; + backend.failNextCasPut(layout.refCatalogKey()); + } + }); + + EXPECT_EQ(outcome, CasRefCatalog::BeginRemovingOutcome::FencedOut); + EXPECT_EQ(fence_checks, 2u); + const CasRefCatalog::Snapshot after = CasRefCatalog::read(backend, layout); + EXPECT_EQ(after.catalog.entries, std::vector{observed}); +} + +/// A re-read that finds the catalog genuinely ABSENT after it was previously observed present is a +/// real concurrent delete, not a bootstrap -- `casUpdate` must refuse rather than silently create a +/// fresh catalog containing only this one mutation's entry (which would drop every other namespace). +/// Reproduced with a REAL delete (no fault injection needed): `mutate`'s first invocation deletes the +/// seeded object using the token `casUpdate`'s own initial read observed, so the loop's own `casPut` +/// against that now-stale token gets a genuine `Conflict`, and the follow-up re-read genuinely finds +/// the key absent. +/// Missing mandatory authority raises `CORRUPTED_DATA`; the split remains only because the debug +/// variant historically lived in the death-test suite. +#ifndef DEBUG_OR_SANITIZER_BUILD +TEST(CASRefCatalog, CasUpdateThrowsOnVanishMidRetryInsteadOfReplacingTheCatalog) +{ + InMemoryBackend backend; + Layout layout("p"); + CasRefCatalog::initializeEmptyForNewPool(backend, layout); + CasRefCatalog::casAdmitEntry(backend, layout, 1, liveEntry("a", 1)); + const CasRefCatalog::Snapshot seeded = CasRefCatalog::read(backend, layout); + ASSERT_TRUE(seeded.token.has_value()); + + DB::Cas::tests::expectThrowsCode(DB::ErrorCodes::CORRUPTED_DATA, [&] + { + CasRefCatalog::casUpdate(backend, layout, [&](const RefCatalog & cur) + { + backend.deleteExact(layout.refCatalogKey(), *seeded.token); + RefCatalog next = cur; + next.entries[0].state = NsState::Removing; + next.entries[0].removal_started_round = 1; + return next; + }); + }); + + /// Nothing was written by the failed attempt: the object is exactly as the delete left it + /// (absent), never a fresh single-entry catalog. + EXPECT_FALSE(backend.head(layout.refCatalogKey()).exists); +} +#endif + +#if defined(DEBUG_OR_SANITIZER_BUILD) +TEST(CASRefCatalogDeathTest, CasUpdateThrowsOnVanishMidRetryInsteadOfReplacingTheCatalogAborts) +{ + InMemoryBackend backend; + Layout layout("p"); + CasRefCatalog::initializeEmptyForNewPool(backend, layout); + CasRefCatalog::casAdmitEntry(backend, layout, 1, liveEntry("a", 1)); + const CasRefCatalog::Snapshot seeded = CasRefCatalog::read(backend, layout); + ASSERT_TRUE(seeded.token.has_value()); + + DB::Cas::tests::expectThrowsCode(DB::ErrorCodes::CORRUPTED_DATA, [&] + { + CasRefCatalog::casUpdate(backend, layout, [&](const RefCatalog & cur) + { + backend.deleteExact(layout.refCatalogKey(), *seeded.token); + RefCatalog next = cur; + next.entries[0].state = NsState::Removing; + next.entries[0].removal_started_round = 1; + return next; + }); + }); +} +#endif + +/// The retry loop is bounded (the same live-lock brake `publishCkpt`/`allocateWriterEpoch` use on +/// their own contended token-CAS singletons) and ends in the typed retryable error, not an infinite +/// spin. `mutate` re-arms the one-shot conflict injection on every call, so every attempt fails. +TEST(CASRefCatalog, CasUpdateGivesUpAfterBoundedAttemptsWithRetryLaterError) +{ + InMemoryBackend backend; + Layout layout("p"); + CasRefCatalog::initializeEmptyForNewPool(backend, layout); + + int mutate_calls = 0; + DB::Cas::tests::expectThrowsCode(DB::ErrorCodes::NETWORK_ERROR, [&] + { + CasRefCatalog::casUpdate(backend, layout, [&](const RefCatalog & cur) + { + ++mutate_calls; + backend.failNextCasPut(layout.refCatalogKey()); + RefCatalog next = cur; + return next; + }); + }); + EXPECT_GT(mutate_calls, 1); /// genuinely retried, not a single-shot failure +} + +TEST(CASRefCatalog, CasAdmitEntryAcceptsAnOrdinaryCreation) +{ + InMemoryBackend backend; + Layout layout("p"); + CasRefCatalog::initializeEmptyForNewPool(backend, layout); + + const RefCatalog created = CasRefCatalog::casAdmitEntry(backend, layout, 1, + CatalogEntry{.ns = RootNamespace{"a"}, .state = NsState::Creating, .incarnation = UInt128(1), + .creator = CreatorFence{.server_root_id = "srv", .writer_epoch = 1, .fence_generation = 1}}); + ASSERT_EQ(created.entries.size(), 1u); + EXPECT_EQ(created.entries[0].state, NsState::Creating); +} + +TEST(CASRefCatalog, CasAdmitEntryInsertsAtCanonicalPosition) +{ + InMemoryBackend backend; + Layout layout("p"); + CasRefCatalog::initializeEmptyForNewPool(backend, layout); + + CasRefCatalog::casAdmitEntry(backend, layout, 1, liveEntry("b", 1)); + const RefCatalog after = CasRefCatalog::casAdmitEntry(backend, layout, 1, liveEntry("a", 2)); + ASSERT_EQ(after.entries.size(), 2u); + EXPECT_EQ(after.entries[0].ns.string(), "a"); /// inserted BEFORE "b", not appended + EXPECT_EQ(after.entries[1].ns.string(), "b"); +} + +/// Caught by `encodeRefCatalog`'s own canonical-order/no-duplicate grammar check, inside +/// `checkCatalogAdmission` -- no separate duplicate check needed here. That `LOGICAL_ERROR` aborts +/// under debug/sanitizer builds -- split like the blocks above. +#ifndef DEBUG_OR_SANITIZER_BUILD +TEST(CASRefCatalog, CasAdmitEntryRejectsADuplicateNamespace) +{ + InMemoryBackend backend; + Layout layout("p"); + CasRefCatalog::initializeEmptyForNewPool(backend, layout); + CasRefCatalog::casAdmitEntry(backend, layout, 1, liveEntry("a", 1)); + DB::Cas::tests::expectThrowsCode(DB::ErrorCodes::LOGICAL_ERROR, + [&] { CasRefCatalog::casAdmitEntry(backend, layout, 1, liveEntry("a", 2)); }); +} +#endif + +#if defined(DEBUG_OR_SANITIZER_BUILD) +TEST(CASRefCatalogDeathTest, CasAdmitEntryRejectsADuplicateNamespaceAborts) +{ + InMemoryBackend backend; + Layout layout("p"); + CasRefCatalog::initializeEmptyForNewPool(backend, layout); + CasRefCatalog::casAdmitEntry(backend, layout, 1, liveEntry("a", 1)); + EXPECT_DEATH({ CasRefCatalog::casAdmitEntry(backend, layout, 1, liveEntry("a", 2)); }, "not canonically ordered"); +} +#endif + +TEST(CASRefCatalog, CasAdmitEntryRefusesOverCapacity) +{ + InMemoryBackend backend; + Layout layout("p"); + + const uint64_t cap = foldSealCaps().object_cap; + const uint64_t fixed = foldSealFixedBytes(); + const uint64_t reservation = worstCaseEntryFoldReservationBytes(); + const uint64_t nonentry = widestBlobTargetRunReservationBytes(layout, 1) + + widestCondemnedSummaryReservationBytes(1); + const uint64_t max_entries = (cap - fixed - nonentry) / reservation; + + /// Seed the catalog directly at the admission boundary (a fixture -- not itself an admission call). + RefCatalog full; + full.entries.reserve(max_entries); + for (uint64_t i = 0; i < max_entries; ++i) + full.entries.push_back(liveEntry(fmt::format("ns{:012}", i), i + 1)); + backend.putIfAbsent(layout.refCatalogKey(), encodeRefCatalog(full)); + + /// Admitting ONE more namespace is refused -- the additive predicate is checked BEFORE the write, + /// so the backend object is untouched. + DB::Cas::tests::expectThrowsCode(DB::ErrorCodes::LIMIT_EXCEEDED, [&] + { + CasRefCatalog::casAdmitEntry(backend, layout, 1, liveEntry("zzz", 999999999)); + }); + + const CasRefCatalog::Snapshot snap = CasRefCatalog::read(backend, layout); + EXPECT_EQ(snap.catalog.entries.size(), max_entries); +} + +TEST(CASRefCatalogRemoval, DeleteCompletedRemovingRequiresExactAdoptedProofAndLeaderFence) +{ + DB::Cas::tests::CountingBackend backend; + const Layout layout("p"); + const CatalogEntry removing{ + .ns = RootNamespace{"a"}, + .state = NsState::Removing, + .incarnation = UInt128{7}, + .removal_started_round = 13}; + ASSERT_EQ(backend.putIfAbsent(layout.refCatalogKey(), encodeRefCatalog(RefCatalog{.entries = {removing}})).outcome, + PutOutcome::Done); + + CasFoldSeal held_parent; + held_parent.ref_lives.emplace(UInt128{7}, RefLifeFoldState{ + .coverage = RefCoverage{ + .classification = 4, + .last_folded_ref_id = RefTxnId{1, 2}, + .hold = RefHold{.offending_position = RefTxnId{1, 3}}}, + .cleanup_evidence = RefCleanupEvidence{.remove_txn_id = RefTxnId{1, 2}}}); + EXPECT_EQ(CasRefCatalog::deleteCompletedRemoving( + backend, layout, removing, held_parent, 5, + [](uint64_t) { return CasRefCatalog::LeaderFenceStatus::Held; }), + CasRefCatalog::CompletedRemovingDeleteOutcome::ProofRefused); + + CasFoldSeal mismatched_parent; + mismatched_parent.ref_lives.emplace(UInt128{8}, RefLifeFoldState{ + .coverage = RefCoverage{.classification = 2, .last_folded_ref_id = RefTxnId{1, 2}}, + .cleanup_evidence = RefCleanupEvidence{.remove_txn_id = RefTxnId{1, 2}}}); + EXPECT_EQ(CasRefCatalog::deleteCompletedRemoving( + backend, layout, removing, mismatched_parent, 5, + [](uint64_t) { return CasRefCatalog::LeaderFenceStatus::Held; }), + CasRefCatalog::CompletedRemovingDeleteOutcome::ProofRefused); + + CasFoldSeal ready_parent; + ready_parent.ref_lives.emplace(UInt128{7}, RefLifeFoldState{ + .coverage = RefCoverage{.classification = 2, .last_folded_ref_id = RefTxnId{1, 2}}, + .cleanup_evidence = RefCleanupEvidence{.remove_txn_id = RefTxnId{1, 2}}}); + + CatalogEntry live = removing; + live.state = NsState::Live; + live.removal_started_round.reset(); + EXPECT_EQ(CasRefCatalog::deleteCompletedRemoving( + backend, layout, live, ready_parent, 5, + [](uint64_t) { return CasRefCatalog::LeaderFenceStatus::Held; }), + CasRefCatalog::CompletedRemovingDeleteOutcome::ProofRefused); + CatalogEntry creating = live; + creating.state = NsState::Creating; + creating.creator = CreatorFence{.server_root_id = "server", .writer_epoch = 3, .fence_generation = 4}; + EXPECT_EQ(CasRefCatalog::deleteCompletedRemoving( + backend, layout, creating, ready_parent, 5, + [](uint64_t) { return CasRefCatalog::LeaderFenceStatus::Held; }), + CasRefCatalog::CompletedRemovingDeleteOutcome::ProofRefused); + + EXPECT_EQ(CasRefCatalog::deleteCompletedRemoving( + backend, layout, removing, ready_parent, 5, + [](uint64_t) { return CasRefCatalog::LeaderFenceStatus::Moved; }), + CasRefCatalog::CompletedRemovingDeleteOutcome::FencedOut); + EXPECT_EQ(backend.casPutCount(layout.refCatalogKey()), 0); + + EXPECT_EQ(CasRefCatalog::deleteCompletedRemoving( + backend, layout, removing, ready_parent, 5, [](uint64_t generation) + { + EXPECT_EQ(generation, 5); + return CasRefCatalog::LeaderFenceStatus::Held; + }), + CasRefCatalog::CompletedRemovingDeleteOutcome::Deleted); + EXPECT_TRUE(CasRefCatalog::read(backend, layout).catalog.entries.empty()); + EXPECT_EQ(backend.casPutCount(layout.refCatalogKey()), 1); + EXPECT_EQ(backend.listTotal(), 0); + EXPECT_EQ(backend.deleteTotal(), 0); +} + +TEST(CASRefCatalogRemoval, ExactDeletionRefusesChangedEntryAndAdmissionCannotCarryRemoval) +{ + DB::Cas::tests::CountingBackend backend; + const Layout layout("p"); + CasRefCatalog::initializeEmptyForNewPool(backend, layout); + const CatalogEntry removing{ + .ns = RootNamespace{"a"}, + .state = NsState::Removing, + .incarnation = UInt128{7}, + .removal_started_round = 13}; +#ifndef DEBUG_OR_SANITIZER_BUILD + DB::Cas::tests::expectThrowsCode(DB::ErrorCodes::LOGICAL_ERROR, + [&] { (void)CasRefCatalog::casAdmitEntry(backend, layout, 1, removing); }); +#endif + + const CatalogEntry current{ + .ns = RootNamespace{"a"}, + .state = NsState::Removing, + .incarnation = UInt128{7}, + .removal_started_round = 14}; + ASSERT_EQ(backend.putIfAbsent("unrelated", "sentinel").outcome, PutOutcome::Done); + ASSERT_EQ(backend.casPut(layout.refCatalogKey(), encodeRefCatalog(RefCatalog{.entries = {current}}), + CasRefCatalog::read(backend, layout).token).outcome, CasOutcome::Committed); + + CasFoldSeal ready_parent; + ready_parent.ref_lives.emplace(UInt128{7}, RefLifeFoldState{ + .coverage = RefCoverage{.classification = 2, .last_folded_ref_id = RefTxnId{1, 2}}, + .cleanup_evidence = RefCleanupEvidence{.remove_txn_id = RefTxnId{1, 2}}}); + EXPECT_EQ(CasRefCatalog::deleteCompletedRemoving( + backend, layout, removing, ready_parent, 5, + [](uint64_t) { return CasRefCatalog::LeaderFenceStatus::Held; }), + CasRefCatalog::CompletedRemovingDeleteOutcome::EntryChanged); + EXPECT_EQ(CasRefCatalog::read(backend, layout).catalog.entries, std::vector{current}); +} + +#if defined(DEBUG_OR_SANITIZER_BUILD) +TEST(CASRefCatalogRemovalDeathTest, AdmissionCannotCarryRemovalAborts) +{ + DB::Cas::tests::CountingBackend backend; + const Layout layout("p"); + CasRefCatalog::initializeEmptyForNewPool(backend, layout); + const CatalogEntry removing{ + .ns = RootNamespace{"a"}, + .state = NsState::Removing, + .incarnation = UInt128{7}, + .removal_started_round = 13}; + + EXPECT_DEATH( + { (void)CasRefCatalog::casAdmitEntry(backend, layout, 1, removing); }, + "cannot admit namespace.*directly as Removing"); +} +#endif + +/// Mutation caught: deriving the control outcome from the resolution snapshot would turn a stale +/// leader's `FencedOut` into `Deleted` or `EntryChanged`. Resolution may prove the old life dead and +/// carry its invalidation, but it cannot restore the caller's authority to continue the GC round. +TEST(CASRefCatalogRemoval, FenceLossRemainsControlOutcomeWhenWinnerRemovesOrReplacesLife) +{ + for (const bool replace : {false, true}) + { + EraseWinnerBackend backend; + const Layout layout(replace ? "replacement" : "absence"); + const CatalogEntry removing{ + .ns = RootNamespace{"a"}, + .state = NsState::Removing, + .incarnation = UInt128{7}, + .removal_started_round = 13}; + ASSERT_EQ(backend.putIfAbsent( + layout.refCatalogKey(), encodeRefCatalog(RefCatalog{.entries = {removing}})).outcome, + PutOutcome::Done); + + CasFoldSeal ready_parent; + ready_parent.ref_lives.emplace(UInt128{7}, RefLifeFoldState{ + .coverage = RefCoverage{.classification = 2, .last_folded_ref_id = RefTxnId{1, 2}}, + .cleanup_evidence = RefCleanupEvidence{.remove_txn_id = RefTxnId{1, 2}}}); + std::optional replacement; + if (replace) + replacement = CatalogEntry{ + .ns = removing.ns, + .state = NsState::Live, + .incarnation = UInt128{8}}; + backend.replaceOnNextCatalogCas(layout.refCatalogKey(), replacement); + + const CasRefCatalog::CompletedRemovingDeleteResult result + = CasRefCatalog::deleteCompletedRemoving( + backend, layout, removing, ready_parent, 5, [&](uint64_t) + { + if (backend.fenceMoved()) + return CasRefCatalog::LeaderFenceStatus::Moved; + return CasRefCatalog::LeaderFenceStatus::Held; + }); + + EXPECT_EQ(result.outcome, CasRefCatalog::CompletedRemovingDeleteOutcome::FencedOut); + ASSERT_TRUE(result.invalidated_life); + EXPECT_EQ(*result.invalidated_life, + NamespaceLifeId::fromCatalogEntry(removing.ns, removing.incarnation)); + const RefCatalog current = CasRefCatalog::read(backend, layout).catalog; + if (replace) + EXPECT_EQ(current.entries, std::vector{*replacement}); + else + EXPECT_TRUE(current.entries.empty()); + } +} + +/// Mutation caught: treating every authority-check exception as a moved fence hides corruption and +/// backend/decode failures. Before any CAS, inability to evaluate authority must propagate unchanged. +TEST(CASRefCatalogRemoval, NonFenceAuthorityExceptionPropagatesBeforeEraseCas) +{ + DB::Cas::tests::CountingBackend backend; + const Layout layout("pre-cas-authority-error"); + const CatalogEntry removing{ + .ns = RootNamespace{"a"}, + .state = NsState::Removing, + .incarnation = UInt128{7}, + .removal_started_round = 13}; + ASSERT_EQ(backend.putIfAbsent( + layout.refCatalogKey(), encodeRefCatalog(RefCatalog{.entries = {removing}})).outcome, + PutOutcome::Done); + CasFoldSeal ready_parent; + ready_parent.ref_lives.emplace(UInt128{7}, RefLifeFoldState{ + .coverage = RefCoverage{.classification = 2, .last_folded_ref_id = RefTxnId{1, 2}}, + .cleanup_evidence = RefCleanupEvidence{.remove_txn_id = RefTxnId{1, 2}}}); + + DB::Cas::tests::expectThrowsCode(DB::ErrorCodes::CORRUPTED_DATA, [&] + { + (void)CasRefCatalog::deleteCompletedRemoving( + backend, layout, removing, ready_parent, 5, [](uint64_t) -> CasRefCatalog::LeaderFenceStatus + { + throw DB::Exception(DB::ErrorCodes::CORRUPTED_DATA, + "injected authority read failure before erase CAS"); + }); + }); + EXPECT_EQ(backend.casPutCount(layout.refCatalogKey()), 0u); +} + +/// The post-CAS authority check is distinct: the erase may already be durable and its mandatory +/// resolution complete, but inability to evaluate authority is still the original error, not +/// `FencedOut`. +TEST(CASRefCatalogRemoval, NonFenceAuthorityExceptionPropagatesAfterEraseResolution) +{ + DB::Cas::tests::CountingBackend backend; + const Layout layout("post-cas-authority-error"); + const CatalogEntry removing{ + .ns = RootNamespace{"a"}, + .state = NsState::Removing, + .incarnation = UInt128{7}, + .removal_started_round = 13}; + ASSERT_EQ(backend.putIfAbsent( + layout.refCatalogKey(), encodeRefCatalog(RefCatalog{.entries = {removing}})).outcome, + PutOutcome::Done); + CasFoldSeal ready_parent; + ready_parent.ref_lives.emplace(UInt128{7}, RefLifeFoldState{ + .coverage = RefCoverage{.classification = 2, .last_folded_ref_id = RefTxnId{1, 2}}, + .cleanup_evidence = RefCleanupEvidence{.remove_txn_id = RefTxnId{1, 2}}}); + + size_t authority_checks = 0; + DB::Cas::tests::expectThrowsCode(DB::ErrorCodes::CORRUPTED_DATA, [&] + { + (void)CasRefCatalog::deleteCompletedRemoving( + backend, layout, removing, ready_parent, 5, [&](uint64_t) + { + if (++authority_checks == 2) + throw DB::Exception(DB::ErrorCodes::CORRUPTED_DATA, + "injected authority read failure after erase resolution"); + return CasRefCatalog::LeaderFenceStatus::Held; + }); + }); + EXPECT_EQ(authority_checks, 2u); + EXPECT_TRUE(CasRefCatalog::read(backend, layout).catalog.entries.empty()); +} + +/// Mutation caught: swallowing a synchronous `casPut` exception raised during the erase attempt +/// itself (as opposed to the authority/fence check) and treating it as ordinary non-convergence +/// would hide a real backend fault behind ProofRefused/EntryChanged, and would skip the mandatory +/// resolution read that this branch's siblings above already prove runs before any conclusion. +TEST(CASRefCatalogRemoval, CasPutExceptionPropagatesAfterMandatoryResolution) +{ + CasPutThrowsOnceBackend backend; + const Layout layout("cas-put-throw"); + const CatalogEntry removing{ + .ns = RootNamespace{"a"}, + .state = NsState::Removing, + .incarnation = UInt128{7}, + .removal_started_round = 13}; + ASSERT_EQ(backend.putIfAbsent( + layout.refCatalogKey(), encodeRefCatalog(RefCatalog{.entries = {removing}})).outcome, + PutOutcome::Done); + CasFoldSeal ready_parent; + ready_parent.ref_lives.emplace(UInt128{7}, RefLifeFoldState{ + .coverage = RefCoverage{.classification = 2, .last_folded_ref_id = RefTxnId{1, 2}}, + .cleanup_evidence = RefCleanupEvidence{.remove_txn_id = RefTxnId{1, 2}}}); + + backend.armCasPutThrow(layout.refCatalogKey()); + + DB::Cas::tests::expectThrowsCode(DB::ErrorCodes::NETWORK_ERROR, [&] + { + (void)CasRefCatalog::deleteCompletedRemoving( + backend, layout, removing, ready_parent, 5, [](uint64_t) + { + return CasRefCatalog::LeaderFenceStatus::Held; + }); + }); + /// The mandatory resolution read ran before the rethrow: the exact old row is still present, + /// unchanged by the failed attempt. + const RefCatalog current = CasRefCatalog::read(backend, layout).catalog; + EXPECT_EQ(current.entries, std::vector{removing}); +} + +TEST(CASRefCatalogRemoval, CancelStalledCreatingRequiresExactRowAndTerminalCreatorFence) +{ + DB::Cas::tests::CountingBackend backend; + const Layout layout("p"); + const CatalogEntry creating{ + .ns = RootNamespace{"a"}, + .state = NsState::Creating, + .incarnation = UInt128{7}, + .creator = CreatorFence{.server_root_id = "server", .writer_epoch = 3, .fence_generation = 4}}; + ASSERT_EQ(backend.putIfAbsent(layout.refCatalogKey(), encodeRefCatalog(RefCatalog{.entries = {creating}})).outcome, + PutOutcome::Done); + + EXPECT_EQ(CasRefCatalog::cancelStalledCreating( + backend, layout, creating, [](const CreatorFence &) { return false; }, 5, [](uint64_t) {}), + CasRefCatalog::StalledCreatingCancelOutcome::CreatorFenceStillLive); + EXPECT_EQ(backend.casPutCount(layout.refCatalogKey()), 0); + + CatalogEntry stale = creating; + stale.creator->writer_epoch = 2; + EXPECT_EQ(CasRefCatalog::cancelStalledCreating( + backend, layout, stale, [](const CreatorFence &) { return true; }, 5, [](uint64_t) {}), + CasRefCatalog::StalledCreatingCancelOutcome::EntryChanged); + EXPECT_EQ(backend.casPutCount(layout.refCatalogKey()), 0); + + EXPECT_EQ(CasRefCatalog::cancelStalledCreating( + backend, layout, creating, [](const CreatorFence &) { return true; }, 5, [](uint64_t) {}), + CasRefCatalog::StalledCreatingCancelOutcome::Cancelled); + EXPECT_TRUE(CasRefCatalog::read(backend, layout).catalog.entries.empty()); + EXPECT_EQ(backend.casPutCount(layout.refCatalogKey()), 1); + EXPECT_EQ(backend.listTotal(), 0); + EXPECT_EQ(backend.deleteTotal(), 0); +} + +TEST(CASGCRefWalkPlan, CatalogIsSoleRowAdmissionAuthorityAcrossOrdinaryAndRebuildInputs) +{ + RefCatalog catalog; + catalog.entries = { + CatalogEntry{ + .ns = RootNamespace{"creating"}, + .state = NsState::Creating, + .incarnation = UInt128{1}, + .creator = CreatorFence{.server_root_id = "server", .writer_epoch = 1, .fence_generation = 1}}, + liveEntry("live", 2), + CatalogEntry{ + .ns = RootNamespace{"removing"}, + .state = NsState::Removing, + .incarnation = UInt128{3}, + .removal_started_round = 8}, + }; + const CasRefCatalog::Snapshot cut{ + .catalog = catalog, .token = std::nullopt, .life_index = CatalogLifeIndex(catalog)}; + + RefScanSummary ordinary_scan; + ordinary_scan.parent_ref_lives.emplace(UInt128{1}, RefLifeFoldState{ + .coverage = RefCoverage{.classification = 2, .last_folded_ref_id = RefTxnId{1, 1}}}); + ordinary_scan.parent_ref_lives.emplace(UInt128{3}, RefLifeFoldState{ + .coverage = RefCoverage{.classification = 2, .last_folded_ref_id = RefTxnId{3, 3}}, + .cleanup_evidence = RefCleanupEvidence{.remove_txn_id = RefTxnId{3, 3}}}); + ordinary_scan.parent_ref_lives.emplace(UInt128{4}, RefLifeFoldState{ + .coverage = RefCoverage{.classification = 2, .last_folded_ref_id = RefTxnId{4, 4}}}); + ordinary_scan.listed_lives = {UInt128{1}, UInt128{2}, UInt128{4}}; + ordinary_scan.holds.emplace(UInt128{1}, RefHold{.offending_position = RefTxnId{1, 2}}); + ordinary_scan.holds.emplace(UInt128{2}, RefHold{.offending_position = RefTxnId{2, 2}}); + ordinary_scan.checkpoint_observations.emplace(UInt128{1}, RefTxnId{1, 9}); + ordinary_scan.checkpoint_observations.emplace(UInt128{2}, RefTxnId{2, 9}); + ordinary_scan.max_log_by_life.emplace(UInt128{1}, RefTxnId{1, 10}); + ordinary_scan.max_log_by_life.emplace(UInt128{2}, RefTxnId{2, 10}); + + RefScanSummary rebuild_scan; + rebuild_scan.parent_ref_lives.emplace(UInt128{1}, ordinary_scan.parent_ref_lives.at(UInt128{1})); + rebuild_scan.parent_ref_lives.emplace(UInt128{5}, RefLifeFoldState{ + .coverage = RefCoverage{.classification = 2, .last_folded_ref_id = RefTxnId{5, 5}}}); + rebuild_scan.listed_lives = {UInt128{1}, UInt128{3}, UInt128{5}}; + rebuild_scan.holds.emplace(UInt128{1}, RefHold{.offending_position = RefTxnId{1, 3}}); + rebuild_scan.holds.emplace(UInt128{3}, RefHold{.offending_position = RefTxnId{3, 4}}); + rebuild_scan.checkpoint_observations.emplace(UInt128{1}, RefTxnId{1, 11}); + rebuild_scan.checkpoint_observations.emplace(UInt128{3}, RefTxnId{3, 11}); + rebuild_scan.max_log_by_life.emplace(UInt128{1}, RefTxnId{1, 12}); + rebuild_scan.max_log_by_life.emplace(UInt128{3}, RefTxnId{3, 12}); + + const RefPlan ordinary = tests::buildRefWalkPlanForTest(ordinary_scan, cut); + const RefPlan rebuild = tests::buildRefWalkPlanForTest(rebuild_scan, cut); + const auto ordinary_parent_states = ordinary.parentFoldStates(); + const auto rebuild_parent_states = rebuild.parentFoldStates(); + const auto ordinary_successor_states = ordinary.successorFoldStates(); + const auto rebuild_successor_states = rebuild.successorFoldStates(); + EXPECT_EQ(ordinary_parent_states.size(), 1u); + EXPECT_TRUE(ordinary_parent_states.contains(UInt128{3})); + EXPECT_FALSE(ordinary_parent_states.contains(UInt128{2})); + EXPECT_TRUE(ordinary_successor_states.contains(UInt128{2})); + EXPECT_TRUE(ordinary_successor_states.contains(UInt128{3})); + EXPECT_TRUE(rebuild_parent_states.empty()); + EXPECT_TRUE(rebuild_successor_states.contains(UInt128{2})); + EXPECT_TRUE(rebuild_successor_states.contains(UInt128{3})); + const std::set expected{UInt128{2}, UInt128{3}}; + EXPECT_EQ(ordinary.lifeIds(), expected); + EXPECT_EQ(rebuild.lifeIds(), expected); + + EXPECT_TRUE(ordinary.row(UInt128{2}).listed_hint); + ASSERT_TRUE(ordinary.row(UInt128{2}).fold_state.coverage.hold); + EXPECT_EQ(ordinary.row(UInt128{2}).checkpoint_observation, (RefTxnId{2, 9})); + EXPECT_EQ(ordinary.row(UInt128{2}).tail_observation, (RefTxnId{2, 10})); + const std::optional cleanup_evidence{ + RefCleanupEvidence{.remove_txn_id = RefTxnId{3, 3}}}; + EXPECT_EQ(ordinary.row(UInt128{3}).fold_state.cleanup_evidence, cleanup_evidence); + EXPECT_EQ(ordinary.row(UInt128{3}).removal_started_round, 8u); + EXPECT_FALSE(ordinary.contains(UInt128{1})); + EXPECT_FALSE(ordinary.contains(UInt128{4})); + + EXPECT_TRUE(rebuild.row(UInt128{3}).listed_hint); + ASSERT_TRUE(rebuild.row(UInt128{3}).fold_state.coverage.hold); + EXPECT_EQ(rebuild.row(UInt128{3}).checkpoint_observation, (RefTxnId{3, 11})); + EXPECT_EQ(rebuild.row(UInt128{3}).tail_observation, (RefTxnId{3, 12})); + EXPECT_FALSE(rebuild.contains(UInt128{1})); + EXPECT_FALSE(rebuild.contains(UInt128{5})); +} + +TEST(CASGCStuckRemoval, ThresholdAndRestartUseOnlyDurableRounds) +{ + const Layout layout("p"); + RefWalkPlanRow row{ + .life = NamespaceLifeId::fromCatalogEntry(RootNamespace{"removing"}, UInt128{7}), + .fold_state = {}, + .removal_started_round = 10, + .has_parent_fold_state = false, + .listed_hint = false, + .checkpoint_observation = std::nullopt, + .tail_observation = std::nullopt}; + + EXPECT_FALSE(stuckRemovalWarning(row, /*current_round=*/12, /*threshold_rounds=*/3, layout)); + const auto at_threshold = stuckRemovalWarning(row, /*current_round=*/13, /*threshold_rounds=*/3, layout); + const auto next_round = stuckRemovalWarning(row, /*current_round=*/14, /*threshold_rounds=*/3, layout); + ASSERT_TRUE(at_threshold); + ASSERT_TRUE(next_round); + EXPECT_NE(at_threshold->find("age_rounds=3"), String::npos); + EXPECT_NE(next_round->find("age_rounds=4"), String::npos); + + /// A fresh process given the same durable catalog row and adopted round produces the same signal. + EXPECT_EQ(stuckRemovalWarning(row, 13, 3, layout), at_threshold); + + row.fold_state.cleanup_evidence = RefCleanupEvidence{.remove_txn_id = RefTxnId{1, 1}}; + EXPECT_FALSE(stuckRemovalWarning(row, 100, 3, layout)); +} + +TEST(CASGCStuckRemoval, BoundaryAndAbsentVersusUnreadableMessagesAreExact) +{ + const Layout layout("p"); + RefWalkPlanRow row{ + .life = NamespaceLifeId::fromCatalogEntry(RootNamespace{"removing"}, UInt128{7}), + .fold_state = {}, + .removal_started_round = std::numeric_limits::max(), + .has_parent_fold_state = false, + .listed_hint = false, + .checkpoint_observation = std::nullopt, + .tail_observation = std::nullopt}; + EXPECT_FALSE(stuckRemovalWarning(row, 0, 1, layout)); + + row.removal_started_round = 1; + const auto absent = stuckRemovalWarning(row, 2, 1, layout); + ASSERT_TRUE(absent); + EXPECT_NE(absent->find("terminal has not folded"), String::npos); + EXPECT_EQ(absent->find("/_log/"), String::npos) << "an absent terminal has no exact id to name"; + + row.fold_state.coverage.classification = 4; + row.fold_state.coverage.hold = RefHold{ + .reason = HoldReason::BodyUndecodable, + .offending_position = RefTxnId{5, 6}}; + const auto unreadable = stuckRemovalWarning(row, 2, 1, layout); + ASSERT_TRUE(unreadable); + EXPECT_NE(unreadable->find(layout.refLogKey(row.life, RefTxnId{5, 6})), String::npos); + EXPECT_NE(unreadable->find("is unreadable"), String::npos); + EXPECT_NE(unreadable->find("restore the exact object"), String::npos); + EXPECT_NE(unreadable->find("recreate the pool"), String::npos); + EXPECT_EQ(unreadable->find("REBUILD"), String::npos) + << "the diagnostic must not promise a command that cannot recover this exact object"; +} + +TEST(CASGCStuckRemoval, DiagnosticDoesNotAppendOrMutateBackend) +{ + DB::Cas::tests::CountingBackend backend; + const Layout layout("p"); + const RefWalkPlanRow row{ + .life = NamespaceLifeId::fromCatalogEntry(RootNamespace{"removing"}, UInt128{7}), + .fold_state = {}, + .removal_started_round = 1, + .has_parent_fold_state = false, + .listed_hint = false, + .checkpoint_observation = std::nullopt, + .tail_observation = std::nullopt}; + const uint64_t puts_before = backend.putTotal(); + const uint64_t cas_before = backend.casPutTotal(); + EXPECT_TRUE(stuckRemovalWarning(row, 11, 10, layout)); + EXPECT_EQ(backend.putTotal(), puts_before); + EXPECT_EQ(backend.casPutTotal(), cas_before); + EXPECT_EQ(backend.deleteTotal(), 0u); +} + +TEST(CASGCStuckRemoval, AdoptedRoundWarnsEveryRestartWithoutAppending) +{ + auto backend = std::make_shared(); + auto store = Pool::open(backend, PoolConfig{ + .pool_prefix = "p", + .server_root_id = "test", + .gc_stuck_removal_rounds = 10}); + const Layout & layout = store->layout(); + const UInt128 gc_id{99}; + const UInt128 life_id{7}; + + const CatalogEntry removing{ + .ns = RootNamespace{"removing"}, + .state = NsState::Removing, + .incarnation = life_id, + .removal_started_round = 1}; + const auto catalog = backend->get(layout.refCatalogKey()); + ASSERT_TRUE(catalog); + ASSERT_EQ(backend->casPut( + layout.refCatalogKey(), encodeRefCatalog(RefCatalog{.entries = {removing}}), catalog->token).outcome, + CasOutcome::Committed); + + CasFoldSeal seal; + seal.generation = 1; + seal.ref_lives.emplace(life_id, RefLifeFoldState{ + .coverage = RefCoverage{ + .classification = 4, + .hold = RefHold{ + .reason = HoldReason::BodyUndecodable, + .offending_position = RefTxnId{5, 6}, + .retry_count = 0, + .next_retry_round = 12}}}); + seal.condemned_summary[0] = CondemnedSummary{}; + ASSERT_EQ(backend->putIfAbsent(layout.foldSealKey(1, 1), encodeFoldSeal(seal)).outcome, PutOutcome::Done); + + GcState state; + state.lease = GcLease{.owner = gc_id, .seq = 1}; + state.round = 11; + state.gc_shards = 1; + state.snap_generation = 1; + state.snap_attempt = 1; + ASSERT_EQ(backend->putIfAbsent(layout.gcStateKey(), encodeGcState(state)).outcome, PutOutcome::Done); + + const uint64_t signals_before + = ProfileEvents::global_counters[ProfileEvents::CASGCStuckRemovals].load(); + const NamespaceLifeId life = NamespaceLifeId::fromCatalogEntry(removing.ns, life_id); + const String unreadable_ref_log_key = layout.refLogKey(life, RefTxnId{5, 6}); + const uint64_t append_puts_before = backend->putCount(unreadable_ref_log_key); + ScopedCasGcLogCapture log_capture; + Gc first_process(store, gc_id); + EXPECT_TRUE(first_process.runRegularRound().acquired_lease); + Gc restarted_process(store, gc_id); + EXPECT_TRUE(restarted_process.runRegularRound().acquired_lease); + + EXPECT_EQ(ProfileEvents::global_counters[ProfileEvents::CASGCStuckRemovals].load() - signals_before, 2u); + EXPECT_EQ(backend->putCount(unreadable_ref_log_key), append_puts_before) + << "the diagnostic cannot append the unreadable ref log"; + const String captured = log_capture.captured(); + EXPECT_EQ(std::count(captured.begin(), captured.end(), '\n'), 2u); + EXPECT_NE(captured.find(unreadable_ref_log_key), String::npos); + EXPECT_NE(captured.find("is unreadable"), String::npos); +} + +TEST(CASGCStuckRemoval, ZeroThresholdIsRefusedAtGcConstruction) +{ + auto backend = std::make_shared(); + auto store = Pool::open(backend, PoolConfig{ + .pool_prefix = "p", + .server_root_id = "test", + .gc_stuck_removal_rounds = 0}); + DB::Cas::tests::expectThrowsCode(DB::ErrorCodes::BAD_ARGUMENTS, + [&] { Gc gc(store, UInt128{1}); }); +} + +TEST(CASGCRefWalkPlan, UnmatchedAdoptedParentLifeIsObservedWithoutEnteringThePlan) +{ + const NamespaceLifePhysicalId current_life{2}; + const NamespaceLifePhysicalId unmatched_life = + hexToU128("fedcba98765432100123456789abcdef"); + RefCatalog catalog{.entries = {liveEntry("live", 2)}}; + const CasRefCatalog::Snapshot cut{ + .catalog = catalog, .token = std::nullopt, .life_index = CatalogLifeIndex(catalog)}; + RefScanSummary scan; + scan.parent_ref_lives.emplace(current_life, RefLifeFoldState{ + .coverage = RefCoverage{.classification = 2, .last_folded_ref_id = RefTxnId{2, 3}}}); + scan.parent_ref_lives.emplace(unmatched_life, RefLifeFoldState{ + .coverage = RefCoverage{.classification = 2, .last_folded_ref_id = RefTxnId{9, 9}}}); + + const uint64_t events_before = + ProfileEvents::global_counters[ProfileEvents::CASGCUnmatchedAdoptedParentLives].load(); + const RefPlan plan = tests::buildRefWalkPlanForTest(scan, cut); + + EXPECT_EQ( + ProfileEvents::global_counters[ProfileEvents::CASGCUnmatchedAdoptedParentLives].load() - events_before, + 1u); + EXPECT_EQ(plan.droppedParentRows(), 1u); + EXPECT_EQ(plan.size(), 1u); + EXPECT_TRUE(plan.contains(current_life)); + EXPECT_FALSE(plan.contains(unmatched_life)); + EXPECT_FALSE(plan.parentFoldStates().contains(unmatched_life)); + EXPECT_FALSE(plan.successorFoldStates().contains(unmatched_life)); +} + +TEST(CASGCRefPlan, RoundInputOwnsObservationsAndSuccessorStateCannotChangePlan) +{ + /// This catches a plan that borrows the post-LIST observations or lets its successor state alias a + /// row. Replacing the owning `RoundInput`/`RefPlan` boundary with the former loose inputs, or + /// returning plan storage for the successor, must make this fail. + static_assert(!std::is_constructible_v); + static_assert(!std::is_default_constructible_v); + static_assert(!std::is_default_constructible_v); + static_assert(!std::is_assignable_v); + static_assert(!std::is_assignable_v); + + RefCatalog catalog; + catalog.entries = {liveEntry("live", 2)}; + CasRefCatalog::Snapshot cut{ + .catalog = catalog, .token = std::nullopt, .life_index = CatalogLifeIndex(catalog)}; + + RefScanSummary observations; + observations.max_log_by_life.emplace(UInt128{2}, RefTxnId{2, 7}); + observations.parent_ref_lives.emplace(UInt128{2}, RefLifeFoldState{ + .coverage = RefCoverage{.classification = 2, .last_folded_ref_id = RefTxnId{2, 3}}}); + + const RefPlan plan = tests::buildRefWalkPlanForTest(observations, cut); + + /// The caller may reuse and mutate the sources after its one post-LIST/catalog observation and + /// plan construction. Those mutations cannot retarget the plan DEFER, fold, and publication use. + observations.max_log_by_life.at(UInt128{2}) = RefTxnId{2, 99}; + observations.parent_ref_lives.at(UInt128{2}).coverage.last_folded_ref_id = RefTxnId{2, 88}; + cut.catalog.entries.clear(); + + ASSERT_TRUE(plan.contains(UInt128{2})); + EXPECT_EQ(plan.row(UInt128{2}).tail_observation, (RefTxnId{2, 7})); + EXPECT_EQ(plan.row(UInt128{2}).fold_state.coverage.last_folded_ref_id, (RefTxnId{2, 3})); + + /// A fold/rebuild successor starts as a copy. It can earn a new cleanup state without changing the + /// immutable input that DEFER, the fold, and publication all consume. + auto successor_lives = plan.successorFoldStates(); + successor_lives.at(UInt128{2}).coverage.last_folded_ref_id = RefTxnId{2, 9}; + successor_lives.emplace(UInt128{9}, RefLifeFoldState{}); + EXPECT_EQ(plan.row(UInt128{2}).fold_state.coverage.last_folded_ref_id, (RefTxnId{2, 3})); + EXPECT_FALSE(plan.contains(UInt128{9})); +} diff --git a/src/Disks/tests/gtest_cas_ref_catalog_birth_wiring.cpp b/src/Disks/tests/gtest_cas_ref_catalog_birth_wiring.cpp new file mode 100644 index 000000000000..1ea4b8096e4d --- /dev/null +++ b/src/Disks/tests/gtest_cas_ref_catalog_birth_wiring.cpp @@ -0,0 +1,512 @@ +#include + +#include +#include +#include +#include +#include +#include + +#include +#include +#include + +namespace DB::ErrorCodes +{ +extern const int CORRUPTED_DATA; +extern const int NETWORK_ERROR; +} + +/// Stage B Task 4-C: production birth wiring. `CasRefLedger::resolveNamespaceLife`, called from +/// `ensureRefTableRecovered`, resolves a namespace's real catalog life ONCE per table-open -- +/// create-if-absent, adopt an existing `Live`/`Removing` entry, or reconcile a stale `Creating` one via +/// `CasRefCatalog::reconcileStaleCreator` + `isCreatorFenceTerminal` -- so every ref-layer object a +/// mounted writer produces is keyed at a real, catalog-proven incarnation (spec INV-3), never the +/// Stage-A sentinel. +/// +/// OBLIGATION 3 (carried from Task 3's review, closed here): Task 3 could only enforce "`Creating` +/// forbids publication" (`CasRefCatalog::checkPublicationAdmittedOrThrow`) AT THE CATALOG LEVEL, because +/// nothing on the production ref-write path consulted the catalog at all. The refusal this suite pins +/// below rests on CONSTRUCTION, not a check: there is no `if (state == Creating) throw` anywhere in +/// `appendRefOps`'s path. `ensureRefTableRecovered` simply cannot make a table's runtime usable +/// (`rt.recovered` never becomes `true`, `rt.life` never gets set) while the catalog entry is `Creating` +/// under a fence that is not provably dead -- so no append can reach `commitRefChunk` for such a +/// namespace, by construction, stronger than any per-write check could prove. Stated here so nobody +/// later greps for a check and concludes the gap Task 3's review flagged is still open. +/// +/// The suite name is prefixed `Cas` so it is covered by the `Cas*` unit-test gate filter. + +using namespace DB::Cas; +using namespace DB::Cas::tests; + +namespace +{ + +/// Fault the mandatory catalog's very first bootstrap write before it reaches durable storage. This +/// models a definite write failure, distinct from the acknowledgement-loss shape below: a retry must +/// still be allowed to prove a new pool, and the failed first attempt must not have published +/// `_pool_meta` without the catalog it makes mandatory. +class CatalogBootstrapPutFailsOnceBackend final : public CountingBackend +{ +public: + using CountingBackend::putIfAbsent; + + PutResult putIfAbsent(const String & key, const String & bytes, const ObjectMeta & meta) override + { + if (fail_once && key == Layout{"p"}.refCatalogKey()) + { + fail_once = false; + throw Poco::TimeoutException("CatalogBootstrapPutFailsOnceBackend: catalog PUT did not land"); + } + return CountingBackend::putIfAbsent(key, bytes, meta); + } + +private: + bool fail_once = true; +}; + +class CatalogCancellationRaceBackend final : public CountingBackend +{ +public: + using CountingBackend::casPut; + + CasResult casPut( + const String & key, const String & bytes, const std::optional & expected, + const ObjectMeta & meta) override + { + if (race_armed && key == Layout{"p"}.refCatalogKey()) + { + race_armed = false; + on_catalog_cas(); + } + return CountingBackend::casPut(key, bytes, expected, meta); + } + + bool race_armed = false; + std::function on_catalog_cas; +}; + +PoolPtr openPoolForBirthTest(const BackendPtr & backend, const String & server_root_id = "test") +{ + seedPoolMetaForRestart(*backend); + return Pool::open(backend, PoolConfig{.pool_prefix = "p", .server_root_id = server_root_id}); +} + +const CatalogEntry * findEntry(const RefCatalog & catalog, const RootNamespace & ns) +{ + for (const CatalogEntry & e : catalog.entries) + if (e.ns.string() == ns.string()) + return &e; + return nullptr; +} + +/// The same one-transaction publish `gtest_cas_ref_ckpt.cpp`'s `publishRef` drives: a namespace's first +/// append through the REAL append lane, which is also what triggers `resolveNamespaceLife`. +RefTxnId publishBirth(const PoolPtr & store, const RootNamespace & ns, const String & ref) +{ + return store->appendRefOps(ns, MutationScope::ref(ref), + [&ref](const RefTableState & state) + { + std::vector ops; + if (state.getLifecycle() != RefLifecycle::Live) + ops.push_back(namespaceBirthOp()); + for (const RefOp & op : publishCommittedOps(ref, ManifestRef{1, 1, 1})) + ops.push_back(op); + return ops; + }, + RootMutationOrigin::Writer, RootMutationKind::Publish); +} + +} + +/// The happy path: nothing to reconcile, no pre-existing entry. The first append mints a fresh `Live` +/// catalog entry and keys the birth transaction at it -- not at the Stage-A sentinel. +TEST(CASRefCatalogBirthWiring, FirstOpenMintsALiveCatalogEntryAndKeysTheBirthAtIt) +{ + auto backend = std::make_shared(); + auto store = openPoolForBirthTest(backend); + const RootNamespace ns{"srv1/birth_wiring"}; + + const RefTxnId id = publishBirth(store, ns, "a"); + EXPECT_EQ(id, (RefTxnId{store->writerEpoch(), 1})); + + const CasRefCatalog::Snapshot snap = CasRefCatalog::read(*backend, store->layout()); + const CatalogEntry * entry = findEntry(snap.catalog, ns); + ASSERT_NE(entry, nullptr) << "the first open must mint a catalog entry"; + EXPECT_EQ(entry->state, NsState::Live); + EXPECT_NE(entry->incarnation, UInt128(0)); + EXPECT_EQ(entry->creator, std::nullopt) << "creator is forbidden outside Creating (strict grammar)"; + + const NamespaceLifeId life = NamespaceLifeId::fromCatalogEntry(entry->ns, entry->incarnation); + EXPECT_TRUE(backend->head(store->layout().refLogKey(life, id)).exists) + << "the birth transaction must be keyed at the REAL minted incarnation, not the Stage-A sentinel"; + EXPECT_FALSE(backend->head(store->layout().refLogKey(fixture::fixtureLife(ns), id)).exists) + << "and must NOT be keyed at the sentinel any more"; +} + +TEST(CASRefCatalogBirthWiring, CatalogLossAfterMountCannotRecreateAOneRowAuthority) +{ + auto backend = std::make_shared(); + auto store = openPoolForBirthTest(backend); + const Layout & layout = store->layout(); + + publishBirth(store, RootNamespace{"srv1/existing"}, "old"); + const auto catalog = backend->get(layout.refCatalogKey()); + ASSERT_TRUE(catalog); + ASSERT_EQ(backend->deleteExact(layout.refCatalogKey(), catalog->token).kind, + DeleteOutcome::Kind::Deleted); + backend->resetCounts(); + + EXPECT_THROW(publishBirth(store, RootNamespace{"srv1/new"}, "new"), DB::Exception); + EXPECT_FALSE(backend->head(layout.refCatalogKey()).exists) + << "runtime loss must not be repaired with a one-row replacement authority"; + EXPECT_EQ(backend->casPutTotal(), 0u); + EXPECT_EQ(backend->putTotal(), 0u) + << "the failed birth must not publish a checkpoint or ref-log body"; + EXPECT_EQ(backend->putOverwriteTotal(), 0u); +} + +TEST(CASRefCatalogBirthWiring, FailedCatalogBootstrapDoesNotPublishPoolMetaAndRetryConverges) +{ + auto backend = std::make_shared(); + const Layout layout{"p"}; + + EXPECT_ANY_THROW(Pool::open(backend, PoolConfig{.pool_prefix = "p", .server_root_id = "test"})); + EXPECT_FALSE(backend->head(layout.poolMetaKey()).exists) + << "a failed mandatory catalog bootstrap must leave no authoritative pool meta behind"; + EXPECT_FALSE(backend->head(layout.refCatalogKey()).exists); + + PoolPtr retry; + ASSERT_NO_THROW(retry = Pool::open(backend, PoolConfig{.pool_prefix = "p", .server_root_id = "test"})); + EXPECT_TRUE(backend->head(layout.poolMetaKey()).exists); + EXPECT_TRUE(backend->head(layout.refCatalogKey()).exists); +} + +TEST(CASRefCatalogBirthWiring, LostCatalogBootstrapAcknowledgementLeavesOnlyRetryableCatalogResidue) +{ + auto backend = std::make_shared(); + const Layout layout{"p"}; + backend->key_substr = layout.refCatalogKey(); + + EXPECT_ANY_THROW(Pool::open(backend, PoolConfig{.pool_prefix = "p", .server_root_id = "test"})); + EXPECT_FALSE(backend->head(layout.poolMetaKey()).exists); + EXPECT_TRUE(backend->head(layout.refCatalogKey()).exists) + << "the injected write must land before its acknowledgement is lost"; + + PoolPtr retry; + ASSERT_NO_THROW(retry = Pool::open(backend, PoolConfig{.pool_prefix = "p", .server_root_id = "test"})); + EXPECT_TRUE(backend->head(layout.poolMetaKey()).exists); +} + +TEST(CASRefCatalogBirthWiring, BootstrapConflictExactReadsTheCanonicalEmptyCatalog) +{ + auto backend = std::make_shared(); + const Layout layout{"p"}; + const String canonical_empty = encodeRefCatalog(RefCatalog{}); + ASSERT_EQ(backend->putIfAbsent(layout.refCatalogKey(), canonical_empty).outcome, PutOutcome::Done); + backend->resetCounts(); + + const CasRefCatalog::Snapshot snap = CasRefCatalog::initializeEmptyForNewPool(*backend, layout); + EXPECT_TRUE(snap.catalog.entries.empty()); + EXPECT_EQ(backend->putCount(layout.refCatalogKey()), 1u); + EXPECT_EQ(backend->getCount(layout.refCatalogKey()), 1u) + << "a concurrent bootstrap winner must be exact-read before acceptance"; +} + +TEST(CASRefCatalogBirthWiring, BootstrapConflictRefusesANonemptyCatalog) +{ + auto backend = std::make_shared(); + const Layout layout{"p"}; + const RefCatalog nonempty{.entries = {CatalogEntry{ + .ns = RootNamespace{"test/nonempty"}, .state = NsState::Live, .incarnation = UInt128{1}, .creator = std::nullopt}}}; + ASSERT_EQ(backend->putIfAbsent(layout.refCatalogKey(), encodeRefCatalog(nonempty)).outcome, PutOutcome::Done); + backend->resetCounts(); + + expectThrowsCode(DB::ErrorCodes::CORRUPTED_DATA, + [&] { CasRefCatalog::initializeEmptyForNewPool(*backend, layout); }); + EXPECT_EQ(backend->getCount(layout.refCatalogKey()), 1u); +} + +TEST(CASRefCatalogBirthWiring, ExistingPoolMetaWithMissingCatalogStillFailsClosed) +{ + auto backend = std::make_shared(); + PoolPtr first = Pool::open(backend, PoolConfig{.pool_prefix = "p", .server_root_id = "test"}); + const auto catalog = backend->get(first->layout().refCatalogKey()); + ASSERT_TRUE(catalog); + ASSERT_EQ(backend->deleteExact(first->layout().refCatalogKey(), catalog->token).kind, + DeleteOutcome::Kind::Deleted); + + expectThrowsCode(DB::ErrorCodes::CORRUPTED_DATA, + [&] { Pool::open(backend, PoolConfig{.pool_prefix = "p", .server_root_id = "test"}); }); +} + +TEST(CASRefCatalogBirthWiring, RestartFixturePreservesItsExistingNonemptyCatalog) +{ + auto backend = std::make_shared(); + const Layout layout{"p"}; + seedPoolMetaForRestart(*backend); + const auto empty = backend->get(layout.refCatalogKey()); + ASSERT_TRUE(empty); + + const RefCatalog nonempty{.entries = {CatalogEntry{ + .ns = RootNamespace{"test/preserved"}, .state = NsState::Live, .incarnation = UInt128{1}, .creator = std::nullopt}}}; + const String bytes = encodeRefCatalog(nonempty); + ASSERT_EQ(backend->putOverwrite(layout.refCatalogKey(), bytes, empty->token).outcome, PutOutcome::Done); + const auto before = backend->get(layout.refCatalogKey()); + ASSERT_TRUE(before); + + seedPoolMetaForRestart(*backend); + const auto after = backend->get(layout.refCatalogKey()); + ASSERT_TRUE(after); + EXPECT_EQ(after->bytes, before->bytes); + EXPECT_EQ(after->token, before->token); +} + +/// A namespace whose catalog entry is ALREADY `Live` (e.g. admitted by an earlier mount that this +/// runtime never cached) must be ADOPTED, never re-minted: `CasRefCatalog::createNamespace` refuses +/// outright once any entry exists, so `resolveNamespaceLife` has no create branch left to take here -- +/// only the adopt branch can succeed. +TEST(CASRefCatalogBirthWiring, AnExistingLiveEntryIsAdoptedRatherThanReminted) +{ + auto backend = std::make_shared(); + auto store = openPoolForBirthTest(backend); + const Layout & layout = store->layout(); + const RootNamespace ns{"srv1/adopt_live"}; + + const CatalogEntry entry{.ns = ns, .state = NsState::Live, .incarnation = UInt128(0xcafe), + .creator = std::nullopt}; + CasRefCatalog::casAdmitEntry(*backend, layout, 1, entry); + DB::Cas::tests::writeRecoverableCkptForRawFixture(*backend, layout, ns, RefCkpt{ + .life_epoch = store->writerEpoch(), + .committed_through = std::nullopt, + .checkpoint_snapshot_id = std::nullopt, + .last_epoch_seal = std::nullopt, + }); + + const RefTxnId id = publishBirth(store, ns, "a"); + EXPECT_EQ(id, (RefTxnId{store->writerEpoch(), 1})); + + /// The read result must outlive the returned pointer -- findEntry points into its entries. + const auto after_cut = CasRefCatalog::read(*backend, layout); + const CatalogEntry * after = findEntry(after_cut.catalog, ns); + ASSERT_NE(after, nullptr); + EXPECT_EQ(after->incarnation, UInt128(0xcafe)) << "adopted, not re-minted"; + EXPECT_EQ(after->state, NsState::Live); + + const NamespaceLifeId life = NamespaceLifeId::fromCatalogEntry(ns, UInt128(0xcafe)); + EXPECT_TRUE(backend->head(layout.refLogKey(life, id)).exists); +} + +/// OBLIGATION 3, pinned through the PRODUCTION path: a `Creating` entry left by a DIFFERENT, still-live +/// (or at least not provably dead) actor refuses every append -- no test-only seam, no direct call to +/// `resolveNamespaceLife`/`reconcileStaleCreator`, just an ordinary `appendRefOps`. +TEST(CASRefCatalogBirthWiring, ANamespaceStuckCreatingUnderALiveForeignFenceRefusesProductionPublicationByConstruction) +{ + auto backend = std::make_shared(); + auto store = openPoolForBirthTest(backend); + const Layout & layout = store->layout(); + const RootNamespace ns{"srv1/stuck_creating"}; + + /// A DIFFERENT actor's `Creating` entry naming a server root that never mounted at all -- + /// `isCreatorFenceTerminal`'s own doc: an ABSENT mount slot answers nothing about liveness, so it + /// is treated as NOT terminal (fail closed), never as proof of death. + const CreatorFence foreign_creator{.server_root_id = "ghost-server", .writer_epoch = 9, .fence_generation = 1}; + const CatalogEntry entry{.ns = ns, .state = NsState::Creating, .incarnation = UInt128(0xdead), + .creator = foreign_creator}; + CasRefCatalog::casAdmitEntry(*backend, layout, 1, entry); + backend->resetCounts(); + + expectThrowsCode(DB::ErrorCodes::NETWORK_ERROR, [&] { publishBirth(store, ns, "a"); }); + + /// Nothing was written: the entry is exactly as observed, still Creating, still the foreign fence. + /// The read result must outlive the returned pointer -- findEntry points into its entries. + const auto still_cut = CasRefCatalog::read(*backend, layout); + const CatalogEntry * still = findEntry(still_cut.catalog, ns); + ASSERT_NE(still, nullptr); + EXPECT_EQ(*still, entry) << "a refused resolution must write nothing"; + EXPECT_EQ(backend->putTotal(), 0u); + EXPECT_EQ(backend->putOverwriteTotal(), 0u); + EXPECT_EQ(backend->casPutTotal(), 0u); +} + +/// The mirror image, and Task 3's own deferred obligation ("wire `reconcileStaleCreator` and pin it +/// with a test that drives reconciliation through the discovery path rather than by calling the +/// primitive directly"): a dead predecessor's `Creating` entry is reconciled onto THIS mount and +/// completed to `Live`, over the SAME incarnation -- resumption, not rebirth. +TEST(CASRefCatalogBirthWiring, AStaleCreatingEntryFromATerminatedForeignFenceIsReconciledThroughTheProductionPath) +{ + auto backend = std::make_shared(); + auto store = openPoolForBirthTest(backend, "this-server"); + const Layout & layout = store->layout(); + const RootNamespace ns{"srv1/reconciled"}; + + /// A dead predecessor's `Creating` entry: its mount lease carries the clean-farewell sentinel + /// (`min_active == UINT64_MAX`), one of `isCreatorFenceTerminal`'s three certificates of death. + const CreatorFence dead_creator{.server_root_id = "dead-server", .writer_epoch = 3, .fence_generation = 1}; + const CatalogEntry entry{.ns = ns, .state = NsState::Creating, .incarnation = UInt128(0xbeef), + .creator = dead_creator}; + CasRefCatalog::casAdmitEntry(*backend, layout, 1, entry); + setWatermarkMinActive(*backend, layout, "dead-server", /*writer_epoch=*/3, + /*min_active=*/std::numeric_limits::max()); + + /// The production path resumes creation itself: reconciles the stale entry onto THIS mount's own + /// fence and completes it to `Live`, over the SAME incarnation the dead creator minted. + const RefTxnId id = publishBirth(store, ns, "a"); + EXPECT_EQ(id, (RefTxnId{store->writerEpoch(), 1})); + + /// The read result must outlive the returned pointer -- findEntry points into its entries. + const auto live_cut = CasRefCatalog::read(*backend, layout); + const CatalogEntry * live = findEntry(live_cut.catalog, ns); + ASSERT_NE(live, nullptr); + EXPECT_EQ(live->state, NsState::Live); + EXPECT_EQ(live->incarnation, UInt128(0xbeef)) << "the SAME incarnation throughout -- resumption, not rebirth"; + EXPECT_EQ(live->creator, std::nullopt); + + const NamespaceLifeId life = NamespaceLifeId::fromCatalogEntry(ns, UInt128(0xbeef)); + EXPECT_TRUE(backend->head(layout.refLogKey(life, id)).exists); +} + +TEST(CASRefCatalogBirthWiring, DropRefusesLiveCreatingFenceWithZeroCatalogMutation) +{ + auto backend = std::make_shared(); + auto store = openPoolForBirthTest(backend); + const Layout & layout = store->layout(); + const RootNamespace ns{"drop_live_creator"}; + const CatalogEntry creating{ + .ns = ns, + .state = NsState::Creating, + .incarnation = UInt128{0xd001}, + .creator = CreatorFence{.server_root_id = "unproven-live", .writer_epoch = 7, .fence_generation = 1}}; + CasRefCatalog::casAdmitEntry(*backend, layout, 1, creating); + backend->resetCounts(); + + expectThrowsCode(DB::ErrorCodes::NETWORK_ERROR, [&] { store->dropNamespace(ns); }); + EXPECT_EQ(backend->putTotal(), 0u); + EXPECT_EQ(backend->putOverwriteTotal(), 0u); + EXPECT_EQ(backend->casPutTotal(), 0u); + EXPECT_EQ(backend->deleteTotal(), 0u); + EXPECT_EQ(CasRefCatalog::read(*backend, layout).catalog.entries, std::vector{creating}); +} + +TEST(CASRefCatalogBirthWiring, DropDeletesTerminalCreatingExactlyAndLeavesCkptForJanitor) +{ + auto backend = std::make_shared(); + auto store = openPoolForBirthTest(backend); + const Layout & layout = store->layout(); + const RootNamespace ns{"drop_terminal_creator"}; + const CatalogEntry creating{ + .ns = ns, + .state = NsState::Creating, + .incarnation = UInt128{0xd002}, + .creator = CreatorFence{.server_root_id = "dead-creator", .writer_epoch = 8, .fence_generation = 1}}; + CasRefCatalog::casAdmitEntry(*backend, layout, 1, creating); + setWatermarkMinActive(*backend, layout, "dead-creator", 8, std::numeric_limits::max()); + const NamespaceLifeId old_life = NamespaceLifeId::fromCatalogEntry(ns, creating.incarnation); + const String ckpt_key = layout.refCkptKey(old_life); + ASSERT_EQ(backend->putIfAbsent(ckpt_key, "stalled-ckpt").outcome, PutOutcome::Done); + backend->resetCounts(); + + store->dropNamespace(ns); + EXPECT_EQ(backend->casPutCount(layout.refCatalogKey()), 1u); + EXPECT_EQ(backend->deleteTotal(), 0u); + EXPECT_EQ(backend->deleteCount(ckpt_key), 0u); + EXPECT_TRUE(backend->head(ckpt_key).exists); + EXPECT_TRUE(CasRefCatalog::read(*backend, layout).catalog.entries.empty()); + + const NamespaceLifeId reborn = store->namespaceLife(ns); + EXPECT_NE(reborn.incarnation, old_life.incarnation); +} + +TEST(CASRefCatalogBirthWiring, DropLosesExactCreatingRaceToReconciliationWithoutDeletingCkpt) +{ + auto backend = std::make_shared(); + auto store = openPoolForBirthTest(backend); + const Layout & layout = store->layout(); + const RootNamespace ns{"drop_reconcile_race"}; + const CreatorFence old_creator{ + .server_root_id = "dead-racing-creator", .writer_epoch = 9, .fence_generation = 1}; + const CatalogEntry creating{ + .ns = ns, .state = NsState::Creating, .incarnation = UInt128{0xd003}, .creator = old_creator}; + CasRefCatalog::casAdmitEntry(*backend, layout, 1, creating); + setWatermarkMinActive( + *backend, layout, old_creator.server_root_id, old_creator.writer_epoch, + std::numeric_limits::max()); + const NamespaceLifeId life = NamespaceLifeId::fromCatalogEntry(ns, creating.incarnation); + const String ckpt_key = layout.refCkptKey(life); + ASSERT_EQ(backend->putIfAbsent(ckpt_key, "stalled-ckpt").outcome, PutOutcome::Done); + backend->on_catalog_cas = [&] + { + EXPECT_EQ(CasRefCatalog::reconcileStaleCreator( + *backend, layout, creating, + CreatorFence{.server_root_id = "replacement", .writer_epoch = 10, .fence_generation = 1}, + [](const CreatorFence &) { return true; }, store->fenceGeneration(), + [](uint64_t) {}), CasRefCatalog::ReconcileCreatorOutcome::Reconciled); + }; + backend->race_armed = true; + backend->resetCounts(); + + expectThrowsCode(DB::ErrorCodes::NETWORK_ERROR, [&] { store->dropNamespace(ns); }); + EXPECT_EQ(backend->deleteTotal(), 0u); + EXPECT_EQ(backend->deleteCount(ckpt_key), 0u); + EXPECT_TRUE(backend->head(ckpt_key).exists); + const CasRefCatalog::Snapshot after = CasRefCatalog::read(*backend, layout); + ASSERT_EQ(after.catalog.entries.size(), 1u); + ASSERT_TRUE(after.catalog.entries.front().creator); + EXPECT_EQ(after.catalog.entries.front().creator->server_root_id, "replacement"); +} + +TEST(CASRefCatalogBirthWiring, FencedDropCannotCancelTerminalCreating) +{ + auto backend = std::make_shared(); + auto store = openPoolForBirthTest(backend); + const Layout & layout = store->layout(); + const RootNamespace ns{"fenced_drop_terminal_creator"}; + const CatalogEntry creating{ + .ns = ns, + .state = NsState::Creating, + .incarnation = UInt128{0xd004}, + .creator = CreatorFence{.server_root_id = "dead-fenced-creator", .writer_epoch = 11, .fence_generation = 1}}; + CasRefCatalog::casAdmitEntry(*backend, layout, 1, creating); + setWatermarkMinActive(*backend, layout, "dead-fenced-creator", 11, std::numeric_limits::max()); + backend->resetCounts(); + + /// The first cancellation attempt passes its fence check, then loses its catalog CAS while the + /// local mount is re-armed at a new fence generation. The retry must re-check the caller fence and + /// refuse before another catalog mutation attempt. + backend->on_catalog_cas = [&] + { + rearmMountFenceAfterAnomalyForTest(store); + backend->failNextCasPut(layout.refCatalogKey()); + }; + backend->race_armed = true; + + expectThrowsCode(DB::ErrorCodes::NETWORK_ERROR, [&] { store->dropNamespace(ns); }); + EXPECT_EQ(backend->casPutCount(layout.refCatalogKey()), 1u); + EXPECT_EQ(backend->deleteTotal(), 0u); + EXPECT_EQ(CasRefCatalog::read(*backend, layout).catalog.entries, std::vector{creating}); +} + +TEST(CASRefCatalogBirthWiring, ExactOldLifeCannotCancelReplacementTerminalCreating) +{ + auto backend = std::make_shared(); + auto store = openPoolForBirthTest(backend); + const Layout & layout = store->layout(); + const RootNamespace ns{"exact_old_life_terminal_creator"}; + const NamespaceLifeId predecessor = NamespaceLifeId::fromCatalogEntry(ns, UInt128{0xd005}); + const CatalogEntry successor{ + .ns = ns, + .state = NsState::Creating, + .incarnation = UInt128{0xd006}, + .creator = CreatorFence{.server_root_id = "dead-successor-creator", .writer_epoch = 12, .fence_generation = 1}}; + CasRefCatalog::casAdmitEntry(*backend, layout, 1, successor); + setWatermarkMinActive(*backend, layout, "dead-successor-creator", 12, std::numeric_limits::max()); + const String ckpt_key = layout.refCkptKey(NamespaceLifeId::fromCatalogEntry(ns, successor.incarnation)); + ASSERT_EQ(backend->putIfAbsent(ckpt_key, "successor-ckpt").outcome, PutOutcome::Done); + backend->resetCounts(); + + expectThrowsCode(DB::ErrorCodes::NETWORK_ERROR, [&] { store->dropNamespace(predecessor); }); + EXPECT_EQ(backend->casPutTotal(), 0u); + EXPECT_EQ(backend->deleteTotal(), 0u); + EXPECT_EQ(backend->deleteCount(ckpt_key), 0u); + EXPECT_EQ(CasRefCatalog::read(*backend, layout).catalog.entries, std::vector{successor}); +} diff --git a/src/Disks/tests/gtest_cas_ref_chunk_preparation.cpp b/src/Disks/tests/gtest_cas_ref_chunk_preparation.cpp new file mode 100644 index 000000000000..c9cc317ab813 --- /dev/null +++ b/src/Disks/tests/gtest_cas_ref_chunk_preparation.cpp @@ -0,0 +1,276 @@ +#include + +#include +#include +#include +#include "cas_test_helpers.h" +#include +#include + +#include + +#include +#include +#include +#include + +/// `prepareRefChunk` is the pure half of `commitRefChunk` (Stage B directive +/// `{#extract-prepare-ref-chunk}`): everything the append lane DECIDES before this chunk can have any +/// durable effect. This TU is where that purity is exercised, and it is deliberately backend-free -- +/// nothing below names a backend, a pool, a ledger instance or a clock, and nothing constructs one. The +/// mechanical guarantee is `static` on `prepareRefChunk` itself: with no `this` there is no member +/// backend, runtime, clock or lock reachable from inside it, so a future edit cannot quietly reach for +/// one and still compile here. +/// +/// What that buys is exactly what shows up below: every case is a direct call, so INV-2's chain-link +/// grammar is swept as a cross product -- including its negatives -- instead of being probed through +/// I/O. +/// +/// The value the extraction protects is pinned elsewhere on purpose: the equivalence fences in +/// `gtest_cas_ref_ckpt.cpp` assert that the durable key, the sealed bytes and the per-key request +/// counts a REAL append produces are unchanged. Those need a backend, so they live there and this TU +/// stays pure. + +namespace DB::ErrorCodes +{ + extern const int CORRUPTED_DATA; +} + +using namespace DB::Cas; + +namespace +{ + +const RootNamespace kNs{"srv1/prep@cas@"}; +const Layout kLayout{"p"}; +/// `prepareRefChunk` takes a resolved catalog life (Stage B, Task 4-C), not a bare namespace; this TU +/// is deliberately backend-free (no catalog to resolve one from), so it threads the Stage-A sentinel +/// through EXPLICITLY as its own test input -- the same value production minted internally before +/// Task 4-C, so every golden byte/key assertion below is unchanged. +const NamespaceLifeId kLife = DB::Cas::tests::fixture::fixtureLife(kNs); + +RefOp birthOp() +{ + RefOp op; + op.kind = RefOpKind::NamespaceBirth; + return op; +} + +RefOp epochSealOp() +{ + RefOp op; + op.kind = RefOpKind::EpochSeal; + return op; +} + +/// A minimal content op: the `AddPrecommit` shape (a pure add of a PRECOMMIT owner). A committed owner +/// is only ever reached by promoting a precommit, so this is the smallest legal content transition. +RefOp addPrecommitOp(const String & ref_name, const ManifestRef & manifest) +{ + RefOp op; + op.kind = RefOpKind::OwnerTransition; + op.new_binding = RefOwnerBinding{RefOwnerKind::Precommit, ref_name, manifest}; + return op; +} + +ManifestRef mref(uint64_t seq) +{ + return ManifestRef{1, seq, 1}; +} + +/// One live namespace, born at `{1,1}`, as the state a later chunk prepares against. +RefTableState bornState() +{ + RefTableState state; + applyRefLogTxn(state, RefLogTxn{kNs.string(), RefTxnId{1, 1}, {birthOp()}, std::nullopt}); + return state; +} + +/// Asserts that preparation REFUSES with `CORRUPTED_DATA` -- the code every ref-log grammar violation +/// normalises to -- and that the message names the chain link, so a row cannot pass because some +/// unrelated validator happened to throw first. +template +void expectGrammarRefusal(F && body, const char * what) +{ + try + { + std::forward(body)(); + FAIL() << "expected a grammar refusal: " << what; + } + catch (const DB::Exception & e) + { + EXPECT_EQ(e.code(), DB::ErrorCodes::CORRUPTED_DATA) << what; + EXPECT_NE(e.message().find("prev_epoch_seal"), String::npos) + << what << " -- refused, but not by the chain-link rule; message: " << e.message(); + } +} + +/// `prepareRefChunk` CONSUMES its state, so this copies -- which also lets every caller below assert +/// afterwards that its own state was left alone. +CasRefLedger::PreparedRefChunk prepare(const RefTableState & state, const RefTxnId & id, + const std::optional & chain_link, + const std::vector & ops, uint64_t admitted_generation = 7) +{ + return CasRefLedger::prepareRefChunk(kLayout, kLife, state, id, chain_link, ops, admitted_generation); +} + +} + +/// The two things that actually become durable -- the key and the sealed body -- are both derivable +/// before any request, and both round-trip: the key parses back to the life and id it names, and the +/// bytes decode back to the very transaction that was prepared. +TEST(CASRefChunkPreparation, PreparedKeyAndSealedBytesAreCanonical) +{ + const RefTxnId id{1, 2}; /// the contiguous successor of the born state's `1-1` + const auto prepared = prepare(bornState(), id, std::nullopt, {addPrecommitOp("r1", mref(3))}); + + const auto parsed = kLayout.parseRefObjectKey(prepared.prepared_attempt.key); + ASSERT_TRUE(parsed.has_value()) << "the prepared key must be one of OUR ref-object keys"; + EXPECT_EQ(parsed->life_id, kLife.incarnation); + EXPECT_EQ(parsed->kind, RefObjectKind::Log); + EXPECT_EQ(parsed->txn_id, id); + EXPECT_EQ(prepared.prepared_attempt.key, kLayout.refLogKey(kLife, id)); + + const RefLogTxn decoded = decodeRefLogTxn( + openObject(FormatId::RefLog, prepared.prepared_attempt.bytes), kNs.string(), id); + EXPECT_EQ(decoded, prepared.chunk_txn) << "the sealed bytes must decode back to the prepared transaction"; + EXPECT_EQ(decoded.ns, kNs.string()); + EXPECT_EQ(decoded.txn_id, id); + ASSERT_EQ(decoded.ops.size(), 1u); + EXPECT_EQ(decoded.ops.front().kind, RefOpKind::OwnerTransition); +} + +/// The base id a later install re-presents is the greatest-applied of the state preparation STARTED +/// from -- not of the candidate it produced. Getting this backwards would let an install adopt a +/// candidate over a state that had moved on. +TEST(CASRefChunkPreparation, CandidateBaseIdIsGreatestApplied) +{ + const RefTableState state = bornState(); + const RefTxnId base = state.getGreatestApplied(); + ASSERT_EQ(base, (RefTxnId{1, 1})) << "precondition: the born state's greatest-applied is its birth"; + + const auto prepared = prepare(state, RefTxnId{1, 2}, std::nullopt, {addPrecommitOp("r1", mref(3))}); + EXPECT_EQ(prepared.candidate_base_id, base) << "the base id describes the state prepared FROM"; + EXPECT_EQ(prepared.candidate.getGreatestApplied(), (RefTxnId{1, 2})) + << "the candidate itself has this chunk applied"; + /// `prepare` handed over a COPY, so the caller's state cannot have been advanced -- the property the + /// real caller relies on when it re-presents `candidate_base_id` at install time. + EXPECT_EQ(state.getGreatestApplied(), base) << "preparation must not mutate the caller's state"; +} + +/// INV-2's chain-link grammar across the full cross product. Preparation runs the real validators, so +/// this sweeps both directions: where the link is required or forbidden, an ill-formed combination must +/// be REFUSED here -- before anything is durable -- rather than sealed into bytes and PUT. That +/// two-sided sweep is what the extraction buys: it needs no backend, so there is no reason not to cover +/// the negatives too. +/// +/// The base state is built per row, because a transaction id is only meaningful as the contiguous +/// successor of some stream (INV-1): a row cannot just assert a grammar rule on an id the stream would +/// never reach. +/// +/// Note which validator each row lands on, because the two halves of the rule are DISJOINT and live in +/// different steps of preparation: the required-iff half is `validateEpochSealGrammarContextual`, run by +/// the candidate apply; the forbidden-off-sequence-1 half is `validateEpochSealGrammarStructural`, run +/// by `encodeRefLogTxn` during the seal. Both are inside preparation, which is the point -- a chunk that +/// passes one and fails the other still fails before any durable effect. +TEST(CASRefChunkPreparation, ChainLinkRequiredExactlyOnSequenceOneOfNonGenesisEpoch) +{ + const std::vector ops{addPrecommitOp("r1", mref(3))}; + const RefTxnId epoch1_seal{1, 5}; /// the seal that closed epoch 1 + + /// From a namespace born at `1-1` (so `life_epoch == 1`). + /// Sequence > 1 of the genesis epoch: the link is FORBIDDEN. + EXPECT_NO_THROW(prepare(bornState(), RefTxnId{1, 2}, std::nullopt, ops)) + << "seq >1 with no link is the ordinary case"; + expectGrammarRefusal([&] { prepare(bornState(), RefTxnId{1, 2}, epoch1_seal, ops); }, + "a link at sequence >1 is forbidden and must be refused before any durable effect"); + + /// Sequence 1 of an epoch ABOVE genesis: the link is REQUIRED. + EXPECT_NO_THROW(prepare(bornState(), RefTxnId{2, 1}, epoch1_seal, ops)) + << "seq 1 of a higher epoch names the seal that closed the previous one"; + expectGrammarRefusal([&] { prepare(bornState(), RefTxnId{2, 1}, std::nullopt, ops); }, + "seq 1 of a higher epoch without a link must be refused -- 'no seal' is a fact " + "about the stream, not a defaulted field"); + + /// Genesis itself: sequence 1 of the birth epoch has nothing to name, so a link is FORBIDDEN. + EXPECT_NO_THROW(prepare(RefTableState{}, RefTxnId{3, 1}, std::nullopt, {birthOp()})) + << "a genesis birth at sequence 1 finds nothing to name"; + expectGrammarRefusal([&] { prepare(RefTableState{}, RefTxnId{3, 1}, epoch1_seal, {birthOp()}); }, + "a link on the birth transaction itself must be refused"); + + /// Whatever the grammar admitted, the sealed bytes carry exactly that link and nothing else. + const auto linked = prepare(bornState(), RefTxnId{2, 1}, epoch1_seal, ops); + ASSERT_TRUE(linked.chunk_txn.prev_epoch_seal.has_value()); + EXPECT_EQ(*linked.chunk_txn.prev_epoch_seal, epoch1_seal); + const RefLogTxn decoded = decodeRefLogTxn( + openObject(FormatId::RefLog, linked.prepared_attempt.bytes), kNs.string(), RefTxnId{2, 1}); + EXPECT_EQ(decoded.prev_epoch_seal, linked.chunk_txn.prev_epoch_seal) + << "the link must survive into the bytes that would become durable"; +} + +/// The birth `_ckpt` contribution is PREPARED here and published by `commitRefChunk`, because +/// publishing it is a birth chunk's first durable effect. Preparation therefore owes two things: the +/// value only for a birth, and the one fact no later writer can recover -- `life_epoch`. +TEST(CASRefChunkPreparation, BirthContributionSetOnlyForNamespaceBirth) +{ + /// A birth chunk at epoch 3: the contribution exists and names THIS transaction's writer epoch. + const RefTxnId birth_id{3, 1}; + const auto born = prepare(RefTableState{}, birth_id, std::nullopt, {birthOp()}); + ASSERT_TRUE(born.birth_contribution.has_value()); + ASSERT_TRUE(born.birth_contribution->life_epoch.has_value()); + EXPECT_EQ(*born.birth_contribution->life_epoch, birth_id.writer_epoch); + EXPECT_FALSE(born.birth_contribution->checkpoint_snapshot_id.has_value()) + << "the birth contributes life_epoch and nothing else -- the publisher owns the checkpoint field"; + EXPECT_FALSE(born.birth_contribution->last_epoch_seal.has_value()); + + /// An ordinary content chunk contributes nothing: a second `_ckpt` write here would be a request the + /// append lane does not owe. + const auto ordinary = prepare(bornState(), RefTxnId{1, 2}, std::nullopt, {addPrecommitOp("r1", mref(3))}); + EXPECT_FALSE(ordinary.birth_contribution.has_value()); + + /// A birth op mixed into a larger chunk still counts -- the check is over the whole chunk. + const auto mixed = prepare(RefTableState{}, RefTxnId{5, 1}, std::nullopt, + {birthOp(), addPrecommitOp("r1", mref(3))}); + ASSERT_TRUE(mixed.birth_contribution.has_value()); + EXPECT_EQ(*mixed.birth_contribution->life_epoch, 5u); +} + +TEST(CASRefChunkPreparation, CommitContributionCarriesFrontierAndOnlyMatchingSeal) +{ + const RefTxnId ordinary_id{1, 2}; + const auto ordinary = prepare(bornState(), ordinary_id, std::nullopt, + {addPrecommitOp("r1", mref(3))}); + EXPECT_EQ(ordinary.commit_contribution.committed_through, ordinary_id); + EXPECT_FALSE(ordinary.commit_contribution.life_epoch.has_value()); + EXPECT_FALSE(ordinary.commit_contribution.checkpoint_snapshot_id.has_value()); + EXPECT_FALSE(ordinary.commit_contribution.last_epoch_seal.has_value()); + + const RefTxnId seal_id{1, 2}; + const auto seal = prepare(bornState(), seal_id, std::nullopt, {epochSealOp()}); + EXPECT_EQ(seal.commit_contribution.committed_through, seal_id); + EXPECT_EQ(seal.commit_contribution.last_epoch_seal, seal_id) + << "an epoch seal and its committed frontier must be one checkpoint contribution"; + EXPECT_FALSE(seal.commit_contribution.life_epoch.has_value()); + EXPECT_FALSE(seal.commit_contribution.checkpoint_snapshot_id.has_value()); +} + +/// The attempt exists so that an `Unresolved` PUT -- an object that may be durable -- can be recorded by +/// a MOVE and nothing else. That only holds if every field is already populated before the request goes +/// out, so this asserts the whole struct is complete at the end of preparation. +TEST(CASRefChunkPreparation, PreparedAttemptIsCompleteBeforeAnyDurableEffect) +{ + const RefTxnId id{1, 2}; + const auto prepared = prepare(bornState(), id, std::nullopt, {addPrecommitOp("r1", mref(3))}, /*admitted_generation=*/42); + + EXPECT_EQ(prepared.prepared_attempt.txn_id, id); + EXPECT_FALSE(prepared.prepared_attempt.key.empty()); + EXPECT_FALSE(prepared.prepared_attempt.bytes.empty()); + EXPECT_EQ(prepared.prepared_attempt.admitted_fence_generation, 42u) + << "the attempt carries the generation it was ADMITTED under, not a current reading"; + + /// Nothing left to build: the key and body the request will read are already the canonical ones, so + /// the arming block's only remaining work really is the move it declares itself to be. + EXPECT_EQ(prepared.prepared_attempt.key, kLayout.refLogKey(kLife, id)); + EXPECT_EQ(prepared.prepared_attempt.bytes, + sealObject(FormatId::RefLog, encodeRefLogTxn(prepared.chunk_txn))); +} diff --git a/src/Disks/tests/gtest_cas_ref_chunked_flush.cpp b/src/Disks/tests/gtest_cas_ref_chunked_flush.cpp new file mode 100644 index 000000000000..2cdc32ff1851 --- /dev/null +++ b/src/Disks/tests/gtest_cas_ref_chunked_flush.cpp @@ -0,0 +1,919 @@ +#include + +#include "config.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace DB::ErrorCodes +{ +extern const int LIMIT_EXCEEDED; +extern const int CORRUPTED_DATA; +extern const int NETWORK_ERROR; +} + +namespace ProfileEvents +{ +extern const Event CASRefBatchFlushes; +extern const Event CASRefBatchedMutations; +extern const Event CASRefSnapshotPublishDispatched; +} + +/// Task 8 (stage-1 §3 "Budget: counts only, chunked flush"): the counts-only admission caps -- +/// `ref_txn_max_ops` (5000), the carve item cap `kMaxRefBatch` (1000), and the per-op size cap +/// `ref_op_max_bytes` (4096 bytes on normal-class ops) -- plus their failure-isolation contract: a +/// single item whose own op count, or whose one op's encoded size, exceeds its cap fails ALONE; a +/// neighbor co-batched into the same flush still commits. `ref_txn_max_ops` is checked exactly (the +/// `build_ops` result's size), and the per-op cap is checked by encoding exactly one op at a time -- +/// no accumulation, matching the admission machinery this replaces. T9 (removal-class detection by +/// op inspection) and T10 (chunked flush across a whole-batch op-count overflow) extend this file; +/// this task adds only the per-item / per-op isolation tests and the canonical round-trip leg of +/// test 12 (the maximum legally-admissible normal-class transaction). +/// +/// The suite name is prefixed `RefWriter` so it is covered by the `RefWriter*` unit-test gate filter. + +using namespace DB::Cas; +using DB::Cas::tests::committedRow; +using DB::Cas::tests::minimalLiveSnapshot; +using DB::Cas::tests::writeRefSnapshotRaw; + +namespace +{ + +PoolPtr openPool(const BackendPtr & backend) +{ + /// A fresh pool with no residue, mirroring the T7 carve suite's `openPool`. + /// + /// The FROZEN clock is load-bearing, not hygiene. `CasMountRuntime::refAppendFenceOk` gates every + /// controlled attempt against `boot_ms_fn`, and with the compiled defaults (mount_lease_ttl_ms + /// 30000, safety margin 7000) a pool opened on the REAL clock fences itself ~23s later — no + /// background renewal advances that deadline in a unit-test pool. Every test in this suite is + /// about chunking and op-caps, none about wall-clock lease behaviour, so any of them that runs + /// long enough simply dies of an unrelated fence trip: `DropNamespaceOverOpCapSucceeds` (5200 + /// refs) takes 43-65s under a sanitizer and failed deterministically on all three sanitizer CI + /// builds with `txn is UNCERTAIN (retry budget exhausted)` — the pre-attempt fence reject, not a + /// real retry exhaustion. Same artifact, same fix as + /// `CASPartWriteTxn.ManifestCapEncodedBytesJustUnderStagesSuccessfully` (2026-07-18): decouple the + /// fence from execution speed. The waits in this file are `steady_clock` timeouts on futures and + /// condvars, which are unaffected by this injection. + DB::Cas::tests::seedPoolMetaForRestart(*backend); + return Pool::open(backend, PoolConfig{ + .pool_prefix = "p", .server_root_id = "test", .boot_ms_fn = [] { return uint64_t{0}; }}); +} + +/// A legal blob-free part: stage an empty manifest, precommit, promote -- enough to leave one +/// committed ref (and a `Live` table) that a later co-batched item can join. +/// +/// Stage B (Task 4-C): pin `ns` to the sentinel before the first real touch -- the ONE choke point +/// every test in this file uses to birth its namespace, before any `launchAppendOps`/`launchAppend`/ +/// `launchDrop` call. Several tests separately compute an expected key via +/// `DB::Cas::tests::fixture::fixtureLife(ns)` for verification/fault injection; without this the real +/// production birth mints a random incarnation and those computed keys land nowhere real. +void publishEmptyPart(const PoolPtr & s, const RootNamespace & ns, const String & ref) +{ + DB::Cas::tests::casAdmitRecoverableEntry(s->backend(), s->layout(), ns, s->liveWriterEpoch()); + PartWriteInfo info; + info.intended_namespace = ns; + info.intended_ref = ns.string() + "/" + ref; + auto build = s->beginPartWrite(info); + const ManifestId id = build->stageManifest({}); + build->precommitAdd(ns, ref, id); + build->promote(ns, ref, build->buildId(), id); +} + +/// One queued append (or drop) driven on its own thread; the future becomes ready only when the call +/// RETURNS (normally or by throwing). Mirrors `gtest_cas_ref_carve.cpp`'s `Caller`/`launchDrop`. +struct Caller +{ + std::thread t; + std::future fut; +}; + +Caller launchAppend(const PoolPtr & store, const RootNamespace & ns, MutationScope scope, + std::function(const RefTableState &)> build_ops) +{ + auto prom = std::make_shared>(); + std::future fut = prom->get_future(); + std::thread t([store, ns, scope, build_ops, prom] + { + std::exception_ptr err; + try { store->appendRefOps(ns, scope, build_ops, RootMutationOrigin::Writer, RootMutationKind::Publish); } + catch (...) { err = std::current_exception(); } + prom->set_value(err); + }); + return Caller{std::move(t), std::move(fut)}; +} + +Caller launchDrop(const PoolPtr & store, const RootNamespace & ns, const String & ref) +{ + auto prom = std::make_shared>(); + std::future fut = prom->get_future(); + std::thread t([store, ns, ref, prom] + { + std::exception_ptr err; + try { store->dropRef(ns, ref); } + catch (...) { err = std::current_exception(); } + prom->set_value(err); + }); + return Caller{std::move(t), std::move(fut)}; +} + +/// `n` filler ops for a `build_ops` result whose only purpose is to overflow the per-item op-count +/// cap. They are NOT inert-when-applied: a default-constructed op is a `NamespaceBirth`, which throws +/// `CORRUPTED_DATA` ("namespace_birth while already Live") if it were ever applied to the pre-published +/// namespace. The load-bearing safety property is that the count check fires BEFORE any of these ops is +/// applied or otherwise inspected. +std::vector fillerOps(size_t n) +{ + return std::vector(n, RefOp{}); +} + +/// A zero-padded ref name for index `i`, so `kTotalRefs` names sort in the same order as their index +/// (the snapshot fixture's committed rows must already be sorted by `ref_name`). +String paddedRefName(size_t i) +{ + String s = std::to_string(i); + return "ref_" + String(6 - s.size(), '0') + s; +} + +/// A single `SetPublishedAt` op whose `ref_name` is padded so its OWN encoded size (`encodedOpSize`) +/// is exactly `target_bytes`. Every added 'a' is one un-escaped byte in the JSON ref-name string, so +/// the size grows one-for-one; `checkCanonicalRefName` imposes no length limit, so this stays a +/// valid, merely over-long, canonical ref name. +RefOp paddedSetPublishedAtOp(size_t target_bytes) +{ + RefOp op; + op.kind = RefOpKind::SetPublishedAt; + op.ref_name = "r"; + op.expected_manifest_ref = ManifestRef{1, 1, 1}; + op.published_at_ms = 0; + const size_t base = encodedOpSize(op); + op.ref_name = "r" + String(target_bytes - base, 'a'); + return op; +} + +/// Blocks the FIRST flush's leader in the pre-carve window until `expected_pending` items are queued, +/// forcing a deterministic multi-item batch (mirrors `gtest_cas_ref_carve.cpp`'s `CaseSync`/pre-carve +/// hook pattern). Only the first carve blocks; retries proceed straight through. +struct CaseSync +{ + std::mutex m; + std::condition_variable cv; + bool entered = false; +}; + +void armPreCarveBlock(const PoolPtr & store, const RootNamespace & ns, const std::shared_ptr & sync, size_t expected_pending) +{ + store->setRefPreCarveHookForTest([sync, store, ns, expected_pending] + { + std::unique_lock lk(sync->m); + if (sync->entered) + return; + sync->entered = true; + sync->cv.notify_all(); + /// Bounded (10s) so a staging bug bounds the wait instead of blocking the whole suite. + sync->cv.wait_for(lk, std::chrono::seconds(10), [&] { return store->refQueuePendingForTest(ns) >= expected_pending; }); + }); +} + +void waitEntered(const std::shared_ptr & sync) +{ + std::unique_lock lk(sync->m); + sync->cv.wait_for(lk, std::chrono::seconds(10), [&] { return sync->entered; }); +} + +void waitPendingAtLeast(const PoolPtr & store, const RootNamespace & ns, size_t n) +{ + const auto deadline = std::chrono::steady_clock::now() + std::chrono::seconds(10); + while (store->refQueuePendingForTest(ns) < n && std::chrono::steady_clock::now() < deadline) + std::this_thread::yield(); +} + +/// Asserts `err` is non-null and carries EXACTLY `expected_code` -- distinguishes the new counts-only +/// admission checks (`LIMIT_EXCEEDED`) from any other per-item validation failure. +void expectFailedWithCode(const std::exception_ptr & err, int expected_code, const char * what) +{ + ASSERT_TRUE(err != nullptr) << what << ": the caller must observe the admission-cap error"; + try + { + std::rethrow_exception(err); + } + catch (const DB::Exception & e) + { + EXPECT_EQ(e.code(), expected_code) << what; + } +} + +} + +/// Test 10 (spec §3 "Oversized item / oversized op fail alone"): an item whose OWN op count exceeds +/// `ref_txn_max_ops` fails alone -- its ops never enter the batch's transaction -- and a co-batched +/// neighbor still commits. +TEST(CASRefWriterChunkedFlush, OversizedItemFailsAlone) +{ + auto backend = std::make_shared(); + auto store = openPool(backend); + const RootNamespace ns{"srv1/chunked_oversized_item"}; + publishEmptyPart(store, ns, "neighbor"); + ASSERT_TRUE(store->resolveRef(ns, "neighbor").has_value()); + + auto sync = std::make_shared(); + armPreCarveBlock(store, ns, sync, 2); + + Caller oversized = launchAppend(store, ns, MutationScope::ref("oversized"), + [](const RefTableState &) -> std::vector { return fillerOps(ref_txn_max_ops + 1); }); + waitEntered(sync); + Caller neighbor = launchDrop(store, ns, "neighbor"); + waitPendingAtLeast(store, ns, 2); + sync->cv.notify_all(); /// release the pre-carve hook now its (>=2 pending) predicate holds + + ASSERT_EQ(oversized.fut.wait_for(std::chrono::seconds(10)), std::future_status::ready) << "oversized item must not hang"; + ASSERT_EQ(neighbor.fut.wait_for(std::chrono::seconds(10)), std::future_status::ready) << "neighbor must not hang"; + const std::exception_ptr oversized_err = oversized.fut.get(); + const std::exception_ptr neighbor_err = neighbor.fut.get(); + oversized.t.join(); + neighbor.t.join(); + store->setRefPreCarveHookForTest(nullptr); + + expectFailedWithCode(oversized_err, DB::ErrorCodes::LIMIT_EXCEEDED, "oversized item (op count)"); + EXPECT_TRUE(neighbor_err == nullptr) << "the co-batched neighbor must commit despite the oversized item"; + EXPECT_FALSE(store->resolveRef(ns, "neighbor").has_value()) << "neighbor's drop must have committed"; +} + +/// Test 10, second leg: one op whose OWN encoded size exceeds `ref_op_max_bytes` (a maximum-length +/// ref name -- `checkCanonicalRefName` imposes no length limit) fails only its item; a co-batched +/// neighbor still commits. +TEST(CASRefWriterChunkedFlush, OversizedOpFailsItsItemAlone) +{ + auto backend = std::make_shared(); + auto store = openPool(backend); + const RootNamespace ns{"srv1/chunked_oversized_op"}; + publishEmptyPart(store, ns, "neighbor"); + ASSERT_TRUE(store->resolveRef(ns, "neighbor").has_value()); + + const RefOp oversized_op = paddedSetPublishedAtOp(ref_op_max_bytes + 1); + ASSERT_GT(encodedOpSize(oversized_op), ref_op_max_bytes); + + auto sync = std::make_shared(); + armPreCarveBlock(store, ns, sync, 2); + + Caller oversized = launchAppend(store, ns, MutationScope::ref("oversized_op"), + [oversized_op](const RefTableState &) -> std::vector { return {oversized_op}; }); + waitEntered(sync); + Caller neighbor = launchDrop(store, ns, "neighbor"); + waitPendingAtLeast(store, ns, 2); + sync->cv.notify_all(); + + ASSERT_EQ(oversized.fut.wait_for(std::chrono::seconds(10)), std::future_status::ready) << "oversized op item must not hang"; + ASSERT_EQ(neighbor.fut.wait_for(std::chrono::seconds(10)), std::future_status::ready) << "neighbor must not hang"; + const std::exception_ptr oversized_err = oversized.fut.get(); + const std::exception_ptr neighbor_err = neighbor.fut.get(); + oversized.t.join(); + neighbor.t.join(); + store->setRefPreCarveHookForTest(nullptr); + + expectFailedWithCode(oversized_err, DB::ErrorCodes::LIMIT_EXCEEDED, "oversized op"); + EXPECT_TRUE(neighbor_err == nullptr) << "the co-batched neighbor must commit despite the oversized op"; + EXPECT_FALSE(store->resolveRef(ns, "neighbor").has_value()) << "neighbor's drop must have committed"; +} + +/// Test 12, canonical round-trip leg: the maximum legally-admissible normal-class transaction under +/// the new counts-only caps -- `ref_txn_max_ops` ops, each padded to exactly `ref_op_max_bytes` -- +/// round-trips comfortably under the whole-transaction `ref_txn_max_bytes` decode cap (5000 * 4096 = +/// 20,480,000 bytes, with framing headroom to spare). Pure codec-level: proves the two counts-only +/// caps compose without ever approaching the byte cap the encode-side estimation machinery used to +/// police. +TEST(CASRefWriterChunkedFlush, CanonicalMaxTransactionRoundTrips) +{ + RefLogTxn txn; + txn.ns = "ns"; + txn.txn_id = RefTxnId{1, 1}; + txn.ops.reserve(ref_txn_max_ops); + for (size_t i = 0; i < ref_txn_max_ops; ++i) + { + RefOp op = paddedSetPublishedAtOp(ref_op_max_bytes); + ASSERT_EQ(encodedOpSize(op), ref_op_max_bytes); + txn.ops.push_back(std::move(op)); + } + + const String bytes = encodeRefLogTxn(txn); + /// Every op contributes exactly `ref_op_max_bytes`; header/meta/trailer framing adds strictly + /// more on top, and the whole thing still stays well under the 20 MiB decode cap. + EXPECT_GT(bytes.size(), ref_txn_max_ops * ref_op_max_bytes); + EXPECT_LT(bytes.size(), ref_txn_max_bytes); + + const RefLogTxn decoded = decodeRefLogTxn(bytes, txn.ns, txn.txn_id); + EXPECT_EQ(decoded.ops.size(), ref_txn_max_ops); + EXPECT_EQ(decoded, txn); +} + +/// Test 11 (spec §3 "Removal-class detection, falsifiably"): `dropNamespace` over a table with +/// > `ref_txn_max_ops` committed refs builds ONE transaction whose ops (one `owner_transition` +/// removal per ref, plus a terminal `remove_namespace`) exceed the normal-class op-count cap -- +/// and must still succeed, because removal-class is byte-budgeted (`ref_removal_max_bytes`, 64 MiB) +/// and has no op-count cap. Seeded via a raw snapshot (not `kTotalRefs` individual writer round-trips +/// through `publishEmptyPart`) so the fixture stays fast; the writer never touches these rows until +/// `dropNamespace` itself builds the one removal transaction. +TEST(CASRefWriterChunkedFlush, DropNamespaceOverOpCapSucceeds) +{ + auto backend = std::make_shared(); + const Layout layout("p"); + const RootNamespace ns{"srv1/dropns_over_cap"}; + constexpr size_t kTotalRefs = static_cast(ref_txn_max_ops) + 200; + + /// Open the store FIRST (still untouched for `ns`) so the seeded snapshot can use THIS mount's own + /// writer_epoch: namespace recovery is per-namespace and lazy (first touch), so writing the raw + /// fixture directly to `backend` after open, but before `ns` is ever touched, is observed identically + /// to writing it before open. + auto store = openPool(backend); + const uint64_t epoch = store->writerEpoch(); + /// Stage B (Task 4-C): pin `ns` to the sentinel now, before the raw snapshot below -- `listRefs`/ + /// `dropNamespace` further down are real production reads that trigger `resolveNamespaceLife`, + /// which for an UNADMITTED namespace mints a fresh RANDOM incarnation rather than adopting the + /// sentinel the raw fixture wrote at. Pinning first makes them adopt it instead. + DB::Cas::tests::fixture::admitLive(*backend, layout, ns); + + /// Ids are PER-NAMESPACE and derived from the table's own `greatest_applied` (INV-1), so seeding + /// `ns` at `{epoch, 1}` is all this fixture has to do: the `dropNamespace` below derives `{epoch, 2}` + /// from the seeded snapshot, and no other namespace's traffic can move it. + std::vector committed; + committed.reserve(kTotalRefs); + for (size_t i = 0; i < kTotalRefs; ++i) + committed.push_back(committedRow(paddedRefName(i), ManifestRef{epoch, i + 1, 1})); + ASSERT_GT(committed.size(), ref_txn_max_ops); + + /// Recovery's checkpoint anchor includes the same-id ordinary log. The synthetic snapshot stands + /// for a long prior history, while this genesis record supplies the retained non-seal witness the + /// real publisher would necessarily leave at the selected id. + DB::Cas::tests::fixture::writeRefLogRaw(*backend, layout, RefLogTxn{ + .ns = ns.string(), + .txn_id = RefTxnId{epoch, 1}, + .ops = {DB::Cas::tests::namespaceBirthOp()}, + .prev_epoch_seal = std::nullopt}); + writeRefSnapshotRaw(*backend, layout, minimalLiveSnapshot(ns.string(), RefTxnId{epoch, 1}, committed)); + DB::Cas::tests::writeRecoverableCkptForRawFixture(*backend, layout, ns, RefCkpt{ + .life_epoch = epoch, + .committed_through = RefTxnId{epoch, 1}, + .checkpoint_snapshot_id = RefTxnId{epoch, 1}, + .last_epoch_seal = std::nullopt, + }); + const NamespaceLifeId life = CasRefCatalog::lifeIfCataloged(*backend, layout, ns).value(); + backend->resetCounts(); + ASSERT_EQ(store->listRefs(ns).size(), kTotalRefs); + EXPECT_EQ(backend->getCount(layout.refLogKey(life, RefTxnId{epoch, 1})), 1u); + EXPECT_EQ(backend->getCount(layout.refSnapshotKey(life, RefTxnId{epoch, 1})), 1u); + + DropNamespaceStats stats; + EXPECT_NO_THROW(stats = store->dropNamespace(ns)); + EXPECT_EQ(stats.committed_refs, kTotalRefs); + EXPECT_EQ(CasRefCatalog::read(*backend, layout).catalog.entries.front().state, NsState::Removing); +} + +/// Test 11, second leg: `WholeShard` scope ALONE is not the removal-class discriminator -- the +/// stale-precommit reclaim sweep is also `WholeShard`-scoped but is not removal-class +/// (`CasRefLedger.cpp` ~:1979). Only a SYNTHETIC item can pin this: the production stale-precommit +/// sweep self-limits its own chunk size to the op cap, so running it proves nothing (spec's own +/// warning). This item drives `MutationScope::wholeShard()` directly with ops that contain NO +/// `RemoveNamespace` op -- if classification were keyed on scope instead of op inspection, this would +/// be wrongly treated as removal-class and admitted; op-inspection correctly rejects it under the +/// ordinary normal-class op-count cap, exactly like `OversizedItemFailsAlone` above. +TEST(CASRefWriterChunkedFlush, SyntheticWholeShardNonRemovalRejected) +{ + auto backend = std::make_shared(); + auto store = openPool(backend); + const RootNamespace ns{"srv1/synthetic_wholeshard_nonremoval"}; + + Caller synthetic = launchAppend(store, ns, MutationScope::wholeShard(), + [](const RefTableState &) -> std::vector { return fillerOps(ref_txn_max_ops + 1); }); + ASSERT_EQ(synthetic.fut.wait_for(std::chrono::seconds(10)), std::future_status::ready) + << "synthetic WholeShard item must not hang"; + const std::exception_ptr err = synthetic.fut.get(); + synthetic.t.join(); + + expectFailedWithCode(err, DB::ErrorCodes::LIMIT_EXCEEDED, + "synthetic WholeShard-scoped item with non-removal ops over the op cap"); +} + +/// =================================================================================== +/// Task 10 (spec §3 "Chunked flush, where each chunk is a complete commit boundary"): when admitting +/// the next item's ops would exceed `ref_txn_max_ops`, the leader commits the accumulated chunk as a +/// COMPLETE ref-log transaction (real id, PUT, apply, tail, metrics, survivor completion + waiter +/// wakeups, snapshot scheduling), reseeds `working`/the trial-id high-water mark from the now-live +/// state, and continues into a fresh chunk -- so one tenure can emit several transactions, each a valid +/// persisted prefix. The failure-isolation and tenure-containment contracts are pinned below. +/// =================================================================================== + +namespace +{ + +/// The `_log/`-PUT fault seam these tests are built on now lives in `cas_test_helpers.h`, next to +/// `CountingBackend` it derives from: `gtest_cas_ref_install_safety.cpp` needs the SAME seam (spec §A1 +/// sites 2 and 3 both turn on what happens when a `_log/` PUT's response is lost), and two copies of a +/// fault backend would drift apart. +using DB::Cas::tests::ChunkFaultBackend; + +PoolPtr openPoolWith(const BackendPtr & backend, PoolConfig cfg) +{ + DB::Cas::tests::seedPoolMetaForRestart(*backend); + cfg.pool_prefix = "p"; + cfg.server_root_id = "test"; + /// Same frozen clock as `openPool` above, and for the same reason — see its comment. Defaulted + /// rather than forced, so a future test that IS about lease timing can still supply its own. + if (!cfg.boot_ms_fn) + cfg.boot_ms_fn = [] { return uint64_t{0}; }; + return Pool::open(backend, cfg); +} + +/// `num_pairs` add-then-remove precommit op pairs (2 * `num_pairs` ops total) for distinct refs +/// (`prefix` + zero-padded index) each naming a distinct valid manifest. Every pair adds a precommit +/// binding and immediately removes it, so the LIVE state (the `precommits` set, the committed COW map, +/// the owned-manifest index) stays ~empty throughout the whole transaction -- keeping the per-op +/// `admits` preview and the sanitizer-only body-counter assert O(1), so validating a maximal chunk of +/// thousands of ops stays O(ops), not O(ops^2). It is the OP COUNT (not the resident state) that drives +/// the chunk split under test; each op is tiny (well under `ref_op_max_bytes`), so the whole run is +/// admissible on a `Live` namespace. The durable transaction still carries every op verbatim, so a +/// chunk's ops can be compared against the exact expected vector. +std::vector addRemovePrecommitPairs(const String & prefix, size_t num_pairs, uint64_t manifest_epoch) +{ + std::vector ops; + ops.reserve(num_pairs * 2); + for (size_t i = 0; i < num_pairs; ++i) + { + const String ref = prefix + paddedRefName(i); + const ManifestRef manifest{manifest_epoch, i + 1, 1}; + RefOp add; + add.kind = RefOpKind::OwnerTransition; + add.new_binding = RefOwnerBinding{RefOwnerKind::Precommit, ref, manifest}; + ops.push_back(std::move(add)); + RefOp remove; + remove.kind = RefOpKind::OwnerTransition; + remove.old_binding = RefOwnerBinding{RefOwnerKind::Precommit, ref, manifest}; + ops.push_back(std::move(remove)); + } + return ops; +} + +/// Every durable `_log/` transaction for `ns`, decoded, sorted ascending by transaction id. Undecodable +/// objects (e.g. the foreign bytes a `ForeignConflict` fault lands) are skipped so a corrupt object never +/// breaks the inventory. Reads the backend directly (no Pool cache). +std::vector listLogTxns(DB::Cas::Backend & backend, const DB::Cas::Layout & layout, const RootNamespace & ns) +{ + std::vector ids; + String cursor; + for (;;) + { + const ListPage page = backend.list(layout.namespaceStreamPrefix(DB::Cas::tests::fixture::fixtureLife(ns)), cursor, 1000); + for (const ListedKey & lk : page.keys) + { + const auto parsed = layout.parseRefObjectKey(lk.key); + if (parsed && parsed->life_id == DB::Cas::tests::fixture::fixtureLife(ns).incarnation + && parsed->kind == RefObjectKind::Log) + ids.push_back(parsed->txn_id); + } + if (page.next_cursor.empty()) + break; + cursor = page.next_cursor; + } + std::sort(ids.begin(), ids.end(), [](const RefTxnId & a, const RefTxnId & b) { return a < b; }); + std::vector txns; + for (const RefTxnId & id : ids) + { + const auto got = backend.get(layout.refLogKey(DB::Cas::tests::fixture::fixtureLife(ns), id)); + if (!got) + continue; + try + { + txns.push_back(decodeRefLogTxn(openObject(FormatId::RefLog, got->bytes), ns.string(), id)); + } + catch (...) // NOLINT(bugprone-empty-catch): best-effort helper -- an undecodable txn is simply skipped, not asserted on + { + } + } + return txns; +} + +/// One queued append driven on its own thread, capturing BOTH the committed transaction id (on success) +/// and the exception (on failure); `build_calls` (when non-null) counts `build_ops` invocations to pin +/// the at-most-once contract across chunk boundaries. The ops are precomputed and returned verbatim, so a +/// second invocation (a bug) is caught by the counter, not masked by a state-dependent rebuild. +struct AppendResult +{ + std::exception_ptr err; + RefTxnId id{}; +}; + +struct AppendCaller +{ + std::thread t; + std::future fut; +}; + +AppendCaller launchAppendOps(const PoolPtr & store, const RootNamespace & ns, MutationScope scope, + std::vector ops, std::shared_ptr> build_calls) +{ + auto prom = std::make_shared>(); + std::future fut = prom->get_future(); + auto build_ops = [captured_ops = std::move(ops), build_calls](const RefTableState &) -> std::vector + { + if (build_calls) + build_calls->fetch_add(1); + return captured_ops; + }; + std::thread t([store, ns, scope, build_ops, prom] + { + AppendResult r; + try { r.id = store->appendRefOps(ns, scope, build_ops, RootMutationOrigin::Writer, RootMutationKind::Publish); } + catch (...) { r.err = std::current_exception(); } + prom->set_value(r); + }); + return AppendCaller{std::move(t), std::move(fut)}; +} + +} + +/// Test 9 (happy path): a carve whose total ops exceed `ref_txn_max_ops` emits >= 2 ref-log transactions +/// in ONE leader tenure. Three items (2000 ops each = 6000 > 5000) split into chunk 1 = {item_a,item_b} +/// (4000 ops, one id) and chunk 2 = {item_c} (2000 ops, the next id). Per-chunk assertions: committed +/// ids (co-chunk survivors share one real id; the next chunk allocates the next), tail counters (one per +/// chunk), per-chunk metrics (`CASRefBatchFlushes` once per chunk, `CASRefBatchedMutations` counting +/// survivors per chunk), follower wakeups (both followers return their correct real id -> completed + +/// woken at their chunk's commit), `build_ops` at-most-once (invocation counters == 1), and folded state +/// == the sequential result (the two durable transactions carry exactly item_a++item_b, then item_c). +TEST(CASRefWriterChunkedFlush, ChunkedFlushCommitsPerChunk) +{ + auto backend = std::make_shared(); + /// Default thresholds: this handful of transactions never crosses the snapshot-publish threshold, so + /// no background publish interleaves and the tail/metric deltas below are exact. + auto store = openPool(backend); + const DB::Cas::Layout & layout = store->layout(); + const RootNamespace ns{"srv1/chunked_commits_per_chunk"}; + publishEmptyPart(store, ns, "seed"); + ASSERT_TRUE(store->resolveRef(ns, "seed").has_value()); + + /// 2000 ops per item (1000 add/remove pairs) -> 6000 > ref_txn_max_ops (5000): chunk 1 = + /// {item_a,item_b} (4000), chunk 2 = {item_c} (2000). + const std::vector ops1 = addRemovePrecommitPairs("aaa_", 1000, 900000001); + const std::vector ops2 = addRemovePrecommitPairs("bbb_", 1000, 900000002); + const std::vector ops3 = addRemovePrecommitPairs("ccc_", 1000, 900000003); + auto c1 = std::make_shared>(0); + auto c2 = std::make_shared>(0); + auto c3 = std::make_shared>(0); + + const size_t tail_before = store->tailSinceSnapshotCountForTest(ns); + const uint64_t flushes_before = ProfileEvents::global_counters[ProfileEvents::CASRefBatchFlushes].load(); + const uint64_t mutations_before = ProfileEvents::global_counters[ProfileEvents::CASRefBatchedMutations].load(); + + auto sync = std::make_shared(); + armPreCarveBlock(store, ns, sync, 3); + /// Serialise the enqueue order so the batch is exactly [item_a(leader), item_b, item_c]. + AppendCaller a = launchAppendOps(store, ns, MutationScope::ref("item_a"), ops1, c1); + waitEntered(sync); + AppendCaller b = launchAppendOps(store, ns, MutationScope::ref("item_b"), ops2, c2); + waitPendingAtLeast(store, ns, 2); + AppendCaller c = launchAppendOps(store, ns, MutationScope::ref("item_c"), ops3, c3); + waitPendingAtLeast(store, ns, 3); + sync->cv.notify_all(); + + ASSERT_EQ(a.fut.wait_for(std::chrono::seconds(20)), std::future_status::ready) << "item_a must not hang"; + ASSERT_EQ(b.fut.wait_for(std::chrono::seconds(20)), std::future_status::ready) << "item_b must not hang"; + ASSERT_EQ(c.fut.wait_for(std::chrono::seconds(20)), std::future_status::ready) << "item_c must not hang"; + const AppendResult ra = a.fut.get(); + const AppendResult rb = b.fut.get(); + const AppendResult rc = c.fut.get(); + a.t.join(); + b.t.join(); + c.t.join(); + store->setRefPreCarveHookForTest(nullptr); + + ASSERT_TRUE(ra.err == nullptr) << "item_a must commit"; + ASSERT_TRUE(rb.err == nullptr) << "item_b must commit"; + ASSERT_TRUE(rc.err == nullptr) << "item_c must commit"; + + /// `build_ops` ran exactly once per item -- including item_c, the overflowing item validated once in + /// the fresh chunk it lands in. + EXPECT_EQ(c1->load(), 1); + EXPECT_EQ(c2->load(), 1); + EXPECT_EQ(c3->load(), 1); + + /// Committed ids per chunk: item_a and item_b share chunk 1's real id (co-chunk survivors, both + /// woken with it); item_c gets chunk 2's id, exactly one sequence step above chunk 1. + EXPECT_EQ(ra.id, rb.id) << "co-chunk survivors must complete with the SAME real transaction id"; + EXPECT_EQ(rc.id.writer_epoch, ra.id.writer_epoch); + EXPECT_EQ(rc.id.ref_sequence, ra.id.ref_sequence + 1) << "chunk 2 must allocate the id after chunk 1"; + + /// >= 2 durable transactions in the tenure, and the split is exactly the sequential result: chunk 1 + /// carries item_a's then item_b's ops (survivor order), chunk 2 carries item_c's. + const std::vector logs = listLogTxns(*backend, layout, ns); + std::optional chunk1_txn; + std::optional chunk2_txn; + for (const RefLogTxn & txn : logs) + { + if (txn.txn_id == ra.id) + chunk1_txn = txn; + if (txn.txn_id == rc.id) + chunk2_txn = txn; + } + ASSERT_TRUE(chunk1_txn.has_value()) << "chunk 1 must be durable"; + ASSERT_TRUE(chunk2_txn.has_value()) << "chunk 2 must be durable (a second transaction in one tenure)"; + std::vector expect_chunk1 = ops1; + expect_chunk1.insert(expect_chunk1.end(), ops2.begin(), ops2.end()); + EXPECT_EQ(chunk1_txn->ops, expect_chunk1); + EXPECT_EQ(chunk2_txn->ops, ops3); + + /// Tail counters advanced once per committed chunk. + EXPECT_EQ(store->tailSinceSnapshotCountForTest(ns), tail_before + 2); + + /// Per-chunk metrics: one batch-flush per chunk (2), survivors counted per chunk (2 + 1 = 3). The + /// snapshot-scheduling trigger is the final step of the SAME committed arm that increments + /// `CASRefBatchFlushes`, so == 2 also proves the scheduler was invoked per chunk; + /// `SnapshotPublisherLatchedAcrossChunks` proves that trigger actually re-fires across chunks. + EXPECT_EQ(ProfileEvents::global_counters[ProfileEvents::CASRefBatchFlushes].load() - flushes_before, 2u); + EXPECT_EQ(ProfileEvents::global_counters[ProfileEvents::CASRefBatchedMutations].load() - mutations_before, 3u); +} + +namespace +{ + +/// Shared body for the three chunk-failure variants: two items (3000 ops each) -> chunk 1 = {item_a} +/// (the leader's own item), chunk 2 = {item_b}. `mode` faults ONLY chunk 2's `_log/` PUT (skip chunk 1). +/// In every variant chunk 1 commits and the leader's own call returns chunk 1's real id, while chunk 2's +/// caller fails. Returns the two callers' results plus chunk 1's id for the per-variant assertions. +struct ChunkFailureOutcome +{ + AppendResult leader; /// item_a, chunk 1 + AppendResult follower; /// item_b, chunk 2 + RefTxnId chunk1_id{}; + std::shared_ptr backend; + PoolPtr store; +}; + +ChunkFailureOutcome runChunkFailureCase(const String & ns_suffix, ChunkFaultBackend::Mode mode) +{ + auto backend = std::make_shared(); + PoolConfig cfg; + /// Single-attempt budget: one ambiguous PUT is a conclusive Unresolved (wedge) / DefiniteFailure, + /// with no inter-attempt sleep to serve. + CasRequestBudget budget; + budget.max_attempts = 1; + budget.attempt_timeout_ms = 100; + budget.operation_deadline_ms = 5000; /// strictly above attempt_timeout_ms: equality is a wall-clock race (validateCasRequestBudget) + budget.lease_safety_margin_ms = 100; + cfg.cas_request_budget = budget; + auto store = openPoolWith(backend, cfg); + const DB::Cas::Layout & layout = store->layout(); + const RootNamespace ns{String("srv1/") + ns_suffix}; + publishEmptyPart(store, ns, "seed"); + + /// Fault ONLY chunk 2's `_log/` PUT: skip chunk 1's (the first match), fault the second. Armed AFTER + /// the seed so only the flush's two log PUTs are counted. + backend->fault_substr = layout.namespaceStreamPrefix(DB::Cas::tests::fixture::fixtureLife(ns)) + "_log/"; + backend->mode = mode; + backend->fault_skip = 1; + backend->fault_count = 1; + + auto sync = std::make_shared(); + armPreCarveBlock(store, ns, sync, 2); + /// 3000 ops per item (1500 add/remove pairs) -> 6000 > ref_txn_max_ops: chunk 1 = {item_a}, + /// chunk 2 = {item_b}. + AppendCaller a = launchAppendOps(store, ns, MutationScope::ref("item_a"), addRemovePrecommitPairs("aaa_", 1500, 900000001), nullptr); + waitEntered(sync); + AppendCaller b = launchAppendOps(store, ns, MutationScope::ref("item_b"), addRemovePrecommitPairs("bbb_", 1500, 900000002), nullptr); + waitPendingAtLeast(store, ns, 2); + sync->cv.notify_all(); + + EXPECT_EQ(a.fut.wait_for(std::chrono::seconds(20)), std::future_status::ready) << "leader must not hang"; + EXPECT_EQ(b.fut.wait_for(std::chrono::seconds(20)), std::future_status::ready) << "follower must not hang"; + ChunkFailureOutcome out; + out.leader = a.fut.get(); + out.follower = b.fut.get(); + a.t.join(); + b.t.join(); + store->setRefPreCarveHookForTest(nullptr); + out.chunk1_id = out.leader.id; + out.backend = backend; + out.store = store; + return out; +} + +} + +/// Test 9 (chunk-failure variant a -- definite failure): chunk 2's PUT is conclusively rejected +/// (`CasWriteOutcome::DefiniteFailure`). Chunk 1's caller (the leader's own item) observes SUCCESS with +/// chunk 1's real id; chunk 2's caller fails; the lane does NOT wedge (a definite rejection is a safe +/// gap, not an uncertain PUT). +TEST(CASRefWriterChunkedFlush, ChunkFailureDefinite) +{ +#if !USE_AWS_S3 + GTEST_SKIP() << "DefiniteFailure classification requires S3 error types (USE_AWS_S3 off)"; +#endif + ChunkFailureOutcome out = runChunkFailureCase("chunk_fail_definite", ChunkFaultBackend::Mode::Definite); + ASSERT_TRUE(out.leader.err == nullptr) << "chunk-1 caller must observe success even though chunk 2 failed"; + ASSERT_TRUE(out.follower.err != nullptr) << "chunk-2 caller must observe the definite failure"; + EXPECT_FALSE(out.store->refLaneWedgedForTest(RootNamespace{"srv1/chunk_fail_definite"})) + << "a definite failure is proven non-durable and must NOT wedge the lane"; + + const auto logs = listLogTxns(*out.backend, out.store->layout(), RootNamespace{"srv1/chunk_fail_definite"}); + bool saw_chunk1 = false; + for (const RefLogTxn & txn : logs) + if (txn.txn_id == out.chunk1_id) + saw_chunk1 = true; + EXPECT_TRUE(saw_chunk1) << "chunk 1 must be durably committed"; +} + +/// Test 9 (chunk-failure variant b -- unresolved wedge): chunk 2's PUT is ambiguous and exhausts the +/// budget, wedging the lane. Chunk 1's caller observes SUCCESS; chunk 2's caller fails; the wedge holds +/// ONLY chunk 2's key (chunk 1 + 1), and chunk 1's object is durable while chunk 2's was never written. +TEST(CASRefWriterChunkedFlush, ChunkFailureWedge) +{ + const RootNamespace ns{"srv1/chunk_fail_wedge"}; + ChunkFailureOutcome out = runChunkFailureCase("chunk_fail_wedge", ChunkFaultBackend::Mode::Unresolved); + ASSERT_TRUE(out.leader.err == nullptr) << "chunk-1 caller must observe success even though chunk 2 wedged"; + ASSERT_TRUE(out.follower.err != nullptr) << "chunk-2 caller must observe the append failure"; + + EXPECT_TRUE(out.store->refLaneWedgedForTest(ns)) << "chunk 2's unresolved PUT must wedge the lane"; + RefTxnId chunk2_id = out.chunk1_id; + ++chunk2_id.ref_sequence; + EXPECT_EQ(out.store->wedgedKeyForTest(ns), out.store->layout().refLogKey(DB::Cas::tests::fixture::fixtureLife(ns), chunk2_id)) + << "the wedge must contain ONLY chunk 2's key"; + + const auto logs = listLogTxns(*out.backend, out.store->layout(), ns); + bool saw_chunk1 = false; + bool saw_chunk2 = false; + for (const RefLogTxn & txn : logs) + { + if (txn.txn_id == out.chunk1_id) + saw_chunk1 = true; + if (txn.txn_id == chunk2_id) + saw_chunk2 = true; + } + EXPECT_TRUE(saw_chunk1) << "chunk 1 must be durably committed"; + EXPECT_FALSE(saw_chunk2) << "chunk 2's wedged object was never durably written"; +} + +/// Test 9 (chunk-failure variant c -- a throw): chunk 2's PUT surfaces a proven conflict (CORRUPTED_DATA +/// thrown by the controller). Chunk 1's caller observes SUCCESS; chunk 2's caller fails with +/// CORRUPTED_DATA; the lane does NOT wedge (a conclusive rejection). +TEST(CASRefWriterChunkedFlush, ChunkFailureThrow) +{ + const RootNamespace ns{"srv1/chunk_fail_throw"}; + ChunkFailureOutcome out = runChunkFailureCase("chunk_fail_throw", ChunkFaultBackend::Mode::ForeignConflict); + ASSERT_TRUE(out.leader.err == nullptr) << "chunk-1 caller must observe success even though chunk 2 threw"; + ASSERT_TRUE(out.follower.err != nullptr) << "chunk-2 caller must observe the thrown failure"; + expectFailedWithCode(out.follower.err, DB::ErrorCodes::CORRUPTED_DATA, "chunk-2 proven-conflict throw"); + EXPECT_FALSE(out.store->refLaneWedgedForTest(ns)) << "a proven conflict is conclusive and must NOT wedge"; + + const auto logs = listLogTxns(*out.backend, out.store->layout(), ns); + bool saw_chunk1 = false; + for (const RefLogTxn & txn : logs) + if (txn.txn_id == out.chunk1_id) + saw_chunk1 = true; + EXPECT_TRUE(saw_chunk1) << "chunk 1 must be durably committed"; +} + +/// Test 9 (containment variant 1): the leader's OWN item lands in chunk 1; a throw is injected at the +/// chunk boundary (simulating a reseed allocation failure) AFTER chunk 1 committed. Tenure containment +/// (spec §3): the leader's own `appendRefOps` returns chunk 1's real id -- NOT the later exception -- +/// while the unattempted remainder (item_b) fails. This exercises the reworked outer catch, which no +/// longer rethrows unconditionally over a durable own item. +TEST(CASRefWriterChunkedFlush, LeaderOwnItemCommittedBeforeThrow) +{ + auto backend = std::make_shared(); + auto store = openPool(backend); + const DB::Cas::Layout & layout = store->layout(); + const RootNamespace ns{"srv1/chunk_leader_own_committed"}; + publishEmptyPart(store, ns, "seed"); + + auto c1 = std::make_shared>(0); + auto c2 = std::make_shared>(0); + + /// Throw once at the first chunk boundary -- after chunk 1 (the leader's own item) is durable and + /// before the reseed completes. + auto boundary_hits = std::make_shared>(0); + store->setCarveHookForTest([boundary_hits](CasRefLedger::CarvePhaseForTest ph) + { + if (ph == CasRefLedger::CarvePhaseForTest::ChunkReseed && boundary_hits->fetch_add(1) == 0) + throw std::bad_alloc{}; + }); + + auto sync = std::make_shared(); + armPreCarveBlock(store, ns, sync, 2); + /// 3000 ops per item (1500 add/remove pairs) -> chunk 1 = {item_a}, boundary throw before chunk 2. + AppendCaller a = launchAppendOps(store, ns, MutationScope::ref("item_a"), addRemovePrecommitPairs("aaa_", 1500, 900000001), c1); + waitEntered(sync); + AppendCaller b = launchAppendOps(store, ns, MutationScope::ref("item_b"), addRemovePrecommitPairs("bbb_", 1500, 900000002), c2); + waitPendingAtLeast(store, ns, 2); + sync->cv.notify_all(); + + ASSERT_EQ(a.fut.wait_for(std::chrono::seconds(20)), std::future_status::ready) << "leader must not hang"; + ASSERT_EQ(b.fut.wait_for(std::chrono::seconds(20)), std::future_status::ready) << "follower must not hang"; + const AppendResult ra = a.fut.get(); + const AppendResult rb = b.fut.get(); + a.t.join(); + b.t.join(); + store->setCarveHookForTest(nullptr); + store->setRefPreCarveHookForTest(nullptr); + + ASSERT_TRUE(ra.err == nullptr) + << "the leader's own committed-chunk item must return success, not the later boundary throw"; + ASSERT_TRUE(rb.err != nullptr) << "the unattempted remainder must fail"; + + const std::vector logs = listLogTxns(*backend, layout, ns); + std::optional chunk1_txn; + for (const RefLogTxn & txn : logs) + if (txn.txn_id == ra.id) + chunk1_txn = txn; + ASSERT_TRUE(chunk1_txn.has_value()) << "chunk 1 must be durable"; + EXPECT_EQ(chunk1_txn->ops, addRemovePrecommitPairs("aaa_", 1500, 900000001)); + /// item_a's build_ops ran once (chunk 1); item_b's ran once (before the boundary throw preempted its + /// validation) and is NOT re-invoked -- the at-most-once contract holds through the failed tenure. + EXPECT_EQ(c1->load(), 1); + EXPECT_EQ(c2->load(), 1); +} + +/// Test 9 (containment variant 2 -- snapshot coalescing): a snapshot publisher dispatched by chunk 1 is +/// latched at its PUT AFTER capturing chunk 1's prefix; chunk 2 then commits and its publish trigger is +/// discarded by the single-in-flight gate. When the latched publisher settles, settlement must re-fire +/// the dropped trigger so a FOLLOW-UP publication covers chunk 2 -- otherwise chunk 2 would stay +/// unsnapshotted until an unrelated later mutation. The chunk boundary is gated until the publisher has +/// parked, so its captured candidate is provably chunk 1's prefix only. +TEST(CASRefWriterChunkedFlush, SnapshotPublisherLatchedAcrossChunks) +{ + auto backend = std::make_shared(); + PoolConfig cfg; + cfg.snapshot_log_count_threshold = 0; /// every committed chunk crosses the tail-count threshold + auto store = openPoolWith(backend, cfg); + const DB::Cas::Layout & layout = store->layout(); + const RootNamespace ns{"srv1/chunk_snapshot_coalesce"}; + publishEmptyPart(store, ns, "seed"); + store->waitForSnapshotPublishSettleForTest(ns); /// drain the seed's publish chain -> tail == 0 + + /// Latch the FIRST `_snap/` PUT (chunk 1's publisher) at its conditional PUT -- i.e. AFTER it has + /// captured chunk 1's prefix under state_mutex. + backend->armBlock(layout.namespaceStreamPrefix(DB::Cas::tests::fixture::fixtureLife(ns)) + "_snap/"); + /// Gate the leader at the chunk boundary until that publisher has parked on its PUT, so its captured + /// candidate is EXACTLY chunk 1's prefix (not chunk 1 + chunk 2). + store->setCarveHookForTest([backend](CasRefLedger::CarvePhaseForTest ph) + { + if (ph == CasRefLedger::CarvePhaseForTest::ChunkReseed) + backend->awaitBlockEntered(); + }); + + auto sync = std::make_shared(); + armPreCarveBlock(store, ns, sync, 2); + /// 3000 ops per item (1500 add/remove pairs) -> chunk 1 = {item_a}, chunk 2 = {item_b}. + AppendCaller a = launchAppendOps(store, ns, MutationScope::ref("item_a"), addRemovePrecommitPairs("aaa_", 1500, 900000001), nullptr); + waitEntered(sync); + AppendCaller b = launchAppendOps(store, ns, MutationScope::ref("item_b"), addRemovePrecommitPairs("bbb_", 1500, 900000002), nullptr); + waitPendingAtLeast(store, ns, 2); + sync->cv.notify_all(); + + ASSERT_EQ(a.fut.wait_for(std::chrono::seconds(20)), std::future_status::ready) << "leader must not hang"; + ASSERT_EQ(b.fut.wait_for(std::chrono::seconds(20)), std::future_status::ready) << "follower must not hang"; + const AppendResult ra = a.fut.get(); + const AppendResult rb = b.fut.get(); + a.t.join(); + b.t.join(); + ASSERT_TRUE(ra.err == nullptr) << "chunk 1 must commit"; + ASSERT_TRUE(rb.err == nullptr) << "chunk 2 must commit"; + RefTxnId chunk2_id = ra.id; + ++chunk2_id.ref_sequence; + EXPECT_EQ(rb.id, chunk2_id); + + /// Release the latched chunk-1 publisher. Its settlement must re-fire the chunk-2 trigger the + /// single-flight gate dropped -> a follow-up publication covers chunk 2. + backend->releaseBlock(); + store->waitForSnapshotPublishSettleForTest(ns); + store->setCarveHookForTest(nullptr); + store->setRefPreCarveHookForTest(nullptr); + + const std::optional newest = store->newestPublishedSnapshotIdForTest(ns); + ASSERT_TRUE(newest.has_value()) << "at least one snapshot must have been published"; + EXPECT_FALSE(*newest < chunk2_id) + << "settlement must re-fire the dropped chunk-2 trigger so a snapshot covers chunk 2 (no lost trigger)"; +} diff --git a/src/Disks/tests/gtest_cas_ref_ckpt.cpp b/src/Disks/tests/gtest_cas_ref_ckpt.cpp new file mode 100644 index 000000000000..34bd84aee056 --- /dev/null +++ b/src/Disks/tests/gtest_cas_ref_ckpt.cpp @@ -0,0 +1,1270 @@ +#include + +#include "config.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +#include +#include +#include +#include +#include +#include +#include + +/// Stage A task 5 (INV-4): the `_ckpt` object. +/// +/// `_ckpt` exists because prefix cleaning made the ref stream unreadable from a LIST alone, so it is +/// simultaneously the thing recovery point-reads to find its base AND the gate on what cleanup may +/// delete. Both roles are only safe while three properties hold, and this suite pins exactly those: +/// +/// 1. the codec is STRICT in both directions -- a body that only partly decoded would be a cleanup +/// decision taken from a partly-read object; +/// 2. there is ONE merge, by semantic maximum per field, used by BOTH writers -- a writer that +/// wrote back the value it sampled earlier regresses the other writer's progress, which is TLC +/// counterexample `_sab_sealclobbersbase` and costs an acked transaction; +/// 3. every CAS attempt re-checks the admitted fence generation AFTER its read and BEFORE its write, +/// so a writer whose mount incarnation moved advances nothing. +/// +/// The suite name is prefixed `Cas` so it is covered by the `Cas*` unit-test gate filter. + +namespace DB::ErrorCodes +{ +extern const int CORRUPTED_DATA; +extern const int MEMORY_LIMIT_EXCEEDED; +extern const int NETWORK_ERROR; +extern const int UNKNOWN_FORMAT_VERSION; +} + +using namespace DB::Cas; +using DB::Cas::tests::CountingBackend; +using DB::Cas::tests::expectThrowsCode; +using DB::Cas::tests::namespaceBirthOp; +using DB::Cas::tests::publishCommittedOps; + +namespace +{ + +const RefTxnId ID_1_1{1, 1}; +const RefTxnId ID_1_2{1, 2}; +const RefTxnId ID_2_1{2, 1}; + +PoolPtr openPool(const BackendPtr & backend) +{ + DB::Cas::tests::seedPoolMetaForRestart(*backend); + return Pool::open(backend, PoolConfig{.pool_prefix = "p", .server_root_id = "test"}); +} + +/// The same one-transaction publish the other ref suites drive, so a namespace reaches `Live` through +/// the REAL append lane (which is also what creates its `_ckpt`). +RefTxnId publishRef(const PoolPtr & store, const RootNamespace & ns, const String & ref, uint64_t ordinal) +{ + return store->appendRefOps(ns, MutationScope::ref(ref), + [&ref, ordinal](const RefTableState & state) + { + std::vector ops; + if (state.getLifecycle() != RefLifecycle::Live) + ops.push_back(namespaceBirthOp()); + for (const RefOp & op : publishCommittedOps(ref, ManifestRef{1, ordinal, 1})) + ops.push_back(op); + return ops; + }, + RootMutationOrigin::Writer, RootMutationKind::Publish); +} + +/// A fence that never refuses, for the tests whose subject is not the fence. +const std::function ALWAYS_ADMITTED = [](uint64_t) {}; + +/// A deadline far enough out that only the test's own contention decides the outcome. The clock is +/// frozen (a constant `now`), which is what makes every non-exhaustion test independent of wall time. +CkptDeadline generousDeadline() +{ + return CkptDeadline{[] { return uint64_t{1000}; }, 60000}; +} + +/// Reads `life`'s `_ckpt` and returns its body, or a default-constructed one after failing the +/// current test when the object is absent. Every assertion below goes through this rather than +/// dereferencing the optional directly: a bare `->` on a disengaged optional ABORTS the whole test +/// binary, so one regression would take every later suite's result with it instead of failing a test. +RefCkpt readCkptOrFail(Backend & backend, const Layout & layout, const NamespaceLifeId & life) +{ + const std::optional sample = readCkpt(backend, layout, life); + if (!sample) + { + ADD_FAILURE() << "expected a _ckpt for namespace '" << life.ns.string() << "', found none"; + return RefCkpt{}; + } + return sample->ckpt; +} + +/// Stage B (Task 4-C): the incarnation `store`'s production birth wiring minted for `ns`, learned back +/// from the catalog exactly as a real reader would (`NamespaceLifeId::fromCatalogEntry`) -- once a real +/// `Pool`/`CasRefLedger` has opened the table, its ref-layer objects are no longer keyed at the +/// Stage-A sentinel, so every test below that drives the REAL append lane must ask the catalog what +/// incarnation it minted rather than assume the sentinel. Fails the current test (rather than +/// dereferencing a disengaged optional) if the catalog carries no entry for `ns` -- e.g. called before +/// the namespace's first append. +NamespaceLifeId liveLifeOrFail(Backend & backend, const Layout & layout, const RootNamespace & ns) +{ + const CasRefCatalog::Snapshot snap = CasRefCatalog::read(backend, layout); + for (const CatalogEntry & entry : snap.catalog.entries) + if (entry.ns.string() == ns.string()) + return NamespaceLifeId::fromCatalogEntry(entry.ns, entry.incarnation); + ADD_FAILURE() << "expected a catalog entry for namespace '" << ns.string() << "', found none"; + return DB::Cas::tests::fixture::fixtureLife(ns); +} + +/// Replaces the whole body of one key, minting a new incarnation -- how a test installs a deliberately +/// malformed or concurrently-advanced object. +void overwriteObject(Backend & backend, const String & key, const String & bytes) +{ + const HeadResult h = backend.head(key); + ASSERT_TRUE(h.exists) << "overwriteObject expects " << key << " to exist"; + ASSERT_EQ(backend.putOverwrite(key, bytes, h.token).outcome, PutOutcome::Done); +} + +/// Runs `on_get` right after every `get` of `watched_key` -- the deterministic way to act inside +/// another component's read-then-write window without a sleep or a second thread. The hook is a public +/// member rather than a constructor argument so it can be installed AFTER the backend exists (every +/// interesting hook writes through that same backend) and only once the test's setup writes are done. +class GetHookBackend : public CountingBackend +{ +public: + using CountingBackend::get; + + explicit GetHookBackend(String watched_key_) : watched_key(std::move(watched_key_)) {} + + /// Stage B (Task 4-C): a test that must watch a namespace's `_ckpt` key can no longer compute it + /// before the pool exists -- the real incarnation is minted only once the namespace's first open + /// resolves it, which requires the pool (and so this backend) to already be constructed. Lets a + /// test retarget the watch once it has learned the real key, strictly before arming `on_get`. + void setWatchedKey(String watched_key_) { watched_key = std::move(watched_key_); } + + std::function on_get; + + std::optional get(const String & key, Range range) override + { + auto result = CountingBackend::get(key, range); + if (key == watched_key && on_get) + on_get(); + return result; + } + +private: + String watched_key; +}; + +/// Records the exact `_ckpt` recovery protocol and injects an ambiguous CAS response. The fault is +/// armed only after fixture setup, so the journal contains solely the operation under test. +class AmbiguousCkptBackend : public CountingBackend +{ +public: + enum class Fault : uint8_t + { + None, + CommitThenThrow, + ThrowWithoutCommit, + AlwaysThrowWithoutCommit, + }; + + using CountingBackend::casPut; + using CountingBackend::get; + + String watched_key; + Fault fault = Fault::None; + String dominating_bytes; + bool fail_resolution_get = false; + std::function before_resolution_get; + std::function after_resolution_get; + std::vector journal; + + void arm(const String & key, Fault fault_) + { + watched_key = key; + fault = fault_; + watched_get_count = 0; + journal.clear(); + } + + std::optional get(const String & key, Range range) override + { + if (key != watched_key) + return CountingBackend::get(key, range); + + journal.push_back("GET"); + ++watched_get_count; + if (watched_get_count >= 2 && before_resolution_get) + before_resolution_get(); + if (watched_get_count == 2 && fail_resolution_get) + throw Poco::TimeoutException("AmbiguousCkptBackend: exact-read response lost"); + auto result = CountingBackend::get(key, range); + if (watched_get_count >= 2 && after_resolution_get) + after_resolution_get(); + return result; + } + + CasResult casPut(const String & key, const String & bytes, const std::optional & expected, + const ObjectMeta & meta) override + { + if (key != watched_key) + return CountingBackend::casPut(key, bytes, expected, meta); + + journal.push_back("CAS"); + if (fault == Fault::None) + return CountingBackend::casPut(key, bytes, expected, meta); + const Fault this_fault = fault; + if (fault != Fault::AlwaysThrowWithoutCommit) + fault = Fault::None; + if (this_fault == Fault::CommitThenThrow) + { + const CasResult result = CountingBackend::casPut(key, bytes, expected, meta); + if (result.outcome == CasOutcome::Committed && !dominating_bytes.empty()) + { + const HeadResult head_result = CountingBackend::head(key); + EXPECT_EQ(CountingBackend::putOverwrite(key, dominating_bytes, head_result.token).outcome, + PutOutcome::Done); + } + } + throw Poco::TimeoutException("AmbiguousCkptBackend: CAS response lost"); + } + +private: + size_t watched_get_count = 0; +}; + +} + +/// --------------------------------------------------------------------------------------------- +/// The codec +/// --------------------------------------------------------------------------------------------- + +/// Every combination of the frontier and existing optionals survives a round trip. Both-absent is the shape a namespace +/// carries from creation until its first snapshot, so it is a real state and not a degenerate one. +TEST(CASRefCheckpoint, RoundTripsEveryFieldCombination) +{ + const std::vector cases = { + RefCkpt{.life_epoch = std::optional{7}, .checkpoint_snapshot_id = std::nullopt, .last_epoch_seal = std::nullopt}, + RefCkpt{.life_epoch = std::optional{1}, .committed_through = ID_1_2, .checkpoint_snapshot_id = ID_1_2, .last_epoch_seal = std::nullopt}, + RefCkpt{.life_epoch = std::optional{1}, .committed_through = ID_2_1, .checkpoint_snapshot_id = std::nullopt, .last_epoch_seal = ID_2_1}, + RefCkpt{.life_epoch = std::optional{1}, .committed_through = ID_2_1, .checkpoint_snapshot_id = ID_1_2, .last_epoch_seal = ID_2_1}, + RefCkpt{.life_epoch = std::optional{1}, .committed_through = ID_2_1, .checkpoint_snapshot_id = ID_1_2, .last_epoch_seal = ID_2_1}, + }; + for (const RefCkpt & ckpt : cases) + EXPECT_EQ(decodeRefCkpt(encodeRefCkpt(ckpt)), ckpt); +} + +TEST(CASRefCheckpoint, CommittedThroughHasCanonicalExactWireEncoding) +{ + const RefCkpt ckpt{.life_epoch = std::optional{7}, + .committed_through = RefTxnId{9, 11}, + .checkpoint_snapshot_id = RefTxnId{9, 10}, + .last_epoch_seal = RefTxnId{8, 12}}; + const String expected = R"({"type":"cas_ref_ckpt","v":9} +{"le":"7","cte":"9","cts":"11","cse":"9","css":"10","lse":"8","lss":"12"} +)"; + + EXPECT_EQ(encodeRefCkpt(ckpt), expected); + EXPECT_EQ(decodeRefCkpt(expected), ckpt); +} + +/// `last_epoch_seal` is chain evidence, not an arbitrary lower bound. It either names the frontier +/// itself when that frontier is the terminal seal, or closes the immediately preceding numeric epoch. +/// Accepting a gap or a later same-epoch frontier would manufacture a boundary that INV-2 never proved. +TEST(CASRefCheckpoint, CodecRejectsIncoherentCommittedFrontierAndSealEpochs) +{ + const RefCkpt valid{.life_epoch = 7, .committed_through = RefTxnId{8, 5}, + .checkpoint_snapshot_id = RefTxnId{7, 4}, .last_epoch_seal = RefTxnId{7, 9}}; + EXPECT_NO_THROW(encodeRefCkpt(valid)); + + const RefCkpt skipped_epoch{.life_epoch = 7, .committed_through = RefTxnId{10, 1}, + .checkpoint_snapshot_id = std::nullopt, .last_epoch_seal = RefTxnId{7, 9}}; + expectThrowsCode(DB::ErrorCodes::CORRUPTED_DATA, [&] { encodeRefCkpt(skipped_epoch); }); + + const RefCkpt frontier_after_same_epoch_seal{.life_epoch = 7, .committed_through = RefTxnId{8, 5}, + .checkpoint_snapshot_id = std::nullopt, + .last_epoch_seal = RefTxnId{8, 1}}; + expectThrowsCode(DB::ErrorCodes::CORRUPTED_DATA, + [&] { encodeRefCkpt(frontier_after_same_epoch_seal); }); + + const RefCkpt unsealed_non_genesis{.life_epoch = 7, .committed_through = RefTxnId{8, 1}, + .checkpoint_snapshot_id = std::nullopt, .last_epoch_seal = std::nullopt}; + expectThrowsCode(DB::ErrorCodes::CORRUPTED_DATA, [&] { encodeRefCkpt(unsealed_non_genesis); }); + + String malformed = encodeRefCkpt(valid); + const size_t cte = malformed.find(R"("cte":"8")"); + ASSERT_NE(cte, String::npos); + malformed.replace(cte, String{R"("cte":"8")"}.size(), R"("cte":"10")"); + expectThrowsCode(DB::ErrorCodes::CORRUPTED_DATA, [&] { decodeRefCkpt(malformed); }); +} + +/// STRICT means an unknown key is corruption, not something to skip. A `_ckpt` decides deletions, so a +/// reader that ignored a field it did not understand would be authorizing them from a body it only +/// partly read. +TEST(CASRefCheckpoint, RejectsAnUnknownKey) +{ + const String good = encodeRefCkpt(RefCkpt{.life_epoch = std::optional{1}, .committed_through = ID_1_1, .checkpoint_snapshot_id = ID_1_1, + .last_epoch_seal = std::nullopt}); + String with_unknown = good; + with_unknown.replace(with_unknown.rfind('}'), 1, R"(,"zz":"1"})"); + expectThrowsCode(DB::ErrorCodes::CORRUPTED_DATA, [&] { decodeRefCkpt(with_unknown); }); + + /// A `!`-prefixed key is a REQUIRED extension and reports the version, not corruption -- the + /// distinction is what lets an operator tell "this build is too old" from "this object is broken". + String with_critical = good; + with_critical.replace(with_critical.rfind('}'), 1, R"(,"!zz":"1"})"); + expectThrowsCode(DB::ErrorCodes::UNKNOWN_FORMAT_VERSION, [&] { decodeRefCkpt(with_critical); }); +} + +/// A duplicate key has no single meaning, so it can never be resolved by a reader's preference. +TEST(CASRefCheckpoint, RejectsADuplicateKey) +{ + const String good = encodeRefCkpt(RefCkpt{.life_epoch = std::optional{7}, .checkpoint_snapshot_id = std::nullopt, + .last_epoch_seal = std::nullopt}); + String duplicated = good; + duplicated.replace(duplicated.rfind('}'), 1, R"(,"le":"9"})"); + expectThrowsCode(DB::ErrorCodes::CORRUPTED_DATA, [&] { decodeRefCkpt(duplicated); }); +} + +/// Truncation in each of its shapes. Half an optional pair is the dangerous one: silently dropping it +/// would turn a truncated body into a well-formed `_ckpt` with NO checkpoint, which reads as +/// "recovery has no base" and would be trusted. +TEST(CASRefCheckpoint, RejectsTruncation) +{ + const String good = encodeRefCkpt(RefCkpt{.life_epoch = std::optional{1}, .committed_through = ID_1_2, .checkpoint_snapshot_id = ID_1_2, + .last_epoch_seal = std::nullopt}); + + const String header_only = good.substr(0, good.find('\n') + 1); + expectThrowsCode(DB::ErrorCodes::CORRUPTED_DATA, [&] { decodeRefCkpt(header_only); }); + + /// The body line without its terminator: a read that stopped mid-object. + expectThrowsCode(DB::ErrorCodes::CORRUPTED_DATA, [&] { decodeRefCkpt(good.substr(0, good.size() - 1)); }); + + /// An EMPTY body is not truncation, it is the legitimate "nobody knows anything yet" object -- the + /// shape a namespace carries between its creation and its first checkpoint. Asserted here, next to + /// the truncation cases, because the two are one character apart on the wire. + const String empty_body = good.substr(0, good.find('\n') + 1) + "{}\n"; + EXPECT_EQ(decodeRefCkpt(empty_body), RefCkpt{}); + + const String half_pair = good.substr(0, good.find('\n') + 1) + R"({"le":"7","cse":"1"})" + "\n"; + expectThrowsCode(DB::ErrorCodes::CORRUPTED_DATA, [&] { decodeRefCkpt(half_pair); }); + + const String other_half = good.substr(0, good.find('\n') + 1) + R"({"le":"7","lss":"2"})" + "\n"; + expectThrowsCode(DB::ErrorCodes::CORRUPTED_DATA, [&] { decodeRefCkpt(other_half); }); + + const String frontier_half = good.substr(0, good.find('\n') + 1) + R"({"le":"7","cte":"1"})" + "\n"; + expectThrowsCode(DB::ErrorCodes::CORRUPTED_DATA, [&] { decodeRefCkpt(frontier_half); }); +} + +TEST(CASRefCheckpoint, RejectsTrailingBytes) +{ + const String good = encodeRefCkpt(RefCkpt{.life_epoch = std::optional{7}, .checkpoint_snapshot_id = std::nullopt, + .last_epoch_seal = std::nullopt}); + expectThrowsCode(DB::ErrorCodes::CORRUPTED_DATA, [&] { decodeRefCkpt(good + "junk\n"); }); +} + +/// The field-validity rule runs in BOTH directions: a struct this build refuses to read can never be +/// written by it either, so a bug on the write side surfaces at the writer and not as an unreadable +/// object discovered by a future recovery. +TEST(CASRefCheckpoint, RejectsInvalidFieldsOnEncodeAndOnDecode) +{ + /// PRESENT means REAL: an absent field is legal, a present-but-impossible one is not. A zero + /// `life_epoch` would give the field two meanings ("unknown" and "epoch zero") on an object that + /// gates deletions. + expectThrowsCode(DB::ErrorCodes::CORRUPTED_DATA, + [] { encodeRefCkpt(RefCkpt{.life_epoch = std::optional{0}, .checkpoint_snapshot_id = std::nullopt, .last_epoch_seal = std::nullopt}); }); + expectThrowsCode(DB::ErrorCodes::CORRUPTED_DATA, + [] { encodeRefCkpt(RefCkpt{.life_epoch = std::optional{7}, .checkpoint_snapshot_id = RefTxnId{1, 0}, .last_epoch_seal = std::nullopt}); }); + expectThrowsCode(DB::ErrorCodes::CORRUPTED_DATA, + [] { encodeRefCkpt(RefCkpt{.life_epoch = std::optional{7}, .checkpoint_snapshot_id = std::nullopt, .last_epoch_seal = RefTxnId{0, 1}}); }); + expectThrowsCode(DB::ErrorCodes::CORRUPTED_DATA, + [] { encodeRefCkpt(RefCkpt{.life_epoch = std::optional{7}, .checkpoint_snapshot_id = ID_1_1, .last_epoch_seal = std::nullopt}); }); + + const String header = encodeRefCkpt(RefCkpt{.life_epoch = std::optional{7}, .checkpoint_snapshot_id = std::nullopt, + .last_epoch_seal = std::nullopt}); + const String prefix = header.substr(0, header.find('\n') + 1); + expectThrowsCode(DB::ErrorCodes::CORRUPTED_DATA, [&] { decodeRefCkpt(prefix + R"({"le":"0"})" + "\n"); }); + expectThrowsCode(DB::ErrorCodes::CORRUPTED_DATA, + [&] { decodeRefCkpt(prefix + R"({"le":"7","cse":"1","css":"0"})" + "\n"); }); +} + +/// The registry row is part of the contract: Control/Strict decides how the decoder treats unknown +/// keys, and the caps are the first thing that fires if a foreign object ever lands at the key. +TEST(CASRefCheckpoint, RegistryRowIsControlStrictWithTightCaps) +{ + const FormatTraits & traits = traitsFor(FormatId::RefCkpt); + EXPECT_EQ(traits.type, "cas_ref_ckpt"); + EXPECT_EQ(traits.family, TextFamily::Control); + EXPECT_EQ(traits.strictness, KeyStrictness::Strict); + EXPECT_EQ(traits.object_cap, 64u * 1024u); + EXPECT_EQ(traits.line_cap, 4u * 1024u); + EXPECT_EQ(traitsForType("cas_ref_ckpt"), &traits); + /// Raw, so the key has no suffix -- the Stage A shape is exactly `/_ckpt`. This line is also + /// the TRIPWIRE for the codec's shortcut: `encodeRefCkpt`/`decodeRefCkpt` hand bytes to and from + /// the backend directly, bypassing `sealObject`/`openObject` because both are the identity under + /// `CompressionPolicy::Never`. Flip the policy to `Always` and that bypass would silently write + /// uncompressed bodies under a `.zst` key -- which this assertion catches first. + EXPECT_EQ(storedSuffix(FormatId::RefCkpt), ""); + EXPECT_EQ(traits.compression, CompressionPolicy::Never); +} + +/// --------------------------------------------------------------------------------------------- +/// The key +/// --------------------------------------------------------------------------------------------- + +TEST(CASRefCheckpoint, KeyIsTheLifeLeafAndParsesBack) +{ + const Layout layout{"p"}; + const RootNamespace ns{"srv1/ckpt_key"}; + const NamespaceLifeId ns_id = DB::Cas::tests::fixture::fixtureLife(ns); + EXPECT_EQ(layout.refCkptKey(ns_id), + "p/cas/ns/state/" + renderIncarnation(ns_id.incarnation) + "/_ckpt"); + EXPECT_EQ(layout.parseRefCkptKey(layout.refCkptKey(ns_id)), ns_id.incarnation); + + /// `_ckpt` has no kind directory, so the id-bearing parser must NOT claim it -- and the `_ckpt` + /// parser must not claim the id-bearing keys either. Each key has exactly one classifier. + EXPECT_FALSE(layout.parseRefObjectKey(layout.refCkptKey(ns_id)).has_value()); + EXPECT_FALSE(layout.parseRefCkptKey(layout.refLogKey(ns_id, ID_1_1)).has_value()); + EXPECT_FALSE(layout.parseRefCkptKey(layout.refSnapshotKey(ns_id, ID_1_1)).has_value()); + EXPECT_FALSE(layout.parseRefCkptKey(layout.refCkptKey(ns_id) + ".zst").has_value()); + EXPECT_FALSE(layout.parseRefCkptKey("p/cas/ns/state/_ckpt").has_value()); + EXPECT_FALSE(layout.parseRefCkptKey("q" + layout.refCkptKey(ns_id).substr(1)).has_value()); +} + +/// The hot stream grouping accepts logs and snapshots while ignoring a checkpoint from the separate +/// state tree. An unrecognized key inside the stream tree still aborts the round. +TEST(CASRefCheckpoint, GroupRefKeysScopesHotIntakeToTheStreamTree) +{ + const Layout layout{"p"}; + const RootNamespace ns{"srv1/ckpt_group"}; + const std::vector keys = { + layout.refLogKey(DB::Cas::tests::fixture::fixtureLife(ns), ID_1_1), + layout.refSnapshotKey(DB::Cas::tests::fixture::fixtureLife(ns), ID_1_1), + layout.refCkptKey(DB::Cas::tests::fixture::fixtureLife(ns)), + }; + + const auto grouped = groupRefKeys(layout, keys); + ASSERT_EQ(grouped.size(), 1u); + const RefTableListing & listing = grouped.at(DB::Cas::tests::fixture::fixtureLife(ns).incarnation); + EXPECT_EQ(listing.logs, std::vector{ID_1_1}); + EXPECT_EQ(listing.snapshots, std::vector{ID_1_1}); + + /// A genuinely unrecognizable key inside this life stream is still corruption. + expectThrowsCode(DB::ErrorCodes::CORRUPTED_DATA, + [&] { groupRefKeys(layout, {layout.namespaceStreamPrefix(DB::Cas::tests::fixture::fixtureLife(ns)) + "_bogus"}); }); +} + +/// --------------------------------------------------------------------------------------------- +/// The merge -- per field, both directions +/// --------------------------------------------------------------------------------------------- + +/// The per-field table the ledger obligation from the TLA phase asks for: each field independently +/// newer on either side, plus both-absent and equal bodies. A merge that is not per-field would pass +/// some rows and fail others, which is the point of enumerating them. +TEST(CASRefCheckpoint, MergeTakesThePerFieldSemanticMaximum) +{ + const RefCkpt low{.life_epoch = std::optional{3}, .checkpoint_snapshot_id = ID_1_1, .last_epoch_seal = ID_1_1}; + const RefCkpt high_ckpt{.life_epoch = std::optional{3}, .checkpoint_snapshot_id = ID_1_2, .last_epoch_seal = ID_1_1}; + const RefCkpt high_seal{.life_epoch = std::optional{3}, .checkpoint_snapshot_id = ID_1_1, .last_epoch_seal = ID_2_1}; + const RefCkpt high_life{.life_epoch = std::optional{9}, .checkpoint_snapshot_id = ID_1_1, .last_epoch_seal = ID_1_1}; + + /// Each field newer on the RIGHT, then the same case mirrored to the LEFT: the merge is symmetric, + /// which is exactly why the two writers need no ordering between them. + /// + /// The `life_epoch` rows stay mirrored, and that is a deliberate statement rather than an oversight: + /// a `life_epoch` that FALLS is refused, but the refusal lives in `publishCkpt`, which knows which + /// side is durable, and NOT here. This function stays commutative, so both directions must keep + /// yielding the maximum. See `CASRefCheckpointJoin` (`gtest_cas_ref_ckpt_join.cpp`) for the refusal itself + /// and for why it cannot be expressed at this level. + EXPECT_EQ(mergeCkpt(low, high_ckpt), high_ckpt); + EXPECT_EQ(mergeCkpt(high_ckpt, low), high_ckpt); + EXPECT_EQ(mergeCkpt(low, high_seal), high_seal); + EXPECT_EQ(mergeCkpt(high_seal, low), high_seal); + EXPECT_EQ(mergeCkpt(low, high_life), high_life); + EXPECT_EQ(mergeCkpt(high_life, low), high_life); + + /// Fields advance INDEPENDENTLY: a merge of two bodies each newer in a different field keeps both. + const RefCkpt both = mergeCkpt(high_ckpt, high_seal); + EXPECT_EQ(both.checkpoint_snapshot_id, ID_1_2); + EXPECT_EQ(both.last_epoch_seal, ID_2_1); + + /// An absent optional loses to a present one, whichever side it is on, and two absents stay absent. + const RefCkpt none{.life_epoch = std::optional{3}, .checkpoint_snapshot_id = std::nullopt, .last_epoch_seal = std::nullopt}; + EXPECT_EQ(mergeCkpt(none, low), low); + EXPECT_EQ(mergeCkpt(low, none), low); + EXPECT_EQ(mergeCkpt(none, none), none); + + /// Identical bodies merge to themselves -- the property `publishCkpt` turns into "no write". + EXPECT_EQ(mergeCkpt(low, low), low); + + /// A contribution that knows NOTHING about `life_epoch` (the snapshot publisher's shape) must not + /// erase it. This is the case a plain assignment would get wrong. + const RefCkpt publisher_only{.life_epoch = std::nullopt, .checkpoint_snapshot_id = ID_1_2, .last_epoch_seal = std::nullopt}; + const RefCkpt advanced = mergeCkpt(low, publisher_only); + EXPECT_EQ(advanced.life_epoch, 3u); + EXPECT_EQ(advanced.checkpoint_snapshot_id, ID_1_2); + EXPECT_EQ(advanced.last_epoch_seal, ID_1_1) << "the publisher knows nothing about the seal and must " + "not drag it backwards"; +} + +/// --------------------------------------------------------------------------------------------- +/// publishCkpt +/// --------------------------------------------------------------------------------------------- + +TEST(CASRefCheckpoint, CreatesTheObjectWhenItIsAbsent) +{ + auto backend = std::make_shared(); + const Layout layout{"p"}; + const RootNamespace ns{"srv1/ckpt_create"}; + const NamespaceLifeId life = DB::Cas::tests::fixture::fixtureLife(ns); + const RefCkpt birth{.life_epoch = std::optional{5}, .checkpoint_snapshot_id = std::nullopt, .last_epoch_seal = std::nullopt}; + + EXPECT_EQ(publishCkpt(*backend, layout, life, birth, 1, ALWAYS_ADMITTED, generousDeadline()), + CkptPublishOutcome::Published); + const auto sample = readCkpt(*backend, layout, life); + ASSERT_TRUE(sample.has_value()); + EXPECT_EQ(sample->ckpt, birth); +} + +/// Any writer may CREATE the object, and none of them may complete it. A publisher knows only the +/// checkpoint, so it creates an object that knows only the checkpoint; the birth transaction's +/// `life_epoch` merges in afterwards. Order does not matter -- the merge is a per-field maximum, and +/// no writer ever supplies a field it does not know (a guess here would be permanent, since the merge +/// can never lower it). +TEST(CASRefCheckpoint, EachWriterCreatesWithOnlyWhatItKnowsAndTheOtherFieldsMergeInLater) +{ + auto backend = std::make_shared(); + const Layout layout{"p"}; + const RootNamespace ns{"srv1/ckpt_partial_create"}; + const NamespaceLifeId life = DB::Cas::tests::fixture::fixtureLife(ns); + const RefCkpt publisher{.life_epoch = std::nullopt, .committed_through = ID_1_1, .checkpoint_snapshot_id = ID_1_1, .last_epoch_seal = std::nullopt}; + + ASSERT_EQ(publishCkpt(*backend, layout, life, publisher, 1, ALWAYS_ADMITTED, generousDeadline()), + CkptPublishOutcome::Published); + const auto created = readCkpt(*backend, layout, life); + ASSERT_TRUE(created.has_value()); + EXPECT_EQ(created->ckpt.checkpoint_snapshot_id, ID_1_1); + EXPECT_FALSE(created->ckpt.life_epoch.has_value()) << "the publisher must not invent a genesis epoch"; + + const RefCkpt birth{.life_epoch = std::optional{1}, .checkpoint_snapshot_id = std::nullopt, + .last_epoch_seal = std::nullopt}; + ASSERT_EQ(publishCkpt(*backend, layout, life, birth, 1, ALWAYS_ADMITTED, generousDeadline()), + CkptPublishOutcome::Published); + const auto completed = readCkpt(*backend, layout, life); + ASSERT_TRUE(completed.has_value()); + EXPECT_EQ(completed->ckpt.life_epoch, 1u); + EXPECT_EQ(completed->ckpt.checkpoint_snapshot_id, ID_1_1) << "and must not lose the checkpoint on the way in"; +} + +/// The conflict path is the whole reason the algorithm re-READS instead of retrying its bytes: the +/// winner's field must survive the loser's retry. Here a concurrent writer advances the seal between +/// our read and our CAS; our retry must merge onto the new body, not overwrite it. +TEST(CASRefCheckpoint, TokenConflictRereadsAndMergesOntoTheWinner) +{ + const Layout layout{"p"}; + const RootNamespace ns{"srv1/ckpt_conflict"}; + const NamespaceLifeId life = DB::Cas::tests::fixture::fixtureLife(ns); + const String key = layout.refCkptKey(life); + const RefCkpt base{.life_epoch = std::optional{1}, .checkpoint_snapshot_id = std::nullopt, .last_epoch_seal = std::nullopt}; + + auto backend = std::make_shared(key); + ASSERT_EQ(backend->casPut(key, encodeRefCkpt(base), std::nullopt).outcome, CasOutcome::Committed); + + /// The concurrent sealer lands exactly ONCE, immediately after our first read -- so our first CAS + /// carries a token that is no longer current, and our retry has to merge onto its body. + bool interfered = false; + backend->on_get = [&] + { + if (interfered) + return; + interfered = true; + const RefCkpt sealer{.life_epoch = std::optional{1}, .committed_through = ID_2_1, .checkpoint_snapshot_id = std::nullopt, .last_epoch_seal = ID_2_1}; + const HeadResult h = backend->head(key); + ASSERT_EQ(backend->putOverwrite(key, encodeRefCkpt(mergeCkpt(base, sealer)), h.token).outcome, + PutOutcome::Done); + }; + + const RefCkpt publisher{.life_epoch = std::nullopt, .committed_through = ID_1_2, .checkpoint_snapshot_id = ID_1_2, .last_epoch_seal = std::nullopt}; + EXPECT_EQ(publishCkpt(*backend, layout, life, publisher, 1, ALWAYS_ADMITTED, generousDeadline()), + CkptPublishOutcome::Published); + + const auto sample = readCkpt(*backend, layout, life); + ASSERT_TRUE(sample.has_value()); + EXPECT_EQ(sample->ckpt.checkpoint_snapshot_id, ID_1_2) << "our own contribution must land"; + EXPECT_EQ(sample->ckpt.last_epoch_seal, ID_2_1) + << "the concurrent writer's seal must survive our retry -- a retry that reused the body read " + "before the conflict would silently drop it (TLC `_sab_sealclobbersbase`)"; + EXPECT_EQ(sample->ckpt.life_epoch, 1u); + EXPECT_GE(backend->casPutCount(key), 2u) << "the first CAS must have been rejected, not skipped"; +} + +/// A contribution that adds nothing issues NO write. This is a correctness property, not a saving: +/// both writers publish on every snapshot and every seal, and a no-op write would mint a fresh token +/// each time, turning every other writer's in-flight CAS into a conflict for identical bytes. +TEST(CASRefCheckpoint, AnIdenticalMergedBodyIssuesNoWrite) +{ + auto backend = std::make_shared(); + const Layout layout{"p"}; + const RootNamespace ns{"srv1/ckpt_noop"}; + const NamespaceLifeId life = DB::Cas::tests::fixture::fixtureLife(ns); + const String key = layout.refCkptKey(life); + const RefCkpt full{.life_epoch = std::optional{1}, .committed_through = ID_2_1, .checkpoint_snapshot_id = ID_1_2, .last_epoch_seal = ID_2_1}; + + ASSERT_EQ(publishCkpt(*backend, layout, life, full, 1, ALWAYS_ADMITTED, generousDeadline()), + CkptPublishOutcome::Published); + const uint64_t writes_after_create = backend->casPutCount(key); + const Token token_after_create = backend->head(key).token; + + /// The same contribution again, and a strictly OLDER one: neither adds anything. + EXPECT_EQ(publishCkpt(*backend, layout, life, full, 1, ALWAYS_ADMITTED, generousDeadline()), + CkptPublishOutcome::IdenticalSkip); + const RefCkpt older{.life_epoch = std::optional{1}, .committed_through = ID_1_1, .checkpoint_snapshot_id = ID_1_1, .last_epoch_seal = std::nullopt}; + EXPECT_EQ(publishCkpt(*backend, layout, life, older, 1, ALWAYS_ADMITTED, generousDeadline()), + CkptPublishOutcome::IdenticalSkip); + + EXPECT_EQ(backend->casPutCount(key), writes_after_create) << "a skip must issue no CAS at all"; + EXPECT_EQ(backend->head(key).token, token_after_create) << "and must not mint a new incarnation"; +} + +/// The fence is re-checked AFTER the read and BEFORE the write, on every attempt. A generation that +/// moved means this writer's lease incarnation is gone, so its merged body is stale even if the fence +/// is live again under a fresh incarnation. +TEST(CASRefCheckpoint, AFenceBumpBetweenTheReadAndTheCasWritesNothing) +{ + auto backend = std::make_shared(); + const Layout layout{"p"}; + const RootNamespace ns{"srv1/ckpt_fenced"}; + const NamespaceLifeId life = DB::Cas::tests::fixture::fixtureLife(ns); + const String key = layout.refCkptKey(life); + const RefCkpt base{.life_epoch = std::optional{1}, .committed_through = ID_1_1, .checkpoint_snapshot_id = ID_1_1, .last_epoch_seal = std::nullopt}; + ASSERT_EQ(publishCkpt(*backend, layout, life, base, 1, ALWAYS_ADMITTED, generousDeadline()), + CkptPublishOutcome::Published); + const Token token_before = backend->head(key).token; + const uint64_t writes_before = backend->casPutCount(key); + + /// The callback the pool wires from `CasMountRuntime::checkFenceOrThrow`: it throws when the + /// generation moved since admission. Mirrors the real site's class (the transient, upstream-retryable + /// one) so the stub cannot drift into testing a shape production never produces. + const auto moved_fence = [](uint64_t admitted) + { + throw DB::Exception(DB::ErrorCodes::NETWORK_ERROR, + "fence generation moved since admission ({})", admitted); + }; + + const RefCkpt advance{.life_epoch = std::nullopt, .committed_through = ID_1_2, .checkpoint_snapshot_id = ID_1_2, .last_epoch_seal = std::nullopt}; + EXPECT_EQ(publishCkpt(*backend, layout, life, advance, 1, moved_fence, generousDeadline()), + CkptPublishOutcome::FencedOut); + EXPECT_EQ(backend->casPutCount(key), writes_before) << "the check precedes the CAS, so nothing is sent"; + EXPECT_EQ(backend->head(key).token, token_before); + EXPECT_EQ(readCkptOrFail(*backend, layout, life), base); +} + +/// Persistent contention fails CLOSED and says so. There is no partial state to clean up -- every +/// attempt either committed the complete merged body or changed nothing -- but the caller must be told +/// its contribution is unpublished rather than left to assume it landed. +TEST(CASRefCheckpoint, AnExhaustedDeadlineUnderPersistentConflictThrowsRetryLater) +{ + const Layout layout{"p"}; + const RootNamespace ns{"srv1/ckpt_exhausted"}; + const NamespaceLifeId life = DB::Cas::tests::fixture::fixtureLife(ns); + const String key = layout.refCkptKey(life); + const RefCkpt base{.life_epoch = std::optional{1}, .committed_through = ID_1_1, .checkpoint_snapshot_id = ID_1_1, .last_epoch_seal = std::nullopt}; + + auto backend = std::make_shared(key); + ASSERT_EQ(backend->casPut(key, encodeRefCkpt(base), std::nullopt).outcome, CasOutcome::Committed); + + /// Every read is followed by a rewrite of the SAME body under a fresh incarnation, so the token this + /// call holds is always stale and every CAS it issues conflicts. The clock advances one step per + /// read, so the DEADLINE is what ends the loop -- deterministically, with no sleeping and well + /// before the live-lock brake. + uint64_t now = 0; + backend->on_get = [&] + { + ++now; + const HeadResult h = backend->head(key); + if (h.exists) + backend->putOverwrite(key, encodeRefCkpt(base), h.token); + }; + + const RefCkpt advance{.life_epoch = std::nullopt, .committed_through = ID_1_2, .checkpoint_snapshot_id = ID_1_2, .last_epoch_seal = std::nullopt}; + expectThrowsCode(DB::ErrorCodes::NETWORK_ERROR, + [&] { publishCkpt(*backend, layout, life, advance, 1, ALWAYS_ADMITTED, CkptDeadline{[&] { return now; }, 5}); }); + EXPECT_EQ(readCkptOrFail(*backend, layout, life), base) << "no partial state: every attempt either " + "committed the complete merged body or wrote nothing"; +} + +TEST(CASRefCheckpoint, AmbiguousCommittedCasIsResolvedByOneExactReadWithoutBlindRetry) +{ + auto backend = std::make_shared(); + const Layout layout{"p"}; + const NamespaceLifeId life = DB::Cas::tests::fixture::fixtureLife(RootNamespace{"srv1/ckpt_ambiguous_committed"}); + const String key = layout.refCkptKey(life); + const RefCkpt base{.life_epoch = 1, .committed_through = ID_1_1, + .checkpoint_snapshot_id = std::nullopt, .last_epoch_seal = std::nullopt}; + const RefCkpt contribution{.life_epoch = std::nullopt, .committed_through = ID_1_2, + .checkpoint_snapshot_id = std::nullopt, .last_epoch_seal = std::nullopt}; + ASSERT_EQ(backend->casPut(key, encodeRefCkpt(base), std::nullopt).outcome, CasOutcome::Committed); + backend->arm(key, AmbiguousCkptBackend::Fault::CommitThenThrow); + + EXPECT_EQ(publishCkpt(*backend, layout, life, contribution, 7, ALWAYS_ADMITTED, generousDeadline()), + CkptPublishOutcome::Published); + EXPECT_EQ(backend->journal, (std::vector{"GET", "CAS", "GET"})); + EXPECT_EQ(readCkptOrFail(*backend, layout, life).committed_through, ID_1_2); +} + +TEST(CASRefCheckpoint, AmbiguousUncommittedCasRetriesAgainstTheExactReadToken) +{ + auto backend = std::make_shared(); + const Layout layout{"p"}; + const NamespaceLifeId life = DB::Cas::tests::fixture::fixtureLife(RootNamespace{"srv1/ckpt_ambiguous_retry"}); + const String key = layout.refCkptKey(life); + const RefCkpt base{.life_epoch = 1, .committed_through = ID_1_1, + .checkpoint_snapshot_id = std::nullopt, .last_epoch_seal = std::nullopt}; + const RefCkpt contribution{.life_epoch = std::nullopt, .committed_through = ID_1_2, + .checkpoint_snapshot_id = std::nullopt, .last_epoch_seal = std::nullopt}; + ASSERT_EQ(backend->casPut(key, encodeRefCkpt(base), std::nullopt).outcome, CasOutcome::Committed); + backend->arm(key, AmbiguousCkptBackend::Fault::ThrowWithoutCommit); + + EXPECT_EQ(publishCkpt(*backend, layout, life, contribution, 7, ALWAYS_ADMITTED, generousDeadline()), + CkptPublishOutcome::Published); + EXPECT_EQ(backend->journal, (std::vector{"GET", "CAS", "GET", "CAS"})); + EXPECT_EQ(readCkptOrFail(*backend, layout, life).committed_through, ID_1_2); +} + +TEST(CASRefCheckpoint, AmbiguousCasAcceptsAValidDominatingDurableFrontier) +{ + auto backend = std::make_shared(); + const Layout layout{"p"}; + const NamespaceLifeId life = DB::Cas::tests::fixture::fixtureLife(RootNamespace{"srv1/ckpt_ambiguous_dominating"}); + const String key = layout.refCkptKey(life); + const RefCkpt base{.life_epoch = 1, .committed_through = ID_1_1, + .checkpoint_snapshot_id = std::nullopt, .last_epoch_seal = std::nullopt}; + const RefCkpt contribution{.life_epoch = std::nullopt, .committed_through = ID_1_2, + .checkpoint_snapshot_id = std::nullopt, .last_epoch_seal = std::nullopt}; + const RefCkpt dominating{.life_epoch = 1, .committed_through = ID_2_1, + .checkpoint_snapshot_id = std::nullopt, .last_epoch_seal = ID_2_1}; + ASSERT_EQ(backend->casPut(key, encodeRefCkpt(base), std::nullopt).outcome, CasOutcome::Committed); + backend->dominating_bytes = encodeRefCkpt(dominating); + backend->arm(key, AmbiguousCkptBackend::Fault::CommitThenThrow); + + EXPECT_EQ(publishCkpt(*backend, layout, life, contribution, 7, ALWAYS_ADMITTED, generousDeadline()), + CkptPublishOutcome::Published); + EXPECT_EQ(backend->journal, (std::vector{"GET", "CAS", "GET"})); + EXPECT_EQ(readCkptOrFail(*backend, layout, life), dominating); +} + +TEST(CASRefCheckpoint, FailedExactReadAfterAmbiguousCasFailsClosedWithoutAnotherCas) +{ + auto backend = std::make_shared(); + const Layout layout{"p"}; + const NamespaceLifeId life = DB::Cas::tests::fixture::fixtureLife(RootNamespace{"srv1/ckpt_ambiguous_read_failed"}); + const String key = layout.refCkptKey(life); + const RefCkpt base{.life_epoch = 1, .committed_through = ID_1_1, + .checkpoint_snapshot_id = std::nullopt, .last_epoch_seal = std::nullopt}; + ASSERT_EQ(backend->casPut(key, encodeRefCkpt(base), std::nullopt).outcome, CasOutcome::Committed); + backend->fail_resolution_get = true; + backend->arm(key, AmbiguousCkptBackend::Fault::ThrowWithoutCommit); + + expectThrowsCode(DB::ErrorCodes::NETWORK_ERROR, [&] + { + publishCkpt(*backend, layout, life, RefCkpt{.life_epoch = std::nullopt, .committed_through = ID_1_2, + .checkpoint_snapshot_id = std::nullopt, .last_epoch_seal = std::nullopt}, 7, + ALWAYS_ADMITTED, generousDeadline()); + }); + EXPECT_EQ(backend->journal, (std::vector{"GET", "CAS", "GET"})); + EXPECT_EQ(readCkptOrFail(*backend, layout, life), base); +} + +TEST(CASRefCheckpoint, FenceMovementAroundAmbiguityResolutionMakesTheExactReadInert) +{ + for (const bool move_before_read : {true, false}) + { + auto backend = std::make_shared(); + const Layout layout{"p"}; + const NamespaceLifeId life = DB::Cas::tests::fixture::fixtureLife( + RootNamespace{move_before_read ? "srv1/ckpt_fence_before_resolution" : "srv1/ckpt_fence_after_resolution"}); + const String key = layout.refCkptKey(life); + const RefCkpt base{.life_epoch = 1, .committed_through = ID_1_1, + .checkpoint_snapshot_id = std::nullopt, .last_epoch_seal = std::nullopt}; + ASSERT_EQ(backend->casPut(key, encodeRefCkpt(base), std::nullopt).outcome, CasOutcome::Committed); + bool admitted = true; + const auto move_fence = [&] { admitted = false; }; + if (move_before_read) + backend->before_resolution_get = move_fence; + else + backend->after_resolution_get = move_fence; + backend->arm(key, AmbiguousCkptBackend::Fault::CommitThenThrow); + + const auto check_admission = [&](uint64_t) + { + if (!admitted) + throw DB::Exception(DB::ErrorCodes::NETWORK_ERROR, "fence moved"); + }; + EXPECT_EQ(publishCkpt(*backend, layout, life, + RefCkpt{.life_epoch = std::nullopt, .committed_through = ID_1_2, + .checkpoint_snapshot_id = std::nullopt, .last_epoch_seal = std::nullopt}, 7, + check_admission, generousDeadline()), CkptPublishOutcome::FencedOut); + EXPECT_EQ(backend->journal, (std::vector{"GET", "CAS", "GET"})); + } +} + +TEST(CASRefCheckpoint, ContinuedAmbiguityStopsAtTheDeadlineAndNeverIssuesConsecutiveCasAttempts) +{ + auto backend = std::make_shared(); + const Layout layout{"p"}; + const NamespaceLifeId life = DB::Cas::tests::fixture::fixtureLife(RootNamespace{"srv1/ckpt_ambiguity_deadline"}); + const String key = layout.refCkptKey(life); + const RefCkpt base{.life_epoch = 1, .committed_through = ID_1_1, + .checkpoint_snapshot_id = std::nullopt, .last_epoch_seal = std::nullopt}; + ASSERT_EQ(backend->casPut(key, encodeRefCkpt(base), std::nullopt).outcome, CasOutcome::Committed); + uint64_t now = 0; + backend->after_resolution_get = [&] { ++now; }; + backend->arm(key, AmbiguousCkptBackend::Fault::AlwaysThrowWithoutCommit); + + expectThrowsCode(DB::ErrorCodes::NETWORK_ERROR, [&] + { + publishCkpt(*backend, layout, life, + RefCkpt{.life_epoch = std::nullopt, .committed_through = ID_1_2, + .checkpoint_snapshot_id = std::nullopt, .last_epoch_seal = std::nullopt}, + 7, ALWAYS_ADMITTED, CkptDeadline{[&] { return now; }, 3}); + }); + EXPECT_EQ(backend->journal, + (std::vector{"GET", "CAS", "GET", "CAS", "GET", "CAS", "GET"})); + EXPECT_EQ(readCkptOrFail(*backend, layout, life), base); +} + +/// A `_ckpt` that does not decode is NEVER overwritten. It is the only record of recovery's base and +/// of what cleanup may delete, so replacing it with a body derived from the contribution alone would +/// erase the base and leave a well-formed object a reader would trust. +TEST(CASRefCheckpoint, ACorruptCheckpointIsNeverOverwritten) +{ + auto backend = std::make_shared(); + const Layout layout{"p"}; + const RootNamespace ns{"srv1/ckpt_corrupt"}; + const NamespaceLifeId life = DB::Cas::tests::fixture::fixtureLife(ns); + const String key = layout.refCkptKey(life); + ASSERT_EQ(publishCkpt(*backend, layout, life, + RefCkpt{.life_epoch = std::optional{1}, .committed_through = ID_1_2, .checkpoint_snapshot_id = ID_1_2, .last_epoch_seal = std::nullopt}, + 1, ALWAYS_ADMITTED, generousDeadline()), CkptPublishOutcome::Published); + + const String garbage = "not a cas object\n"; + overwriteObject(*backend, key, garbage); + + const RefCkpt birth{.life_epoch = std::optional{5}, .checkpoint_snapshot_id = std::nullopt, .last_epoch_seal = std::nullopt}; + expectThrowsCode(DB::ErrorCodes::CORRUPTED_DATA, + [&] { publishCkpt(*backend, layout, life, birth, 1, ALWAYS_ADMITTED, generousDeadline()); }); + EXPECT_EQ(backend->get(key)->bytes, garbage) << "corruption must be surfaced, never laundered into a " + "well-formed object"; +} + +/// --------------------------------------------------------------------------------------------- +/// The reader-side rules Task 6 and the cleanup call sites consume +/// --------------------------------------------------------------------------------------------- + +/// INV-4's three-way revalidation of a base that turned out to be missing. +TEST(CASRefCheckpoint, AMissingSampledBaseRestartsOnAnAdvancedTokenAndIsCorruptionOnAnUnchangedOne) +{ + const Token sampled{"t1", TokenType::Emulated}; + const Token advanced{"t2", TokenType::Emulated}; + + EXPECT_EQ(classifyMissingSampledBase(sampled, advanced), MissingBaseVerdict::RestartRecovery) + << "cleanup legitimately moved the checkpoint while we read; restart from the newer base"; + EXPECT_EQ(classifyMissingSampledBase(sampled, sampled), MissingBaseVerdict::Corrupted) + << "the checkpoint still names an object that is not there, which the strictly-below deletion " + "gate makes unreachable in an honest run"; + EXPECT_EQ(classifyMissingSampledBase(sampled, std::nullopt), MissingBaseVerdict::Corrupted) + << "a namespace with a sampled base and no checkpoint at all is worse, not better"; +} + +/// The deletion gate is STRICTLY below, because the checkpoint names the snapshot a recovery is +/// entitled to fetch by exact key. At-or-below is TLC counterexample `_sab_staleckptcorruption`. +TEST(CASRefCheckpoint, SnapshotsAreDeletableStrictlyBelowTheCheckpoint) +{ + EXPECT_TRUE(snapshotDeletableUnderCkpt(ID_1_1, ID_1_2)); + EXPECT_FALSE(snapshotDeletableUnderCkpt(ID_1_2, ID_1_2)) << "the checkpoint's own base is off limits"; + EXPECT_FALSE(snapshotDeletableUnderCkpt(ID_2_1, ID_1_2)); + /// Fail closed: a namespace with no checkpoint has established no covering base, so nothing is + /// deletable -- a stale or absent pointer may only ever under-clean. + EXPECT_FALSE(snapshotDeletableUnderCkpt(ID_1_1, std::nullopt)); +} + +/// --------------------------------------------------------------------------------------------- +/// The REAL call sites, through the ledger +/// --------------------------------------------------------------------------------------------- + +/// The namespace-birth transaction creates the checkpoint, and it is the only writer that can: the +/// `life_epoch` is this transaction's own writer epoch. +TEST(CASRefCheckpoint, NamespaceBirthCreatesTheCheckpointCarryingItsLifeEpoch) +{ + auto backend = std::make_shared(); + auto store = openPool(backend); + const RootNamespace ns{"srv1/ckpt_birth"}; + + /// Stage B (Task 4-C): the catalog carries no entry for `ns` before its first open, and the + /// namespace's real incarnation does not exist to name a key with yet -- the pre-birth analog of + /// "nothing exists" is "nothing is even NAMED", checked at the catalog rather than at a key this + /// test cannot yet compute. + EXPECT_TRUE(CasRefCatalog::read(*backend, store->layout()).catalog.entries.empty()) + << "nothing exists before the birth"; + ASSERT_EQ(publishRef(store, ns, "ref_1", 1), (RefTxnId{store->writerEpoch(), 1})); + + const NamespaceLifeId life = liveLifeOrFail(*backend, store->layout(), ns); + const auto sample = readCkpt(*backend, store->layout(), life); + ASSERT_TRUE(sample.has_value()) << "spec §3 creates the _ckpt before the namespace becomes Live"; + EXPECT_EQ(sample->ckpt.life_epoch, store->writerEpoch()); + EXPECT_FALSE(sample->ckpt.checkpoint_snapshot_id.has_value()) << "a newborn namespace has no base yet"; + EXPECT_FALSE(sample->ckpt.last_epoch_seal.has_value()); +} + +/// The snapshot publisher is INV-4's second writer: the body PUT commits, then the checkpoint names it. +TEST(CASRefCheckpoint, ACommittedSnapshotPublishAdvancesTheCheckpoint) +{ + auto backend = std::make_shared(); + auto store = openPool(backend); + const uint64_t epoch = store->writerEpoch(); + const RootNamespace ns{"srv1/ckpt_publish"}; + + ASSERT_EQ(publishRef(store, ns, "ref_1", 1), (RefTxnId{epoch, 1})); + const NamespaceLifeId life = liveLifeOrFail(*backend, store->layout(), ns); + ASSERT_FALSE(readCkptOrFail(*backend, store->layout(), life).checkpoint_snapshot_id.has_value()); + + ASSERT_TRUE(store->tryPublishSnapshotAndAdvanceCheckpointOnce(ns)); + const auto published = store->newestPublishedSnapshotIdForTest(ns); + ASSERT_TRUE(published.has_value()); + + const auto sample = readCkpt(*backend, store->layout(), life); + ASSERT_TRUE(sample.has_value()); + EXPECT_EQ(sample->ckpt.checkpoint_snapshot_id, published); + EXPECT_EQ(sample->ckpt.life_epoch, epoch) << "the publisher contributes nothing about life_epoch, so " + "the merge must preserve what the birth wrote"; + /// And the snapshot body it names really is there -- the checkpoint may never point at a key that + /// does not exist, which is the premise the missing-base rule reasons from. + EXPECT_TRUE(backend->head(store->layout().refSnapshotKey(life, *published)).exists); +} + +/// The body-PUT/cleanup/`_ckpt` race, decided by the ORDER of the two writes: cleanup planned in the +/// window between the snapshot body PUT and the checkpoint CAS still reads the OLD checkpoint, and the +/// gate is strictly below it -- so it cannot delete the snapshot just published. +TEST(CASRefCheckpoint, CleanupPlannedBetweenTheBodyPutAndTheCkptCasCannotDeleteTheNewSnapshot) +{ + auto backend = std::make_shared(); + auto store = openPool(backend); + const uint64_t epoch = store->writerEpoch(); + const RootNamespace ns{"srv1/ckpt_race"}; + + ASSERT_EQ(publishRef(store, ns, "ref_1", 1), (RefTxnId{epoch, 1})); + const NamespaceLifeId life = liveLifeOrFail(*backend, store->layout(), ns); + ASSERT_TRUE(store->tryPublishSnapshotAndAdvanceCheckpointOnce(ns)); + const RefTxnId first_snapshot = *store->newestPublishedSnapshotIdForTest(ns); + + ASSERT_EQ(publishRef(store, ns, "ref_2", 2), (RefTxnId{epoch, 2})); + /// The checkpoint a cleanup pass sampled BEFORE the second publication -- the stale reading the + /// race hands it. + const std::optional stale_checkpoint = readCkptOrFail(*backend, store->layout(), life).checkpoint_snapshot_id; + ASSERT_EQ(stale_checkpoint, first_snapshot); + + ASSERT_TRUE(store->tryPublishSnapshotAndAdvanceCheckpointOnce(ns)); + const RefTxnId second_snapshot = *store->newestPublishedSnapshotIdForTest(ns); + ASSERT_LT(first_snapshot, second_snapshot); + + /// Planning against the STALE checkpoint: the just-published snapshot is not deletable, and neither + /// is the one the stale checkpoint itself names. A stale pointer can only under-clean. + EXPECT_FALSE(snapshotDeletableUnderCkpt(second_snapshot, stale_checkpoint)); + EXPECT_FALSE(snapshotDeletableUnderCkpt(first_snapshot, stale_checkpoint)); + /// Once the checkpoint is re-read, the older snapshot becomes reclaimable and the base does not. + const std::optional fresh_checkpoint = readCkptOrFail(*backend, store->layout(), life).checkpoint_snapshot_id; + EXPECT_TRUE(snapshotDeletableUnderCkpt(first_snapshot, fresh_checkpoint)); + EXPECT_FALSE(snapshotDeletableUnderCkpt(second_snapshot, fresh_checkpoint)); +} + +/// One `_ckpt` write per publication and not one more: the checkpoint is written where the snapshot is +/// published, and a publisher with nothing above its newest snapshot touches it at all. +TEST(CASRefCheckpoint, TheCheckpointIsWrittenOncePerPublicationAndNotOnIdleAttempts) +{ + auto backend = std::make_shared(); + auto store = openPool(backend); + const uint64_t epoch = store->writerEpoch(); + const RootNamespace ns{"srv1/ckpt_republish"}; + + ASSERT_EQ(publishRef(store, ns, "ref_1", 1), (RefTxnId{epoch, 1})); + const NamespaceLifeId life = liveLifeOrFail(*backend, store->layout(), ns); + const String key = store->layout().refCkptKey(life); + const uint64_t writes_after_birth = backend->casPutCount(key); + EXPECT_EQ(writes_after_birth, 2u) + << "birth publishes `life_epoch` before its log, then the durable log's committed frontier"; + + ASSERT_TRUE(store->tryPublishSnapshotAndAdvanceCheckpointOnce(ns)); + EXPECT_EQ(backend->casPutCount(key), writes_after_birth + 1) << "one publication, one checkpoint CAS"; + const uint64_t writes_after_publish = backend->casPutCount(key); + const auto after_publish = readCkpt(*backend, store->layout(), life); + ASSERT_TRUE(after_publish.has_value()); + + /// Nothing was appended since, so there is nothing above the newest snapshot: the publisher declines + /// before it reaches the checkpoint at all, and repeating the attempt changes nothing. + EXPECT_FALSE(store->tryPublishSnapshotAndAdvanceCheckpointOnce(ns)); + EXPECT_FALSE(store->tryPublishSnapshotAndAdvanceCheckpointOnce(ns)); + EXPECT_EQ(backend->casPutCount(key), writes_after_publish); + EXPECT_EQ(readCkptOrFail(*backend, store->layout(), life), after_publish->ckpt); +} + +TEST(CASRefCheckpoint, SnapshotPublisherRefusesEpochSealCandidateWithoutAnyWrite) +{ + auto backend = std::make_shared(); + const RootNamespace ns{"srv1/no_snapshot_at_seal"}; + uint64_t predecessor_epoch = 0; + { + auto predecessor = openPool(backend); + predecessor_epoch = predecessor->writerEpoch(); + ASSERT_EQ(publishRef(predecessor, ns, "ref_1", 1), (RefTxnId{predecessor_epoch, 1})); + } + + auto store = openPool(backend); + ASSERT_GT(store->writerEpoch(), predecessor_epoch); + ASSERT_EQ(store->listRefs(ns).size(), 1u) << "recovery must close the predecessor epoch before publishing"; + + const RefTxnId seal_id{predecessor_epoch, 2}; + const NamespaceLifeId life = liveLifeOrFail(*backend, store->layout(), ns); + const String snapshot_key = store->layout().refSnapshotKey(life, seal_id); + const String ckpt_key = store->layout().refCkptKey(life); + ASSERT_EQ(store->lastEpochSealForTest(ns), std::make_optional(seal_id)); + ASSERT_EQ(readCkptOrFail(*backend, store->layout(), life).committed_through, std::make_optional(seal_id)); + const uint64_t snapshot_puts_before = backend->putCount(snapshot_key); + const uint64_t ckpt_cas_before = backend->casPutCount(ckpt_key); + + /// Recovery installed the epoch seal as the runtime's greatest applied transaction. The publisher + /// must decline it without reaching either durable write. + EXPECT_FALSE(store->tryPublishSnapshotAndAdvanceCheckpointOnce(ns)); + EXPECT_EQ(backend->putCount(snapshot_key), snapshot_puts_before); + EXPECT_EQ(backend->casPutCount(ckpt_key), ckpt_cas_before); + + /// Once an ordinary transaction advances the candidate beyond the seal, normal publication resumes. + ASSERT_EQ(publishRef(store, ns, "ref_2", 2), (RefTxnId{store->writerEpoch(), 1})); + EXPECT_TRUE(store->tryPublishSnapshotAndAdvanceCheckpointOnce(ns)); +} + +/// Publication replays a `NeedsRecovery` lane before it captures a snapshot and advances `_ckpt`. +TEST(CASRefCheckpoint, NeedsRecoveryReplaysBeforeCheckpointAdvance) +{ + auto backend = std::make_shared(); + auto store = openPool(backend); + const RootNamespace ns{"srv1/ckpt_poisoned"}; + + ASSERT_EQ(publishRef(store, ns, "ref_1", 1), (RefTxnId{store->writerEpoch(), 1})); + const NamespaceLifeId life = liveLifeOrFail(*backend, store->layout(), ns); + const String key = store->layout().refCkptKey(life); + const auto before = readCkpt(*backend, store->layout(), life); + ASSERT_TRUE(before.has_value()); + ASSERT_FALSE(before->ckpt.checkpoint_snapshot_id.has_value()); + const uint64_t writes_before = backend->casPutCount(key); + + /// Enter `NeedsRecovery`: an install throws after its transaction is + /// durable, leaving this cached table missing a transaction the log contains. + auto planned = std::make_exception_ptr(DB::Exception(DB::ErrorCodes::MEMORY_LIMIT_EXCEEDED, + "simulated allocation failure inside the post-durable install region")); + auto fired = std::make_shared>(false); + store->setInstallRegionProbeForTest([planned, fired] + { + if (fired->exchange(true)) + return; + ALLOW_ALLOCATIONS_IN_SCOPE; + std::rethrow_exception(planned); + }); + expectThrowsCode(DB::ErrorCodes::MEMORY_LIMIT_EXCEEDED, [&] { publishRef(store, ns, "ref_2", 2); }); + store->setInstallRegionProbeForTest(nullptr); + ASSERT_EQ(store->laneStateForTest(ns), RefLaneState::NeedsRecovery); + + /// The publish entry point recovers first, so the snapshot covers the stranded transaction. + EXPECT_TRUE(store->tryPublishSnapshotAndAdvanceCheckpointOnce(ns)); + EXPECT_TRUE(store->resolveRef(ns, "ref_2", /*allow_stale=*/false).has_value()) + << "the stranded transaction is durable; the re-derivation must have applied it"; + EXPECT_EQ(store->laneStateForTest(ns), RefLaneState::Ready); + EXPECT_GT(backend->casPutCount(key), writes_before) + << "and the checkpoint advances -- truthfully, over a snapshot that is not missing anything"; + EXPECT_TRUE(readCkptOrFail(*backend, store->layout(), life).checkpoint_snapshot_id.has_value()); + +} + +/// A publish admitted under an incarnation that is replaced mid-attempt advances NOTHING, and does not +/// adopt the snapshot either -- adopting would suppress every later publication for it while the +/// checkpoint still pointed below it, leaving recovery on an older base with nothing to fix it. +TEST(CASRefCheckpoint, APublishFencedOutMidAttemptDoesNotAdvanceTheCheckpoint) +{ + const RootNamespace ns{"srv1/ckpt_stale_gen"}; + + /// The watched key cannot be computed yet -- the real incarnation is minted only once the pool + /// exists and this namespace's first open resolves it (`setWatchedKey` below, once it has). + auto backend = std::make_shared(""); + DB::Cas::tests::seedPoolMetaForRestart(*backend); + PoolPtr store = Pool::open(backend, PoolConfig{.pool_prefix = "p", .server_root_id = "test"}); + + ASSERT_EQ(publishRef(store, ns, "ref_1", 1), (RefTxnId{store->writerEpoch(), 1})); + const NamespaceLifeId life = liveLifeOrFail(*backend, store->layout(), ns); + const String ckpt_key = store->layout().refCkptKey(life); + backend->setWatchedKey(ckpt_key); + + const auto before = readCkpt(*backend, store->layout(), life); + ASSERT_TRUE(before.has_value()); + ASSERT_FALSE(before->ckpt.checkpoint_snapshot_id.has_value()); + const uint64_t writes_before = backend->casPutCount(ckpt_key); + + /// Arm only after the precondition read above. The next watched `_ckpt` read is therefore the one + /// inside this publish's read-then-CAS window, after the attempt captured its immutable runtime + /// generation. Arming before `readCkpt` would stale the runtime before the operation began and test + /// entry admission instead of the intended mid-attempt recheck. + bool hook_fired = false; + backend->on_get = [&] + { + if (hook_fired) + return; + hook_fired = true; + DB::Cas::tests::rearmMountFenceAfterAnomalyForTest(store); + }; + + EXPECT_FALSE(store->tryPublishSnapshotAndAdvanceCheckpointOnce(ns)) + << "a publish whose checkpoint could not be advanced must not report success"; + EXPECT_TRUE(hook_fired) << "the checkpoint read-then-CAS seam was never exercised"; + backend->on_get = nullptr; + EXPECT_EQ(backend->casPutCount(ckpt_key), writes_before) << "nothing may be sent after the fence moved"; + EXPECT_FALSE(readCkptOrFail(*backend, store->layout(), life).checkpoint_snapshot_id.has_value()); + EXPECT_FALSE(store->newestPublishedSnapshotIdForTest(ns).has_value()) + << "the snapshot must not be adopted as the newest while its checkpoint is unpublished"; +} + +/// =================================================================================== +/// Equivalence fences for the `prepareRefChunk` extraction (Stage B `{#extract-prepare-ref-chunk}`) +/// =================================================================================== +/// +/// An extraction is only safe to review if something pins what crosses its boundary. These three +/// fences are deliberately NOT red-first: they pass on the PRE-extraction tree and must keep passing +/// after it, which is the whole point -- the literals below were captured from a real append on the +/// pre-extraction tree and pasted in, so re-deriving them afterwards cannot silently measure the +/// change against itself. +/// +/// They live in this TU rather than beside the pure preparation tests because all three need a real +/// backend and the real append lane, which this suite already drives through `publishRef` (including +/// the namespace birth, the one chunk shape whose first durable effect is the `_ckpt` and not the +/// ref-log `PUT`). +TEST(CASRefCheckpoint, CommitRefChunkDurableBytesUnchangedByExtraction) +{ + auto backend = std::make_shared(); + auto store = openPool(backend); + const RootNamespace ns{"test/golden@cas@"}; + + const RefTxnId id = publishRef(store, ns, "gold_ref", 7); + ASSERT_EQ(id.writer_epoch, 1u); + ASSERT_EQ(id.ref_sequence, 1u); + + /// The KEY carries the namespace incarnation, so its life segment is rendered rather than pasted + /// (Task 1c re-keys it); every other segment is literal. Stage B (Task 4-C): the incarnation is now + /// a REAL, randomly minted catalog value rather than the Stage-A sentinel, so it is learned back + /// from the catalog (`liveLifeOrFail`) rather than pasted as a literal -- the shape assertion below + /// is unaffected, since it names every OTHER segment literally and renders this one dynamically. + const NamespaceLifeId life = liveLifeOrFail(*backend, store->layout(), ns); + const String key = store->layout().refLogKey(life, id); + EXPECT_EQ(key, "p/cas/ns/stream/" + renderIncarnation(life.incarnation) + + "/_log/0000000000000001-0000000000000001.zst") + << "the canonical ref-log key the append lane derives"; + + /// The BODY is checked as exact length plus a 128-bit SipHash of it -- not literally byte for byte, + /// but any change that survives both is a 128-bit collision at a fixed length, which is the trade for + /// keeping the assertion readable. It is a function of `{format generation, ns, id, ops, + /// chain_link}` only -- no incarnation reaches it. The generation-9 committed-frontier cut updates + /// the hash while leaving the payload shape and exact length unchanged. + const auto got = backend->get(key); + ASSERT_TRUE(got.has_value()) << "the birth chunk must be durable at its canonical key"; + EXPECT_EQ(got->bytes.size(), 177u) << "the sealed ref-log body changed size"; + SipHash body_hash; + body_hash.update(got->bytes.data(), got->bytes.size()); + EXPECT_EQ(getHexUIntLowercase(body_hash.get128()), "784e7fd1ae0010f9cffdac1796730070") + << "the sealed ref-log body changed content -- preparation must seal the same bytes it sealed " + "before the extraction"; +} + +/// The directive's "preserve backend request counts", asserted rather than assumed: preparation is pure, +/// so lifting it out must not add or remove a single request. One birth chunk = exactly one write-once +/// `PUT` at the ref-log key, no read-back, plus the two ordered `_ckpt` CASes required by the protocol: +/// creation publishes `life_epoch` before the log and the append lane publishes `committed_through` +/// after the log is durable. +/// +/// COUNTS per key. Request ORDER is not checked here and cannot be with these counters; the ordering +/// that matters for a birth -- `_ckpt` before the ref-log `PUT` -- is argued at the call site and would +/// need a sequence-recording backend to pin. +TEST(CASRefCheckpoint, AppendRequestCountUnchangedByExtraction) +{ + auto backend = std::make_shared(); + auto store = openPool(backend); + const RootNamespace ns{"test/req@cas@"}; + + const RefTxnId id = publishRef(store, ns, "req_ref", 1); + const NamespaceLifeId life = liveLifeOrFail(*backend, store->layout(), ns); + const String log_key = store->layout().refLogKey(life, id); + const String ckpt_key = store->layout().refCkptKey(life); + + EXPECT_EQ(backend->putCount(log_key), 1u) << "exactly one write-once PUT per committed chunk"; + /// ONE GET, not zero, since Stage B (Task 4-C): `resolveNamespaceLife`'s `completeCreation` call + /// publishes this life's `_ckpt.life_epoch` BEFORE the birth chunk is prepared, so this table's + /// OWN recovery walk (also inside this `appendRefOps`, ahead of the commit) grounds itself at the + /// genesis position `_ckpt` now names and confirms it absent by exact key -- which is `log_key` + /// itself, the position the birth chunk is about to occupy. That GET precedes the Committed PUT; + /// the PUT itself still owes no read-back. + EXPECT_EQ(backend->getCount(log_key), 1u) << "one grounding probe from recovery, before the birth PUT"; + EXPECT_EQ(backend->casPutCount(ckpt_key), 2u) + << "the birth contributes `life_epoch` before its log and `committed_through` after the durable " + "log; these are two different ordering obligations, not a duplicate publication"; +} + +/// The post-durable install region is the reason preparation has to happen where it does: once "this +/// object may be durable", recording it must not fail. The extraction moves work EARLIER, never into that +/// region. +/// +/// The guarded region is NOT the whole window: between the `Committed` outcome and the swap, +/// `carve_hook_for_test(PostDurableInstall)`, the `state_mutex` acquisition and the `state_unchanged` +/// evaluation all run OUTSIDE `DENY_ALLOCATIONS_IN_SCOPE`. This fence does not prove allocation-freedom +/// for them or for anything else. +/// +/// WHAT THIS TEST PROVES, and what it does NOT -- stated precisely, because a fence trusted for more +/// than it checks is worse than no fence. +/// +/// `DENY_ALLOCATIONS_IN_SCOPE` is `static_assert(true)` unless `!defined(NDEBUG)` (`MemoryTracker.h`), +/// so it is inert in every build that leaves `NDEBUG` defined -- which includes this gate and CI's +/// sanitizer lanes, since those configure `CMAKE_BUILD_TYPE=None` and `CMakeLists.txt` maps that to +/// `RelWithDebInfo`. Only a `Debug` build, or a tidy lane (which adds `-UNDEBUG`), has the +/// no-allocation half live. Nothing here proves the region does not allocate. +/// +/// What is left is weaker than "the install is still guarded": `install_region_probe_for_test` fires +/// as the FIRST statement inside the guarded scope, BEFORE `rt->state.swap(*candidate)`, and the SAME +/// probe is shared by BOTH probe-instrumented post-durable install regions (`CasRefLedger.cpp`: the +/// wedge-resolution adoption and `commitRefChunk`'s `Committed` install). So `probe_hits > 0` proves +/// only that SOME probe-instrumented region was entered on this path -- which on this path can only be +/// the commit install, since nothing here wedges. It goes red if the region stops being entered at all +/// (a lost commit path, a skipped install arm); a refactor that lifted the swap out of the scope while +/// leaving the guard shell and the probe behind would keep it GREEN. +TEST(CASRefCheckpoint, PostDurableInstallRegionStillEnteredAfterExtraction) +{ + auto backend = std::make_shared(); + auto store = openPool(backend); + const RootNamespace ns{"test/region@cas@"}; + + unsigned probe_hits = 0; + store->setInstallRegionProbeForTest([&probe_hits] { ++probe_hits; }); + const RefTxnId id = publishRef(store, ns, "region_ref", 1); + store->setInstallRegionProbeForTest(nullptr); + + EXPECT_GT(probe_hits, 0u) + << "no probe-instrumented post-durable install region was entered on a committing append -- " + "the `Committed` install arm was not reached at all"; + const NamespaceLifeId life = liveLifeOrFail(*backend, store->layout(), ns); + EXPECT_TRUE(backend->get(store->layout().refLogKey(life, id)).has_value()); +} diff --git a/src/Disks/tests/gtest_cas_ref_ckpt_join.cpp b/src/Disks/tests/gtest_cas_ref_ckpt_join.cpp new file mode 100644 index 000000000000..99f7f0042f99 --- /dev/null +++ b/src/Disks/tests/gtest_cas_ref_ckpt_join.cpp @@ -0,0 +1,548 @@ +#include + +#include "config.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include + +/// The `_ckpt` JOIN law and its `O(1)` SIZE invariant. +/// +/// `mergeCkpt` already has a suite (`CasRefCkpt` in `gtest_cas_ref_ckpt.cpp`) covering it as one step of +/// the publish algorithm. This suite's subject is narrower and different: the JOIN LAW itself, per +/// field, stated so that a later change to any one field's rule fails here rather than being absorbed +/// into a publish-path assertion; plus the size invariant, which no existing test constrains at all. +/// +/// The size half is a REGRESSION FENCE, not a fix -- nothing about today's `_ckpt` is non-`O(1)`. It +/// exists to fail the day someone adds a map, a collection, or any per-ref/per-file term to an object +/// that has no repair path and gates destructive cleanup. +/// +/// Constraint 15 names four dimensions (refs, files, transactions, writer epochs) and they do NOT +/// behave the same way, so they get two different assertions rather than one claim covering both: +/// +/// - REFS and FILES never enter the body in any form, so the encoded size is BYTE-EQUAL between a +/// namespace holding one and a namespace holding ten thousand. That is `EncodedCkptSizeIs...` +/// below, and it drives the REAL append lane on purpose: a hand-built pair of `RefCkpt` structs +/// would leave a newly-added collection field EMPTY in both and the equality would still hold, +/// so the fence would not fire on the very change it exists to catch. Only a real producer +/// populates a real field. +/// - TRANSACTIONS and WRITER EPOCHS enter as the DECIMAL WIDTH of the two id pairs. That is not +/// equality: `{cse=1,css=1}` and `{cse=1,css=10000}` differ by four bytes. It is `O(1)` because +/// the fields are `uint64_t` and so the width is ceilinged at twenty digits, which is a bound a +/// test asserts on a constructed worst case -- `EncodedCkptSizeHasAConstantCeiling...` below. + +namespace DB::ErrorCodes +{ +extern const int CORRUPTED_DATA; +extern const int NETWORK_ERROR; +} + +using namespace DB::Cas; +using DB::Cas::tests::CountingBackend; +using DB::Cas::tests::namespaceBirthOp; +using DB::Cas::tests::publishCommittedOps; + +/// Constraint 15's COMPILE-TIME half: `_ckpt` is a fixed-size product of scalar monotone facts. Any +/// field that owns heap storage -- a map, a vector, a `String` -- makes `RefCkpt` non-trivially-copyable +/// and fails the build here, which is the earliest and cheapest place the constraint can be enforced. +/// The two runtime size tests below are the rest of the fence: this one cannot see a fixed-capacity +/// array, and they cannot see a field that is never populated by the producers they drive. +static_assert(std::is_trivially_copyable_v, + "Constraint 15: _ckpt is a fixed-size product of scalar monotone facts, so its encoded size is " + "O(1) in refs, files, transactions and writer epochs. A field with heap storage (a map, a " + "vector, a String) breaks that and belongs in a separate immutable object or ledger."); + +namespace +{ + +constexpr uint64_t U64_MAX = std::numeric_limits::max(); + +/// Constraint 15's bound, as a number: the encoded size of the WIDEST `_ckpt` this build can produce +/// (all three fields present, every integer component at `UINT64_MAX`). Pinned as a literal so that +/// adding a field, or widening one, fails a test rather than quietly moving the bound. +constexpr size_t CKPT_WORST_CASE_ENCODED_BYTES = 234; + +/// The high-cardinality side of the size fence, in ONE transaction. Bounded above by the append lane's +/// 5000-operation cap on a normal-class item (`publishCommittedOps` emits two ops per ref), and kept at +/// one transaction on purpose: spreading a larger namespace over several of them costs tens of seconds +/// in a debug build, and a size fence that cannot finish inside the harness budget fences nothing. Any +/// per-ref term in `_ckpt` is as visible at this count as at any larger one. +constexpr size_t MANY_REFS = 2000; + +/// Fixed-width, so the refs themselves cannot be what differs between the two namespaces: the claim +/// under test is that ref cardinality does not reach `_ckpt`, and a name that grew with `i` would +/// confound a size comparison if it ever did. +String refName(size_t i) +{ + return fmt::format("r{:08}", i); +} + +/// A fence that never refuses, and a deadline far enough out that only the test's own contention +/// decides the outcome -- each `_ckpt`/catalog test file defines its own copy, matching the precedent +/// `gtest_cas_ns_creation_lifecycle.cpp` states explicitly. +const std::function ALWAYS_ADMITTED = [](uint64_t) {}; + +CkptDeadline generousDeadline() +{ + return CkptDeadline{[] { return uint64_t{1000}; }, 60000}; +} + +/// Admits the FIRST call (spent by `completeCreation`'s step-2 `publishCkpt`) and refuses every call +/// after (step 3's own `mutate`): "fenced out between the `_ckpt` create and the `Creating -> Live` +/// CAS", deterministically and without a second thread. That is the durable shape a stalled creator +/// leaves behind, and the starting state the resumption test needs. +std::function admittedOnceThenFenced() +{ + auto calls = std::make_shared(0); + return [calls](uint64_t admitted) + { + if (++*calls > 1) + throw DB::Exception(DB::ErrorCodes::NETWORK_ERROR, "fence generation moved since admission ({})", admitted); + }; +} + +CreatorFence creatorFence(const String & srid, uint64_t writer_epoch, uint64_t fence_generation = 1) +{ + return CreatorFence{.server_root_id = srid, .writer_epoch = writer_epoch, .fence_generation = fence_generation}; +} + +/// A `is_creator_fence_terminal` stub answering one fixed verdict: terminality itself is not this +/// suite's subject (its tests live next to the real predicate in `gtest_cas_mount.cpp`). +std::function fixedTerminality(bool terminal) +{ + return [terminal](const CreatorFence &) { return terminal; }; +} + +const CatalogEntry * findEntryForTest(const RefCatalog & catalog, const RootNamespace & ns) +{ + for (const CatalogEntry & e : catalog.entries) + if (e.ns.string() == ns.string()) + return &e; + return nullptr; +} + +/// `life`'s durable `life_epoch`, failing the current test rather than dereferencing a disengaged +/// optional -- a bare `->` on one aborts the whole binary and takes every later suite's result with it. +uint64_t lifeEpochOrFail(Backend & backend, const Layout & layout, const NamespaceLifeId & life) +{ + const std::optional sample = readCkpt(backend, layout, life); + if (!sample || !sample->ckpt.life_epoch) + { + ADD_FAILURE() << "expected a _ckpt carrying a life_epoch for namespace '" << life.ns.string() << "'"; + return 0; + } + return *sample->ckpt.life_epoch; +} + +/// `boot_ms_fn` defaults to the real clock. A caller whose test body does enough CPU-bound work +/// against ONE open pool to risk outrunning `mount_lease_ttl_ms` on a slow sanitizer build should +/// pass a frozen one instead of widening the TTL: the mount fence and the ref-log request controller +/// both read time through this same seam (see `CasRefLedger`'s `controller_boot_ms_fn`), so freezing +/// it removes the wall-clock race rather than merely giving it more room. +PoolPtr openPool(const BackendPtr & backend, std::function boot_ms_fn = {}) +{ + DB::Cas::tests::seedPoolMetaForRestart(*backend); + PoolConfig config{.pool_prefix = "p", .server_root_id = "test"}; + config.boot_ms_fn = std::move(boot_ms_fn); + return Pool::open(backend, std::move(config)); +} + +/// The incarnation the production birth wiring minted for `ns`, learned back from the catalog the way a +/// real reader does. Fails the current test rather than dereferencing a disengaged optional, so one +/// regression cannot abort the binary and take every later suite's result with it. +NamespaceLifeId liveLifeOrFail(Backend & backend, const Layout & layout, const RootNamespace & ns) +{ + const CasRefCatalog::Snapshot snap = CasRefCatalog::read(backend, layout); + for (const CatalogEntry & entry : snap.catalog.entries) + if (entry.ns.string() == ns.string()) + return NamespaceLifeId::fromCatalogEntry(entry.ns, entry.incarnation); + ADD_FAILURE() << "expected a catalog entry for namespace '" << ns.string() << "', found none"; + return DB::Cas::tests::fixture::fixtureLife(ns); +} + +/// Births `ns` and publishes `ref_count` committed refs through the REAL append lane, in ONE +/// transaction, and returns that namespace's durable `_ckpt` as encoded bytes. +/// +/// One transaction also holds every OTHER dimension fixed while `ref_count` varies: two namespaces +/// built this way end at the same transaction id, so a difference in their `_ckpt` bodies can only be +/// the refs. `ref_count` must therefore stay within the append lane's per-item operation cap. +String encodedCkptOfNamespaceWithRefs(const PoolPtr & store, Backend & backend, const Layout & layout, + const RootNamespace & ns, size_t ref_count) +{ + store->appendRefOps(ns, MutationScope::wholeShard(), + [ref_count](const RefTableState & state) + { + std::vector ops; + if (state.getLifecycle() != RefLifecycle::Live) + ops.push_back(namespaceBirthOp()); + for (size_t i = 0; i < ref_count; ++i) + for (const RefOp & op : publishCommittedOps(refName(i), ManifestRef{1, i + 1, 1})) + ops.push_back(op); + return ops; + }, + RootMutationOrigin::Writer, RootMutationKind::Publish); + + const NamespaceLifeId life = liveLifeOrFail(backend, layout, ns); + const std::optional sample = readCkpt(backend, layout, life); + if (!sample) + { + ADD_FAILURE() << "expected a _ckpt for namespace '" << ns.string() << "' after its birth transaction"; + return {}; + } + return encodeRefCkpt(sample->ckpt); +} + +} + +/// --------------------------------------------------------------------------------------------- +/// The join law, per field +/// --------------------------------------------------------------------------------------------- + +/// An absence is "this writer knew nothing", never "this writer says none". Exactly one writer ever +/// knows a namespace's genesis epoch, so every other contribution is `nullopt` and must leave what is +/// on record alone -- in BOTH argument orders, because the two `_ckpt` writers have no ordering +/// between them and the merge is what makes that safe. +TEST(CASRefCheckpointJoin, JoinUnknownLifeEpochWithPresentYieldsPresent) +{ + const RefCkpt unknown{.life_epoch = std::nullopt, .checkpoint_snapshot_id = std::nullopt, .last_epoch_seal = std::nullopt}; + const RefCkpt present{.life_epoch = 7, .checkpoint_snapshot_id = std::nullopt, .last_epoch_seal = std::nullopt}; + + EXPECT_EQ(mergeCkpt(unknown, present).life_epoch, std::optional{7}); + EXPECT_EQ(mergeCkpt(present, unknown).life_epoch, std::optional{7}) + << "the merge is commutative -- a writer that knows nothing must not be able to erase the " + "genesis epoch, whichever side it is on"; + + /// The other half of "absent loses": two absences stay absent. `life_epoch` has no floor to fall + /// back to, and a fabricated one is permanent -- the semantic-max merge can never lower it again. + EXPECT_EQ(mergeCkpt(unknown, unknown).life_epoch, std::nullopt); +} + +/// The ordinary steady state: both writers agree. Asserted for its own sake because it is what +/// `publishCkpt`'s may-not-decrease rule must keep admitting -- an equal republish is not a decrease -- +/// and it is also what `publishCkpt` turns into "no write at all". +TEST(CASRefCheckpointJoin, JoinEqualLifeEpochsYieldsSame) +{ + const RefCkpt a{.life_epoch = 9, .checkpoint_snapshot_id = std::nullopt, .last_epoch_seal = std::nullopt}; + const RefCkpt b{.life_epoch = 9, .checkpoint_snapshot_id = RefTxnId{9, 4}, .last_epoch_seal = std::nullopt}; + + EXPECT_EQ(mergeCkpt(a, b).life_epoch, std::optional{9}); + EXPECT_EQ(mergeCkpt(b, a).life_epoch, std::optional{9}); + const std::optional b_checkpoint = RefTxnId{9, 4}; + EXPECT_EQ(mergeCkpt(a, b).checkpoint_snapshot_id, b_checkpoint) + << "an equal life_epoch must not disturb the other fields' own join"; +} + +TEST(CASRefCheckpointJoin, CrossEpochFrontierRequiresAnImmediatelyAdjacentSeal) +{ + const RefCkpt older{.life_epoch = std::nullopt, .committed_through = RefTxnId{7, 9}, + .checkpoint_snapshot_id = std::nullopt, .last_epoch_seal = std::nullopt}; + const RefCkpt transitioned{.life_epoch = std::nullopt, .committed_through = RefTxnId{8, 1}, + .checkpoint_snapshot_id = std::nullopt, .last_epoch_seal = RefTxnId{8, 1}}; + EXPECT_EQ(mergeCkpt(older, transitioned).committed_through, transitioned.committed_through); + EXPECT_EQ(mergeCkpt(transitioned, older).committed_through, transitioned.committed_through); + + /// Every committed epoch is materialized. A later frontier may advance only to the immediately + /// following numeric writer epoch, otherwise a missing epoch would be mistaken for a proved + /// boundary. The log grammar rejects this same skip at the record boundary; `_ckpt` must not + /// reintroduce it through its semantic merge. + const RefCkpt skipped_epoch{.life_epoch = std::nullopt, .committed_through = RefTxnId{10, 1}, + .checkpoint_snapshot_id = std::nullopt, + .last_epoch_seal = RefTxnId{7, 9}}; + EXPECT_THROW(mergeCkpt(older, skipped_epoch), DB::Exception); + + const RefCkpt advanced{.life_epoch = std::nullopt, .committed_through = RefTxnId{8, 5}, + .checkpoint_snapshot_id = std::nullopt, .last_epoch_seal = RefTxnId{7, 9}}; + EXPECT_EQ(mergeCkpt(advanced, older).committed_through, advanced.committed_through); + + const RefCkpt unsealed{.life_epoch = std::nullopt, .committed_through = RefTxnId{8, 1}, + .checkpoint_snapshot_id = std::nullopt, .last_epoch_seal = std::nullopt}; + EXPECT_THROW(mergeCkpt(older, unsealed), DB::Exception); + const RefCkpt stale_prior_seal{.life_epoch = std::nullopt, .committed_through = RefTxnId{10, 1}, + .checkpoint_snapshot_id = std::nullopt, + .last_epoch_seal = RefTxnId{7, 8}}; + EXPECT_THROW(mergeCkpt(older, stale_prior_seal), DB::Exception) + << "a seal below the lower durable frontier does not connect the two histories"; + const RefCkpt seal_above_frontier{.life_epoch = std::nullopt, .committed_through = RefTxnId{8, 5}, + .checkpoint_snapshot_id = std::nullopt, .last_epoch_seal = RefTxnId{8, 6}}; + EXPECT_THROW(mergeCkpt(older, seal_above_frontier), DB::Exception); +} + +/// THE FIRST OF THE TWO SEQUENCES THAT RAISE `life_epoch` HONESTLY, end to end through the production +/// primitives rather than at the merge: a creator publishes `_ckpt` at E1 (`completeCreation` step 2) +/// and dies before its `Creating -> Live` CAS (step 3), and a later actor reconciles the stalled entry +/// and resumes over the SAME incarnation, contributing E2. Two different present values in one +/// incarnation, and NOT a conflict -- the stored value must simply become E2, which is also what +/// `CASNsCreationLifecycle.ReconcileSucceedsTokenExactlyAfterTheOriginalCreatorFenceIsTerminalThenResumesToLive` +/// already pins from the catalog side ("the RESUMING actor's writer_epoch is the genesis epoch that +/// actually landed"). Had the directive's literal rule landed, this sequence would raise +/// `CORRUPTED_DATA` and, since `_ckpt` has no repair path, wedge the namespace forever. +/// +/// Both fences share ONE `server_root_id` on purpose: every live namespace is rooted at its own pool +/// member's `server_root_id`, so a creator and its reconciler are always actors of the same server root +/// and draw from the same durable-monotone epoch counter. That is the whole basis for "contributions +/// only ever rise", so the fixture must not quietly model two roots. +TEST(CASRefCheckpointJoin, ResumedCreationRaisesLifeEpochWithoutRefusal) +{ + InMemoryBackend backend; + Layout layout("p"); + DB::Cas::tests::seedPoolMetaForRestart(backend); + const RootNamespace ns{"a"}; + + ASSERT_EQ(CasRefCatalog::createNamespace(backend, layout, 1, ns, creatorFence("srv1", 5), + /*admitted_generation=*/1, admittedOnceThenFenced(), generousDeadline()), + CasRefCatalog::NamespaceCreationOutcome::FencedOut); + + /// Bound to a name, never chained through a temporary: a `const CatalogEntry *` taken from an + /// unbound `Snapshot` dangles the instant the full expression ends. + const CasRefCatalog::Snapshot stalled = CasRefCatalog::read(backend, layout); + const CatalogEntry * entry = findEntryForTest(stalled.catalog, ns); + ASSERT_NE(entry, nullptr); + ASSERT_EQ(entry->state, NsState::Creating); + const NamespaceLifeId life = NamespaceLifeId::fromCatalogEntry(entry->ns, entry->incarnation); + EXPECT_EQ(lifeEpochOrFail(backend, layout, life), 5u) << "step 2 landed before the creator stalled"; + + const CreatorFence resumer = creatorFence("srv1", 9); + ASSERT_EQ(CasRefCatalog::reconcileStaleCreator(backend, layout, *entry, resumer, fixedTerminality(true), + /*admitted_generation=*/1, ALWAYS_ADMITTED), + CasRefCatalog::ReconcileCreatorOutcome::Reconciled); + + CatalogEntry resumed = *entry; + resumed.creator = resumer; + EXPECT_EQ(CasRefCatalog::completeCreation(backend, layout, resumed, /*admitted_generation=*/1, + ALWAYS_ADMITTED, generousDeadline()), + CasRefCatalog::NamespaceCreationOutcome::Live) + << "the resumption must not be refused by the join"; + EXPECT_EQ(lifeEpochOrFail(backend, layout, life), 9u) + << "the genesis epoch that actually landed is the resuming actor's, and the join must let it rise"; +} + +/// THE SECOND SEQUENCE, at the seam where the two `life_epoch`-knowing writers actually meet -- both of +/// them reach this object only through `publishCkpt`, so driving that twice over one key IS the +/// production interleaving, not a stand-in for it. `completeCreation` contributes the catalog creator's +/// epoch; the mount's writer epoch then advances (a restart, a remount); the first precommit's birth +/// chunk contributes the `NamespaceBirth` record's epoch. CREATE TABLE, restart, INSERT. +TEST(CASRefCheckpointJoin, RestartBetweenCreationAndFirstWriteRaisesLifeEpochWithoutRefusal) +{ + InMemoryBackend backend; + Layout layout("p"); + const NamespaceLifeId life = NamespaceLifeId::fromCatalogEntry(RootNamespace{"a"}, UInt128(42)); + + const RefCkpt from_creation{.life_epoch = 4, .checkpoint_snapshot_id = std::nullopt, .last_epoch_seal = std::nullopt}; + ASSERT_EQ(publishCkpt(backend, layout, life, from_creation, 1, ALWAYS_ADMITTED, generousDeadline()), + CkptPublishOutcome::Published); + + const RefCkpt from_birth_chunk{.life_epoch = 7, .checkpoint_snapshot_id = std::nullopt, .last_epoch_seal = std::nullopt}; + EXPECT_EQ(publishCkpt(backend, layout, life, from_birth_chunk, 1, ALWAYS_ADMITTED, generousDeadline()), + CkptPublishOutcome::Published) + << "the birth chunk's later epoch must be publishable, not refused as a conflict"; + EXPECT_EQ(lifeEpochOrFail(backend, layout, life), 7u); +} + +/// THE REFUSAL, and the state it constructs IS UNREACHABLE ON ANY HONEST PATH -- that is the point of +/// the test, not a caveat on it. `writer_epoch` is durable-monotone per server root +/// (`allocateWriterEpoch` CAS-bumps `/gc/server-roots//epoch`) and a namespace belongs to +/// exactly one server root, so no live writer can contribute an epoch below one already durable. The +/// only way to reach this is for the fence discipline itself to have failed and a SUPERSEDED writer's +/// contribution to have landed anyway. +/// +/// So this test does not model an operating condition; it asserts what happens if the guarantee above +/// is ever violated -- `publishCkpt` refuses and names both values, rather than absorbing the violation +/// into a maximum and leaving no trace. The state is built by publishing the two contributions in the +/// order the fence discipline is supposed to prevent, which needs no seam that manufactures impossible +/// states: `publishCkpt` is a public entry point and the order of two calls is the test's to choose. +/// +/// It is driven through `publishCkpt` for a second reason, not just convenience: that IS where the rule +/// lives and the only place it CAN live. `mergeCkpt` is commutative -- the stated reason the two writers +/// need no ordering between them -- so it cannot tell a decrease from an increase, having no idea which +/// of its arguments is durable. There is deliberately no merge-level counterpart to this test. +TEST(CASRefCheckpointJoin, JoinDecreasingLifeEpochIsCorruptionAndPublishesNothing) +{ + CountingBackend backend; + Layout layout("p"); + const NamespaceLifeId life = NamespaceLifeId::fromCatalogEntry(RootNamespace{"a"}, UInt128(42)); + const String key = layout.refCkptKey(life); + + const RefCkpt durable{.life_epoch = 9, .checkpoint_snapshot_id = std::nullopt, .last_epoch_seal = std::nullopt}; + ASSERT_EQ(publishCkpt(backend, layout, life, durable, 1, ALWAYS_ADMITTED, generousDeadline()), + CkptPublishOutcome::Published); + const uint64_t cas_puts_before = backend.casPutCount(key); + + const RefCkpt superseded{.life_epoch = 3, .checkpoint_snapshot_id = std::nullopt, .last_epoch_seal = std::nullopt}; + String message; + try + { + publishCkpt(backend, layout, life, superseded, 1, ALWAYS_ADMITTED, generousDeadline()); + ADD_FAILURE() << "a contribution below the durable life_epoch must not be published"; + } + catch (const DB::Exception & e) + { + EXPECT_EQ(e.code(), DB::ErrorCodes::CORRUPTED_DATA); + message = e.message(); + } + + /// BOTH values, not just the offending one: an operator reading this has to be able to tell which + /// writer is the superseded one without going to the object. Matched as RENDERED substrings rather + /// than as bare digits -- a lone "9" would also be satisfied by a key or a byte count that happened + /// to contain it, so a bare-digit match would keep passing after the message stopped saying this. + EXPECT_NE(message.find("9 is durable"), String::npos) << "the durable value must be named: " << message; + EXPECT_NE(message.find("contributed 3"), String::npos) << "the contributed value must be named: " << message; + EXPECT_NE(message.find(key), String::npos) << "the key must be named: " << message; + /// And that the object cannot be repaired in place, which is the part an operator cannot derive + /// from the two numbers. + EXPECT_NE(message.find("NO in-place repair"), String::npos) + << "the message must say the object has no in-place repair: " << message; + + /// And nothing was written. The refusal is decided before the body is built, so the durable object + /// is untouched and no write was even attempted. + EXPECT_EQ(backend.casPutCount(key), cas_puts_before) << "the publisher must not CAS on a refused publish"; + EXPECT_EQ(lifeEpochOrFail(backend, layout, life), 9u) << "the durable value is unchanged"; +} + +/// The other half of the refusal, and the reason it consults the fence before classifying: the SAME +/// decrease from a writer the fence is about to refuse is not corruption. That writer landed nothing +/// anywhere, so what it gets is the transient control signal every other refusal in `publishCkpt` +/// returns rather than throws. Reporting corruption for it would turn "your incarnation moved, retry" +/// into a permanent verdict on the namespace, which is the opposite of what the detector means: the +/// violation is a STILL-ADMITTED writer contributing a superseded epoch. +TEST(CASRefCheckpointJoin, ADecreasingLifeEpochFromAFencedOutWriterIsReportedFencedOutNotCorruption) +{ + CountingBackend backend; + Layout layout("p"); + const NamespaceLifeId life = NamespaceLifeId::fromCatalogEntry(RootNamespace{"a"}, UInt128(42)); + const String key = layout.refCkptKey(life); + + const RefCkpt durable{.life_epoch = 9, .checkpoint_snapshot_id = std::nullopt, .last_epoch_seal = std::nullopt}; + ASSERT_EQ(publishCkpt(backend, layout, life, durable, 1, ALWAYS_ADMITTED, generousDeadline()), + CkptPublishOutcome::Published); + const uint64_t cas_puts_before = backend.casPutCount(key); + + const std::function always_fenced = [](uint64_t admitted) + { + throw DB::Exception(DB::ErrorCodes::NETWORK_ERROR, "fence generation moved since admission ({})", admitted); + }; + const RefCkpt superseded{.life_epoch = 3, .checkpoint_snapshot_id = std::nullopt, .last_epoch_seal = std::nullopt}; + EXPECT_EQ(publishCkpt(backend, layout, life, superseded, 1, always_fenced, generousDeadline()), + CkptPublishOutcome::FencedOut); + + EXPECT_EQ(backend.casPutCount(key), cas_puts_before); + EXPECT_EQ(lifeEpochOrFail(backend, layout, life), 9u); +} + +/// `checkpoint_snapshot_id` and `last_epoch_seal` continue to merge by SEMANTIC MAXIMUM. Unlike +/// `life_epoch` these two genuinely advance over a namespace's life, and the max is what stops a writer +/// that sampled an older body from regressing the other writer's progress (TLC counterexample +/// `_sab_sealclobbersbase`, which costs an acked transaction). Both directions and present-beats-absent, +/// since the two writers have no ordering between them. +TEST(CASRefCheckpointJoin, CheckpointAndSealStillMergeBySemanticMaximum) +{ + const RefCkpt lower{.life_epoch = std::nullopt, .checkpoint_snapshot_id = RefTxnId{3, 5}, .last_epoch_seal = RefTxnId{3, 4}}; + const RefCkpt higher{.life_epoch = std::nullopt, .checkpoint_snapshot_id = RefTxnId{4, 1}, .last_epoch_seal = RefTxnId{4, 2}}; + + const std::optional higher_checkpoint = higher.checkpoint_snapshot_id; + const std::optional higher_seal = higher.last_epoch_seal; + const std::optional lower_checkpoint = lower.checkpoint_snapshot_id; + const std::optional lower_seal = lower.last_epoch_seal; + + /// Ordered by writer_epoch FIRST: `{4,1}` beats `{3,5}` even though its sequence is smaller, which + /// is the intended timeline across an epoch restart that resets the sequence. + EXPECT_EQ(mergeCkpt(lower, higher).checkpoint_snapshot_id, higher_checkpoint); + EXPECT_EQ(mergeCkpt(higher, lower).checkpoint_snapshot_id, higher_checkpoint); + EXPECT_EQ(mergeCkpt(lower, higher).last_epoch_seal, higher_seal); + EXPECT_EQ(mergeCkpt(higher, lower).last_epoch_seal, higher_seal); + + /// Present beats absent, both directions and both fields. + const RefCkpt nothing; + EXPECT_EQ(mergeCkpt(nothing, lower).checkpoint_snapshot_id, lower_checkpoint); + EXPECT_EQ(mergeCkpt(lower, nothing).checkpoint_snapshot_id, lower_checkpoint); + EXPECT_EQ(mergeCkpt(nothing, lower).last_epoch_seal, lower_seal); + EXPECT_EQ(mergeCkpt(lower, nothing).last_epoch_seal, lower_seal); + EXPECT_EQ(mergeCkpt(nothing, nothing).checkpoint_snapshot_id, std::nullopt); + EXPECT_EQ(mergeCkpt(nothing, nothing).last_epoch_seal, std::nullopt); +} + +/// --------------------------------------------------------------------------------------------- +/// Constraint 15: the `O(1)` size invariant +/// --------------------------------------------------------------------------------------------- + +/// REFS and FILES: byte-equal, because they never enter the body. Driven through the REAL append lane +/// (see `encodedCkptOfNamespaceWithRefs` on why a hand-built struct pair would not fence anything). +TEST(CASRefCheckpointJoin, EncodedCkptSizeIsIndependentOfCardinality) +{ + auto backend = std::make_shared(); + /// `MANY_REFS` committed through ONE `appendRefOps` call is CPU-bound encoding, not I/O -- on a + /// slow sanitizer build (msan in particular) it can outrun the real-clock `mount_lease_ttl_ms` + /// this pool was opened under and trip the mount fence mid-publish. Freeze the pool's clock + /// instead of racing it (see `openPool`'s doc comment). + auto store = openPool(backend, [] { return uint64_t{0}; }); + Layout layout("p"); + + const String one = encodedCkptOfNamespaceWithRefs(store, *backend, layout, RootNamespace{"srv1/one"}, 1); + const String many = encodedCkptOfNamespaceWithRefs(store, *backend, layout, RootNamespace{"srv1/many"}, MANY_REFS); + + ASSERT_FALSE(one.empty()); + ASSERT_FALSE(many.empty()); + EXPECT_EQ(one, many) + << "not merely equal in SIZE: refs and files reach `_ckpt` in no form at all, so the two bodies " + "are byte-identical."; + /// The same claim stated so that it does not depend on the chosen cardinality at all: no ref + /// PUBLISHED into the namespace appears anywhere in its `_ckpt`. A count-based comparison can only + /// catch a term that grows; this catches one that is merely there. + EXPECT_EQ(many.find(refName(0)), String::npos) + << "a published ref's NAME appears in `_ckpt`: " << many; + EXPECT_EQ(many.find(refName(MANY_REFS - 1)), String::npos) + << "a published ref's NAME appears in `_ckpt`: " << many; + EXPECT_EQ(one.size(), many.size()) + << "Constraint 15: `_ckpt`'s encoded size must not grow with the number of refs or files in the " + "namespace. A collection or per-ref term was added to an object that has NO repair path and " + "gates destructive cleanup; it belongs in a separate immutable object or ledger instead.\n" + " 1 ref: " << one + << " " << MANY_REFS << " refs: " << many; +} + +/// TRANSACTIONS and WRITER EPOCHS: not equality -- they enter as the decimal width of the id pairs -- +/// but ceilinged, because the fields are `uint64_t`. The worst case is constructible exactly (every +/// field present at `UINT64_MAX`), so the bound is asserted on it rather than believed about it. +TEST(CASRefCheckpointJoin, EncodedCkptSizeHasAConstantCeilingAcrossTransactionsAndEpochs) +{ + /// The true worst case over every namespace history: all three fields present, every component at + /// the widest value its type can hold. No real `_ckpt` can encode larger, because there is no field + /// that is not one of these five integers. + const RefCkpt worst{.life_epoch = U64_MAX, + .committed_through = RefTxnId{U64_MAX, U64_MAX}, + .checkpoint_snapshot_id = RefTxnId{U64_MAX, U64_MAX}, + .last_epoch_seal = RefTxnId{U64_MAX, U64_MAX}}; + const size_t worst_bytes = encodeRefCkpt(worst).size(); + + /// Pinned as a literal, not merely compared against itself: this is the number Constraint 15's + /// `O(1)` claim reduces to, and a change to it means a field was added, removed or rewidened. + EXPECT_EQ(worst_bytes, CKPT_WORST_CASE_ENCODED_BYTES) + << "the widest `_ckpt` this build can encode changed size -- a field was added, removed, or " + "given a wider type. Constraint 15's O(1) bound is exactly this constant."; + + /// The growth term is the decimal width, and it is bounded by that ceiling rather than proportional + /// to the number of transactions: four orders of magnitude of `ref_sequence` cost four bytes. + const RefCkpt at_sequence_1{.life_epoch = 1, .committed_through = RefTxnId{1, 1}, .checkpoint_snapshot_id = RefTxnId{1, 1}, .last_epoch_seal = RefTxnId{1, 1}}; + const RefCkpt at_sequence_10k{.life_epoch = 1, .committed_through = RefTxnId{1, 10000}, .checkpoint_snapshot_id = RefTxnId{1, 10000}, .last_epoch_seal = RefTxnId{1, 10000}}; + EXPECT_EQ(encodeRefCkpt(at_sequence_10k).size(), encodeRefCkpt(at_sequence_1).size() + 12); + EXPECT_LE(encodeRefCkpt(at_sequence_10k).size(), worst_bytes); + EXPECT_LE(encodeRefCkpt(at_sequence_1).size(), worst_bytes); + + /// And the ceiling is far below the format registry's own object cap, so the cap is what it is + /// documented to be -- a corruption brake this object cannot approach -- and never the thing that + /// makes the size bounded. + EXPECT_LT(worst_bytes, traitsFor(FormatId::RefCkpt).object_cap); +} diff --git a/src/Disks/tests/gtest_cas_ref_contiguous_alloc.cpp b/src/Disks/tests/gtest_cas_ref_contiguous_alloc.cpp new file mode 100644 index 000000000000..92f65c9f4b55 --- /dev/null +++ b/src/Disks/tests/gtest_cas_ref_contiguous_alloc.cpp @@ -0,0 +1,640 @@ +#include + +#include "config.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace ProfileEvents +{ +extern const Event CASMountReleaseSkippedForeignOccupant; +extern const Event CASMountExclusivityViolation; +} + +/// Stage A task 3 (INV-1): ref-log transaction ids are PER-NAMESPACE and CONTIGUOUS. +/// +/// The id an append persists is not drawn from a counter at all -- it is DERIVED from the table's own +/// durable state: `{live_epoch, greatest_applied.ref_sequence + 1}` within one epoch, `{live_epoch, 1}` +/// at an epoch change. Two consequences this suite pins, both of which the pool-wide counter this +/// replaced made impossible: +/// +/// 1. namespaces are independent -- a busy table cannot push another table's ids up, so `(namespace, +/// epoch)` ids are dense `1..T` and a reader can tell "this stream is complete" from the ids alone; +/// 2. an attempt that provably sent nothing consumes nothing -- the next caller re-derives the SAME +/// id, so a refusal leaves no hole behind it. +/// +/// The read side enforces exactly what the allocator produces: `RefTableState::applyTxnInPlace` rejects +/// a non-successor id as `CORRUPTED_DATA`, so a hole can never become durable even if some future +/// writer path forgot the rule. +/// +/// The suite name is prefixed `Cas` so it is covered by the `Cas*` unit-test gate filter. + +namespace DB::ErrorCodes +{ +extern const int CORRUPTED_DATA; +extern const int MEMORY_LIMIT_EXCEEDED; +extern const int UNKNOWN_FORMAT_VERSION; +} + +using namespace DB::Cas; +using DB::Cas::tests::namespaceBirthOp; +using DB::Cas::tests::publishCommittedOps; + +namespace +{ + +PoolPtr openPool(const BackendPtr & backend) +{ + DB::Cas::tests::seedPoolMetaForRestart(*backend); + return Pool::open(backend, PoolConfig{.pool_prefix = "p", .server_root_id = "test"}); +} + +/// The fence-controlled pool of `gtest_cas_ref_install_safety.cpp`, for the pre-attempt refusal: the +/// boot clock is frozen so `setMountDeadline` alone decides both fence predicates, renewal is parked an +/// hour out so nothing re-arms the deadline underneath the test, and the single-attempt budget makes +/// `attempt_timeout_ms + lease_safety_margin_ms` (200 ms) the window between "the flush is admitted" +/// and "an attempt may start". +PoolPtr openPoolFenceControlled(const BackendPtr & backend) +{ + DB::Cas::tests::seedPoolMetaForRestart(*backend); + PoolConfig cfg{.pool_prefix = "p", .server_root_id = "test"}; + cfg.boot_ms_fn = [] { return uint64_t{0}; }; + cfg.mount_renew_period = std::chrono::milliseconds{3600000}; + CasRequestBudget budget; + budget.max_attempts = 1; + budget.attempt_timeout_ms = 100; + budget.operation_deadline_ms = 5000; /// strictly above attempt_timeout_ms: equality is a wall-clock race (validateCasRequestBudget) + budget.lease_safety_margin_ms = 100; + cfg.cas_request_budget = budget; + return Pool::open(backend, cfg); +} + +constexpr uint64_t FENCE_DEADLINE_HEALTHY_MS = 30000; +constexpr uint64_t FENCE_DEADLINE_REFUSES_ATTEMPT_MS = 100; + +/// A bare `Pool::open` with no `_pool_meta` seeded: the path an operator's pool RECREATION takes, and +/// the only one that runs the bootstrap residual + quiesce gates (`seedPoolMetaForRestart` mints the +/// metadata directly and would bypass them). +PoolPtr openPoolWithoutSeeding(const BackendPtr & backend, const String & srid) +{ + return Pool::open(backend, PoolConfig{.pool_prefix = "p", .server_root_id = srid}); +} + +/// Deletes every object whose key contains `substr` ("" = the whole prefix), as an operator clearing +/// the prefix would. Returns how many were removed. +size_t eraseKeysContaining(Backend & backend, const String & substr) +{ + size_t removed = 0; + String cursor; + std::vector keys; + while (true) + { + const ListPage page = backend.list("", cursor, 1000); + for (const ListedKey & listed : page.keys) + if (substr.empty() || listed.key.find(substr) != String::npos) + keys.push_back(listed.key); + if (page.next_cursor.empty()) + break; + cursor = page.next_cursor; + } + for (const String & key : keys) + { + const HeadResult h = backend.head(key); + if (h.exists && backend.deleteExact(key, h.token).kind == DeleteOutcome::Kind::Deleted) + ++removed; + } + return removed; +} + +String messageOfThrow(const std::function & fn) +{ + try + { + fn(); + } + catch (const DB::Exception & e) + { + return e.message(); + } + return {}; +} + +/// One ordinary publish transaction, driven straight through the append lane so the committed id is +/// observable: `namespace_birth` while the table is not yet `Live`, then the precommit+promote pair for +/// `ref`. Returns the id the append persisted under. +RefTxnId publishRef(const PoolPtr & store, const RootNamespace & ns, const String & ref, uint64_t ordinal) +{ + return store->appendRefOps(ns, MutationScope::ref(ref), + [&ref, ordinal](const RefTableState & state) + { + std::vector ops; + if (state.getLifecycle() != RefLifecycle::Live) + ops.push_back(namespaceBirthOp()); + for (const RefOp & op : publishCommittedOps(ref, ManifestRef{1, ordinal, 1})) + ops.push_back(op); + return ops; + }, + RootMutationOrigin::Writer, RootMutationKind::Publish); +} + +} + +/// INV-1, first half: each namespace has its OWN stream. Two tables are written strictly alternately, +/// so a pool-wide counter would hand them 1,3,5 and 2,4 -- every id unique across the pool and dense +/// nowhere. Per-namespace derivation gives each table 1,2,3.. of its own. +TEST(CASRefContiguousAlloc, TwoNamespacesAllocateIndependently) +{ + auto backend = std::make_shared(); + auto store = openPool(backend); + const uint64_t epoch = store->writerEpoch(); + const RootNamespace ns_a{"srv1/contig_ns_a"}; + const RootNamespace ns_b{"srv1/contig_ns_b"}; + + const RefTxnId a1 = publishRef(store, ns_a, "ref_1", 1); + const RefTxnId b1 = publishRef(store, ns_b, "ref_1", 1); + const RefTxnId a2 = publishRef(store, ns_a, "ref_2", 2); + const RefTxnId b2 = publishRef(store, ns_b, "ref_2", 2); + const RefTxnId a3 = publishRef(store, ns_a, "ref_3", 3); + + EXPECT_EQ(a1, (RefTxnId{epoch, 1})); + EXPECT_EQ(a2, (RefTxnId{epoch, 2})); + EXPECT_EQ(a3, (RefTxnId{epoch, 3})) + << "ns_a's third transaction must be its own third id -- the two ns_b transactions interleaved " + "between them belong to a different stream and must not push it up"; + EXPECT_EQ(b1, (RefTxnId{epoch, 1})); + EXPECT_EQ(b2, (RefTxnId{epoch, 2})); +} + +/// INV-1, second half (the free half of the every-attempt rule): a refusal that PROVES nothing was sent +/// consumes no id. The pre-attempt gate refuses while the flush is still admitted -- no fault injection, +/// nothing reaches the backend -- and the very next append on that table commits under the SAME id the +/// refused one would have used. Under the pool-wide counter that id was burned as a "safe gap", which is +/// precisely what makes a durable stream unreadable as a contiguous chain. +TEST(CASRefContiguousAlloc, PreAttemptRefusalConsumesNoId) +{ + auto backend = std::make_shared(); + auto store = openPoolFenceControlled(backend); + const uint64_t epoch = store->writerEpoch(); + const RootNamespace ns{"srv1/contig_no_gap"}; + + ASSERT_EQ(publishRef(store, ns, "ref_1", 1), (RefTxnId{epoch, 1})); + + store->setMountDeadline(FENCE_DEADLINE_REFUSES_ATTEMPT_MS); + ASSERT_TRUE(store->mayMutate()) << "the flush must still be ADMITTED, or this exercises the " + "top-of-flush gate instead of the pre-attempt one"; + const String refusal = messageOfThrow([&] { publishRef(store, ns, "ref_2", 2); }); + ASSERT_NE(refusal, String()) << "the pre-attempt gate must refuse this append"; + /// Pin WHICH refusal this is. The id-reuse below is only meaningful for a refusal that proves + /// nothing was sent; a different failure (an ambiguous PUT, say) would be free to have landed, and + /// re-deriving its id would then be a collision rather than the no-gap property under test. + EXPECT_NE(refusal.find("was refused BEFORE any request was sent"), String::npos) + << "this test is about the provably-sent-nothing refusal specifically: " << refusal; + EXPECT_NE(refusal.find("the txn id is not consumed"), String::npos) << refusal; + ASSERT_FALSE(store->refLaneWedgedForTest(ns)) << "a refusal that sent nothing must not wedge"; + + store->setMountDeadline(FENCE_DEADLINE_HEALTHY_MS); + EXPECT_EQ(publishRef(store, ns, "ref_2", 2), (RefTxnId{epoch, 2})) + << "the refused attempt sent nothing, so the next caller must re-derive the SAME id -- a refusal " + "must never leave a hole in the durable stream"; +} + +/// The epoch component is the second half of the id, and the sequence is dense WITHIN an epoch: a new +/// mount incarnation restarts its table's sequence at 1 rather than continuing the dead incarnation's +/// numbering. `{E1, 2}` -> `{E2, 1}` is therefore not a gap, and the apply-side check must admit it. +TEST(CASRefContiguousAlloc, EpochChangeRestartsTheSequenceAtOne) +{ + auto backend = std::make_shared(); + const RootNamespace ns{"srv1/contig_epoch_reset"}; + + uint64_t e1 = 0; + { + auto predecessor = openPool(backend); + e1 = predecessor->writerEpoch(); + ASSERT_EQ(publishRef(predecessor, ns, "ref_1", 1), (RefTxnId{e1, 1})); + ASSERT_EQ(publishRef(predecessor, ns, "ref_2", 2), (RefTxnId{e1, 2})); + } /// predecessor destroyed: its mount lease is released + + auto successor = openPool(backend); + const uint64_t e2 = successor->writerEpoch(); + ASSERT_GT(e2, e1); + EXPECT_EQ(publishRef(successor, ns, "ref_3", 3), (RefTxnId{e2, 1})) + << "a fresh incarnation starts this table's sequence over at 1"; + EXPECT_EQ(publishRef(successor, ns, "ref_4", 4), (RefTxnId{e2, 2})); +} + +/// The read side is what makes INV-1 an invariant rather than a convention: a transaction whose id is +/// not the successor of `greatest_applied` is CORRUPTED_DATA, naming both ids. Before this task the +/// state machine checked strict increase only, so a stream with a hole applied cleanly and no reader +/// could tell a complete chain from a truncated one. +TEST(CASRefContiguousAlloc, NonSuccessorIdIsRejectedOnApply) +{ + const String ns = "srv1/contig_density"; + constexpr uint64_t kEpoch = 7; + + RefTableState state = replay(DB::Cas::tests::minimalLiveSnapshot(ns, RefTxnId{kEpoch, 1}), {}); + ASSERT_EQ(state.getGreatestApplied(), (RefTxnId{kEpoch, 1})); + + /// Strictly greater, but skips {7,2}: admitted before this task, rejected now. + try + { + applyRefLogTxn(state, RefLogTxn{ns, RefTxnId{kEpoch, 3}, publishCommittedOps("r", ManifestRef{1, 1, 1}), std::nullopt}); + FAIL() << "a non-successor id must be rejected"; + } + catch (const DB::Exception & e) + { + EXPECT_EQ(e.code(), DB::ErrorCodes::CORRUPTED_DATA); + EXPECT_NE(e.message().find("7-3"), String::npos) << "the offending id must be named: " << e.message(); + EXPECT_NE(e.message().find("7-1"), String::npos) << "the greatest applied id must be named: " << e.message(); + } + EXPECT_EQ(state.getGreatestApplied(), (RefTxnId{kEpoch, 1})) << "the rejected apply must change nothing"; + + /// A new epoch must ALSO start at 1: continuing the previous epoch's numbering is a hole in the new + /// epoch's stream, which reads exactly like a lost first transaction. + DB::Cas::tests::expectThrowsCode(DB::ErrorCodes::CORRUPTED_DATA, [&] + { + applyRefLogTxn(state, RefLogTxn{ns, RefTxnId{kEpoch + 1, 2}, publishCommittedOps("r", ManifestRef{1, 1, 1}), std::nullopt}); + }); + + /// The two shapes the allocator can produce are the two the checker admits -- and `nextRefTxnId` is + /// the single rule both sides use, so they cannot drift apart. + EXPECT_EQ(nextRefTxnId(state.getGreatestApplied(), kEpoch), (RefTxnId{kEpoch, 2})); + EXPECT_NO_THROW(applyRefLogTxn(state, RefLogTxn{ns, nextRefTxnId(state.getGreatestApplied(), kEpoch), + publishCommittedOps("r", ManifestRef{1, 1, 1}), std::nullopt})); + ASSERT_EQ(state.getGreatestApplied(), (RefTxnId{kEpoch, 2})); + + EXPECT_EQ(nextRefTxnId(state.getGreatestApplied(), kEpoch + 1), (RefTxnId{kEpoch + 1, 1})); + /// The id is admissible, but a Live table crossing into a new epoch also owes INV-2's chain link -- + /// the seal that closed the epoch below, at the slot one past its last durable id. + EXPECT_NO_THROW(applyRefLogTxn(state, RefLogTxn{ns, nextRefTxnId(state.getGreatestApplied(), kEpoch + 1), + publishCommittedOps("r2", ManifestRef{1, 2, 1}), RefTxnId{kEpoch, 3}})); + EXPECT_EQ(state.getGreatestApplied(), (RefTxnId{kEpoch + 1, 1})); +} + +/// The format floor. A pool written before contiguous ref streams holds ref logs whose ids this build +/// would read as a corrupt (holed) chain, so opening it must fail closed at the pool metadata, naming +/// recreation as the migration -- CAS is pre-release and has no in-place migration path. +TEST(CASRefContiguousAlloc, OldPoolFormatIsRefusedNamingRecreation) +{ + PoolMeta pm; + pm.pool_id = UInt128{1, 2}; + pm.blob_header_len = 256; + pm.min_reader_generation = G_BUILD; + pm.algos_used = {static_cast(BlobHashAlgo::CityHash128)}; + + const String current = encodePoolMeta(pm); + EXPECT_NO_THROW(decodePoolMeta(current)); + + /// Rewrite the header-line generation to the last pre-contiguous one, exactly as an older build + /// would have stamped it. + const String from = "\"v\":" + std::to_string(G_BUILD); + const String to = "\"v\":" + std::to_string(kContiguousRefStreamsGeneration - 1); + const size_t at = current.find(from); + ASSERT_NE(at, String::npos); + String old_format = current; + old_format.replace(at, from.size(), to); + + try + { + decodePoolMeta(old_format); + FAIL() << "a pre-contiguous pool must not open"; + } + catch (const DB::Exception & e) + { + EXPECT_EQ(e.code(), DB::ErrorCodes::UNKNOWN_FORMAT_VERSION); + EXPECT_NE(e.message().find(fmt::format("CAS pool format {} predates generation-9 exact _ckpt committed_through recovery frontier", + kContiguousRefStreamsGeneration - 1)), String::npos) + << "the message must name the migration: " << e.message(); + } +} + +/// Generation 6 is a recreate-only physical-layout cut. A generation-5 pool has contiguous, +/// incarnation-qualified streams but still repeats the logical namespace in every key; accepting it +/// would silently run the generation-6 parsers over a different grammar. +TEST(CASRefContiguousAlloc, GenerationFiveNamespaceBearingPoolIsRefusedNamingRecreation) +{ + PoolMeta pm; + pm.pool_id = UInt128{1, 2}; + pm.blob_header_len = 256; + pm.min_reader_generation = G_BUILD; + pm.algos_used = {static_cast(BlobHashAlgo::CityHash128)}; + + const String current = encodePoolMeta(pm); + EXPECT_NO_THROW(decodePoolMeta(current)); + + /// Rewrite the header to the immediately preceding generation, which used + /// `cas/refs///...`. + const String from = "\"v\":" + std::to_string(G_BUILD); + const String to = "\"v\":" + std::to_string(kNamespaceLifeKeyedGeneration); + const size_t at = current.find(from); + ASSERT_NE(at, String::npos); + String old_format = current; + old_format.replace(at, from.size(), to); + ASSERT_EQ(kNamespaceLifeKeyedGeneration + 1, kOpaqueNamespaceLifeLayoutGeneration) + << "this test pins the immediately preceding namespace-bearing generation"; + + try + { + decodePoolMeta(old_format); + FAIL() << "a generation-5 namespace-bearing pool must not open"; + } + catch (const DB::Exception & e) + { + EXPECT_EQ(e.code(), DB::ErrorCodes::UNKNOWN_FORMAT_VERSION); + EXPECT_NE(e.message().find(fmt::format("CAS pool format {} predates generation-9 exact _ckpt committed_through recovery frontier", + kNamespaceLifeKeyedGeneration)), String::npos) + << "the message must name the migration: " << e.message(); + } +} + +/// Mutation caught: leaving the pool floor at generation 6 would admit a seal whose independent +/// name-keyed coverage and cleanup collections this build no longer has. Generation 7 is a +/// recreate-only grammar cut, so the immediately preceding generation must fail at pool open. +TEST(CASRefContiguousAlloc, GenerationSixSplitFoldSealPoolIsRefusedNamingRecreation) +{ + PoolMeta pm; + pm.pool_id = UInt128{1, 2}; + pm.blob_header_len = 256; + pm.min_reader_generation = G_BUILD; + pm.algos_used = {static_cast(BlobHashAlgo::CityHash128)}; + + const String current = encodePoolMeta(pm); + const String from = "\"v\":" + std::to_string(G_BUILD); + const String to = "\"v\":6"; + const size_t at = current.find(from); + ASSERT_NE(at, String::npos); + String old_format = current; + old_format.replace(at, from.size(), to); + + try + { + decodePoolMeta(old_format); + FAIL() << "a generation-6 split ref-life fold seal pool must not open"; + } + catch (const DB::Exception & e) + { + EXPECT_EQ(e.code(), DB::ErrorCodes::UNKNOWN_FORMAT_VERSION); + EXPECT_NE(e.message().find("CAS pool format 6 predates generation-9 exact _ckpt committed_through recovery frontier"), String::npos) + << "the message must name the recreate-only grammar cut: " << e.message(); + } +} + +TEST(CASPoolMeta, GcShardsIsPersistedAndOverridesMismatchedReopenConfig) +{ + InMemoryBackend backend; + const Layout layout("p"); + const PoolMeta created = PoolMeta::createOrValidate( + backend, layout, /*blob_header_len=*/256, /*gc_shards=*/4, + BlobHashAlgo::CityHash128, /*allow_new=*/false, /*allow_mint=*/true); + EXPECT_EQ(created.gc_shards, 4u); + + const PoolMeta reopened = PoolMeta::createOrValidate( + backend, layout, /*blob_header_len=*/256, /*gc_shards=*/1, + BlobHashAlgo::CityHash128, /*allow_new=*/false, /*allow_mint=*/false); + EXPECT_EQ(reopened.gc_shards, 4u); + EXPECT_EQ(decodePoolMeta(backend.get(layout.poolMetaKey())->bytes).gc_shards, 4u); +} + +/// The one path where "an attempt that provably sent nothing consumes nothing" does not hold, and the +/// A known-durable install failure must replay before the next id is derived. Replay installs the +/// stranded transaction, so the next append derives its real contiguous successor. +TEST(CASRefContiguousAlloc, NeedsRecoveryReplaysBeforeAllocatingTheNextId) +{ + auto backend = std::make_shared(); + auto store = openPool(backend); + const uint64_t epoch = store->writerEpoch(); + const RootNamespace ns{"srv1/contig_durable_floor"}; + + ASSERT_EQ(publishRef(store, ns, "ref_1", 1), (RefTxnId{epoch, 1})); + + /// One-shot throw inside the post-durable install region: txn {epoch, 2} commits durably and is + /// never installed. The exception is built OUTSIDE the region (building it inside would trip + /// `DENY_ALLOCATIONS_IN_SCOPE` and test the guard instead of the recovery transition). + auto planned = std::make_exception_ptr(DB::Exception(DB::ErrorCodes::MEMORY_LIMIT_EXCEEDED, + "simulated allocation failure inside the post-durable install region")); + auto fired = std::make_shared>(false); + store->setInstallRegionProbeForTest([planned, fired] + { + if (fired->exchange(true)) + return; + ALLOW_ALLOCATIONS_IN_SCOPE; + std::rethrow_exception(planned); + }); + DB::Cas::tests::expectThrowsCode(DB::ErrorCodes::MEMORY_LIMIT_EXCEEDED, + [&] { publishRef(store, ns, "ref_2", 2); }); + store->setInstallRegionProbeForTest(nullptr); + ASSERT_EQ(store->laneStateForTest(ns), RefLaneState::NeedsRecovery); + + /// The next append first recovers `{epoch, 2}`, then lands at `{epoch, 3}`. + EXPECT_EQ(publishRef(store, ns, "ref_3", 3), (RefTxnId{epoch, 3})) + << "the stranded transaction is durable, so the next id must be its successor, not itself"; + EXPECT_TRUE(store->resolveRef(ns, "ref_3", /*allow_stale=*/false).has_value()) + << "the append may proceed only after recovery has repaired the cached state"; + + EXPECT_TRUE(store->resolveRef(ns, "ref_2", /*allow_stale=*/false).has_value()) + << "the stranded transaction is back in this cache, which is what repairs the divergence"; + EXPECT_EQ(store->laneStateForTest(ns), RefLaneState::Ready); + + /// The durable stream itself is dense: `1`, `2`, `3` all exist as objects. `ns` was born through + /// the REAL append lane (Stage B Task 4-C), so its objects sit at a real catalog-minted incarnation, + /// not the Stage-A sentinel -- resolve it the same way production discovery does. + const NamespaceLifeId life = CasRefCatalog::lifeIfCataloged(*backend, store->layout(), ns).value(); + for (uint64_t seq = 1; seq <= 3; ++seq) + EXPECT_TRUE(backend->head(store->layout().refLogKey(life, RefTxnId{epoch, seq})).exists) + << "log object " << epoch << "-" << seq << " must exist: the durable stream has no hole"; +} + +/// Snapshot publication also recovers a `NeedsRecovery` lane before it captures state. +TEST(CASRefContiguousAlloc, NeedsRecoveryReplaysBeforeSnapshotPublication) +{ + auto backend = std::make_shared(); + auto store = openPool(backend); + const uint64_t epoch = store->writerEpoch(); + const RootNamespace ns{"srv1/contig_poison_publish"}; + + ASSERT_EQ(publishRef(store, ns, "ref_1", 1), (RefTxnId{epoch, 1})); + ASSERT_TRUE(store->tryPublishSnapshotAndAdvanceCheckpointOnce(ns)) << "a healthy table must publish, or the refusal " + "asserted below would prove nothing"; + const auto published_before = store->newestPublishedSnapshotIdForTest(ns); + ASSERT_TRUE(published_before.has_value()); + + auto planned = std::make_exception_ptr(DB::Exception(DB::ErrorCodes::MEMORY_LIMIT_EXCEEDED, + "simulated allocation failure inside the post-durable install region")); + auto fired = std::make_shared>(false); + store->setInstallRegionProbeForTest([planned, fired] + { + if (fired->exchange(true)) + return; + ALLOW_ALLOCATIONS_IN_SCOPE; + std::rethrow_exception(planned); + }); + DB::Cas::tests::expectThrowsCode(DB::ErrorCodes::MEMORY_LIMIT_EXCEEDED, + [&] { publishRef(store, ns, "ref_2", 2); }); + store->setInstallRegionProbeForTest(nullptr); + ASSERT_EQ(store->laneStateForTest(ns), RefLaneState::NeedsRecovery); + + /// The append entry point replays before admitting this transaction. + EXPECT_EQ(publishRef(store, ns, "ref_3", 3), (RefTxnId{epoch, 3})); + + /// Publication is safe because recovery installed the stranded transaction first. + EXPECT_TRUE(store->tryPublishSnapshotAndAdvanceCheckpointOnce(ns)); + EXPECT_TRUE(store->resolveRef(ns, "ref_2", /*allow_stale=*/false).has_value()) + << "the stranded transaction is durable and was re-derived -- publishing is safe precisely " + "because there is nothing left to omit"; + EXPECT_EQ(store->laneStateForTest(ns), RefLaneState::Ready); + EXPECT_NE(store->newestPublishedSnapshotIdForTest(ns), published_before); +} + +/// Recreation quiesce, refusal leg. Refusing to OPEN an old-format pool fences nothing: the server that +/// mounted it before the operator acted is still running, still holds its mount lease, and still has +/// queued writes. If "recreate the pool" is followed literally -- clear the prefix, start fresh -- that +/// writer's next flush lands its old-format transactions inside the NEW pool. So a recreation over a +/// prefix whose mount slots are not terminal must fail closed, and must say why, BEFORE the operator +/// clears anything. +TEST(CASRefContiguousAlloc, RecreationRefusedWhileAMountSlotIsStillHeld) +{ + auto backend = std::make_shared(); + auto holder = openPool(backend); + const RootNamespace ns{"srv1/contig_quiesce"}; + ASSERT_EQ(publishRef(holder, ns, "ref_1", 1), (RefTxnId{holder->writerEpoch(), 1})); + + /// The operator removes the pool identity, intending to recreate -- but the holder is still up. + ASSERT_EQ(eraseKeysContaining(*backend, "_pool_meta"), 1u); + + const String message = messageOfThrow([&] { openPoolWithoutSeeding(backend, "test2"); }); + EXPECT_NE(message.find("mount lease(s) under this prefix are still held"), String::npos) + << "the refusal must name the held lease, not merely the residual data: " << message; + EXPECT_NE(message.find("do NOT clear the prefix first"), String::npos) + << "the remedy ordering is the whole point of this gate: " << message; + EXPECT_NE(message.find("server root 'test'"), String::npos) + << "the holder must be identified so the operator knows what to stop: " << message; + + /// And the holder is untouched by the refused recreation: its own stream continues contiguously. + EXPECT_EQ(publishRef(holder, ns, "ref_2", 2), (RefTxnId{holder->writerEpoch(), 2})); +} + +/// Recreation quiesce, acceptance leg. Once the holder is gone its slot carries the graceful-farewell +/// marker -- one of the two clock-free certificates of death the mount protocol already recognises -- +/// so the quiesce gate stops firing and the ordinary bootstrap rules take over: clear the prefix, and +/// the recreation mints a fresh pool. +TEST(CASRefContiguousAlloc, RecreationProceedsOnceTheHolderIsTerminal) +{ + auto backend = std::make_shared(); + const RootNamespace ns{"srv1/contig_quiesce_ok"}; + { + auto holder = openPool(backend); + publishRef(holder, ns, "ref_1", 1); + } /// destroyed: the keeper stamps the farewell, making the slot terminal + ASSERT_EQ(eraseKeysContaining(*backend, "_pool_meta"), 1u); + + /// The prefix still holds this pool's data, so the bootstrap still refuses -- but on the ORDINARY + /// residual rule, not the quiesce gate. That difference is the whole assertion: nothing is being + /// held any more. + const String residual = messageOfThrow([&] { openPoolWithoutSeeding(backend, "test2"); }); + EXPECT_EQ(residual.find("still held"), String::npos) + << "a terminal slot must not block recreation: " << residual; + EXPECT_NE(residual.find("refusing to bootstrap over residual data"), String::npos) << residual; + + /// The operator now clears the prefix -- in the order the refusal prescribed -- and the recreation + /// mints a fresh pool that starts its own ref stream at 1. + ASSERT_GT(eraseKeysContaining(*backend, ""), 0u); + auto recreated = openPoolWithoutSeeding(backend, "test"); + EXPECT_EQ(publishRef(recreated, ns, "ref_1", 1), (RefTxnId{recreated->writerEpoch(), 1})); +} + +/// The other half of the rule: if the prefix IS cleared while a writer survives (the mistake the +/// refusal above exists to prevent, or a writer that was already mid-flight), the recreated pool's +/// ordinary mount claim is what stops it. The survivor's next lease renewal finds a slot it can no +/// longer hold, its local fence latches shut, and every later write is refused -- so a straggler can +/// never append into the new pool. +/// +/// The recreating mount here is a DIFFERENT server (its own `server_id`), which is what makes the +/// survivor's renewal conclusive. Clearing the prefix also resets the durable writer-epoch counter, so +/// a recreation by the SAME server uuid can be handed the very same `(uuid, epoch)` the survivor still +/// holds -- and the two are then indistinguishable to the lease protocol, which reads the survivor's +/// renewal as its own keeper adopting a refreshed body. That is precisely why the refusal above is the +/// primary defence and this fence is only the backstop: quiescing the holder BEFORE the prefix is +/// cleared is what keeps the ambiguous case from arising at all. +TEST(CASRefContiguousAlloc, SurvivingWriterIsFencedByTheRecreatedPoolsMount) +{ + auto backend = std::make_shared(); + /// The survivor renews on its own thread, as a real mount does: the renewal loop is what latches the + /// write fence when a renewal fails, so a hand-driven `renewWatermarkOnce` would reproduce only the + /// failure and not the fencing it causes. + PoolConfig survivor_cfg{.pool_prefix = "p", .server_root_id = "test"}; + survivor_cfg.background_watermark = true; + survivor_cfg.mount_renew_period = std::chrono::milliseconds{50}; + DB::Cas::tests::seedPoolMetaForRestart(*backend); + auto survivor = Pool::open(backend, survivor_cfg); + const RootNamespace ns{"srv1/contig_survivor"}; + ASSERT_EQ(publishRef(survivor, ns, "ref_1", 1), (RefTxnId{survivor->writerEpoch(), 1})); + + /// The prefix is cleared and the pool recreated underneath the still-running survivor. + ASSERT_GT(eraseKeysContaining(*backend, ""), 0u); + PoolConfig recreated_cfg{.pool_prefix = "p", .server_root_id = "test"}; + recreated_cfg.server_id = UInt128{7, 7}; + auto recreated = Pool::open(backend, recreated_cfg); + ASSERT_TRUE(recreated->mayMutate()); + + /// The survivor's next renewal finds a slot held by a foreign server and fails closed, and the loop + /// latches the local write fence. Bounded wait: a real hang fails the test instead of stalling it. + const auto deadline = std::chrono::steady_clock::now() + std::chrono::seconds(10); + while (survivor->mayMutate() && std::chrono::steady_clock::now() < deadline) + std::this_thread::sleep_for(std::chrono::milliseconds(20)); + EXPECT_FALSE(survivor->mayMutate()) + << "a survivor whose slot was reclaimed must be fenced closed by its own failing renewal, not " + "left writing into the new pool"; + EXPECT_NE(messageOfThrow([&] { publishRef(survivor, ns, "ref_2", 2); }), String()) + << "the survivor's queued write must be refused"; + + /// The recreated pool is unaffected and owns the stream from 1. + EXPECT_EQ(publishRef(recreated, ns, "ref_1", 1), (RefTxnId{recreated->writerEpoch(), 1})); + + /// The survivor's TEARDOWN is the other half, and it is asserted here rather than left to the + /// destructor at scope exit, because which arm of the release path it takes is exactly what a + /// regressed discriminator would get wrong — silently. A deposed writer meeting its successor in the + /// slot is the EXPECTED end of a failover (arm A: skip the farewell, leave the successor's slot + /// untouched); a writer that still believed it owned the mount meeting a stranger is + /// single-writer exclusivity BROKEN (arm B, must-always-be-zero). If `deposition_observed` ever stops + /// being set, every ordinary failover in production starts reporting itself as a broken guarantee — + /// and without the +0 assertion below, not one test would notice. + const String survivor_mount_key = recreated->layout().mountKey("test"); + const auto successor_slot_before = backend->get(survivor_mount_key); + ASSERT_TRUE(successor_slot_before.has_value()); + const uint64_t skipped_before + = ProfileEvents::global_counters[ProfileEvents::CASMountReleaseSkippedForeignOccupant].load(); + const uint64_t violations_before + = ProfileEvents::global_counters[ProfileEvents::CASMountExclusivityViolation].load(); + + survivor.reset(); + + EXPECT_EQ(ProfileEvents::global_counters[ProfileEvents::CASMountReleaseSkippedForeignOccupant].load(), + skipped_before + 1) + << "a deposed writer's release must take the skip-the-farewell arm"; + EXPECT_EQ(ProfileEvents::global_counters[ProfileEvents::CASMountExclusivityViolation].load(), + violations_before) + << "and must NOT report an exclusivity violation: this is a failover, not a broken guarantee"; + const auto successor_slot_after = backend->get(survivor_mount_key); + ASSERT_TRUE(successor_slot_after.has_value()); + EXPECT_EQ(successor_slot_after->bytes, successor_slot_before->bytes) + << "the deposed writer must not stamp its farewell over the successor's lease"; + EXPECT_TRUE(recreated->mayMutate()) << "and must not disturb the live successor"; +} diff --git a/src/Disks/tests/gtest_cas_ref_cow_manifest_set.cpp b/src/Disks/tests/gtest_cas_ref_cow_manifest_set.cpp new file mode 100644 index 000000000000..5e1ddea64568 --- /dev/null +++ b/src/Disks/tests/gtest_cas_ref_cow_manifest_set.cpp @@ -0,0 +1,392 @@ +#include +#include +#include +#include + +#include +#include + +namespace DB::ErrorCodes +{ +extern const int CORRUPTED_DATA; +} + +using namespace DB::Cas; +using DB::Cas::tests::expectThrowsCode; + +namespace +{ + +ManifestRef mref(uint64_t epoch, uint64_t seq, uint32_t ordinal) +{ + return ManifestRef{epoch, seq, ordinal}; +} + +} + +/// =================================================================================== +/// Keyed ops: contains/insert/erase across base+overlay (the "E2 owned-manifest index" work). +/// =================================================================================== + +TEST(CASRefCowManifestSet, EmptySetHasNoMembers) +{ + RefCowManifestSet s; + EXPECT_TRUE(s.empty()); + EXPECT_EQ(s.size(), 0u); + EXPECT_FALSE(s.contains(mref(1, 1, 1))); +} + +TEST(CASRefCowManifestSet, InsertThenContains) +{ + RefCowManifestSet s; + s.insert(mref(1, 1, 1)); + EXPECT_TRUE(s.contains(mref(1, 1, 1))); + EXPECT_EQ(s.size(), 1u); + EXPECT_FALSE(s.contains(mref(2, 2, 2))); +} + +TEST(CASRefCowManifestSet, InsertMultipleThenContainsEachIndependently) +{ + RefCowManifestSet s; + s.insert(mref(1, 1, 1)); + s.insert(mref(1, 1, 2)); + s.insert(mref(2, 1, 1)); + EXPECT_EQ(s.size(), 3u); + EXPECT_TRUE(s.contains(mref(1, 1, 1))); + EXPECT_TRUE(s.contains(mref(1, 1, 2))); + EXPECT_TRUE(s.contains(mref(2, 1, 1))); + EXPECT_FALSE(s.contains(mref(3, 3, 3))); +} + +TEST(CASRefCowManifestSet, EraseRemovesAnOverlayOnlyMember) +{ + RefCowManifestSet s; + s.insert(mref(1, 1, 1)); + s.erase(mref(1, 1, 1)); + EXPECT_FALSE(s.contains(mref(1, 1, 1))); + EXPECT_EQ(s.size(), 0u); + EXPECT_TRUE(s.empty()); + EXPECT_EQ(s.overlayEntriesForTest(), 0u); /// pure-overlay member: erase removes it outright +} + +TEST(CASRefCowManifestSet, TombstoneThenReinsertWhilePurelyInOverlay) +{ + RefCowManifestSet s; + s.insert(mref(1, 1, 1)); + s.erase(mref(1, 1, 1)); + s.insert(mref(1, 1, 1)); /// re-insert -- must not be treated as "still present" + EXPECT_TRUE(s.contains(mref(1, 1, 1))); + EXPECT_EQ(s.size(), 1u); +} + +/// =================================================================================== +/// materialize() +/// =================================================================================== + +TEST(CASRefCowManifestSet, MaterializeFoldsOverlayIntoBaseAndEmptiesOverlay) +{ + RefCowManifestSet s; + s.insert(mref(1, 1, 1)); + s.insert(mref(1, 1, 2)); + EXPECT_GT(s.overlayEntriesForTest(), 0u); + + s.materialize(); + EXPECT_EQ(s.overlayEntriesForTest(), 0u); + EXPECT_TRUE(s.contains(mref(1, 1, 1))); + EXPECT_TRUE(s.contains(mref(1, 1, 2))); + EXPECT_EQ(s.size(), 2u); +} + +TEST(CASRefCowManifestSet, MaterializeOnAnEmptyOverlayIsANoOp) +{ + RefCowManifestSet s; + s.insert(mref(1, 1, 1)); + s.materialize(); + const int64_t use_count_before = s.baseUseCountForTest(); + s.materialize(); /// overlay is already empty + EXPECT_EQ(s.baseUseCountForTest(), use_count_before); + EXPECT_TRUE(s.contains(mref(1, 1, 1))); +} + +TEST(CASRefCowManifestSet, EraseAfterMaterializeTombstonesABaseMember) +{ + RefCowManifestSet s; + s.insert(mref(1, 1, 1)); + s.insert(mref(1, 1, 2)); + s.materialize(); /// both now live in `base` + + s.erase(mref(1, 1, 1)); + EXPECT_FALSE(s.contains(mref(1, 1, 1))); + EXPECT_TRUE(s.contains(mref(1, 1, 2))); + EXPECT_EQ(s.size(), 1u); + + s.materialize(); /// tombstone folds away; base member actually removed + EXPECT_FALSE(s.contains(mref(1, 1, 1))); + EXPECT_EQ(s.size(), 1u); +} + +TEST(CASRefCowManifestSet, TombstoneThenReinsertAcrossMaterializedBase) +{ + RefCowManifestSet s; + s.insert(mref(1, 1, 1)); + s.materialize(); /// mref(1,1,1) now lives in `base` + + s.erase(mref(1, 1, 1)); /// tombstone shadowing the base member + s.insert(mref(1, 1, 1)); /// revive the tombstone -- must read as present again + EXPECT_TRUE(s.contains(mref(1, 1, 1))); + EXPECT_EQ(s.size(), 1u); + + s.materialize(); + EXPECT_TRUE(s.contains(mref(1, 1, 1))); + EXPECT_EQ(s.size(), 1u); +} + +/// =================================================================================== +/// materialize() fast path: fold into a uniquely-owned base IN PLACE, no O(N) copy (E5). +/// =================================================================================== + +TEST(CASRefCowManifestSet, MaterializeReusesBaseWhenUniquelyOwned) +{ + RefCowManifestSet s; + s.insert(mref(1, 1, 1)); + s.materialize(); /// mref(1,1,1) now in base; base is uniquely owned + const void * base_before = s.baseIdentityForTest(); + ASSERT_EQ(s.baseUseCountForTest(), 1); + + s.insert(mref(2, 2, 2)); /// pure-overlay addition + s.erase(mref(1, 1, 1)); /// tombstone a base member + s.materialize(); + + EXPECT_EQ(s.baseIdentityForTest(), base_before); /// folded in place: same base allocation + EXPECT_EQ(s.overlayEntriesForTest(), 0u); + EXPECT_FALSE(s.contains(mref(1, 1, 1))); /// tombstone erased from base + EXPECT_TRUE(s.contains(mref(2, 2, 2))); + EXPECT_EQ(s.size(), 1u); /// net_delta reset, size still exact +} + +TEST(CASRefCowManifestSet, MaterializeBuildsFreshBaseWhenBaseIsShared) +{ + RefCowManifestSet original; + original.insert(mref(1, 1, 1)); + original.materialize(); + const void * shared_base = original.baseIdentityForTest(); + + RefCowManifestSet writer = original; /// shares the base (use_count 2) + ASSERT_EQ(writer.baseUseCountForTest(), 2); + writer.insert(mref(9, 9, 9)); + writer.erase(mref(1, 1, 1)); + writer.materialize(); /// base is shared -> must build a fresh one, mutate nothing shared + + /// Load-bearing correctness pin: the OTHER holder's view is byte-unchanged. + EXPECT_EQ(original.baseIdentityForTest(), shared_base); + EXPECT_TRUE(original.contains(mref(1, 1, 1))); + EXPECT_FALSE(original.contains(mref(9, 9, 9))); + EXPECT_EQ(original.size(), 1u); + + /// The writer folded its overlay into a fresh base of its own. + EXPECT_NE(writer.baseIdentityForTest(), shared_base); + EXPECT_FALSE(writer.contains(mref(1, 1, 1))); + EXPECT_TRUE(writer.contains(mref(9, 9, 9))); + EXPECT_EQ(writer.size(), 1u); +} + +TEST(CASRefCowManifestSet, MaterializeEmptyOverlayIsANoOpEvenWhenUniquelyOwned) +{ + RefCowManifestSet s; + s.insert(mref(1, 1, 1)); + s.materialize(); + ASSERT_EQ(s.baseUseCountForTest(), 1); + const void * base_before = s.baseIdentityForTest(); + s.materialize(); /// overlay already empty: no fold, no reallocation + EXPECT_EQ(s.baseIdentityForTest(), base_before); + EXPECT_TRUE(s.contains(mref(1, 1, 1))); +} + +/// =================================================================================== +/// Copy-on-write isolation + O(1)-copy assertion. +/// =================================================================================== + +TEST(CASRefCowManifestSet, CopyIsIsolatedFromOriginal) +{ + RefCowManifestSet original; + original.insert(mref(1, 1, 1)); + original.materialize(); + + RefCowManifestSet copy = original; + copy.insert(mref(9, 9, 9)); + copy.erase(mref(1, 1, 1)); + + EXPECT_TRUE(original.contains(mref(1, 1, 1))); + EXPECT_FALSE(original.contains(mref(9, 9, 9))); + + EXPECT_FALSE(copy.contains(mref(1, 1, 1))); + EXPECT_TRUE(copy.contains(mref(9, 9, 9))); +} + +TEST(CASRefCowManifestSet, CopySharesBaseUntilEitherSideMaterializesANewOne) +{ + RefCowManifestSet original; + original.insert(mref(1, 1, 1)); + original.materialize(); + + RefCowManifestSet copy = original; + /// A copy shares the SAME base object (refcount bump, no per-element allocation) until a write + /// forces a new base into existence via `materialize()`. + EXPECT_EQ(original.baseUseCountForTest(), 2); + EXPECT_EQ(copy.baseUseCountForTest(), 2); + + copy.insert(mref(2, 2, 2)); /// writes go to `copy`'s overlay; `base` is untouched + EXPECT_EQ(original.baseUseCountForTest(), 2); + EXPECT_EQ(copy.baseUseCountForTest(), 2); + EXPECT_FALSE(original.contains(mref(2, 2, 2))); + + copy.materialize(); /// NOW `copy` points at a fresh base of its own + EXPECT_EQ(original.baseUseCountForTest(), 1); + EXPECT_EQ(copy.baseUseCountForTest(), 1); +} + +/// =================================================================================== +/// size()/net_delta correctness across a longer op sequence, mixing base and overlay changes. +/// =================================================================================== + +TEST(CASRefCowManifestSet, SizeTracksNetDeltaAcrossMixedOps) +{ + RefCowManifestSet s; + s.insert(mref(1, 1, 1)); + s.insert(mref(1, 1, 2)); + s.insert(mref(1, 1, 3)); + EXPECT_EQ(s.size(), 3u); + s.materialize(); + EXPECT_EQ(s.size(), 3u); + + s.erase(mref(1, 1, 2)); /// base member removed via overlay tombstone + EXPECT_EQ(s.size(), 2u); + s.insert(mref(1, 1, 4)); /// pure-overlay addition + EXPECT_EQ(s.size(), 3u); + s.erase(mref(1, 1, 4)); /// pure-overlay addition removed outright + EXPECT_EQ(s.size(), 2u); + s.insert(mref(1, 1, 2)); /// revive the earlier tombstone + EXPECT_EQ(s.size(), 3u); + + s.materialize(); + EXPECT_EQ(s.size(), 3u); + EXPECT_TRUE(s.contains(mref(1, 1, 1))); + EXPECT_TRUE(s.contains(mref(1, 1, 2))); + EXPECT_TRUE(s.contains(mref(1, 1, 3))); + EXPECT_FALSE(s.contains(mref(1, 1, 4))); +} + +/// =================================================================================== +/// Drift-detection misuse (throws `CORRUPTED_DATA` in EVERY build, post-consult -- previously a +/// debug-only `chassert`): `insert` requires absence, `erase` requires presence. The ref table's own +/// uniqueness invariant guarantees both before either is ever called, so a violation here means the +/// index has drifted, not that a legitimate caller can trigger it. Failing closed (rather than a silent +/// release-build `net_delta` drift) is what keeps a corrupted history from later hiding a still-live +/// owner. +/// =================================================================================== + +TEST(CASRefCowManifestSet, InsertThrowsWhenAlreadyPresentInOverlay) +{ + RefCowManifestSet s; + s.insert(mref(1, 1, 1)); + expectThrowsCode(DB::ErrorCodes::CORRUPTED_DATA, [&] { s.insert(mref(1, 1, 1)); }); +} + +TEST(CASRefCowManifestSet, InsertThrowsWhenAlreadyPresentInBase) +{ + RefCowManifestSet s; + s.insert(mref(1, 1, 1)); + s.materialize(); + expectThrowsCode(DB::ErrorCodes::CORRUPTED_DATA, [&] { s.insert(mref(1, 1, 1)); }); +} + +TEST(CASRefCowManifestSet, EraseThrowsWhenAbsent) +{ + RefCowManifestSet s; + expectThrowsCode(DB::ErrorCodes::CORRUPTED_DATA, [&] { s.erase(mref(1, 1, 1)); }); +} + +TEST(CASRefCowManifestSet, EraseThrowsWhenAlreadyTombstoned) +{ + RefCowManifestSet s; + s.insert(mref(1, 1, 1)); + s.materialize(); + s.erase(mref(1, 1, 1)); + expectThrowsCode(DB::ErrorCodes::CORRUPTED_DATA, [&] { s.erase(mref(1, 1, 1)); }); +} + +/// =================================================================================== +/// Fast-vs-forced-slow materialize parity (E5 xhigh review): the in-place fold (uniquely-owned base) +/// and the build-fresh-and-swap fold (a copy still shares the base) must agree on membership and size +/// across randomized op sequences. No iteration surface here, so membership is probed over a fixed +/// keyspace. insert/erase preconditions are respected (guarded by the shared membership) so the two +/// sets never drift and never trip the fail-closed CORRUPTED_DATA guards. +/// =================================================================================== + +TEST(CASRefCowManifestSet, FastAndForcedSlowMaterializeAgreeOverRandomOps) +{ + std::mt19937 rng(20260722); // NOLINT(cert-msc32-c,cert-msc51-cpp): deterministic seed for reproducible coverage. + + std::vector keyspace; + for (uint64_t k = 0; k < 10; ++k) + keyspace.push_back(mref(1, k, 1)); + + for (int trial = 0; trial < 60; ++trial) + { + RefCowManifestSet fast; /// never copied -> in-place (uniquely-owned) materialize + RefCowManifestSet slow; /// a live copy is held across each materialize -> forced fresh-base path + + for (int step = 0; step < 150; ++step) + { + const ManifestRef m = keyspace[rng() % keyspace.size()]; + const bool present = fast.contains(m); /// identical in both sets by construction + switch (rng() % 5) + { + case 0: + if (!present) /// respect the insert precondition (absent) + { + fast.insert(m); + slow.insert(m); + } + break; + case 1: + if (present) /// respect the erase precondition (present) + { + fast.erase(m); + slow.erase(m); + } + break; + case 2: /// materialize both, each via its intended path + { + ASSERT_EQ(fast.baseUseCountForTest(), 1) << "fast set must be uniquely owned"; + fast.materialize(); /// in-place fast path + { + RefCowManifestSet pin = slow; /// shares slow's base + ASSERT_EQ(slow.baseUseCountForTest(), 2) << "slow set must be forced onto the copy path"; + slow.materialize(); /// build-fresh-and-swap slow path + } + EXPECT_EQ(fast.overlayEntriesForTest(), 0u) << "trial " << trial << " step " << step; + EXPECT_EQ(slow.overlayEntriesForTest(), 0u) << "trial " << trial << " step " << step; + break; + } + default: + break; /// accumulate overlay without materializing + } + + ASSERT_EQ(fast.size(), slow.size()) << "trial " << trial << " step " << step; + for (const auto & probe : keyspace) + ASSERT_EQ(fast.contains(probe), slow.contains(probe)) << "trial " << trial << " step " << step; + } + + fast.materialize(); + { + RefCowManifestSet pin = slow; + slow.materialize(); + } + EXPECT_EQ(fast.overlayEntriesForTest(), 0u) << "trial " << trial; + EXPECT_EQ(slow.overlayEntriesForTest(), 0u) << "trial " << trial; + EXPECT_EQ(fast.size(), slow.size()) << "trial " << trial; + for (const auto & probe : keyspace) + EXPECT_EQ(fast.contains(probe), slow.contains(probe)) << "trial " << trial; + } +} diff --git a/src/Disks/tests/gtest_cas_ref_cow_map.cpp b/src/Disks/tests/gtest_cas_ref_cow_map.cpp new file mode 100644 index 000000000000..40e4cb6b9cb5 --- /dev/null +++ b/src/Disks/tests/gtest_cas_ref_cow_map.cpp @@ -0,0 +1,516 @@ +#include +#include +#include + +#include +#include +#include +#include +#include +#include + +using namespace DB::Cas; + +namespace +{ + +RefCommittedRow row(uint64_t epoch, uint64_t seq, uint32_t ordinal) +{ + RefCommittedRow r; + r.manifest_ref = ManifestRef{epoch, seq, ordinal}; + return r; +} + +} + +/// =================================================================================== +/// Keyed ops +/// =================================================================================== + +TEST(CASRefCowMap, EmptyMapHasNoEntries) +{ + RefCowMap m; + EXPECT_TRUE(m.empty()); + EXPECT_EQ(m.size(), 0u); + EXPECT_FALSE(m.contains("a")); + EXPECT_FALSE(m.contains("a")); +} + +TEST(CASRefCowMap, EmplaceThenFind) +{ + RefCowMap m; + const auto [it, inserted] = m.emplace("a", row(1, 1, 1)); + EXPECT_TRUE(inserted); + EXPECT_EQ(m.size(), 1u); + ASSERT_TRUE(m.contains("a")); + EXPECT_EQ(it->second.manifest_ref, (ManifestRef{1, 1, 1})); + EXPECT_EQ(m.at("a").manifest_ref, (ManifestRef{1, 1, 1})); +} + +TEST(CASRefCowMap, EmplaceDoesNotOverwriteExisting) +{ + RefCowMap m; + m.emplace("a", row(1, 1, 1)); + const auto [it, inserted] = m.emplace("a", row(2, 2, 2)); + EXPECT_FALSE(inserted); + EXPECT_EQ(it->second.manifest_ref, (ManifestRef{1, 1, 1})); + EXPECT_EQ(m.at("a").manifest_ref, (ManifestRef{1, 1, 1})); /// unchanged +} + +TEST(CASRefCowMap, InsertOrAssignOverwritesExisting) +{ + RefCowMap m; + m.emplace("a", row(1, 1, 1)); + const auto [it, inserted] = m.insert_or_assign("a", row(2, 2, 2)); + EXPECT_FALSE(inserted); + EXPECT_EQ(it->second.manifest_ref, (ManifestRef{2, 2, 2})); + EXPECT_EQ(m.at("a").manifest_ref, (ManifestRef{2, 2, 2})); +} + +TEST(CASRefCowMap, InsertOrAssignInsertsWhenAbsent) +{ + RefCowMap m; + const auto [it, inserted] = m.insert_or_assign("a", row(1, 1, 1)); + EXPECT_TRUE(inserted); + EXPECT_EQ(m.size(), 1u); + EXPECT_EQ(it->second.manifest_ref, (ManifestRef{1, 1, 1})); +} + +TEST(CASRefCowMap, EraseByKey) +{ + RefCowMap m; + m.emplace("a", row(1, 1, 1)); + EXPECT_EQ(m.erase("a"), 1u); + EXPECT_FALSE(m.contains("a")); + EXPECT_EQ(m.size(), 0u); + EXPECT_EQ(m.erase("a"), 0u); /// already gone: no-op + EXPECT_EQ(m.erase("nonexistent"), 0u); +} + +TEST(CASRefCowMap, AtThrowsOnMissingKey) +{ + RefCowMap m; + EXPECT_THROW(m.at("missing"), std::out_of_range); +} + +TEST(CASRefCowMap, CountMatchesContains) +{ + RefCowMap m; + m.emplace("a", row(1, 1, 1)); + EXPECT_EQ(m.count("a"), 1u); + EXPECT_EQ(m.count("b"), 0u); +} + +/// =================================================================================== +/// Ordered iteration -- overlay overrides/tombstones a materialized base (spec: "Ordered +/// iteration: merge-iterate base and overlay ... a standard two-sorted-range merge"). +/// =================================================================================== + +TEST(CASRefCowMap, OrderedIterationOverAllBaseRowsIsSorted) +{ + RefCowMap m; + m.emplace("c", row(1, 3, 1)); + m.emplace("a", row(1, 1, 1)); + m.emplace("b", row(1, 2, 1)); + + std::vector names; + for (const auto [name, r] : m) + names.push_back(name); + EXPECT_EQ(names, (std::vector{"a", "b", "c"})); +} + +TEST(CASRefCowMap, MergedIterationAppliesTombstonesAndOverrides) +{ + RefCowMap m; + m.emplace("a", row(1, 1, 1)); + m.emplace("b", row(1, 2, 1)); + m.emplace("c", row(1, 3, 1)); + m.materialize(); /// a, b, c now live in `base` + + m.insert_or_assign("b", row(9, 9, 9)); /// override b via the overlay + m.erase("c"); /// tombstone c via the overlay + m.emplace("d", row(9, 9, 2)); /// pure-overlay addition (not in base) + + std::vector> seen; + for (const auto [name, r] : m) + seen.emplace_back(name, r.manifest_ref); + + const std::vector> expected = { + {"a", ManifestRef{1, 1, 1}}, + {"b", ManifestRef{9, 9, 9}}, + {"d", ManifestRef{9, 9, 2}}, + }; + EXPECT_EQ(seen, expected); + EXPECT_EQ(m.size(), 3u); +} + +TEST(CASRefCowMap, FindOverlayOnlyKeyIteratesIntoBase) +{ + RefCowMap m; + m.emplace("A", row(1, 1, 1)); + m.emplace("D", row(1, 4, 1)); + m.materialize(); /// A, D now live in `base` + + m.insert_or_assign("B", row(2, 2, 1)); /// overlay-only key between base keys "A" and "D" + + auto it = m.find("B"); + ASSERT_NE(it, m.end()); + EXPECT_EQ(it->first, "B"); + ++it; + ASSERT_NE(it, m.end()); /// must land on "D", not collapse straight to end() + EXPECT_EQ(it->first, "D"); +} + +TEST(CASRefCowMap, EraseByIteratorReturnsNextAndRemovesTheRow) +{ + RefCowMap m; + m.emplace("a", row(1, 1, 1)); + m.emplace("b", row(1, 2, 1)); + m.emplace("c", row(1, 3, 1)); + + auto it = m.find("b"); + ASSERT_TRUE(it != m.end()); + auto next = m.erase(it); + ASSERT_TRUE(next != m.end()); + EXPECT_EQ(next->first, "c"); + EXPECT_FALSE(m.contains("b")); + EXPECT_EQ(m.size(), 2u); +} + +TEST(CASRefCowMap, EraseByIteratorOfLastElementReturnsEnd) +{ + RefCowMap m; + m.emplace("a", row(1, 1, 1)); + auto it = m.find("a"); + auto next = m.erase(it); + EXPECT_TRUE(next == m.end()); + EXPECT_TRUE(m.empty()); +} + +/// =================================================================================== +/// materialize() (spec §Materialization) +/// =================================================================================== + +TEST(CASRefCowMap, MaterializeFoldsOverlayIntoFreshBaseAndKeepsValuesUnchanged) +{ + RefCowMap m; + m.emplace("a", row(1, 1, 1)); + m.emplace("b", row(1, 2, 1)); + m.erase("a"); + EXPECT_GT(m.overlayEntriesForTest(), 0u); + + m.materialize(); + EXPECT_EQ(m.overlayEntriesForTest(), 0u); + EXPECT_FALSE(m.contains("a")); + ASSERT_TRUE(m.contains("b")); + EXPECT_EQ(m.at("b").manifest_ref, (ManifestRef{1, 2, 1})); + EXPECT_EQ(m.size(), 1u); +} + +TEST(CASRefCowMap, MaterializeOnAnEmptyOverlayIsANoOp) +{ + RefCowMap m; + m.emplace("a", row(1, 1, 1)); + m.materialize(); + const int64_t use_count_before = m.baseUseCountForTest(); + m.materialize(); /// overlay is already empty + EXPECT_EQ(m.baseUseCountForTest(), use_count_before); + EXPECT_TRUE(m.contains("a")); +} + +TEST(CASRefCowMap, MaterializeDoesNotAffectACopyTakenBeforeIt) +{ + RefCowMap m; + m.emplace("a", row(1, 1, 1)); + RefCowMap snapshot_before = m; /// copy shares m's pre-materialize base, owns its own overlay + m.insert_or_assign("a", row(2, 2, 2)); + m.materialize(); + + EXPECT_EQ(m.at("a").manifest_ref, (ManifestRef{2, 2, 2})); + EXPECT_EQ(snapshot_before.at("a").manifest_ref, (ManifestRef{1, 1, 1})); +} + +/// =================================================================================== +/// materialize() fast path: fold into a uniquely-owned base IN PLACE, no O(N) copy (E5). +/// =================================================================================== + +TEST(CASRefCowMap, MaterializeReusesBaseWhenUniquelyOwned) +{ + RefCowMap m; + m.emplace("a", row(1, 1, 1)); + m.materialize(); /// "a" now in base; base is uniquely owned + const void * base_before = m.baseIdentityForTest(); + ASSERT_EQ(m.baseUseCountForTest(), 1); + + m.insert_or_assign("b", row(2, 2, 2)); /// pure-overlay addition + m.erase("a"); /// tombstone a base member + m.materialize(); + + EXPECT_EQ(m.baseIdentityForTest(), base_before); /// folded in place: same base allocation + EXPECT_EQ(m.overlayEntriesForTest(), 0u); + EXPECT_FALSE(m.contains("a")); /// tombstone erased from base + ASSERT_TRUE(m.contains("b")); + EXPECT_EQ(m.at("b").manifest_ref, (ManifestRef{2, 2, 2})); + EXPECT_EQ(m.size(), 1u); /// net_delta reset, size still exact +} + +TEST(CASRefCowMap, MaterializeBuildsFreshBaseWhenBaseIsShared) +{ + RefCowMap original; + original.emplace("a", row(1, 1, 1)); + original.materialize(); + const void * shared_base = original.baseIdentityForTest(); + + RefCowMap writer = original; /// shares the base (use_count 2) + ASSERT_EQ(writer.baseUseCountForTest(), 2); + writer.insert_or_assign("a", row(9, 9, 9)); + writer.emplace("b", row(9, 9, 2)); + writer.materialize(); /// base is shared -> must build a fresh one, mutate nothing shared + + /// Load-bearing correctness pin: the OTHER holder's view is byte-unchanged. + EXPECT_EQ(original.baseIdentityForTest(), shared_base); + EXPECT_EQ(original.at("a").manifest_ref, (ManifestRef{1, 1, 1})); + EXPECT_FALSE(original.contains("b")); + EXPECT_EQ(original.size(), 1u); + + /// The writer folded its overlay into a fresh base of its own. + EXPECT_NE(writer.baseIdentityForTest(), shared_base); + EXPECT_EQ(writer.at("a").manifest_ref, (ManifestRef{9, 9, 9})); + EXPECT_TRUE(writer.contains("b")); + EXPECT_EQ(writer.size(), 2u); +} + +TEST(CASRefCowMap, MaterializeEmptyOverlayIsANoOpEvenWhenUniquelyOwned) +{ + RefCowMap m; + m.emplace("a", row(1, 1, 1)); + m.materialize(); + ASSERT_EQ(m.baseUseCountForTest(), 1); + const void * base_before = m.baseIdentityForTest(); + m.materialize(); /// overlay already empty: no fold, no reallocation + EXPECT_EQ(m.baseIdentityForTest(), base_before); + EXPECT_TRUE(m.contains("a")); +} + +TEST(CASRefCowMap, EqualityComparesEffectiveContentsNotInternalLayout) +{ + RefCowMap a; + a.emplace("x", row(1, 1, 1)); + a.materialize(); /// "x" lives in `base` + + RefCowMap b; + b.emplace("x", row(1, 1, 1)); /// same logical content, but lives entirely in `overlay` + + EXPECT_EQ(a.overlayEntriesForTest(), 0u); + EXPECT_GT(b.overlayEntriesForTest(), 0u); + EXPECT_TRUE(a == b); +} + +/// =================================================================================== +/// Copy-on-write isolation + O(1)-copy assertion (spec §Correctness & testing) +/// =================================================================================== + +TEST(CASRefCowMap, CopyIsIsolatedFromOriginal) +{ + RefCowMap original; + original.emplace("a", row(1, 1, 1)); + original.materialize(); + + RefCowMap copy = original; + copy.insert_or_assign("a", row(9, 9, 9)); + copy.emplace("b", row(9, 9, 9)); + + EXPECT_EQ(original.at("a").manifest_ref, (ManifestRef{1, 1, 1})); + EXPECT_FALSE(original.contains("b")); + + EXPECT_EQ(copy.at("a").manifest_ref, (ManifestRef{9, 9, 9})); + EXPECT_TRUE(copy.contains("b")); +} + +TEST(CASRefCowMap, CopySharesBaseUntilEitherSideMaterializesANewOne) +{ + RefCowMap original; + original.emplace("a", row(1, 1, 1)); + original.materialize(); + + RefCowMap copy = original; + /// A copy shares the SAME base object (refcount bump, no per-row allocation) until a write + /// forces a new base into existence via `materialize()` (spec §Mechanism: "Copy = O(1)"). + EXPECT_EQ(original.baseUseCountForTest(), 2); + EXPECT_EQ(copy.baseUseCountForTest(), 2); + + copy.insert_or_assign("a", row(2, 2, 2)); /// writes go to `copy`'s overlay; `base` is untouched + EXPECT_EQ(original.baseUseCountForTest(), 2); + EXPECT_EQ(copy.baseUseCountForTest(), 2); + + copy.materialize(); /// NOW `copy` points at a fresh base of its own + EXPECT_EQ(original.baseUseCountForTest(), 1); + EXPECT_EQ(copy.baseUseCountForTest(), 1); +} + +/// =================================================================================== +/// Randomized exactness property test: RefCowMap must behave IDENTICALLY to +/// std::map across randomized op sequences (spec §Correctness & +/// testing: "random op sequences ... including copy-then-mutate isolation ... and +/// tombstone/override correctness on the merged iterator"). +/// =================================================================================== + +TEST(CASRefCowMap, PropertyMatchesStdMapOverRandomOps) +{ + std::mt19937 rng(20260717); // NOLINT(cert-msc32-c,cert-msc51-cpp): deterministic seed is required for reproducible property coverage. + + for (int trial = 0; trial < 50; ++trial) + { + RefCowMap actual; + std::map oracle; + + for (int step = 0; step < 200; ++step) + { + const String key = "ref" + std::to_string(rng() % 12); + const uint32_t action = rng() % 6; + switch (action) + { + case 0: /// emplace + { + RefCommittedRow r = row(1, static_cast(step) + 1, 1); + const bool oracle_inserted = oracle.emplace(key, r).second; + const bool actual_inserted = actual.emplace(key, r).second; + EXPECT_EQ(oracle_inserted, actual_inserted) << "trial " << trial << " step " << step; + break; + } + case 1: /// insert_or_assign + { + RefCommittedRow r = row(2, static_cast(step) + 1, 2); + oracle[key] = r; + actual.insert_or_assign(key, r); + break; + } + case 2: /// erase by key + { + const size_t oracle_erased = oracle.erase(key); + const size_t actual_erased = actual.erase(key); + EXPECT_EQ(oracle_erased, actual_erased) << "trial " << trial << " step " << step; + break; + } + case 3: /// find/contains/at (read-only) + { + EXPECT_EQ(oracle.contains(key), actual.contains(key)) << "trial " << trial << " step " << step; + if (oracle.contains(key)) + EXPECT_EQ(oracle.at(key), actual.at(key)) << "trial " << trial << " step " << step; + break; + } + case 4: /// erase via a found iterator + { + if (auto it = actual.find(key); it != actual.end()) + { + oracle.erase(key); + actual.erase(it); + } + break; + } + case 5: /// materialize -- must not change observable content + { + actual.materialize(); + break; + } + default: + UNREACHABLE(); + } + + ASSERT_EQ(oracle.size(), actual.size()) << "trial " << trial << " step " << step; + + auto oit = oracle.begin(); + auto ait = actual.begin(); + for (; oit != oracle.end() && ait != actual.end(); ++oit, ++ait) + { + ASSERT_EQ(oit->first, ait->first) << "trial " << trial << " step " << step; + ASSERT_EQ(oit->second, ait->second) << "trial " << trial << " step " << step; + } + ASSERT_TRUE(oit == oracle.end()) << "trial " << trial << " step " << step; + ASSERT_TRUE(ait == actual.end()) << "trial " << trial << " step " << step; + } + } +} + +/// =================================================================================== +/// Fast-vs-forced-slow materialize parity (E5 xhigh review): the in-place fold (uniquely-owned base) +/// and the build-fresh-and-swap fold (a copy still shares the base) must produce IDENTICAL merged +/// content, size, and empty overlay across randomized op sequences. This pins that the two code paths +/// -- which handle `net_delta`, tombstones, and overrides differently -- never diverge. +/// =================================================================================== + +TEST(CASRefCowMap, FastAndForcedSlowMaterializeAgreeOverRandomOps) +{ + std::mt19937 rng(20260722); // NOLINT(cert-msc32-c,cert-msc51-cpp): deterministic seed for reproducible coverage. + + for (int trial = 0; trial < 60; ++trial) + { + RefCowMap fast; /// never copied -> `materialize` always takes the in-place (uniquely-owned) path + RefCowMap slow; /// a live copy is held across each `materialize` -> forced fresh-base path + + for (int step = 0; step < 150; ++step) + { + const String key = "ref" + std::to_string(rng() % 10); + switch (rng() % 5) + { + case 0: + { + RefCommittedRow r = row(1, static_cast(step) + 1, 1); + fast.emplace(key, r); + slow.emplace(key, r); + break; + } + case 1: + { + RefCommittedRow r = row(2, static_cast(step) + 1, 2); + fast.insert_or_assign(key, r); + slow.insert_or_assign(key, r); + break; + } + case 2: + { + fast.erase(key); + slow.erase(key); + break; + } + case 3: /// materialize both, each via its intended path + { + ASSERT_EQ(fast.baseUseCountForTest(), 1) << "fast map must be uniquely owned"; + fast.materialize(); /// in-place fast path + { + RefCowMap pin = slow; /// shares slow's base + ASSERT_EQ(slow.baseUseCountForTest(), 2) << "slow map must be forced onto the copy path"; + slow.materialize(); /// build-fresh-and-swap slow path + } + EXPECT_EQ(fast.overlayEntriesForTest(), 0u) << "trial " << trial << " step " << step; + EXPECT_EQ(slow.overlayEntriesForTest(), 0u) << "trial " << trial << " step " << step; + break; + } + default: + break; /// accumulate overlay without materializing + } + + /// Content + size parity holds at EVERY step, materialized or not. + ASSERT_EQ(fast.size(), slow.size()) << "trial " << trial << " step " << step; + auto fi = fast.begin(); + auto si = slow.begin(); + for (; fi != fast.end() && si != slow.end(); ++fi, ++si) + { + ASSERT_EQ(fi->first, si->first) << "trial " << trial << " step " << step; + ASSERT_EQ(fi->second, si->second) << "trial " << trial << " step " << step; + } + ASSERT_TRUE(fi == fast.end() && si == slow.end()) << "trial " << trial << " step " << step; + } + + /// A final materialize of both via their two paths must leave identical, fully-folded state. + fast.materialize(); + { + RefCowMap pin = slow; + slow.materialize(); + } + EXPECT_EQ(fast.overlayEntriesForTest(), 0u) << "trial " << trial; + EXPECT_EQ(slow.overlayEntriesForTest(), 0u) << "trial " << trial; + EXPECT_TRUE(fast == slow) << "trial " << trial; + EXPECT_EQ(fast.size(), slow.size()) << "trial " << trial; + } +} diff --git a/src/Disks/tests/gtest_cas_ref_decode_bounds.cpp b/src/Disks/tests/gtest_cas_ref_decode_bounds.cpp new file mode 100644 index 000000000000..a2301f88bb08 --- /dev/null +++ b/src/Disks/tests/gtest_cas_ref_decode_bounds.cpp @@ -0,0 +1,136 @@ +#include + +#include +#include +#include +#include +#include +#include + +#include +#include + +namespace DB::ErrorCodes +{ +extern const int CORRUPTED_DATA; +} + +using namespace DB::Cas; +using DB::Cas::tests::expectThrowsCode; + +/// Stage-1 T11 (spec §3 "Byte limits: encode-side estimation machinery is what dies; the decode-side +/// cap stays"). Two closures: +/// +/// 1. `openObject`'s raw (uncompressed) arm skipped `object_cap` entirely -- only the zstd arm checked +/// the declared decompressed content size against it. A tolerated-unknown-field-padded or raw-body +/// object up to `object_cap` would decode as though it were within budget just because it skipped +/// compression. Fixed by gating the raw arm on the SAME cap. +/// 2. The writer's post-encode budget check (`checkBudget`, called from `encodeRefLogTxn`) must be a +/// real `if`+`throw` (CORRUPTED_DATA), never a debug-only `chassert` -- verified here, not +/// re-implemented (it was already a runtime throw as of stage-1 T8). + +namespace +{ + +/// A single `SetPublishedAt` op whose `ref_name` is padded so its own encoded size (`encodedOpSize`) +/// is exactly `target_bytes` -- same construction as `gtest_cas_ref_chunked_flush.cpp`'s helper of +/// the same shape (not shared: each test file owns its small fixture helpers). +RefOp paddedSetPublishedAtOp(size_t target_bytes) +{ + RefOp op; + op.kind = RefOpKind::SetPublishedAt; + op.ref_name = "r"; + op.expected_manifest_ref = ManifestRef{1, 1, 1}; + op.published_at_ms = 0; + const size_t base = encodedOpSize(op); + op.ref_name = "r" + String(target_bytes - base, 'a'); + return op; +} + +} + +/// --------------------------------------------------------------------------------------------- +/// `openObject`: object_cap must gate a raw (uncompressed) body exactly as it gates a zstd frame's +/// declared content size -- skipping compression must never also skip the size cap. +/// --------------------------------------------------------------------------------------------- + +TEST(CASRefDecodeBounds, RawOverCapObjectRejected) +{ + const FormatTraits & t = traitsFor(FormatId::RefLog); + ASSERT_NE(t.object_cap, 0u); + + /// A raw body strictly larger than the format's object cap. It carries no valid header at all -- + /// the raw arm returns bytes verbatim (or, once fixed, rejects them by size) before any JSON + /// parsing happens, so the content need not be well-formed. + const String oversized(t.object_cap + 1, 'x'); + expectThrowsCode(DB::ErrorCodes::CORRUPTED_DATA, [&] { openObject(FormatId::RefLog, oversized); }); +} + +TEST(CASRefDecodeBounds, RawAtCapObjectAccepted) +{ + /// The boundary itself must stay legal: exactly `object_cap` bytes, raw, still opens unchanged. + const FormatTraits & t = traitsFor(FormatId::RefLog); + const String at_cap(t.object_cap, 'x'); + EXPECT_EQ(openObject(FormatId::RefLog, at_cap), at_cap); +} + +/// --------------------------------------------------------------------------------------------- +/// `checkBudget` (decode side): the whole-object byte cap is measured over the ACTUAL decoded bytes, +/// not accumulated per-op, so padding smuggled through a tolerant unknown field is caught exactly like +/// padding smuggled through an oversized raw body. +/// --------------------------------------------------------------------------------------------- + +TEST(CASRefDecodeBounds, PaddedNormalTxnOver20MiBRejected) +{ + RefLogTxn txn; + txn.ns = "ns"; + txn.txn_id = RefTxnId{1, 1}; + + RefOp op; + op.kind = RefOpKind::SetPublishedAt; + op.ref_name = "r"; + op.expected_manifest_ref = ManifestRef{1, 1, 1}; + op.published_at_ms = 1; + txn.ops.push_back(op); + + const String text = encodeRefLogTxn(txn); + ASSERT_GE(text.size(), 2u); + ASSERT_EQ(text[text.size() - 1], '\n'); + ASSERT_EQ(text[text.size() - 2], '}'); + + /// Pad the trailer line with an unknown tolerant field ("zz") -- legal per the wire's evolution + /// policy (`skipUnknown`) -- inflating the decoded object well past `ref_txn_max_bytes` without + /// touching a single op line or the op count. Padding an op line would only trip the per-op cap + /// and prove nothing about this (much larger) whole-transaction bound. + constexpr size_t pad_bytes = ref_txn_max_bytes + (1 << 20); + String padded = text.substr(0, text.size() - 2); + padded += ",\"zz\":\"" + String(pad_bytes, 'A') + "\"}\n"; // NOLINT(modernize-raw-string-literal): mixes '\"' quoting with '\n' line endings across this concatenated literal; a raw string can't hold the newline as-is. + ASSERT_GT(padded.size(), ref_txn_max_bytes); + + expectThrowsCode(DB::ErrorCodes::CORRUPTED_DATA, [&] { decodeRefLogTxn(padded, txn.ns, txn.txn_id); }); +} + +/// --------------------------------------------------------------------------------------------- +/// Writer side: the post-encode budget check is a real `if`+`throw`, never a debug-only `chassert` -- +/// a release build must reject an over-cap encode, not silently persist it. +/// --------------------------------------------------------------------------------------------- + +TEST(CASRefDecodeBounds, WriterPostEncodeThrowIsRuntime) +{ + /// Constructed directly at the codec level -- bypassing the ledger's op-count admission gate + /// (`ref_txn_max_ops`) -- so the transaction's total encoded size alone drives the outcome: the + /// canonical writer can never reach this state through admission (at most `ref_txn_max_ops` ops at + /// `ref_op_max_bytes` each stays under `ref_txn_max_bytes`), but `encodeRefLogTxn`'s own post-encode + /// `checkBudget` call must still catch a direct over-cap construction as a real exception, not an + /// assert that a release build would silently skip. + RefLogTxn txn; + txn.ns = "ns"; + txn.txn_id = RefTxnId{1, 1}; + + constexpr size_t op_count = ref_txn_max_bytes / ref_op_max_bytes + 16; + txn.ops.reserve(op_count); + for (size_t i = 0; i < op_count; ++i) + txn.ops.push_back(paddedSetPublishedAtOp(ref_op_max_bytes)); + + expectThrowsCode(DB::ErrorCodes::CORRUPTED_DATA, [&] { encodeRefLogTxn(txn); }); +} diff --git a/src/Disks/tests/gtest_cas_ref_epoch_seal_format.cpp b/src/Disks/tests/gtest_cas_ref_epoch_seal_format.cpp new file mode 100644 index 000000000000..33d13ca10afa --- /dev/null +++ b/src/Disks/tests/gtest_cas_ref_epoch_seal_format.cpp @@ -0,0 +1,491 @@ +#include "cas_format_test_battery.h" +#include +#include +#include +#include +#include + +/// v3 text codec tests for the `EpochSeal` record kind + strict seal grammar added to `cas_ref_log` +/// (stage A task 1, spec INV-2). Split into its own file per the plan's "prefer NEW test files" +/// constraint, rather than extending `gtest_cas_ref_log_format.cpp`. Covers: the new op kind's round +/// trip (including the meta-line `prev_epoch_seal` field), the context-free structural grammar +/// (`validateEpochSealGrammarStructural`, run by both `encodeRefLogTxn` and `decodeRefLogTxn`), and +/// the contextual required-iff rule (`validateEpochSealGrammarContextual`, exercised directly against +/// explicit `life_epoch` values -- its writer-runtime call sites land in later tasks). + +using namespace DB::Cas; +using DB::Cas::tests::expectThrowsCode; + +namespace +{ + +RefOp epochSealOp() +{ + RefOp op; + op.kind = RefOpKind::EpochSeal; + return op; +} + +RefOp namespaceBirthOp() +{ + RefOp op; + op.kind = RefOpKind::NamespaceBirth; + return op; +} + +} + +/// =================================================================================== +/// refLogTxnIsEpochSeal / refLogTxnIsRemovalClass classification +/// =================================================================================== + +TEST(CASRefEpochSealFormat, IsEpochSealTrueForSoleSealOp) +{ + RefLogTxn txn; + txn.ops.push_back(epochSealOp()); + EXPECT_TRUE(refLogTxnIsEpochSeal(txn)); +} + +TEST(CASRefEpochSealFormat, IsEpochSealFalseForSealPlusOtherOp) +{ + RefLogTxn txn; + txn.ops.push_back(epochSealOp()); + txn.ops.push_back(namespaceBirthOp()); + EXPECT_FALSE(refLogTxnIsEpochSeal(txn)); +} + +TEST(CASRefEpochSealFormat, IsEpochSealFalseForNonSealOp) +{ + RefLogTxn txn; + txn.ops.push_back(namespaceBirthOp()); + EXPECT_FALSE(refLogTxnIsEpochSeal(txn)); +} + +TEST(CASRefEpochSealFormat, IsEpochSealFalseForEmptyOps) +{ + RefLogTxn txn; + EXPECT_FALSE(refLogTxnIsEpochSeal(txn)); +} + +/// Step 3's explicit regression note: an `EpochSeal`-only op vector is not removal-class. +TEST(CASRefEpochSealFormat, RemovalClassIsFalseForEpochSeal) +{ + std::vector ops{epochSealOp()}; + EXPECT_FALSE(refLogTxnIsRemovalClass(ops)); +} + +/// =================================================================================== +/// Round trip +/// =================================================================================== + +TEST(CASRefEpochSealFormat, RoundTripSealAtSequenceOneWithPrevEpochSeal) +{ + /// An empty dead epoch (3) closes with a sequence-1 seal, which is therefore itself required to + /// carry `prev_epoch_seal` chaining to the seal that closed epoch 2 (spec INV-2's grammar: required + /// on exactly sequence 1 of every epoch above genesis). + RefLogTxn txn; + txn.ns = "ns"; + txn.txn_id = RefTxnId{3, 1}; + txn.prev_epoch_seal = RefTxnId{2, 9}; + txn.ops.push_back(epochSealOp()); + + const String bytes = encodeRefLogTxn(txn); + const RefLogTxn decoded = decodeRefLogTxn(bytes, txn.ns, txn.txn_id); + EXPECT_EQ(decoded, txn); + ASSERT_TRUE(decoded.prev_epoch_seal.has_value()); + EXPECT_EQ(*decoded.prev_epoch_seal, (RefTxnId{2, 9})); + ASSERT_EQ(decoded.ops.size(), 1u); + EXPECT_EQ(decoded.ops[0].kind, RefOpKind::EpochSeal); +} + +TEST(CASRefEpochSealFormat, RoundTripSealWithoutPrevEpochSeal) +{ + /// The common case: epoch 2 had real records (greatest applied sequence 5), so its closing seal + /// lands at sequence 6 -- not sequence 1 -- and therefore must NOT carry `prev_epoch_seal`. + RefLogTxn txn; + txn.ns = "ns"; + txn.txn_id = RefTxnId{2, 6}; + txn.ops.push_back(epochSealOp()); + + const String bytes = encodeRefLogTxn(txn); + const RefLogTxn decoded = decodeRefLogTxn(bytes, txn.ns, txn.txn_id); + EXPECT_EQ(decoded, txn); + EXPECT_FALSE(decoded.prev_epoch_seal.has_value()); +} + +/// A re-encode of a decoded seal transaction is byte-identical (the encoder is a pure function of the +/// txn), matching the pin `gtest_cas_ref_log_format.cpp` keeps for the other op kinds. +TEST(CASRefEpochSealFormat, ByteIdenticalReencodeWithPrevEpochSeal) +{ + RefLogTxn txn; + txn.ns = "ns"; + txn.txn_id = RefTxnId{3, 1}; + txn.prev_epoch_seal = RefTxnId{2, 9}; + txn.ops.push_back(epochSealOp()); + + const String bytes1 = encodeRefLogTxn(txn); + const RefLogTxn decoded = decodeRefLogTxn(bytes1, txn.ns, txn.txn_id); + const String bytes2 = encodeRefLogTxn(decoded); + EXPECT_EQ(bytes1, bytes2); +} + +/// =================================================================================== +/// Structural grammar (validateEpochSealGrammarStructural, via encode/decode -- context-free) +/// =================================================================================== + +TEST(CASRefEpochSealFormat, EncodeRejectsSealTxnWithTwoSealOps) +{ + RefLogTxn txn; + txn.ns = "ns"; + txn.txn_id = RefTxnId{1, 1}; + txn.ops.push_back(epochSealOp()); + txn.ops.push_back(epochSealOp()); + expectThrowsCode(DB::ErrorCodes::CORRUPTED_DATA, [&] { encodeRefLogTxn(txn); }); +} + +TEST(CASRefEpochSealFormat, EncodeRejectsSealTxnWithSecondNonSealOp) +{ + RefLogTxn txn; + txn.ns = "ns"; + txn.txn_id = RefTxnId{1, 1}; + txn.ops.push_back(epochSealOp()); + txn.ops.push_back(namespaceBirthOp()); + expectThrowsCode(DB::ErrorCodes::CORRUPTED_DATA, [&] { encodeRefLogTxn(txn); }); +} + +/// Decode-side pin for the same op-count rule (review finding I1): `encodeRefLogTxn` can never +/// produce a 2-op seal body, so only a decode-only splice proves `decodeRefLogTxn` independently +/// re-derives the rule rather than trusting whatever the encoder produced -- deleting the structural +/// validator's call site inside `decodeRefLogTxn` would leave this the only failing test. +TEST(CASRefEpochSealFormat, DecodeRejectsSealTxnWithTwoOpsSpliced) +{ + RefLogTxn txn; + txn.ns = "ns"; + txn.txn_id = RefTxnId{2, 6}; + txn.ops.push_back(epochSealOp()); + const String bytes = encodeRefLogTxn(txn); + + const String op_line = "{\"op\":\"epoch_seal\"}\n"; + const auto op_pos = bytes.find(op_line); + ASSERT_NE(op_pos, String::npos); + String tampered = bytes; + tampered.insert(op_pos, op_line); /// two consecutive "epoch_seal" op lines now + + const String old_trailer = "{\"n\":1}\n"; + const auto trailer_pos = tampered.find(old_trailer); + ASSERT_NE(trailer_pos, String::npos); + tampered.replace(trailer_pos, old_trailer.size(), "{\"n\":2}\n"); /// keep the trailer honest + + expectThrowsCode(DB::ErrorCodes::CORRUPTED_DATA, [&] { decodeRefLogTxn(tampered, txn.ns, txn.txn_id); }); +} + +/// Decode-side pin for the same op-count rule, with a DIFFERENT second op kind -- proves the rule +/// rejects any companion op, not just a second `epoch_seal`. +TEST(CASRefEpochSealFormat, DecodeRejectsSealTxnWithSecondNonSealOpSpliced) +{ + RefLogTxn txn; + txn.ns = "ns"; + txn.txn_id = RefTxnId{2, 6}; + txn.ops.push_back(epochSealOp()); + const String bytes = encodeRefLogTxn(txn); + + const String op_line = "{\"op\":\"epoch_seal\"}\n"; + const auto op_pos = bytes.find(op_line); + ASSERT_NE(op_pos, String::npos); + String tampered = bytes; + tampered.insert(op_pos + op_line.size(), "{\"op\":\"namespace_birth\"}\n"); + + const String old_trailer = "{\"n\":1}\n"; + const auto trailer_pos = tampered.find(old_trailer); + ASSERT_NE(trailer_pos, String::npos); + tampered.replace(trailer_pos, old_trailer.size(), "{\"n\":2}\n"); + + expectThrowsCode(DB::ErrorCodes::CORRUPTED_DATA, [&] { decodeRefLogTxn(tampered, txn.ns, txn.txn_id); }); +} + +TEST(CASRefEpochSealFormat, EncodeRejectsPrevEpochSealAtNonUnitSequence) +{ + RefLogTxn txn; + txn.ns = "ns"; + txn.txn_id = RefTxnId{1, 2}; + txn.prev_epoch_seal = RefTxnId{1, 1}; + txn.ops.push_back(namespaceBirthOp()); + expectThrowsCode(DB::ErrorCodes::CORRUPTED_DATA, [&] { encodeRefLogTxn(txn); }); +} + +/// Decode-side pin for the sequence-1-only rule (review finding I1). `prev_epoch_seal`'s +/// writer_epoch (1) is strictly below the transaction's own (5), satisfying the I3 chain-direction +/// rule, so this isolates the sequence-1 rule specifically rather than incidentally also tripping I3. +TEST(CASRefEpochSealFormat, DecodeRejectsPrevEpochSealAtNonUnitSequenceSpliced) +{ + RefLogTxn txn; + txn.ns = "ns"; + txn.txn_id = RefTxnId{5, 2}; + txn.ops.push_back(namespaceBirthOp()); + const String bytes = encodeRefLogTxn(txn); + + const String needle = R"("rs":"2")"; + const auto pos = bytes.find(needle); + ASSERT_NE(pos, String::npos); + String tampered = bytes; + tampered.insert(pos + needle.size(), R"(,"!pse":"1","!pss":"1")"); + + expectThrowsCode(DB::ErrorCodes::CORRUPTED_DATA, [&] { decodeRefLogTxn(tampered, txn.ns, txn.txn_id); }); +} + +/// Well-formedness (review finding M2): a zero component inside `prev_epoch_seal` is rejected the +/// same way a zero component in the primary `txn_id` is (`checkRefTxnIdNonzero`, shared code path). +TEST(CASRefEpochSealFormat, EncodeRejectsPrevEpochSealWithZeroWriterEpoch) +{ + RefLogTxn txn; + txn.ns = "ns"; + txn.txn_id = RefTxnId{3, 1}; + txn.prev_epoch_seal = RefTxnId{0, 9}; + txn.ops.push_back(epochSealOp()); + expectThrowsCode(DB::ErrorCodes::CORRUPTED_DATA, [&] { encodeRefLogTxn(txn); }); +} + +TEST(CASRefEpochSealFormat, EncodeRejectsPrevEpochSealWithZeroRefSequence) +{ + RefLogTxn txn; + txn.ns = "ns"; + txn.txn_id = RefTxnId{3, 1}; + txn.prev_epoch_seal = RefTxnId{2, 0}; + txn.ops.push_back(epochSealOp()); + expectThrowsCode(DB::ErrorCodes::CORRUPTED_DATA, [&] { encodeRefLogTxn(txn); }); +} + +/// Decode-side splice: `prev_epoch_seal` present as only one of its two wire fields ("!pse" without +/// "!pss") -- a shape only reachable via corrupted bytes, since the encoder always writes both +/// together. Boundary-plus-one for the additive-field decode contract (Constraint 7). +TEST(CASRefEpochSealFormat, DecodeRejectsPrevEpochSealMissingPssComponent) +{ + RefLogTxn txn; + txn.ns = "ns"; + txn.txn_id = RefTxnId{3, 1}; + txn.prev_epoch_seal = RefTxnId{2, 9}; + txn.ops.push_back(epochSealOp()); + const String bytes = encodeRefLogTxn(txn); + + const String needle = R"(,"!pss":"9")"; + const auto pos = bytes.find(needle); + ASSERT_NE(pos, String::npos); + String tampered = bytes; + tampered.erase(pos, needle.size()); + + expectThrowsCode(DB::ErrorCodes::CORRUPTED_DATA, [&] { decodeRefLogTxn(tampered, txn.ns, txn.txn_id); }); +} + +/// Chain direction (review finding I3): a seal closing epoch E always has id `{E, T+1}`, and the +/// sequence-1 transaction in the next numeric epoch must name it. This remains context-free (a +/// property of one transaction), so it belongs in the structural half; Tasks 2/6 walk this pointer +/// backwards over untrusted decoded bodies and must not have to re-derive the rule themselves. +TEST(CASRefEpochSealFormat, EncodeRejectsPrevEpochSealPointingAtSameEpoch) +{ + /// Self-pointer: prev_epoch_seal names the SAME epoch this transaction is in. + RefLogTxn txn; + txn.ns = "ns"; + txn.txn_id = RefTxnId{5, 1}; + txn.prev_epoch_seal = RefTxnId{5, 3}; + txn.ops.push_back(epochSealOp()); + expectThrowsCode(DB::ErrorCodes::CORRUPTED_DATA, [&] { encodeRefLogTxn(txn); }); +} + +TEST(CASRefEpochSealFormat, EncodeRejectsPrevEpochSealPointingAtFutureEpoch) +{ + /// Forward-pointer: prev_epoch_seal names an epoch AFTER this transaction's own. + RefLogTxn txn; + txn.ns = "ns"; + txn.txn_id = RefTxnId{5, 1}; + txn.prev_epoch_seal = RefTxnId{9, 3}; + txn.ops.push_back(epochSealOp()); + expectThrowsCode(DB::ErrorCodes::CORRUPTED_DATA, [&] { encodeRefLogTxn(txn); }); +} + +/// INV-2 materializes every global writer epoch for an existing life. A sequence-1 transaction in +/// epoch E therefore chains to the seal of exactly E-1: accepting an older link would make an omitted +/// epoch look like a proved boundary and let a fold bypass its missing seal. +TEST(CASRefEpochSealFormat, EncodeRejectsPrevEpochSealSkippingImmediateEpoch) +{ + RefLogTxn txn; + txn.ns = "ns"; + txn.txn_id = RefTxnId{5, 1}; + txn.prev_epoch_seal = RefTxnId{3, 7}; + txn.ops.push_back(epochSealOp()); + + expectThrowsCode(DB::ErrorCodes::CORRUPTED_DATA, [&] { encodeRefLogTxn(txn); }); +} + +/// A damaged object bypasses the encoder, so the decoder must independently reject the same skipped +/// link before any GC or recovery walker can treat it as boundary evidence. +TEST(CASRefEpochSealFormat, DecodeRejectsPrevEpochSealSkippingImmediateEpochSpliced) +{ + RefLogTxn txn; + txn.ns = "ns"; + txn.txn_id = RefTxnId{5, 1}; + txn.ops.push_back(namespaceBirthOp()); + const String bytes = encodeRefLogTxn(txn); + + const String needle = R"("rs":"1")"; + const auto pos = bytes.find(needle); + ASSERT_NE(pos, String::npos); + String tampered = bytes; + tampered.insert(pos + needle.size(), R"(,"!pse":"3","!pss":"1")"); + + expectThrowsCode(DB::ErrorCodes::CORRUPTED_DATA, [&] { decodeRefLogTxn(tampered, txn.ns, txn.txn_id); }); +} + +/// Decode-side pin for the chain-direction rule (review finding I3): the encoder's own check would +/// refuse to produce this shape (the two Encode* tests above pin that direction), so a splice into an +/// otherwise-valid sequence-1 body proves decode re-derives the rule independently. +TEST(CASRefEpochSealFormat, DecodeRejectsPrevEpochSealPointingAtSameOrFutureEpochSpliced) +{ + RefLogTxn txn; + txn.ns = "ns"; + txn.txn_id = RefTxnId{5, 1}; + txn.ops.push_back(namespaceBirthOp()); + const String bytes = encodeRefLogTxn(txn); /// valid: sequence 1, no prev_epoch_seal + + const String needle = R"("rs":"1")"; + const auto pos = bytes.find(needle); + ASSERT_NE(pos, String::npos); + String tampered = bytes; + tampered.insert(pos + needle.size(), R"(,"!pse":"5","!pss":"1")"); /// self-pointer + + expectThrowsCode(DB::ErrorCodes::CORRUPTED_DATA, [&] { decodeRefLogTxn(tampered, txn.ns, txn.txn_id); }); +} + +/// =================================================================================== +/// Contextual grammar (validateEpochSealGrammarContextual, called directly against explicit +/// life_epoch values -- the writer-runtime call sites are wired by later tasks) +/// =================================================================================== + +TEST(CASRefEpochSealFormat, ContextualRejectsMissingPrevEpochSealWhenRequired) +{ + RefLogTxn txn; + txn.ns = "ns"; + txn.txn_id = RefTxnId{3, 1}; + txn.ops.push_back(namespaceBirthOp()); + /// life_epoch 1 < writer_epoch 3: a sequence-1 txn above genesis MUST carry prev_epoch_seal. + expectThrowsCode(DB::ErrorCodes::CORRUPTED_DATA, [&] { validateEpochSealGrammarContextual(txn, /*life_epoch=*/1); }); +} + +TEST(CASRefEpochSealFormat, ContextualRejectsPrevEpochSealWhenForbidden) +{ + RefLogTxn txn; + txn.ns = "ns"; + txn.txn_id = RefTxnId{5, 1}; + txn.prev_epoch_seal = RefTxnId{4, 3}; + txn.ops.push_back(namespaceBirthOp()); + /// life_epoch == writer_epoch == 5: this IS the namespace's genesis sequence-1 txn, so + /// prev_epoch_seal is forbidden -- there is no preceding epoch to chain to. + expectThrowsCode(DB::ErrorCodes::CORRUPTED_DATA, [&] { validateEpochSealGrammarContextual(txn, /*life_epoch=*/5); }); +} + +/// codex r2 finding 2: "genesis" is per-namespace. A namespace first born at global epoch 5 (not +/// epoch 1) appends {5, 1} with NO prev_epoch_seal -- that IS its genesis, not a transition. +TEST(CASRefEpochSealFormat, ContextualAllowsGenesisBirthAboveEpochOneWithoutPrevEpochSeal) +{ + RefLogTxn txn; + txn.ns = "ns"; + txn.txn_id = RefTxnId{5, 1}; + txn.ops.push_back(namespaceBirthOp()); + EXPECT_NO_THROW(validateEpochSealGrammarContextual(txn, /*life_epoch=*/5)); +} + +/// Review finding I2: the `ref_sequence != 1` early return is load-bearing for Task 4's encode call +/// site, which calls this on every txn it mints, including ordinary sequence->=2 transactions in a +/// post-transition epoch that legitimately carry no `prev_epoch_seal`. Pinned on both sides of the +/// life_epoch relation to prove the early return fires regardless of it. +TEST(CASRefEpochSealFormat, ContextualPassesThroughNonSequenceOneAboveLifeEpoch) +{ + RefLogTxn txn; + txn.ns = "ns"; + txn.txn_id = RefTxnId{3, 5}; + txn.ops.push_back(namespaceBirthOp()); + /// writer_epoch(3) > life_epoch(1): would be REQUIRED if this were sequence 1. + EXPECT_NO_THROW(validateEpochSealGrammarContextual(txn, /*life_epoch=*/1)); +} + +TEST(CASRefEpochSealFormat, ContextualPassesThroughNonSequenceOneAtOrBelowLifeEpoch) +{ + RefLogTxn txn; + txn.ns = "ns"; + txn.txn_id = RefTxnId{1, 5}; + txn.ops.push_back(namespaceBirthOp()); + /// writer_epoch(1) == life_epoch(1): would be FORBIDDEN-if-present if this were sequence 1. + EXPECT_NO_THROW(validateEpochSealGrammarContextual(txn, /*life_epoch=*/1)); +} + +/// =================================================================================== +/// Criticality of the prev_epoch_seal wire fields (review finding M4) +/// =================================================================================== + +/// `!pse`/`!pss` are `!`-prefixed CRITICAL keys: `prev_epoch_seal` is INV-2 chain evidence, and a +/// build that silently dropped it would still pass the structural grammar (absent field => no check) +/// while losing the chain link. Proven here by splicing in a DIFFERENT, genuinely-unrecognized +/// `!`-key (simulating a future critical field this build predates) rather than `!pse`/`!pss` +/// themselves, which this build DOES recognize: `JsonObjectReader::skipUnknown` rejects any +/// unrecognized `!`-prefixed key with `UNKNOWN_FORMAT_VERSION` (never a silent skip), so this pins +/// the general mechanism the meta-line reader relies on to keep `!pse`/`!pss` safe against a decoder +/// that doesn't (yet, or anymore) understand them. +TEST(CASRefEpochSealFormat, DecodeRejectsUnknownCriticalKeyInMetaLine) +{ + RefLogTxn txn; + txn.ns = "ns"; + txn.txn_id = RefTxnId{1, 1}; + txn.ops.push_back(namespaceBirthOp()); + const String bytes = encodeRefLogTxn(txn); + + const String needle = R"("rs":"1")"; + const auto pos = bytes.find(needle); + ASSERT_NE(pos, String::npos); + String tampered = bytes; + tampered.insert(pos + needle.size(), R"(,"!future_critical_field":"1")"); + + expectThrowsCode(DB::ErrorCodes::UNKNOWN_FORMAT_VERSION, [&] { decodeRefLogTxn(tampered, txn.ns, txn.txn_id); }); +} + +/// =================================================================================== +/// Regression guard: existing unknown-op-word behavior stays intact after adding "epoch_seal" +/// =================================================================================== + +TEST(CASRefEpochSealFormat, DecodeRejectsUnknownOpWordRegressionGuard) +{ + RefLogTxn txn; + txn.ns = "ns"; + txn.txn_id = RefTxnId{1, 1}; + txn.ops.push_back(epochSealOp()); + const String bytes = encodeRefLogTxn(txn); + + const String needle = "\"epoch_seal\""; + const auto pos = bytes.find(needle); + ASSERT_NE(pos, String::npos); + String tampered = bytes; + tampered.replace(pos, needle.size(), "\"totally_bogus_op\""); + + expectThrowsCode(DB::ErrorCodes::CORRUPTED_DATA, [&] { decodeRefLogTxn(tampered, txn.ns, txn.txn_id); }); +} + +/// =================================================================================== +/// Shape-level failure-mode battery (truncation / v+1 gate / wrong type / leading garbage) +/// =================================================================================== + +TEST(CASRefEpochSealFormat, FormatBatteryEpochSeal) +{ + RefLogTxn txn; + txn.ns = "ns"; + txn.txn_id = RefTxnId{3, 1}; + txn.prev_epoch_seal = RefTxnId{2, 9}; + txn.ops.push_back(epochSealOp()); + + const String ns = txn.ns; + const RefTxnId id = txn.txn_id; + runFormatBattery({FormatId::RefLog, + [txn] { return sealObject(FormatId::RefLog, encodeRefLogTxn(txn)); }, + [ns, id](std::string_view s) { decodeRefLogTxn(openObject(FormatId::RefLog, s), ns, id); }, + "{\"type\":\"cas_ref_log\",\"v\":9}\n" + "{\"ns\":\"ns\",\"we\":\"3\",\"rs\":\"1\",\"!pse\":\"2\",\"!pss\":\"9\"}\n" + "{\"op\":\"epoch_seal\"}\n" + "{\"n\":1}\n"}); +} diff --git a/src/Disks/tests/gtest_cas_ref_gc.cpp b/src/Disks/tests/gtest_cas_ref_gc.cpp new file mode 100644 index 000000000000..97d637982af6 --- /dev/null +++ b/src/Disks/tests/gtest_cas_ref_gc.cpp @@ -0,0 +1,987 @@ +#include + +#include +#include +#include +#include +#include +#include +#include +#include "cas_test_helpers.h" + +#include + +#include + +/// Task 12 required GC tests over the snapshot+log ref model (spec 2026-07-11-cas-ref-table-snapshot-log-design). +/// Every fixture produces REAL wire-format ref logs (via the writer or `writeRefLogTxnRaw`, never hand-rolled +/// bytes), and every test proves the fold actually consumed them (cursor advanced / nonzero in-degree), so a +/// silent no-op fold cannot pass vacuously. + +using namespace DB::Cas; +using namespace DB::Cas::tests; + +namespace DB::ErrorCodes +{ +extern const int CORRUPTED_DATA; +} + +namespace ProfileEvents +{ +extern const Event CASRefGlobalListPages; +extern const Event CASRefLogBodyGets; +extern const Event CASRefManifestBodyFoldGets; +extern const Event CASRefEmittedEdges; +extern const Event CASRefCleanupObjectsDeleted; +} + +namespace +{ +const UInt128 kGc = hexToU128("00000000000000000000000000000001"); +const UInt128 kGc2 = hexToU128("00000000000000000000000000000002"); + +ManifestRef mref(uint64_t seq, uint32_t ord = 1) +{ + return ManifestRef{.writer_epoch = 1, .build_sequence = seq, .manifest_ordinal = ord}; +} + +/// Append a committed-ref log at an EXPLICIT sequence (no per-call LIST) -- fast bulk seeding of a +/// >1000-key stream. The ops are replay-valid (birth on the first, then add-precommit + promote). +void seedCommittedAt( + Backend & backend, const Layout & layout, const RootNamespace & ns, uint64_t seq, + const String & ref_name, const ManifestRef & mr, bool birth) +{ + std::vector ops; + if (birth) + ops.push_back(namespaceBirthOp()); + const std::vector commit_ops = publishCommittedOps(ref_name, mr); + ops.insert(ops.end(), commit_ops.begin(), commit_ops.end()); + RefLogTxn txn; + txn.ns = ns.string(); + txn.txn_id = RefTxnId{1, seq}; + txn.ops = std::move(ops); + fixture::writeRefLogRaw(backend, layout, txn); +} + +/// Drive regular rounds, renewing the mount ack after each, until quiescent or `max_rounds`. +size_t runToFixpoint(const PoolPtr & s, Gc & gc, size_t max_rounds = 64) +{ + size_t rounds = 0; + for (; rounds < max_rounds; ++rounds) + { + const RoundReport rep = runRegularRoundReclaiming(gc); + if (!rep.acquired_lease) + continue; + s->renewWatermarkOnce(); + const bool no_work = rep.candidates == 0 && rep.deleted == 0 && rep.absent == 0 + && rep.replaced == 0 && rep.spared == 0; + if (no_work && !anyCondemnedInSeal(s->backend(), s->layout())) + break; + } + return rounds; +} + +bool blobPresent(Backend & b, const Layout & layout, const UInt128 & hash) +{ + return b.head(layout.blobKey(BlobRef{BlobHashAlgo::CityHash128, BlobDigest::fromU128(hash)})).exists; +} + +/// Denies ONCE the single round-commit `gc/state` CAS that advances `snap_generation` (the losing +/// leader deposed mid-round). The denied round leaves only never-adopted attempt-scoped debris. +class DeposeRoundCommitBackend : public InMemoryBackend +{ +public: + CasResult casPut(const String & key, const String & bytes, const std::optional & expected, + const ObjectMeta & meta) override + { + if (arm && key == "p/gc/state") + { + const auto stored = get(key); + const uint64_t stored_gen = stored ? decodeGcState(stored->bytes).snap_generation : 0; + if (decodeGcState(bytes).snap_generation > stored_gen) + { + arm = false; + throw DB::Exception(DB::ErrorCodes::CORRUPTED_DATA, + "test-injected: round-commit gc/state CAS denied (losing leader deposed mid-round)"); + } + } + return InMemoryBackend::casPut(key, bytes, expected, meta); + } + bool arm = false; +}; + +/// Moves one of the two authorities `cleanupRefObjects` must revalidate at a precise ref-log delete +/// boundary. The target object's own token is untouched, so only an authority check can refuse it. +class RefCleanupAuthorityRaceBackend : public CountingBackend +{ +public: + enum class Authority : uint8_t + { + Catalog, + GcFence, + }; + + enum class Timing : uint8_t + { + BeforeFirstDelete, + AfterFirstDelete, + }; + + void arm( + Authority authority_, Timing timing_, const Layout & layout, + const String & first_cleanup_key_) + { + authority = authority_; + timing = timing_; + catalog_key = layout.refCatalogKey(); + gc_state_key = layout.gcStateKey(); + first_cleanup_key = first_cleanup_key_; + armed = true; + } + + HeadResult head(const String & key) override + { + HeadResult result = CountingBackend::head(key); + if (armed && timing == Timing::BeforeFirstDelete && key == first_cleanup_key) + moveAuthority(); + return result; + } + + DeleteOutcome deleteExact(const String & key, const Token & token) override + { + DeleteOutcome result = CountingBackend::deleteExact(key, token); + if (armed && timing == Timing::AfterFirstDelete && key == first_cleanup_key) + moveAuthority(); + return result; + } + +private: + void moveAuthority() + { + armed = false; + const String & key = authority == Authority::Catalog ? catalog_key : gc_state_key; + const auto got = CountingBackend::get(key); + if (!got) + throw std::runtime_error("test-injected cleanup authority object is absent"); + + String bytes = got->bytes; + if (authority == Authority::GcFence) + { + GcState moved = decodeGcState(bytes); + ++moved.lease.seq; + bytes = encodeGcState(moved); + } + if (CountingBackend::casPut(key, bytes, got->token).outcome != CasOutcome::Committed) + throw std::runtime_error("test-injected cleanup authority move lost its CAS"); + } + + Authority authority = Authority::Catalog; + Timing timing = Timing::BeforeFirstDelete; + String catalog_key; + String gc_state_key; + String first_cleanup_key; + bool armed = false; +}; + +struct RefCleanupFixture +{ + String first_log_key; + String second_log_key; +}; + +RefCleanupFixture seedTwoCoveredLogs( + RefCleanupAuthorityRaceBackend & backend, const Layout & layout, + const RootNamespace & ns) +{ + fixture::admitLive(backend, layout, ns); + const ManifestRef r1 = mref(1); + const ManifestRef r2 = mref(2); + const ManifestRef r3 = mref(3); + writeManifestRaw(backend, layout, ns, r1, {blobEntryFor("a", DB::UInt128(1))}); + writeManifestRaw(backend, layout, ns, r2, {blobEntryFor("b", DB::UInt128(2))}); + writeManifestRaw(backend, layout, ns, r3, {blobEntryFor("c", DB::UInt128(3))}); + const uint64_t v1 = publishCommittedTransition(backend, layout, ns, "t1", std::nullopt, r1); + const uint64_t v2 = publishCommittedTransition(backend, layout, ns, "t2", std::nullopt, r2); + const uint64_t v3 = publishCommittedTransition(backend, layout, ns, "t3", std::nullopt, r3); + writeRefSnapshotRaw(backend, layout, + minimalLiveSnapshot(ns.string(), RefTxnId{1, v3}, + {committedRow("t1", r1), committedRow("t2", r2), committedRow("t3", r3)})); + replaceRecoverableCkptForRawFixture(backend, layout, ns, RefCkpt{ + .life_epoch = 1, + .committed_through = RefTxnId{1, v3}, + .checkpoint_snapshot_id = RefTxnId{1, v3}, + .last_epoch_seal = std::nullopt, + }); + const NamespaceLifeId life = fixture::fixtureLife(ns); + return { + .first_log_key = layout.refLogKey(life, RefTxnId{1, v1}), + .second_log_key = layout.refLogKey(life, RefTxnId{1, v2})}; +} +} + +/// (1) A >1000-key ref scan folds every pre-existing log exactly once: the cursor advances to the greatest +/// id and every referenced blob has in-degree exactly 1 (folded once, not skipped, not doubled). +TEST(CASRefGc, LargeRefScanFoldsEveryLogExactlyOnce) +{ + auto backend = std::make_shared(); + auto store = openPoolForTest(backend, /*gc_fold_max_defer_rounds*/ 0); + const Layout & layout = store->layout(); + const RootNamespace ns{"00/aa@cas@"}; + + constexpr uint64_t N = 1200; /// > 1000: forces multi-page LIST paging in the fold's global scan + for (uint64_t i = 1; i <= N; ++i) + { + const ManifestRef mr = mref(i); + writeManifestRaw(*backend, layout, ns, mr, {blobEntryFor("data", DB::UInt128(i))}); + seedCommittedAt(*backend, layout, ns, /*seq*/ i, "t" + std::to_string(i), mr, /*birth*/ i == 1); + } + writeRecoverableCkptForRawFixture( + *backend, layout, ns, + RefCkpt{.life_epoch = 1, .committed_through = RefTxnId{1, N}, + .checkpoint_snapshot_id = std::nullopt, .last_epoch_seal = std::nullopt}); + + Gc gc(store, kGc); + ASSERT_NO_THROW(gc.runRegularRound()); + + /// The durable cursor advanced to the greatest log id. + EXPECT_EQ(foldCursorOf(*backend, layout, ns, 0), N) + << "the fold must advance the per-table cursor to the greatest pre-existing log id"; + + /// Every referenced blob folded EXACTLY once (in-degree 1). Spot-check a spread across the >1000 set. + for (uint64_t i : {uint64_t{1}, uint64_t{2}, uint64_t{999}, uint64_t{1000}, uint64_t{1001}, N}) + EXPECT_EQ(inDegreeOf(*backend, layout, DB::UInt128(i)), 1) + << "blob " << i << " must be folded exactly once (not skipped, not doubled)"; +} + +/// (2) A concurrent log appended AFTER the round's scan has passed its table is NOT skipped: the sealed +/// cursor stays below it, and the next round folds it. +TEST(CASRefGc, ConcurrentLogAfterScanIsFoldedNextRound) +{ + auto backend = std::make_shared(); + auto store = openPoolForTest(backend, /*gc_fold_max_defer_rounds*/ 0); + const Layout & layout = store->layout(); + const RootNamespace ns{"00/aa@cas@"}; + + const ManifestRef r1 = mref(1); + writeManifestRaw(*backend, layout, ns, r1, {blobEntryFor("a", DB::UInt128(1))}); + const uint64_t v1 = publishCommittedTransition(*backend, layout, ns, "tbl", std::nullopt, r1); + + Gc gc(store, kGc); + gc.runRegularRound(); /// round 1 folds v1 + ASSERT_EQ(foldCursorOf(*backend, layout, ns, 0), v1); + ASSERT_EQ(inDegreeOf(*backend, layout, DB::UInt128(1)), 1); + + /// A NEW log lands after the round sealed its cursor at v1 (a concurrent writer). + const ManifestRef r2 = mref(2); + writeManifestRaw(*backend, layout, ns, r2, {blobEntryFor("b", DB::UInt128(2))}); + const uint64_t v2 = publishCommittedTransition(*backend, layout, ns, "tbl2", std::nullopt, r2); + ASSERT_GT(v2, v1); + + /// The sealed cursor is still v1 (< v2) -- the new log was never skipped past. + EXPECT_EQ(foldCursorOf(*backend, layout, ns, 0), v1) + << "a log that landed after the scan must remain below the durable cursor, never skipped"; + + gc.runRegularRound(); /// round 2 folds v2 + EXPECT_EQ(foldCursorOf(*backend, layout, ns, 0), v2); + EXPECT_EQ(inDegreeOf(*backend, layout, DB::UInt128(2)), 1) + << "the next round must fold the concurrently-appended log"; +} + +/// (3) Fold barrier: a live precommit whose manifest body is absent clamps the table cursor below its +/// log (an anomaly is recorded), then folds once the body appears. +TEST(CASRefGc, FoldBarrierClampsBelowMissingBodyThenFoldsOnAppear) +{ + auto backend = std::make_shared(); + auto store = openPoolForTest(backend, /*gc_fold_max_defer_rounds*/ 0); + const Layout & layout = store->layout(); + const RootNamespace ns{"00/aa@cas@"}; + + const ManifestRef pre = mref(7); + /// No writeManifestRaw for `pre`: its body is intentionally absent (the live precommit's barrier). + const uint64_t v = addPrecommitTransition(*backend, layout, ns, DB::UInt128(9), "part", std::nullopt, pre); + + Gc gc(store, kGc); + RoundReport report; + ASSERT_NO_THROW(report = gc.runRegularRound()); + EXPECT_TRUE(report.hasAnomaly(ns, /*shard*/0)) << "a missing live-precommit body must record an anomaly"; + EXPECT_LT(foldCursorOf(*backend, layout, ns, 0), v) + << "the barrier must clamp the durable cursor BELOW the bodiless-precommit log"; + EXPECT_EQ(inDegreeOf(*backend, layout, DB::UInt128(1)), 0); + + /// The body appears (the build finished staging): the next fold passes the barrier. + writeManifestRaw(*backend, layout, ns, pre, {blobEntryFor("p", DB::UInt128(1))}); + gc.runRegularRound(); + EXPECT_GE(foldCursorOf(*backend, layout, ns, 0), v) << "the barrier lifts once the body lands"; + EXPECT_EQ(inDegreeOf(*backend, layout, DB::UInt128(1)), 1); +} + +/// (4) Edge cancellation: a manifest added then removed across a batch nets to zero in-degree and the +/// exclusively-owned blob is reclaimed. +TEST(CASRefGc, EdgeCancellationAddThenRemoveReclaimsBlob) +{ + auto backend = std::make_shared(); + auto store = openPoolForTest(backend, /*gc_fold_max_defer_rounds*/ 0); + const Layout & layout = store->layout(); + const RootNamespace ns{"00/aa@cas@"}; + + const ManifestRef r = mref(1); + writeBlobBody(*backend, layout, DB::UInt128(1)); + writeManifestRaw(*backend, layout, ns, r, {blobEntryFor("a", DB::UInt128(1))}); + publishCommittedTransition(*backend, layout, ns, "tbl", std::nullopt, r); /// +1 for r's blob + dropRefTransition(*backend, layout, ns, "tbl", r); /// -1: the add is cancelled + + Gc gc(store, kGc); + ASSERT_TRUE(runToFixpoint(store, gc) < 64u) << "the add+remove batch must converge to a fixpoint"; + + EXPECT_EQ(inDegreeOf(*backend, layout, DB::UInt128(1)), 0) + << "an added-then-removed manifest nets to zero in-degree"; + EXPECT_FALSE(blobPresent(*backend, layout, DB::UInt128(1))) + << "the net-zero blob is reclaimed"; +} + +/// (5) A losing generation commit adopts nothing and deletes nothing: a round whose single round-commit +/// `gc/state` CAS is denied (deposed mid-round) must NOT advance the adopted (snap_generation, snap_attempt) +/// and must NOT delete the condemned-but-unadopted blob. Its fold seal is durable only under its OWN +/// never-adopted attempt (harmless debris). +TEST(CASRefGc, LosingGenerationCommitAdoptsNothingDeletesNothing) +{ + auto backend = std::make_shared(); + auto store = openPoolForTest(backend, /*gc_fold_max_defer_rounds*/ 0); + const Layout & layout = store->layout(); + const RootNamespace ns{"00/aa@cas@"}; + + const ManifestRef r = mref(1); + writeBlobBody(*backend, layout, DB::UInt128(1)); + writeManifestRaw(*backend, layout, ns, r, {blobEntryFor("a", DB::UInt128(1))}); + publishCommittedTransition(*backend, layout, ns, "tbl", std::nullopt, r); + + Gc gc(store, kGc); + gc.runRegularRound(); /// round 1: folds the +1 and adopts it cleanly + store->renewWatermarkOnce(); + const auto adopted = decodeGcState(backend->get(layout.gcStateKey())->bytes); + ASSERT_GT(adopted.snap_generation, 0u); + + /// Drop the ref, then run the round whose commit is DENIED (losing leader). + dropRefTransition(*backend, layout, ns, "tbl", r); + backend->arm = true; + EXPECT_ANY_THROW(gc.runRegularRound()); + backend->arm = false; + + /// The deposed round adopted NOTHING: the durable pointers are unchanged... + const auto after = decodeGcState(backend->get(layout.gcStateKey())->bytes); + EXPECT_EQ(after.snap_generation, adopted.snap_generation) + << "a denied round-commit CAS must not advance the adopted generation"; + EXPECT_EQ(after.snap_attempt, adopted.snap_attempt); + /// ...and it deleted NOTHING: the blob its unadopted fold condemned is still present. + EXPECT_TRUE(blobPresent(*backend, layout, DB::UInt128(1))) + << "a losing generation commit must never delete a blob against an unadopted fold"; +} + +/// (6) Ref-object cleanup trusts only a checkpoint-named recovery triple: an older `_log` and `_snap` +/// are deleted after the durable cursor reaches them, while that triple remains intact. +TEST(CASRefGc, RefObjectCleanupRetainsCheckpointNamedTriple) +{ + auto backend = std::make_shared(); + auto store = openPoolForTest(backend, /*gc_fold_max_defer_rounds*/ 0); + const Layout & layout = store->layout(); + const RootNamespace ns{"00/aa@cas@"}; + fixture::admitLive(*backend, store->layout(), ns); /// Stage B (Task 4-C): pin to the sentinel before the first real touch + + /// Two committed publishes -> logs {1,1} and {1,2}. + const ManifestRef r1 = mref(1); + const ManifestRef r2 = mref(2); + writeManifestRaw(*backend, layout, ns, r1, {blobEntryFor("a", DB::UInt128(1))}); + writeManifestRaw(*backend, layout, ns, r2, {blobEntryFor("b", DB::UInt128(2))}); + const uint64_t v1 = publishCommittedTransition(*backend, layout, ns, "t1", std::nullopt, r1); + const uint64_t v2 = publishCommittedTransition(*backend, layout, ns, "t2", std::nullopt, r2); + + /// Two observed snapshots: an OLD one covering only v1, and the NEWEST covering v2. Both are real + /// wire-format snapshot objects (the recovery codec reads them). + RefTableSnapshot old_snap = minimalLiveSnapshot(ns.string(), RefTxnId{1, v1}, + {committedRow("t1", r1)}); + RefTableSnapshot new_snap = minimalLiveSnapshot(ns.string(), RefTxnId{1, v2}, + {committedRow("t1", r1), committedRow("t2", r2)}); + writeRefSnapshotRaw(*backend, layout, old_snap); + writeRefSnapshotRaw(*backend, layout, new_snap); + replaceRecoverableCkptForRawFixture(*backend, layout, ns, RefCkpt{ + .life_epoch = 1, + .committed_through = RefTxnId{1, v2}, + .checkpoint_snapshot_id = RefTxnId{1, v2}, + .last_epoch_seal = std::nullopt, + }); + + const String log_v1_key = layout.refLogKey(fixture::fixtureLife(ns), RefTxnId{1, v1}); + const String log_v2_key = layout.refLogKey(fixture::fixtureLife(ns), RefTxnId{1, v2}); + const String old_snap_key = layout.refSnapshotKey(fixture::fixtureLife(ns), RefTxnId{1, v1}); + const String new_snap_key = layout.refSnapshotKey(fixture::fixtureLife(ns), RefTxnId{1, v2}); + ASSERT_TRUE(backend->head(log_v1_key).exists); + ASSERT_TRUE(backend->head(log_v2_key).exists); + ASSERT_TRUE(backend->head(old_snap_key).exists); + + Gc gc(store, kGc); + runToFixpoint(store, gc); /// folds v1,v2 (cursor -> v2) then cleans covered ref objects post-CAS + + /// The old log lies below both the durable cursor and the validated checkpoint base => DELETED. + EXPECT_FALSE(backend->head(log_v1_key).exists) + << "a log below the checkpoint-named snapshot base and durable cursor must be deleted"; + /// The same-id ordinary log is part of recovery's triple and must survive. + EXPECT_TRUE(backend->head(log_v2_key).exists) + << "the checkpoint-named non-seal log must survive with its snapshot"; + /// The older snapshot is deleted; the checkpoint-named snapshot is retained. + EXPECT_FALSE(backend->head(old_snap_key).exists) << "an older snapshot must be deleted"; + EXPECT_TRUE(backend->head(new_snap_key).exists) << "the checkpoint-named snapshot must be retained"; +} + +/// `cleanupRefObjects`'s per-round cap. Five deletable logs share one +/// namespace with a tiny `gc_round_ref_cleanup_budget`; the per-key fail-close validation +/// (`deleteRefObject`'s catalog/lease revalidation before every exact delete) is untouched -- it is +/// NOT amortized, only the cohort size per round is capped. `planRefCleanup` recomputes the same +/// remaining candidates from durable state every round, so the excess needs no cursor of its own. +TEST(CASRefGc, RefObjectCleanupRespectsRoundBudgetAndConvergesAcrossRounds) +{ + auto backend = std::make_shared(); + auto store = Pool::open(backend, PoolConfig{ + .pool_prefix = "p", .server_root_id = "test", + .gc_round_ref_cleanup_budget = 1, + .gc_fold_max_defer_rounds = 0}); + const Layout & layout = store->layout(); + const RootNamespace ns{"00/aa@cas@"}; + fixture::admitLive(*backend, layout, ns); + + /// Six sequential replacements of the SAME ref -> six committed logs {1,1}..{1,6}. + constexpr int kLogs = 6; + std::optional prev; + ManifestRef latest{}; + uint64_t last_seq = 0; + for (int i = 1; i <= kLogs; ++i) + { + const ManifestRef r = mref(i); + writeManifestRaw(*backend, layout, ns, r, {blobEntryFor("a" + std::to_string(i), DB::UInt128(static_cast(i)))}); + last_seq = publishCommittedTransition(*backend, layout, ns, "t", prev, r); + prev = r; + latest = r; + } + + /// A snapshot + checkpoint naming the LATEST row: every earlier log is below the checkpoint base. + RefTableSnapshot snap = minimalLiveSnapshot(ns.string(), RefTxnId{1, last_seq}, {committedRow("t", latest)}); + writeRefSnapshotRaw(*backend, layout, snap); + replaceRecoverableCkptForRawFixture(*backend, layout, ns, RefCkpt{ + .life_epoch = 1, + .committed_through = RefTxnId{1, last_seq}, + .checkpoint_snapshot_id = RefTxnId{1, last_seq}, + .last_epoch_seal = std::nullopt, + }); + + std::vector deletable_log_keys; + for (int i = 1; i < kLogs; ++i) /// {1,1}..{1,5}: strictly below the checkpoint base, hence deletable + deletable_log_keys.push_back(layout.refLogKey(fixture::fixtureLife(ns), RefTxnId{1, static_cast(i)})); + + Gc gc(store, kGc); + auto countSurviving = [&] + { + size_t n = 0; + for (const String & k : deletable_log_keys) + if (backend->head(k).exists) + ++n; + return n; + }; + ASSERT_EQ(countSurviving(), deletable_log_keys.size()) + << "nothing cleaned before the first round even runs"; + + /// The SAME round that folds the whole tail also runs post-CAS cleanup, and with + /// `gc_round_ref_cleanup_budget = 1` deletes exactly one of the five deletable candidates. + runRegularRoundReclaiming(gc); + EXPECT_EQ(countSurviving(), deletable_log_keys.size() - 1) + << "a round with gc_round_ref_cleanup_budget=1 must delete exactly one ref object"; + + /// Repeated budgeted rounds converge: the whole deletable tail eventually drains, none stranded. + for (int i = 0; i < 10 && countSurviving() > 0; ++i) + runRegularRoundReclaiming(gc); + EXPECT_EQ(countSurviving(), 0u) + << "the whole deletable tail must eventually drain under repeated budgeted rounds"; +} + +TEST(CASRefGc, RefObjectCleanupRetainsCheckpointPredecessorSealProof) +{ + auto backend = std::make_shared(); + auto store = openPoolForTest(backend, /*gc_fold_max_defer_rounds*/ 0); + const Layout & layout = store->layout(); + const RootNamespace ns{"00/cross-epoch-cleanup@cas@"}; + fixture::admitLive(*backend, layout, ns); + const NamespaceLifeId life = fixture::fixtureLife(ns); + const RefTxnId birth_id{1, 1}; + const RefTxnId seal_id{1, 2}; + const RefTxnId base_id{2, 1}; + + const RefLogTxn birth{ + .ns = ns.string(), + .txn_id = birth_id, + .ops = {namespaceBirthOp()}, + .prev_epoch_seal = std::nullopt}; + RefOp seal_op; + seal_op.kind = RefOpKind::EpochSeal; + const RefLogTxn seal{ + .ns = ns.string(), + .txn_id = seal_id, + .ops = {std::move(seal_op)}, + .prev_epoch_seal = std::nullopt}; + const RefLogTxn base{ + .ns = ns.string(), + .txn_id = base_id, + .ops = {}, + .prev_epoch_seal = seal_id}; + fixture::writeRefLogRaw(*backend, layout, birth); + fixture::writeRefLogRaw(*backend, layout, seal); + fixture::writeRefLogRaw(*backend, layout, base); + + RefTableState state; + applyRefLogTxn(state, birth); + writeRefSnapshotRaw(*backend, layout, snapshotOf(state, ns.string())); + applyRefLogTxn(state, seal); + applyRefLogTxn(state, base); + writeRefSnapshotRaw(*backend, layout, snapshotOf(state, ns.string())); + writeRecoverableCkptForRawFixture(*backend, layout, ns, RefCkpt{ + .life_epoch = 1, + .committed_through = base_id, + .checkpoint_snapshot_id = base_id, + .last_epoch_seal = seal_id}); + + Gc gc(store, kGc); + runToFixpoint(store, gc); + + EXPECT_TRUE(backend->head(layout.refLogKey(life, seal_id)).exists) + << "cleanup must retain the predecessor seal that proves the checkpoint base's epoch transition"; + const CasRefCatalog::Snapshot cut = CasRefCatalog::read(*backend, layout); + const auto entry = std::find_if(cut.catalog.entries.begin(), cut.catalog.entries.end(), + [&](const CatalogEntry & candidate) { return candidate.ns == ns; }); + ASSERT_NE(entry, cut.catalog.entries.end()); + const std::optional checkpoint = readCkpt(*backend, layout, life); + ASSERT_TRUE(checkpoint); + EXPECT_NO_THROW((void)recoverRefTableDetailedFromAuthority(*backend, layout, *entry, checkpoint->ckpt)); +} + +TEST(CASRefGcCleanupAuthority, CatalogTokenMoveBeforeFirstDeleteRefusesEveryRefObjectDelete) +{ + auto backend = std::make_shared(); + auto store = openPoolForTest(backend, /*gc_fold_max_defer_rounds*/ 0); + const Layout & layout = store->layout(); + const RefCleanupFixture keys = seedTwoCoveredLogs(*backend, layout, RootNamespace{"00/aa@cas@"}); + backend->arm( + RefCleanupAuthorityRaceBackend::Authority::Catalog, + RefCleanupAuthorityRaceBackend::Timing::BeforeFirstDelete, layout, keys.first_log_key); + + Gc gc(store, kGc); + ASSERT_TRUE(runRegularRoundReclaiming(gc).acquired_lease); + + EXPECT_TRUE(backend->head(keys.first_log_key).exists); + EXPECT_TRUE(backend->head(keys.second_log_key).exists); + EXPECT_EQ(backend->deleteCount(keys.first_log_key), 0u); + EXPECT_EQ(backend->deleteCount(keys.second_log_key), 0u); +} + +TEST(CASRefGcCleanupAuthority, CatalogTokenMoveBetweenKeysAllowsFirstAndRefusesSecondDelete) +{ + auto backend = std::make_shared(); + auto store = openPoolForTest(backend, /*gc_fold_max_defer_rounds*/ 0); + const Layout & layout = store->layout(); + const RefCleanupFixture keys = seedTwoCoveredLogs(*backend, layout, RootNamespace{"00/aa@cas@"}); + backend->arm( + RefCleanupAuthorityRaceBackend::Authority::Catalog, + RefCleanupAuthorityRaceBackend::Timing::AfterFirstDelete, layout, keys.first_log_key); + + Gc gc(store, kGc); + ASSERT_TRUE(runRegularRoundReclaiming(gc).acquired_lease); + + EXPECT_FALSE(backend->head(keys.first_log_key).exists); + EXPECT_TRUE(backend->head(keys.second_log_key).exists); + EXPECT_EQ(backend->deleteCount(keys.first_log_key), 1u); + EXPECT_EQ(backend->deleteCount(keys.second_log_key), 0u); +} + +TEST(CASRefGcCleanupAuthority, GcFenceMoveBeforeFirstDeleteRefusesEveryRefObjectDelete) +{ + auto backend = std::make_shared(); + auto store = openPoolForTest(backend, /*gc_fold_max_defer_rounds*/ 0); + const Layout & layout = store->layout(); + const RefCleanupFixture keys = seedTwoCoveredLogs(*backend, layout, RootNamespace{"00/aa@cas@"}); + backend->arm( + RefCleanupAuthorityRaceBackend::Authority::GcFence, + RefCleanupAuthorityRaceBackend::Timing::BeforeFirstDelete, layout, keys.first_log_key); + + Gc gc(store, kGc); + ASSERT_TRUE(runRegularRoundReclaiming(gc).acquired_lease); + + EXPECT_TRUE(backend->head(keys.first_log_key).exists); + EXPECT_TRUE(backend->head(keys.second_log_key).exists); + EXPECT_EQ(backend->deleteCount(keys.first_log_key), 0u); + EXPECT_EQ(backend->deleteCount(keys.second_log_key), 0u); +} + +TEST(CASRefGcCleanupAuthority, GcFenceMoveBetweenKeysAllowsFirstAndRefusesSecondDelete) +{ + auto backend = std::make_shared(); + auto store = openPoolForTest(backend, /*gc_fold_max_defer_rounds*/ 0); + const Layout & layout = store->layout(); + const RefCleanupFixture keys = seedTwoCoveredLogs(*backend, layout, RootNamespace{"00/aa@cas@"}); + backend->arm( + RefCleanupAuthorityRaceBackend::Authority::GcFence, + RefCleanupAuthorityRaceBackend::Timing::AfterFirstDelete, layout, keys.first_log_key); + + Gc gc(store, kGc); + ASSERT_TRUE(runRegularRoundReclaiming(gc).acquired_lease); + + EXPECT_FALSE(backend->head(keys.first_log_key).exists); + EXPECT_TRUE(backend->head(keys.second_log_key).exists); + EXPECT_EQ(backend->deleteCount(keys.first_log_key), 1u); + EXPECT_EQ(backend->deleteCount(keys.second_log_key), 0u); +} + +/// Task 13 (spec §implementation-impact / §GC Budget): one fold+clean round increments every ref-intake +/// observability counter -- global LIST pages (Q), log-body GETs (K), manifest-body fold GETs (H), emitted +/// manifest edges, and cleaned old ref objects (D). Before/after deltas prove each site actually fires. +TEST(CASRefGc, RefIntakeIncrementsObservabilityCounters) +{ + using ProfileEvents::global_counters; + const auto list_pages_before = global_counters[ProfileEvents::CASRefGlobalListPages].load(); + const auto log_gets_before = global_counters[ProfileEvents::CASRefLogBodyGets].load(); + const auto mf_gets_before = global_counters[ProfileEvents::CASRefManifestBodyFoldGets].load(); + const auto edges_before = global_counters[ProfileEvents::CASRefEmittedEdges].load(); + const auto cleaned_before = global_counters[ProfileEvents::CASRefCleanupObjectsDeleted].load(); + + auto backend = std::make_shared(); + auto store = openPoolForTest(backend, /*gc_fold_max_defer_rounds*/ 0); + const Layout & layout = store->layout(); + const RootNamespace ns{"00/aa@cas@"}; + + const ManifestRef r1 = mref(1); + const ManifestRef r2 = mref(2); + writeManifestRaw(*backend, layout, ns, r1, {blobEntryFor("a", DB::UInt128(1))}); + writeManifestRaw(*backend, layout, ns, r2, {blobEntryFor("b", DB::UInt128(2))}); + const uint64_t v1 = publishCommittedTransition(*backend, layout, ns, "t1", std::nullopt, r1); + const uint64_t v2 = publishCommittedTransition(*backend, layout, ns, "t2", std::nullopt, r2); + /// A checkpoint-named snapshot base makes older listed objects eligible for cleanup once folded. + writeRefSnapshotRaw(*backend, layout, + minimalLiveSnapshot(ns.string(), RefTxnId{1, v2}, {committedRow("t1", r1), committedRow("t2", r2)})); + replaceRecoverableCkptForRawFixture(*backend, layout, ns, RefCkpt{ + .life_epoch = 1, + .committed_through = RefTxnId{1, v2}, + .checkpoint_snapshot_id = RefTxnId{1, v2}, + .last_epoch_seal = std::nullopt, + }); + (void)v1; + + Gc gc(store, kGc); + runToFixpoint(store, gc); + + EXPECT_GT(global_counters[ProfileEvents::CASRefGlobalListPages].load(), list_pages_before); + EXPECT_GT(global_counters[ProfileEvents::CASRefLogBodyGets].load(), log_gets_before); + EXPECT_GT(global_counters[ProfileEvents::CASRefManifestBodyFoldGets].load(), mf_gets_before); + EXPECT_GT(global_counters[ProfileEvents::CASRefEmittedEdges].load(), edges_before); + EXPECT_GT(global_counters[ProfileEvents::CASRefCleanupObjectsDeleted].load(), cleaned_before); +} + +/// Task 13 e2e (in-process regression twin of the rustfs integration test): the whole snapshot+log +/// lifecycle over real wire-format objects and real GC rounds -- publish committed refs across two +/// tables, replace one (dropping a blob), publish a covering snapshot, drive GC to a fixpoint, and +/// assert the fold + ref-object cleanup + snapshot lifecycle plus the two read-only consumers: +/// `runFsck(*store).clean()` (the fsck CLI's verdict, oracle included) and `gc.previewDeletes().empty()` +/// (what `cas-gc-dryrun` reports). This is the deterministic permanent twin the unit sweep keeps running. +TEST(CASRefGc, RefSnaplogLifecycleE2E) +{ + auto backend = std::make_shared(); + auto store = openPoolForTest(backend, /*gc_fold_max_defer_rounds*/ 0); + const Layout & layout = store->layout(); + const RootNamespace ns_a{"00/aa@cas@"}; + fixture::admitLive(*backend, store->layout(), ns_a); /// Stage B (Task 4-C): pin to the sentinel before the first real touch + const RootNamespace ns_b{"00/bb@cas@"}; + + /// Two tables with committed refs naming present manifests + blobs (insert-like). ns_a's ref is then + /// re-published to a second manifest, dropping the first manifest's blob (a replace: -1 old, +1 new). + const ManifestRef a1 = mref(1); + const ManifestRef a2 = mref(2); + const ManifestRef b1 = mref(3); + writeBlobBody(*backend, layout, DB::UInt128(1)); + writeBlobBody(*backend, layout, DB::UInt128(2)); + writeBlobBody(*backend, layout, DB::UInt128(3)); + writeManifestRaw(*backend, layout, ns_a, a1, {blobEntryFor("a", DB::UInt128(1))}); + writeManifestRaw(*backend, layout, ns_a, a2, {blobEntryFor("a", DB::UInt128(2))}); + writeManifestRaw(*backend, layout, ns_b, b1, {blobEntryFor("b", DB::UInt128(3))}); + const uint64_t va1 = publishCommittedTransition(*backend, layout, ns_a, "t", std::nullopt, a1); + const uint64_t va2 = publishCommittedTransition(*backend, layout, ns_a, "t", a1, a2); /// replace a1 -> a2 + publishCommittedTransition(*backend, layout, ns_b, "t", std::nullopt, b1); + /// The semantic transition helper has already published the exact CTE for each life. + + /// The writer's compaction: a snapshot of ns_a covering its greatest log (va2), the same + /// deterministic bytes the oracle recomputes. + const CasRefCatalog::Snapshot catalog_cut = CasRefCatalog::read(*backend, layout); + const RefTableState sa = recoverRefTableDetailedAtCatalogCutForTest(*backend, layout, catalog_cut, ns_a).state; + writeRefSnapshotRaw(*backend, layout, snapshotOf(sa, ns_a.string())); + const NamespaceLifeId life_a = store->namespaceLife(ns_a); + const CkptSample before_snapshot_publish = *readCkpt(*backend, layout, life_a); + RefCkpt after_snapshot_publish = before_snapshot_publish.ckpt; + after_snapshot_publish.checkpoint_snapshot_id = RefTxnId{1, va2}; + ASSERT_EQ(backend->casPut( + layout.refCkptKey(life_a), encodeRefCkpt(after_snapshot_publish), before_snapshot_publish.token).outcome, + CasOutcome::Committed); + + Gc gc(store, kGc); + runToFixpoint(store, gc); + + /// Snapshot lifecycle: the covering snapshot is retained; the covered logs (folded + snapshot-covered) + /// are cleaned; the replaced manifest's blob is reclaimed while the live blobs survive. + EXPECT_TRUE(backend->head(layout.refSnapshotKey(fixture::fixtureLife(ns_a), RefTxnId{1, va2})).exists) + << "covering snapshot retained"; + EXPECT_FALSE(backend->head(layout.refLogKey(fixture::fixtureLife(ns_a), RefTxnId{1, va1})).exists) << "covered log cleaned"; + EXPECT_FALSE(blobPresent(*backend, layout, DB::UInt128(1))) << "replaced blob reclaimed"; + EXPECT_TRUE(blobPresent(*backend, layout, DB::UInt128(2))) << "live blob survives"; + EXPECT_TRUE(blobPresent(*backend, layout, DB::UInt128(3))) << "other table's blob survives"; + + /// Read-only consumers agree: fsck recovers through the exact checkpoint base and reports no dangle, + /// while cas-gc-dryrun has no pending content deletes. Covered LIST debris is not diagnostic authority. + const FsckReport rep = runFsck(*store, /*detail*/true); + EXPECT_TRUE(rep.clean()); + EXPECT_EQ(rep.dangling, 0u); + EXPECT_TRUE(gc.previewDeletes().empty()) << "cas-gc-dryrun equivalent: no pending content deletes"; +} + +/// (8) A malformed/adversarial ref key aborts ref folding for the round: no partial delta, no cursor +/// advance. The malformed key is a real object under `cas/ns/stream/` whose `RefTxnId` render is invalid. +TEST(CASRefGc, MalformedRefKeyAbortsRefFoldingNoPartialDelta) +{ + auto backend = std::make_shared(); + auto store = openPoolForTest(backend, /*gc_fold_max_defer_rounds*/ 0); + const Layout & layout = store->layout(); + const RootNamespace ns{"00/aa@cas@"}; + + const ManifestRef r = mref(1); + writeManifestRaw(*backend, layout, ns, r, {blobEntryFor("a", DB::UInt128(1))}); + publishCommittedTransition(*backend, layout, ns, "tbl", std::nullopt, r); + /// The semantic transition helper has already published the exact CTE. + + /// Plant a malformed ref key under the ref prefix (a `_log` with a non-canonical id render). + const NamespaceLifeId life = store->namespaceLife(ns); + backend->putIfAbsent(layout.namespaceStreamPrefix(life) + "_log/not-a-valid-txn-id", "garbage"); + + Gc gc(store, kGc); + /// The fold's `groupRefKeys` rejects the unrecognized key and ABORTS ref folding for the round (spec + /// §Step 2: a malformed key cannot produce a partial ref delta or authorize destructive work). The + /// round CATCHES this internally and survives -- it must not propagate, and must not fold anything. + ASSERT_NO_THROW(gc.runRegularRound()); + + /// No partial delta, no cursor advance: the valid log's blob was NOT folded. + EXPECT_EQ(inDegreeOf(*backend, layout, DB::UInt128(1)), 0) + << "a malformed ref key must abort the round before any partial ref delta lands"; + EXPECT_EQ(foldCursorOf(*backend, layout, ns, 0), 0u) + << "the durable cursor must not advance on an aborted round"; +} + +/// (8b) A non-canonical physical life segment is the OTHER way a ref key can be malformed, and it must +/// land on exactly the path (8) pins -- abort ref folding, record the anomaly, COMPLETE the round. +/// +/// It gets its own test because the failure mode is worse than a lost round. The parser REFUSES this +/// shape by name rather than returning `std::nullopt`, so it is the one malformed key that can throw +/// from the round's global `cas/ns/stream/` enumeration, which runs in `defer_decision` -- before the fold, +/// and outside the fold's catch. Escaping there does not merely fail one round: GC is the only thing +/// that could ever delete the key, so a round that dies on it dies on it again every time, forever. +/// The enumeration must therefore absorb the refusal per key and leave the key unindexed in +/// `scan.keys`, exactly as it already does for every other malformed shape, and let `groupRefKeys` +/// raise it once where the round is ready to catch it. +TEST(CASRefGc, NonCanonicalLifeKeyAbortsRefFoldingWithoutWedgingTheRound) +{ + auto backend = std::make_shared(); + auto store = openPoolForTest(backend, /*gc_fold_max_defer_rounds*/ 0); + const Layout & layout = store->layout(); + const RootNamespace ns{"00/aa@cas@"}; + + const ManifestRef r = mref(1); + writeManifestRaw(*backend, layout, ns, r, {blobEntryFor("a", DB::UInt128(1))}); + publishCommittedTransition(*backend, layout, ns, "tbl", std::nullopt, r); + + /// A ref log whose supposed life segment contains logical namespace text rather than one canonical + /// opaque id. Only a foreign or corrupt writer can put this key here, and the pool must survive it. + const String noncanonical_life = + layout.casRefsPrefix() + ns.string() + "/_log/" + renderRefTxnId(RefTxnId{1, 1}) + ".zst"; + ASSERT_EQ(backend->putIfAbsent(noncanonical_life, "garbage").outcome, PutOutcome::Done); + + Gc gc(store, kGc); + RoundReport rep; + ASSERT_NO_THROW(rep = gc.runRegularRound()) + << "the round must COMPLETE: a key GC alone could remove must never abort the round that would"; + EXPECT_TRUE(rep.hasAnomaly(RootNamespace{}, /*shard*/ 0)) + << "the refusal must surface as the fold's abort anomaly, not vanish"; + EXPECT_EQ(rep.deleted, 0u); + EXPECT_EQ(rep.redeleted, 0u); + + /// Same fail-close as (8): no partial delta, no cursor advance. + EXPECT_EQ(inDegreeOf(*backend, layout, DB::UInt128(1)), 0) + << "an aborted ref fold must land no partial ref delta"; + EXPECT_EQ(foldCursorOf(*backend, layout, ns, 0), 0u) + << "the durable cursor must not advance on an aborted round"; + + /// The wedge is only visible over time: the key is still there (nothing deletes it), so a second + /// round meets it again. It must survive that one too. + ASSERT_TRUE(backend->head(noncanonical_life).exists) << "precondition: nothing removed the key"; + ASSERT_NO_THROW(gc.runRegularRound()) << "a round that dies on this key would die on it forever"; +} + +/// Coverage gap (Task 13a): a ref log at a CANONICAL key but with an undecodable BODY -- distinct from a +/// malformed *key* (which aborts earlier at the group step, above). This exercises the +/// GET-then-decode-throw path. +/// +/// Its blast radius is the NAMESPACE, not the round (spec §5: the whole-round abort survives only for a +/// key that cannot be attributed to any namespace). The body sits at the position the arithmetic walk +/// reads next, so the walk stops there: everything below it stays folded (a transaction applies +/// atomically -- there is no partial delta either way), the cursor never moves past it, and the recorded +/// anomaly suppresses every destructive step of the round, so nothing the unfolded tail might still +/// reference can be reclaimed. +TEST(CASRefGc, InvalidRefLogBodyHoldsNamespaceNoPartialDelta) +{ + auto backend = std::make_shared(); + auto store = openPoolForTest(backend, /*gc_fold_max_defer_rounds*/ 0); + const Layout & layout = store->layout(); + const RootNamespace ns{"00/aa@cas@"}; + fixture::admitLive(*backend, store->layout(), ns); /// Stage B (Task 4-C): pin to the sentinel before the first real touch + + const ManifestRef r = mref(1); + writeBlobBody(*backend, layout, DB::UInt128(1)); + writeManifestRaw(*backend, layout, ns, r, {blobEntryFor("a", DB::UInt128(1))}); + publishCommittedTransition(*backend, layout, ns, "tbl", std::nullopt, r); + + Gc gc(store, kGc); + ASSERT_TRUE(runRegularRoundReclaiming(gc).acquired_lease); + ASSERT_EQ(inDegreeOf(*backend, layout, DB::UInt128(1)), 1) << "published and folded"; + + /// Now DROP the ref, so the blob is genuinely unreferenced once that record folds, and only then + /// plant the invalid body at the walk's very next position. This ordering is what makes the + /// suppression assertion below mean something: asserting that a LIVE blob survives a held round + /// proves nothing, since a live blob is never reclaimable in the first place. + const uint64_t dropped = dropRefTransition(*backend, layout, ns, "tbl", r); + + /// A canonical `_log` key (groupRefKeys accepts it) whose body cannot be decoded: the fold GETs it + /// and `decodeRefLogTxn` throws. + const String garbage_key = layout.refLogKey(fixture::fixtureLife(ns), RefTxnId{1, dropped + 1}); + backend->putIfAbsent(garbage_key, "garbage-not-a-valid-reflog-body"); + /// The corruption claims the next committed position. Advance only the durable frontier, not the + /// log body, so recovery must exact-GET and hold this malformed object instead of ignoring F+1. + advanceRecoverableCkptForRawFixture(*backend, layout, ns, RefTxnId{1, dropped + 1}); + + /// Eight rounds under the hold. Each one catches the hold internally and survives. + for (int i = 0; i < 8; ++i) + { + ASSERT_NO_THROW(runRegularRoundReclaiming(gc)); + store->renewWatermarkOnce(); + } + + EXPECT_EQ(foldCursorOf(*backend, layout, ns, 0), dropped) + << "the durable cursor must stop BELOW the invalid record, and never advance past it"; + EXPECT_EQ(inDegreeOf(*backend, layout, DB::UInt128(1)), 0) + << "the complete transaction below the invalid body folded -- the drop applied, so the blob is " + "unreferenced and would be reclaimed by any unsuppressed round"; + EXPECT_TRUE(blobPresent(*backend, layout, DB::UInt128(1))) + << "the held namespace's anomaly suppresses graduation and pending deletes: an unreferenced " + "blob is NOT reclaimed while any namespace is held, because the unfolded tail behind the " + "hold may still name it"; + + /// DELETING THE EVIDENCE DOES NOT RELEASE THE HOLD. The hold is durable and clears by exactly one + /// event -- the fold resolving its offending position -- so an object that stops answering does not + /// turn the gap into a frontier. It is the same observation a lying store produces, and it is + /// precisely what made the hold necessary; if an absent could clear it, the whole mechanism would + /// be defeated by the corruption it exists to survive. (Before durable holds this delete DID + /// release the namespace, which is the hole Task 8 closed.) + const HeadResult h = backend->head(garbage_key); + ASSERT_TRUE(h.exists); + ASSERT_EQ(backend->deleteExact(garbage_key, h.token).kind, DeleteOutcome::Kind::Deleted); + + for (int i = 0; i < 4; ++i) + { + ASSERT_NO_THROW(runRegularRoundReclaiming(gc)); + store->renewWatermarkOnce(); + } + EXPECT_TRUE(blobPresent(*backend, layout, DB::UInt128(1))) + << "the hold still stands: nothing resolved the offending position, an absent proved nothing"; + + /// REPAIR is the release: a DECODABLE record at the offending position. The fold reads it, folds + /// through it, seals a cursor above it -- and only then does the namespace stop being held and + /// destruction resumes. The CTE already claims this position, so this must replace the repaired + /// body at its exact id rather than use the semantic wrapper, which would attempt a non-monotone + /// checkpoint advance. + const ManifestRef r2 = mref(2); + writeBlobBody(*backend, layout, DB::UInt128(2)); + writeManifestRaw(*backend, layout, ns, r2, {blobEntryFor("b", DB::UInt128(2))}); + writeTxnAt(*backend, layout, ns, RefTxnId{1, dropped + 1}, publishCommittedOps("tbl2", r2)); + + ASSERT_TRUE(runToFixpoint(store, gc) < 64u) << "the released namespace must converge"; + EXPECT_EQ(foldCursorOf(*backend, layout, ns, 0), dropped + 1) << "the walk folded through the hold"; + EXPECT_FALSE(blobPresent(*backend, layout, DB::UInt128(1))) + << "once the hold clears, the unreferenced blob is reclaimed -- so the survival above was the " + "suppression doing its job, not the blob being unreclaimable"; + EXPECT_TRUE(blobPresent(*backend, layout, DB::UInt128(2))) << "the repair's own blob is referenced"; +} + +/// Coverage gap (Task 13a): the per-table baseline guard (spec §Offline Recovery) has no positive-trip +/// test at HEAD -- the adapted successor of the retired CASGCBaselineGuard.FreshStateOverTrimmedJournals +/// contract. A table whose logs at/below its newest snapshot are gone and that has no sealed fold cursor +/// is the "a prior fold advanced+cleaned covered logs, then gc/state was lost" signature: folding it from +/// {0,0} would emit no edges and mass-condemn its still-referenced blob. GC must refuse the round before +/// any delete. The existing CASGCBaselineGuard tests cover only the genuinely-fresh pass case and the +/// adopted-seal-missing guard, not this branch. +TEST(CASRefGc, BaselineGuardRefusesWhenSnapshotSurvivesWithoutLogsOrCursor) +{ + auto backend = std::make_shared(); + auto store = openPoolForTest(backend, /*gc_fold_max_defer_rounds*/ 0); + const Layout & layout = store->layout(); + + /// Table A is healthy (a committed ref with its manifest+blob, no snapshot), giving GC a normal table + /// to fold in the same round. + const RootNamespace ns_a{"00/aa@cas@"}; + const ManifestRef ra = mref(1); + writeBlobBody(*backend, layout, DB::UInt128(1)); + writeManifestRaw(*backend, layout, ns_a, ra, {blobEntryFor("a", DB::UInt128(1))}); + publishCommittedTransition(*backend, layout, ns_a, "ta", std::nullopt, ra); + + /// Table B is poisoned: a durable snapshot survives, but its logs at/below it are GONE and B has no + /// sealed cursor (first round -> no adopted parent cursors). This is the exact baseline-guard input. + const RootNamespace ns_b{"00/bb@cas@"}; + /// Stage B (Task 4-C): `writeRefSnapshotRaw` deliberately does NOT self-admit (several fixtures + /// build a table with no catalog entry on purpose), so without this `ns_b` would never enter the + /// catalog at all and would be invisible to the round -- the baseline guard below could then never + /// fire, since it never runs on a namespace outside the universe. + fixture::admitLive(*backend, layout, ns_b); + const ManifestRef rb = mref(2); + writeBlobBody(*backend, layout, DB::UInt128(2)); + writeManifestRaw(*backend, layout, ns_b, rb, {blobEntryFor("b", DB::UInt128(2))}); + writeRefSnapshotRaw(*backend, layout, minimalLiveSnapshot(ns_b.string(), RefTxnId{1, 5}, + {committedRow("tb", rb)})); + + /// The baseline guard must fail closed BEFORE any destructive step (first round: no prior fold seal, + /// so the failure can only come from the baseline guard, not the seal-divergence guard). + Gc gc(store, kGc); + expectThrowsCode(DB::ErrorCodes::CORRUPTED_DATA, [&] { gc.runRegularRound(); }); + EXPECT_TRUE(blobPresent(*backend, layout, DB::UInt128(1))) << "table A's blob survives the refusal"; + EXPECT_TRUE(blobPresent(*backend, layout, DB::UInt128(2))) + << "table B's blob must NOT be condemned -- the guard fires before any delete"; +} + +/// A catalog-admitted life without a parent cursor is a valid fresh fold target when it has no +/// snapshot or logs. The fold must seed its successor seal from every plan row, not only the +/// parent-cursor subset used by the baseline guard. +TEST(CASRefGc, CatalogAdmittedFreshLifeWithoutParentSeedsSuccessorSeal) +{ + auto backend = std::make_shared(); + auto store = openPoolForTest(backend, /*gc_fold_max_defer_rounds*/ 0); + const Layout & layout = store->layout(); + const RootNamespace ns{"00/aa@cas@"}; + fixture::admitLive(*backend, layout, ns); + + const CasRefCatalog::Snapshot catalog_cut = CasRefCatalog::read(*backend, layout); + ASSERT_EQ(catalog_cut.catalog.entries.size(), 1u); + const UInt128 life_id = catalog_cut.catalog.entries.front().incarnation; + + Gc gc(store, kGc); + ASSERT_NO_THROW(gc.runRegularRound()); + + const GcState state = decodeGcState(backend->get(layout.gcStateKey())->bytes); + const CasFoldSeal seal = decodeFoldSeal( + backend->get(layout.foldSealKey(state.snap_generation, state.snap_attempt))->bytes); + EXPECT_TRUE(seal.ref_lives.contains(life_id)); +} diff --git a/src/Disks/tests/gtest_cas_ref_install_safety.cpp b/src/Disks/tests/gtest_cas_ref_install_safety.cpp new file mode 100644 index 000000000000..d05ae091fd39 --- /dev/null +++ b/src/Disks/tests/gtest_cas_ref_install_safety.cpp @@ -0,0 +1,959 @@ +#include + +#include "config.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include + +/// Task 3 (spec §A1, site 1): the region of `CasRefLedger::commitRefChunk` between "this chunk's +/// ref-log object is durable" and "the runtime records it". +/// +/// Before the fix that region ran `applyRefLogTxn(rt->state, chunk_txn)`, which allocates (the COW +/// containers build an overlay) and can therefore throw `MEMORY_LIMIT_EXCEEDED`. A throw there left the +/// transaction durable but invisible to the writer -- and because a later transaction only needs +/// `greatest_applied < its own id` (contiguity is never checked), a snapshot published afterwards is +/// labelled with that LATER id, so recovery skips the stranded transaction permanently while GC, which +/// folds the ref logs themselves, still applies it. That divergence loses data (a stranded removal +/// leaves the writer holding a ref whose blobs GC deleted), which is why the install is now a +/// prepared-candidate swap: allocation-free, hence non-throwing, and enforced as such by +/// `DENY_ALLOCATIONS_IN_SCOPE`. +/// +/// The suite name is prefixed `Cas` so it is covered by the `Cas*` unit-test gate filter. + +namespace DB::ErrorCodes +{ +extern const int NETWORK_ERROR; +extern const int CORRUPTED_DATA; +extern const int LOGICAL_ERROR; +extern const int MEMORY_LIMIT_EXCEEDED; +} + +namespace ProfileEvents +{ +extern const Event CASRefNeedsRecovery; +} + +using namespace DB::Cas; + +namespace +{ + +PoolPtr openPool(const BackendPtr & backend) +{ + /// A fresh pool with no residue, mirroring `gtest_cas_ref_chunked_flush.cpp`'s `openPool`. + DB::Cas::tests::seedPoolMetaForRestart(*backend); + return Pool::open(backend, PoolConfig{.pool_prefix = "p", .server_root_id = "test"}); +} + +/// As `openPool`, but with a SINGLE-attempt request budget, which is what makes one ambiguous `PUT` +/// conclusive: with retries allowed the controller's resolve-before-reissue would either re-`PUT` (the +/// object never landed) or prove the object durable (it did) and report `Committed`, and neither of the +/// wedge arms under test would ever be reached. Same budget shape as +/// `gtest_cas_ref_chunked_flush.cpp`'s `runChunkFailureCase`, including the short timeouts so there is +/// no inter-attempt sleep to serve. +PoolPtr openPoolSingleAttempt(const BackendPtr & backend) +{ + DB::Cas::tests::seedPoolMetaForRestart(*backend); + PoolConfig cfg{.pool_prefix = "p", .server_root_id = "test"}; + CasRequestBudget budget; + /// ONE attempt is the whole mechanism these tests need: it is what turns an injected lost + /// acknowledgement into `Unresolved` instead of a transparent retry, and it does so independently of + /// how fast the machine is. + /// + /// The operation deadline must therefore NOT sit at `attempt_timeout_ms`, which is where it used to. + /// The controller's pre-send gate (`putIfAbsentControlled`: `now + attempt_timeout > deadline` + /// returns `Unresolved` WITHOUT sending) is then a zero-width race that passes only if no + /// millisecond tick elapses between the deadline capture and the gate. Under parallel-build load it + /// loses: the gate fires first, nothing is sent, the injected fault is never reached, and the flush + /// fails CLEAN -- so the product correctly does NOT wedge the lane and the wedge expectations flip. + /// `UncertainPrecommitKeepsItsCleanupOwnerAndItsBody` was observed failing exactly that way (Task 9, + /// `refLaneWedgedForTest` false at the wedge assertion), and every test on this fixture carries the + /// same razor. Same root cause and same fix as `8f9e63c7a19` for the sweep-interruption test. + /// + /// A WIDE deadline keeps the request always actually sent, so the injected fault decides the outcome + /// rather than the scheduler. Tests that want the pre-send REFUSAL instead use + /// `openPoolFenceControlled`, where a frozen clock makes that refusal deterministic rather than raced. + budget.max_attempts = 1; + budget.attempt_timeout_ms = 100; + budget.operation_deadline_ms = 5000; + budget.lease_safety_margin_ms = 100; + cfg.cas_request_budget = budget; + return Pool::open(backend, cfg); +} + +/// The mount-fence deadlines the pre-attempt tests drive, in the FROZEN boot clock of +/// `openPoolFenceControlled` (which is pinned at 0, so these are also the remaining lease budgets). +/// +/// `CasMountRuntime` has TWO fence predicates and they are deliberately not the same: +/// `mayMutate` -- `now < deadline`; the top-of-flush gate in `flushRefBatch`. +/// `refAppendFenceOk` -- additionally `attempt_timeout_ms + lease_safety_margin_ms < deadline - now`, +/// i.e. "there is room for one whole controlled attempt"; the `fence_ok` +/// `commitRefChunk` hands to `putIfAbsentControlled`. +/// With `openPoolFenceControlled`'s budget below that margin is 100 + 100 = 200 ms, so a 100 ms +/// remaining lease sits BETWEEN them: the flush is admitted and then its very first pre-attempt gate +/// refuses. That is +/// exactly the production shape this task is about (a lease too short to start a write, not a lost +/// one), and it needs no fault injection at all -- which is the point: nothing is sent. +constexpr uint64_t FENCE_DEADLINE_HEALTHY_MS = 30000; +constexpr uint64_t FENCE_DEADLINE_REFUSES_ATTEMPT_MS = 100; + +/// A legal blob-free part: stage an empty manifest, precommit, promote -- enough to drive real +/// ref-log transactions through the append lane. +void publishEmptyPart(const PoolPtr & s, const RootNamespace & ns, const String & ref) +{ + PartWriteInfo info; + info.intended_namespace = ns; + info.intended_ref = ns.string() + "/" + ref; + auto build = s->beginPartWrite(info); + const ManifestId id = build->stageManifest({}); + build->precommitAdd(ns, ref, id); + build->promote(ns, ref, build->buildId(), id); +} + +/// As `openPoolSingleAttempt`, but with the mount fence under the TEST's control instead of the wall +/// clock's: +/// - the boot clock is FROZEN at 0, so `setMountDeadline` alone decides both fence predicates and no +/// elapsed real time can flip one of them mid-test (the same load-bearing injection, for the same +/// reason, as `gtest_cas_ref_chunked_flush.cpp`'s `openPool`); +/// - lease renewal is parked an hour out, so the keeper's background renew cannot re-arm the deadline +/// underneath a test that just shortened it. Ten seconds (the default) would be enough in practice +/// and flaky in principle; this removes the race rather than betting on it. +PoolPtr openPoolFenceControlled(const BackendPtr & backend) +{ + DB::Cas::tests::seedPoolMetaForRestart(*backend); + PoolConfig cfg{.pool_prefix = "p", .server_root_id = "test"}; + cfg.boot_ms_fn = [] { return uint64_t{0}; }; + cfg.mount_renew_period = std::chrono::milliseconds{3600000}; + CasRequestBudget budget; + budget.max_attempts = 1; + budget.attempt_timeout_ms = 100; + budget.operation_deadline_ms = 5000; /// strictly above attempt_timeout_ms: equality is a wall-clock race (validateCasRequestBudget) + budget.lease_safety_margin_ms = 100; + cfg.cas_request_budget = budget; + return Pool::open(backend, cfg); +} + +/// Runs `f`, requires it to throw the ref lane's retry-later condition, and returns the message so a +/// caller can assert WHICH condition it was. The message is the only place the `CasUnresolvedReason` +/// surfaces -- there is no accessor for it, by design (it is a diagnostic, not state) -- so this is how +/// a test proves the reason actually reached the decision site instead of defaulting. +String retryLaterMessageOf(const std::function & f) +{ + try + { + f(); + } + catch (const DB::Exception & e) + { + EXPECT_EQ(e.code(), DB::ErrorCodes::NETWORK_ERROR) << e.message(); + return e.message(); + } + ADD_FAILURE() << "expected the CAS retry-later condition, but nothing was thrown"; + return {}; +} + +/// Installs a ONE-SHOT throwing probe into the post-durable install regions (spec §A2): the next region +/// entered throws, every later one runs normally -- which is what lets a terminality test drive a +/// successful flush after the recovery transition. +/// +/// The exception is built HERE, outside the region, and the probe only rethrows it: constructing a +/// `DB::Exception` inside the region would allocate and trip `DENY_ALLOCATIONS_IN_SCOPE`, so the test +/// would be exercising the guard instead of the recovery transition. `MEMORY_LIMIT_EXCEEDED` (what a +/// real tracked allocation failure raises) is used deliberately instead of `LOGICAL_ERROR`, which +/// aborts at construction in debug/sanitizer builds. +/// +/// With §A1 landed this seam is the only way to reach `NeedsRecovery` from an install region: +/// install regions are allocation-free and therefore cannot throw on their own. +void armOneShotInstallFailure(const PoolPtr & store) +{ + auto planned = std::make_exception_ptr(DB::Exception(DB::ErrorCodes::MEMORY_LIMIT_EXCEEDED, + "simulated allocation failure inside the post-durable install region")); + auto fired = std::make_shared>(false); + store->setInstallRegionProbeForTest([planned, fired] + { + if (fired->exchange(true)) + return; + /// The throw itself allocates its exception object through `malloc`, which the memory tracker + /// does not see, so it would not trip the guard anyway -- re-allowing allocations for the + /// duration of the throw makes that a stated property of the test rather than a bet on a libc++ + /// implementation detail. + ALLOW_ALLOCATIONS_IN_SCOPE; + std::rethrow_exception(planned); + }); +} + +} + +/// The post-durable install seam exists, is reached by an ordinary commit, and every transaction that +/// reaches it is RECORDED: the tail counter advances exactly once per install, and the ref resolves. +/// The equality is the point -- it is the invariant the old code could break, since there the install +/// was an allocating apply that could throw between the durable `PUT` and the counter bump. +TEST(CASRefInstallSafety, PostDurableInstallIsAllocationFree) +{ + auto backend = std::make_shared(); + auto store = openPool(backend); + const RootNamespace ns{"srv1/install_safety_seam"}; + + /// Fired on the calling thread by the flush leader, which is this thread; `atomic` regardless, so + /// the assertions below cannot be read as depending on that. + std::atomic installs{0}; + store->setCarveHookForTest([&installs](CasRefLedger::CarvePhaseForTest phase) + { + if (phase == CasRefLedger::CarvePhaseForTest::PostDurableInstall) + installs.fetch_add(1); + }); + + publishEmptyPart(store, ns, "part_a"); + store->setCarveHookForTest(nullptr); + + const size_t seen = installs.load(); + EXPECT_GT(seen, 0u) << "the post-durable install seam must be reached by an ordinary commit"; + /// No snapshot publish can interfere: the thresholds are 256 logs / 1 MiB and this part is a + /// handful of tiny transactions, so the tail counter still holds every one of them. + EXPECT_EQ(store->tailSinceSnapshotCountForTest(ns), seen) + << "every durable transaction that entered the install region must be recorded in the tail"; + EXPECT_TRUE(store->resolveRef(ns, "part_a", /*allow_stale=*/false).has_value()); +} + +/// Task 4 (spec §A1, site 3). An `Unresolved` PUT must ALWAYS leave the lane wedged with the exact +/// {id, key, bytes} of the in-doubt object -- the wedge is the only record that can ever resolve it, and +/// on this path the object may already be durable. Building the wedge AFTER the PUT copies two `String`s +/// and could therefore fail on allocation, recording NEITHER the transaction nor the wedge: strictly +/// worse than a wedge, because the next append then mints a fresh id and proceeds against a state that +/// is missing a landed transaction. It is now preconstructed before the PUT and installed by a +/// non-throwing move. +/// +/// `Mode::Unresolved` deliberately lands NOTHING, so this test also pins the other half of the wedge +/// contract: an ambiguous outcome wedges even when the object turns out never to have existed. The tail +/// counter must NOT advance -- an unproven transaction is not a recorded one. +TEST(CASRefInstallSafety, UnresolvedAlwaysRecordsTheWedge) +{ + auto backend = std::make_shared(); + auto store = openPoolSingleAttempt(backend); + const RootNamespace ns{"srv1/unresolved_wedge"}; + /// Stage B (Task 4-C): pin `ns` to the Stage-A sentinel BEFORE its first real touch, so + /// the fault injected below (computed from that same sentinel) lands on the key production + /// actually writes to. + DB::Cas::tests::casAdmitRecoverableEntry(*backend, store->layout(), ns, store->liveWriterEpoch()); + + /// Scoped to THIS namespace's ref log, so nothing else the part publish writes (the manifest, the + /// pool's own metadata) can consume the single fault. + backend->fault_substr = store->layout().namespaceStreamPrefix(DB::Cas::tests::fixture::fixtureLife(ns)) + "_log/"; + backend->mode = DB::Cas::tests::ChunkFaultBackend::Mode::Unresolved; + backend->fault_count = 1; + + const String message = retryLaterMessageOf([&] { publishEmptyPart(store, ns, "part_a"); }); + + EXPECT_TRUE(store->refLaneWedgedForTest(ns)) << "an Unresolved PUT must always leave a wedge"; + const String wedged_key = store->wedgedKeyForTest(ns); + EXPECT_FALSE(wedged_key.empty()) << "the wedge must retain the in-doubt object's key"; + EXPECT_TRUE(wedged_key.starts_with(store->layout().namespaceStreamPrefix(DB::Cas::tests::fixture::fixtureLife(ns)) + "_log/")) + << "the wedged key must be this namespace's ref-log object, not some other key: " << wedged_key; + EXPECT_EQ(store->tailSinceSnapshotCountForTest(ns), 0u) + << "an UNPROVEN transaction must not be recorded as applied"; + + /// Task 18. The contrast half of `PreAttemptRefusalDoesNotWedgeTheLane` below, asserted on the ONE + /// artifact that carries the distinction: an attempt WAS sent here (the fault is thrown by the + /// backend's `putIfAbsent`, so the request reached it), the single-attempt budget is then spent, and + /// the lane wedges. The message must say so -- and must NOT say "no attempt was sent", which is the + /// only shape allowed to skip the wedge. + EXPECT_NE(message.find("attempt budget was exhausted"), String::npos) + << "the reason must reach the wedge message rather than defaulting: " << message; + EXPECT_EQ(message.find("no attempt was sent"), String::npos) + << "an ambiguous PUT is not a pre-attempt refusal: " << message; +} + +/// Task 18 (finding #37 defect 3, behavioural half). A `NoAttemptSent` `Unresolved` must NOT wedge. +/// +/// The wedge exists because an ambiguous PUT MAY HAVE LANDED, so the durable log may or may not contain +/// the transaction and only an exact-key GET can settle it. That reasoning needs an attempt to have been +/// SENT. Here both pre-attempt gates reject on the FIRST iteration -- the remaining lease has no room +/// for one controlled attempt -- so nothing reaches the backend, the key is provably unwritten, and a +/// wedge would protect against nothing while costing the table every ref append (inserts included) +/// until a remount: an exact-key GET of a key that was never written reports `Unresolved` forever, so +/// such a wedge can never clear itself. +/// +/// No fault injection anywhere in this test, deliberately: the ZERO ref-log I/O assertion below is the +/// direct proof that nothing was sent, and it would be meaningless if a fault backend were swallowing +/// the request. +TEST(CASRefInstallSafety, PreAttemptRefusalDoesNotWedgeTheLane) +{ + auto backend = std::make_shared(); + auto store = openPoolFenceControlled(backend); + const RootNamespace ns{"srv1/pre_attempt_refusal"}; + + publishEmptyPart(store, ns, "part_a"); + const size_t tail_after_seed = store->tailSinceSnapshotCountForTest(ns); + const String log_prefix = store->layout().namespaceStreamPrefix(DB::Cas::tests::fixture::fixtureLife(ns)) + "_log/"; + const uint64_t log_io_after_seed = backend->ioCountForKeysContaining(log_prefix); + + /// Shorten the lease to the window where the flush is admitted but no attempt may start. + store->setMountDeadline(FENCE_DEADLINE_REFUSES_ATTEMPT_MS); + /// Half of "the PRE-ATTEMPT gate is what refuses" is asserted here (the flush is admitted, so this + /// is not the top-of-flush `mayMutate` gate); the other half is asserted below, by the message + /// naming `NoAttemptSent` and by the ref-log I/O count not moving. `refAppendFenceOk` itself is + /// private to `Pool`, and is not worth widening for a test that can prove the same thing from the + /// outside. + ASSERT_TRUE(store->mayMutate()) << "the flush must still be ADMITTED, or this exercises the " + "top-of-flush gate instead of the pre-attempt one"; + + const String message = retryLaterMessageOf([&] { store->dropRef(ns, "part_a"); }); + + EXPECT_NE(message.find("no attempt was sent"), String::npos) + << "the caller must be told WHY, and this is the reason the no-wedge decision rests on: " << message; + EXPECT_FALSE(store->refLaneWedgedForTest(ns)) + << "nothing was sent, so nothing can be durable: there is no ambiguity for a wedge to resolve"; + EXPECT_TRUE(store->wedgedKeyForTest(ns).empty()); + EXPECT_EQ(backend->ioCountForKeysContaining(log_prefix), log_io_after_seed) + << "the refusal must be PRE-attempt: not one ref-log object may have been touched"; + EXPECT_EQ(store->laneStateForTest(ns), RefLaneState::Ready) + << "no apply is owed for a transaction that was never sent -- leaving the marker pending would " + "claim this table may be missing a durable transaction for the rest of its life"; + EXPECT_EQ(store->tailSinceSnapshotCountForTest(ns), tail_after_seed) + << "nothing was committed, so nothing may be recorded"; + EXPECT_TRUE(store->resolveRef(ns, "part_a", /*allow_stale=*/false).has_value()) + << "the refused drop must not have taken effect"; + + /// The availability half of the claim: the lane is usable the moment the lease is healthy again -- + /// no remount, no wedge resolution, nothing to clear. Before this task the same sequence left a + /// wedge over a key that was never written, and this append would have failed forever. + store->setMountDeadline(FENCE_DEADLINE_HEALTHY_MS); + store->dropRef(ns, "part_a"); + EXPECT_FALSE(store->resolveRef(ns, "part_a", /*allow_stale=*/false).has_value()) + << "the retry on the same lane must commit"; + EXPECT_FALSE(store->refLaneWedgedForTest(ns)); + EXPECT_EQ(store->laneStateForTest(ns), RefLaneState::Ready); +} + +/// Task 18, the same pair on the WEDGE-RESOLUTION path -- the negative half. +/// +/// One flush does both things: it resolves an outstanding wedge over a genuinely durable object (the +/// resolving GET proves it, so the transaction is installed and the lane unwedged), and then commits +/// its own new chunk, which the pre-attempt gate refuses. The lane must come out CLEAN. +/// +/// This is the worst pre-fix shape and the reason the case is worth its own test: the flush had just +/// converted a resolvable wedge into a recorded transaction, and the old code immediately re-wedged the +/// lane over an id whose object was never written -- turning a wedge that WOULD have cleared into one +/// that never can. +TEST(CASRefInstallSafety, PreAttemptRefusalAfterAWedgeResolutionLeavesTheLaneClean) +{ + auto backend = std::make_shared(); + auto store = openPoolFenceControlled(backend); + const RootNamespace ns{"srv1/pre_attempt_after_unwedge"}; + /// Stage B (Task 4-C): pin `ns` to the Stage-A sentinel BEFORE its first real touch, so + /// the fault injected below (computed from that same sentinel) lands on the key production + /// actually writes to. + DB::Cas::tests::casAdmitRecoverableEntry(*backend, store->layout(), ns, store->liveWriterEpoch()); + + publishEmptyPart(store, ns, "x"); + publishEmptyPart(store, ns, "y"); + const size_t tail_after_seed = store->tailSinceSnapshotCountForTest(ns); + + /// Wedge over an object that IS durable: the write lands, its acknowledgement is lost, and the + /// controller's own verifying read is lost too (the only mode that reaches the resolution install). + backend->fault_substr = store->layout().namespaceStreamPrefix(DB::Cas::tests::fixture::fixtureLife(ns)) + "_log/"; + backend->mode = DB::Cas::tests::ChunkFaultBackend::Mode::LandedThenLost; + backend->fault_count = 1; + DB::Cas::tests::expectThrowsCode(DB::ErrorCodes::NETWORK_ERROR, [&] { store->dropRef(ns, "x"); }); + ASSERT_TRUE(store->refLaneWedgedForTest(ns)); + ASSERT_EQ(store->tailSinceSnapshotCountForTest(ns), tail_after_seed); + + backend->mode = DB::Cas::tests::ChunkFaultBackend::Mode::None; + /// The wedge resolution is itself a conditional CREATE under the every-attempt rule, so it is + /// fence-gated like any other write: shortening the lease BEFORE the flush would refuse the + /// resolution too, and there would be no "after a wedge resolution" left to test. Shorten it + /// BETWEEN the two instead -- the pre-carve hook fires exactly there, after the wedge block and + /// before the batch is carved. + store->setRefPreCarveHookForTest([&] { store->setMountDeadline(FENCE_DEADLINE_REFUSES_ATTEMPT_MS); }); + const String message = retryLaterMessageOf([&] { store->dropRef(ns, "y"); }); + store->setRefPreCarveHookForTest(nullptr); + + EXPECT_NE(message.find("no attempt was sent"), String::npos) << message; + EXPECT_FALSE(store->refLaneWedgedForTest(ns)) + << "the wedge that existed was RESOLVED, and the chunk that followed it was never sent -- the " + "lane must be left clean, not re-wedged over an id that can never resolve"; + EXPECT_EQ(store->tailSinceSnapshotCountForTest(ns), tail_after_seed + 1) + << "the resolved wedge must still have been installed exactly once"; + EXPECT_FALSE(store->resolveRef(ns, "x", /*allow_stale=*/false).has_value()) + << "the wedged drop was proven durable, so its removal must be visible"; + EXPECT_TRUE(store->resolveRef(ns, "y", /*allow_stale=*/false).has_value()) + << "the refused chunk must not have taken effect"; + EXPECT_EQ(store->laneStateForTest(ns), RefLaneState::Ready); + + store->setMountDeadline(FENCE_DEADLINE_HEALTHY_MS); + store->dropRef(ns, "y"); + EXPECT_FALSE(store->resolveRef(ns, "y", /*allow_stale=*/false).has_value()); + EXPECT_FALSE(store->refLaneWedgedForTest(ns)); +} + +/// Task 18, the same pair on the wedge-resolution path -- the POSITIVE half, so the test above cannot +/// pass by the fix having weakened the wedge generally. Same flush shape (resolve a durable wedge, then +/// commit a new chunk), except the new chunk's PUT is genuinely ambiguous: an attempt WAS sent, so the +/// lane must wedge again, now over the NEW transaction. +TEST(CASRefInstallSafety, AmbiguousChunkAfterAWedgeResolutionRewedgesTheLane) +{ + auto backend = std::make_shared(); + auto store = openPoolFenceControlled(backend); + const RootNamespace ns{"srv1/ambiguous_after_unwedge"}; + /// Stage B (Task 4-C): pin `ns` to the Stage-A sentinel BEFORE its first real touch, so + /// the fault injected below (computed from that same sentinel) lands on the key production + /// actually writes to. + DB::Cas::tests::casAdmitRecoverableEntry(*backend, store->layout(), ns, store->liveWriterEpoch()); + + publishEmptyPart(store, ns, "x"); + publishEmptyPart(store, ns, "y"); + const size_t tail_after_seed = store->tailSinceSnapshotCountForTest(ns); + + backend->fault_substr = store->layout().namespaceStreamPrefix(DB::Cas::tests::fixture::fixtureLife(ns)) + "_log/"; + backend->mode = DB::Cas::tests::ChunkFaultBackend::Mode::LandedThenLost; + backend->fault_count = 1; + DB::Cas::tests::expectThrowsCode(DB::ErrorCodes::NETWORK_ERROR, [&] { store->dropRef(ns, "x"); }); + ASSERT_TRUE(store->refLaneWedgedForTest(ns)); + const String first_wedged_key = store->wedgedKeyForTest(ns); + ASSERT_FALSE(first_wedged_key.empty()); + + /// The resolution is a conditional CREATE at the wedged key now, and that key already holds our + /// own landed object, so it conflicts and the follow-up read adopts it (`LandedThenLost`'s one-shot + /// lost read was consumed inside the previous attempt, so this read succeeds). `fault_skip` lets + /// that create through and puts the fault on this flush's OWN chunk PUT, which is the subject. + backend->mode = DB::Cas::tests::ChunkFaultBackend::Mode::Unresolved; + backend->fault_skip = 1; + backend->fault_count = 1; + DB::Cas::tests::expectThrowsCode(DB::ErrorCodes::NETWORK_ERROR, [&] { store->dropRef(ns, "y"); }); + + EXPECT_TRUE(store->refLaneWedgedForTest(ns)) + << "an attempt was sent for the new chunk, so its object may be durable: the lane must wedge"; + EXPECT_NE(store->wedgedKeyForTest(ns), first_wedged_key) + << "the new wedge must describe the NEW transaction, not the resolved one"; + EXPECT_EQ(store->tailSinceSnapshotCountForTest(ns), tail_after_seed + 1) + << "only the resolved wedge is recorded; the in-doubt chunk is not"; + EXPECT_EQ(store->laneStateForTest(ns), RefLaneState::Wedged) + << "a wedged lane may hold a durable transaction the runtime has not recorded"; +} + +/// Task 18's regression guard, asserted on the mapping itself rather than through six pieces of fault +/// choreography. `unresolvedProvesNothingWasSent` is the whole decision: the ledger wedges unless it +/// answers true, so this table IS the protocol. +/// +/// What protects a future contributor who adds a `CasUnresolvedReason` member and forgets this file: +/// the predicate is a switch with NO `default`, so the addition is a `-Wswitch` build error (a forced +/// decision, not a silent one), and its trailing `return false` makes the runtime answer "wedge" even +/// if that diagnostic is ever suppressed. Both directions fail closed; neither can widen the allow-list +/// by omission. The `static_assert`s make the mapping a compile-time fact, and the `EXPECT`s repeat it +/// so a break names the offending value in the test report. +TEST(CASRefInstallSafety, OnlyNoAttemptSentMaySkipTheWedge) +{ + static_assert(unresolvedProvesNothingWasSent(CasUnresolvedReason::NoAttemptSent)); + static_assert(!unresolvedProvesNothingWasSent(CasUnresolvedReason::NotUnresolved)); + static_assert(!unresolvedProvesNothingWasSent(CasUnresolvedReason::FenceLostMidWay)); + static_assert(!unresolvedProvesNothingWasSent(CasUnresolvedReason::DeadlineMidWay)); + static_assert(!unresolvedProvesNothingWasSent(CasUnresolvedReason::FenceLostPostWrite)); + static_assert(!unresolvedProvesNothingWasSent(CasUnresolvedReason::AttemptsExhausted)); + + EXPECT_TRUE(unresolvedProvesNothingWasSent(CasUnresolvedReason::NoAttemptSent)) + << "the pre-attempt gates rejected before the first request: the key is provably unwritten"; + /// `NotUnresolved` is reachable at the decision site if any path ever returns `Unresolved` without + /// recording a reason, so it is listed here as a real case, not as enum hygiene. + EXPECT_FALSE(unresolvedProvesNothingWasSent(CasUnresolvedReason::NotUnresolved)) + << "an unrecorded reason proves nothing and must keep wedging"; + EXPECT_FALSE(unresolvedProvesNothingWasSent(CasUnresolvedReason::FenceLostMidWay)) + << "an attempt was already sent: its object may be durable"; + EXPECT_FALSE(unresolvedProvesNothingWasSent(CasUnresolvedReason::DeadlineMidWay)) + << "an attempt was already sent: its object may be durable"; + EXPECT_FALSE(unresolvedProvesNothingWasSent(CasUnresolvedReason::FenceLostPostWrite)) + << "the attempt COMMITTED and only the fence was lost afterwards -- the most durable case of all"; + EXPECT_FALSE(unresolvedProvesNothingWasSent(CasUnresolvedReason::AttemptsExhausted)) + << "every attempt is a candidate for having landed"; +} + +/// Task 5 (spec §A1, site 2). Resolving a wedge is a post-durable install too: the resolving GET PROVES +/// the object landed, so the transaction MUST be recorded -- and recording it must be inseparable from +/// clearing the wedge. It was not: the apply and the `materializeCommitted` fold sat between them +/// WITHOUT the ordinary commit arm's swallow, so a fold failure left the transaction applied and the +/// wedge still set, and the next resolution re-applied the same transaction and DOUBLE-bumped the tail +/// counters. The candidate is now built before the GET and installed by a `noexcept` swap that clears +/// the wedge in the same allocation-free region, with the fold outside it and swallowing. +/// +/// Drives the real thing end to end (no seam beyond the `LandedThenLost` backend mode): a drop whose +/// object landed but whose acknowledgement -- and whose immediate verification read -- were both lost, +/// then a second append whose flush resolves it. The tail counter is the "exactly once" witness: it is +/// bumped once per install, so a re-applied transaction shows up as one extra. +TEST(CASRefInstallSafety, WedgeResolutionInstallsExactlyOnce) +{ + auto backend = std::make_shared(); + auto store = openPoolSingleAttempt(backend); + const RootNamespace ns{"srv1/wedge_resolution"}; + /// Stage B (Task 4-C): pin `ns` to the Stage-A sentinel BEFORE its first real touch, so + /// the fault injected below (computed from that same sentinel) lands on the key production + /// actually writes to. + DB::Cas::tests::casAdmitRecoverableEntry(*backend, store->layout(), ns, store->liveWriterEpoch()); + + publishEmptyPart(store, ns, "x"); + publishEmptyPart(store, ns, "y"); + /// No snapshot publish can interfere and reset these: the thresholds are 256 logs / 1 MiB and this + /// whole test is a handful of tiny transactions, so every delta below is exact. + const size_t tail_after_seed = store->tailSinceSnapshotCountForTest(ns); + + /// Drop "x" through a PUT that LANDS and then loses its response, plus the one-shot lost read that + /// keeps the controller's own resolve-before-reissue from settling it inside the same attempt. One + /// attempt, so the lane wedges over an object that is genuinely durable -- the only way in. + backend->fault_substr = store->layout().namespaceStreamPrefix(DB::Cas::tests::fixture::fixtureLife(ns)) + "_log/"; + backend->mode = DB::Cas::tests::ChunkFaultBackend::Mode::LandedThenLost; + backend->fault_count = 1; + DB::Cas::tests::expectThrowsCode(DB::ErrorCodes::NETWORK_ERROR, [&] { store->dropRef(ns, "x"); }); + + ASSERT_TRUE(store->refLaneWedgedForTest(ns)) << "the lost-response drop must wedge the lane"; + ASSERT_FALSE(store->wedgedKeyForTest(ns).empty()); + ASSERT_EQ(store->tailSinceSnapshotCountForTest(ns), tail_after_seed) + << "the wedged transaction is durable but not yet PROVEN, so it must not be recorded yet"; + + /// A second append into the same table: its flush resolves the wedge first (+1 install) and then + /// commits its own transaction (+1). Nothing else can add a transaction in between. + backend->mode = DB::Cas::tests::ChunkFaultBackend::Mode::None; + store->dropRef(ns, "y"); + + EXPECT_FALSE(store->refLaneWedgedForTest(ns)) << "a wedge proven durable must be cleared"; + EXPECT_EQ(store->tailSinceSnapshotCountForTest(ns), tail_after_seed + 2) + << "the resolved transaction must be recorded EXACTLY once: +1 for it and +1 for the append that " + "resolved it (a double-apply would show as +3)"; + /// Both drops took effect -- the wedged one via the resolution install, which is what proves that + /// install happened at all rather than the wedge merely being discarded. + EXPECT_FALSE(store->resolveRef(ns, "x", /*allow_stale=*/false).has_value()) + << "the wedged drop was proven durable, so its removal must be visible in the cached state"; + EXPECT_FALSE(store->resolveRef(ns, "y", /*allow_stale=*/false).has_value()); +} + +/// Negative control, part 1 of 2: does `DENY_ALLOCATIONS_IN_SCOPE` actually fire on an allocation in +/// THIS binary? Gated on `MEMORY_TRACKER_DEBUG_CHECKS`, because that is the macro the guard itself is +/// gated on (`MemoryTracker.h`: defined only under `!NDEBUG`, i.e. plain debug builds; everywhere else +/// `DENY_ALLOCATIONS_IN_SCOPE` compiles to `static_assert(true)` and there is nothing to observe). +/// An earlier version dispatched on `DEBUG_OR_SANITIZER_BUILD` instead — but sanitizer builds define +/// NDEBUG, so the guard is a no-op there and the death test "failed to die" on all three sanitizer CI +/// lanes. Note the implication chain: `MEMORY_TRACKER_DEBUG_CHECKS` ⇒ `!NDEBUG` ⇒ +/// `DEBUG_OR_SANITIZER_BUILD`, so whenever the guard exists its `LOGICAL_ERROR` aborts at Exception +/// construction (`Exception.cpp`) — death is the only observable outcome, and a throw-only variant is +/// dead code. +#if defined(MEMORY_TRACKER_DEBUG_CHECKS) +TEST(CASRefInstallSafetyDeathTest, DenyGuardStopsAnAllocation) +{ + EXPECT_DEATH( + { + DENY_ALLOCATIONS_IN_SCOPE; + volatile auto * p = new char[64]; + (void)p; + }, + ""); +} +#endif + +/// Negative control, part 2 of 2: the region the guard protects is actually ENTERED, and the guard is +/// armed at that exact point. A probe that only reads flags proves both without allocating, so unlike +/// part 1 this assertion is immune to how a build type renders a `LOGICAL_ERROR`. Together the two +/// parts give what a single death test was meant to give, and a failure now names WHICH half broke: +/// "the guard does not fire" versus "the install region is never reached / not armed". +TEST(CASRefInstallSafety, InstallRegionProbeIsInvokedAndTheGuardIsArmed) +{ + auto backend = std::make_shared(); + auto store = openPool(backend); + const RootNamespace ns{"srv1/install_probe_diag"}; + + bool probe_ran = false; + [[maybe_unused]] bool guard_armed_when_probe_ran = false; + store->setInstallRegionProbeForTest([&] + { + probe_ran = true; +#if defined(MEMORY_TRACKER_DEBUG_CHECKS) + guard_armed_when_probe_ran = memory_tracker_always_throw_logical_error_on_allocation; +#endif + }); + + publishEmptyPart(store, ns, "part_a"); + store->setInstallRegionProbeForTest(nullptr); + + EXPECT_TRUE(probe_ran) << "the install-region probe was never invoked"; +#if defined(MEMORY_TRACKER_DEBUG_CHECKS) + EXPECT_TRUE(guard_armed_when_probe_ran) << "the probe ran but DENY_ALLOCATIONS_IN_SCOPE was not armed"; +#endif +} + +/// =================================================================================== +/// The append lane state machine. +/// =================================================================================== + +/// The exact attempt is visible as `Writing` after durability and before installation. +TEST(CASRefInstallSafety, WritingOwnsTheAttemptUntilInstall) +{ + auto backend = std::make_shared(); + auto store = openPool(backend); + const RootNamespace ns{"srv1/apply_state_commit"}; + + EXPECT_EQ(store->laneStateForTest(ns), RefLaneState::Closed) + << "a resident-only observer must not materialize a runtime for an untouched name"; + + /// Fired on the calling thread by the flush leader, which is this thread; `atomic` regardless, as in + /// `PostDurableInstallIsAllocationFree` above, so no assertion here reads as depending on that. + std::atomic observations{0}; + std::atomic pending_observations{0}; + store->setCarveHookForTest([&](CasRefLedger::CarvePhaseForTest phase) + { + if (phase != CasRefLedger::CarvePhaseForTest::PostDurableInstall) + return; + observations.fetch_add(1); + if (store->laneStateForTest(ns) == RefLaneState::Writing) + pending_observations.fetch_add(1); + }); + + publishEmptyPart(store, ns, "part_a"); + store->setCarveHookForTest(nullptr); + + EXPECT_GT(observations.load(), 0u) << "the post-durable seam must be reached by an ordinary commit"; + EXPECT_EQ(pending_observations.load(), observations.load()) + << "every durable-but-not-yet-installed transaction remains Writing"; + EXPECT_EQ(store->laneStateForTest(ns), RefLaneState::Ready) + << "a completed install owes no apply: the marker must be back to Clean"; +} + +/// Ambiguity transfers the same exact attempt from `Writing` to `Wedged`. +TEST(CASRefInstallSafety, UnresolvedTransfersWritingToWedged) +{ + auto backend = std::make_shared(); + auto store = openPoolSingleAttempt(backend); + const RootNamespace ns{"srv1/apply_state_wedge"}; + /// Stage B (Task 4-C): pin `ns` to the Stage-A sentinel BEFORE its first real touch, so + /// the fault injected below (computed from that same sentinel) lands on the key production + /// actually writes to. + DB::Cas::tests::casAdmitRecoverableEntry(*backend, store->layout(), ns, store->liveWriterEpoch()); + + backend->fault_substr = store->layout().namespaceStreamPrefix(DB::Cas::tests::fixture::fixtureLife(ns)) + "_log/"; + backend->mode = DB::Cas::tests::ChunkFaultBackend::Mode::Unresolved; + backend->fault_count = 1; + + DB::Cas::tests::expectThrowsCode(DB::ErrorCodes::NETWORK_ERROR, [&] { publishEmptyPart(store, ns, "part_a"); }); + + ASSERT_TRUE(store->refLaneWedgedForTest(ns)); + EXPECT_EQ(store->laneStateForTest(ns), RefLaneState::Wedged) + << "a wedged lane may hold a durable transaction the runtime has not recorded"; +} + +/// Durable resolution installs the attempt and returns the lane to `Ready`. +TEST(CASRefInstallSafety, WedgeResolutionReturnsReady) +{ + auto backend = std::make_shared(); + auto store = openPoolSingleAttempt(backend); + const RootNamespace ns{"srv1/apply_state_unwedge"}; + /// Stage B (Task 4-C): pin `ns` to the Stage-A sentinel BEFORE its first real touch, so + /// the fault injected below (computed from that same sentinel) lands on the key production + /// actually writes to. + DB::Cas::tests::casAdmitRecoverableEntry(*backend, store->layout(), ns, store->liveWriterEpoch()); + + publishEmptyPart(store, ns, "x"); + publishEmptyPart(store, ns, "y"); + + /// The one mode that wedges over a GENUINELY durable object (see `ChunkFaultBackend`): the write + /// lands, its acknowledgement is lost, and the controller's own verifying read is lost too. + backend->fault_substr = store->layout().namespaceStreamPrefix(DB::Cas::tests::fixture::fixtureLife(ns)) + "_log/"; + backend->mode = DB::Cas::tests::ChunkFaultBackend::Mode::LandedThenLost; + backend->fault_count = 1; + DB::Cas::tests::expectThrowsCode(DB::ErrorCodes::NETWORK_ERROR, [&] { store->dropRef(ns, "x"); }); + ASSERT_TRUE(store->refLaneWedgedForTest(ns)); + ASSERT_EQ(store->laneStateForTest(ns), RefLaneState::Wedged); + + backend->mode = DB::Cas::tests::ChunkFaultBackend::Mode::None; + store->dropRef(ns, "y"); + + EXPECT_FALSE(store->refLaneWedgedForTest(ns)); + EXPECT_EQ(store->laneStateForTest(ns), RefLaneState::Ready) + << "the wedged transaction was proven durable AND installed, so nothing is owed any more"; +} + +/// A foreign occupant is a terminal `Faulted` verdict. +TEST(CASRefInstallSafety, ConclusiveForeignConflictFaultsTheLane) +{ + auto backend = std::make_shared(); + auto store = openPoolSingleAttempt(backend); + const RootNamespace ns{"srv1/apply_state_conflict"}; + /// Stage B (Task 4-C): pin `ns` to the Stage-A sentinel BEFORE its first real touch, so + /// the fault injected below (computed from that same sentinel) lands on the key production + /// actually writes to. + DB::Cas::tests::casAdmitRecoverableEntry(*backend, store->layout(), ns, store->liveWriterEpoch()); + + backend->fault_substr = store->layout().namespaceStreamPrefix(DB::Cas::tests::fixture::fixtureLife(ns)) + "_log/"; + backend->mode = DB::Cas::tests::ChunkFaultBackend::Mode::ForeignConflict; + backend->fault_count = 1; + + DB::Cas::tests::expectThrowsCode(DB::ErrorCodes::CORRUPTED_DATA, [&] { publishEmptyPart(store, ns, "part_a"); }); + + EXPECT_FALSE(store->refLaneWedgedForTest(ns)) << "a proven conflict is conclusive and must not wedge"; + EXPECT_EQ(store->laneStateForTest(ns), RefLaneState::Faulted) + << "a foreign occupant is a terminal protocol verdict, not a retryable attempt"; +} + +/// `DefiniteFailure` proves nothing became durable and returns the lane to `Ready`. Needs S3 error +/// classification: that is the only exception family +/// `classifyConditionalWriteResult` will ever call definite (everything else is fail-safe Unresolved). +TEST(CASRefInstallSafety, DefiniteFailureReturnsReady) +{ +#if !USE_AWS_S3 + GTEST_SKIP() << "DefiniteFailure classification requires S3 error types (USE_AWS_S3 off)"; +#else + auto backend = std::make_shared(); + auto store = openPoolSingleAttempt(backend); + const RootNamespace ns{"srv1/apply_state_definite"}; + /// Stage B (Task 4-C): pin `ns` to the Stage-A sentinel BEFORE its first real touch, so + /// the fault injected below (computed from that same sentinel) lands on the key production + /// actually writes to. + DB::Cas::tests::casAdmitRecoverableEntry(*backend, store->layout(), ns, store->liveWriterEpoch()); + + backend->fault_substr = store->layout().namespaceStreamPrefix(DB::Cas::tests::fixture::fixtureLife(ns)) + "_log/"; + backend->mode = DB::Cas::tests::ChunkFaultBackend::Mode::Definite; + backend->fault_count = 1; + + DB::Cas::tests::expectThrowsCode(DB::ErrorCodes::NETWORK_ERROR, [&] { publishEmptyPart(store, ns, "part_a"); }); + + EXPECT_FALSE(store->refLaneWedgedForTest(ns)) << "a definite failure is proven non-durable and must not wedge"; + EXPECT_EQ(store->laneStateForTest(ns), RefLaneState::Ready) + << "a definitively rejected PUT is proven non-durable, so no apply is owed"; +#endif +} + +/// A foreign occupant is the conclusive negative wedge resolution: +/// the wedged (write-once) key prove our body never landed there. `resolveByExactGet` never reports a +/// plain "absent" verdict -- absent or unreadable is `Unresolved`, since another attempt may still be +/// legal -- so this arm is the whole of "a resolution that proves the key is not ours". +/// +/// Runs in every build: the arm reports `CORRUPTED_DATA` (storage-controlled input must never be able +/// to abort the server), where it used to raise the process-aborting `LOGICAL_ERROR` and this test had +/// to be release-only with a death-test twin standing in. The marker is cleared BEFORE the anomaly +/// reaction, which is what this test pins; the fence/audit half is +/// `CASAnomalyPolicy.ForeignBytesAtWedgeKeyTripFenceAndRemount`'s. +TEST(CASRefInstallSafety, WedgeResolutionProvenForeignFaultsTheLane) +{ + auto backend = std::make_shared(); + auto store = openPoolSingleAttempt(backend); + const RootNamespace ns{"srv1/apply_state_foreign_wedge"}; + /// Stage B (Task 4-C): pin `ns` to the Stage-A sentinel BEFORE its first real touch, so + /// the fault injected below (computed from that same sentinel) lands on the key production + /// actually writes to. + DB::Cas::tests::casAdmitRecoverableEntry(*backend, store->layout(), ns, store->liveWriterEpoch()); + + publishEmptyPart(store, ns, "x"); + publishEmptyPart(store, ns, "y"); + + backend->fault_substr = store->layout().namespaceStreamPrefix(DB::Cas::tests::fixture::fixtureLife(ns)) + "_log/"; + backend->mode = DB::Cas::tests::ChunkFaultBackend::Mode::Unresolved; + backend->fault_count = 1; + DB::Cas::tests::expectThrowsCode(DB::ErrorCodes::NETWORK_ERROR, [&] { store->dropRef(ns, "x"); }); + ASSERT_EQ(store->laneStateForTest(ns), RefLaneState::Wedged); + + /// Out of band, a foreign writer lands DIFFERENT bytes at the exact wedged key. The fault mode is + /// off first so this write is not itself intercepted. + backend->mode = DB::Cas::tests::ChunkFaultBackend::Mode::None; + const String wedged_key = store->wedgedKeyForTest(ns); + ASSERT_FALSE(wedged_key.empty()); + ASSERT_EQ(backend->putIfAbsent(wedged_key, "a-different-object").outcome, PutOutcome::Done); + + DB::Cas::tests::expectThrowsCode(DB::ErrorCodes::CORRUPTED_DATA, [&] { store->dropRef(ns, "y"); }); + + EXPECT_FALSE(store->refLaneWedgedForTest(ns)); + EXPECT_EQ(store->laneStateForTest(ns), RefLaneState::Faulted) + << "foreign interference is a terminal verdict, not an unresolved attempt"; +} + +/// `Writing -> NeedsRecovery` at the ordinary candidate install. Unreachable +/// in production with §A1 landed -- the region allocates nothing -- so the probe seam simulates the +/// post-durable failure. The transaction is durable at that point, but the runtime has not installed it: +/// exactly the condition that `NeedsRecovery` names. +TEST(CASRefInstallSafety, PostDurableInstallFailureRequiresRecovery) +{ + auto backend = std::make_shared(); + auto store = openPool(backend); + const RootNamespace ns{"srv1/apply_state_poison"}; + + publishEmptyPart(store, ns, "x"); + ASSERT_EQ(store->laneStateForTest(ns), RefLaneState::Ready); + + const uint64_t needs_recovery_before = ProfileEvents::global_counters[ProfileEvents::CASRefNeedsRecovery].load(); + armOneShotInstallFailure(store); + DB::Cas::tests::expectThrowsCode(DB::ErrorCodes::MEMORY_LIMIT_EXCEEDED, [&] { store->dropRef(ns, "x"); }); + store->setInstallRegionProbeForTest(nullptr); + + EXPECT_EQ(store->laneStateForTest(ns), RefLaneState::NeedsRecovery) + << "an install that failed AFTER its object was durable must be visible, not silent"; + EXPECT_EQ(ProfileEvents::global_counters[ProfileEvents::CASRefNeedsRecovery].load() - needs_recovery_before, 1u) + << "the transition to NeedsRecovery must be exported exactly once"; +} + +/// `Wedged -> NeedsRecovery` at the wedge-resolution install. Same class of failure +/// one region over: the resolving GET already PROVED the object durable, so an install that does not +/// complete there leaves the same missing transaction -- and the wedge survives, because the swap that +/// would have cleared it is in the same region that threw. +TEST(CASRefInstallSafety, WedgeResolutionInstallFailureRequiresRecovery) +{ + auto backend = std::make_shared(); + auto store = openPoolSingleAttempt(backend); + const RootNamespace ns{"srv1/apply_state_poison_unwedge"}; + /// Stage B (Task 4-C): pin `ns` to the Stage-A sentinel BEFORE its first real touch, so + /// the fault injected below (computed from that same sentinel) lands on the key production + /// actually writes to. + DB::Cas::tests::casAdmitRecoverableEntry(*backend, store->layout(), ns, store->liveWriterEpoch()); + + publishEmptyPart(store, ns, "x"); + publishEmptyPart(store, ns, "y"); + + backend->fault_substr = store->layout().namespaceStreamPrefix(DB::Cas::tests::fixture::fixtureLife(ns)) + "_log/"; + backend->mode = DB::Cas::tests::ChunkFaultBackend::Mode::LandedThenLost; + backend->fault_count = 1; + DB::Cas::tests::expectThrowsCode(DB::ErrorCodes::NETWORK_ERROR, [&] { store->dropRef(ns, "x"); }); + ASSERT_TRUE(store->refLaneWedgedForTest(ns)); + + backend->mode = DB::Cas::tests::ChunkFaultBackend::Mode::None; + armOneShotInstallFailure(store); + DB::Cas::tests::expectThrowsCode(DB::ErrorCodes::MEMORY_LIMIT_EXCEEDED, [&] { store->dropRef(ns, "y"); }); + store->setInstallRegionProbeForTest(nullptr); + + EXPECT_EQ(store->laneStateForTest(ns), RefLaneState::NeedsRecovery); + EXPECT_FALSE(store->refLaneWedgedForTest(ns)) + << "known durability transfers ownership from the attempt to recovery"; +} + +/// A later append may proceed only after the top-of-flush recovery has replayed the known-durable +/// transaction and returned the lane to `Ready`. +TEST(CASRefInstallSafety, NeedsRecoveryReplaysBeforeALaterFlush) +{ + auto backend = std::make_shared(); + auto store = openPool(backend); + const RootNamespace ns{"srv1/apply_state_poison_terminal"}; + + publishEmptyPart(store, ns, "x"); + publishEmptyPart(store, ns, "y"); + + const uint64_t needs_recovery_before = ProfileEvents::global_counters[ProfileEvents::CASRefNeedsRecovery].load(); + armOneShotInstallFailure(store); + DB::Cas::tests::expectThrowsCode(DB::ErrorCodes::MEMORY_LIMIT_EXCEEDED, [&] { store->dropRef(ns, "x"); }); + store->setInstallRegionProbeForTest(nullptr); + ASSERT_EQ(store->laneStateForTest(ns), RefLaneState::NeedsRecovery); + + /// A perfectly ordinary, fully successful append afterwards. + store->dropRef(ns, "y"); + EXPECT_FALSE(store->resolveRef(ns, "y", /*allow_stale=*/false).has_value()) + << "the later flush must really have committed AND installed -- otherwise the assertion below " + "would pass for the wrong reason"; + + /// A flush's own success is not evidence that the stranded transaction was installed. What returns + /// the lane to `Ready` is the re-derivation that `ensureRefTableRecovered` performs at the top of + /// that same flush: the walk reads the stranded transaction from the durable log, then installs the + /// recovered state. + /// + /// Both halves are asserted, because only together do they mean the repair happened rather than the + /// state being relabeled: `x` really is gone (the stranded drop is applied at last), and the lane is + /// `Ready`. + EXPECT_FALSE(store->resolveRef(ns, "x", /*allow_stale=*/false).has_value()) + << "the stranded drop of 'x' is durable, so the re-derivation must install it before returning " + "the lane to Ready"; + EXPECT_EQ(store->laneStateForTest(ns), RefLaneState::Ready); + EXPECT_EQ(ProfileEvents::global_counters[ProfileEvents::CASRefNeedsRecovery].load() - needs_recovery_before, 1u) + << "the event counts transitions, so the successful flush must not have added another"; +} + +/// Part B review, BLOCKER 2: an UNCERTAIN `precommitAdd` must keep its cleanup owner and its body. +/// +/// `PartWriteTxn::precommitAdd` used to record `precommit_*` and set its `precommitted` flag only AFTER +/// `appendRefOps` returned. But an `Unresolved` append MAY HAVE LANDED -- the `Unresolved` arm of +/// `commitRefChunk` says exactly that, and wedges the lane for precisely that reason -- so on that path +/// `abandon` ran against an object that believed it had never precommitted. It therefore queued NO +/// removal, and `cleanupStagedManifestDebrisBestEffort`, deciding from the same unset state, DELETED the +/// manifest body. When the wedge later resolved as committed, the table gained a live precommit with no +/// cleanup owner and no body -- which clamps GC's fold barrier (a live precommit whose body is missing) +/// forever. +/// +/// The fix is the same discipline the wedge itself uses: record the intent BEFORE the ambiguous +/// operation. Both assertions below fail against the old code -- the body is gone, and the precommit is +/// still live once the wedge resolves. +TEST(CASRefInstallSafety, UncertainPrecommitKeepsItsCleanupOwnerAndItsBody) +{ + auto backend = std::make_shared(); + auto store = openPoolSingleAttempt(backend); + const RootNamespace ns{"srv1/uncertain_precommit"}; + /// Stage B (Task 4-C): pin `ns` to the Stage-A sentinel BEFORE its first real touch, so + /// the fault injected below (computed from that same sentinel) lands on the key production + /// actually writes to. + DB::Cas::tests::casAdmitRecoverableEntry(*backend, store->layout(), ns, store->liveWriterEpoch()); + + PartWriteInfo info; + info.intended_namespace = ns; + info.intended_ref = ns.string() + "/part_a"; + auto build = store->beginPartWrite(info); + const ManifestId id = build->stageManifest({}); + const String manifest_key = store->layout().manifestKey(id); + ASSERT_TRUE(backend->head(manifest_key).exists) << "the staged body must exist before the precommit"; + + /// Scoped to THIS namespace's ref log so the manifest body's own PUT cannot consume the fault. The + /// object LANDS and only its acknowledgement is lost, which with the single-attempt budget wedges + /// the lane over a genuinely durable precommit -- the exact shape the old code mishandled. + backend->fault_substr = store->layout().namespaceStreamPrefix(DB::Cas::tests::fixture::fixtureLife(ns)) + "_log/"; + backend->mode = DB::Cas::tests::ChunkFaultBackend::Mode::LandedThenLost; + backend->fault_count = 1; + DB::Cas::tests::expectThrowsCode(DB::ErrorCodes::NETWORK_ERROR, [&] { build->precommitAdd(ns, "part_a", id); }); + ASSERT_TRUE(store->refLaneWedgedForTest(ns)) << "the lost-response precommit must wedge the lane"; + EXPECT_EQ(build->precommitState(), PartWriteTxn::PrecommitState::Uncertain) + << "an append that may have landed is neither 'never precommitted' nor 'durably precommitted'"; + + /// The cleanup owner survives the uncertainty: this `abandon` resolves the wedge (proving the + /// precommit durable) and appends the exact removal in the same flush. + build->abandon(); + + EXPECT_TRUE(backend->head(manifest_key).exists) + << "abandon writer-deleted the body of a precommit that may be live -- GC's fold barrier would " + "clamp on it forever"; + EXPECT_TRUE(store->livePrecommitsForTest(ns).empty()) + << "the uncertain precommit landed, so abandon owed its exact removal"; + EXPECT_FALSE(store->refLaneWedgedForTest(ns)) << "the abandon's own flush must have resolved the wedge"; +} + +/// The other side of the same state, and the reason it is a STATE and not just an extra bool: an +/// `Uncertain` precommit that in fact never landed must not make `abandon` fail forever. +/// +/// `RefTableState::applyOwnerTransition` rejects a removal whose `old_binding` names an absent +/// precommit, so the removal is NOT unconditionally idempotent (the review's "it is idempotent" is only +/// true with the presence check this test pins). Here the append is refused BEFORE any request is sent, +/// which is provably-nothing-durable, and yet the transaction has already recorded the intent -- so the +/// removal it owes must resolve to a no-op rather than to `CORRUPTED_DATA`. +TEST(CASRefInstallSafety, UncertainPrecommitThatNeverLandedStillAbandonsCleanly) +{ + auto backend = std::make_shared(); + auto store = openPoolFenceControlled(backend); + const RootNamespace ns{"srv1/uncertain_precommit_absent"}; + + PartWriteInfo info; + info.intended_namespace = ns; + info.intended_ref = ns.string() + "/part_a"; + auto build = store->beginPartWrite(info); + const ManifestId id = build->stageManifest({}); + + /// A lease with room for the flush but not for one whole controlled attempt: the pre-attempt gate + /// refuses, nothing is sent, and no wedge forms (`PreAttemptRefusalDoesNotWedgeTheLane`). + store->setMountDeadline(FENCE_DEADLINE_REFUSES_ATTEMPT_MS); + DB::Cas::tests::expectThrowsCode(DB::ErrorCodes::NETWORK_ERROR, [&] { build->precommitAdd(ns, "part_a", id); }); + ASSERT_FALSE(store->refLaneWedgedForTest(ns)); + EXPECT_EQ(build->precommitState(), PartWriteTxn::PrecommitState::Uncertain); + + store->setMountDeadline(FENCE_DEADLINE_HEALTHY_MS); + build->abandon(); /// must not throw: there is no binding to remove, and that is not an anomaly here + EXPECT_EQ(build->precommitState(), PartWriteTxn::PrecommitState::Settled); + EXPECT_TRUE(store->livePrecommitsForTest(ns).empty()); +} diff --git a/src/Disks/tests/gtest_cas_ref_intake.cpp b/src/Disks/tests/gtest_cas_ref_intake.cpp new file mode 100644 index 000000000000..49ae6bed4137 --- /dev/null +++ b/src/Disks/tests/gtest_cas_ref_intake.cpp @@ -0,0 +1,260 @@ +#include +#include +#include "cas_test_helpers.h" +#include +#include + +using namespace DB::Cas; + +namespace +{ + +ManifestRef mr(uint64_t epoch, uint64_t seq, uint32_t ordinal = 1) +{ + return ManifestRef{epoch, seq, ordinal}; +} + +RefTxnId rid(uint64_t epoch, uint64_t seq) +{ + return RefTxnId{epoch, seq}; +} + +RefOp addOwner(RefOwnerKind kind, const String & ref, const ManifestRef & manifest) +{ + RefOp op; + op.kind = RefOpKind::OwnerTransition; + op.new_binding = RefOwnerBinding{kind, ref, manifest}; + return op; +} + +RefOp removeOwner(RefOwnerKind kind, const String & ref, const ManifestRef & manifest) +{ + RefOp op; + op.kind = RefOpKind::OwnerTransition; + op.old_binding = RefOwnerBinding{kind, ref, manifest}; + return op; +} + +RefOp promote(const String & ref, const ManifestRef & manifest) +{ + RefOp op; + op.kind = RefOpKind::OwnerTransition; + op.old_binding = RefOwnerBinding{RefOwnerKind::Precommit, ref, manifest}; + op.new_binding = RefOwnerBinding{RefOwnerKind::Committed, ref, manifest}; + return op; +} + +/// A raw `owner_transition` op from explicit optional bindings, bypassing every shape-builder above -- +/// used by the rejection tests to construct shapes `classifyOwnerTransitionShape` does not recognize. +RefOp rawOwnerTransition(std::optional old_binding, std::optional new_binding) +{ + RefOp op; + op.kind = RefOpKind::OwnerTransition; + op.old_binding = std::move(old_binding); + op.new_binding = std::move(new_binding); + return op; +} + +RefLogTxn txn(const String & ns, RefTxnId id, std::vector ops) +{ + RefLogTxn t; + t.ns = ns; + t.txn_id = id; + t.ops = std::move(ops); + return t; +} + +} + +/// spec §gc-step-produce-manifest-edge-delta: each explicit operation states its own edge change. +TEST(CASRefIntake, ManifestEdgesPerOperationShape) +{ + /// Add precommit => one +1. + { + const auto edges = manifestEdgesOfTxn(txn("db/t", rid(1, 1), {addOwner(RefOwnerKind::Precommit, "p", mr(1, 5))})); + ASSERT_EQ(edges.size(), 1u); + EXPECT_EQ(edges[0].change, 1); + EXPECT_EQ(edges[0].manifest_id, (ManifestId{RootNamespace{"db/t"}, mr(1, 5)})); + EXPECT_EQ(edges[0].op_ordinal, 0u); + EXPECT_EQ(edges[0].edge_ordinal, 1u); + } + /// Remove committed => one -1. + { + const auto edges = manifestEdgesOfTxn(txn("db/t", rid(1, 2), {removeOwner(RefOwnerKind::Committed, "p", mr(1, 5))})); + ASSERT_EQ(edges.size(), 1u); + EXPECT_EQ(edges[0].change, -1); + EXPECT_EQ(edges[0].manifest_id, (ManifestId{RootNamespace{"db/t"}, mr(1, 5)})); + } + /// Remove precommit => one -1 (the fourth classified shape, distinct from remove committed only by + /// `old_binding.kind`). + { + const auto edges = manifestEdgesOfTxn(txn("db/t", rid(1, 25), {removeOwner(RefOwnerKind::Precommit, "p", mr(1, 5))})); + ASSERT_EQ(edges.size(), 1u); + EXPECT_EQ(edges[0].change, -1); + EXPECT_EQ(edges[0].owner_kind, RefOwnerKind::Precommit); + EXPECT_EQ(edges[0].manifest_id, (ManifestId{RootNamespace{"db/t"}, mr(1, 5)})); + } + /// Promote same manifest => no net edge (spec §Promote). + { + const auto edges = manifestEdgesOfTxn(txn("db/t", rid(1, 3), {promote("p", mr(1, 5))})); + EXPECT_TRUE(edges.empty()); + } + /// set_published_at / namespace_birth / remove_namespace => no edge. + { + RefOp set_published_at; + set_published_at.kind = RefOpKind::SetPublishedAt; + set_published_at.ref_name = "p"; + set_published_at.expected_manifest_ref = mr(1, 5); + EXPECT_TRUE(manifestEdgesOfTxn(txn("db/t", rid(1, 4), {set_published_at})).empty()); + + RefOp birth; + birth.kind = RefOpKind::NamespaceBirth; + EXPECT_TRUE(manifestEdgesOfTxn(txn("db/t", rid(1, 5), {birth})).empty()); + } + /// Replace one manifest by a different one (two explicit ops) => -1 old, +1 new. + { + const auto edges = manifestEdgesOfTxn(txn("db/t", rid(1, 6), + {removeOwner(RefOwnerKind::Committed, "p", mr(1, 5)), addOwner(RefOwnerKind::Precommit, "p", mr(1, 6))})); + ASSERT_EQ(edges.size(), 2u); + EXPECT_EQ(edges[0].change, -1); + EXPECT_EQ(edges[0].manifest_id.ref, mr(1, 5)); + EXPECT_EQ(edges[1].change, 1); + EXPECT_EQ(edges[1].manifest_id.ref, mr(1, 6)); + } +} + +/// `manifestEdgesOfTxn` rejects every `owner_transition` shape outside the four `classifyOwnerTransitionShape` +/// recognizes (Pool/CasRefProtocol.cpp) -- it must never silently assign edge meaning to a shape the +/// writer/replay state machine would refuse to apply. Each case throws `CORRUPTED_DATA`. +TEST(CASRefIntake, ManifestEdgesRejectsUnrecognizedShapes) +{ + /// Neither binding: a degenerate owner_transition that names no owner change at all. + EXPECT_THROW(manifestEdgesOfTxn(txn("db/t", rid(1, 1), {rawOwnerTransition(std::nullopt, std::nullopt)})), + DB::Exception); + + /// old+new naming DIFFERENT manifests in ONE op (the never-legal "replace" shape; an atomic + /// manifest replace is always two ops -- an explicit removal then a same-manifest promote). + EXPECT_THROW(manifestEdgesOfTxn(txn("db/t", rid(1, 2), + {rawOwnerTransition(RefOwnerBinding{RefOwnerKind::Committed, "p", mr(1, 5)}, + RefOwnerBinding{RefOwnerKind::Precommit, "p", mr(1, 6)})})), + DB::Exception); + + /// Promote-shaped kinds (old=Precommit, new=Committed) but with MISMATCHED ref_names. + EXPECT_THROW(manifestEdgesOfTxn(txn("db/t", rid(1, 3), + {rawOwnerTransition(RefOwnerBinding{RefOwnerKind::Precommit, "p", mr(1, 5)}, + RefOwnerBinding{RefOwnerKind::Committed, "q", mr(1, 5)})})), + DB::Exception); + + /// Add with new.kind == Committed (only Precommit is a legal add target). + EXPECT_THROW(manifestEdgesOfTxn(txn("db/t", rid(1, 4), + {rawOwnerTransition(std::nullopt, RefOwnerBinding{RefOwnerKind::Committed, "p", mr(1, 5)})})), + DB::Exception); + + /// old+new both Committed, same manifest: not a promote (promote requires old.kind == Precommit). + EXPECT_THROW(manifestEdgesOfTxn(txn("db/t", rid(1, 5), + {rawOwnerTransition(RefOwnerBinding{RefOwnerKind::Committed, "p", mr(1, 5)}, + RefOwnerBinding{RefOwnerKind::Committed, "p", mr(1, 5)})})), + DB::Exception); +} + +/// Namespaces are edge-distinct even with identical ManifestRef tuples (spec §gc-inputs-and-output). +TEST(CASRefIntake, EdgesAreNamespaceQualified) +{ + const auto a = manifestEdgesOfTxn(txn("db/a", rid(1, 1), {addOwner(RefOwnerKind::Precommit, "p", mr(1, 5))})); + const auto b = manifestEdgesOfTxn(txn("db/b", rid(1, 1), {addOwner(RefOwnerKind::Precommit, "p", mr(1, 5))})); + ASSERT_EQ(a.size(), 1u); + ASSERT_EQ(b.size(), 1u); + EXPECT_NE(a[0].manifest_id, b[0].manifest_id); +} + +TEST(CASRefIntake, RemovalTxnIdDetection) +{ + RefOp remove_ns; + remove_ns.kind = RefOpKind::RemoveNamespace; + const auto with_removal = txn("db/t", rid(3, 8), {removeOwner(RefOwnerKind::Committed, "p", mr(1, 5)), remove_ns}); + ASSERT_TRUE(removalTxnId(with_removal).has_value()); + EXPECT_EQ(*removalTxnId(with_removal), rid(3, 8)); + + const auto ordinary = txn("db/t", rid(3, 9), {addOwner(RefOwnerKind::Precommit, "p", mr(1, 5))}); + EXPECT_FALSE(removalTxnId(ordinary).has_value()); +} + +/// spec §Step 1: one global LIST groups by table, split by kind, sorted; the reconstructed namespace is +/// re-validated (VERIFY-AT-T12) and a malformed ref key aborts ref folding (throws). +TEST(CASRefIntake, GroupRefKeys) +{ + const Layout layout{"p"}; + const RootNamespace ns{"db/t"}; + const NamespaceLifeId life = DB::Cas::tests::fixture::fixtureLife(ns); + + std::vector keys{ + layout.refSnapshotKey(life, rid(1, 4)), + layout.refLogKey(life, rid(1, 5)), + layout.refLogKey(life, rid(1, 3)), + layout.refCkptKey(life), /// state-family keys are outside the hot stream LIST + "p/cas/manifests/db/t/foo", /// outside the ref prefix -> ignored + }; + const auto grouped = groupRefKeys(layout, keys); + ASSERT_EQ(grouped.size(), 1u); + const RefTableListing & t = grouped.at(life.incarnation); + EXPECT_EQ(t.logs, (std::vector{rid(1, 3), rid(1, 5)})); + EXPECT_EQ(t.snapshots, (std::vector{rid(1, 4)})); + /// Checkpoints live under `cas/ns/state/` and are deliberately absent from the hot stream listing. + + /// A key under the ref prefix that is not a valid ref object aborts (a leftover old-format shard key). + EXPECT_THROW(groupRefKeys(layout, {"p/cas/ns/stream/0"}), DB::Exception); + /// A malformed physical id under a valid stream prefix aborts. + EXPECT_THROW(groupRefKeys(layout, {"p/cas/ns/stream/not-an-id/_log/" + renderRefTxnId(rid(1, 1))}), DB::Exception); +} + +/// A LIST can observe a snapshot after its PUT but before the `_ckpt` CAS makes it a recovery base. +/// That physical object proves nothing by itself: without a checkpoint-named triple, cleanup leaks +/// rather than deleting either the genesis log or the unacknowledged snapshot. +TEST(CASRefIntake, PlanRefCleanupRequiresCheckpointNamedBase) +{ + RefTableListing listing; + listing.logs = {rid(1, 1), rid(1, 2), rid(1, 3)}; + listing.snapshots = {rid(1, 2)}; /// newest observed snapshot X = (1,2) + + /// Even a complete-looking listing and cursor do not license cleanup without the checkpoint's + /// exact base. This is the snapshot-PUT-before-checkpoint-CAS sabotage. + { + const auto plan = planRefCleanup(listing, rid(1, 3), {}); + EXPECT_TRUE(plan.deletable_logs.empty()); + EXPECT_TRUE(plan.deletable_snapshots.empty()); + } + /// A smaller cursor cannot turn that incomplete authority into a cleanup range. + { + const auto plan = planRefCleanup(listing, rid(1, 1), {}); + EXPECT_TRUE(plan.deletable_logs.empty()); + } + /// Nor may a newer listed snapshot reclaim an older listed snapshot before `_ckpt` names a base. + { + RefTableListing two_snaps = listing; + two_snaps.snapshots = {rid(1, 1), rid(1, 2)}; + const auto plan = planRefCleanup(two_snaps, rid(1, 3), {}); + EXPECT_TRUE(plan.deletable_snapshots.empty()); + } + /// No snapshot => no coverage boundary => empty plan (condition 2). + { + RefTableListing no_snap; + no_snap.logs = {rid(1, 1)}; + const auto plan = planRefCleanup(no_snap, rid(1, 5), {}); + EXPECT_TRUE(plan.deletable_logs.empty()); + EXPECT_TRUE(plan.deletable_snapshots.empty()); + } +} + +/// The checkpoint recovery anchor is a triple: `_ckpt`, its same-id `_snap`, and the same-id ordinary +/// `_log` that proves the id is not an `EpochSeal`. Cleanup may reclaim older covered logs, but must +/// retain that one witness for recovery and fsck. +TEST(CASRefIntake, PlanRefCleanupRetainsCheckpointBaseLog) +{ + RefTableListing listing; + listing.logs = {rid(1, 1), rid(1, 2), rid(1, 3)}; + listing.snapshots = {rid(1, 2)}; + + const RefCleanupPlan plan = planRefCleanup(listing, rid(1, 3), rid(1, 2)); + EXPECT_EQ(plan.deletable_logs, (std::vector{rid(1, 1)})); + EXPECT_TRUE(plan.deletable_snapshots.empty()); +} diff --git a/src/Disks/tests/gtest_cas_ref_lane_exception_safety.cpp b/src/Disks/tests/gtest_cas_ref_lane_exception_safety.cpp new file mode 100644 index 000000000000..a88838dd7049 --- /dev/null +++ b/src/Disks/tests/gtest_cas_ref_lane_exception_safety.cpp @@ -0,0 +1,217 @@ +#include + +#include +#include +#include +#include + +#include +#include +#include +#include +#include + +/// Task 1: ref-lane exception-safety. A queue leader that throws BEFORE carving its compatible batch +/// must not leave its own enqueued item stranded in `rt->pending`. If it does, a later leader (a woken +/// follower) carves the stranded item and runs its `build_ops` closure long after the original caller's +/// stack -- which the production `[&]` closures capture by reference -- has unwound: a use-after-free. +/// +/// These tests drive the fault through the SAME pre-carve injection point production leaders pass +/// (`setRefPreCarveHookForTest`, invoked inside `flushRefBatch` immediately before the batch is carved). +/// The suite name is prefixed `RefWriter` so it is covered by the `RefWriter*` unit-test gate filter. + +namespace DB::ErrorCodes +{ +extern const int CORRUPTED_DATA; +} + +using namespace DB::Cas; + +namespace +{ + +PoolPtr openPoolForRefLane(const BackendPtr & backend) +{ + return Pool::open(backend, PoolConfig{.pool_prefix = "p", .server_root_id = "test"}); +} + +} + +/// A SOLO faulted caller must not leave its own item behind in the pending queue. Before the fix, the +/// leader's `appendRefOps` catch reset `leader_active` and rethrew but never completed / de-pended the +/// leader's own item, so it was stranded in `rt->pending` with `done == false` forever (nothing left to +/// carve it) -- the deterministic, sanitizer-independent shape of the stranded-item defect. +TEST(CASRefWriterLaneExceptionSafety, SoloLeaderThrowBeforeCarveDrainsOwnItem) +{ + auto backend = std::make_shared(); + auto store = openPoolForRefLane(backend); + const RootNamespace ns{"srv1/reflane_solo"}; + + std::atomic fault_armed{1}; + store->setRefPreCarveHookForTest([&] + { + if (fault_armed.exchange(0) == 1) + throw DB::Exception(DB::ErrorCodes::CORRUPTED_DATA, "injected pre-carve fault"); + }); + + bool threw = false; + try + { + store->appendRefOps(ns, MutationScope::ref("ref_solo"), + [](const RefTableState &) -> std::vector { return {}; }, + RootMutationOrigin::Writer, RootMutationKind::Publish); + } + catch (const DB::Exception &) + { + threw = true; + } + store->setRefPreCarveHookForTest(nullptr); + + EXPECT_TRUE(threw) << "the faulted solo caller must observe the injected error"; + EXPECT_EQ(store->refQueuePendingForTest(ns), 0u) + << "the leader's own item was left stranded in rt->pending after it threw before carving"; +} + +/// Two concurrent callers on one namespace. The first flush's leader throws before carving; a woken +/// follower then leads. Before the fix, the follower carved the faulted leader's STILL-pending item and +/// ran its `build_ops` closure -- the use-after-free window. This asserts, sanitizer-independently, that +/// the follower never invokes the faulted caller's closure, that the queue drains, and that the +/// non-faulted caller still completes. +TEST(CASRefWriterLaneExceptionSafety, FollowerNeverRunsStrandedLeaderClosure) +{ + auto backend = std::make_shared(); + auto store = openPoolForRefLane(backend); + const RootNamespace ns{"srv1/reflane_follower"}; + + std::atomic fault_armed{1}; + /// The leader parks HERE, at the pre-carve point, until the main thread has queued the follower + /// behind it -- and only then throws. Parking (rather than letting the leader race ahead while the + /// main thread polls the queue depth) is what makes the interleaving this test is about -- + /// "the leader throws WHILE a follower is waiting for the baton" -- deterministic. The previous + /// formulation polled `refQueuePendingForTest(ns) >= 1` from the main thread AFTER starting t1, + /// which loses a race the scheduler decides: t1 could enqueue, take the baton, throw, and have its + /// item erased by `completeOwnedItemsAndReleaseLeadership` before the main thread was ever + /// scheduled to sample -- after which `pending` is 0 forever and the poll spins until the harness + /// is killed. Invisible on an idle 32-core box (10/10 green) and a guaranteed hang under + /// contention (8/8 when pinned to one CPU with `taskset -c 3`). + std::mutex hook_mutex; + std::condition_variable hook_cv; + bool leader_parked_at_precarve = false; /// guarded by hook_mutex + bool release_leader = false; /// guarded by hook_mutex + store->setRefPreCarveHookForTest([&] + { + /// Park+throw only on the FIRST leader flush, so the follower (or a re-drive) can proceed. + if (fault_armed.exchange(0) != 1) + return; + { + std::lock_guard announce(hook_mutex); + leader_parked_at_precarve = true; + } + hook_cv.notify_all(); + { + std::unique_lock wait_for_follower(hook_mutex); + hook_cv.wait(wait_for_follower, [&] { return release_leader; }); + } + throw DB::Exception(DB::ErrorCodes::CORRUPTED_DATA, "injected pre-carve fault"); + }); + + /// Set by the faulted caller's own closure iff a DIFFERENT thread (a follower leader) ever runs it -- + /// i.e. the stranded item was carved by someone other than its owner. This is the direct, portable + /// signature of the use-after-free the fix prevents. + std::atomic faulted_owner{}; + std::atomic faulted_closure_ran_on_follower{false}; + + std::atomic ok{0}; + auto caller = [&](int seq, bool is_faulted) + { + try + { + store->appendRefOps(ns, MutationScope::ref("ref_" + std::to_string(seq)), + [&, is_faulted](const RefTableState &) -> std::vector + { + if (is_faulted && std::this_thread::get_id() != faulted_owner.load()) + faulted_closure_ran_on_follower.store(true); + return {}; + }, + RootMutationOrigin::Writer, RootMutationKind::Publish); + ok.fetch_add(1); + } + catch (const DB::Exception &) // NOLINT(bugprone-empty-catch) + { + /// The faulted caller may see the injected error; that is expected. + } + }; + + /// Serialize the two callers so the fault deterministically lands on the FIRST one to lead: t1 + /// enqueues, takes the baton, and PARKS at the pre-carve hook; t2 then queues behind it as a + /// follower; only then is t1 released to throw. + std::thread t1([&] + { + faulted_owner.store(std::this_thread::get_id()); + caller(1, /*is_faulted=*/true); + }); + { + std::unique_lock wait_for_leader(hook_mutex); + hook_cv.wait(wait_for_leader, [&] { return leader_parked_at_precarve; }); + } + std::thread t2([&] { caller(2, /*is_faulted=*/false); }); + /// The parked leader cannot drain anything, so this poll cannot miss its window: t1's own item is + /// already in `pending`, and the count reaches 2 as soon as t2 has enqueued. + while (store->refQueuePendingForTest(ns) < 2) + std::this_thread::yield(); + { + std::lock_guard release(hook_mutex); + release_leader = true; + } + hook_cv.notify_all(); + + t1.join(); + t2.join(); + store->setRefPreCarveHookForTest(nullptr); + + EXPECT_FALSE(faulted_closure_ran_on_follower.load()) + << "a follower leader carved and ran the stranded faulted caller's build_ops closure (use-after-free)"; + EXPECT_EQ(store->refQueuePendingForTest(ns), 0u) << "an item was stranded in rt->pending"; + EXPECT_GE(ok.load(), 1) << "the non-faulted caller must complete cleanly"; +} + +/// codex stage-1 review (Important): an allocation exception at the PRE-TENURE point -- the first +/// allocation that builds the leader's responsibility set, BEFORE `leader_active` is published -- must +/// not permanently strand the append-lane baton. Before the fix the throwing allocation fired AFTER +/// `leader_active = true` (and after the queue mutex was released), leaving the baton held with no live +/// leader and the caller's item stuck in `pending`: every later writer on the namespace would wait +/// forever at the leader-election cv, and shutdown draining could only time out. This drives the fault +/// through the dedicated pre-tenure seam and asserts, deterministically (no hang), that the lane is left +/// idle: the item is un-enqueued and the baton is un-taken. +TEST(CASRefWriterLaneExceptionSafety, PreTenureAllocFailureReleasesBaton) +{ + auto backend = std::make_shared(); + auto store = openPoolForRefLane(backend); + const RootNamespace ns{"srv1/reflane_pretenure"}; + + std::atomic fault_armed{1}; + store->setRefPreTenureHookForTest([&] + { + if (fault_armed.exchange(0) == 1) + throw DB::Exception(DB::ErrorCodes::CORRUPTED_DATA, "injected pre-tenure fault"); + }); + + bool threw = false; + try + { + store->appendRefOps(ns, MutationScope::ref("ref_pretenure"), + [](const RefTableState &) -> std::vector { return {}; }, + RootMutationOrigin::Writer, RootMutationKind::Publish); + } + catch (const DB::Exception &) + { + threw = true; + } + store->setRefPreTenureHookForTest(nullptr); + + EXPECT_TRUE(threw) << "the faulted caller must observe the injected error"; + EXPECT_EQ(store->refQueuePendingForTest(ns), 0u) + << "a pre-tenure allocation failure left the caller's item stranded in rt->pending"; + EXPECT_FALSE(store->refLeaderActiveForTest(ns)) + << "a pre-tenure allocation failure left the append-lane baton held with no live leader"; +} diff --git a/src/Disks/tests/gtest_cas_ref_log_format.cpp b/src/Disks/tests/gtest_cas_ref_log_format.cpp new file mode 100644 index 000000000000..d5186e77aca1 --- /dev/null +++ b/src/Disks/tests/gtest_cas_ref_log_format.cpp @@ -0,0 +1,790 @@ +#include "cas_format_test_battery.h" +#include +#include +#include +#include +#include +#include + +/// v3 text codec tests for `cas_ref_log` (codecs-v3 phase 3). Split out of the retired +/// `gtest_cas_ref_codecs.cpp` and re-pointed at the TEXT codec: the encoder-side validation tests are +/// format-agnostic (they only assert `encodeRefLogTxn` throws) and carry over verbatim; the old +/// binary-offset byte-patch decode tests (`bytes[k] = 99`) are gone — the shape-level corruption +/// classes (truncation, `v`+1 forward-gate, wrong type, leading garbage) are now covered by the +/// `CASFormatBattery.RefLog` row below. `RefTxnId` render/parse coverage lives here too (it rode in +/// the same suite and is independent of either ref codec). + +using namespace DB::Cas; +using DB::Cas::tests::expectThrowsCode; + +namespace +{ + +ManifestRef manifestRef(uint64_t epoch, uint64_t seq, uint32_t ordinal) +{ + return ManifestRef{epoch, seq, ordinal}; +} + +} + +/// =================================================================================== +/// RefTxnId: render / parse +/// =================================================================================== + +TEST(CASRefCodec, RenderCanonicalForm) +{ + EXPECT_EQ(renderRefTxnId(RefTxnId{7, 0x8e}), "0000000000000007-000000000000008e"); + EXPECT_EQ(renderRefTxnId(RefTxnId{1, 1}), "0000000000000001-0000000000000001"); + EXPECT_EQ(renderRefTxnId(RefTxnId{0xffffffffffffffffULL, 0xffffffffffffffffULL}), + "ffffffffffffffff-ffffffffffffffff"); +} + +TEST(CASRefCodec, RenderRejectsZeroComponent) +{ + EXPECT_DEATH( + { + DB::abort_on_logical_error.store(true, std::memory_order_relaxed); + renderRefTxnId(RefTxnId{0, 1}); + }, + "RefTxnId: writer_epoch and ref_sequence must both be nonzero"); + EXPECT_DEATH( + { + DB::abort_on_logical_error.store(true, std::memory_order_relaxed); + renderRefTxnId(RefTxnId{1, 0}); + }, + "RefTxnId: writer_epoch and ref_sequence must both be nonzero"); + EXPECT_DEATH( + { + DB::abort_on_logical_error.store(true, std::memory_order_relaxed); + renderRefTxnId(RefTxnId{0, 0}); + }, + "RefTxnId: writer_epoch and ref_sequence must both be nonzero"); +} + +TEST(CASRefCodec, ParseRoundTrip) +{ + for (const RefTxnId id : {RefTxnId{7, 0x8e}, RefTxnId{1, 1}, RefTxnId{255, 2}, RefTxnId{0x100000000ULL, 3}, + RefTxnId{0x8000000000000000ULL, 0x8000000000000000ULL}, + RefTxnId{0xffffffffffffffffULL, 0xffffffffffffffffULL}}) + { + const String rendered = renderRefTxnId(id); + const auto parsed = parseRefTxnId(rendered); + ASSERT_TRUE(parsed.has_value()); + EXPECT_EQ(*parsed, id); + } +} + +TEST(CASRefCodec, ParseRejectsShort) +{ + EXPECT_FALSE(parseRefTxnId("000000000000007-000000000000008e").has_value()); /// 32 chars, one short + EXPECT_FALSE(parseRefTxnId("7-8e").has_value()); + EXPECT_FALSE(parseRefTxnId("").has_value()); +} + +TEST(CASRefCodec, ParseRejectsLong) +{ + EXPECT_FALSE(parseRefTxnId("00000000000000007-000000000000008e").has_value()); /// 34 chars, one long + EXPECT_FALSE(parseRefTxnId("0000000000000007-000000000000008e0").has_value()); +} + +TEST(CASRefCodec, ParseRejectsUppercase) +{ + EXPECT_FALSE(parseRefTxnId("0000000000000007-00000000000000AE").has_value()); + EXPECT_FALSE(parseRefTxnId("0000000000000007-000000000000008E").has_value()); + EXPECT_FALSE(parseRefTxnId("0000000000000007-00000000000000Ae").has_value()); /// mixed case +} + +TEST(CASRefCodec, ParseRejectsZeroComponent) +{ + EXPECT_FALSE(parseRefTxnId("0000000000000000-000000000000008e").has_value()); + EXPECT_FALSE(parseRefTxnId("0000000000000007-0000000000000000").has_value()); + EXPECT_FALSE(parseRefTxnId("0000000000000000-0000000000000000").has_value()); +} + +TEST(CASRefCodec, ParseRejectsNonHexGarbage) +{ + EXPECT_FALSE(parseRefTxnId("000000000000000g-000000000000008e").has_value()); + EXPECT_FALSE(parseRefTxnId("!!!!!!!!!!!!!!!!-000000000000008e").has_value()); + EXPECT_FALSE(parseRefTxnId("0000000000000007_000000000000008e").has_value()); /// wrong separator +} + +TEST(CASRefCodec, ParseRejectsMisplacedSeparator) +{ + /// 17 hex digits then '-' then 15: same total length (33), dash at the wrong index -- the kind of + /// shape that, read naively without a fixed dash position, could be mistaken for an in-range but + /// overflowing first component. + EXPECT_FALSE(parseRefTxnId("00000000000000078-00000000000000e").has_value()); +} + +TEST(CASRefCodec, OrderMatchesLexicalOrderOfRender) +{ + const std::vector values{1, 2, 255, 1ULL << 32, 1ULL << 63}; + std::vector ids; + for (uint64_t epoch : values) + for (uint64_t seq : values) + ids.push_back(RefTxnId{epoch, seq}); + + std::mt19937 rng(42); // NOLINT(cert-msc32-c,cert-msc51-cpp): deterministic seed is required for reproducible property coverage. + for (int iter = 0; iter < 200; ++iter) + { + const RefTxnId & a = ids[rng() % ids.size()]; + const RefTxnId & b = ids[rng() % ids.size()]; + const String ra = renderRefTxnId(a); + const String rb = renderRefTxnId(b); + EXPECT_EQ(a < b, ra < rb) << ra << " vs " << rb; + EXPECT_EQ(a == b, ra == rb); + } +} + +/// =================================================================================== +/// RefLogTxn: round trip +/// =================================================================================== + +TEST(CASRefCodec, RoundTripNamespaceBirth) +{ + RefLogTxn txn; + txn.ns = "srv1/db/table@cas@"; + txn.txn_id = RefTxnId{1, 1}; + RefOp op; + op.kind = RefOpKind::NamespaceBirth; + txn.ops.push_back(op); + + const String bytes = encodeRefLogTxn(txn); + const RefLogTxn decoded = decodeRefLogTxn(bytes, txn.ns, txn.txn_id); + EXPECT_EQ(decoded, txn); +} + +TEST(CASRefCodec, RoundTripRemoveNamespace) +{ + RefLogTxn txn; + txn.ns = "srv1/db/table@cas@"; + txn.txn_id = RefTxnId{1, 2}; + RefOp op; + op.kind = RefOpKind::RemoveNamespace; + txn.ops.push_back(op); + + const String bytes = encodeRefLogTxn(txn); + const RefLogTxn decoded = decodeRefLogTxn(bytes, txn.ns, txn.txn_id); + EXPECT_EQ(decoded, txn); +} + +TEST(CASRefCodec, RoundTripSetPublishedAt) +{ + RefLogTxn txn; + txn.ns = "srv1/db/table@cas@"; + txn.txn_id = RefTxnId{3, 5}; + RefOp op; + op.kind = RefOpKind::SetPublishedAt; + op.ref_name = "all_1_1_0"; + op.expected_manifest_ref = manifestRef(3, 4, 1); + op.published_at_ms = 1717000000000ULL; + txn.ops.push_back(op); + + const String bytes = encodeRefLogTxn(txn); + const RefLogTxn decoded = decodeRefLogTxn(bytes, txn.ns, txn.txn_id); + EXPECT_EQ(decoded, txn); +} + +/// No-tolerance decode pin (codex round-2, finding 3): the `"pl"` (payload) field was removed from the +/// ref-op wire in stage-1 T12. Although the retired `set_payload` op WORD is already rejected by +/// `opKindFromWord`, the generic op-record reader reads all field keys before switching on kind, so a +/// `"pl"` field paired with a still-recognized op word would otherwise be `skipUnknown`'d. It is a +/// removed field, not a genuinely-unknown one: decoding an op record that still carries `"pl"` must FAIL +/// with `CORRUPTED_DATA` naming the removed field. +TEST(CASRefCodec, DecodeRejectsRemovedPayloadFieldInOpRecord) +{ + RefLogTxn txn; + txn.ns = "srv1/db/table@cas@"; + txn.txn_id = RefTxnId{3, 5}; + RefOp op; + op.kind = RefOpKind::SetPublishedAt; + op.ref_name = "all_1_1_0"; + op.expected_manifest_ref = manifestRef(3, 4, 1); + op.published_at_ms = 1717000000000ULL; + txn.ops.push_back(op); + + const String bytes = encodeRefLogTxn(txn); + /// Splice the retired `"pl"` field back into the op record, just before its `"ts"` field. + const String needle = ",\"ts\":"; + const auto pos = bytes.find(needle); + ASSERT_NE(pos, String::npos); + const String tampered = bytes.substr(0, pos) + R"(,"pl":"deadbeef")" + bytes.substr(pos); + + expectThrowsCode(DB::ErrorCodes::CORRUPTED_DATA, [&] { decodeRefLogTxn(tampered, txn.ns, txn.txn_id); }); +} + +TEST(CASRefCodec, RoundTripSetPublishedAtZeroTimestamp) +{ + RefLogTxn txn; + txn.ns = "ns"; + txn.txn_id = RefTxnId{1, 1}; + RefOp op; + op.kind = RefOpKind::SetPublishedAt; + op.ref_name = "r"; + op.expected_manifest_ref = manifestRef(1, 1, 1); + op.published_at_ms = 0; + txn.ops.push_back(op); + + const String bytes = encodeRefLogTxn(txn); + const RefLogTxn decoded = decodeRefLogTxn(bytes, txn.ns, txn.txn_id); + EXPECT_EQ(decoded, txn); +} + +TEST(CASRefCodec, RoundTripOwnerTransitionAdd) +{ + /// new-only = add: no old_binding, a fresh new_binding. + RefLogTxn txn; + txn.ns = "ns"; + txn.txn_id = RefTxnId{1, 1}; + RefOp op; + op.kind = RefOpKind::OwnerTransition; + op.new_binding = RefOwnerBinding{RefOwnerKind::Precommit, "all_1_1_0", manifestRef(1, 1, 1)}; + txn.ops.push_back(op); + + const String bytes = encodeRefLogTxn(txn); + const RefLogTxn decoded = decodeRefLogTxn(bytes, txn.ns, txn.txn_id); + EXPECT_EQ(decoded, txn); + ASSERT_TRUE(decoded.ops[0].new_binding.has_value()); + EXPECT_FALSE(decoded.ops[0].old_binding.has_value()); +} + +TEST(CASRefCodec, RoundTripOwnerTransitionRemoval) +{ + /// old-only = removal: an old_binding, no new_binding. + RefLogTxn txn; + txn.ns = "ns"; + txn.txn_id = RefTxnId{1, 1}; + RefOp op; + op.kind = RefOpKind::OwnerTransition; + op.old_binding = RefOwnerBinding{RefOwnerKind::Precommit, "all_1_1_0", manifestRef(1, 1, 1)}; + txn.ops.push_back(op); + + const String bytes = encodeRefLogTxn(txn); + const RefLogTxn decoded = decodeRefLogTxn(bytes, txn.ns, txn.txn_id); + EXPECT_EQ(decoded, txn); + EXPECT_FALSE(decoded.ops[0].new_binding.has_value()); + ASSERT_TRUE(decoded.ops[0].old_binding.has_value()); +} + +TEST(CASRefCodec, RoundTripOwnerTransitionReplace) +{ + /// both present = replace. + RefLogTxn txn; + txn.ns = "ns"; + txn.txn_id = RefTxnId{1, 1}; + RefOp op; + op.kind = RefOpKind::OwnerTransition; + op.old_binding = RefOwnerBinding{RefOwnerKind::Precommit, "all_1_1_0", manifestRef(1, 1, 1)}; + op.new_binding = RefOwnerBinding{RefOwnerKind::Committed, "all_1_1_0", manifestRef(1, 1, 1)}; + txn.ops.push_back(op); + + const String bytes = encodeRefLogTxn(txn); + const RefLogTxn decoded = decodeRefLogTxn(bytes, txn.ns, txn.txn_id); + EXPECT_EQ(decoded, txn); + ASSERT_TRUE(decoded.ops[0].old_binding.has_value()); + ASSERT_TRUE(decoded.ops[0].new_binding.has_value()); +} + +TEST(CASRefCodec, RoundTripMultipleOpsInOneTransaction) +{ + RefLogTxn txn; + txn.ns = "ns"; + txn.txn_id = RefTxnId{9, 100}; + + RefOp birth; + birth.kind = RefOpKind::NamespaceBirth; + txn.ops.push_back(birth); + + RefOp add; + add.kind = RefOpKind::OwnerTransition; + add.new_binding = RefOwnerBinding{RefOwnerKind::Precommit, "a/b/c", manifestRef(9, 1, 1)}; + txn.ops.push_back(add); + + RefOp set_published_at; + set_published_at.kind = RefOpKind::SetPublishedAt; + set_published_at.ref_name = "a/b/c"; + set_published_at.expected_manifest_ref = manifestRef(9, 1, 1); + set_published_at.published_at_ms = 42; + txn.ops.push_back(set_published_at); + + const String bytes = encodeRefLogTxn(txn); + const RefLogTxn decoded = decodeRefLogTxn(bytes, txn.ns, txn.txn_id); + EXPECT_EQ(decoded, txn); + EXPECT_EQ(decoded.ops.size(), 3u); +} + +/// A re-encode of a decoded transaction is byte-identical (the encoder is a pure function of the txn). +TEST(CASRefCodec, ByteIdenticalReencode) +{ + RefLogTxn txn; + txn.ns = "srv1/db/table@cas@"; + txn.txn_id = RefTxnId{9, 100}; + + RefOp birth; + birth.kind = RefOpKind::NamespaceBirth; + txn.ops.push_back(birth); + + RefOp add; + add.kind = RefOpKind::OwnerTransition; + add.old_binding = RefOwnerBinding{RefOwnerKind::Precommit, "a/b/c", manifestRef(9, 1, 1)}; + add.new_binding = RefOwnerBinding{RefOwnerKind::Committed, "a/b/c", manifestRef(9, 1, 1)}; + txn.ops.push_back(add); + + RefOp set_published_at; + set_published_at.kind = RefOpKind::SetPublishedAt; + set_published_at.ref_name = "a/b/c"; + set_published_at.expected_manifest_ref = manifestRef(9, 1, 1); + set_published_at.published_at_ms = 1717000000000ULL; + txn.ops.push_back(set_published_at); + + const String bytes1 = encodeRefLogTxn(txn); + const RefLogTxn decoded = decodeRefLogTxn(bytes1, txn.ns, txn.txn_id); + const String bytes2 = encodeRefLogTxn(decoded); + EXPECT_EQ(bytes1, bytes2); +} + +/// =================================================================================== +/// RefLogTxn: validation rejections (encoder-side + key/body binding + truncation) +/// =================================================================================== + +TEST(CASRefCodec, EncodeRejectsZeroTxnId) +{ + RefLogTxn txn; + txn.ns = "ns"; + txn.txn_id = RefTxnId{0, 1}; + expectThrowsCode(DB::ErrorCodes::CORRUPTED_DATA, [&] { encodeRefLogTxn(txn); }); +} + +TEST(CASRefCodec, DecodeRejectsTruncatedBuffer) +{ + RefLogTxn txn; + txn.ns = "ns"; + txn.txn_id = RefTxnId{1, 1}; + RefOp op; + op.kind = RefOpKind::SetPublishedAt; + op.ref_name = "r"; + op.expected_manifest_ref = manifestRef(1, 1, 1); + txn.ops.push_back(op); + const String bytes = encodeRefLogTxn(txn); + + /// Dropping the trailing bytes leaves the final line without its '\n' terminator -> fail closed. + expectThrowsCode(DB::ErrorCodes::CORRUPTED_DATA, + [&] { decodeRefLogTxn(bytes.substr(0, bytes.size() - 3), txn.ns, txn.txn_id); }); +} + +TEST(CASRefCodec, DecodeRejectsBodyNamespaceMismatch) +{ + RefLogTxn txn; + txn.ns = "ns-a"; + txn.txn_id = RefTxnId{1, 1}; + RefOp op; + op.kind = RefOpKind::NamespaceBirth; + txn.ops.push_back(op); + const String bytes = encodeRefLogTxn(txn); + + expectThrowsCode(DB::ErrorCodes::CORRUPTED_DATA, + [&] { decodeRefLogTxn(bytes, "ns-b", txn.txn_id); }); +} + +TEST(CASRefCodec, DecodeRejectsBodyTxnIdMismatch) +{ + RefLogTxn txn; + txn.ns = "ns"; + txn.txn_id = RefTxnId{1, 1}; + RefOp op; + op.kind = RefOpKind::NamespaceBirth; + txn.ops.push_back(op); + const String bytes = encodeRefLogTxn(txn); + + expectThrowsCode(DB::ErrorCodes::CORRUPTED_DATA, + [&] { decodeRefLogTxn(bytes, txn.ns, RefTxnId{1, 2}); }); +} + +TEST(CASRefCodec, EncodeRejectsEmptyRefName) +{ + RefLogTxn txn; + txn.ns = "ns"; + txn.txn_id = RefTxnId{1, 1}; + RefOp op; + op.kind = RefOpKind::SetPublishedAt; + op.ref_name = ""; + op.expected_manifest_ref = manifestRef(1, 1, 1); + txn.ops.push_back(op); + expectThrowsCode(DB::ErrorCodes::CORRUPTED_DATA, [&] { encodeRefLogTxn(txn); }); +} + +TEST(CASRefCodec, EncodeRejectsDotRefName) +{ + RefLogTxn txn; + txn.ns = "ns"; + txn.txn_id = RefTxnId{1, 1}; + RefOp op; + op.kind = RefOpKind::SetPublishedAt; + op.ref_name = "."; + op.expected_manifest_ref = manifestRef(1, 1, 1); + txn.ops.push_back(op); + expectThrowsCode(DB::ErrorCodes::CORRUPTED_DATA, [&] { encodeRefLogTxn(txn); }); +} + +TEST(CASRefCodec, EncodeRejectsDotDotSegment) +{ + RefLogTxn txn; + txn.ns = "ns"; + txn.txn_id = RefTxnId{1, 1}; + RefOp op; + op.kind = RefOpKind::SetPublishedAt; + op.ref_name = "a/../b"; + op.expected_manifest_ref = manifestRef(1, 1, 1); + txn.ops.push_back(op); + expectThrowsCode(DB::ErrorCodes::CORRUPTED_DATA, [&] { encodeRefLogTxn(txn); }); +} + +TEST(CASRefCodec, EncodeRejectsRepeatedSeparator) +{ + RefLogTxn txn; + txn.ns = "ns"; + txn.txn_id = RefTxnId{1, 1}; + RefOp op; + op.kind = RefOpKind::SetPublishedAt; + op.ref_name = "a//b"; + op.expected_manifest_ref = manifestRef(1, 1, 1); + txn.ops.push_back(op); + expectThrowsCode(DB::ErrorCodes::CORRUPTED_DATA, [&] { encodeRefLogTxn(txn); }); +} + +TEST(CASRefCodec, EncodeRejectsLeadingSlash) +{ + RefLogTxn txn; + txn.ns = "ns"; + txn.txn_id = RefTxnId{1, 1}; + RefOp op; + op.kind = RefOpKind::SetPublishedAt; + op.ref_name = "/a"; + op.expected_manifest_ref = manifestRef(1, 1, 1); + txn.ops.push_back(op); + expectThrowsCode(DB::ErrorCodes::CORRUPTED_DATA, [&] { encodeRefLogTxn(txn); }); +} + +TEST(CASRefCodec, EncodeRejectsTrailingSlash) +{ + RefLogTxn txn; + txn.ns = "ns"; + txn.txn_id = RefTxnId{1, 1}; + RefOp op; + op.kind = RefOpKind::SetPublishedAt; + op.ref_name = "a/"; + op.expected_manifest_ref = manifestRef(1, 1, 1); + txn.ops.push_back(op); + expectThrowsCode(DB::ErrorCodes::CORRUPTED_DATA, [&] { encodeRefLogTxn(txn); }); +} + +TEST(CASRefCodec, EncodeRejectsBackslash) +{ + RefLogTxn txn; + txn.ns = "ns"; + txn.txn_id = RefTxnId{1, 1}; + RefOp op; + op.kind = RefOpKind::SetPublishedAt; + op.ref_name = "a\\b"; + op.expected_manifest_ref = manifestRef(1, 1, 1); + txn.ops.push_back(op); + expectThrowsCode(DB::ErrorCodes::CORRUPTED_DATA, [&] { encodeRefLogTxn(txn); }); +} + +TEST(CASRefCodec, EncodeRejectsNonCanonicalOwnerBindingRefName) +{ + RefLogTxn txn; + txn.ns = "ns"; + txn.txn_id = RefTxnId{1, 1}; + RefOp op; + op.kind = RefOpKind::OwnerTransition; + op.new_binding = RefOwnerBinding{RefOwnerKind::Precommit, "..", manifestRef(1, 1, 1)}; + txn.ops.push_back(op); + expectThrowsCode(DB::ErrorCodes::CORRUPTED_DATA, [&] { encodeRefLogTxn(txn); }); +} + +TEST(CASRefCodec, EncodeRejectsEmbeddedNulRefName) +{ + RefLogTxn txn; + txn.ns = "ns"; + txn.txn_id = RefTxnId{1, 1}; + RefOp op; + op.kind = RefOpKind::SetPublishedAt; + op.ref_name = String("a\0b", 3); /// embedded NUL byte -- never legitimate in a ref name + op.expected_manifest_ref = manifestRef(1, 1, 1); + txn.ops.push_back(op); + expectThrowsCode(DB::ErrorCodes::CORRUPTED_DATA, [&] { encodeRefLogTxn(txn); }); +} + +TEST(CASRefCodec, EncodeRejectsTooManyOps) +{ + RefLogTxn txn; + txn.ns = "ns"; + txn.txn_id = RefTxnId{1, 1}; + for (size_t i = 0; i < ref_txn_max_ops + 1; ++i) + { + RefOp op; + op.kind = RefOpKind::NamespaceBirth; + txn.ops.push_back(op); + } + expectThrowsCode(DB::ErrorCodes::CORRUPTED_DATA, [&] { encodeRefLogTxn(txn); }); +} + +TEST(CASRefCodec, EncodeAllowsExactlyMaxOps) +{ + RefLogTxn txn; + txn.ns = "ns"; + txn.txn_id = RefTxnId{1, 1}; + for (size_t i = 0; i < ref_txn_max_ops; ++i) + { + RefOp op; + op.kind = RefOpKind::NamespaceBirth; + txn.ops.push_back(op); + } + const String bytes = encodeRefLogTxn(txn); + const RefLogTxn decoded = decodeRefLogTxn(bytes, txn.ns, txn.txn_id); + EXPECT_EQ(decoded.ops.size(), ref_txn_max_ops); +} + +TEST(CASRefCodec, EncodeRejectsOversizedNormalTransaction) +{ + RefLogTxn txn; + txn.ns = "ns"; + txn.txn_id = RefTxnId{1, 1}; + RefOp op; + op.kind = RefOpKind::SetPublishedAt; + op.ref_name = "r" + String(ref_txn_max_bytes + 1, 'x'); + op.expected_manifest_ref = manifestRef(1, 1, 1); + txn.ops.push_back(op); + expectThrowsCode(DB::ErrorCodes::CORRUPTED_DATA, [&] { encodeRefLogTxn(txn); }); +} + +TEST(CASRefCodec, RemovalClassTransactionLiftsByteBudgetAboveNormalLimit) +{ + /// A RemoveNamespace transaction carrying a ref_name bigger than the NORMAL limit but within the + /// REMOVAL limit must succeed -- proving the removal-class flag actually lifts the byte budget + /// rather than merely being ignored. The single set_published_at op here is also vastly bigger + /// than `ref_op_max_bytes`, so this doubles as proof that removal-class ops are exempt from the + /// per-op cap too. + RefLogTxn txn; + txn.ns = "ns"; + txn.txn_id = RefTxnId{1, 1}; + + RefOp remove; + remove.kind = RefOpKind::RemoveNamespace; + txn.ops.push_back(remove); + + RefOp set_published_at; + set_published_at.kind = RefOpKind::SetPublishedAt; + set_published_at.ref_name = "r" + String(ref_txn_max_bytes + 1024, 'x'); + set_published_at.expected_manifest_ref = manifestRef(1, 1, 1); + txn.ops.push_back(set_published_at); + + const String bytes = encodeRefLogTxn(txn); + EXPECT_GT(bytes.size(), ref_txn_max_bytes); + const RefLogTxn decoded = decodeRefLogTxn(bytes, txn.ns, txn.txn_id); + EXPECT_EQ(decoded, txn); +} + +TEST(CASRefCodec, RemovalClassTransactionStillRejectsBeyondRemovalLimit) +{ + RefLogTxn txn; + txn.ns = "ns"; + txn.txn_id = RefTxnId{1, 1}; + + RefOp remove; + remove.kind = RefOpKind::RemoveNamespace; + txn.ops.push_back(remove); + + RefOp set_published_at; + set_published_at.kind = RefOpKind::SetPublishedAt; + set_published_at.ref_name = "r" + String(ref_removal_max_bytes + 1, 'x'); + set_published_at.expected_manifest_ref = manifestRef(1, 1, 1); + txn.ops.push_back(set_published_at); + + expectThrowsCode(DB::ErrorCodes::CORRUPTED_DATA, [&] { encodeRefLogTxn(txn); }); +} + +TEST(CASRefCodec, RemovalClassTransactionNotCappedOnOpCount) +{ + /// A removal-class transaction may exceed `ref_txn_max_ops` -- only the (much larger) byte budget + /// bounds it, per spec ("its operation count is bounded by that byte limit"). + RefLogTxn txn; + txn.ns = "ns"; + txn.txn_id = RefTxnId{1, 1}; + + RefOp remove; + remove.kind = RefOpKind::RemoveNamespace; + txn.ops.push_back(remove); + for (size_t i = 0; i < ref_txn_max_ops + 10; ++i) + { + RefOp op; + op.kind = RefOpKind::NamespaceBirth; + txn.ops.push_back(op); + } + + const String bytes = encodeRefLogTxn(txn); + const RefLogTxn decoded = decodeRefLogTxn(bytes, txn.ns, txn.txn_id); + EXPECT_EQ(decoded.ops.size(), txn.ops.size()); +} + +/// Stage-1 T8 (spec §3 "Budget: counts only, chunked flush") retires the scenario this test used to +/// pin: ONE op carrying almost the whole `ref_txn_max_bytes` budget in its payload. The new per-op +/// cap (`ref_op_max_bytes`, `EncodeAllowsExactlyMaxPerOpBytes` below) makes that construction illegal +/// for a normal-class transaction — no single op may exceed `ref_op_max_bytes` regardless of the +/// whole-transaction budget — so the exact-boundary pin moves to the per-op cap, the boundary a +/// legally-admitted normal-class transaction can actually reach (`ref_txn_max_ops * ref_op_max_bytes` +/// stays comfortably under `ref_txn_max_bytes`, pinned by `CanonicalMaxTransactionRoundTrips` in +/// `gtest_cas_ref_chunked_flush.cpp`). + +TEST(CASRefCodec, EncodeAllowsExactlyMaxPerOpBytes) +{ + RefLogTxn txn; + txn.ns = "ns"; + txn.txn_id = RefTxnId{1, 1}; + RefOp op; + op.kind = RefOpKind::SetPublishedAt; + op.ref_name = "r"; + op.expected_manifest_ref = manifestRef(1, 1, 1); + txn.ops.push_back(op); + + const size_t base_size = encodedOpSize(op); + ASSERT_LE(base_size, ref_op_max_bytes); + /// Every added 'a' is one un-escaped byte inside the JSON ref-name string, so the encoded op size + /// grows one-for-one to exactly the per-op cap. + txn.ops[0].ref_name = "r" + String(ref_op_max_bytes - base_size, 'a'); + ASSERT_EQ(encodedOpSize(txn.ops[0]), ref_op_max_bytes); + + const String bytes = encodeRefLogTxn(txn); + const RefLogTxn decoded = decodeRefLogTxn(bytes, txn.ns, txn.txn_id); + EXPECT_EQ(decoded, txn); +} + +TEST(CASRefCodec, EncodeRejectsOversizedOpOnNormalTransaction) +{ + RefLogTxn txn; + txn.ns = "ns"; + txn.txn_id = RefTxnId{1, 1}; + RefOp op; + op.kind = RefOpKind::SetPublishedAt; + op.ref_name = "r"; + op.expected_manifest_ref = manifestRef(1, 1, 1); + txn.ops.push_back(op); + + const size_t base_size = encodedOpSize(op); + ASSERT_LE(base_size, ref_op_max_bytes); + /// One byte past the per-op cap, well within the whole-transaction byte cap -- isolates the + /// per-op check from the (much larger) whole-transaction one. + txn.ops[0].ref_name = "r" + String(ref_op_max_bytes - base_size + 1, 'a'); + ASSERT_GT(encodedOpSize(txn.ops[0]), ref_op_max_bytes); + ASSERT_LT(encodedOpSize(txn.ops[0]), ref_txn_max_bytes); + + expectThrowsCode(DB::ErrorCodes::CORRUPTED_DATA, [&] { encodeRefLogTxn(txn); }); +} + +TEST(CASRefCodec, EncodeAllowsExactlyMaxRemovalBytes) +{ + RefLogTxn txn; + txn.ns = "ns"; + txn.txn_id = RefTxnId{1, 1}; + RefOp remove; + remove.kind = RefOpKind::RemoveNamespace; + txn.ops.push_back(remove); + RefOp ts_op; + ts_op.kind = RefOpKind::SetPublishedAt; + ts_op.ref_name = "r"; + ts_op.expected_manifest_ref = manifestRef(1, 1, 1); + txn.ops.push_back(ts_op); + + const size_t base_size = encodeRefLogTxn(txn).size(); + ASSERT_LE(base_size, ref_removal_max_bytes); + /// Every added 'x' is one un-escaped byte inside the JSON ref-name string, so the encoded size + /// grows one-for-one to exactly the cap; the base "r" contributes 1 byte already counted in + /// base_size, so appending (rather than replacing) reaches the target exactly. + txn.ops[1].ref_name = "r" + String(ref_removal_max_bytes - base_size, 'x'); + + const String bytes = encodeRefLogTxn(txn); + EXPECT_EQ(bytes.size(), ref_removal_max_bytes); + const RefLogTxn decoded = decodeRefLogTxn(bytes, txn.ns, txn.txn_id); + EXPECT_EQ(decoded, txn); +} + +/// ManifestRef field validation, enforced by the log codec (spec's "invalid identifiers are rejected" +/// binds both codecs). Encoder-side only -- the decode path re-runs the identical checks and is +/// covered by the round-trips + the battery. + +TEST(CASRefCodec, EncodeRejectsZeroManifestRefWriterEpochInOwnerBinding) +{ + RefLogTxn txn; + txn.ns = "ns"; + txn.txn_id = RefTxnId{1, 1}; + RefOp op; + op.kind = RefOpKind::OwnerTransition; + op.new_binding = RefOwnerBinding{RefOwnerKind::Precommit, "r", manifestRef(0, 1, 1)}; + txn.ops.push_back(op); + expectThrowsCode(DB::ErrorCodes::CORRUPTED_DATA, [&] { encodeRefLogTxn(txn); }); +} + +TEST(CASRefCodec, EncodeRejectsZeroManifestRefBuildSequenceInOwnerBinding) +{ + RefLogTxn txn; + txn.ns = "ns"; + txn.txn_id = RefTxnId{1, 1}; + RefOp op; + op.kind = RefOpKind::OwnerTransition; + op.new_binding = RefOwnerBinding{RefOwnerKind::Precommit, "r", manifestRef(1, 0, 1)}; + txn.ops.push_back(op); + expectThrowsCode(DB::ErrorCodes::CORRUPTED_DATA, [&] { encodeRefLogTxn(txn); }); +} + +TEST(CASRefCodec, EncodeRejectsOutOfRangeManifestOrdinalInOwnerBinding) +{ + RefLogTxn txn; + txn.ns = "ns"; + txn.txn_id = RefTxnId{1, 1}; + RefOp op; + op.kind = RefOpKind::OwnerTransition; + op.new_binding = RefOwnerBinding{RefOwnerKind::Precommit, "r", manifestRef(1, 1, 0)}; + txn.ops.push_back(op); + expectThrowsCode(DB::ErrorCodes::CORRUPTED_DATA, [&] { encodeRefLogTxn(txn); }); +} + +TEST(CASRefCodec, EncodeRejectsZeroManifestRefInSetPublishedAt) +{ + RefLogTxn txn; + txn.ns = "ns"; + txn.txn_id = RefTxnId{1, 1}; + RefOp op; + op.kind = RefOpKind::SetPublishedAt; + op.ref_name = "r"; + op.expected_manifest_ref = manifestRef(1, 1, 0); + txn.ops.push_back(op); + expectThrowsCode(DB::ErrorCodes::CORRUPTED_DATA, [&] { encodeRefLogTxn(txn); }); +} + +/// =================================================================================== +/// Shape-level failure-mode battery (truncation / v+1 gate / wrong type / leading garbage) +/// =================================================================================== + +TEST(CASFormatBattery, RefLog) +{ + RefLogTxn txn; + txn.ns = "ns"; + txn.txn_id = RefTxnId{1, 1}; + RefOp op; + op.kind = RefOpKind::SetPublishedAt; + op.ref_name = "all_1_1_0"; + op.expected_manifest_ref = manifestRef(1, 1, 1); + op.published_at_ms = 42; + txn.ops.push_back(op); + + const String ns = txn.ns; + const RefTxnId id = txn.txn_id; + runFormatBattery({FormatId::RefLog, + [txn] { return sealObject(FormatId::RefLog, encodeRefLogTxn(txn)); }, + [ns, id](std::string_view s) { decodeRefLogTxn(openObject(FormatId::RefLog, s), ns, id); }, + currentFormatHeader("cas_ref_log") + + "{\"ns\":\"ns\",\"we\":\"1\",\"rs\":\"1\"}\n" + "{\"op\":\"set_published_at\",\"rn\":\"all_1_1_0\",\"me\":\"1\",\"mb\":\"1\",\"mo\":1,\"ts\":42}\n" + "{\"n\":1}\n"}); +} diff --git a/src/Disks/tests/gtest_cas_ref_read_contract.cpp b/src/Disks/tests/gtest_cas_ref_read_contract.cpp new file mode 100644 index 000000000000..a86d2530d4e8 --- /dev/null +++ b/src/Disks/tests/gtest_cas_ref_read_contract.cpp @@ -0,0 +1,256 @@ +#include + +#include +#include +#include +#include +#include +#include + +#include +#include +#include + +/// The ref-side read contract (the ten `CasRefCatalog::read` sites in `CasRefLedger.cpp` were +/// classified elsewhere: every site is a mutation/admission authority or a per-key destructive +/// revalidation, and every live table reader reaches `acquireReadableRefTableRuntime`, whose warm path +/// returns the resident runtime before any catalog read). These are COVERAGE PINS for a contract the +/// classification predicted already holds, not a fix: a held reader runtime answers stale-or-absent +/// across a same-name rebirth, a warm read costs no catalog request, and the one held ref-writer seam +/// that exists (`dropNamespace(const NamespaceLifeId &)`) refuses across the same rebirth rather than +/// touching the successor. + +namespace DB::ErrorCodes +{ + extern const int LOGICAL_ERROR; + extern const int NETWORK_ERROR; +} + +using namespace DB::Cas; +using namespace DB::Cas::tests; + +namespace +{ + +/// One committed ref, born and published through the REAL production write path (`beginPartWrite` / +/// `stageManifest` / `precommitAdd` / `promote`) -- this is what mints `ns`'s catalog life for real, +/// exactly as an ordinary insert would, rather than a fixture sentinel. +ManifestId publishRefThroughPool(const PoolPtr & store, const RootNamespace & ns, const String & ref_name) +{ + PartWriteInfo info; + info.intended_namespace = ns; + info.intended_ref = ns.string() + "/" + ref_name; + auto build = store->beginPartWrite(info); + const ManifestId id = build->stageManifest({}); + build->precommitAdd(ns, ref_name, id); + build->promote(ns, ref_name, build->buildId(), id); + return id; +} + +/// Delete the current catalog life through the production exact-removal authority (`casUpdate` to +/// `Removing`, then `deleteCompletedRemoving` under a held fence), retaining every old physical byte +/// and any already-resident runtime. Mirrors `gtest_cas_ns_file_read_contract.cpp`'s +/// `deleteCatalogLife` -- lifecycle-real, not a raw sentinel overwrite. +void deleteCatalogLife(Backend & backend, const Layout & layout, const NamespaceLifeId & life) +{ + CasRefCatalog::casUpdate(backend, layout, [&](const RefCatalog & current) + { + RefCatalog next = current; + const auto it = std::find_if(next.entries.begin(), next.entries.end(), [&](const CatalogEntry & entry) + { + return entry.ns == life.ns && entry.incarnation == life.incarnation; + }); + if (it == next.entries.end()) + throw DB::Exception(DB::ErrorCodes::LOGICAL_ERROR, "Missing fixture catalog life '{}'", life.ns.string()); + it->state = NsState::Removing; + it->removal_started_round = 1; + return next; + }); + + const CasRefCatalog::Snapshot snapshot = CasRefCatalog::read(backend, layout); + const auto it = std::find_if(snapshot.catalog.entries.begin(), snapshot.catalog.entries.end(), [&](const CatalogEntry & entry) + { + return entry.ns == life.ns && entry.incarnation == life.incarnation; + }); + if (it == snapshot.catalog.entries.end()) + throw DB::Exception(DB::ErrorCodes::LOGICAL_ERROR, "Missing Removing fixture catalog life '{}'", life.ns.string()); + + CasFoldSeal parent; + parent.ref_lives.emplace(life.incarnation, RefLifeFoldState{ + .coverage = RefCoverage{.classification = 2, .last_folded_ref_id = RefTxnId{1, 1}}, + .cleanup_evidence = RefCleanupEvidence{.remove_txn_id = RefTxnId{1, 1}}}); + if (CasRefCatalog::deleteCompletedRemoving( + backend, layout, *it, parent, 1, + [](uint64_t) { return CasRefCatalog::LeaderFenceStatus::Held; }) + != CasRefCatalog::CompletedRemovingDeleteOutcome::Deleted) + throw DB::Exception(DB::ErrorCodes::LOGICAL_ERROR, "Failed to delete fixture catalog life '{}'", life.ns.string()); +} + +/// Admit a fresh `Live` catalog row at the SAME logical name as `predecessor` -- the same-name +/// rebirth every test below drives. Mirrors `gtest_cas_ns_file_read_contract.cpp`'s +/// `admitReplacementLife`. +NamespaceLifeId admitReplacementLife( + Backend & backend, const Layout & layout, uint64_t gc_shards, + const NamespaceLifeId & predecessor, UInt128 successor_incarnation) +{ + if (predecessor.incarnation == successor_incarnation) + throw DB::Exception(DB::ErrorCodes::LOGICAL_ERROR, "Fixture life ids unexpectedly collide"); + const NamespaceLifeId successor = NamespaceLifeId::fromCatalogEntry(predecessor.ns, successor_incarnation); + CasRefCatalog::casAdmitEntry(backend, layout, gc_shards, CatalogEntry{ + .ns = successor.ns, .state = NsState::Live, .incarnation = successor.incarnation}); + return successor; +} + +} + +/// This reader's runtime already holds life 1. Reusing it after a same-name rebirth is a retained +/// life-handle operation, not a fresh logical-name admission: it may still answer life 1's committed +/// value (or absent), but it must never surface life 2's -- the opaque physical life id makes the +/// successor's bytes structurally unreachable through an unrefreshed handle. +TEST(CASRefReadContract, HeldRuntimeAfterSameNameRebirthReadsStaleOrNotFoundNeverSuccessorRefs) +{ + auto backend = std::make_shared(); + /// A 1-byte whole-table cache budget is the production knob (`CASRefTableCacheEviction`) that lets a + /// single store instance both HOLD a table's runtime and, later, genuinely forget it by touching a + /// different table -- so the "fresh resolution" positive control below is a real re-recovery, not + /// a second mount. + auto store = Pool::open(backend, PoolConfig{.pool_prefix = "p", .server_root_id = "test", .ref_table_cache_bytes = 1}); + const Layout & layout = store->layout(); + const RootNamespace ns{"00/ref_read_contract_rebirth@cas@"}; + const RootNamespace throwaway_ns{"00/ref_read_contract_rebirth_evictor@cas@"}; + const String ref_name = "part_1"; + + const ManifestId life1_manifest = publishRefThroughPool(store, ns, ref_name); + + /// Hold the reader runtime resident: one read. + const auto held_before = store->resolveRef(ns, ref_name); + ASSERT_TRUE(held_before.has_value()); + EXPECT_EQ(held_before->manifest_id, life1_manifest); + ASSERT_TRUE(store->refTableLifeForTest(ns).has_value()); + const NamespaceLifeId life1 = *store->refTableLifeForTest(ns); + + /// Drop and re-admit under the SAME logical name, bypassing this store's own ledger entirely -- + /// exactly as an independent actor's drop/rebirth would look from this reader's point of view. + deleteCatalogLife(*backend, layout, life1); + const NamespaceLifeId life2 = admitReplacementLife(*backend, layout, store->poolConfig().gc_shards, life1, UInt128{0xabc123}); + ASSERT_NE(life1.incarnation, life2.incarnation); + + const ManifestRef life2_ref{/*writer_epoch*/ 1, /*build_sequence*/ 777, /*manifest_ordinal*/ 1}; + publishCommittedTransition(*backend, layout, ns, ref_name, std::nullopt, life2_ref); + const ManifestId life2_manifest{ns, life2_ref}; + ASSERT_NE(life2_manifest, life1_manifest); + + /// The held runtime never re-validates the catalog: it answers from its resident cache -- stale or + /// not-found -- but never the successor's value. + const auto held_after = store->resolveRef(ns, ref_name); + EXPECT_NE( + held_after.has_value() ? std::optional(held_after->manifest_id) : std::nullopt, + std::optional(life2_manifest)); + if (held_after.has_value()) + EXPECT_EQ(held_after->manifest_id, life1_manifest); + + /// Force the cached runtime out: touch a different namespace under the 1-byte cache budget (the + /// production whole-table eviction path), so the NEXT access to `ns` re-recovers from scratch. + (void)publishRefThroughPool(store, throwaway_ns, "evict"); + ASSERT_FALSE(store->refTableCachedForTest(ns)); + + /// Positive control: a fresh resolution -- through the SAME Pool, now cold -- sees life 2. Not + /// vacuous: the value really did move, and an unrefreshed handle really would have missed it. + const auto fresh = store->resolveRef(ns, ref_name); + ASSERT_TRUE(fresh.has_value()); + EXPECT_EQ(fresh->manifest_id, life2_manifest); +} + +/// The disjoint half of the read-side contract: once a table's runtime is resident, an ordinary read +/// costs no catalog request at all -- the recovered-and-cached `RefTableState` is this process's +/// sole authority for a table it has already opened. +TEST(CASRefReadContract, HotRefReadsThroughHeldRuntimeIssueZeroCatalogRequests) +{ + auto backend = std::make_shared(); + PoolPtr store = openPoolForTest(backend); + const Layout & layout = store->layout(); + const RootNamespace ns{"00/ref_read_contract_hot@cas@"}; + const String ref_name = "part_1"; + + const ManifestId published = publishRefThroughPool(store, ns, ref_name); + const auto warm = store->resolveRef(ns, ref_name); + ASSERT_TRUE(warm.has_value()); + EXPECT_EQ(warm->manifest_id, published); + ASSERT_TRUE(store->refTableLifeForTest(ns).has_value()); + const NamespaceLifeId life = *store->refTableLifeForTest(ns); + + /// Positive control, captured BEFORE the reset below: the cold admission above really did reach + /// the catalog and this namespace's own ref stream, so the upcoming zero is an absence and not a + /// recorder that never saw anything. + EXPECT_GT( + backend->headCount(layout.refCatalogKey()) + backend->getCount(layout.refCatalogKey()) + + backend->casPutCount(layout.refCatalogKey()), + 0u) << "the cold admission above must have reached the catalog at least once"; + EXPECT_GT(backend->getCount(layout.refCkptKey(life)), 0u) + << "the cold recovery above must have read this namespace's own checkpoint at least once"; + + backend->resetCounts(); + + (void)store->resolveRef(ns, ref_name); + (void)store->listRefs(ns); + (void)store->hasAnyRefWithPrefix(ns, ""); + + EXPECT_EQ(backend->headCount(layout.refCatalogKey()), 0u); + EXPECT_EQ(backend->getCount(layout.refCatalogKey()), 0u); + EXPECT_EQ(backend->casPutCount(layout.refCatalogKey()), 0u); + EXPECT_EQ(backend->putCount(layout.refCatalogKey()), 0u); + EXPECT_EQ(backend->putOverwriteCount(layout.refCatalogKey()), 0u); + /// Stronger than the catalog-only clauses above: a warm ref read is a pure map lookup over the + /// recovered state (`ensureRefTableRecovered`'s early return once `rt.recovered`), so it issues no + /// backend request whatsoever, not merely none against the catalog. + EXPECT_TRUE(backend->touchedKeys().empty()) + << "a warm ref read must issue no backend requests at all"; +} + +/// The one held ref-WRITER seam the classification found: `dropNamespace(const NamespaceLifeId &)`'s +/// exact-incarnation guard. A stale holder can only be refused, never allowed to act on the successor +/// -- there is no path by which it could target life 2's row or its ref data. +TEST(CASRefReadContract, StaleLifeDropRefusesAfterRebirthAndNeverTouchesSuccessor) +{ + auto backend = std::make_shared(); + auto store = openPoolForTest(backend); + const Layout & layout = store->layout(); + const RootNamespace ns{"00/ref_read_contract_stale_drop@cas@"}; + const String ref_name = "part_1"; + + (void)publishRefThroughPool(store, ns, ref_name); + ASSERT_TRUE(store->refTableLifeForTest(ns).has_value()); + const NamespaceLifeId life1 = *store->refTableLifeForTest(ns); + + deleteCatalogLife(*backend, layout, life1); + const NamespaceLifeId life2 = admitReplacementLife(*backend, layout, store->poolConfig().gc_shards, life1, UInt128{0xabc456}); + + const ManifestRef life2_ref{/*writer_epoch*/ 1, /*build_sequence*/ 999, /*manifest_ordinal*/ 1}; + publishCommittedTransition(*backend, layout, ns, ref_name, std::nullopt, life2_ref); + const ManifestId life2_manifest{ns, life2_ref}; + + const HeadResult catalog_head_before = backend->head(layout.refCatalogKey()); + ASSERT_TRUE(catalog_head_before.exists); + const auto catalog_get_before = backend->get(layout.refCatalogKey()); + ASSERT_TRUE(catalog_get_before.has_value()); + + /// The held life-1 handle names an incarnation the catalog no longer carries: refused, not + /// resolved against the current (life-2) row. + expectThrowsCode(DB::ErrorCodes::NETWORK_ERROR, [&] { store->dropNamespace(life1); }); + + const HeadResult catalog_head_after = backend->head(layout.refCatalogKey()); + ASSERT_TRUE(catalog_head_after.exists); + EXPECT_EQ(catalog_head_after.token, catalog_head_before.token) + << "a refused stale-life drop must not touch the catalog object at all"; + const auto catalog_get_after = backend->get(layout.refCatalogKey()); + ASSERT_TRUE(catalog_get_after.has_value()); + EXPECT_EQ(catalog_get_after->bytes, catalog_get_before->bytes); + + /// Life 2's ref data is untouched: a fresh resolution (a separate mount over the same backend, + /// exactly like `CASRefWriterRuntimeIdentity.ColdReadRejectsReplacementByExternalPoolActor`'s + /// `external_store`) still sees exactly the value published above. + auto verify_store = Pool::open(backend, PoolConfig{.pool_prefix = "p", .server_root_id = "verify"}); + const auto resolved = verify_store->resolveRef(ns, ref_name); + ASSERT_TRUE(resolved.has_value()); + EXPECT_EQ(resolved->manifest_id, life2_manifest); +} diff --git a/src/Disks/tests/gtest_cas_ref_recovery_cas_walk.cpp b/src/Disks/tests/gtest_cas_ref_recovery_cas_walk.cpp new file mode 100644 index 000000000000..2b75ae13ada0 --- /dev/null +++ b/src/Disks/tests/gtest_cas_ref_recovery_cas_walk.cpp @@ -0,0 +1,2010 @@ +#include + +#include "config.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +/// Stage A task 6: recovery is `_ckpt` + an ARITHMETIC tail + a seal CAS-walk, and it installs nothing +/// without presenting the fence generation it was admitted under. +/// +/// The one sentence this suite exists to defend: **recovery performs no stream `LIST`.** Everything the +/// old recovery knew about a table's durable stream came from one `LIST`, so a listing that silently +/// omitted a key produced a table missing an ACKED transaction and looked perfectly healthy. The +/// checkpoint now supplies the only base and finite frontier; arithmetic exact GETs decide recovery. +/// These list-liar fixtures are retained as sentinels: hiding or fabricating a listed key cannot affect +/// recovery because recovery sends zero stream LIST requests. +/// +/// The other half is INV-2: a dead epoch is closed IN-BAND, by a seal transaction the store's own +/// conditional create places at exactly `{E, T+1}` -- the key a dying predecessor's in-flight PUT would +/// have taken. That is why the walk WRITES, and why every write it performs is gated on the ONE fence +/// generation captured when the recovery was admitted (slot-occupy, the `_ckpt` CAS, and the install +/// recheck -- one capture, three checks). +/// +/// The suite name is prefixed `Cas` so it is covered by the `Cas*` unit-test gate filter. + +namespace DB::ErrorCodes +{ +extern const int CORRUPTED_DATA; +extern const int NETWORK_ERROR; +} + +namespace ProfileEvents +{ +extern const Event CASRefRecoveryRestarts; +extern const Event CASRefRecoveryEpochSealed; +extern const Event CASRefRecoveryEpochSealAdopted; +extern const Event CASRefRecoveryStragglerAdopted; +extern const Event CASRefRecoveryCancelled; +extern const Event CASRefCheckpointPublished; +} + +using namespace DB::Cas; +using DB::Cas::tests::committedRow; +using DB::Cas::tests::CountingBackend; +using DB::Cas::tests::expectThrowsCode; +using DB::Cas::tests::minimalLiveSnapshot; +using DB::Cas::tests::namespaceBirthOp; +using DB::Cas::tests::publishCommittedOps; +using DB::Cas::tests::rearmMountFenceAfterAnomalyForTest; +using DB::Cas::tests::writeRefSnapshotRaw; + +namespace +{ + +ManifestRef manifestRef(uint64_t epoch, uint64_t build_sequence, uint32_t ordinal) +{ + return ManifestRef{epoch, build_sequence, ordinal}; +} + +/// Make the durable mount immediately reclaimable so a test that deliberately moved the local fence +/// generation can drive the production remount boundary without paying a live-lease expiry wait. +void fenceOutMountForRemount(Backend & backend, const String & mount_key) +{ + const auto got = backend.get(mount_key); + ASSERT_TRUE(got.has_value()); + MountLease mount = decodeMountLease(got->bytes); + mount.gc_fenced = true; + mount.seq += 1; + ASSERT_EQ(backend.putOverwrite(mount_key, encodeMountLease(mount), got->token).outcome, + PutOutcome::Done); +} + +/// A backend whose `LIST` can lie by omission. `hidden_keys` remain readable by exact key, so these +/// fixtures prove the stronger modern rule: recovery sends no stream `LIST` at all and therefore cannot +/// be affected by an enumeration inconsistency. +/// +/// Deliberately NOT a "delete the object" fixture: an object that is genuinely gone is a different +/// (and already covered) case. The blocker is an object that EXISTS and is invisible to enumeration. +class HidingListBackend : public CountingBackend +{ +public: + explicit HidingListBackend(bool seed_pool_meta = true) + { + if (seed_pool_meta) + DB::Cas::tests::seedPoolMetaForRestart(*this); + } + + using CountingBackend::get; + using CountingBackend::list; + using CountingBackend::putIfAbsent; + using CountingBackend::casPut; + + std::set hidden_keys; + std::set phantom_list_keys; + + /// Every `putIfAbsent` of a key containing this substring throws a PLAIN (non-`DB::Exception`) + /// error, which `classifyConditionalWriteResult` can only ever classify `Unresolved` -- never + /// `DefiniteFailure`. Persistent rather than one-shot on purpose: the subject is what recovery does + /// when the store KEEPS refusing to say whether the write landed. + String ambiguous_put_substr; + + /// Persistent thrown response for a matching mutable checkpoint CAS. The ref-log PUT has already + /// completed when tests arm this, producing the exact one-successor recovery window. + String ambiguous_cas_substr; + int ambiguous_cas_count = 0; + + /// Runs after a checkpoint publisher read its expected token but before that publisher presents + /// its CAS. This is the exact window in which another admitted writer can advance the frontier. + std::function &)> before_cas_put; + + ListPage list(const String & prefix, const String & cursor, size_t limit) override + { + ListPage page = CountingBackend::list(prefix, cursor, limit); + std::vector kept; + kept.reserve(page.keys.size()); + for (ListedKey & lk : page.keys) + if (!hidden_keys.contains(lk.key)) + kept.push_back(std::move(lk)); + if (cursor.empty()) + { + for (const String & key : phantom_list_keys) + { + if (key.starts_with(prefix)) + kept.push_back(ListedKey{.key = key, .size = 0, .token = std::nullopt}); + } + } + page.keys = std::move(kept); + return page; + } + + PutResult putIfAbsent(const String & key, const String & bytes, const ObjectMeta & meta) override + { + if (!ambiguous_put_substr.empty() && key.find(ambiguous_put_substr) != String::npos) + throw std::runtime_error("injected ambiguous putIfAbsent"); + return CountingBackend::putIfAbsent(key, bytes, meta); + } + + CasResult casPut(const String & key, const String & bytes, const std::optional & expected, + const ObjectMeta & meta) override + { + if (before_cas_put) + before_cas_put(key, bytes, expected); + if (ambiguous_cas_count > 0 && !ambiguous_cas_substr.empty() + && key.find(ambiguous_cas_substr) != String::npos) + { + --ambiguous_cas_count; + throw Poco::TimeoutException("HidingListBackend: simulated ambiguous checkpoint CAS"); + } + return CountingBackend::casPut(key, bytes, expected, meta); + } +}; + +/// Fires `on_key` immediately AFTER a `putIfAbsent` whose key contains `watched_substr` -- the +/// deterministic way to act inside recovery's own write window (bump a fence, land a straggler) with no +/// sleep and no second thread. `skip` lets a test target the Nth such write. +class PutHookBackend : public HidingListBackend +{ +public: + using HidingListBackend::putIfAbsent; + + using HidingListBackend::casPut; + + String watched_substr; + uint64_t skip = 0; + std::function on_key; + + PutResult putIfAbsent(const String & key, const String & bytes, const ObjectMeta & meta) override + { + PutResult result = HidingListBackend::putIfAbsent(key, bytes, meta); + fireIfWatched(key); + return result; + } + + /// The `_ckpt` advance is a token-CAS, not a create, whenever the object already exists -- which is + /// the normal case, since the namespace birth creates it. Hooking only `putIfAbsent` would silently + /// never fire for it. + CasResult casPut(const String & key, const String & bytes, const std::optional & expected, + const ObjectMeta & meta) override + { + CasResult result = HidingListBackend::casPut(key, bytes, expected, meta); + fireIfWatched(key); + return result; + } + +private: + void fireIfWatched(const String & key) + { + if (!on_key || watched_substr.empty() || key.find(watched_substr) == String::npos) + return; + if (skip > 0) + { + --skip; + return; + } + auto hook = on_key; + on_key = nullptr; /// one-shot: a hook that re-enters its own trigger would recurse + hook(); + } +}; + +/// Materializes `late_bytes` at `late_key` at the instant the walk READS that key and finds it absent -- +/// i.e. strictly between the read and the conditional create that follows it. +/// +/// This is the only faithful way to construct the race the `Occupied` arms exist for. Seeding the object +/// up front does NOT work, and finding that out is the point: the walk fetches every id by EXACT KEY, so +/// an object hidden from the listing is simply FOUND by the read and applied there. To meet it as an +/// OCCUPANT of the slot, it has to arrive after the read said absent -- which is exactly what a +/// straggler, or a concurrent recoverer's seal, does. +class LateMaterializeBackend : public HidingListBackend +{ +public: + using HidingListBackend::get; + + String late_key; + String late_bytes; + + std::optional get(const String & key, Range range) override + { + std::optional result = HidingListBackend::get(key, range); + if (!result && !late_key.empty() && key == late_key) + { + CountingBackend::putIfAbsent(late_key, late_bytes); + late_key.clear(); /// one-shot: the walk must see it present from here on + } + return result; + } +}; + +/// Fires `on_key` immediately BEFORE a `get` whose key contains `watched_substr`, and can additionally +/// FAULT that read with a transient object-store error -- the I/O seam the remount-barrier test pauses +/// recovery at. +class GetSeamBackend : public HidingListBackend +{ +public: + using HidingListBackend::get; + + String watched_substr; + + /// Assigned from the test thread and read from whatever thread the recovery runs on, so the + /// read-and-move below is guarded. Today's tests all assign before starting the recovery thread and + /// clear after joining it, so there is no race to fix -- but this is the same seam that already + /// produced one use-after-free, and "the current tests happen not to race" is not a property a + /// future test author can see. The mutex makes the constraint enforced rather than remembered. + std::mutex hook_mutex; + std::function on_key; + + std::optional get(const String & key, Range range) override + { + std::unique_lock hook_lock(hook_mutex); + if (on_key && !watched_substr.empty() && key.find(watched_substr) != String::npos) + { + /// ONE-SHOT by moving the callback OUT before invoking it, and that is a correctness + /// requirement rather than a convenience. A hook that cleared `on_key` from inside its own + /// body would destroy the `std::function` whose closure it is still executing, and every + /// by-reference capture it touched afterwards would read freed heap. That is not + /// theoretical: it is what the first version of these tests did, and the ASan gate caught + /// it as a `heap-use-after-free` while a hook was parked on a condition variable. + auto hook = std::move(on_key); + on_key = nullptr; + /// Released before the hook runs: it parks on a condition variable, and holding the seam's + /// own mutex across that would deadlock the very thread meant to release it. + hook_lock.unlock(); + hook(key); + } + return HidingListBackend::get(key, range); + } +}; + +/// Fires once after an exact GET has already fixed its result. This is the recovery authority seam: +/// another actor advances the log+checkpoint after the walk observed its old end, but before the walk +/// performs its final catalog/checkpoint validation. +class AfterGetHookBackend : public HidingListBackend +{ +public: + using HidingListBackend::get; + + String watched_key; + std::function after_get; + + std::optional get(const String & key, Range range) override + { + std::optional result = HidingListBackend::get(key, range); + if (after_get && key == watched_key) + { + auto hook = std::move(after_get); + after_get = nullptr; + hook(); + } + return result; + } +}; + +CasRequestBudget tinyBudget() +{ + return CasRequestBudget{ + .attempt_timeout_ms = 50, .operation_deadline_ms = 500, .max_attempts = 1, .lease_safety_margin_ms = 50}; +} + +PoolConfig walkTestConfig() +{ + PoolConfig config; + config.pool_prefix = "p"; + config.server_root_id = "test"; + config.server_id = DB::UInt128(1); + config.cas_request_budget = tinyBudget(); + config.wait_sleep_fn = [](uint64_t) {}; + /// No background publication: every test here drives its own, so a threshold-triggered snapshot can + /// never move the base under an assertion about which base recovery chose. + config.snapshot_log_count_threshold = 1ULL << 40; + config.snapshot_log_bytes_threshold = 1ULL << 40; + return config; +} + +PoolPtr openWalkPool(const BackendPtr & backend, PoolConfig config = walkTestConfig()) +{ + DB::Cas::tests::seedPoolMetaForRestart(*backend, config.pool_prefix); + return Pool::open(backend, std::move(config)); +} + +/// Burns durable writer epochs so a subsequent `Pool::open` allocates `target_live_epoch`. Epochs are +/// minted, never reclaimed (`CasPool.cpp`'s allocator), so this is exactly what a pool that has been +/// mounted `n` times looks like -- including the burned epochs in which nothing was ever written, which +/// the seal chain must cross. +void burnEpochsUpTo(Backend & backend, const Layout & layout, uint64_t target_live_epoch) +{ + for (uint64_t e = 1; e < target_live_epoch; ++e) + allocateWriterEpoch(backend, layout, "test", EpochMintPolicy::NormalMount, 0, [] { return RefCatalog{}; }); +} + +/// One ordinary transaction at `id`, publishing `ref` (prepending the birth op when `birth`). +RefLogTxn makeOrdinaryTxn(const RootNamespace & ns, RefTxnId id, const String & ref, bool birth, + std::optional prev_epoch_seal = std::nullopt) +{ + RefLogTxn txn; + txn.ns = ns.string(); + txn.txn_id = id; + if (birth) + txn.ops.push_back(namespaceBirthOp()); + for (const RefOp & op : publishCommittedOps(ref, manifestRef(id.writer_epoch, id.ref_sequence, 1u))) + txn.ops.push_back(op); + txn.prev_epoch_seal = prev_epoch_seal; + return txn; +} + +/// The terminal `remove_namespace` op (this project's warning set requires every field named, so it is +/// built field-by-field rather than by designated init). +RefOp removeNamespaceOp() +{ + RefOp op; + op.kind = RefOpKind::RemoveNamespace; + return op; +} + +/// One EPOCH SEAL transaction at `id` -- what a concurrent recoverer leaves behind. +RefLogTxn makeSealTxn(const RootNamespace & ns, RefTxnId id, + std::optional prev_epoch_seal = std::nullopt) +{ + RefLogTxn seal; + seal.ns = ns.string(); + seal.txn_id = id; + RefOp op; + op.kind = RefOpKind::EpochSeal; + seal.ops.push_back(op); + seal.prev_epoch_seal = prev_epoch_seal; + return seal; +} + +void seedTxn(Backend & backend, const Layout & layout, const RootNamespace & ns, RefTxnId id, + const String & ref, bool birth) +{ + DB::Cas::tests::fixture::writeRefLogRaw(backend, layout, makeOrdinaryTxn(ns, id, ref, birth)); +} + +/// Seeds the `_ckpt` a real namespace birth would have created, so recovery can ground its walk at the +/// namespace's `life_epoch` without consulting the (untrusted) listing. Raw, because these fixtures +/// never run a birth through the append lane. +void seedCkpt(Backend & backend, const Layout & layout, const RootNamespace & ns, const RefCkpt & ckpt) +{ + backend.putIfAbsent(layout.refCkptKey(DB::Cas::tests::fixture::fixtureLife(ns)), encodeRefCkpt(ckpt)); +} + +RefCkpt lifeEpochCkpt(uint64_t life_epoch, std::optional committed_through = std::nullopt) +{ + return RefCkpt{.life_epoch = std::optional{life_epoch}, + .committed_through = committed_through, + .checkpoint_snapshot_id = std::nullopt, + .last_epoch_seal = std::nullopt}; +} + +/// The decoded transaction at `id`, or `nullopt` when the object is absent. Never dereferences a +/// disengaged optional: an aborted binary would take every later suite's result with it. +std::optional readLogTxn(Backend & backend, const Layout & layout, const RootNamespace & ns, RefTxnId id) +{ + const auto got = backend.get(layout.refLogKey(DB::Cas::tests::fixture::fixtureLife(ns), id)); + if (!got) + return std::nullopt; + return decodeRefLogTxn(openObject(FormatId::RefLog, got->bytes), ns.string(), id); +} + +uint64_t counterOf(ProfileEvents::Event event) +{ + return ProfileEvents::global_counters[event].load(); +} + +NamespaceLifeId catalogLife(Backend & backend, const Layout & layout, const RootNamespace & ns) +{ + const CasRefCatalog::Snapshot catalog = CasRefCatalog::read(backend, layout); + for (const CatalogEntry & entry : catalog.catalog.entries) + if (entry.ns == ns) + return NamespaceLifeId::fromCatalogEntry(entry.ns, entry.incarnation); + throw std::runtime_error("test namespace has no catalog life"); +} + +NamespaceLifeId strandOneUnfrontieredSuccessor( + HidingListBackend & backend, const PoolPtr & store, const Layout & layout, const RootNamespace & ns) +{ + store->appendRefOps(ns, MutationScope::ref("a"), + [](const RefTableState & state) + { + std::vector ops; + if (state.getLifecycle() != RefLifecycle::Live) + ops.push_back(namespaceBirthOp()); + for (const RefOp & op : publishCommittedOps("a", manifestRef(1, 1, 1))) + ops.push_back(op); + return ops; + }, RootMutationOrigin::Writer, RootMutationKind::Publish); + + const NamespaceLifeId life = catalogLife(backend, layout, ns); + backend.ambiguous_cas_substr = layout.refCkptKey(life); + backend.ambiguous_cas_count = 200; + expectThrowsCode(DB::ErrorCodes::NETWORK_ERROR, [&] + { + store->appendRefOps(ns, MutationScope::ref("b"), + [](const RefTableState &) { return publishCommittedOps("b", manifestRef(1, 2, 1)); }, + RootMutationOrigin::Writer, RootMutationKind::Publish); + }); + backend.ambiguous_cas_count = 0; + return life; +} + +CatalogEntry replaceCatalogLifeForTest( + Backend & backend, const Layout & layout, const CatalogEntry & predecessor, UInt128 successor_incarnation) +{ + const CasRefCatalog::Snapshot before_delete = CasRefCatalog::read(backend, layout); + RefCatalog without_predecessor = before_delete.catalog; + std::erase_if(without_predecessor.entries, [&](const CatalogEntry & entry) + { + return entry.ns == predecessor.ns && entry.incarnation == predecessor.incarnation; + }); + if (backend.casPut(layout.refCatalogKey(), encodeRefCatalog(without_predecessor), before_delete.token).outcome + != CasOutcome::Committed) + throw std::runtime_error("test failed to retire exact predecessor catalog life"); + + CatalogEntry successor{ + .ns = predecessor.ns, + .state = NsState::Live, + .incarnation = successor_incarnation, + .creator = std::nullopt}; + const CasRefCatalog::Snapshot after_delete = CasRefCatalog::read(backend, layout); + RefCatalog reborn = after_delete.catalog; + reborn.entries.push_back(successor); + if (backend.casPut(layout.refCatalogKey(), encodeRefCatalog(reborn), after_delete.token).outcome + != CasOutcome::Committed) + throw std::runtime_error("test failed to publish successor catalog life"); + return successor; +} + +} + +/// --------------------------------------------------------------------------------------------- +/// The checkpoint-bounded arithmetic tail: no recovery LIST +/// --------------------------------------------------------------------------------------------- + +/// The durable stream is `{1,1} {1,2} {1,3}` while the backend hides the middle key from LIST. +/// Recovery must make zero stream LIST requests and recover the same exact checkpoint range. +TEST(CASRefRecoveryCasWalk, HiddenMiddleLogDoesNotAffectCheckpointRecovery) +{ + auto backend = std::make_shared(); + const Layout layout("p"); + const RootNamespace ns{"srv1/hint_middle"}; + + seedCkpt(*backend, layout, ns, lifeEpochCkpt(1, RefTxnId{1, 3})); + seedTxn(*backend, layout, ns, RefTxnId{1, 1}, "a", /*birth=*/true); + seedTxn(*backend, layout, ns, RefTxnId{1, 2}, "b", /*birth=*/false); + seedTxn(*backend, layout, ns, RefTxnId{1, 3}, "c", /*birth=*/false); + backend->hidden_keys.insert(layout.refLogKey(DB::Cas::tests::fixture::fixtureLife(ns), RefTxnId{1, 2})); + + auto store = openWalkPool(backend); + ASSERT_TRUE(store); + backend->resetCounts(); + + const auto refs = store->listRefs(ns); + EXPECT_EQ(backend->listCount(layout.namespaceStreamPrefix(DB::Cas::tests::fixture::fixtureLife(ns))), 0u); + EXPECT_EQ(refs.size(), 3u) << "the arithmetic walk must fetch {1,2} by exact key"; + EXPECT_TRUE(refs.contains("a")); + EXPECT_TRUE(refs.contains("b")) << "'b' is the ref the omitted transaction published"; + EXPECT_TRUE(refs.contains("c")); +} + +/// The same sentinel at the tail. A hidden tail key is still found by the bounded exact walk, not by a +/// stream enumeration. +TEST(CASRefRecoveryCasWalk, HiddenTailLogDoesNotAffectCheckpointRecovery) +{ + auto backend = std::make_shared(); + const Layout layout("p"); + const RootNamespace ns{"srv1/hint_tail"}; + + seedCkpt(*backend, layout, ns, lifeEpochCkpt(1, RefTxnId{1, 2})); + seedTxn(*backend, layout, ns, RefTxnId{1, 1}, "a", /*birth=*/true); + seedTxn(*backend, layout, ns, RefTxnId{1, 2}, "b", /*birth=*/false); + backend->hidden_keys.insert(layout.refLogKey(DB::Cas::tests::fixture::fixtureLife(ns), RefTxnId{1, 2})); + + auto store = openWalkPool(backend); + ASSERT_TRUE(store); + backend->resetCounts(); + + const auto refs = store->listRefs(ns); + EXPECT_EQ(backend->listCount(layout.namespaceStreamPrefix(DB::Cas::tests::fixture::fixtureLife(ns))), 0u); + EXPECT_EQ(refs.size(), 2u) << "an omitted TAIL id is indistinguishable from the end of the stream to a " + "listing; recovery never enumerates it and exact-reads the checkpoint range"; + EXPECT_TRUE(refs.contains("b")); +} + +/// Hiding the checkpoint base snapshot from LIST cannot matter: the checkpoint names it, recovery +/// exact-reads its matching non-seal log first, then exact-reads the snapshot. +TEST(CASRefRecoveryCasWalk, CkptNamedBaseIsRecoveredWithoutStreamList) +{ + auto backend = std::make_shared(); + const Layout layout("p"); + const RootNamespace ns{"srv1/hint_snap"}; + + const RefTxnId base{1, 1}; + seedTxn(*backend, layout, ns, base, "a", /*birth=*/true); + writeRefSnapshotRaw(*backend, layout, + minimalLiveSnapshot(ns.string(), base, {committedRow("a", manifestRef(1, 1, 1))})); + seedTxn(*backend, layout, ns, RefTxnId{1, 2}, "c", /*birth=*/false); + seedCkpt(*backend, layout, ns, RefCkpt{.life_epoch = std::optional{1}, + .committed_through = base, + .checkpoint_snapshot_id = base, + .last_epoch_seal = std::nullopt}); + backend->hidden_keys.insert(layout.refSnapshotKey(DB::Cas::tests::fixture::fixtureLife(ns), base)); + + auto store = openWalkPool(backend); + ASSERT_TRUE(store); + backend->resetCounts(); + + const auto refs = store->listRefs(ns); + EXPECT_EQ(backend->listCount(layout.namespaceStreamPrefix(DB::Cas::tests::fixture::fixtureLife(ns))), 0u); + EXPECT_EQ(refs.size(), 2u) << "the checkpoint names the base; the listing's omission is irrelevant"; + EXPECT_TRUE(refs.contains("a")) << "'a' exists inside the checkpoint-named snapshot"; + EXPECT_TRUE(refs.contains("c")); +} + +TEST(CASRefRecoveryCasWalk, MissingExactIdAtOrBelowCommittedFrontierIsCorruption) +{ + auto backend = std::make_shared(); + const Layout layout("p"); + const RootNamespace ns{"srv1/missing_below_frontier"}; + const RefTxnId frontier{1, 2}; + + DB::Cas::tests::fixture::admitLive(*backend, layout, ns); + const NamespaceLifeId life = catalogLife(*backend, layout, ns); + seedTxn(*backend, layout, ns, RefTxnId{1, 1}, "a", /*birth=*/true); + ASSERT_EQ(backend->putIfAbsent(layout.refCkptKey(life), encodeRefCkpt(RefCkpt{ + .life_epoch = std::optional{1}, + .committed_through = frontier, + .checkpoint_snapshot_id = std::nullopt, + .last_epoch_seal = std::nullopt})).outcome, PutOutcome::Done); + const auto ckpt_before = readCkpt(*backend, layout, life); + ASSERT_TRUE(ckpt_before); + + auto store = openWalkPool(backend); + expectThrowsCode(DB::ErrorCodes::CORRUPTED_DATA, [&] { (void)store->listRefs(ns); }); + + const auto ckpt_after = readCkpt(*backend, layout, life); + ASSERT_TRUE(ckpt_after); + EXPECT_EQ(ckpt_after->token, ckpt_before->token) + << "an unchanged checkpoint makes the missing committed id corruption, not a shorter stream"; +} + +TEST(CASRefRecoveryCasWalk, UncommittedSnapshotIsUnobservedWithoutStreamList) +{ + auto backend = std::make_shared(); + const Layout layout("p"); + const RootNamespace ns{"srv1/hint_above_frontier"}; + const RefTxnId frontier{1, 1}; + const RefTxnId uncommitted_snapshot_id{1, 2}; + + DB::Cas::tests::fixture::admitLive(*backend, layout, ns); + const NamespaceLifeId life = catalogLife(*backend, layout, ns); + seedTxn(*backend, layout, ns, frontier, "committed", /*birth=*/true); + writeRefSnapshotRaw(*backend, layout, + minimalLiveSnapshot(ns.string(), uncommitted_snapshot_id, + {committedRow("laundered", manifestRef(1, 2, 1))})); + ASSERT_EQ(backend->putIfAbsent(layout.refCkptKey(life), encodeRefCkpt(RefCkpt{ + .life_epoch = std::optional{1}, + .committed_through = frontier, + .checkpoint_snapshot_id = std::nullopt, + .last_epoch_seal = std::nullopt})).outcome, PutOutcome::Done); + + auto store = openWalkPool(backend); + backend->resetCounts(); + const auto refs = store->listRefs(ns); + + EXPECT_EQ(backend->listCount(layout.namespaceStreamPrefix(life)), 0u); + EXPECT_TRUE(refs.contains("committed")); + EXPECT_FALSE(refs.contains("laundered")) + << "a physical snapshot not named by `_ckpt` cannot raise the recovered cut"; +} + +TEST(CASRefRecoveryCasWalk, ListingShapeDoesNotAffectCheckpointRecovery) +{ + const Layout layout("p"); + const RootNamespace ns{"srv1/list_equivalence"}; + const RefTxnId base{1, 1}; + const RefTxnId frontier{1, 2}; + auto seed = std::make_shared(); + + DB::Cas::tests::fixture::admitLive(*seed, layout, ns); + seedTxn(*seed, layout, ns, base, "a", /*birth=*/true); + writeRefSnapshotRaw(*seed, layout, + minimalLiveSnapshot(ns.string(), base, {committedRow("a", manifestRef(1, 1, 1))})); + seedTxn(*seed, layout, ns, frontier, "b", /*birth=*/false); + seedCkpt(*seed, layout, ns, RefCkpt{ + .life_epoch = std::optional{1}, + .committed_through = frontier, + .checkpoint_snapshot_id = base, + .last_epoch_seal = std::nullopt}); + const NamespaceLifeId life = catalogLife(*seed, layout, ns); + + const auto clone_seed = [&]() -> std::shared_ptr + { + /// A clone starts empty: constructing the normal fixture would pre-seed independent pool-meta + /// bytes before this loop could copy the source's identical durable image. + auto backend = std::make_shared(/*seed_pool_meta=*/false); + String cursor; + do + { + const ListPage page = seed->list("", cursor, 1000); + for (const ListedKey & listed : page.keys) + { + const auto object = seed->get(listed.key); + if (!object) + throw std::runtime_error("seed LIST returned a key that exact GET could not read"); + const auto existing = backend->get(listed.key); + if (existing) + { + if (existing->bytes != object->bytes || existing->attributes != object->attributes) + throw std::runtime_error("clone backend constructor disagreed with seeded object"); + } + else if (backend->putIfAbsent(listed.key, object->bytes, object->attributes).outcome != PutOutcome::Done) + throw std::runtime_error("clone backend failed to copy seeded object"); + } + cursor = page.next_cursor; + } while (!cursor.empty()); + return backend; + }; + const auto recover = [&](const std::shared_ptr & backend) + { + auto store = openWalkPool(backend); + backend->resetCounts(); + const auto refs = store->listRefs(ns); + EXPECT_EQ(backend->listCount(layout.namespaceStreamPrefix(life)), 0u); + return refs; + }; + + const auto full = recover(clone_seed()); + auto empty_backend = clone_seed(); + empty_backend->hidden_keys.insert(layout.refSnapshotKey(life, base)); + empty_backend->hidden_keys.insert(layout.refLogKey(life, frontier)); + const auto empty = recover(empty_backend); + ASSERT_EQ(full.size(), empty.size()); + for (const auto & [name, resolved] : full) + { + ASSERT_TRUE(empty.contains(name)); + EXPECT_EQ(resolved.manifest_id, empty.at(name).manifest_id); + EXPECT_EQ(resolved.manifest_size, empty.at(name).manifest_size); + EXPECT_EQ(resolved.published_at_ms, empty.at(name).published_at_ms); + } + EXPECT_TRUE(empty.contains("a")); + EXPECT_TRUE(empty.contains("b")); +} + +TEST(CASRefRecoveryCasWalk, PhantomListedSnapshotIsUnobserved) +{ + auto backend = std::make_shared(); + const Layout layout("p"); + const RootNamespace ns{"srv1/stale_snapshot_hint"}; + const RefTxnId checkpoint_base{1, 1}; + const RefTxnId frontier{1, 2}; + + seedTxn(*backend, layout, ns, checkpoint_base, "a", /*birth=*/true); + writeRefSnapshotRaw(*backend, layout, + minimalLiveSnapshot(ns.string(), checkpoint_base, {committedRow("a", manifestRef(1, 1, 1))})); + seedTxn(*backend, layout, ns, frontier, "b", /*birth=*/false); + seedCkpt(*backend, layout, ns, RefCkpt{ + .life_epoch = std::optional{1}, + .committed_through = frontier, + .checkpoint_snapshot_id = checkpoint_base, + .last_epoch_seal = std::nullopt}); + const NamespaceLifeId life = catalogLife(*backend, layout, ns); + backend->phantom_list_keys.insert(layout.refSnapshotKey(life, frontier)); + + auto store = openWalkPool(backend); + backend->resetCounts(); + const auto refs = store->listRefs(ns); + + EXPECT_EQ(backend->listCount(layout.namespaceStreamPrefix(life)), 0u); + EXPECT_TRUE(refs.contains("a")); + EXPECT_TRUE(refs.contains("b")); +} + +TEST(CASRefRecoveryCasWalk, ListedFPlusTwoWithoutFPlusOneIsInertUncommittedDebris) +{ + auto backend = std::make_shared(); + const Layout layout("p"); + const RootNamespace ns{"srv1/listed_uncommitted_debris"}; + const RefTxnId frontier{1, 1}; + + seedTxn(*backend, layout, ns, frontier, "a", /*birth=*/true); + seedTxn(*backend, layout, ns, RefTxnId{1, 3}, "debris", /*birth=*/false); + seedCkpt(*backend, layout, ns, lifeEpochCkpt(1, frontier)); + + auto store = openWalkPool(backend); + backend->resetCounts(); + const auto refs = store->listRefs(ns); + + EXPECT_EQ(backend->listCount(layout.namespaceStreamPrefix(DB::Cas::tests::fixture::fixtureLife(ns))), 0u); + EXPECT_TRUE(refs.contains("a")); + EXPECT_FALSE(refs.contains("debris")); +} + +TEST(CASRefRecoveryCasWalk, DuplicateCatalogLifeIsCorruptionBeforeColdRuntimeAdmission) +{ + auto backend = std::make_shared(); + const Layout layout("p"); + const RootNamespace ns{"srv1/ambiguous_life_a"}; + auto store = openWalkPool(backend); + const NamespaceLifeId life = strandOneUnfrontieredSuccessor(*backend, store, layout, ns); + ASSERT_EQ(store->laneStateForTest(ns), RefLaneState::NeedsRecovery); + + const CasRefCatalog::Snapshot sampled = CasRefCatalog::read(*backend, layout); + ASSERT_EQ(sampled.catalog.entries.size(), 1u); + RefCatalog ambiguous = sampled.catalog; + ambiguous.entries.push_back(CatalogEntry{ + .ns = RootNamespace{"srv1/ambiguous_life_b"}, + .state = NsState::Live, + .incarnation = life.incarnation}); + std::sort(ambiguous.entries.begin(), ambiguous.entries.end(), + [](const CatalogEntry & lhs, const CatalogEntry & rhs) { return lhs.ns.string() < rhs.ns.string(); }); + ASSERT_EQ(backend->casPut(layout.refCatalogKey(), encodeRefCatalog(ambiguous), sampled.token).outcome, + CasOutcome::Committed); + + expectThrowsCode(DB::ErrorCodes::CORRUPTED_DATA, [&] + { + auto cold_store = openWalkPool(backend); + (void)cold_store->listRefs(ns); + }); +} + +TEST(CASRefRecoveryCasWalk, CheckpointAdvanceAfterLastLogProbeRestartsBeforeInstall) +{ + auto backend = std::make_shared(); + const Layout layout("p"); + const RootNamespace ns{"srv1/final_authority_validation"}; + const RefTxnId initial_frontier{1, 1}; + const RefTxnId concurrent_frontier{1, 2}; + + seedTxn(*backend, layout, ns, initial_frontier, "a", /*birth=*/true); + seedCkpt(*backend, layout, ns, lifeEpochCkpt(1, initial_frontier)); + const NamespaceLifeId life = catalogLife(*backend, layout, ns); + backend->watched_key = layout.refLogKey(life, concurrent_frontier); + backend->after_get = [&] + { + seedTxn(*backend, layout, ns, concurrent_frontier, "b", /*birth=*/false); + const auto sampled = readCkpt(*backend, layout, life); + ASSERT_TRUE(sampled); + RefCkpt advanced = sampled->ckpt; + advanced.committed_through = concurrent_frontier; + ASSERT_EQ(backend->casPut(layout.refCkptKey(life), encodeRefCkpt(advanced), sampled->token).outcome, + CasOutcome::Committed); + }; + + auto store = openWalkPool(backend); + const uint64_t restarts_before = store->refRecoveryRestartsForTest(ns); + const auto refs = store->listRefs(ns); + + EXPECT_TRUE(refs.contains("a")); + EXPECT_TRUE(refs.contains("b")) + << "the old private cut must be discarded when final exact authority moved after its last probe"; + EXPECT_GT(store->refRecoveryRestartsForTest(ns), restarts_before) + << "the final authority observation is recovery's linearization point"; +} + +TEST(CASRefRecoveryCasWalk, LiveCatalogLifeWithoutReadableCheckpointIsCorruption) +{ + auto backend = std::make_shared(); + const Layout layout("p"); + const RootNamespace ns{"srv1/live_without_ckpt"}; + + DB::Cas::tests::fixture::admitLive(*backend, layout, ns); + const NamespaceLifeId life = catalogLife(*backend, layout, ns); + seedTxn(*backend, layout, ns, RefTxnId{7, 1}, "hint-must-not-be-genesis", /*birth=*/true); + ASSERT_FALSE(readCkpt(*backend, layout, life)); + + auto store = openWalkPool(backend); + expectThrowsCode(DB::ErrorCodes::CORRUPTED_DATA, [&] { (void)store->listRefs(ns); }); +} + +/// A 404 BELOW the exact committed frontier is not the end of the stream -- it is a HOLE, and a hole +/// in a dense stream is corruption. Recovery exact-reads the checkpoint token once to distinguish a +/// concurrently moved cut from durable-data loss, then FAILS CLOSED while that token is unchanged. It +/// must never fold what it has: that is precisely how an acknowledged transaction disappears. +TEST(CASRefRecoveryCasWalk, AbsentIdBelowADurableHigherIdFailsClosed) +{ + auto backend = std::make_shared(); + const Layout layout("p"); + const RootNamespace ns{"srv1/hole"}; + + seedCkpt(*backend, layout, ns, lifeEpochCkpt(1, RefTxnId{1, 3})); + seedTxn(*backend, layout, ns, RefTxnId{1, 1}, "a", /*birth=*/true); + /// {1,2} is MISSING while {1,3} is durable and listed: the listing itself witnesses the hole. + seedTxn(*backend, layout, ns, RefTxnId{1, 3}, "c", /*birth=*/false); + + auto store = openWalkPool(backend); + ASSERT_TRUE(store); + store->setCasRetrySleepForTest([](uint64_t) {}); + + expectThrowsCode(DB::ErrorCodes::CORRUPTED_DATA, [&] { store->listRefs(ns); }); +} + +/// --------------------------------------------------------------------------------------------- +/// The CAS-walk: closing dead epochs in-band +/// --------------------------------------------------------------------------------------------- + +/// The ordinary case: one dead epoch, closed by OUR seal at `{E, T+1}` -- the exact key a dying +/// predecessor's in-flight PUT would have taken, which is what makes the store's conditional create the +/// fence (INV-2) rather than a detector after the fact. +TEST(CASRefRecoveryCasWalk, DeadEpochIsClosedByOurOwnSealAtTPlusOne) +{ + auto backend = std::make_shared(); + const Layout layout("p"); + const RootNamespace ns{"srv1/seal_created"}; + + burnEpochsUpTo(*backend, layout, /*target_live_epoch=*/2); + seedCkpt(*backend, layout, ns, lifeEpochCkpt(1, RefTxnId{1, 1})); + seedTxn(*backend, layout, ns, RefTxnId{1, 1}, "a", /*birth=*/true); + + auto store = openWalkPool(backend); + ASSERT_TRUE(store); + ASSERT_EQ(store->liveWriterEpoch(), 2u); + + const uint64_t sealed_before = counterOf(ProfileEvents::CASRefRecoveryEpochSealed); + ASSERT_EQ(store->listRefs(ns).size(), 1u); + EXPECT_EQ(counterOf(ProfileEvents::CASRefRecoveryEpochSealed), sealed_before + 1); + + const RefTxnId seal_id{1, 2}; + const auto seal = readLogTxn(*backend, layout, ns, seal_id); + ASSERT_TRUE(seal.has_value()) << "epoch 1 is dead and must be closed at {1,2}"; + EXPECT_TRUE(refLogTxnIsEpochSeal(*seal)); + EXPECT_EQ(seal->prev_epoch_seal, std::nullopt) << "sequence 2 never carries a chain link"; + EXPECT_EQ(store->lastEpochSealForTest(ns), std::optional(seal_id)) + << "the chain link the next epoch's sequence-1 transaction must name"; +} + +/// A concurrent recoverer got there first. Its seal is already at `{E, T+1}`, so our conditional create +/// loses -- and the right reaction is to ADOPT it, not to treat a peer's correct write as interference. +/// The adopted seal is the same chain link ours would have been. +TEST(CASRefRecoveryCasWalk, ConcurrentRecoverersSealIsAdoptedNotContested) +{ + auto backend = std::make_shared(); + const Layout layout("p"); + const RootNamespace ns{"srv1/seal_adopt"}; + + burnEpochsUpTo(*backend, layout, /*target_live_epoch=*/2); + seedCkpt(*backend, layout, ns, lifeEpochCkpt(1, RefTxnId{1, 1})); + seedTxn(*backend, layout, ns, RefTxnId{1, 1}, "a", /*birth=*/true); + /// The peer's seal lands between our read of {1,2} and our create of it, so we meet it as an + /// OCCUPANT rather than as a tail entry. + backend->late_key = layout.refLogKey(DB::Cas::tests::fixture::fixtureLife(ns), RefTxnId{1, 2}); + backend->late_bytes = sealObject(FormatId::RefLog, encodeRefLogTxn(makeSealTxn(ns, RefTxnId{1, 2}))); + + auto store = openWalkPool(backend); + ASSERT_TRUE(store); + + const uint64_t adopted_before = counterOf(ProfileEvents::CASRefRecoveryEpochSealAdopted); + ASSERT_EQ(store->listRefs(ns).size(), 1u); + EXPECT_GT(counterOf(ProfileEvents::CASRefRecoveryEpochSealAdopted), adopted_before); + EXPECT_EQ(store->lastEpochSealForTest(ns), std::optional(RefTxnId{1, 2})) + << "an adopted seal is this namespace's chain link exactly as a minted one is"; +} + +/// A STRAGGLER: an ordinary transaction of the dead epoch landed at `{E, T+1}` after our read of the +/// tail and before our seal. The rule is state-derived ids (INV-2): adopt the transaction, advance `T` +/// by ONE, and re-seal at the NEW `T+1`. Never mint `T+2` around it -- that writes a hole into the +/// durable stream that no later reader can tell from a lost object. +TEST(CASRefRecoveryCasWalk, StragglerAtTPlusOneIsAdoptedAndResealedAtTheNewTPlusOne) +{ + auto backend = std::make_shared(); + const Layout layout("p"); + const RootNamespace ns{"srv1/straggler"}; + + burnEpochsUpTo(*backend, layout, /*target_live_epoch=*/2); + seedCkpt(*backend, layout, ns, lifeEpochCkpt(1, RefTxnId{1, 1})); + seedTxn(*backend, layout, ns, RefTxnId{1, 1}, "a", /*birth=*/true); + /// The dying epoch's last append materializes between our read of {1,2} and our create of it -- the + /// straggler, arriving exactly where the every-attempt rule says it can. + backend->late_key = layout.refLogKey(DB::Cas::tests::fixture::fixtureLife(ns), RefTxnId{1, 2}); + backend->late_bytes = sealObject(FormatId::RefLog, + encodeRefLogTxn(makeOrdinaryTxn(ns, RefTxnId{1, 2}, "late", /*birth=*/false))); + + auto store = openWalkPool(backend); + ASSERT_TRUE(store); + + const uint64_t straggler_before = counterOf(ProfileEvents::CASRefRecoveryStragglerAdopted); + const auto refs = store->listRefs(ns); + EXPECT_EQ(refs.size(), 2u) << "the straggler's transaction is durable and must be applied, not skipped"; + EXPECT_TRUE(refs.contains("late")); + EXPECT_GT(counterOf(ProfileEvents::CASRefRecoveryStragglerAdopted), straggler_before); + + const auto seal = readLogTxn(*backend, layout, ns, RefTxnId{1, 3}); + ASSERT_TRUE(seal.has_value()) << "the epoch must be re-sealed at the NEW T+1 = {1,3}, never at a blindly minted T+2"; + EXPECT_TRUE(refLogTxnIsEpochSeal(*seal)); + EXPECT_EQ(store->lastEpochSealForTest(ns), std::optional(RefTxnId{1, 3})); +} + +TEST(CASRefRecoveryCasWalk, RecoveryPublishesEveryOccupiedObjectBeforeAdvancingPastIt) +{ + struct Case + { + String suffix; + uint64_t live_epoch; + RefTxnId occupant; + RefTxnId forbidden_successor; + bool occupant_is_seal; + }; + const std::vector cases{ + {"seal", 3, {1, 2}, {2, 1}, true}, + {"straggler", 2, {1, 2}, {1, 3}, false}, + }; + + for (const Case & test_case : cases) + { + SCOPED_TRACE(test_case.suffix); + auto backend = std::make_shared(); + const Layout layout("p"); + const RootNamespace ns{"srv1/occupied_frontier_" + test_case.suffix}; + const RefTxnId initial_frontier{1, 1}; + + burnEpochsUpTo(*backend, layout, test_case.live_epoch); + seedTxn(*backend, layout, ns, initial_frontier, "a", /*birth=*/true); + seedCkpt(*backend, layout, ns, lifeEpochCkpt(1, initial_frontier)); + const NamespaceLifeId life = catalogLife(*backend, layout, ns); + backend->late_key = layout.refLogKey(life, test_case.occupant); + const RefLogTxn occupant = test_case.occupant_is_seal + ? makeSealTxn(ns, test_case.occupant) + : makeOrdinaryTxn(ns, test_case.occupant, "late", /*birth=*/false); + backend->late_bytes = sealObject(FormatId::RefLog, encodeRefLogTxn(occupant)); + + uint64_t fake_now = 1'000'000; + PoolConfig config = walkTestConfig(); + config.boot_ms_fn = [&fake_now] { return fake_now; }; + config.cas_request_budget.recovery_retry_budget_ms = 1; + config.cas_request_budget.recovery_retry_initial_backoff_ms = 1; + config.cas_request_budget.recovery_retry_max_backoff_ms = 1; + auto store = openWalkPool(backend, config); + store->setCasRetrySleepForTest([&fake_now](uint64_t ms) { fake_now += ms; }); + + backend->ambiguous_cas_substr = layout.refCkptKey(life); + backend->ambiguous_cas_count = 100'000; + expectThrowsCode(DB::ErrorCodes::NETWORK_ERROR, [&] { (void)store->listRefs(ns); }); + + EXPECT_TRUE(backend->get(layout.refLogKey(life, test_case.occupant))); + EXPECT_FALSE(backend->get(layout.refLogKey(life, test_case.forbidden_successor))) + << "recovery advanced before exact _ckpt certified the occupied object"; + EXPECT_EQ(readCkpt(*backend, layout, life)->ckpt.committed_through, initial_frontier); + EXPECT_FALSE(store->refTableRecoveredForTest(ns)); + } +} + +/// Two BURNED epochs -- mounted, never written to, and abandoned. `CasPool`'s epoch allocator mints and +/// never reclaims, so this is the normal shape of a pool that has restarted a few times, not an +/// anomaly. Each empty epoch is closed by its own sequence-1 seal, and each carries the previous seal as +/// its `prev_epoch_seal`: the chain is what makes a MISSING epoch detectable, which arithmetic within an +/// epoch cannot do. +TEST(CASRefRecoveryCasWalk, TwoBurnedEmptyEpochsProduceTwoChainedSequenceOneSeals) +{ + auto backend = std::make_shared(); + const Layout layout("p"); + const RootNamespace ns{"srv1/burned"}; + + burnEpochsUpTo(*backend, layout, /*target_live_epoch=*/4); + seedCkpt(*backend, layout, ns, lifeEpochCkpt(1, RefTxnId{1, 1})); + seedTxn(*backend, layout, ns, RefTxnId{1, 1}, "a", /*birth=*/true); + + auto store = openWalkPool(backend); + ASSERT_TRUE(store); + ASSERT_EQ(store->liveWriterEpoch(), 4u); + + ASSERT_EQ(store->listRefs(ns).size(), 1u); + + const auto seal1 = readLogTxn(*backend, layout, ns, RefTxnId{1, 2}); + ASSERT_TRUE(seal1.has_value()) << "epoch 1 closes at {1,2}"; + EXPECT_EQ(seal1->prev_epoch_seal, std::nullopt); + + const auto seal2 = readLogTxn(*backend, layout, ns, RefTxnId{2, 1}); + ASSERT_TRUE(seal2.has_value()) << "empty epoch 2 still closes -- at its sequence 1"; + EXPECT_TRUE(refLogTxnIsEpochSeal(*seal2)); + EXPECT_EQ(seal2->prev_epoch_seal, std::optional(RefTxnId{1, 2})) + << "a sequence-1 seal MUST name the seal that closed the previous epoch"; + + const auto seal3 = readLogTxn(*backend, layout, ns, RefTxnId{3, 1}); + ASSERT_TRUE(seal3.has_value()) << "empty epoch 3 closes too"; + EXPECT_EQ(seal3->prev_epoch_seal, std::optional(RefTxnId{2, 1})); + + EXPECT_FALSE(readLogTxn(*backend, layout, ns, RefTxnId{4, 1}).has_value()) + << "epoch 4 is LIVE -- sealing it would close the epoch this mount writes in"; + EXPECT_EQ(store->lastEpochSealForTest(ns), std::optional(RefTxnId{3, 1})); +} + +TEST(CASRefRecoveryCasWalk, RecoveryPublishesEachCreatedSealBeforeCreatingTheNextEpochSeal) +{ + auto backend = std::make_shared(); + const Layout layout("p"); + const RootNamespace ns{"srv1/seal_frontier_before_next"}; + const RefTxnId initial_frontier{1, 1}; + const RefTxnId first_seal{1, 2}; + const RefTxnId second_seal{2, 1}; + const RefTxnId cold_remount_frontier{3, 1}; + + burnEpochsUpTo(*backend, layout, /*target_live_epoch=*/3); + seedTxn(*backend, layout, ns, initial_frontier, "a", /*birth=*/true); + seedCkpt(*backend, layout, ns, lifeEpochCkpt(1, initial_frontier)); + const NamespaceLifeId life = catalogLife(*backend, layout, ns); + + uint64_t fake_now = 1'000'000; + PoolConfig config = walkTestConfig(); + config.boot_ms_fn = [&fake_now] { return fake_now; }; + config.cas_request_budget.recovery_retry_budget_ms = 1; + config.cas_request_budget.recovery_retry_initial_backoff_ms = 1; + config.cas_request_budget.recovery_retry_max_backoff_ms = 1; + auto store = openWalkPool(backend, config); + ASSERT_EQ(store->liveWriterEpoch(), 3u); + store->setCasRetrySleepForTest([&fake_now](uint64_t ms) { fake_now += ms; }); + + backend->ambiguous_cas_substr = layout.refCkptKey(life); + backend->ambiguous_cas_count = 100'000; + expectThrowsCode(DB::ErrorCodes::NETWORK_ERROR, [&] { (void)store->listRefs(ns); }); + + EXPECT_TRUE(backend->get(layout.refLogKey(life, first_seal))) + << "the first recovery seal became durable before its frontier attempt"; + EXPECT_FALSE(backend->get(layout.refLogKey(life, second_seal))) + << "recovery may not create a second object while the first is still above exact _ckpt"; + EXPECT_EQ(readCkpt(*backend, layout, life)->ckpt.committed_through, initial_frontier); + EXPECT_FALSE(store->refTableRecoveredForTest(ns)); + + /// Restart cold, without the failed mount's `NeedsRecovery` attempt. The first seal is durable but + /// still outside `_ckpt`; the remount must recover and certify it before it may create `{2,1}`. + backend->ambiguous_cas_count = 0; + store.reset(); + auto cold_store = openWalkPool(backend); + ASSERT_EQ(cold_store->liveWriterEpoch(), 4u); + ASSERT_EQ(cold_store->listRefs(ns).size(), 1u); + EXPECT_TRUE(backend->get(layout.refLogKey(life, second_seal))); + EXPECT_TRUE(backend->get(layout.refLogKey(life, cold_remount_frontier))); + EXPECT_EQ(readCkpt(*backend, layout, life)->ckpt.committed_through, cold_remount_frontier); +} + +/// A straggler is not an exception to the recovered-successor rule. When it materializes in the seal +/// slot, recovery adopts it as the one object above its accepted checkpoint and must certify that exact +/// frontier before it can create the following seal at the new `T+1`. +TEST(CASRefRecoveryCasWalk, RecoveryPublishesAnAdoptedStragglerBeforeCreatingItsFollowingSeal) +{ + auto backend = std::make_shared(); + const Layout layout("p"); + const RootNamespace ns{"srv1/straggler_frontier_before_seal"}; + const RefTxnId initial_frontier{1, 1}; + const RefTxnId straggler{1, 2}; + const RefTxnId following_seal{1, 3}; + + burnEpochsUpTo(*backend, layout, /*target_live_epoch=*/2); + seedTxn(*backend, layout, ns, initial_frontier, "a", /*birth=*/true); + seedCkpt(*backend, layout, ns, lifeEpochCkpt(1, initial_frontier)); + const NamespaceLifeId life = catalogLife(*backend, layout, ns); + backend->late_key = layout.refLogKey(life, straggler); + backend->late_bytes = sealObject(FormatId::RefLog, + encodeRefLogTxn(makeOrdinaryTxn(ns, straggler, "late", /*birth=*/false))); + + uint64_t fake_now = 1'000'000; + PoolConfig config = walkTestConfig(); + config.boot_ms_fn = [&fake_now] { return fake_now; }; + config.cas_request_budget.recovery_retry_budget_ms = 1; + config.cas_request_budget.recovery_retry_initial_backoff_ms = 1; + config.cas_request_budget.recovery_retry_max_backoff_ms = 1; + auto store = openWalkPool(backend, config); + store->setCasRetrySleepForTest([&fake_now](uint64_t ms) { fake_now += ms; }); + + backend->ambiguous_cas_substr = layout.refCkptKey(life); + backend->ambiguous_cas_count = 100'000; + expectThrowsCode(DB::ErrorCodes::NETWORK_ERROR, [&] { (void)store->listRefs(ns); }); + + EXPECT_TRUE(backend->get(layout.refLogKey(life, straggler))) + << "the straggler occupied the recovery seal slot"; + EXPECT_FALSE(backend->get(layout.refLogKey(life, following_seal))) + << "recovery may not create a seal after an adopted straggler above exact _ckpt"; + EXPECT_EQ(readCkpt(*backend, layout, life)->ckpt.committed_through, initial_frontier); + EXPECT_FALSE(store->refTableRecoveredForTest(ns)); + + backend->ambiguous_cas_count = 0; + ASSERT_EQ(store->listRefs(ns).size(), 2u); + EXPECT_TRUE(backend->get(layout.refLogKey(life, following_seal))); + EXPECT_EQ(readCkpt(*backend, layout, life)->ckpt.committed_through, following_seal); +} + +/// GENESIS. A namespace born at epoch 5 has no epochs 1-4 of its own: they are not "empty epochs it +/// failed to close", they are epochs before it existed. The walk starts at the namespace's `life_epoch` +/// and writes no phantom seals below it, and with no transition ever having happened it installs NO +/// chain link -- `nullopt` means genesis and must mean it exactly, or the table's first transaction +/// would be required to name a seal that never existed. +TEST(CASRefRecoveryCasWalk, GenesisAtEpochFiveWritesNoPhantomSealsBelowLifeEpoch) +{ + auto backend = std::make_shared(); + const Layout layout("p"); + const RootNamespace ns{"srv1/genesis5"}; + + burnEpochsUpTo(*backend, layout, /*target_live_epoch=*/5); + seedCkpt(*backend, layout, ns, lifeEpochCkpt(5, RefTxnId{5, 1})); + seedTxn(*backend, layout, ns, RefTxnId{5, 1}, "a", /*birth=*/true); + + auto store = openWalkPool(backend); + ASSERT_TRUE(store); + ASSERT_EQ(store->liveWriterEpoch(), 5u); + + ASSERT_EQ(store->listRefs(ns).size(), 1u); + + for (uint64_t e = 1; e <= 4; ++e) + EXPECT_FALSE(readLogTxn(*backend, layout, ns, RefTxnId{e, 1}).has_value()) + << "no seal may be written for epoch " << e << ", which predates this namespace"; + EXPECT_EQ(store->lastEpochSealForTest(ns), std::nullopt) + << "no transition ever happened for this namespace: nullopt means GENESIS and must mean it exactly"; +} + +/// --------------------------------------------------------------------------------------------- +/// The trio: ONE captured generation, three checks +/// --------------------------------------------------------------------------------------------- + +/// The GENERIC mid-walk bump: the fence moves while recovery is doing I/O, so the incarnation that +/// admitted this work is gone. Nothing may be installed -- the recovered view belongs to a mount that no +/// longer owns the namespace. The table stays unrecovered, and a retry under the CURRENT generation +/// succeeds, which is what makes this a refusal rather than a wedge. +TEST(CASRefRecoveryCasWalk, FenceBumpedMidWalkRefusesTheInstallAndTheRetrySucceeds) +{ + auto backend = std::make_shared(); + const Layout layout("p"); + const RootNamespace ns{"srv1/bump_midwalk"}; + + seedCkpt(*backend, layout, ns, lifeEpochCkpt(1, RefTxnId{1, 1})); + seedTxn(*backend, layout, ns, RefTxnId{1, 1}, "a", /*birth=*/true); + + auto store = openWalkPool(backend); + ASSERT_TRUE(store); + + std::atomic bumped{false}; + backend->watched_substr = "_log/"; + backend->on_key = [&](const String &) + { + if (!bumped.exchange(true)) + rearmMountFenceAfterAnomalyForTest(store); + }; + + EXPECT_ANY_THROW(store->listRefs(ns)) << "a recovery whose I/O window straddled a fence bump must install nothing"; + + backend->on_key = nullptr; + fenceOutMountForRemount(*backend, layout.mountKey("test")); + ASSERT_TRUE(store->tryRemountOnce()) + << "a generation bump cannot rebind the captured runtime; the production remount must publish " + "a distinct runtime at the accepted generation"; + EXPECT_EQ(store->listRefs(ns).size(), 1u) << "the retry through the remounted runtime succeeds"; +} + +TEST(CASRefRecoveryCasWalk, RetiredLifePausedInRealRecoveryIoWritesAndInstallsNothing) +{ + auto backend = std::make_shared(); + const Layout layout("p"); + const RootNamespace ns{"srv1/recovery-retired-mid-io"}; + + burnEpochsUpTo(*backend, layout, /*target_live_epoch=*/2); + seedCkpt(*backend, layout, ns, lifeEpochCkpt(1, RefTxnId{1, 1})); + seedTxn(*backend, layout, ns, RefTxnId{1, 1}, "predecessor", /*birth=*/true); + const CatalogEntry predecessor = CasRefCatalog::read(*backend, layout).catalog.entries.front(); + const NamespaceLifeId predecessor_life + = NamespaceLifeId::fromCatalogEntry(predecessor.ns, predecessor.incarnation); + const auto predecessor_ckpt_before = backend->get(layout.refCkptKey(predecessor_life)); + ASSERT_TRUE(predecessor_ckpt_before); + + auto store = openWalkPool(backend); + ASSERT_EQ(store->liveWriterEpoch(), 2u); + const uint64_t recovery_installs_before = store->recoveryInstallCountForTest(); + + std::mutex mutex; + std::condition_variable cv; + bool paused = false; + bool resume = false; + backend->watched_substr = "_log/"; + backend->on_key = [&](const String &) + { + std::unique_lock lock(mutex); + paused = true; + cv.notify_all(); + cv.wait(lock, [&] { return resume; }); + }; + + std::exception_ptr recovery_error; + std::thread recovery([&] + { + try + { + (void)store->listRefs(ns); + } + catch (...) + { + recovery_error = std::current_exception(); + } + }); + { + std::unique_lock lock(mutex); + cv.wait(lock, [&] { return paused; }); + } + + const CatalogEntry successor = replaceCatalogLifeForTest(*backend, layout, predecessor, UInt128{0x5152}); + const NamespaceLifeId successor_life + = NamespaceLifeId::fromCatalogEntry(successor.ns, successor.incarnation); + ASSERT_EQ(backend->putIfAbsent(layout.refCkptKey(successor_life), encodeRefCkpt(lifeEpochCkpt(2))).outcome, + PutOutcome::Done); + const auto successor_ckpt_before = backend->get(layout.refCkptKey(successor_life)); + ASSERT_TRUE(successor_ckpt_before); + store->invalidateRemovedCatalogLife(predecessor_life); + backend->resetCounts(); + + { + std::lock_guard lock(mutex); + resume = true; + } + cv.notify_all(); + recovery.join(); + + EXPECT_TRUE(recovery_error) << "the predecessor recovery must be refused, not exposed"; + EXPECT_EQ(backend->putCount(layout.refLogKey(predecessor_life, RefTxnId{1, 2})), 0u) + << "no predecessor seal retry may be sent after exact retirement"; + EXPECT_EQ(backend->casPutCount(layout.refCkptKey(predecessor_life)), 0u) + << "no predecessor checkpoint CAS may be sent after exact retirement"; + const auto predecessor_ckpt_after = backend->get(layout.refCkptKey(predecessor_life)); + ASSERT_TRUE(predecessor_ckpt_after); + EXPECT_EQ(predecessor_ckpt_after->token, predecessor_ckpt_before->token); + EXPECT_FALSE(store->refTableRecoveredForTest(ns)) << "the detached predecessor result was installed"; + EXPECT_EQ(store->recoveryInstallCountForTest(), recovery_installs_before) + << "the detached predecessor reached the recovery publication point"; + const String successor_prefix = layout.namespaceStreamPrefix(successor_life); + for (const String & key : backend->touchedKeys()) + EXPECT_EQ(key.find(successor_prefix), String::npos) + << "predecessor recovery retargeted storage I/O into successor key " << key; + const auto successor_ckpt_after = backend->get(layout.refCkptKey(successor_life)); + ASSERT_TRUE(successor_ckpt_after); + EXPECT_EQ(successor_ckpt_after->token, successor_ckpt_before->token); + EXPECT_EQ(successor_ckpt_after->bytes, successor_ckpt_before->bytes); +} + +/// Bump point 1 of the trio's two interior seams: AFTER the slot-occupy landed, BEFORE the `_ckpt` CAS. +/// The seal is durable (it was written under a generation that was still valid), but the checkpoint must +/// NOT advance and nothing may be installed. This is the seam a single "check the fence at entry" would +/// miss entirely. +TEST(CASRefRecoveryCasWalk, FenceBumpedAfterSlotOccupyBeforeCkptCasAdvancesNoCheckpoint) +{ + auto backend = std::make_shared(); + const Layout layout("p"); + const RootNamespace ns{"srv1/bump_after_seal"}; + + burnEpochsUpTo(*backend, layout, /*target_live_epoch=*/2); + seedCkpt(*backend, layout, ns, lifeEpochCkpt(1, RefTxnId{1, 1})); + seedTxn(*backend, layout, ns, RefTxnId{1, 1}, "a", /*birth=*/true); + + auto store = openWalkPool(backend); + ASSERT_TRUE(store); + + const auto ckpt_before = readCkpt(*backend, layout, DB::Cas::tests::fixture::fixtureLife(ns)); + ASSERT_TRUE(ckpt_before.has_value()); + + backend->watched_substr = "_log/"; + backend->on_key = [&] { rearmMountFenceAfterAnomalyForTest(store); }; + + EXPECT_ANY_THROW(store->listRefs(ns)); + + const auto ckpt_after = readCkpt(*backend, layout, DB::Cas::tests::fixture::fixtureLife(ns)); + ASSERT_TRUE(ckpt_after.has_value()); + EXPECT_EQ(ckpt_after->ckpt.last_epoch_seal, std::nullopt) + << "the seal is durable but the checkpoint must not record it under a generation that moved"; + EXPECT_EQ(ckpt_after->token, ckpt_before->token) << "no CAS was sent at all"; +} + +/// Bump point 2: AFTER the `_ckpt` CAS, BEFORE the install. The checkpoint advance is harmless (the +/// merge is a semantic maximum, so the retry re-derives the same or a greater value), but the STATE must +/// not be published: this runtime's view belongs to a dead incarnation. Today there is no such recheck +/// at all -- that gap is the whole reason this test exists. +TEST(CASRefRecoveryCasWalk, FenceBumpedAfterCkptCasBeforeInstallPublishesNoState) +{ + auto backend = std::make_shared(); + const Layout layout("p"); + const RootNamespace ns{"srv1/bump_after_ckpt"}; + + burnEpochsUpTo(*backend, layout, /*target_live_epoch=*/2); + seedCkpt(*backend, layout, ns, lifeEpochCkpt(1, RefTxnId{1, 1})); + seedTxn(*backend, layout, ns, RefTxnId{1, 1}, "a", /*birth=*/true); + + auto store = openWalkPool(backend); + ASSERT_TRUE(store); + + /// The `_ckpt` CAS is the LAST write recovery performs, so hooking it fires strictly between the + /// checkpoint advance and the install recheck. + backend->watched_substr = "/_ckpt"; + backend->on_key = [&] { rearmMountFenceAfterAnomalyForTest(store); }; + + EXPECT_ANY_THROW(store->listRefs(ns)) << "the install recheck must refuse a result from a moved generation"; + + const auto ckpt_after = readCkpt(*backend, layout, DB::Cas::tests::fixture::fixtureLife(ns)); + ASSERT_TRUE(ckpt_after.has_value()); + EXPECT_EQ(ckpt_after->ckpt.last_epoch_seal, std::optional(RefTxnId{1, 2})) + << "the checkpoint advance already landed and is harmless -- the merge is a semantic maximum"; + + backend->on_key = nullptr; + fenceOutMountForRemount(*backend, layout.mountKey("test")); + ASSERT_TRUE(store->tryRemountOnce()) + << "a generation bump cannot rebind the captured runtime; the production remount must publish " + "a distinct runtime at the accepted generation"; + EXPECT_EQ(store->listRefs(ns).size(), 1u) << "the retry through the remounted runtime installs normally"; +} + +/// --------------------------------------------------------------------------------------------- +/// The self-remount barrier +/// --------------------------------------------------------------------------------------------- + +/// Spec §3: "self-remount cancels or waits out recovery before rearming." The install recheck alone is +/// not that rule -- it protects the install, not the WINDOW. A recovery paused in its I/O while the +/// fence is re-armed would still be holding an admitted generation that is about to be superseded, and +/// the barrier is what guarantees no `_ckpt` CAS and no install can follow the re-arm. +/// +/// Driven at a real I/O seam: recovery blocks inside a `get`, the remount barrier is invoked from +/// another thread and must BLOCK, the recovery is released, acknowledges the cancellation, and only then +/// does the barrier return. +TEST(CASRefRecoveryCasWalk, RemountBarrierBlocksUntilAPausedRecoveryAcknowledgesCancellation) +{ + auto backend = std::make_shared(); + const Layout layout("p"); + const RootNamespace ns{"srv1/remount_barrier"}; + + burnEpochsUpTo(*backend, layout, /*target_live_epoch=*/2); + seedCkpt(*backend, layout, ns, lifeEpochCkpt(1, RefTxnId{1, 1})); + seedTxn(*backend, layout, ns, RefTxnId{1, 1}, "a", /*birth=*/true); + + auto store = openWalkPool(backend); + ASSERT_TRUE(store); + + std::mutex m; + std::condition_variable cv; + bool recovery_parked = false; + bool release_recovery = false; + + backend->watched_substr = "_log/"; + /// `GetSeamBackend` moves the hook out before calling it, so this parks exactly once without the + /// hook having to clear itself -- see its `get` for why self-clearing is a use-after-free. + backend->on_key = [&](const String &) + { + std::unique_lock lock(m); + recovery_parked = true; + cv.notify_all(); + cv.wait(lock, [&] { return release_recovery; }); + }; + + const uint64_t ckpt_before = counterOf(ProfileEvents::CASRefCheckpointPublished); + const uint64_t cancelled_before = counterOf(ProfileEvents::CASRefRecoveryCancelled); + + std::thread recovery([&] { try { store->listRefs(ns); } catch (...) {} }); // NOLINT(bugprone-empty-catch): the outcome is asserted below via the ProfileEvents counters, not this thread's exception + + { + std::unique_lock lock(m); + cv.wait(lock, [&] { return recovery_parked; }); + } + + std::atomic barrier_returned{false}; + std::thread barrier([&] + { + store->cancelRefRecoveriesAndAwaitQuiescence(); + barrier_returned.store(true); + }); + + /// Wait for the barrier's REQUEST to be visible before touching anything else. Releasing the parked + /// recovery any earlier would race it past a flag set a moment too late, and the test would observe + /// an ordinary completion and call it a missing cancellation. + while (!store->refRecoveryCancelRequestedForTest(ns)) + std::this_thread::yield(); + + /// The request is published and the recovery is still parked, so the barrier is now provably inside + /// its wait. It must not have returned: fence re-arm may not proceed while a recovery is in flight. + EXPECT_FALSE(barrier_returned.load()); + + { + std::lock_guard lock(m); + release_recovery = true; + } + cv.notify_all(); + + barrier.join(); + recovery.join(); + EXPECT_TRUE(barrier_returned.load()); + + EXPECT_GT(counterOf(ProfileEvents::CASRefRecoveryCancelled), cancelled_before) + << "the released recovery must observe the cancellation rather than run to completion"; + EXPECT_EQ(counterOf(ProfileEvents::CASRefCheckpointPublished), ckpt_before) + << "a cancelled recovery performs ZERO _ckpt CASes"; + EXPECT_FALSE(store->refTableRecoveredForTest(ns)) << "and ZERO installs"; +} + +/// A `NeedsRecovery` lane replays the known-durable transaction before returning to `Ready`. +TEST(CASRefRecoveryCasWalk, NeedsRecoveryReplaysTheStrandedTxn) +{ + auto backend = std::make_shared(); + const Layout layout("p"); + const RootNamespace ns{"srv1/poisoned"}; + + auto store = openWalkPool(backend); + ASSERT_TRUE(store); + + /// Publish one ref through the real lane, then fail the next commit's install region: the + /// transaction is durable and the install that would have recorded it throws. + ASSERT_NO_THROW(store->appendRefOps(ns, MutationScope::ref("a"), + [](const RefTableState & state) + { + std::vector ops; + if (state.getLifecycle() != RefLifecycle::Live) + ops.push_back(namespaceBirthOp()); + for (const RefOp & op : publishCommittedOps("a", manifestRef(1, 1, 1))) + ops.push_back(op); + return ops; + }, + RootMutationOrigin::Writer, RootMutationKind::Publish)); + + /// Built outside the region. One-shot, and re-allowing allocations for + /// the duration of the throw: `std::rethrow_exception` allocates through libc++'s + /// `__cxa_rethrow_primary_exception`, which the debug build's `DENY_ALLOCATIONS_IN_SCOPE` aborts on. + /// (Found by the debug gate -- the first cut of this probe took the whole binary down there.) Same + /// shape as `gtest_cas_ref_install_safety.cpp`'s `armOneShotInstallFailure`. + auto planned_failure = std::make_exception_ptr(DB::Exception(DB::ErrorCodes::CORRUPTED_DATA, "install probe")); + auto fired = std::make_shared>(false); + store->setInstallRegionProbeForTest([planned_failure, fired] + { + if (fired->exchange(true)) + return; + ALLOW_ALLOCATIONS_IN_SCOPE; + std::rethrow_exception(planned_failure); + }); + EXPECT_ANY_THROW(store->appendRefOps(ns, MutationScope::ref("b"), + [](const RefTableState &) { return publishCommittedOps("b", manifestRef(1, 2, 1)); }, + RootMutationOrigin::Writer, RootMutationKind::Publish)); + store->setInstallRegionProbeForTest(nullptr); + + ASSERT_EQ(store->laneStateForTest(ns), RefLaneState::NeedsRecovery); + /// "Durable but not applied here", stated as the two facts it is made of: the object IS in the + /// store, and this runtime's floor is what keeps the allocator off its id. `ns` was born through + /// the REAL production lane (`appendRefOps`), not the raw `seedTxn`/`casAdmitEntry` fixtures this + /// file's OTHER tests use -- so its ref-layer objects sit at a REAL, catalog-minted incarnation, + /// not the Stage-A sentinel `readLogTxn` assumes. Resolved here rather than through `readLogTxn`. + { + const CasRefCatalog::Snapshot snap = CasRefCatalog::read(*backend, layout); + const CatalogEntry * entry = nullptr; + for (const CatalogEntry & e : snap.catalog.entries) + if (e.ns.string() == ns.string()) + entry = &e; + ASSERT_NE(entry, nullptr) << "the birth above must have minted a catalog entry for " << ns.string(); + const NamespaceLifeId life = NamespaceLifeId::fromCatalogEntry(entry->ns, entry->incarnation); + ASSERT_TRUE(backend->get(layout.refLogKey(life, RefTxnId{1, 2})).has_value()) + << "the stranded transaction must be durable -- otherwise recovery is not owed"; + } + + /// The next touch drives recovery again -- this is the structural closure Task 3 deferred here. + const auto refs = store->listRefs(ns); + EXPECT_EQ(refs.size(), 2u); + EXPECT_TRUE(refs.contains("b")) << "the walk re-derived the stranded transaction from the durable log"; + EXPECT_EQ(store->laneStateForTest(ns), RefLaneState::Ready) + << "only a completed recovery install returns the lane to Ready"; +} + +TEST(CASRefRecoveryCasWalk, WriterRecoveryAdoptsOneExactUnfrontieredSuccessorAndPublishesItsFrontier) +{ + auto backend = std::make_shared(); + const Layout layout("p"); + const RootNamespace ns{"srv1/recovery_one_successor"}; + auto store = openWalkPool(backend); + + ASSERT_NO_THROW(store->appendRefOps(ns, MutationScope::ref("a"), + [](const RefTableState & state) + { + std::vector ops; + if (state.getLifecycle() != RefLifecycle::Live) + ops.push_back(namespaceBirthOp()); + for (const RefOp & op : publishCommittedOps("a", manifestRef(1, 1, 1))) + ops.push_back(op); + return ops; + }, RootMutationOrigin::Writer, RootMutationKind::Publish)); + + const NamespaceLifeId life = catalogLife(*backend, layout, ns); + const String ckpt_key = layout.refCkptKey(life); + ASSERT_EQ(readCkpt(*backend, layout, life)->ckpt.committed_through, (RefTxnId{1, 1})); + backend->ambiguous_cas_substr = ckpt_key; + backend->ambiguous_cas_count = 200; + + expectThrowsCode(DB::ErrorCodes::NETWORK_ERROR, [&] + { + store->appendRefOps(ns, MutationScope::ref("b"), + [](const RefTableState &) { return publishCommittedOps("b", manifestRef(1, 2, 1)); }, + RootMutationOrigin::Writer, RootMutationKind::Publish); + }); + ASSERT_EQ(store->laneStateForTest(ns), RefLaneState::NeedsRecovery); + ASSERT_TRUE(backend->get(layout.refLogKey(life, RefTxnId{1, 2}))) + << "the sole deterministic successor must be durable before recovery"; + ASSERT_EQ(readCkpt(*backend, layout, life)->ckpt.committed_through, (RefTxnId{1, 1})); + + backend->ambiguous_cas_count = 0; + const auto refs = store->listRefs(ns); + + EXPECT_TRUE(refs.contains("b")); + EXPECT_EQ(store->laneStateForTest(ns), RefLaneState::Ready); + EXPECT_EQ(readCkpt(*backend, layout, life)->ckpt.committed_through, (RefTxnId{1, 2})) + << "the successor is not installable until the current admitted fence publishes its frontier"; +} + +TEST(CASRefRecoveryCasWalk, ColdWriterRecoveryPublishesOneExactUnfrontieredSuccessorBeforeSealing) +{ + auto backend = std::make_shared(); + const Layout layout("p"); + const RootNamespace ns{"srv1/recovery_cold_successor"}; + auto store = openWalkPool(backend); + const NamespaceLifeId life = strandOneUnfrontieredSuccessor(*backend, store, layout, ns); + ASSERT_EQ(store->laneStateForTest(ns), RefLaneState::NeedsRecovery); + store.reset(); + std::vector checkpoint_cas_bodies; + backend->before_cas_put = [&](const String & key, const String & bytes, const std::optional &) + { + if (key == layout.refCkptKey(life)) + checkpoint_cas_bodies.push_back(decodeRefCkpt(bytes)); + }; + + /// A remount/process restart has no in-memory `RefAppendAttempt`; the writer recovery entry point + /// still owns its one exact F+1 adoption duty from the durable checkpoint and log alone. + auto cold_store = openWalkPool(backend); + const auto refs = cold_store->listRefs(ns); + + EXPECT_TRUE(refs.contains("b")); + EXPECT_EQ(cold_store->laneStateForTest(ns), RefLaneState::Ready); + EXPECT_TRUE(std::any_of(checkpoint_cas_bodies.begin(), checkpoint_cas_bodies.end(), + [](const RefCkpt & ckpt) { return ckpt.committed_through == std::make_optional(RefTxnId{1, 2}); })) + << "the exact F+1 frontier must publish before the remount seals its dead epoch"; + EXPECT_EQ(readCkpt(*backend, layout, life)->ckpt.committed_through, (RefTxnId{1, 3})); +} + +TEST(CASRefRecoveryCasWalk, WriterRecoveryAdoptsFirstCommittedTxnAboveLifeEpochOnlyCheckpoint) +{ + auto backend = std::make_shared(); + const Layout layout("p"); + const RootNamespace ns{"srv1/recovery_first_unfrontiered"}; + auto store = openWalkPool(backend); + + /// Create the catalog life explicitly, then retain exactly the checkpoint fragment published by + /// production birth before its first log. This makes `{1,1}` the first durable transaction above a + /// readable checkpoint whose `committed_through` is absent. + DB::Cas::tests::fixture::admitLive(*backend, layout, ns); + const NamespaceLifeId life = catalogLife(*backend, layout, ns); + ASSERT_EQ(backend->putIfAbsent(layout.refCkptKey(life), encodeRefCkpt(lifeEpochCkpt(1))).outcome, + PutOutcome::Done); + ASSERT_TRUE(readCkpt(*backend, layout, life)->ckpt.life_epoch); + ASSERT_EQ(readCkpt(*backend, layout, life)->ckpt.committed_through, std::nullopt); + + backend->ambiguous_cas_substr = layout.refCkptKey(life); + backend->ambiguous_cas_count = 200; + expectThrowsCode(DB::ErrorCodes::NETWORK_ERROR, [&] + { + store->appendRefOps(ns, MutationScope::ref("a"), + [](const RefTableState & state) + { + std::vector ops; + if (state.getLifecycle() != RefLifecycle::Live) + ops.push_back(namespaceBirthOp()); + for (const RefOp & op : publishCommittedOps("a", manifestRef(1, 1, 1))) + ops.push_back(op); + return ops; + }, RootMutationOrigin::Writer, RootMutationKind::Publish); + }); + ASSERT_EQ(store->laneStateForTest(ns), RefLaneState::NeedsRecovery); + ASSERT_TRUE(backend->get(layout.refLogKey(life, RefTxnId{1, 1}))); + ASSERT_EQ(readCkpt(*backend, layout, life)->ckpt.committed_through, std::nullopt); + ASSERT_FALSE(backend->get(layout.refSnapshotKey(life, RefTxnId{1, 1}))) + << "the grounding test must exercise the exact log successor, not a hinted snapshot"; + + backend->ambiguous_cas_count = 0; + const auto refs = store->listRefs(ns); + + EXPECT_TRUE(refs.contains("a")); + EXPECT_EQ(store->laneStateForTest(ns), RefLaneState::Ready); + EXPECT_EQ(readCkpt(*backend, layout, life)->ckpt.committed_through, (RefTxnId{1, 1})); +} + +TEST(CASRefRecoveryCasWalk, WriterRecoveryRestartsWhenCheckpointAdvancesPastPrivateCandidate) +{ + auto backend = std::make_shared(); + const Layout layout("p"); + const RootNamespace ns{"srv1/recovery_checkpoint_moves"}; + auto store = openWalkPool(backend); + const NamespaceLifeId life = strandOneUnfrontieredSuccessor(*backend, store, layout, ns); + const String ckpt_key = layout.refCkptKey(life); + const RefLogTxn later = makeOrdinaryTxn(ns, RefTxnId{1, 3}, "c", /*birth=*/false); + bool injected = false; + + /// Recovery has fetched `{1,2}` and proved `{1,3}` absent. Another admitted writer can then append + /// `{1,3}` and publish its frontier before recovery's own checkpoint CAS. The stale private + /// candidate contains only `b`; it must restart and replay `c`, not accept an `IdenticalSkip` and + /// install below the exact checkpoint it just observed. + backend->before_cas_put = [&](const String & key, const String &, const std::optional & expected) + { + if (injected || key != ckpt_key) + return; + injected = true; + ASSERT_TRUE(expected); + ASSERT_EQ(backend->putIfAbsent(layout.refLogKey(life, later.txn_id), + sealObject(FormatId::RefLog, encodeRefLogTxn(later))).outcome, + PutOutcome::Done); + const auto current = backend->get(key); + ASSERT_TRUE(current); + ASSERT_EQ(current->token, *expected); + const RefCkpt advanced = mergeCkpt( + decodeRefCkpt(current->bytes), + RefCkpt{.life_epoch = std::nullopt, + .committed_through = later.txn_id, + .checkpoint_snapshot_id = std::nullopt, + .last_epoch_seal = std::nullopt}); + ASSERT_EQ(backend->putOverwrite(key, encodeRefCkpt(advanced), current->token).outcome, PutOutcome::Done); + }; + + const auto refs = store->listRefs(ns); + + EXPECT_TRUE(injected); + EXPECT_TRUE(refs.contains("b")); + EXPECT_TRUE(refs.contains("c")) << "recovery must restart from the newer exact frontier"; + EXPECT_EQ(store->laneStateForTest(ns), RefLaneState::Ready); + EXPECT_EQ(readCkpt(*backend, layout, life)->ckpt.committed_through, later.txn_id); +} + +TEST(CASRefRecoveryCasWalk, WriterRecoveryRejectsTwoUnfrontieredSuccessorsAfterExactCheckpointReread) +{ + auto backend = std::make_shared(); + const Layout layout("p"); + const RootNamespace ns{"srv1/recovery_two_successors"}; + auto store = openWalkPool(backend); + + ASSERT_NO_THROW(store->appendRefOps(ns, MutationScope::ref("a"), + [](const RefTableState & state) + { + std::vector ops; + if (state.getLifecycle() != RefLifecycle::Live) + ops.push_back(namespaceBirthOp()); + for (const RefOp & op : publishCommittedOps("a", manifestRef(1, 1, 1))) + ops.push_back(op); + return ops; + }, RootMutationOrigin::Writer, RootMutationKind::Publish)); + + const NamespaceLifeId life = catalogLife(*backend, layout, ns); + const String ckpt_key = layout.refCkptKey(life); + backend->ambiguous_cas_substr = ckpt_key; + backend->ambiguous_cas_count = 200; + expectThrowsCode(DB::ErrorCodes::NETWORK_ERROR, [&] + { + store->appendRefOps(ns, MutationScope::ref("b"), + [](const RefTableState &) { return publishCommittedOps("b", manifestRef(1, 2, 1)); }, + RootMutationOrigin::Writer, RootMutationKind::Publish); + }); + ASSERT_EQ(store->laneStateForTest(ns), RefLaneState::NeedsRecovery); + + const RefLogTxn second_successor = makeOrdinaryTxn(ns, RefTxnId{1, 3}, "c", /*birth=*/false); + ASSERT_EQ(backend->putIfAbsent(layout.refLogKey(life, second_successor.txn_id), + sealObject(FormatId::RefLog, encodeRefLogTxn(second_successor))).outcome, + PutOutcome::Done); + backend->ambiguous_cas_count = 0; + + expectThrowsCode(DB::ErrorCodes::CORRUPTED_DATA, [&] { (void)store->listRefs(ns); }); + EXPECT_EQ(store->laneStateForTest(ns), RefLaneState::NeedsRecovery); + EXPECT_EQ(readCkpt(*backend, layout, life)->ckpt.committed_through, (RefTxnId{1, 1})) + << "corruption must not launder either successor into the frontier"; +} + +TEST(CASRefRecoveryCasWalk, WriterRecoveryRejectsDifferentOrdinaryBytesAtTheRetainedSuccessorSlot) +{ + auto backend = std::make_shared(); + const Layout layout("p"); + const RootNamespace ns{"srv1/recovery_different_successor"}; + auto store = openWalkPool(backend); + const NamespaceLifeId life = strandOneUnfrontieredSuccessor(*backend, store, layout, ns); + ASSERT_EQ(store->laneStateForTest(ns), RefLaneState::NeedsRecovery); + + const String successor_key = layout.refLogKey(life, RefTxnId{1, 2}); + const auto original = backend->get(successor_key); + ASSERT_TRUE(original); + const RefLogTxn different = makeOrdinaryTxn(ns, RefTxnId{1, 2}, "different", /*birth=*/false); + ASSERT_EQ(backend->putOverwrite(successor_key, + sealObject(FormatId::RefLog, encodeRefLogTxn(different)), + original->token).outcome, + PutOutcome::Done); + + expectThrowsCode(DB::ErrorCodes::CORRUPTED_DATA, [&] { (void)store->listRefs(ns); }); + EXPECT_EQ(store->laneStateForTest(ns), RefLaneState::NeedsRecovery); + EXPECT_EQ(readCkpt(*backend, layout, life)->ckpt.committed_through, (RefTxnId{1, 1})); +} + +TEST(CASRefRecoveryCasWalk, RetainedOldWriterAttemptLosesConclusiveToASuccessorSeal) +{ + auto backend = std::make_shared(); + const Layout layout("p"); + const RootNamespace ns{"srv1/recovery_successor_seal"}; + auto store = openWalkPool(backend); + const NamespaceLifeId life = strandOneUnfrontieredSuccessor(*backend, store, layout, ns); + ASSERT_EQ(store->laneStateForTest(ns), RefLaneState::NeedsRecovery); + + const RefTxnId successor_id{1, 2}; + const String successor_key = layout.refLogKey(life, successor_id); + const auto original = backend->get(successor_key); + ASSERT_TRUE(original); + const RefLogTxn successor_seal = makeSealTxn(ns, successor_id); + ASSERT_EQ(backend->putOverwrite(successor_key, + sealObject(FormatId::RefLog, encodeRefLogTxn(successor_seal)), + original->token).outcome, + PutOutcome::Done); + + const auto refs = store->listRefs(ns); + + EXPECT_FALSE(refs.contains("b")) << "the old writer's retained ordinary bytes lost at the sealed slot"; + EXPECT_EQ(store->lastEpochSealForTest(ns), std::make_optional(successor_id)); + EXPECT_EQ(readCkpt(*backend, layout, life)->ckpt.committed_through, successor_id); + EXPECT_EQ(readCkpt(*backend, layout, life)->ckpt.last_epoch_seal, successor_id); + EXPECT_EQ(store->laneStateForTest(ns), RefLaneState::Ready); +} + +/// --------------------------------------------------------------------------------------------- +/// Fail-closed on an unresolved slot +/// --------------------------------------------------------------------------------------------- + +/// `Unresolved` from the slot-occupy means the store will not say whether our seal landed. That is not a +/// state to guess about: recovery takes the transient-retry path and, once its budget is spent, fails +/// closed with the table left unrecovered. Exposing a table whose dead epoch may or may not be closed is +/// the one outcome that must be impossible. +TEST(CASRefRecoveryCasWalk, UnresolvedSealSlotFailsClosedWithoutInstalling) +{ + auto backend = std::make_shared(); + const Layout layout("p"); + const RootNamespace ns{"srv1/unresolved"}; + + burnEpochsUpTo(*backend, layout, /*target_live_epoch=*/2); + seedCkpt(*backend, layout, ns, lifeEpochCkpt(1, RefTxnId{1, 1})); + seedTxn(*backend, layout, ns, RefTxnId{1, 1}, "a", /*birth=*/true); + + /// The backoff sleep ADVANCES the same fake clock the budget is measured against, so the retry + /// envelope is spent in a handful of iterations instead of spinning against a frozen clock. Not + /// cosmetic: with a frozen clock this test burns ~700k retries and the same number of log lines, + /// which is how a real regression in this arm would become invisible in the noise. + uint64_t fake_now = 1'000'000; + PoolConfig config = walkTestConfig(); + config.boot_ms_fn = [&fake_now] { return fake_now; }; + auto store = openWalkPool(backend, config); + ASSERT_TRUE(store); + + store->setCasRetrySleepForTest([&fake_now](uint64_t ms) { fake_now += ms; }); + backend->ambiguous_put_substr = "/_log/"; + + EXPECT_ANY_THROW(store->listRefs(ns)); + EXPECT_FALSE(store->refTableRecoveredForTest(ns)) + << "a table whose dead epoch may or may not be closed must never be exposed as recovered"; +} + +/// --------------------------------------------------------------------------------------------- +/// Carried forward from the retired `RefWriterRecoverySeal` suite +/// --------------------------------------------------------------------------------------------- + +/// THE property the whole in-band design exists for, and the one the retired suite could only +/// approximate with a detector: the Late Predecessor PUT is REFUSED, by the store, at the key it wanted. +/// +/// A dying writer of epoch 1 has an append in flight for `{1,2}`. Recovery closes epoch 1 by occupying +/// exactly that slot. When the ghost's conditional create finally reaches the store there is nothing for +/// it to do -- the key is write-once and taken. The old sentinel seal was a SNAPSHOT at a synthetic id, +/// which left `{1,2}` free: the ghost landed, and all anyone could do was notice afterwards. +TEST(CASRefRecoveryCasWalk, ALatePredecessorPutAtTheSealedSlotIsRefusedByTheStore) +{ + auto backend = std::make_shared(); + const Layout layout("p"); + const RootNamespace ns{"srv1/ghost"}; + + burnEpochsUpTo(*backend, layout, /*target_live_epoch=*/2); + seedCkpt(*backend, layout, ns, lifeEpochCkpt(1, RefTxnId{1, 1})); + seedTxn(*backend, layout, ns, RefTxnId{1, 1}, "a", /*birth=*/true); + + auto store = openWalkPool(backend); + ASSERT_TRUE(store); + ASSERT_EQ(store->listRefs(ns).size(), 1u); + + /// The ghost: the exact append the dead epoch's writer had in flight, arriving late. + const RefTxnId ghost_id{1, 2}; + const String ghost_bytes = sealObject(FormatId::RefLog, + encodeRefLogTxn(makeOrdinaryTxn(ns, ghost_id, "ghost", /*birth=*/false))); + const PutResult put = backend->putIfAbsent(layout.refLogKey(DB::Cas::tests::fixture::fixtureLife(ns), ghost_id), ghost_bytes); + EXPECT_EQ(put.outcome, PutOutcome::PreconditionFailed) + << "the seal occupies the ghost's own key, so the store itself is the fence"; + + /// And the object at that key is still the seal, byte for byte -- nothing adopted the ghost. + const auto occupant = readLogTxn(*backend, layout, ns, ghost_id); + ASSERT_TRUE(occupant.has_value()); + EXPECT_TRUE(refLogTxnIsEpochSeal(*occupant)); +} + +/// An occupant at the seal slot that this build cannot decode is NOT a straggler to adopt and NOT a +/// peer's seal to defer to: it is an object at a key this namespace exclusively owns whose meaning is +/// unknown. Recovery fails closed on it -- and, just as importantly, stays RESTARTABLE: the throw must +/// leave `recovery_in_progress` cleared, or the table would be unrecoverable for the mount's life and +/// every later toucher would park forever on a condition variable nobody will signal. +TEST(CASRefRecoveryCasWalk, UndecodableOccupantAtTheSealSlotFailsClosedAndLeavesRecoveryRestartable) +{ + auto backend = std::make_shared(); + const Layout layout("p"); + const RootNamespace ns{"srv1/foreign_slot"}; + + burnEpochsUpTo(*backend, layout, /*target_live_epoch=*/2); + seedCkpt(*backend, layout, ns, lifeEpochCkpt(1, RefTxnId{1, 1})); + seedTxn(*backend, layout, ns, RefTxnId{1, 1}, "a", /*birth=*/true); + backend->late_key = layout.refLogKey(DB::Cas::tests::fixture::fixtureLife(ns), RefTxnId{1, 2}); + backend->late_bytes = "not a ref-log object at all"; + + auto store = openWalkPool(backend); + ASSERT_TRUE(store); + + expectThrowsCode(DB::ErrorCodes::CORRUPTED_DATA, [&] { store->listRefs(ns); }); + EXPECT_FALSE(store->refTableRecoveredForTest(ns)); + + /// Restartable: a second touch runs a WHOLE new attempt (it fails the same way, which is the point -- + /// it reaches the failure again rather than hanging on a stuck `recovery_in_progress`). + expectThrowsCode(DB::ErrorCodes::CORRUPTED_DATA, [&] { store->listRefs(ns); }); +} + +/// A second caller that arrives while a recovery is mid-walk WAITS for it rather than racing an +/// independent walk of its own. Two concurrent walks would both try to occupy the same seal slot, and +/// while the loser adopts correctly, they would also both replay the whole tail and one would install a +/// state the other's install immediately replaces -- work and I/O for nothing, on the path that is +/// already the most expensive one in the system. +TEST(CASRefRecoveryCasWalk, ASecondCallerWaitsForTheWalkInsteadOfRacingIt) +{ + auto backend = std::make_shared(); + const Layout layout("p"); + const RootNamespace ns{"srv1/serialized"}; + + burnEpochsUpTo(*backend, layout, /*target_live_epoch=*/2); + seedCkpt(*backend, layout, ns, lifeEpochCkpt(1, RefTxnId{1, 1})); + seedTxn(*backend, layout, ns, RefTxnId{1, 1}, "a", /*birth=*/true); + + auto store = openWalkPool(backend); + ASSERT_TRUE(store); + + std::mutex m; + std::condition_variable cv; + bool parked = false; + bool release = false; + + backend->watched_substr = "_log/"; + backend->on_key = [&](const String &) + { + std::unique_lock lock(m); + parked = true; + cv.notify_all(); + cv.wait(lock, [&] { return release; }); + }; + + const uint64_t adopted_before = counterOf(ProfileEvents::CASRefRecoveryEpochSealAdopted); + std::thread first([&] { store->listRefs(ns); }); + { + std::unique_lock lock(m); + cv.wait(lock, [&] { return parked; }); + } + + /// The second caller blocks on `recovery_in_progress`. Its own recovery would have to LIST, and the + /// walk holds no lock while parked, so nothing but the serialization flag can be keeping it out. + std::atomic second_done{false}; + std::thread second([&] { store->listRefs(ns); second_done.store(true); }); + for (int i = 0; i < 50 && !second_done.load(); ++i) + std::this_thread::yield(); + EXPECT_FALSE(second_done.load()) << "a second caller must wait out the in-flight walk, not race it"; + + { + std::lock_guard lock(m); + release = true; + } + cv.notify_all(); + first.join(); + second.join(); + + EXPECT_TRUE(store->refTableRecoveredForTest(ns)); + /// Exactly ONE walk minted the seal, and no second walk ever met it as an occupant. Adopting is the + /// CORRECT outcome for a concurrent recoverer -- it is just work this serialization exists to avoid + /// paying inside one process, so observing zero adoptions is what proves the second caller waited. + EXPECT_EQ(counterOf(ProfileEvents::CASRefRecoveryEpochSealAdopted), adopted_before); + const auto seal = readLogTxn(*backend, layout, ns, RefTxnId{1, 2}); + ASSERT_TRUE(seal.has_value()); + EXPECT_TRUE(refLogTxnIsEpochSeal(*seal)); +} + +/// Checkpoint-grounded recovery starts at the recreated life's own genesis and does not replay or +/// extend the predecessor life's stream. +/// +/// The old same-stream fixture claimed that recovery walked through the epoch-1 removal into epoch 2. +/// With authoritative `_ckpt.life_epoch=2`, epoch 2 is instead the current life's genesis and the walk +/// begins at `{2,1}`. Epoch-1 objects are inert predecessor-life debris: they neither supply state nor +/// receive a recovery seal. +TEST(CASRefRecoveryCasWalk, RecoveryStartsAtRecreatedLifeGenesisAndLeavesPredecessorStreamUntouched) +{ + auto backend = std::make_shared(); + const Layout layout("p"); + const RootNamespace ns{"srv1/removed_then_reborn"}; + + burnEpochsUpTo(*backend, layout, /*target_live_epoch=*/3); + seedCkpt(*backend, layout, ns, lifeEpochCkpt(2, RefTxnId{2, 1})); + seedTxn(*backend, layout, ns, RefTxnId{1, 1}, "a", /*birth=*/true); + + /// Epoch 1 ends with the terminal record: the ref is removed, then the namespace. + RefLogTxn removal; + removal.ns = ns.string(); + removal.txn_id = RefTxnId{1, 2}; + removal.ops = {DB::Cas::tests::ownerTransitionOp( + RefOwnerBinding{RefOwnerKind::Committed, "a", manifestRef(1, 1, 1u)}, std::nullopt), + removeNamespaceOp()}; + DB::Cas::tests::fixture::writeRefLogRaw(*backend, layout, removal); + + /// The current life starts in epoch 2. Its birth is sequence 1 of its own genesis epoch, so it + /// carries no chain link to the predecessor life. + seedTxn(*backend, layout, ns, RefTxnId{2, 1}, "reborn", /*birth=*/true); + + auto store = openWalkPool(backend); + ASSERT_TRUE(store); + ASSERT_EQ(store->liveWriterEpoch(), 3u); + + const auto refs = store->listRefs(ns); + EXPECT_EQ(refs.size(), 1u); + EXPECT_TRUE(refs.contains("reborn")) << "recovery must begin at the recreated life's genesis"; + + EXPECT_FALSE(readLogTxn(*backend, layout, ns, RefTxnId{1, 3}).has_value()) + << "recovery of life epoch 2 must not extend the predecessor-life stream"; + const auto seal2 = readLogTxn(*backend, layout, ns, RefTxnId{2, 2}); + ASSERT_TRUE(seal2.has_value()) << "epoch 2 IS live again by the time it dies, so it closes normally"; + EXPECT_TRUE(refLogTxnIsEpochSeal(*seal2)); + EXPECT_EQ(seal2->prev_epoch_seal, std::nullopt) << "sequence 2 carries no chain link"; +} + +/// `PutHookBackend::casPut` must route through its immediate parent `HidingListBackend::casPut`, not +/// past it to `CountingBackend`, so that a test arming BOTH layers on one `PutHookBackend` instance +/// gets both behaviors composed rather than one silently disabled by the other. +TEST(CASRefRecoveryCasWalk, PutHookBackendComposesHidingListBackendCasPutFaultInjection) +{ + auto backend = std::make_shared(); + + bool before_cas_put_fired = false; + backend->before_cas_put = [&](const String &, const String &, const std::optional &) + { + before_cas_put_fired = true; + }; + + backend->watched_substr = "probe"; + bool on_key_fired = false; + backend->on_key = [&] { on_key_fired = true; }; + + ASSERT_EQ(backend->casPut("p/probe", "x", std::nullopt).outcome, CasOutcome::Committed); + + EXPECT_TRUE(before_cas_put_fired) + << "HidingListBackend's before_cas_put hook must still fire for a PutHookBackend instance"; + EXPECT_TRUE(on_key_fired) << "PutHookBackend's own on_key hook must still fire on top of it"; +} diff --git a/src/Disks/tests/gtest_cas_ref_snapshot_format.cpp b/src/Disks/tests/gtest_cas_ref_snapshot_format.cpp new file mode 100644 index 000000000000..2293c0bc166c --- /dev/null +++ b/src/Disks/tests/gtest_cas_ref_snapshot_format.cpp @@ -0,0 +1,436 @@ +#include "cas_format_test_battery.h" +#include +#include +#include +#include + +/// v3 text codec tests for `cas_ref_snap` (codecs-v3 phase 3). Split out of the retired +/// `gtest_cas_ref_codecs.cpp` and re-pointed at the TEXT codec. The encoder-side validation tests are +/// format-agnostic and carry over verbatim; the old binary-offset byte-patch decode tests +/// (`bytes[k] = 99`) are gone -- the shape-level corruption classes (truncation, `v`+1 forward-gate, +/// wrong type, leading garbage) are covered by the `CASFormatBattery.RefSnapshot` row below, which also +/// subsumes the old `DecodeRejectsFutureFormatVersion`/`DecodeRejectsFormatVersionOne` pair (there is +/// no `format_version` byte any more -- the header `v` gate is the single forward-compat mechanism). + +using namespace DB::Cas; +using DB::Cas::tests::expectThrowsCode; + +namespace +{ + +ManifestRef manifestRef(uint64_t epoch, uint64_t seq, uint32_t ordinal) +{ + return ManifestRef{epoch, seq, ordinal}; +} + +RefTableSnapshot makeLiveSnapshot() +{ + RefTableSnapshot s; + s.ns = "srv1/db/table@cas@"; + s.snapshot_id = RefTxnId{5, 200}; + + RefCommittedRow c1; + c1.ref_name = "all_1_1_0"; + c1.manifest_ref = manifestRef(5, 10, 1); + c1.published_at_ms = 1717000000000ULL; + s.committed.push_back(c1); + + RefCommittedRow c2; + c2.ref_name = "all_2_2_0"; + c2.manifest_ref = manifestRef(5, 11, 1); + c2.published_at_ms = 1717000000001ULL; + s.committed.push_back(c2); + + RefOwnerBinding p1{RefOwnerKind::Precommit, "all_3_3_0", manifestRef(5, 12, 1)}; + s.precommits.push_back(p1); + + return s; +} + +} + +/// =================================================================================== +/// RefTableSnapshot: round trip +/// =================================================================================== + +TEST(CASRefSnapshotCodec, RoundTripLive) +{ + const RefTableSnapshot s = makeLiveSnapshot(); + const String bytes = encodeRefTableSnapshot(s); + const RefTableSnapshot decoded = decodeRefTableSnapshot(bytes, s.ns, s.snapshot_id); + EXPECT_EQ(decoded, s); +} + +TEST(CASRefSnapshotCodec, DecodeRequiresLifecycleField) +{ + const RefTableSnapshot s = makeLiveSnapshot(); + String bytes = encodeRefTableSnapshot(s); + const String field = R"(,"lc":"live")"; + const size_t at = bytes.find(field); + ASSERT_NE(at, String::npos); + bytes.erase(at, field.size()); + + expectThrowsCode(DB::ErrorCodes::CORRUPTED_DATA, + [&] { (void)decodeRefTableSnapshot(bytes, s.ns, s.snapshot_id); }); +} + +TEST(CASRefSnapshotCodec, DecodeRejectsTerminalLifecycleWord) +{ + const RefTableSnapshot s = makeLiveSnapshot(); + String bytes = encodeRefTableSnapshot(s); + const String live = R"("lc":"live")"; + const size_t at = bytes.find(live); + ASSERT_NE(at, String::npos); + bytes.replace(at, live.size(), R"("lc":"removed")"); + + expectThrowsCode(DB::ErrorCodes::CORRUPTED_DATA, + [&] { (void)decodeRefTableSnapshot(bytes, s.ns, s.snapshot_id); }); +} + +TEST(CASRefSnapshotCodec, DecodeRejectsRetiredRemoveTxnEpochField) +{ + const RefTableSnapshot s = makeLiveSnapshot(); + String bytes = encodeRefTableSnapshot(s); + const String live = R"("lc":"live")"; + const size_t at = bytes.find(live); + ASSERT_NE(at, String::npos); + bytes.replace(at, live.size(), live + R"(,"rte":"7")"); + + expectThrowsCode(DB::ErrorCodes::CORRUPTED_DATA, + [&] { (void)decodeRefTableSnapshot(bytes, s.ns, s.snapshot_id); }); +} + +TEST(CASRefSnapshotCodec, DecodeRejectsRetiredRemoveTxnSequenceField) +{ + const RefTableSnapshot s = makeLiveSnapshot(); + String bytes = encodeRefTableSnapshot(s); + const String live = R"("lc":"live")"; + const size_t at = bytes.find(live); + ASSERT_NE(at, String::npos); + bytes.replace(at, live.size(), live + R"(,"rts":"9")"); + + expectThrowsCode(DB::ErrorCodes::CORRUPTED_DATA, + [&] { (void)decodeRefTableSnapshot(bytes, s.ns, s.snapshot_id); }); +} + +TEST(CASRefSnapshotCodec, DecodeRejectsRetiredRemoveTxnFieldPair) +{ + const RefTableSnapshot s = makeLiveSnapshot(); + String bytes = encodeRefTableSnapshot(s); + const String live = R"("lc":"live")"; + const size_t at = bytes.find(live); + ASSERT_NE(at, String::npos); + bytes.replace(at, live.size(), live + R"(,"rte":"7","rts":"9")"); + + expectThrowsCode(DB::ErrorCodes::CORRUPTED_DATA, + [&] { (void)decodeRefTableSnapshot(bytes, s.ns, s.snapshot_id); }); +} + +/// No-tolerance decode pin (codex round-2, finding 3): the `"pl"` (payload) field was removed from the +/// committed-row wire in stage-1 T12. It is NOT a genuinely-unknown future field the tolerant reader may +/// skip -- silently discarding a persisted payload would lose data -- so decoding a committed row that +/// still carries `"pl"` must FAIL with `CORRUPTED_DATA` naming the removed field, not `skipUnknown` it. +TEST(CASRefSnapshotCodec, DecodeRejectsRemovedPayloadFieldInCommittedRow) +{ + RefTableSnapshot s; + s.ns = "ns"; + s.snapshot_id = RefTxnId{1, 1}; + RefCommittedRow c; + c.ref_name = "all_1_1_0"; + c.manifest_ref = manifestRef(5, 10, 1); + c.published_at_ms = 1717000000000ULL; + s.committed.push_back(c); + + const String bytes = encodeRefTableSnapshot(s); + /// Splice the retired `"pl"` field back into the committed record, just before its `"ts"` field. + const String needle = ",\"ts\":"; + const auto pos = bytes.find(needle); + ASSERT_NE(pos, String::npos); + const String tampered = bytes.substr(0, pos) + R"(,"pl":"deadbeef")" + bytes.substr(pos); + + expectThrowsCode(DB::ErrorCodes::CORRUPTED_DATA, + [&] { decodeRefTableSnapshot(tampered, s.ns, s.snapshot_id); }); +} + +TEST(CASRefSnapshotCodec, RoundTripLiveEmpty) +{ + RefTableSnapshot s; + s.ns = "ns"; + s.snapshot_id = RefTxnId{1, 1}; + + const String bytes = encodeRefTableSnapshot(s); + const RefTableSnapshot decoded = decodeRefTableSnapshot(bytes, s.ns, s.snapshot_id); + EXPECT_EQ(decoded, s); + EXPECT_TRUE(decoded.committed.empty()); + EXPECT_TRUE(decoded.precommits.empty()); +} + +TEST(CASRefSnapshotCodec, ByteIdenticalReencode) +{ + const RefTableSnapshot s = makeLiveSnapshot(); + const String bytes1 = encodeRefTableSnapshot(s); + const RefTableSnapshot decoded = decodeRefTableSnapshot(bytes1, s.ns, s.snapshot_id); + const String bytes2 = encodeRefTableSnapshot(decoded); + EXPECT_EQ(bytes1, bytes2); +} + +TEST(CASRefSnapshotCodec, RoundTripPrecommitsSameNameDifferentManifest) +{ + /// Two builds racing for the same final ref name: same ref_name, different manifest_ref, sorted + /// by manifest_ref as the tiebreak. + RefTableSnapshot s; + s.ns = "ns"; + s.snapshot_id = RefTxnId{1, 1}; + s.precommits.push_back(RefOwnerBinding{RefOwnerKind::Precommit, "same", manifestRef(1, 1, 1)}); + s.precommits.push_back(RefOwnerBinding{RefOwnerKind::Precommit, "same", manifestRef(1, 2, 1)}); + + const String bytes = encodeRefTableSnapshot(s); + const RefTableSnapshot decoded = decodeRefTableSnapshot(bytes, s.ns, s.snapshot_id); + EXPECT_EQ(decoded, s); + EXPECT_EQ(decoded.precommits.size(), 2u); +} + +/// =================================================================================== +/// Large ids +/// =================================================================================== + +/// `ref_sequence` is a 64-bit counter and the codec writes it as a decimal STRING, so the top of the +/// range survives a round trip without JSON's number semantics getting involved. This used to be pinned +/// through the retired sentinel seal, whose synthetic `{E-1, UINT64_MAX}` id was the only place such a +/// value arose; the representation guarantee is what actually mattered and it is pinned directly here. +TEST(CASRefSnapshotFormat, MaximalRefSequenceRoundTripsAsADecimalString) +{ + RefTableSnapshot m; + m.ns = "ns"; + m.snapshot_id = RefTxnId{5, std::numeric_limits::max()}; + + const String text = encodeRefTableSnapshot(m); + const RefTableSnapshot back = decodeRefTableSnapshot(text, m.ns, m.snapshot_id); + EXPECT_EQ(back.snapshot_id.ref_sequence, std::numeric_limits::max()); + EXPECT_NE(text.find("\"rs\":\"18446744073709551615\""), String::npos); +} + +/// =================================================================================== +/// RefTableSnapshot: validation rejections (encoder-side + key/body binding + truncation) +/// =================================================================================== + +TEST(CASRefSnapshotCodec, EncodeRejectsZeroSnapshotId) +{ + RefTableSnapshot s; + s.ns = "ns"; + s.snapshot_id = RefTxnId{0, 1}; + expectThrowsCode(DB::ErrorCodes::CORRUPTED_DATA, [&] { encodeRefTableSnapshot(s); }); +} + +TEST(CASRefSnapshotCodec, EncodeRejectsUnsortedCommitted) +{ + RefTableSnapshot s; + s.ns = "ns"; + s.snapshot_id = RefTxnId{1, 1}; + RefCommittedRow a; + a.ref_name = "b"; + a.manifest_ref = manifestRef(1, 1, 1); + RefCommittedRow b; + b.ref_name = "a"; + b.manifest_ref = manifestRef(1, 2, 1); + s.committed.push_back(a); + s.committed.push_back(b); + expectThrowsCode(DB::ErrorCodes::CORRUPTED_DATA, [&] { encodeRefTableSnapshot(s); }); +} + +TEST(CASRefSnapshotCodec, EncodeRejectsDuplicateCommittedRefName) +{ + RefTableSnapshot s; + s.ns = "ns"; + s.snapshot_id = RefTxnId{1, 1}; + RefCommittedRow a; + a.ref_name = "same"; + a.manifest_ref = manifestRef(1, 1, 1); + RefCommittedRow b; + b.ref_name = "same"; + b.manifest_ref = manifestRef(1, 2, 1); + s.committed.push_back(a); + s.committed.push_back(b); + expectThrowsCode(DB::ErrorCodes::CORRUPTED_DATA, [&] { encodeRefTableSnapshot(s); }); +} + +TEST(CASRefSnapshotCodec, EncodeRejectsUnsortedPrecommits) +{ + RefTableSnapshot s; + s.ns = "ns"; + s.snapshot_id = RefTxnId{1, 1}; + s.precommits.push_back(RefOwnerBinding{RefOwnerKind::Precommit, "b", manifestRef(1, 1, 1)}); + s.precommits.push_back(RefOwnerBinding{RefOwnerKind::Precommit, "a", manifestRef(1, 2, 1)}); + expectThrowsCode(DB::ErrorCodes::CORRUPTED_DATA, [&] { encodeRefTableSnapshot(s); }); +} + +TEST(CASRefSnapshotCodec, EncodeRejectsPrecommitsSameNameWrongManifestOrder) +{ + /// Same ref_name but the manifest_ref tiebreak is descending -- must be rejected even though the + /// names alone look sorted. + RefTableSnapshot s; + s.ns = "ns"; + s.snapshot_id = RefTxnId{1, 1}; + s.precommits.push_back(RefOwnerBinding{RefOwnerKind::Precommit, "same", manifestRef(1, 2, 1)}); + s.precommits.push_back(RefOwnerBinding{RefOwnerKind::Precommit, "same", manifestRef(1, 1, 1)}); + expectThrowsCode(DB::ErrorCodes::CORRUPTED_DATA, [&] { encodeRefTableSnapshot(s); }); +} + +TEST(CASRefSnapshotCodec, EncodeRejectsDuplicatePrecommitBinding) +{ + RefTableSnapshot s; + s.ns = "ns"; + s.snapshot_id = RefTxnId{1, 1}; + s.precommits.push_back(RefOwnerBinding{RefOwnerKind::Precommit, "same", manifestRef(1, 1, 1)}); + s.precommits.push_back(RefOwnerBinding{RefOwnerKind::Precommit, "same", manifestRef(1, 1, 1)}); + expectThrowsCode(DB::ErrorCodes::CORRUPTED_DATA, [&] { encodeRefTableSnapshot(s); }); +} + +TEST(CASRefSnapshotCodec, EncodeRejectsNonCanonicalCommittedRefName) +{ + RefTableSnapshot s; + s.ns = "ns"; + s.snapshot_id = RefTxnId{1, 1}; + RefCommittedRow row; + row.ref_name = "a/../b"; + row.manifest_ref = manifestRef(1, 1, 1); + s.committed.push_back(row); + expectThrowsCode(DB::ErrorCodes::CORRUPTED_DATA, [&] { encodeRefTableSnapshot(s); }); +} + +TEST(CASRefSnapshotCodec, EncodeRejectsNonCanonicalPrecommitRefName) +{ + RefTableSnapshot s; + s.ns = "ns"; + s.snapshot_id = RefTxnId{1, 1}; + s.precommits.push_back(RefOwnerBinding{RefOwnerKind::Precommit, "", manifestRef(1, 1, 1)}); + expectThrowsCode(DB::ErrorCodes::CORRUPTED_DATA, [&] { encodeRefTableSnapshot(s); }); +} + +TEST(CASRefSnapshotCodec, EncodeRejectsPrecommitWrongKind) +{ + RefTableSnapshot s; + s.ns = "ns"; + s.snapshot_id = RefTxnId{1, 1}; + s.precommits.push_back(RefOwnerBinding{RefOwnerKind::Committed, "r", manifestRef(1, 1, 1)}); + expectThrowsCode(DB::ErrorCodes::CORRUPTED_DATA, [&] { encodeRefTableSnapshot(s); }); +} + +TEST(CASRefSnapshotCodec, EncodeRejectsZeroManifestRefFields) +{ + { + RefTableSnapshot s; + s.ns = "ns"; + s.snapshot_id = RefTxnId{1, 1}; + RefCommittedRow row; + row.ref_name = "r"; + row.manifest_ref = manifestRef(0, 1, 1); + s.committed.push_back(row); + expectThrowsCode(DB::ErrorCodes::CORRUPTED_DATA, [&] { encodeRefTableSnapshot(s); }); + } + { + RefTableSnapshot s; + s.ns = "ns"; + s.snapshot_id = RefTxnId{1, 1}; + RefCommittedRow row; + row.ref_name = "r"; + row.manifest_ref = manifestRef(1, 1, 0); /// ordinal 0 is out of range + s.committed.push_back(row); + expectThrowsCode(DB::ErrorCodes::CORRUPTED_DATA, [&] { encodeRefTableSnapshot(s); }); + } +} + +TEST(CASRefSnapshotCodec, EncodeRejectsOversizedSnapshot) +{ + RefTableSnapshot s; + s.ns = "ns"; + s.snapshot_id = RefTxnId{1, 1}; + RefCommittedRow row; + /// `ref_name` has no length limit (`checkCanonicalRefName`), so it is the padding field now that + /// `payload` is gone: a run of un-escaped 'x' bytes inflates the encoded row one-for-one. + row.ref_name = String(ref_snapshot_max_bytes + 1, 'x'); + row.manifest_ref = manifestRef(1, 1, 1); + s.committed.push_back(row); + expectThrowsCode(DB::ErrorCodes::CORRUPTED_DATA, [&] { encodeRefTableSnapshot(s); }); +} + +TEST(CASRefSnapshotCodec, DecodeRejectsTruncatedBuffer) +{ + const RefTableSnapshot s = makeLiveSnapshot(); + const String bytes = encodeRefTableSnapshot(s); + /// Dropping the trailing bytes leaves the final line without its '\n' terminator -> fail closed. + expectThrowsCode(DB::ErrorCodes::CORRUPTED_DATA, + [&] { decodeRefTableSnapshot(bytes.substr(0, bytes.size() - 3), s.ns, s.snapshot_id); }); +} + +TEST(CASRefSnapshotCodec, DecodeRejectsNamespaceMismatch) +{ + RefTableSnapshot s; + s.ns = "ns-a"; + s.snapshot_id = RefTxnId{1, 1}; + const String bytes = encodeRefTableSnapshot(s); + expectThrowsCode(DB::ErrorCodes::CORRUPTED_DATA, + [&] { decodeRefTableSnapshot(bytes, "ns-b", s.snapshot_id); }); +} + +TEST(CASRefSnapshotCodec, DecodeRejectsSnapshotIdMismatch) +{ + RefTableSnapshot s; + s.ns = "ns"; + s.snapshot_id = RefTxnId{1, 1}; + const String bytes = encodeRefTableSnapshot(s); + expectThrowsCode(DB::ErrorCodes::CORRUPTED_DATA, + [&] { decodeRefTableSnapshot(bytes, s.ns, RefTxnId{1, 2}); }); +} + +TEST(CASRefSnapshotCodec, EncodeAllowsExactlySnapshotMaxBytes) +{ + RefTableSnapshot s; + s.ns = "ns"; + s.snapshot_id = RefTxnId{1, 1}; + RefCommittedRow row; + row.ref_name = "r"; + row.manifest_ref = manifestRef(1, 1, 1); + s.committed.push_back(row); + + const size_t base_size = encodeRefTableSnapshot(s).size(); + ASSERT_LE(base_size, ref_snapshot_max_bytes); + /// Every added 'x' is one un-escaped byte inside the JSON ref_name string, so the encoded size + /// grows one-for-one to exactly the cap; +1 accounts for the base row's own 1-byte ref_name "r" + /// already counted in base_size. + s.committed[0].ref_name = String(ref_snapshot_max_bytes - base_size + 1, 'x'); + + const String bytes = encodeRefTableSnapshot(s); + EXPECT_EQ(bytes.size(), ref_snapshot_max_bytes); + const RefTableSnapshot decoded = decodeRefTableSnapshot(bytes, s.ns, s.snapshot_id); + EXPECT_EQ(decoded, s); +} + +TEST(CASRefSnapshotCodec, DecodeRejectsOversizedBufferDirectly) +{ + /// A body with no line terminator inside the first `line_cap` bytes fails closed before any field + /// parsing (the text `readLine` line-cap guard, the text-codec analogue of the old early size guard). + const String oversized(ref_snapshot_max_bytes + 1, 'x'); + expectThrowsCode(DB::ErrorCodes::CORRUPTED_DATA, + [&] { decodeRefTableSnapshot(oversized, "ns", RefTxnId{1, 1}); }); +} + +/// =================================================================================== +/// Shape-level failure-mode battery (truncation / v+1 gate / wrong type / leading garbage) +/// =================================================================================== + +TEST(CASFormatBattery, RefSnapshot) +{ + const RefTableSnapshot s = makeLiveSnapshot(); + const String ns = s.ns; + const RefTxnId id = s.snapshot_id; + runFormatBattery({FormatId::RefSnapshot, + [s] { return sealObject(FormatId::RefSnapshot, encodeRefTableSnapshot(s)); }, + [ns, id](std::string_view d) { decodeRefTableSnapshot(openObject(FormatId::RefSnapshot, d), ns, id); }, + currentFormatHeader("cas_ref_snap") + + "{\"ns\":\"srv1/db/table@cas@\",\"we\":\"5\",\"rs\":\"200\",\"lc\":\"live\"}\n" + "{\"k\":\"c\",\"rn\":\"all_1_1_0\",\"me\":\"5\",\"mb\":\"10\",\"mo\":1,\"ts\":1717000000000}\n" + "{\"k\":\"c\",\"rn\":\"all_2_2_0\",\"me\":\"5\",\"mb\":\"11\",\"mo\":1,\"ts\":1717000000001}\n" + "{\"k\":\"p\",\"rn\":\"all_3_3_0\",\"me\":\"5\",\"mb\":\"12\",\"mo\":1}\n" + "{\"n\":3}\n"}); +} diff --git a/src/Disks/tests/gtest_cas_ref_snapshot_publish_ordering.cpp b/src/Disks/tests/gtest_cas_ref_snapshot_publish_ordering.cpp new file mode 100644 index 000000000000..22be74028768 --- /dev/null +++ b/src/Disks/tests/gtest_cas_ref_snapshot_publish_ordering.cpp @@ -0,0 +1,501 @@ +#include + +#include "config.h" + +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +#include +#include +#include +#include +#include +#include +#include + +namespace ProfileEvents +{ +extern const Event CASRefSnapshotPublishDispatched; +} + +namespace DB::ErrorCodes +{ +extern const int NETWORK_ERROR; +} + +/// Task 6b remainder (Stage B, `{#t2}`): the publication-ordering coverage that Task 6b's rename left +/// undone. This suite PINS existing behavior of `CasRefLedger::tryPublishSnapshotAndAdvanceCheckpointOnce` +/// (the one retry unit), `admitSnapshotPublishUnderStateLock`, `advancePublishBackoff`/ +/// `resetPublishBackoff`, and `dispatchSnapshotPublisher`/`settleSnapshotPublish`. It changes NO +/// production code. +/// +/// Normative ordering: (1) the immutable snapshot body becomes durable; (2) `_ckpt` advances; (3) the new +/// snapshot is adopted in this cache's memory. `NeedsRecovery` (this campaign's `Poisoned`) blocks +/// publication -- a durable transaction may be missing from the cached view -- and forces +/// `ensureRefTableRecovered` to re-walk the durable stream on the very next touch. +/// +/// The suite name is prefixed `Cas` so it is covered by the `Cas*` unit-test gate filter. + +using namespace DB::Cas; +using DB::Cas::tests::CountingBackend; +using DB::Cas::tests::expectThrowsCode; +using DB::Cas::tests::namespaceBirthOp; +using DB::Cas::tests::publishCommittedOps; + +namespace +{ + +/// Records the ORDER of body-PUT / `_ckpt`-CAS operations (so a test can compare indices) and lets a +/// test inject a persistent `Conflict` on one chosen `_ckpt` key -- the same technique +/// `gtest_cas_ref_writer.cpp`'s `RefWriterTestBackend::ckpt_conflict_key`/`ckpt_conflict_count` uses to +/// drive the ledger into `NeedsRecovery`, reproduced here so this suite has no dependency on that file's +/// internal (non-exported) test type. Delegates every operation to `CountingBackend` unchanged, so the +/// per-key counters (`putCount`/`casPutCount`) remain available as the positive control. +class OrderedFaultBackend : public CountingBackend +{ +public: + using CountingBackend::casPut; + using CountingBackend::get; + using CountingBackend::putIfAbsent; + + enum class Op : uint8_t { Put, Cas }; + struct Entry + { + Op op; + String key; + }; + + PutResult putIfAbsent(const String & key, const String & bytes, const ObjectMeta & meta) override + { + record(Op::Put, key); + if (fail_put_count > 0 && !fail_put_substr.empty() && key.find(fail_put_substr) != String::npos) + { + --fail_put_count; + throw Poco::TimeoutException("OrderedFaultBackend: simulated PUT response lost, nothing landed"); + } + return CountingBackend::putIfAbsent(key, bytes, meta); + } + + CasResult casPut(const String & key, const String & bytes, const std::optional & expected, + const ObjectMeta & meta) override + { + record(Op::Cas, key); + if (key == fail_cas_key && fail_cas_count > 0) + { + --fail_cas_count; + /// A `Conflict` (not a thrown/ambiguous response): the caller's own re-read-and-merge loop + /// (`publishCkpt`) treats this exactly like a concurrent writer that landed first, and + /// exhausts `MAX_CKPT_CAS_ATTEMPTS` (100) without ever committing -- deterministically, with + /// no wall-clock wait, since the loop is attempt-bounded rather than only deadline-bounded. + return {CasOutcome::Conflict, {}}; + } + return CountingBackend::casPut(key, bytes, expected, meta); + } + + /// Arms a persistent CAS conflict at `key` for the next `count` attempts. + void armCasConflict(const String & key, size_t count) + { + fail_cas_key = key; + fail_cas_count = count; + } + + /// Arms a persistent, never-committed PUT failure for the next `count` `putIfAbsent` calls whose key + /// contains `substr`: the object is never actually written (unlike a real ambiguous response, which + /// may or may not have landed), so the resolve-by-exact-GET a controlled `CasRequestBudget` with + /// `max_attempts = 1` performs always finds the key absent and classifies the attempt a definite, + /// non-`Committed` failure -- deterministically, with no internal retry and no wall-clock wait. + void armPutFailure(const String & substr, int count) + { + fail_put_substr = substr; + fail_put_count = count; + } + + /// The current length of the journal -- a caller's baseline for `indicesFrom` below, so a query can + /// be scoped to "since I last looked" rather than "since the pool opened" (whose earlier entries + /// belong to unrelated setup writes, e.g. the birth transaction's own checkpoint CAS). + size_t journalSize() const + { + std::lock_guard lock(mutex); + return journal.size(); + } + + /// Every index at or after `from` where `op`/`key` matches, in order. + std::vector indicesFrom(Op op, const String & key, size_t from) const + { + std::lock_guard lock(mutex); + std::vector result; + for (size_t i = from; i < journal.size(); ++i) + if (journal[i].op == op && journal[i].key == key) + result.push_back(i); + return result; + } + + /// The first index at or after `from` where `op`/`key` matches, if any. + std::optional firstIndexFrom(Op op, const String & key, size_t from) const + { + const auto indices = indicesFrom(op, key, from); + return indices.empty() ? std::nullopt : std::make_optional(indices.front()); + } + +private: + void record(Op op, const String & key) + { + std::lock_guard lock(mutex); + journal.push_back({op, key}); + } + + mutable std::mutex mutex; + std::vector journal; + String fail_cas_key; + size_t fail_cas_count = 0; + String fail_put_substr; + int fail_put_count = 0; +}; + +PoolPtr openPool(const std::shared_ptr & backend, PoolConfig config = {}) +{ + config.pool_prefix = "p"; + config.server_root_id = "test"; + DB::Cas::tests::seedPoolMetaForRestart(*backend); + return Pool::open(backend, std::move(config)); +} + +/// The same one-transaction publish every other ref suite drives, so a namespace reaches `Live` through +/// the REAL append lane (which is also what creates its `_ckpt`). +RefTxnId publishRef(const PoolPtr & store, const RootNamespace & ns, const String & ref, uint64_t ordinal) +{ + return store->appendRefOps(ns, MutationScope::ref(ref), + [&ref, ordinal](const RefTableState & state) + { + std::vector ops; + if (state.getLifecycle() != RefLifecycle::Live) + ops.push_back(namespaceBirthOp()); + for (const RefOp & op : publishCommittedOps(ref, ManifestRef{1, ordinal, 1})) + ops.push_back(op); + return ops; + }, + RootMutationOrigin::Writer, RootMutationKind::Publish); +} + +} + +/// --------------------------------------------------------------------------------------------- +/// 1. Snapshot body durable strictly before `_ckpt` advances +/// --------------------------------------------------------------------------------------------- + +TEST(CASRefSnapshotPublishOrdering, SnapshotBodyIsDurableBeforeCheckpointAdvances) +{ + auto backend = std::make_shared(); + auto store = openPool(backend); + const RootNamespace ns{"srv1/order_body_before_ckpt"}; + + ASSERT_EQ(publishRef(store, ns, "ref_1", 1), (RefTxnId{store->writerEpoch(), 1})); + const NamespaceLifeId life = *store->refTableLifeForTest(ns); + const String snapshot_key = store->layout().refSnapshotKey(life, RefTxnId{store->writerEpoch(), 1}); + const String ckpt_key = store->layout().refCkptKey(life); + + /// The birth transaction above already CAS'd `_ckpt` itself (once for its own `life_epoch`, once for + /// its committed frontier) -- ordinary append-commit traffic that has nothing to do with the snapshot + /// publisher. The comparison below must therefore look only at what happens FROM this offset, or it + /// would find the birth's ckpt writes (which precede the snapshot body by construction) and conclude + /// nothing about the publisher's own ordering. + const size_t offset = backend->journalSize(); + const uint64_t put_before = backend->putCount(snapshot_key); + const uint64_t cas_before = backend->casPutCount(ckpt_key); + + ASSERT_TRUE(store->tryPublishSnapshotAndAdvanceCheckpointOnce(ns)) + << "a healthy Ready-lane table with an uncovered tail must publish"; + + /// Positive control: this attempt touched each key exactly once (no retry, no redundant write) -- + /// which is what makes the index comparison below meaningful rather than an artifact of a busy log. + EXPECT_EQ(backend->putCount(snapshot_key) - put_before, 1u); + EXPECT_EQ(backend->casPutCount(ckpt_key) - cas_before, 1u); + + const auto body_index = backend->firstIndexFrom(OrderedFaultBackend::Op::Put, snapshot_key, offset); + const auto ckpt_index = backend->firstIndexFrom(OrderedFaultBackend::Op::Cas, ckpt_key, offset); + ASSERT_TRUE(body_index.has_value()) << "the snapshot body must have been PUT"; + ASSERT_TRUE(ckpt_index.has_value()) << "the checkpoint must have been CAS-advanced"; + EXPECT_LT(*body_index, *ckpt_index) + << "INV-4's second `_ckpt` writer runs strictly after the immutable body is durable"; +} + + +/// --------------------------------------------------------------------------------------------- +/// 2. Adoption happens last, and only once both durable effects landed +/// --------------------------------------------------------------------------------------------- + +TEST(CASRefSnapshotPublishOrdering, AdoptionHappensLastAndOnlyAfterBothDurableEffects) +{ + auto backend = std::make_shared(); + auto store = openPool(backend); + const RootNamespace ns{"srv1/order_adoption_after_both"}; + + ASSERT_EQ(publishRef(store, ns, "ref_1", 1), (RefTxnId{store->writerEpoch(), 1})); + const NamespaceLifeId life = *store->refTableLifeForTest(ns); + const String snapshot_key = store->layout().refSnapshotKey(life, RefTxnId{store->writerEpoch(), 1}); + const String ckpt_key = store->layout().refCkptKey(life); + + /// Fail every one of the (attempt-bounded) 100 `_ckpt` CAS attempts `publishCkpt` will make: the + /// body PUT still commits (dedup: an identical, already-durable body resolves as `Committed` without + /// re-sending), but the checkpoint never advances within this call. + backend->armCasConflict(ckpt_key, 100); + EXPECT_FALSE(store->tryPublishSnapshotAndAdvanceCheckpointOnce(ns)) + << "a persistently conflicting checkpoint CAS must not be reported as a successful publish"; + + EXPECT_EQ(backend->putCount(snapshot_key), 1u) << "the body is durable regardless of the ckpt outcome"; + EXPECT_FALSE(store->newestPublishedSnapshotIdForTest(ns).has_value()) + << "in-memory adoption must NOT happen while the checkpoint has not advanced"; + + /// Disarm the fault and retry (the one retry unit): the retry issues its OWN `putIfAbsent` attempt at + /// the same content-addressed key with the same bytes (so `putCount`, a call counter, becomes 2 -- + /// not a "no write happened" 1), but the backend resolves it as `Committed` against the already-durable + /// object rather than sending a distinct object, and the checkpoint CAS now succeeds. + ASSERT_TRUE(store->tryPublishSnapshotAndAdvanceCheckpointOnce(ns)) + << "the retry, with the fault cleared, must publish"; + EXPECT_EQ(backend->putCount(snapshot_key), 2u) + << "the retry's body PUT is its own attempt, resolved via dedup against identical, " + "already-durable bytes rather than writing a second object"; + EXPECT_EQ(store->newestPublishedSnapshotIdForTest(ns), std::make_optional(RefTxnId{store->writerEpoch(), 1})) + << "adoption happens exactly once, after both effects are durable"; +} + +/// --------------------------------------------------------------------------------------------- +/// 3. `NeedsRecovery` ("Poisoned") lane: recovery precedes any snapshot publication +/// --------------------------------------------------------------------------------------------- + +/// `Poisoned` is this task's plan's name for what the code spells `RefLaneState::NeedsRecovery` -- the +/// state the header documents as "a transaction is known durable but cannot be installed in this cache +/// ... a hard write and certification fence until replay completes". Recorded here as the vocabulary +/// correction for later tasks: there is no state literally named `Poisoned` anywhere in `CasRefLedger`. +/// This test is the plan's `PoisonedRefusesPublicationAndTriggersReRecovery`, renamed to state the actual +/// pinned behavior precisely (recovery precedes publication, rather than an outright refusal). +/// +/// It is reached here the same way `gtest_cas_ref_writer.cpp`'s +/// `CASRefWriterAppendLane.CheckpointConflictAfterLogCommitRequiresRecoveryWithoutInstall` reaches it: a +/// mutation's ref-log body commits durably while its OWN checkpoint-frontier CAS (`commitRefChunk`'s +/// `commit_contribution`, not the snapshot publisher's) conflicts persistently. +/// +/// The INVARIANT this pins (not a raw write count): a snapshot must never be published FROM AN +/// UNRECOVERED CACHE -- a durable transaction may be missing from the cached view, and advancing `_ckpt` +/// onto a snapshot built from that stale view is the data-loss shape `NeedsRecovery` exists to prevent. +/// `tryPublishSnapshotAndAdvanceCheckpointOnce` calls `ensureRefTableRecovered` unconditionally, and that +/// function re-walks the durable stream whenever the lane is `NeedsRecovery`, regardless of `recovered`. +/// Recovery's own `_ckpt` catch-up write is NOT a violation of this invariant -- it is the remedy: it is +/// how the cache stops being stale before anything is allowed to read it for a snapshot. So a request +/// against a poisoned lane recovers first and MAY legitimately go on to publish (this table had never +/// published a snapshot, so once recovered it has a real, uncovered candidate) -- "inert refusal with +/// zero writes" is NOT what production implements, and recover-then-proceed is the correct behavior, not +/// a deviation from it. What this test pins is: (a) no snapshot-publish effect (body PUT, publisher's own +/// checkpoint-advance CAS) can ever appear in the journal before recovery's reconciliation CAS; (b) +/// re-recovery is an observable state transition, never a silent skip; (c) if a snapshot IS published, it +/// reflects the RECOVERED frontier -- the durable transaction the stale cache was missing is actually +/// covered by it, not merely "some snapshot, from whichever view". +TEST(CASRefSnapshotPublishOrdering, NeedsRecoveryLaneRecoversBeforeAnySnapshotPublication) +{ + auto backend = std::make_shared(); + auto store = openPool(backend); + const RootNamespace ns{"srv1/order_poisoned_refuses"}; + + ASSERT_EQ(publishRef(store, ns, "ref_1", 1), (RefTxnId{store->writerEpoch(), 1})); + const NamespaceLifeId life = *store->refTableLifeForTest(ns); + const String ckpt_key = store->layout().refCkptKey(life); + /// The durable transaction the stale cache will be missing: `dropRef`'s removal, sequence 2. + const RefTxnId missing_durable_txn{store->writerEpoch(), 2}; + const String next_snapshot_key = store->layout().refSnapshotKey(life, missing_durable_txn); + + /// Drive the very next mutation's OWN checkpoint-frontier CAS into persistent conflict: the log PUT + /// for `missing_durable_txn` commits durably, but its checkpoint never advances within this call, and + /// the lane is left `NeedsRecovery` rather than installing an uncertain result -- so the cached view + /// still reflects `ref_1` present, while the durable log already reflects it removed. + backend->armCasConflict(ckpt_key, 100); + expectThrowsCode(DB::ErrorCodes::NETWORK_ERROR, [&] { store->dropRef(ns, "ref_1"); }); + ASSERT_EQ(store->laneStateForTest(ns), RefLaneState::NeedsRecovery); + + const uint64_t recovery_installs_before = store->recoveryInstallCountForTest(); + backend->armCasConflict(ckpt_key, 0); /// clear the fault so re-recovery's OWN catch-up CAN succeed + const size_t offset = backend->journalSize(); + + EXPECT_TRUE(store->tryPublishSnapshotAndAdvanceCheckpointOnce(ns)) + << "recovery reconciles the durable gap and this table has never published a snapshot, so the " + "same call legitimately goes on to publish one -- see the invariant note above the test"; + + /// Re-recovery WAS triggered as an observable state transition (not a silent skip): the lane left + /// `NeedsRecovery`, and `recoveryInstallCountForTest` -- a counter of exact recovery-result + /// publications -- advanced. + EXPECT_EQ(store->laneStateForTest(ns), RefLaneState::Ready) + << "ensureRefTableRecovered must have re-walked the durable stream and cleared the fence"; + EXPECT_GT(store->recoveryInstallCountForTest(), recovery_installs_before) + << "a re-recovery install must be observable, not indistinguishable from never having run"; + + /// ORDER, not a global zero: recovery's OWN checkpoint catch-up CAS is the boundary marker. NO + /// snapshot-publish effect (the new snapshot's body PUT, nor the publisher's own checkpoint-advance + /// CAS) may appear at or before it. + const auto ckpt_cas_indices = backend->indicesFrom(OrderedFaultBackend::Op::Cas, ckpt_key, offset); + const auto snap_put_indices = backend->indicesFrom(OrderedFaultBackend::Op::Put, next_snapshot_key, offset); + ASSERT_GE(ckpt_cas_indices.size(), 2u) + << "expected one checkpoint CAS from recovery's catch-up and one from the snapshot publisher"; + const size_t recovery_catchup_index = ckpt_cas_indices.front(); + const size_t publisher_ckpt_index = ckpt_cas_indices.back(); + ASSERT_FALSE(snap_put_indices.empty()) << "the recovered, uncovered candidate must have been published"; + for (const size_t snap_put_index : snap_put_indices) + EXPECT_GT(snap_put_index, recovery_catchup_index) + << "no snapshot-publish body PUT may precede recovery's own checkpoint reconciliation"; + EXPECT_LT(snap_put_indices.front(), publisher_ckpt_index) + << "the snapshot publisher's own checkpoint CAS still runs after ITS OWN body PUT (INV-4), even " + "immediately following recovery"; + + /// STRONGEST form: the published snapshot is not merely "some snapshot from whichever view" -- it + /// covers EXACTLY the recovered, previously-missing-from-cache frontier. Its id names the durable + /// removal transaction, and the recovered cache (which the snapshot was built from) no longer + /// resolves the removed ref. + EXPECT_EQ(store->newestPublishedSnapshotIdForTest(ns), std::make_optional(missing_durable_txn)) + << "the published snapshot's frontier IS the durable transaction the stale cache was missing"; + EXPECT_FALSE(store->resolveRef(ns, "ref_1").has_value()) + << "the recovered (and now snapshotted) cache reflects the durable removal the stale view lacked"; +} + +/// --------------------------------------------------------------------------------------------- +/// 4. Publish backoff: characterized against a controlled clock (`PoolConfig::boot_ms_fn`) +/// --------------------------------------------------------------------------------------------- + +/// `admitSnapshotPublishUnderStateLock`, `advancePublishBackoff` and `resetPublishBackoff` are private +/// to `CasRefLedger`, so they can only be characterized through the public dispatch surface +/// (`appendRefOps`/`resolveRef` triggering `maybeScheduleSnapshotPublish`, and +/// `waitForSnapshotPublishSettleForTest`/`ProfileEvents::CASRefSnapshotPublishDispatched` as the +/// observables). `CASRequestControllerBackoff` is a DIFFERENT mechanism (the request controller's +/// per-attempt retry backoff); this characterizes ONLY the per-table snapshot-publish dispatch backoff. +/// +/// A controlled clock (`PoolConfig::boot_ms_fn`) DOES exist for this seam (`gtest_cas_ref_writer.cpp`'s +/// `C4BackoffDefersThenRetriesAndPublishes` already relies on it) -- so unlike the plan's anticipated +/// fallback, this pins literal accept/refuse decisions against exact clock offsets rather than only +/// attempt counts. +TEST(CASRefSnapshotPublishOrdering, PublishBackoffDecisionsAreCharacterized) +{ + using ProfileEvents::global_counters; + auto backend = std::make_shared(); + + /// A single-attempt request budget, exactly as `gtest_cas_ref_writer.cpp`'s + /// `C4BackoffDefersThenRetriesAndPublishes` uses: with `max_attempts = 1` a faulted PUT resolves to a + /// definite, non-`Committed` outcome on its own attempt, with no internal retry loop and so no + /// wall-clock wait. + CasRequestBudget budget; + budget.max_attempts = 1; + budget.attempt_timeout_ms = 100; + budget.operation_deadline_ms = 5000; + budget.lease_safety_margin_ms = 100; + + uint64_t fake_now = 1'000'000; + PoolConfig config; + config.snapshot_log_count_threshold = 0; /// any nonempty tail is over-threshold + config.snapshot_log_bytes_threshold = 1ULL << 40; + config.snapshot_publish_backoff_initial_ms = 1000; + config.snapshot_publish_backoff_max_ms = 4000; + config.mount_lease_ttl_ms = std::chrono::milliseconds(10'000'000); + config.boot_ms_fn = [&fake_now] { return fake_now; }; + config.cas_request_budget = budget; + auto store = openPool(backend, config); + const RootNamespace ns{"srv1/order_backoff"}; + + ASSERT_EQ(publishRef(store, ns, "ref_1", 1), (RefTxnId{store->writerEpoch(), 1})); + store->waitForSnapshotPublishSettleForTest(ns); /// drain the birth's own auto-dispatched publish + /// The birth's own auto-dispatch already published a snapshot at this point (threshold 0); the + /// baseline every "no new publish yet" check below compares against. + const auto snapshot_after_birth = store->newestPublishedSnapshotIdForTest(ns); + ASSERT_TRUE(snapshot_after_birth.has_value()); + + /// Fault the snapshot BODY put (never the `_ckpt` CAS -- an append-commit's OWN checkpoint write + /// shares that key, and faulting it would drive the append lane into `NeedsRecovery` instead of + /// exercising the snapshot-publish backoff this test targets). Exactly 3 failures: the next 3 + /// automatic dispatch attempts fail (arming, then doubling, then re-doubling the backoff); the 4th + /// finds the fault disarmed and succeeds. + backend->armPutFailure("_snap/", 3); + + const auto dispatchCount = [&] { return global_counters[ProfileEvents::CASRefSnapshotPublishDispatched].load(); }; + + /// Attempt 1: admitted immediately (no backoff armed yet). Fails -> backoff armed at the initial 1000ms. + ASSERT_EQ(publishRef(store, ns, "ref_2", 2), (RefTxnId{store->writerEpoch(), 2})); + store->waitForSnapshotPublishSettleForTest(ns); + const uint64_t d1 = dispatchCount(); + EXPECT_EQ(store->newestPublishedSnapshotIdForTest(ns), snapshot_after_birth) + << "the failed attempt must not have advanced the published snapshot"; + + /// Still within the 1000ms window: a further trigger must NOT re-dispatch. + store->resolveRef(ns, "ref_1"); + store->waitForSnapshotPublishSettleForTest(ns); + EXPECT_EQ(dispatchCount(), d1) << "a read within the initial backoff window must not re-dispatch"; + + /// Cross the 1000ms deadline: exactly one retry dispatches (and fails again, doubling to 2000ms). + fake_now += 1000; + store->resolveRef(ns, "ref_1"); + store->waitForSnapshotPublishSettleForTest(ns); + EXPECT_EQ(dispatchCount(), d1 + 1) << "past the first deadline, exactly one retry dispatches"; + + /// Short of the DOUBLED (2000ms) deadline: still refused. + fake_now += 1000; + store->resolveRef(ns, "ref_1"); + store->waitForSnapshotPublishSettleForTest(ns); + EXPECT_EQ(dispatchCount(), d1 + 1) + << "advancePublishBackoff doubled the interval to 2000ms; 1000ms elapsed is not enough"; + + /// Cross the doubled deadline: one more retry dispatches (and fails again -- the third and last armed + /// failure -- doubling to the 4000ms cap). + fake_now += 1000; + store->resolveRef(ns, "ref_1"); + store->waitForSnapshotPublishSettleForTest(ns); + EXPECT_EQ(dispatchCount(), d1 + 2) << "past the doubled deadline, exactly one more retry dispatches"; + + /// Pin the 4000ms cap FROM BELOW: without this probe, a regression that stopped doubling at + /// 2000ms, or that read `initial` where it means `max`, would still pass -- the only check so far + /// is AT the +4000 crossing below. 2000ms past the doubled deadline is still short of the capped + /// 4000ms backoff, so no third retry may dispatch yet. + fake_now += 2000; + store->resolveRef(ns, "ref_1"); + store->waitForSnapshotPublishSettleForTest(ns); + EXPECT_EQ(dispatchCount(), d1 + 2) + << "2000ms past the doubled deadline is still short of the capped 4000ms backoff"; + + /// Cross the (capped) 4000ms deadline: the retry's fault budget is exhausted, so this attempt + /// succeeds, and `resetPublishBackoff` clears the cooldown -- proved by the NEXT trigger dispatching + /// with no wait at all. + fake_now += 2000; + store->resolveRef(ns, "ref_1"); + store->waitForSnapshotPublishSettleForTest(ns); + EXPECT_EQ(dispatchCount(), d1 + 3) << "past the second (capped) deadline, the retry dispatches and succeeds"; + EXPECT_NE(store->newestPublishedSnapshotIdForTest(ns), snapshot_after_birth) + << "the fault budget is exhausted, so this attempt actually advances the published snapshot"; + + ASSERT_EQ(publishRef(store, ns, "ref_3", 3), (RefTxnId{store->writerEpoch(), 3})); + store->waitForSnapshotPublishSettleForTest(ns); + EXPECT_EQ(dispatchCount(), d1 + 4) + << "resetPublishBackoff must have cleared the cooldown: the very next over-threshold trigger, at " + "the SAME clock reading as the successful publish, dispatches immediately with no wait"; + + /// The assertion just above cannot tell a real reset from a no-op: the successful publish and this + /// next trigger share one `fake_now`, so `now >= until` would still hold even with the stale + /// (pre-reset) deadline in place. Arm one more failure and check that the schedule restarts from + /// the INITIAL 1000ms interval rather than continuing from the 4000ms cap -- refused short of + /// 1000ms, admitted at 1000ms -- which a no-op reset cannot produce (it would refuse both probes, + /// since the stale deadline is still far in the future). + backend->armPutFailure("_snap/", 1); + ASSERT_EQ(publishRef(store, ns, "ref_4", 4), (RefTxnId{store->writerEpoch(), 4})); + store->waitForSnapshotPublishSettleForTest(ns); + const uint64_t d2 = dispatchCount(); + fake_now += 500; + store->resolveRef(ns, "ref_1"); + store->waitForSnapshotPublishSettleForTest(ns); + EXPECT_EQ(dispatchCount(), d2) << "short of 1000ms since the reset, no retry may dispatch yet"; + fake_now += 500; + store->resolveRef(ns, "ref_1"); + store->waitForSnapshotPublishSettleForTest(ns); + EXPECT_EQ(dispatchCount(), d2 + 1) + << "resetPublishBackoff must have restarted the schedule at the INITIAL 1000ms interval, not " + "left it continuing from the 4000ms cap"; +} diff --git a/src/Disks/tests/gtest_cas_ref_statemachine.cpp b/src/Disks/tests/gtest_cas_ref_statemachine.cpp new file mode 100644 index 000000000000..af79232cde2e --- /dev/null +++ b/src/Disks/tests/gtest_cas_ref_statemachine.cpp @@ -0,0 +1,1445 @@ +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace DB::ErrorCodes +{ +extern const int CORRUPTED_DATA; +} + +using namespace DB::Cas; +using DB::Cas::tests::expectThrowsCode; + +namespace +{ + +/// =================================================================================== +/// Small builders (mirrors gtest_cas_ref_codecs.cpp's local helpers) +/// =================================================================================== + +ManifestRef manifestRef(uint64_t epoch, uint64_t seq, uint32_t ordinal) +{ + return ManifestRef{epoch, seq, ordinal}; +} + +RefLogTxn makeTxn(const String & ns, RefTxnId id, std::vector ops) +{ + RefLogTxn txn; + txn.ns = ns; + txn.txn_id = id; + txn.ops = std::move(ops); + return txn; +} + +RefOp birthOp() +{ + RefOp op; + op.kind = RefOpKind::NamespaceBirth; + return op; +} + +RefOp addPrecommitOp(const String & name, const ManifestRef & mref) +{ + RefOp op; + op.kind = RefOpKind::OwnerTransition; + op.new_binding = RefOwnerBinding{RefOwnerKind::Precommit, name, mref}; + return op; +} + +RefOp removePrecommitOp(const String & name, const ManifestRef & mref) +{ + RefOp op; + op.kind = RefOpKind::OwnerTransition; + op.old_binding = RefOwnerBinding{RefOwnerKind::Precommit, name, mref}; + return op; +} + +RefOp promoteOp(const String & name, const ManifestRef & mref) +{ + RefOp op; + op.kind = RefOpKind::OwnerTransition; + op.old_binding = RefOwnerBinding{RefOwnerKind::Precommit, name, mref}; + op.new_binding = RefOwnerBinding{RefOwnerKind::Committed, name, mref}; + return op; +} + +RefOp removeCommittedOp(const String & name, const ManifestRef & mref) +{ + RefOp op; + op.kind = RefOpKind::OwnerTransition; + op.old_binding = RefOwnerBinding{RefOwnerKind::Committed, name, mref}; + return op; +} + +RefOp setPublishedAtOp(const String & name, const ManifestRef & mref, uint64_t ts = 0) +{ + RefOp op; + op.kind = RefOpKind::SetPublishedAt; + op.ref_name = name; + op.expected_manifest_ref = mref; + op.published_at_ms = ts; + return op; +} + +RefOp removeNamespaceOp() +{ + RefOp op; + op.kind = RefOpKind::RemoveNamespace; + return op; +} + +/// Field-by-field comparison (via getters) rather than a `RefTableState::operator==` addition: the +/// class is the plan's verbatim-normative interface and gains no member beyond what it specifies. +void expectStatesEqual(const RefTableState & a, const RefTableState & b) +{ + EXPECT_EQ(a.getLifecycle(), b.getLifecycle()); + EXPECT_EQ(a.getRemoveTxnId(), b.getRemoveTxnId()); + EXPECT_EQ(a.getGreatestApplied(), b.getGreatestApplied()); + EXPECT_EQ(a.getCommitted(), b.getCommitted()); + EXPECT_EQ(a.getPrecommits(), b.getPrecommits()); + /// Also compare the incremental budget counters: in release builds (no `debugAssertBodyCounters`) + /// this is the only cross-check that catches counter drift between two equal-looking states. + EXPECT_EQ(a.getSnapshotBodyBytes(), b.getSnapshotBodyBytes()); + EXPECT_EQ(a.getRemovalBodyBytes(), b.getRemovalBodyBytes()); +} + +/// The spec's own construction for a hypothetical `remove_namespace` transaction (§Remove Namespace): +/// an exact owner-removal op for every committed ref and precommit, then `remove_namespace`. Built +/// independently of `CasRefStateMachine.cpp`'s internal helper of the same shape, purely from the +/// public `RefTableState` fields, so the admission-budget property tests below measure against a +/// ground truth this test file derives on its own. +RefLogTxn buildRemovalTxnForTest(const RefTableState & state, const String & ns, RefTxnId id) +{ + std::vector ops; + for (const auto [name, row] : state.getCommitted()) + ops.push_back(removeCommittedOp(name, row.manifest_ref)); + for (const auto & [name, mref] : state.getPrecommits()) + ops.push_back(removePrecommitOp(name, mref)); + ops.push_back(removeNamespaceOp()); + return makeTxn(ns, id, std::move(ops)); +} + +constexpr const char * kNs = "srv1/db/table@cas@"; + +/// A validated state with "a" committed to manifest (1,1,1) -- the base the fail-closed replay/append +/// tests below reuse to build a tail whose add-precommit op would collide cross-owner (name the SAME +/// manifest under a DIFFERENT ref_name). +RefTableSnapshot buildCollidingBaseSnapshotForTest() +{ + RefTableState state; + applyRefLogTxn(state, makeTxn(kNs, RefTxnId{1, 1}, + {birthOp(), addPrecommitOp("a", manifestRef(1, 1, 1)), promoteOp("a", manifestRef(1, 1, 1))})); + return snapshotOf(state, kNs); +} + +} + +/// =================================================================================== +/// NamespaceBirth +/// =================================================================================== + +TEST(CASRefStateMachine, BirthFromNeverBornAccepts) +{ + RefTableState state; + applyRefLogTxn(state, makeTxn(kNs, RefTxnId{1, 1}, {birthOp()})); + EXPECT_EQ(state.getLifecycle(), RefLifecycle::Live); + EXPECT_FALSE(state.getRemoveTxnId().has_value()); + EXPECT_EQ(state.getGreatestApplied(), (RefTxnId{1, 1})); +} + +TEST(CASRefStateMachine, BirthWhileLiveRejectedAndStateUnchanged) +{ + RefTableState state; + applyRefLogTxn(state, makeTxn(kNs, RefTxnId{1, 1}, {birthOp()})); + const RefTableState before = state; + + expectThrowsCode(DB::ErrorCodes::CORRUPTED_DATA, + [&] { applyRefLogTxn(state, makeTxn(kNs, RefTxnId{1, 2}, {birthOp()})); }); + expectStatesEqual(before, state); +} + +TEST(CASRefStateMachine, BirthAfterRemovalAccepts) +{ + RefTableState state; + applyRefLogTxn(state, makeTxn(kNs, RefTxnId{1, 1}, {birthOp()})); + applyRefLogTxn(state, makeTxn(kNs, RefTxnId{1, 2}, {removeNamespaceOp()})); + ASSERT_EQ(state.getLifecycle(), RefLifecycle::Removed); + ASSERT_TRUE(state.getRemoveTxnId().has_value()); + + applyRefLogTxn(state, makeTxn(kNs, RefTxnId{1, 3}, {birthOp()})); + EXPECT_EQ(state.getLifecycle(), RefLifecycle::Live); + EXPECT_FALSE(state.getRemoveTxnId().has_value()); +} + +/// =================================================================================== +/// Ops rejected outside Live (never-born and Removed) except birth +/// =================================================================================== + +TEST(CASRefStateMachine, OwnerTransitionWhileNeverBornRejected) +{ + RefTableState state; + expectThrowsCode(DB::ErrorCodes::CORRUPTED_DATA, + [&] { applyRefLogTxn(state, makeTxn(kNs, RefTxnId{1, 1}, {addPrecommitOp("a", manifestRef(1, 1, 1))})); }); +} + +TEST(CASRefStateMachine, SetPublishedAtWhileNeverBornRejected) +{ + RefTableState state; + expectThrowsCode(DB::ErrorCodes::CORRUPTED_DATA, + [&] { applyRefLogTxn(state, makeTxn(kNs, RefTxnId{1, 1}, {setPublishedAtOp("a", manifestRef(1, 1, 1))})); }); +} + +TEST(CASRefStateMachine, RemoveNamespaceWhileNeverBornRejected) +{ + RefTableState state; + expectThrowsCode(DB::ErrorCodes::CORRUPTED_DATA, + [&] { applyRefLogTxn(state, makeTxn(kNs, RefTxnId{1, 1}, {removeNamespaceOp()})); }); +} + +TEST(CASRefStateMachine, OpsWhileRemovedRejectedExceptBirth) +{ + RefTableState state; + applyRefLogTxn(state, makeTxn(kNs, RefTxnId{1, 1}, {birthOp()})); + applyRefLogTxn(state, makeTxn(kNs, RefTxnId{1, 2}, {removeNamespaceOp()})); + const RefTableState after_removal = state; + + expectThrowsCode(DB::ErrorCodes::CORRUPTED_DATA, + [&] { applyRefLogTxn(state, makeTxn(kNs, RefTxnId{1, 3}, {addPrecommitOp("a", manifestRef(1, 1, 1))})); }); + expectStatesEqual(after_removal, state); + + expectThrowsCode(DB::ErrorCodes::CORRUPTED_DATA, + [&] { applyRefLogTxn(state, makeTxn(kNs, RefTxnId{1, 4}, {setPublishedAtOp("a", manifestRef(1, 1, 1))})); }); + expectStatesEqual(after_removal, state); + + /// Repeated removal is corruption at THIS layer (spec §Remove Namespace: idempotent-success is + /// the API layer's job, not the state machine's). + expectThrowsCode(DB::ErrorCodes::CORRUPTED_DATA, + [&] { applyRefLogTxn(state, makeTxn(kNs, RefTxnId{1, 5}, {removeNamespaceOp()})); }); + expectStatesEqual(after_removal, state); +} + +/// =================================================================================== +/// Add precommit (spec §Add Precommit) +/// =================================================================================== + +TEST(CASRefStateMachine, AddPrecommitAccepts) +{ + RefTableState state; + applyRefLogTxn(state, makeTxn(kNs, RefTxnId{1, 1}, {birthOp(), addPrecommitOp("a", manifestRef(1, 1, 1))})); + EXPECT_TRUE(state.getPrecommits().contains({"a", manifestRef(1, 1, 1)})); +} + +TEST(CASRefStateMachine, AddPrecommitRejectsExactDuplicate) +{ + RefTableState state; + applyRefLogTxn(state, makeTxn(kNs, RefTxnId{1, 1}, {birthOp(), addPrecommitOp("a", manifestRef(1, 1, 1))})); + const RefTableState before = state; + + expectThrowsCode(DB::ErrorCodes::CORRUPTED_DATA, + [&] { applyRefLogTxn(state, makeTxn(kNs, RefTxnId{1, 2}, {addPrecommitOp("a", manifestRef(1, 1, 1))})); }); + expectStatesEqual(before, state); +} + +TEST(CASRefStateMachine, AddPrecommitRejectsConflictingManifestUnderDifferentName) +{ + RefTableState state; + applyRefLogTxn(state, makeTxn(kNs, RefTxnId{1, 1}, {birthOp(), addPrecommitOp("a", manifestRef(1, 1, 1))})); + const RefTableState before = state; + + /// Same manifest_ref, a DIFFERENT ref_name: "no conflicting owner may name the same manifest". + expectThrowsCode(DB::ErrorCodes::CORRUPTED_DATA, + [&] { applyRefLogTxn(state, makeTxn(kNs, RefTxnId{1, 2}, {addPrecommitOp("b", manifestRef(1, 1, 1))})); }); + expectStatesEqual(before, state); +} + +TEST(CASRefStateMachine, AddPrecommitRejectsManifestAlreadyCommittedElsewhere) +{ + RefTableState state; + applyRefLogTxn(state, makeTxn(kNs, RefTxnId{1, 1}, + {birthOp(), addPrecommitOp("a", manifestRef(1, 1, 1)), promoteOp("a", manifestRef(1, 1, 1))})); + ASSERT_TRUE(state.getCommitted().contains("a")); + const RefTableState before = state; + + expectThrowsCode(DB::ErrorCodes::CORRUPTED_DATA, + [&] { applyRefLogTxn(state, makeTxn(kNs, RefTxnId{1, 2}, {addPrecommitOp("b", manifestRef(1, 1, 1))})); }); + expectStatesEqual(before, state); +} + +TEST(CASRefStateMachine, AddPrecommitAllowsDifferentManifestsRacingForSameName) +{ + /// Two builds racing for the same final ref name (same shape gtest_cas_ref_codecs.cpp's + /// RoundTripPrecommitsSameNameDifferentManifest round-trips): distinct manifest_ref, no conflict. + RefTableState state; + applyRefLogTxn(state, makeTxn(kNs, RefTxnId{1, 1}, + {birthOp(), addPrecommitOp("same", manifestRef(1, 1, 1))})); + applyRefLogTxn(state, makeTxn(kNs, RefTxnId{1, 2}, {addPrecommitOp("same", manifestRef(1, 2, 1))})); + EXPECT_TRUE(state.getPrecommits().contains({"same", manifestRef(1, 1, 1)})); + EXPECT_TRUE(state.getPrecommits().contains({"same", manifestRef(1, 2, 1)})); +} + +/// =================================================================================== +/// Remove precommit / remove committed (spec §Remove Precommit, §Remove Committed Ref) +/// =================================================================================== + +TEST(CASRefStateMachine, RemovePrecommitAccepts) +{ + RefTableState state; + applyRefLogTxn(state, makeTxn(kNs, RefTxnId{1, 1}, {birthOp(), addPrecommitOp("a", manifestRef(1, 1, 1))})); + applyRefLogTxn(state, makeTxn(kNs, RefTxnId{1, 2}, {removePrecommitOp("a", manifestRef(1, 1, 1))})); + EXPECT_TRUE(state.getPrecommits().empty()); +} + +TEST(CASRefStateMachine, RemovePrecommitRejectsAbsentBinding) +{ + RefTableState state; + applyRefLogTxn(state, makeTxn(kNs, RefTxnId{1, 1}, {birthOp()})); + const RefTableState before = state; + + expectThrowsCode(DB::ErrorCodes::CORRUPTED_DATA, + [&] { applyRefLogTxn(state, makeTxn(kNs, RefTxnId{1, 2}, {removePrecommitOp("a", manifestRef(1, 1, 1))})); }); + expectStatesEqual(before, state); +} + +TEST(CASRefStateMachine, RemovePrecommitRejectsWrongManifest) +{ + RefTableState state; + applyRefLogTxn(state, makeTxn(kNs, RefTxnId{1, 1}, {birthOp(), addPrecommitOp("a", manifestRef(1, 1, 1))})); + const RefTableState before = state; + + expectThrowsCode(DB::ErrorCodes::CORRUPTED_DATA, + [&] { applyRefLogTxn(state, makeTxn(kNs, RefTxnId{1, 2}, {removePrecommitOp("a", manifestRef(1, 2, 1))})); }); + expectStatesEqual(before, state); +} + +TEST(CASRefStateMachine, RemoveCommittedAccepts) +{ + RefTableState state; + applyRefLogTxn(state, makeTxn(kNs, RefTxnId{1, 1}, + {birthOp(), addPrecommitOp("a", manifestRef(1, 1, 1)), promoteOp("a", manifestRef(1, 1, 1))})); + applyRefLogTxn(state, makeTxn(kNs, RefTxnId{1, 2}, {removeCommittedOp("a", manifestRef(1, 1, 1))})); + EXPECT_TRUE(state.getCommitted().empty()); +} + +TEST(CASRefStateMachine, RemoveCommittedRejectsAbsentRef) +{ + RefTableState state; + applyRefLogTxn(state, makeTxn(kNs, RefTxnId{1, 1}, {birthOp()})); + const RefTableState before = state; + + expectThrowsCode(DB::ErrorCodes::CORRUPTED_DATA, + [&] { applyRefLogTxn(state, makeTxn(kNs, RefTxnId{1, 2}, {removeCommittedOp("a", manifestRef(1, 1, 1))})); }); + expectStatesEqual(before, state); +} + +TEST(CASRefStateMachine, RemoveCommittedRejectsWrongManifest) +{ + RefTableState state; + applyRefLogTxn(state, makeTxn(kNs, RefTxnId{1, 1}, + {birthOp(), addPrecommitOp("a", manifestRef(1, 1, 1)), promoteOp("a", manifestRef(1, 1, 1))})); + const RefTableState before = state; + + expectThrowsCode(DB::ErrorCodes::CORRUPTED_DATA, + [&] { applyRefLogTxn(state, makeTxn(kNs, RefTxnId{1, 2}, {removeCommittedOp("a", manifestRef(9, 9, 9))})); }); + expectStatesEqual(before, state); +} + +/// =================================================================================== +/// Promote (spec §Promote): exact precommit required, atomicity, invalid shapes +/// =================================================================================== + +TEST(CASRefStateMachine, PromoteRejectsAbsentPrecommit) +{ + RefTableState state; + applyRefLogTxn(state, makeTxn(kNs, RefTxnId{1, 1}, {birthOp()})); + const RefTableState before = state; + + expectThrowsCode(DB::ErrorCodes::CORRUPTED_DATA, + [&] { applyRefLogTxn(state, makeTxn(kNs, RefTxnId{1, 2}, {promoteOp("a", manifestRef(1, 1, 1))})); }); + expectStatesEqual(before, state); +} + +TEST(CASRefStateMachine, PromoteAtomicityNoOwnerlessIntermediate) +{ + /// A bare promote (no set_published_at in the same transaction) is itself a complete, valid, and + /// OBSERVABLE transaction -- there is no partial-op state exposed here, only the choice of + /// whether the timestamp arrives in this txn or a later one (spec §Promote). + RefTableState state; + applyRefLogTxn(state, makeTxn(kNs, RefTxnId{1, 1}, {birthOp(), addPrecommitOp("a", manifestRef(1, 1, 1))})); + applyRefLogTxn(state, makeTxn(kNs, RefTxnId{1, 2}, {promoteOp("a", manifestRef(1, 1, 1))})); + + EXPECT_FALSE(state.getPrecommits().contains({"a", manifestRef(1, 1, 1)})); + ASSERT_TRUE(state.getCommitted().contains("a")); + EXPECT_EQ(state.getCommitted().at("a").manifest_ref, manifestRef(1, 1, 1)); + EXPECT_EQ(state.getCommitted().at("a").published_at_ms, 0u); +} + +TEST(CASRefStateMachine, PromoteWithSetPublishedAtInSameTxnInstallsTimestamp) +{ + RefTableState state; + applyRefLogTxn(state, makeTxn(kNs, RefTxnId{1, 1}, {birthOp(), addPrecommitOp("a", manifestRef(1, 1, 1))})); + applyRefLogTxn(state, makeTxn(kNs, RefTxnId{1, 2}, + {promoteOp("a", manifestRef(1, 1, 1)), setPublishedAtOp("a", manifestRef(1, 1, 1), 42)})); + + ASSERT_TRUE(state.getCommitted().contains("a")); + EXPECT_EQ(state.getCommitted().at("a").published_at_ms, 42u); +} + +TEST(CASRefStateMachine, PromoteRejectsDisplacingAnotherCommittedManifest) +{ + /// A challenger precommit under the SAME ref_name as an already-committed (different) manifest is + /// legal to stage (spec §Add Precommit only restricts manifest identity, not ref_name), but a bare + /// promote of it must not silently displace the stale committed row. + RefTableState state; + applyRefLogTxn(state, makeTxn(kNs, RefTxnId{1, 1}, + {birthOp(), addPrecommitOp("a", manifestRef(1, 1, 1)), promoteOp("a", manifestRef(1, 1, 1))})); + applyRefLogTxn(state, makeTxn(kNs, RefTxnId{1, 2}, {addPrecommitOp("a", manifestRef(1, 2, 1))})); + const RefTableState before = state; + + expectThrowsCode(DB::ErrorCodes::CORRUPTED_DATA, + [&] { applyRefLogTxn(state, makeTxn(kNs, RefTxnId{1, 3}, {promoteOp("a", manifestRef(1, 2, 1))})); }); + expectStatesEqual(before, state); +} + +TEST(CASRefStateMachine, PromoteAcceptsAfterExplicitRemovalOfStaleCommitted) +{ + /// The correct atomic-replace sequence: an explicit removal of the old committed row, followed by + /// the promote, in the SAME transaction -- both ops are recorded, so GC sees the old manifest's + /// "-1" edge explicitly rather than losing it to a silent displacement. + RefTableState state; + applyRefLogTxn(state, makeTxn(kNs, RefTxnId{1, 1}, + {birthOp(), addPrecommitOp("a", manifestRef(1, 1, 1)), promoteOp("a", manifestRef(1, 1, 1))})); + applyRefLogTxn(state, makeTxn(kNs, RefTxnId{1, 2}, {addPrecommitOp("a", manifestRef(1, 2, 1))})); + + applyRefLogTxn(state, makeTxn(kNs, RefTxnId{1, 3}, + {removeCommittedOp("a", manifestRef(1, 1, 1)), promoteOp("a", manifestRef(1, 2, 1))})); + + ASSERT_TRUE(state.getCommitted().contains("a")); + EXPECT_EQ(state.getCommitted().at("a").manifest_ref, manifestRef(1, 2, 1)); + EXPECT_FALSE(state.getPrecommits().contains({"a", manifestRef(1, 2, 1)})); +} + +TEST(CASRefStateMachine, OwnerTransitionRejectsInvalidCombinations) +{ + RefTableState state; + applyRefLogTxn(state, makeTxn(kNs, RefTxnId{1, 1}, {birthOp()})); + + /// old=None, new=Committed: not a recognized shape (committed rows are only reached via promote). + { + RefOp op; + op.kind = RefOpKind::OwnerTransition; + op.new_binding = RefOwnerBinding{RefOwnerKind::Committed, "a", manifestRef(1, 1, 1)}; + expectThrowsCode(DB::ErrorCodes::CORRUPTED_DATA, + [&] { applyRefLogTxn(state, makeTxn(kNs, RefTxnId{1, 2}, {op})); }); + } + + /// A promote-shaped op (Precommit -> Committed) with mismatched ref_name is not a legal promote. + /// The rejected transaction above left `greatest_applied` untouched, so THIS one is still {1, 2}. + applyRefLogTxn(state, makeTxn(kNs, RefTxnId{1, 2}, {addPrecommitOp("a", manifestRef(1, 1, 1))})); + { + RefOp op; + op.kind = RefOpKind::OwnerTransition; + op.old_binding = RefOwnerBinding{RefOwnerKind::Precommit, "a", manifestRef(1, 1, 1)}; + op.new_binding = RefOwnerBinding{RefOwnerKind::Committed, "b", manifestRef(1, 1, 1)}; + const RefTableState before = state; + expectThrowsCode(DB::ErrorCodes::CORRUPTED_DATA, + [&] { applyRefLogTxn(state, makeTxn(kNs, RefTxnId{1, 3}, {op})); }); + expectStatesEqual(before, state); + } + + /// old=Committed, new=Precommit: moving a committed ref "backwards" is not a recognized shape. + { + RefOp op; + op.kind = RefOpKind::OwnerTransition; + op.old_binding = RefOwnerBinding{RefOwnerKind::Committed, "a", manifestRef(1, 1, 1)}; + op.new_binding = RefOwnerBinding{RefOwnerKind::Precommit, "a", manifestRef(1, 1, 1)}; + expectThrowsCode(DB::ErrorCodes::CORRUPTED_DATA, + [&] { applyRefLogTxn(state, makeTxn(kNs, RefTxnId{1, 3}, {op})); }); + } +} + +/// =================================================================================== +/// SetPublishedAt (spec §Update Payload) +/// =================================================================================== + +TEST(CASRefStateMachine, SetPublishedAtRejectsWhenRefAbsent) +{ + RefTableState state; + applyRefLogTxn(state, makeTxn(kNs, RefTxnId{1, 1}, {birthOp()})); + const RefTableState before = state; + + expectThrowsCode(DB::ErrorCodes::CORRUPTED_DATA, + [&] { applyRefLogTxn(state, makeTxn(kNs, RefTxnId{1, 2}, {setPublishedAtOp("a", manifestRef(1, 1, 1))})); }); + expectStatesEqual(before, state); +} + +TEST(CASRefStateMachine, SetPublishedAtRejectsManifestMismatch) +{ + RefTableState state; + applyRefLogTxn(state, makeTxn(kNs, RefTxnId{1, 1}, + {birthOp(), addPrecommitOp("a", manifestRef(1, 1, 1)), promoteOp("a", manifestRef(1, 1, 1))})); + const RefTableState before = state; + + expectThrowsCode(DB::ErrorCodes::CORRUPTED_DATA, + [&] { applyRefLogTxn(state, makeTxn(kNs, RefTxnId{1, 2}, {setPublishedAtOp("a", manifestRef(9, 9, 9))})); }); + expectStatesEqual(before, state); +} + +TEST(CASRefStateMachine, SetPublishedAtAcceptsAndReplacesTimestamp) +{ + RefTableState state; + applyRefLogTxn(state, makeTxn(kNs, RefTxnId{1, 1}, + {birthOp(), addPrecommitOp("a", manifestRef(1, 1, 1)), promoteOp("a", manifestRef(1, 1, 1))})); + applyRefLogTxn(state, makeTxn(kNs, RefTxnId{1, 2}, {setPublishedAtOp("a", manifestRef(1, 1, 1), 10)})); + applyRefLogTxn(state, makeTxn(kNs, RefTxnId{1, 3}, {setPublishedAtOp("a", manifestRef(1, 1, 1), 20)})); + + EXPECT_EQ(state.getCommitted().at("a").published_at_ms, 20u); + EXPECT_EQ(state.getCommitted().at("a").manifest_ref, manifestRef(1, 1, 1)); /// unchanged: no edge move +} + +/// =================================================================================== +/// RemoveNamespace ordering lens (spec §Remove Namespace; codec deliberately doesn't check this) +/// =================================================================================== + +TEST(CASRefStateMachine, RemoveNamespaceAloneOnEmptyTableAccepted) +{ + RefTableState state; + applyRefLogTxn(state, makeTxn(kNs, RefTxnId{1, 1}, {birthOp()})); + applyRefLogTxn(state, makeTxn(kNs, RefTxnId{1, 2}, {removeNamespaceOp()})); + EXPECT_EQ(state.getLifecycle(), RefLifecycle::Removed); + ASSERT_TRUE(state.getRemoveTxnId().has_value()); + EXPECT_EQ(*state.getRemoveTxnId(), (RefTxnId{1, 2})); +} + +TEST(CASRefStateMachine, CatalogedNeverBornLifeAcceptsAtomicEmptyBirthAndRemoval) +{ + RefTableState state; + applyRefLogTxn(state, makeTxn(kNs, RefTxnId{1, 1}, {birthOp(), removeNamespaceOp()})); + EXPECT_EQ(state.getLifecycle(), RefLifecycle::Removed); + ASSERT_TRUE(state.getRemoveTxnId().has_value()); + EXPECT_EQ(*state.getRemoveTxnId(), (RefTxnId{1, 1})); + EXPECT_TRUE(state.getCommitted().empty()); + EXPECT_TRUE(state.getPrecommits().empty()); +} + +TEST(CASRefStateMachine, RemoveNamespaceDrainingOwnersInSameTxnAccepted) +{ + RefTableState state; + applyRefLogTxn(state, makeTxn(kNs, RefTxnId{1, 1}, + {birthOp(), addPrecommitOp("a", manifestRef(1, 1, 1)), addPrecommitOp("b", manifestRef(1, 2, 1)), + promoteOp("b", manifestRef(1, 2, 1))})); + ASSERT_TRUE(state.getPrecommits().contains({"a", manifestRef(1, 1, 1)})); + ASSERT_TRUE(state.getCommitted().contains("b")); + + applyRefLogTxn(state, makeTxn(kNs, RefTxnId{1, 2}, + {removePrecommitOp("a", manifestRef(1, 1, 1)), removeCommittedOp("b", manifestRef(1, 2, 1)), + removeNamespaceOp()})); + EXPECT_EQ(state.getLifecycle(), RefLifecycle::Removed); + EXPECT_TRUE(state.getCommitted().empty()); + EXPECT_TRUE(state.getPrecommits().empty()); +} + +TEST(CASRefStateMachine, RemoveNamespaceRejectsWhenOwnersRemain) +{ + RefTableState state; + applyRefLogTxn(state, makeTxn(kNs, RefTxnId{1, 1}, + {birthOp(), addPrecommitOp("a", manifestRef(1, 1, 1)), addPrecommitOp("b", manifestRef(1, 2, 1))})); + const RefTableState before = state; + + /// Only "a" is drained; "b" remains -- remove_namespace's own precondition (empty owner sets) + /// must fail, and the WHOLE transaction (including the "a" removal) must not apply. + expectThrowsCode(DB::ErrorCodes::CORRUPTED_DATA, + [&] { applyRefLogTxn(state, makeTxn(kNs, RefTxnId{1, 2}, + {removePrecommitOp("a", manifestRef(1, 1, 1)), removeNamespaceOp()})); }); + expectStatesEqual(before, state); +} + +TEST(CASRefStateMachine, RemoveNamespaceMustBeFinalOp) +{ + RefTableState state; + applyRefLogTxn(state, makeTxn(kNs, RefTxnId{1, 1}, {birthOp()})); + const RefTableState before = state; + + expectThrowsCode(DB::ErrorCodes::CORRUPTED_DATA, + [&] { applyRefLogTxn(state, makeTxn(kNs, RefTxnId{1, 2}, {removeNamespaceOp(), birthOp()})); }); + expectStatesEqual(before, state); +} + +TEST(CASRefStateMachine, RemoveNamespaceRejectsNonRemovalEarlierOp) +{ + RefTableState state; + applyRefLogTxn(state, makeTxn(kNs, RefTxnId{1, 1}, + {birthOp(), addPrecommitOp("a", manifestRef(1, 1, 1)), promoteOp("a", manifestRef(1, 1, 1))})); + const RefTableState before = state; + + /// set_published_at before remove_namespace: not an owner-removal transition. + expectThrowsCode(DB::ErrorCodes::CORRUPTED_DATA, + [&] { applyRefLogTxn(state, makeTxn(kNs, RefTxnId{1, 2}, + {setPublishedAtOp("a", manifestRef(1, 1, 1)), removeCommittedOp("a", manifestRef(1, 1, 1)), + removeNamespaceOp()})); }); + expectStatesEqual(before, state); + + /// An ADD (not a removal) owner_transition before remove_namespace: also rejected. + expectThrowsCode(DB::ErrorCodes::CORRUPTED_DATA, + [&] { applyRefLogTxn(state, makeTxn(kNs, RefTxnId{1, 3}, + {addPrecommitOp("c", manifestRef(1, 3, 1)), removeCommittedOp("a", manifestRef(1, 1, 1)), + removeNamespaceOp()})); }); + expectStatesEqual(before, state); +} + +/// =================================================================================== +/// Whole-transaction atomicity: a failing LAST op leaves the whole txn (and earlier ops) unapplied +/// =================================================================================== + +TEST(CASRefStateMachine, WholeTxnAtomicityLastOpFailureLeavesStateUntouched) +{ + RefTableState state; + applyRefLogTxn(state, makeTxn(kNs, RefTxnId{1, 1}, {birthOp()})); + const RefTableState before = state; + + /// ops[0] (add "a") would succeed in isolation; ops[1] (remove absent "b") fails -- the whole + /// transaction, including "a", must be rejected. + expectThrowsCode(DB::ErrorCodes::CORRUPTED_DATA, + [&] { applyRefLogTxn(state, makeTxn(kNs, RefTxnId{1, 2}, + {addPrecommitOp("a", manifestRef(1, 1, 1)), removePrecommitOp("b", manifestRef(9, 9, 9))})); }); + + expectStatesEqual(before, state); + EXPECT_FALSE(state.getPrecommits().contains({"a", manifestRef(1, 1, 1)})); +} + +/// =================================================================================== +/// Contiguous txn ids (INV-1) +/// =================================================================================== + +/// A table's durable ids are DENSE within `(namespace, epoch)`: the only admissible id is the one +/// `nextRefTxnId` derives from `greatest_applied`, which is also the only id the writer ever mints. +/// Equal, lower, and skipped ids are all corruption -- the last of those is what makes "I can see ids +/// 1..T" mean "nothing is missing", the property the whole invariant exists to provide. +TEST(CASRefStateMachine, ContiguousTxnIdsRejectEqualLowerAndSkipped) +{ + RefTableState state; + applyRefLogTxn(state, makeTxn(kNs, RefTxnId{1, 1}, {birthOp()})); + const RefTableState before = state; + + expectThrowsCode(DB::ErrorCodes::CORRUPTED_DATA, + [&] { applyRefLogTxn(state, makeTxn(kNs, RefTxnId{1, 1}, {addPrecommitOp("a", manifestRef(1, 1, 1))})); }); + expectStatesEqual(before, state); + + expectThrowsCode(DB::ErrorCodes::CORRUPTED_DATA, + [&] { applyRefLogTxn(state, makeTxn(kNs, RefTxnId{0, 999}, {addPrecommitOp("a", manifestRef(1, 1, 1))})); }); + expectStatesEqual(before, state); + + /// Strictly greater but SKIPPED: admitted before INV-1, corruption now. + expectThrowsCode(DB::ErrorCodes::CORRUPTED_DATA, + [&] { applyRefLogTxn(state, makeTxn(kNs, RefTxnId{1, 3}, {addPrecommitOp("a", manifestRef(1, 1, 1))})); }); + expectStatesEqual(before, state); + + /// A new epoch restarts the sequence, so it must start at 1 -- carrying the previous epoch's + /// numbering forward would read exactly like a lost first transaction of the new stream. + expectThrowsCode(DB::ErrorCodes::CORRUPTED_DATA, + [&] { applyRefLogTxn(state, makeTxn(kNs, RefTxnId{2, 2}, {addPrecommitOp("a", manifestRef(1, 1, 1))})); }); + expectStatesEqual(before, state); + + /// The successor applies; then the next epoch's first id does. + applyRefLogTxn(state, makeTxn(kNs, RefTxnId{1, 2}, {addPrecommitOp("a", manifestRef(1, 1, 1))})); + EXPECT_EQ(state.getGreatestApplied(), (RefTxnId{1, 2})); + + /// Crossing into a new epoch needs INV-2's chain link as well as INV-1's id: without it the reader + /// cannot tell an EMPTY epoch from a lost one, so a sequence-1 transaction that names no seal is + /// refused on a Live table. Both halves are pinned, since either alone would be silently weaker. + RefLogTxn crossing = makeTxn(kNs, RefTxnId{2, 1}, {addPrecommitOp("b", manifestRef(2, 1, 1))}); + expectThrowsCode(DB::ErrorCodes::CORRUPTED_DATA, [&] { applyRefLogTxn(state, crossing); }); + crossing.prev_epoch_seal = RefTxnId{1, 3}; /// the seal that closed epoch 1, one past its last id + applyRefLogTxn(state, crossing); + EXPECT_EQ(state.getGreatestApplied(), (RefTxnId{2, 1})); +} + +/// =================================================================================== +/// snapshotOf: canonical sort + terminal-state refusal +/// =================================================================================== + +TEST(CASRefStateMachine, SnapshotOfSortsCommittedAndPrecommits) +{ + RefTableState state; + applyRefLogTxn(state, makeTxn(kNs, RefTxnId{1, 1}, + {birthOp(), addPrecommitOp("zzz", manifestRef(1, 3, 1)), addPrecommitOp("aaa", manifestRef(1, 1, 1)), + promoteOp("aaa", manifestRef(1, 1, 1))})); + applyRefLogTxn(state, makeTxn(kNs, RefTxnId{1, 2}, {addPrecommitOp("mmm", manifestRef(1, 2, 1))})); + + const RefTableSnapshot snap = snapshotOf(state, kNs); + ASSERT_EQ(snap.committed.size(), 1u); + EXPECT_EQ(snap.committed[0].ref_name, "aaa"); + ASSERT_EQ(snap.precommits.size(), 2u); + EXPECT_EQ(snap.precommits[0].ref_name, "mmm"); + EXPECT_EQ(snap.precommits[1].ref_name, "zzz"); + EXPECT_EQ(snap.snapshot_id, (RefTxnId{1, 2})); + + /// The result must actually be encodable (canonical shape) -- a real round trip through the codec. + const String bytes = encodeRefTableSnapshot(snap); + const RefTableSnapshot decoded = decodeRefTableSnapshot(bytes, kNs, snap.snapshot_id); + EXPECT_EQ(decoded, snap); +} + +TEST(CASRefStateMachine, SnapshotOfRefusesTerminalState) +{ + RefTableState state; + applyRefLogTxn(state, makeTxn(kNs, RefTxnId{1, 1}, {birthOp()})); + applyRefLogTxn(state, makeTxn(kNs, RefTxnId{1, 2}, {removeNamespaceOp()})); + + EXPECT_EQ(state.getLifecycle(), RefLifecycle::Removed); + ASSERT_TRUE(state.getRemoveTxnId().has_value()); + EXPECT_EQ(*state.getRemoveTxnId(), (RefTxnId{1, 2})); + expectThrowsCode(DB::ErrorCodes::CORRUPTED_DATA, [&] { snapshotOf(state, kNs); }); +} + +/// =================================================================================== +/// replay: TableState = Replay(S_X.state, tail(X)) +/// =================================================================================== + +TEST(CASRefStateMachine, ReplayFromNoSnapshot) +{ + std::vector tail{ + makeTxn(kNs, RefTxnId{1, 1}, {birthOp(), addPrecommitOp("a", manifestRef(1, 1, 1))}), + makeTxn(kNs, RefTxnId{1, 2}, {promoteOp("a", manifestRef(1, 1, 1))}), + }; + const RefTableState state = replay(std::nullopt, tail); + EXPECT_EQ(state.getLifecycle(), RefLifecycle::Live); + EXPECT_TRUE(state.getCommitted().contains("a")); + EXPECT_EQ(state.getGreatestApplied(), (RefTxnId{1, 2})); +} + +TEST(CASRefStateMachine, ReplayFromSnapshotPlusTail) +{ + RefTableState built; + applyRefLogTxn(built, makeTxn(kNs, RefTxnId{1, 1}, {birthOp(), addPrecommitOp("a", manifestRef(1, 1, 1))})); + const RefTableSnapshot snap = snapshotOf(built, kNs); + + std::vector tail{makeTxn(kNs, RefTxnId{1, 2}, {promoteOp("a", manifestRef(1, 1, 1))})}; + const RefTableState state = replay(snap, tail); + EXPECT_TRUE(state.getCommitted().contains("a")); + EXPECT_EQ(state.getGreatestApplied(), (RefTxnId{1, 2})); +} + +TEST(CASRefStateMachine, StateFromSnapshotConstructsLiveState) +{ + RefTableSnapshot snap; + snap.ns = kNs; + snap.snapshot_id = RefTxnId{1, 1}; + + const RefTableState state = stateFromSnapshot(snap); + EXPECT_EQ(state.getLifecycle(), RefLifecycle::Live); + EXPECT_FALSE(state.getRemoveTxnId().has_value()); +} + +TEST(CASRefStateMachine, ReplayRejectsTailNsMismatchAgainstSnapshot) +{ + RefTableState built; + applyRefLogTxn(built, makeTxn(kNs, RefTxnId{1, 1}, {birthOp()})); + const RefTableSnapshot snap = snapshotOf(built, kNs); + + std::vector tail{makeTxn("other-ns", RefTxnId{1, 2}, {addPrecommitOp("a", manifestRef(1, 1, 1))})}; + expectThrowsCode(DB::ErrorCodes::CORRUPTED_DATA, [&] { replay(snap, tail); }); +} + +TEST(CASRefStateMachine, ReplayRejectsTailNsMismatchAcrossEntries) +{ + std::vector tail{ + makeTxn("ns-a", RefTxnId{1, 1}, {birthOp()}), + makeTxn("ns-b", RefTxnId{1, 2}, {addPrecommitOp("a", manifestRef(1, 1, 1))}), + }; + expectThrowsCode(DB::ErrorCodes::CORRUPTED_DATA, [&] { replay(std::nullopt, tail); }); +} + +TEST(CASRefStateMachine, ReplayRejectsHandBuiltSnapshotWithDuplicateCommittedName) +{ + /// A hand-built RefTableSnapshot (never passed through decodeRefTableSnapshot -- exactly what + /// fsck hands to replay) with two committed rows sharing one ref_name must be rejected, not + /// silently collapsed to one row via std::map::emplace (the phantom-alive class of bug fixed in + /// stateFromSnapshot). + RefTableSnapshot snap; + snap.ns = kNs; + snap.snapshot_id = RefTxnId{1, 1}; + RefCommittedRow row1; + row1.ref_name = "a"; + row1.manifest_ref = manifestRef(1, 1, 1); + RefCommittedRow row2; + row2.ref_name = "a"; + row2.manifest_ref = manifestRef(1, 2, 1); + snap.committed.push_back(row1); + snap.committed.push_back(row2); + + expectThrowsCode(DB::ErrorCodes::CORRUPTED_DATA, [&] { replay(snap, {}); }); +} + +TEST(CASRefStateMachine, ReplayRejectsHandBuiltSnapshotWithUnsortedPrecommits) +{ + RefTableSnapshot snap; + snap.ns = kNs; + snap.snapshot_id = RefTxnId{1, 1}; + snap.precommits.push_back(RefOwnerBinding{RefOwnerKind::Precommit, "b", manifestRef(1, 1, 1)}); + snap.precommits.push_back(RefOwnerBinding{RefOwnerKind::Precommit, "a", manifestRef(1, 2, 1)}); + + expectThrowsCode(DB::ErrorCodes::CORRUPTED_DATA, [&] { replay(snap, {}); }); +} + +/// Randomized replay equation: replay(snapshotOf(mid-state), tail) == full replay (spec §Table State). +TEST(CASRefStateMachine, ReplayEquationPropertyTest) +{ + std::mt19937 rng(4242); // NOLINT(cert-msc32-c,cert-msc51-cpp): deterministic seed is required for reproducible property coverage. + const std::vector names{"a", "b", "c"}; + + for (int trial = 0; trial < 30; ++trial) + { + std::vector history; + uint64_t seq = 1; + history.push_back(makeTxn(kNs, RefTxnId{1, seq++}, {birthOp()})); + + /// Track our own model of legal next actions so every generated op is guaranteed valid -- + /// this test exercises the replay equation, not the rejection paths (covered above). + std::vector> open_precommits; + std::vector> open_committed; + uint64_t next_build_seq = 1; + + const int steps = 15; + for (int step = 0; step < steps; ++step) + { + const uint32_t choice = rng() % 4; + if (choice == 0 || (open_precommits.empty() && open_committed.empty())) + { + /// Add precommit under a fresh manifest_ref (never collides, so always legal). + const String & name = names[rng() % names.size()]; + const ManifestRef mref = manifestRef(1, next_build_seq++, 1); + history.push_back(makeTxn(kNs, RefTxnId{1, seq++}, {addPrecommitOp(name, mref)})); + open_precommits.emplace_back(name, mref); + } + else if (choice == 1 && !open_precommits.empty()) + { + /// Only a name NOT already committed is eligible for a BARE promote: promoting into an + /// already-committed name requires an explicit prior removal in the same transaction + /// (spec §Promote; see PromoteRejectsDisplacingAnotherCommittedManifest) -- a distinct + /// scenario from the one this equation test exercises. + std::vector eligible; + for (size_t i = 0; i < open_precommits.size(); ++i) + { + const bool already_committed = std::any_of(open_committed.begin(), open_committed.end(), + [&](const auto & c) { return c.first == open_precommits[i].first; }); + if (!already_committed) + eligible.push_back(i); + } + if (!eligible.empty()) + { + const size_t idx = eligible[rng() % eligible.size()]; + const auto [name, mref] = open_precommits[idx]; + open_precommits.erase(open_precommits.begin() + static_cast(idx)); + history.push_back(makeTxn(kNs, RefTxnId{1, seq++}, {promoteOp(name, mref)})); + open_committed.emplace_back(name, mref); + } + } + else if (choice == 2 && !open_committed.empty()) + { + const size_t idx = rng() % open_committed.size(); + const auto & [name, mref] = open_committed[idx]; + const uint64_t this_id = seq++; + history.push_back(makeTxn(kNs, RefTxnId{1, this_id}, + {setPublishedAtOp(name, mref, this_id)})); + } + else if (!open_precommits.empty()) + { + const size_t idx = rng() % open_precommits.size(); + const auto [name, mref] = open_precommits[idx]; + open_precommits.erase(open_precommits.begin() + static_cast(idx)); + history.push_back(makeTxn(kNs, RefTxnId{1, seq++}, {removePrecommitOp(name, mref)})); + } + else if (!open_committed.empty()) + { + const size_t idx = rng() % open_committed.size(); + const auto [name, mref] = open_committed[idx]; + open_committed.erase(open_committed.begin() + static_cast(idx)); + history.push_back(makeTxn(kNs, RefTxnId{1, seq++}, {removeCommittedOp(name, mref)})); + } + } + + const RefTableState full = replay(std::nullopt, history); + + const size_t cut = rng() % (history.size() + 1); + const std::vector head(history.begin(), history.begin() + static_cast(cut)); + const std::vector tail(history.begin() + static_cast(cut), history.end()); + const RefTableState mid = replay(std::nullopt, head); + const std::optional mid_snapshot = + cut == 0 ? std::nullopt : std::make_optional(snapshotOf(mid, kNs)); + const RefTableState resumed = replay(mid_snapshot, tail); + + expectStatesEqual(full, resumed); + } +} + +/// =================================================================================== +/// Fail-closed replay + snapshot validation: a corrupted history or snapshot naming one manifest under +/// two owners must be REJECTED in EVERY build (post-consult). The cross-owner uniqueness check is O(1) +/// via `owned_manifests`, so it runs unconditionally -- on the writer's append path AND on replay -- +/// rather than being elided into a debug-only assertion. `stateFromSnapshot` enforces the same +/// invariant across snapshot rows (the codec never did). +/// =================================================================================== + +/// (Add path, committed collision) The writer's append-time contract rejects a fresh precommit that +/// names a manifest already committed under a DIFFERENT ref_name, and leaves the state unchanged. +TEST(CASRefStateMachine, LiveAppendRejectsAddPrecommitCollidingWithCommitted) +{ + RefTableState state = stateFromSnapshot(buildCollidingBaseSnapshotForTest()); + const RefTableState before = state; + expectThrowsCode(DB::ErrorCodes::CORRUPTED_DATA, + [&] { applyRefLogTxn(state, makeTxn(kNs, RefTxnId{1, 2}, {addPrecommitOp("b", manifestRef(1, 1, 1))})); }); + expectStatesEqual(before, state); +} + +/// (Replay path, committed collision) A tail whose add-precommit collides cross-owner with an existing +/// committed owner makes `replay` THROW -- it must NOT be silently accepted. This is the exact behavior +/// the deleted `TrustedReplaySkipsCrossOwnerScanInRelease` test pinned as *desired*; post-consult it is +/// the opposite: fail closed. +TEST(CASRefStateMachine, ReplayRejectsTailAddPrecommitCollidingWithCommitted) +{ + const RefTableSnapshot snap = buildCollidingBaseSnapshotForTest(); + const std::vector tail{makeTxn(kNs, RefTxnId{1, 2}, {addPrecommitOp("b", manifestRef(1, 1, 1))})}; + expectThrowsCode(DB::ErrorCodes::CORRUPTED_DATA, [&] { replay(snap, tail); }); +} + +/// (Replay path, precommit collision) The same, but the base already holds a PRECOMMIT for the manifest +/// and the tail adds a second precommit for it under another ref_name (precommit/precommit collision). +TEST(CASRefStateMachine, ReplayRejectsTailAddPrecommitCollidingWithPrecommit) +{ + const std::vector tail{ + makeTxn(kNs, RefTxnId{1, 1}, {birthOp(), addPrecommitOp("a", manifestRef(1, 1, 1))}), + makeTxn(kNs, RefTxnId{1, 2}, {addPrecommitOp("b", manifestRef(1, 1, 1))}), // collides cross-owner + }; + expectThrowsCode(DB::ErrorCodes::CORRUPTED_DATA, [&] { replay(std::nullopt, tail); }); +} + +/// (Snapshot validation, committed/committed) A hand-built snapshot with two committed rows naming ONE +/// manifest passes the codec (it checks only sortedness + no-duplicate ref_name) but must be rejected by +/// `stateFromSnapshot`/`replay` as semantically corrupt. +TEST(CASRefStateMachine, ReplayRejectsSnapshotWithTwoCommittedRowsNamingOneManifest) +{ + RefTableSnapshot snap; + snap.ns = kNs; + snap.snapshot_id = RefTxnId{1, 1}; + RefCommittedRow row1; + row1.ref_name = "a"; + row1.manifest_ref = manifestRef(1, 1, 1); + RefCommittedRow row2; + row2.ref_name = "b"; // distinct ref_name (codec-legal)... + row2.manifest_ref = manifestRef(1, 1, 1); // ...but the SAME manifest (corrupt) + snap.committed.push_back(row1); + snap.committed.push_back(row2); + + expectThrowsCode(DB::ErrorCodes::CORRUPTED_DATA, [&] { replay(snap, {}); }); + expectThrowsCode(DB::ErrorCodes::CORRUPTED_DATA, [&] { (void)stateFromSnapshot(snap); }); +} + +/// (Snapshot validation, committed/precommit) A committed row and a precommit binding sharing one +/// manifest -- also codec-legal (different owner kinds, sorted independently) but corrupt. +TEST(CASRefStateMachine, ReplayRejectsSnapshotWithCommittedAndPrecommitSharingManifest) +{ + RefTableSnapshot snap; + snap.ns = kNs; + snap.snapshot_id = RefTxnId{1, 1}; + RefCommittedRow row; + row.ref_name = "a"; + row.manifest_ref = manifestRef(1, 1, 1); + snap.committed.push_back(row); + snap.precommits.push_back(RefOwnerBinding{RefOwnerKind::Precommit, "b", manifestRef(1, 1, 1)}); + + expectThrowsCode(DB::ErrorCodes::CORRUPTED_DATA, [&] { replay(snap, {}); }); +} + +/// (Snapshot validation, precommit/precommit) Two precommit bindings under different ref_names naming +/// one manifest -- sorted by (ref_name, manifest_ref), so codec-legal, but corrupt. +TEST(CASRefStateMachine, ReplayRejectsSnapshotWithTwoPrecommitsSharingManifest) +{ + RefTableSnapshot snap; + snap.ns = kNs; + snap.snapshot_id = RefTxnId{1, 1}; + snap.precommits.push_back(RefOwnerBinding{RefOwnerKind::Precommit, "a", manifestRef(1, 1, 1)}); + snap.precommits.push_back(RefOwnerBinding{RefOwnerKind::Precommit, "b", manifestRef(1, 1, 1)}); + + expectThrowsCode(DB::ErrorCodes::CORRUPTED_DATA, [&] { replay(snap, {}); }); +} + +/// Positive equivalence: a VALID tail replayed via `replay` (the in-place trusted path) produces a state +/// byte-identical (getters + encoded snapshot) to the same tail applied via the public strong-guarantee +/// `applyRefLogTxn` -- the apply strategy changes nothing a legal transaction produces. +TEST(CASRefStateMachine, TrustedReplayEquivalentToLiveAppendOnValidTail) +{ + const std::vector tail{ + makeTxn(kNs, RefTxnId{1, 1}, {birthOp(), addPrecommitOp("a", manifestRef(1, 1, 1))}), + makeTxn(kNs, RefTxnId{1, 2}, + {promoteOp("a", manifestRef(1, 1, 1)), addPrecommitOp("b", manifestRef(1, 2, 1))}), + makeTxn(kNs, RefTxnId{1, 3}, {setPublishedAtOp("a", manifestRef(1, 1, 1), 7)}), + makeTxn(kNs, RefTxnId{1, 4}, {promoteOp("b", manifestRef(1, 2, 1))}), + }; + + RefTableState full_state; + for (const RefLogTxn & txn : tail) + applyRefLogTxn(full_state, txn); // LiveAppend (default) + + const RefTableState trusted_state = replay(std::nullopt, tail); // replay uses the in-place trusted path internally + + expectStatesEqual(full_state, trusted_state); + EXPECT_EQ(encodeRefTableSnapshot(snapshotOf(full_state, kNs)), + encodeRefTableSnapshot(snapshotOf(trusted_state, kNs))); +} + +/// =================================================================================== +/// E3: apply strategy per validation mode +/// - LiveAppend: two-phase scratch copy, "throw => state byte-for-byte unchanged" +/// - TrustedReplay (replay): in-place, poison-on-throw, discarded by the sole caller +/// =================================================================================== + +namespace +{ +/// A populated, MATERIALIZED Live state -- committed "a"->(1,1,1) plus a pending precommit +/// ("p",(1,2,1)) -- built through the public LiveAppend path, then materialized so its COW overlays are +/// empty (exactly the shape the writer's live state has at each flush boundary). The E3 LiveAppend-path +/// tests mutate a COPY of this and assert the original-equivalent captured bytes/getters are intact +/// after a rejected transaction. +RefTableState buildPopulatedLiveState() +{ + RefTableState state; + applyRefLogTxn(state, makeTxn(kNs, RefTxnId{1, 1}, + {birthOp(), addPrecommitOp("a", manifestRef(1, 1, 1)), promoteOp("a", manifestRef(1, 1, 1)), + addPrecommitOp("p", manifestRef(1, 2, 1))})); + state.materializeCommitted(); + return state; +} +} + +/// LiveAppend-path atomicity, LATER-op throw ("populated" abort path): the first two ops touch committed, +/// precommits, the owned-manifest index and the body counters; the third is illegal. The whole +/// transaction is rejected and `state` is byte-for-byte unchanged -- getters AND encoded-snapshot +/// bytes. This is the writer's live-state contract, preserved verbatim by E3's `LiveAppend` arm. +TEST(CASRefStateMachine, E3LiveAppendLaterOpThrowLeavesPopulatedStateByteIdentical) +{ + RefTableState state = buildPopulatedLiveState(); + const RefTableState before = state; + const String before_bytes = encodeRefTableSnapshot(snapshotOf(state, kNs)); + + expectThrowsCode(DB::ErrorCodes::CORRUPTED_DATA, + [&] { applyRefLogTxn(state, makeTxn(kNs, RefTxnId{1, 2}, { + addPrecommitOp("q", manifestRef(1, 3, 1)), // touches precommits + index + counters + removeCommittedOp("a", manifestRef(1, 1, 1)), // touches committed + index + counters + removePrecommitOp("absent", manifestRef(9, 9, 9)) // ILLEGAL: exact binding absent -> throws + })); }); + + expectStatesEqual(before, state); + EXPECT_EQ(before_bytes, encodeRefTableSnapshot(snapshotOf(state, kNs))); + /// Neither surviving-looking earlier op leaked into the live state. + EXPECT_FALSE(state.getPrecommits().contains({"q", manifestRef(1, 3, 1)})); + EXPECT_TRUE(state.getCommitted().contains("a")); +} + +/// LiveAppend-path atomicity, FIRST-op throw ("empty" abort path -- nothing applied before the throw): the +/// symmetric guarantee still holds. Distinct from the case above because no op ever mutated the +/// scratch, exercising the throw-before-any-effect branch. +TEST(CASRefStateMachine, E3LiveAppendFirstOpThrowLeavesPopulatedStateByteIdentical) +{ + RefTableState state = buildPopulatedLiveState(); + const RefTableState before = state; + const String before_bytes = encodeRefTableSnapshot(snapshotOf(state, kNs)); + + expectThrowsCode(DB::ErrorCodes::CORRUPTED_DATA, + [&] { applyRefLogTxn(state, makeTxn(kNs, RefTxnId{1, 2}, { + removeCommittedOp("absent", manifestRef(9, 9, 9)), // ILLEGAL first op + addPrecommitOp("q", manifestRef(1, 3, 1)) + })); }); + + expectStatesEqual(before, state); + EXPECT_EQ(before_bytes, encodeRefTableSnapshot(snapshotOf(state, kNs))); +} + +/// `admits` previews an op against `state` and must leave it byte-for-byte unchanged whether the op +/// fits (true) or overflows (false) -- it is a pure query. Verified against both getters and encoded +/// bytes, for both the accept and the reject verdicts. +TEST(CASRefStateMachine, E3AdmitsPreviewLeavesStateByteIdentical) +{ + RefTableState state = buildPopulatedLiveState(); + /// Must be an independent snapshot -- `state` is queried and potentially mutated below, and + /// comparing against a reference would make the check vacuous. + // NOLINTNEXTLINE(performance-unnecessary-copy-initialization) + const RefTableState before = state; + const String before_bytes = encodeRefTableSnapshot(snapshotOf(state, kNs)); + + const RefOp grow = addPrecommitOp("q", manifestRef(1, 3, 1)); + + /// Accept verdict (ample budget): state untouched. + EXPECT_TRUE(admits(state, grow, 1'000'000, 1'000'000)); + expectStatesEqual(before, state); + EXPECT_EQ(before_bytes, encodeRefTableSnapshot(snapshotOf(state, kNs))); + + /// Reject verdict (snapshot budget one byte short of the grown size): state STILL untouched. + RefTableState grown = state; + applyRefLogTxn(grown, makeTxn(kNs, RefTxnId{1, 2}, {grow})); + const size_t grown_size = encodeRefTableSnapshot(snapshotOf(grown, "")).size(); + EXPECT_FALSE(admits(state, grow, grown_size - 1, 1'000'000)); + expectStatesEqual(before, state); + EXPECT_EQ(before_bytes, encodeRefTableSnapshot(snapshotOf(state, kNs))); +} + +/// TrustedReplay in-place apply, SUCCESS path across every `applyOp` arm: a tail that births, adds, +/// promotes, replaces a committed manifest, removes a committed and a precommit, restamps a timestamp, +/// and finally removes the namespace, replayed via `replay` (TrustedReplay, in place) must produce a +/// state byte-identical to the SAME tail applied op-by-op through `LiveAppend` (scratch copy). This is the +/// test only E3's in-place machinery can fail: a mis-maintained counter, a dropped owned-manifest +/// index entry, or a lost `greatest_applied` update on the no-copy path would diverge here. +TEST(CASRefStateMachine, E3TrustedReplayInPlaceMatchesLiveAppendAcrossAllArms) +{ + const std::vector tail{ + makeTxn(kNs, RefTxnId{1, 1}, {birthOp(), + addPrecommitOp("a", manifestRef(1, 1, 1)), addPrecommitOp("b", manifestRef(1, 2, 1))}), + makeTxn(kNs, RefTxnId{1, 2}, { + promoteOp("a", manifestRef(1, 1, 1)), // precommit -> committed + setPublishedAtOp("a", manifestRef(1, 1, 1), 42)}), // restamp published_at_ms + makeTxn(kNs, RefTxnId{1, 3}, {removePrecommitOp("b", manifestRef(1, 2, 1))}), // drop precommit + makeTxn(kNs, RefTxnId{1, 4}, { + removeCommittedOp("a", manifestRef(1, 1, 1)), // evict stale committed... + addPrecommitOp("a", manifestRef(1, 9, 1)), // ...then re-add under same name + promoteOp("a", manifestRef(1, 9, 1))}), // and promote the replacement + makeTxn(kNs, RefTxnId{1, 5}, { + removeCommittedOp("a", manifestRef(1, 9, 1)), // drain the last owner... + removeNamespaceOp()}), // ...then remove the namespace + }; + + RefTableState full_state; + for (const RefLogTxn & txn : tail) + applyRefLogTxn(full_state, txn); // LiveAppend (default): two-phase scratch copy + + const RefTableState replayed = replay(std::nullopt, tail); // TrustedReplay in-place + + expectStatesEqual(full_state, replayed); + EXPECT_THROW(encodeRefTableSnapshot(snapshotOf(full_state, kNs)), DB::Exception); + EXPECT_THROW(encodeRefTableSnapshot(snapshotOf(replayed, kNs)), DB::Exception); + EXPECT_EQ(replayed.getLifecycle(), RefLifecycle::Removed); + EXPECT_EQ(replayed.getRemoveTxnId(), std::make_optional(RefTxnId{1, 5})); +} + +/// TrustedReplay in-place apply, THROW path: a tail whose LAST transaction is illegal makes `replay` +/// throw `CORRUPTED_DATA`. The in-place apply poisons a state that is entirely internal to the failed +/// `replay` call (it is never assigned to a caller on a throw), so an INDEPENDENT replay of just the +/// valid prefix is completely unaffected -- pinning that the poison never escapes. +TEST(CASRefStateMachine, E3TrustedReplayPoisonOnBadTailIsInternal) +{ + const std::vector good_prefix{ + makeTxn(kNs, RefTxnId{1, 1}, {birthOp(), addPrecommitOp("a", manifestRef(1, 1, 1))}), + makeTxn(kNs, RefTxnId{1, 2}, {promoteOp("a", manifestRef(1, 1, 1))}), + }; + std::vector bad_tail = good_prefix; + /// A third txn whose op removes an absent precommit -- legal txn_id ordering, illegal effect, so it + /// throws mid-apply AFTER the good prefix has already been applied in place to the internal state. + bad_tail.push_back(makeTxn(kNs, RefTxnId{1, 3}, {removePrecommitOp("absent", manifestRef(9, 9, 9))})); + + expectThrowsCode(DB::ErrorCodes::CORRUPTED_DATA, [&] { replay(std::nullopt, bad_tail); }); + + /// The failed replay's poisoned internal state never leaked: a fresh replay of the valid prefix is + /// byte-identical to one built entirely via LiveAppend, and reflects exactly the prefix. + const RefTableState from_prefix = replay(std::nullopt, good_prefix); + RefTableState full_prefix; + for (const RefLogTxn & txn : good_prefix) + applyRefLogTxn(full_prefix, txn); + expectStatesEqual(full_prefix, from_prefix); + EXPECT_TRUE(from_prefix.getCommitted().contains("a")); + EXPECT_EQ(from_prefix.getGreatestApplied(), (RefTxnId{1, 2})); +} + +/// =================================================================================== +/// admits(): dual-bound admission budget (spec §Snapshot Format) +/// =================================================================================== + +TEST(CASRefStateMachine, AdmitsAcceptsWellUnderBudget) +{ + RefTableState state; + applyRefLogTxn(state, makeTxn(kNs, RefTxnId{1, 1}, {birthOp()})); + EXPECT_TRUE(admits(state, addPrecommitOp("a", manifestRef(1, 1, 1)), 1'000'000, 1'000'000)); +} + +TEST(CASRefStateMachine, AdmitsRejectsGrowthPastSnapshotBudgetOwnerTransitionAdd) +{ + RefTableState state; + applyRefLogTxn(state, makeTxn(kNs, RefTxnId{1, 1}, {birthOp()})); + + const RefOp op = addPrecommitOp("a", manifestRef(1, 1, 1)); + RefTableState scratch = state; + applyRefLogTxn(scratch, makeTxn(kNs, RefTxnId{1, 2}, {op})); + const size_t true_size = encodeRefTableSnapshot(snapshotOf(scratch, "")).size(); + + EXPECT_TRUE(admits(state, op, true_size, 1'000'000)); + EXPECT_FALSE(admits(state, op, true_size - 1, 1'000'000)); +} + +TEST(CASRefStateMachine, AdmitsRejectsGrowthPastSnapshotBudgetSetPublishedAt) +{ + RefTableState state; + applyRefLogTxn(state, makeTxn(kNs, RefTxnId{1, 1}, + {birthOp(), addPrecommitOp("a", manifestRef(1, 1, 1)), promoteOp("a", manifestRef(1, 1, 1))})); + + const RefOp op = setPublishedAtOp("a", manifestRef(1, 1, 1), 1700000000000ull); + RefTableState scratch = state; + applyRefLogTxn(scratch, makeTxn(kNs, RefTxnId{1, 2}, {op})); + const size_t true_size = encodeRefTableSnapshot(snapshotOf(scratch, "")).size(); + + EXPECT_TRUE(admits(state, op, true_size, 1'000'000)); + EXPECT_FALSE(admits(state, op, true_size - 1, 1'000'000)); +} + +TEST(CASRefStateMachine, AdmitsRejectsGrowthPastSnapshotBudgetPromoteWithSetPublishedAt) +{ + /// The "promote-with-set_published_at" growth class: the owner_transition half of a promote is + /// admitted cheaply (published_at_ms starts unset), but the immediately-following set_published_at + /// that installs the REAL initial timestamp is where the growth actually happens. + RefTableState state; + applyRefLogTxn(state, makeTxn(kNs, RefTxnId{1, 1}, {birthOp(), addPrecommitOp("a", manifestRef(1, 1, 1))})); + applyRefLogTxn(state, makeTxn(kNs, RefTxnId{1, 2}, {promoteOp("a", manifestRef(1, 1, 1))})); + ASSERT_TRUE(state.getCommitted().contains("a")); + ASSERT_EQ(state.getCommitted().at("a").published_at_ms, 0u); + + const RefOp op = setPublishedAtOp("a", manifestRef(1, 1, 1), 1700000000099ull); + RefTableState scratch = state; + applyRefLogTxn(scratch, makeTxn(kNs, RefTxnId{1, 3}, {op})); + const size_t true_size = encodeRefTableSnapshot(snapshotOf(scratch, "")).size(); + + EXPECT_TRUE(admits(state, op, true_size, 1'000'000)); + EXPECT_FALSE(admits(state, op, true_size - 1, 1'000'000)); +} + +TEST(CASRefStateMachine, AdmitsRejectsGrowthPastRemovalBudget) +{ + RefTableState state; + applyRefLogTxn(state, makeTxn(kNs, RefTxnId{1, 1}, + {birthOp(), addPrecommitOp("a", manifestRef(1, 1, 1)), promoteOp("a", manifestRef(1, 1, 1))})); + + const RefOp op = setPublishedAtOp("a", manifestRef(1, 1, 1), 1700000000300ull); + RefTableState scratch = state; + applyRefLogTxn(scratch, makeTxn(kNs, RefTxnId{1, 2}, {op})); + const String removal_bytes = encodeRefLogTxn(buildRemovalTxnForTest(scratch, "", RefTxnId{1, 1})); + const size_t true_removal_size = removal_bytes.size(); + + /// A generous snapshot budget isolates the removal-budget bound specifically. + EXPECT_TRUE(admits(state, op, 1'000'000, true_removal_size)); + EXPECT_FALSE(admits(state, op, 1'000'000, true_removal_size - 1)); +} + +/// Randomized exactness property test: admits()'s internal size computation must exactly match the +/// real encoders' output, for both bounds, across randomized states and candidate growing ops. +TEST(CASRefStateMachine, AdmitsExactnessPropertyTest) +{ + std::mt19937 rng(777); // NOLINT(cert-msc32-c,cert-msc51-cpp): deterministic seed is required for reproducible property coverage. + + for (int trial = 0; trial < 20; ++trial) + { + RefTableState state; + applyRefLogTxn(state, makeTxn(kNs, RefTxnId{1, 1}, {birthOp()})); + uint64_t seq = 2; + uint64_t next_build_seq = 1; + std::vector> open_precommits; + std::vector> open_committed; + + /// Build up a random but valid mid-state (a handful of precommits/committed rows/timestamps). + const int setup_steps = 1 + static_cast(rng() % 5); + for (int i = 0; i < setup_steps; ++i) + { + const String name = "ref" + std::to_string(rng() % 4); + const ManifestRef mref = manifestRef(1, next_build_seq++, 1); + applyRefLogTxn(state, makeTxn(kNs, RefTxnId{1, seq++}, {addPrecommitOp(name, mref)})); + open_precommits.emplace_back(name, mref); + + /// A bare promote may not target a name already committed under a different manifest + /// (spec §Promote; see PromoteRejectsDisplacingAnotherCommittedManifest) -- skip promoting + /// this iteration's precommit when an earlier iteration already committed the same name. + const bool name_already_committed = std::any_of(open_committed.begin(), open_committed.end(), + [&](const auto & c) { return c.first == name; }); + if (!name_already_committed && rng() % 2 == 0) + { + const auto [pname, pmref] = open_precommits.back(); + open_precommits.pop_back(); + applyRefLogTxn(state, makeTxn(kNs, RefTxnId{1, seq++}, {promoteOp(pname, pmref)})); + open_committed.emplace_back(pname, pmref); + } + } + + /// Pick a random candidate growing op against this state. + RefOp candidate; + const uint32_t kind = rng() % 3; + if (kind == 0 || open_committed.empty()) + { + candidate = addPrecommitOp("fresh-" + std::to_string(trial), manifestRef(1, next_build_seq++, 1)); + } + else if (kind == 1) + { + const auto & [name, mref] = open_committed[rng() % open_committed.size()]; + candidate = setPublishedAtOp(name, mref, rng()); + } + else + { + /// A genuinely distinct third shape: a racing precommit under an ALREADY-committed name + /// (legal -- spec §Add Precommit only restricts manifest identity, never ref_name). + const String & name = open_committed[rng() % open_committed.size()].first; + candidate = addPrecommitOp(name, manifestRef(1, next_build_seq++, 1)); + } + + RefTableState scratch = state; + applyRefLogTxn(scratch, makeTxn(kNs, RefTxnId{1, seq}, {candidate})); + const size_t true_snapshot_size = encodeRefTableSnapshot(snapshotOf(scratch, "")).size(); + const size_t true_removal_size = + encodeRefLogTxn(buildRemovalTxnForTest(scratch, "", RefTxnId{1, 1})).size(); + + EXPECT_TRUE(admits(state, candidate, true_snapshot_size, true_removal_size)); + EXPECT_FALSE(admits(state, candidate, true_snapshot_size - 1, true_removal_size)); + EXPECT_FALSE(admits(state, candidate, true_snapshot_size, true_removal_size - 1)); + } +} + +/// =================================================================================== +/// Snapshot size helpers: framing + Σ per-row must equal a full encode, byte for byte. +/// =================================================================================== +TEST(CASRefSnapshotSizeHelpers, FramingPlusRowsEqualsFullEncode) +{ + /// Build a non-trivial Live table: two committed rows (one with a stamped published_at_ms) and one + /// precommit. + RefTableState state; + applyRefLogTxn(state, makeTxn(kNs, RefTxnId{1, 1}, + {birthOp(), + addPrecommitOp("alpha", manifestRef(1, 1, 1)), promoteOp("alpha", manifestRef(1, 1, 1)), + addPrecommitOp("beta", manifestRef(1, 2, 1)), promoteOp("beta", manifestRef(1, 2, 1))})); + applyRefLogTxn(state, makeTxn(kNs, RefTxnId{1, 2}, + {setPublishedAtOp("alpha", manifestRef(1, 1, 1), 42)})); + applyRefLogTxn(state, makeTxn(kNs, RefTxnId{1, 3}, {addPrecommitOp("gamma", manifestRef(1, 3, 1))})); + + const RefTableSnapshot snap = snapshotOf(state, ""); + const size_t full = encodeRefTableSnapshot(snap).size(); + + size_t rebuilt = snapshotFramingSize("", snap.snapshot_id, snap.committed.size() + snap.precommits.size()); + for (const RefCommittedRow & row : snap.committed) + rebuilt += committedRowEncodedSize(row); + for (const RefOwnerBinding & pc : snap.precommits) + rebuilt += precommitRowEncodedSize(pc); + + EXPECT_EQ(rebuilt, full); +} + +/// =================================================================================== +/// Removal-txn size helpers: framing + Σ per-owner-op must equal a full removal-txn encode. +/// =================================================================================== +TEST(CASRefLogSizeHelpers, FramingPlusOpsEqualsFullRemovalEncode) +{ + RefTableState state; + applyRefLogTxn(state, makeTxn(kNs, RefTxnId{1, 1}, + {birthOp(), + addPrecommitOp("alpha", manifestRef(1, 1, 1)), promoteOp("alpha", manifestRef(1, 1, 1)), + addPrecommitOp("beta", manifestRef(1, 2, 1))})); + + /// Ground truth: the whole-namespace removal txn this test file already builds independently. + const RefLogTxn removal = buildRemovalTxnForTest(state, "", RefTxnId{1, 1}); + const size_t full = encodeRefLogTxn(removal).size(); + + size_t rebuilt = removalFramingSize("", RefTxnId{1, 1}, + state.getCommitted().size() + state.getPrecommits().size() + 1); + for (const auto [name, row] : state.getCommitted()) + rebuilt += removalOpEncodedSize(RefOwnerKind::Committed, name, row.manifest_ref); + for (const auto & [name, mref] : state.getPrecommits()) + rebuilt += removalOpEncodedSize(RefOwnerKind::Precommit, name, mref); + + EXPECT_EQ(rebuilt, full); +} + +/// =================================================================================== +/// Body-byte counters: snapshot_body_bytes / removal_body_bytes are a pure function of the rows. +/// =================================================================================== +namespace +{ +uint64_t recomputeSnapshotBody(const RefTableState & s) +{ + uint64_t total = 0; + for (const auto [name, row] : s.getCommitted()) + total += committedRowEncodedSize(row); + for (const auto & [name, mref] : s.getPrecommits()) + total += precommitRowEncodedSize(RefOwnerBinding{RefOwnerKind::Precommit, name, mref}); + return total; +} +uint64_t recomputeRemovalBody(const RefTableState & s) +{ + uint64_t total = 0; + for (const auto [name, row] : s.getCommitted()) + total += removalOpEncodedSize(RefOwnerKind::Committed, name, row.manifest_ref); + for (const auto & [name, mref] : s.getPrecommits()) + total += removalOpEncodedSize(RefOwnerKind::Precommit, name, mref); + return total; +} +} + +TEST(CASRefStateCounters, CountersTrackRowsThroughEveryOpKind) +{ + RefTableState state; + EXPECT_EQ(state.getSnapshotBodyBytes(), 0u); + EXPECT_EQ(state.getRemovalBodyBytes(), 0u); + + applyRefLogTxn(state, makeTxn(kNs, RefTxnId{1, 1}, {birthOp()})); + applyRefLogTxn(state, makeTxn(kNs, RefTxnId{1, 2}, {addPrecommitOp("a", manifestRef(1, 1, 1))})); + applyRefLogTxn(state, makeTxn(kNs, RefTxnId{1, 3}, {promoteOp("a", manifestRef(1, 1, 1))})); + applyRefLogTxn(state, makeTxn(kNs, RefTxnId{1, 4}, + {setPublishedAtOp("a", manifestRef(1, 1, 1), 5)})); + applyRefLogTxn(state, makeTxn(kNs, RefTxnId{1, 5}, {addPrecommitOp("b", manifestRef(1, 2, 1))})); + EXPECT_EQ(state.getSnapshotBodyBytes(), recomputeSnapshotBody(state)); + EXPECT_EQ(state.getRemovalBodyBytes(), recomputeRemovalBody(state)); + + /// Shrink back down: remove the precommit, then the committed row. + applyRefLogTxn(state, makeTxn(kNs, RefTxnId{1, 6}, {removePrecommitOp("b", manifestRef(1, 2, 1))})); + applyRefLogTxn(state, makeTxn(kNs, RefTxnId{1, 7}, {removeCommittedOp("a", manifestRef(1, 1, 1))})); + EXPECT_EQ(state.getSnapshotBodyBytes(), recomputeSnapshotBody(state)); + EXPECT_EQ(state.getRemovalBodyBytes(), recomputeRemovalBody(state)); + EXPECT_EQ(state.getSnapshotBodyBytes(), 0u); + EXPECT_EQ(state.getRemovalBodyBytes(), 0u); +} + +/// =================================================================================== +/// Budget-size accessors equal the real encoders across randomized states. +/// =================================================================================== +TEST(CASRefBudgetSize, AccessorsEqualFullEncodeRandomized) +{ + std::mt19937 rng(1234); // NOLINT(cert-msc32-c,cert-msc51-cpp): deterministic seed for reproducibility. + for (int trial = 0; trial < 30; ++trial) + { + RefTableState state; + applyRefLogTxn(state, makeTxn(kNs, RefTxnId{1, 1}, {birthOp()})); + uint64_t seq = 2; + uint64_t build = 1; + std::vector> committed_names; + + const int steps = 1 + static_cast(rng() % 6); + for (int i = 0; i < steps; ++i) + { + const String name = "r" + std::to_string(rng() % 5); + const ManifestRef mref = manifestRef(1, build++, 1); + applyRefLogTxn(state, makeTxn(kNs, RefTxnId{1, seq++}, {addPrecommitOp(name, mref)})); + const bool already = std::any_of(committed_names.begin(), committed_names.end(), + [&](const auto & c) { return c.first == name; }); + if (!already && rng() % 2 == 0) + { + applyRefLogTxn(state, makeTxn(kNs, RefTxnId{1, seq++}, {promoteOp(name, mref)})); + committed_names.emplace_back(name, mref); + if (rng() % 2 == 0) + applyRefLogTxn(state, makeTxn(kNs, RefTxnId{1, seq++}, + {setPublishedAtOp(name, mref, rng())})); + } + } + + const size_t true_snapshot = encodeRefTableSnapshot(snapshotOf(state, "")).size(); + const size_t true_removal = encodeRefLogTxn(buildRemovalTxnForTest(state, "", RefTxnId{1, 1})).size(); + EXPECT_EQ(encodedSnapshotBudgetSize(state), true_snapshot); + EXPECT_EQ(encodedRemovalBudgetSize(state), true_removal); + } +} diff --git a/src/Disks/tests/gtest_cas_ref_wedge_every_attempt.cpp b/src/Disks/tests/gtest_cas_ref_wedge_every_attempt.cpp new file mode 100644 index 000000000000..ace02d24836e --- /dev/null +++ b/src/Disks/tests/gtest_cas_ref_wedge_every_attempt.cpp @@ -0,0 +1,1476 @@ +#include + +#include "config.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +#include +#include +#include +#include +#include +#include +#include +#include + +/// ================================================================================================ +/// Task 4 (2026-07-28 CAS ref-chain Stage A streams, spec INV-1's every-attempt rule + INV-2's seal): +/// the writer wedge. +/// +/// An id is freed only when NOTHING WAS SENT, or when every sent attempt has its own CONCLUSIVE +/// rejection. That is what these tests are about, and the second half is the part that changed: an +/// ambiguous attempt used to be resolved by a bare exact GET, which can only ever report "absent" -- +/// and absent is not a rejection, because the ambiguous attempt may still land afterwards. The lane +/// therefore stayed wedged FOREVER over a key nothing had written. The rule now runs one bounded +/// `slotOccupy` per later caller's flush: the ref-log key is write-once, so a conditional CREATE of +/// the SAME bytes either makes the transaction durable (adopt it) or conflicts with whatever is +/// there, which the follow-up read then names -- our own earlier write (adopt), a successor's +/// `EpochSeal` (the operation is conclusively rejected and never was acked), or a foreign object +/// (impossible under mount-lease exclusivity: fail loud). +/// +/// Two cross-cutting rules are exercised throughout rather than in one place: +/// - the ADMISSION FENCE: a wedge carries the mount-fence generation it was admitted under, every +/// retry is gated on THAT generation (never the current one), and every result is re-checked +/// under `state_mutex` before anything acts on it. A result that returns after a fence +/// bump/re-arm, or after the wedge it belonged to was replaced, must be INERT. +/// - `prev_epoch_seal`: a seal observed at the wedged key IS this namespace's epoch-closing record, +/// so it becomes the `prev_epoch_seal` the next sequence-1 append carries. `nullopt` means +/// genesis, and means it exactly. +/// ================================================================================================ + +namespace ProfileEvents +{ +extern const Event CASRefAppendSealRejected; +extern const Event CASRefAppendOccupantUnreadable; +extern const Event CASRefAppendWedged; +extern const Event CASRefAppendDefiniteFailure; +} + +namespace DB::ErrorCodes +{ +extern const int BAD_ARGUMENTS; +extern const int CORRUPTED_DATA; +extern const int INVALID_STATE; +extern const int MEMORY_LIMIT_EXCEEDED; +extern const int NETWORK_ERROR; +} + +using namespace DB::Cas; +using DB::Cas::tests::CountingBackend; +using DB::Cas::tests::LandedButAckLostOnceBackend; +using DB::Cas::tests::expectThrowsCode; + +namespace +{ + +PoolPtr openPool(const BackendPtr & backend, CasRequestBudget budget = {}) +{ + DB::Cas::tests::seedPoolMetaForRestart(*backend); + return Pool::open(backend, PoolConfig{.pool_prefix = "p", .server_root_id = "test", .cas_request_budget = budget}); +} + +/// The budget every wedge test uses: ONE attempt, so a single injected ambiguity is the whole +/// operation and the lane wedges deterministically instead of retrying its way out. +CasRequestBudget singleAttemptBudget() +{ + CasRequestBudget budget; + budget.max_attempts = 1; + budget.attempt_timeout_ms = 100; + budget.operation_deadline_ms = 5000; /// strictly above attempt_timeout_ms: equality is a wall-clock race (validateCasRequestBudget) + budget.lease_safety_margin_ms = 100; + return budget; +} + +/// TWO attempts of one logical operation, with the inter-attempt backoff disabled. Everything about the +/// call-level verdict rule lives BETWEEN two attempts of a single call, so it cannot be reached with the +/// one-attempt budget the tests above use; the backoff is switched off because the schedule is +/// `gtest_cas_request_control.cpp`'s subject and a real sleep here would only slow the suite. +/// +/// `[[maybe_unused]]`: both of its callers need a real S3-classified rejection to script their second +/// attempt, so they compile away entirely in a build without S3. +[[maybe_unused]] CasRequestBudget twoAttemptBudget() +{ + CasRequestBudget budget = singleAttemptBudget(); + budget.max_attempts = 2; + budget.retry_initial_backoff_ms = 0; + budget.retry_max_backoff_ms = 0; + return budget; +} + +PartWriteTxnPtr startBuildFor(const PoolPtr & s, const RootNamespace & ns, const String & ref) +{ + PartWriteInfo info; + info.intended_namespace = ns; + info.intended_ref = ns.string() + "/" + ref; + return s->beginPartWrite(info); +} + +void publishEmptyPart(const PoolPtr & s, const RootNamespace & ns, const String & ref) +{ + auto build = startBuildFor(s, ns, ref); + const ManifestId id = build->stageManifest({}); + build->precommitAdd(ns, ref, id); + build->promote(ns, ref, build->buildId(), id); +} + +/// A `CountingBackend` with the exact seams these tests need, all keyed by substring so a whole Pool's +/// bootstrap traffic never consumes a fault meant for a `_log/` PUT. +class WedgeTestBackend : public CountingBackend +{ +public: + using CountingBackend::putIfAbsent; + using CountingBackend::get; + + /// One-shot ambiguity that writes NOTHING: the response is lost and the key stays absent, which is + /// the input that makes a later `slotOccupy` report `Created`. + String ambiguous_substr; + int ambiguous_count = 0; + + /// One-shot DETERMINISTIC LOCAL failure (`BAD_ARGUMENTS`, in `isDeterministicLocalFailure`'s set), + /// which `slotOccupy` rethrows unchanged -- a definite refusal of THIS attempt. Portable stand-in + /// for the S3 `DefiniteFailure` shape, which needs `USE_AWS_S3`; both are "proven never applied". + String definite_substr; + int definite_count = 0; + + /// One-shot WHITELISTED SYNCHRONOUS REJECTION: the ONLY shape `classifyConditionalWriteResult` + /// answers `DefiniteFailure` for, and therefore the only way to drive the append lane's definite + /// arm. Distinct from `definite_substr` above on purpose -- that one is a deterministic LOCAL + /// failure, which `slotOccupy` rethrows but `putIfAbsentControlled` (no such special case) merely + /// classifies Unresolved, so it cannot script this arm at all. + String s3_definite_substr; + int s3_definite_count = 0; + + /// A SUCCESSOR lands `conflict_bytes` at the key and only then is our response lost, so the + /// controller's resolve-before-reissue reads a different object and proves the conflict. This is how + /// the ordinary append site meets an occupant at the id it derived. + String conflict_substr; + int conflict_count = 0; + String conflict_bytes; + + /// Fail GETs of matching keys after skipping the first `fail_get_skip` of them -- the resolve read + /// that PROVES the conflict must succeed, so only the adjudication read that follows it is faulted. + String fail_get_substr; + int fail_get_skip = 0; + int fail_get_count = 0; + + String fail_cas_substr; + int fail_cas_count = 0; + + std::optional get(const String & key, Range range) override + { + if (fail_get_count > 0 && !fail_get_substr.empty() && key.find(fail_get_substr) != String::npos) + { + if (fail_get_skip > 0) + --fail_get_skip; + else + { + --fail_get_count; + throw Poco::TimeoutException("WedgeTestBackend: simulated lost GET (read response never arrived)"); + } + } + return CountingBackend::get(key, range); + } + + CasResult casPut(const String & key, const String & bytes, const std::optional & expected, + const ObjectMeta & meta) override + { + if (fail_cas_count > 0 && !fail_cas_substr.empty() && key.find(fail_cas_substr) != String::npos) + { + --fail_cas_count; + throw Poco::TimeoutException("WedgeTestBackend: simulated ambiguous checkpoint CAS"); + } + return CountingBackend::casPut(key, bytes, expected, meta); + } + + /// Park a matching PUT until `releaseBlock()`, notifying `awaitBlockEntered()` on arrival, so a + /// test can drive a fence bump or a successor's write into the exact I/O window. + void armBlock(const String & substr) + { + std::lock_guard g(block_mutex); + block_substr = substr; + block_armed = true; + block_entered = false; + } + void awaitBlockEntered() + { + std::unique_lock lk(block_mutex); + block_cv.wait(lk, [&] { return block_entered; }); + } + void releaseBlock() + { + { + std::lock_guard g(block_mutex); + block_armed = false; + } + block_cv.notify_all(); + } + + /// Write straight through, bypassing every fault and block seam above -- how a test models what a + /// SUCCESSOR (another process entirely) put at a key. Using the faulting entry point instead would + /// park the test's own write on the very gate it is trying to drive a scenario through. The + /// qualification must name the THREE-argument overload: `Backend`'s two-argument convenience + /// forwards to the VIRTUAL one, so `CountingBackend::putIfAbsent(key, bytes)` would dispatch right + /// back into the override above and deadlock the test against its own block. + PutResult putAsSuccessor(const String & key, const String & bytes) + { + return CountingBackend::putIfAbsent(key, bytes, ObjectMeta{}); + } + + PutResult putIfAbsent(const String & key, const String & bytes, const ObjectMeta & meta) override + { + if (ambiguous_count > 0 && !ambiguous_substr.empty() && key.find(ambiguous_substr) != String::npos) + { + --ambiguous_count; + throw Poco::TimeoutException("WedgeTestBackend: simulated ambiguous PUT (response lost, nothing landed)"); + } + if (definite_count > 0 && !definite_substr.empty() && key.find(definite_substr) != String::npos) + { + --definite_count; + throw DB::Exception(DB::ErrorCodes::BAD_ARGUMENTS, "WedgeTestBackend: scripted deterministic local failure"); + } + /// AFTER the ambiguity seam, so arming both scripts one call's attempts in order: the first + /// attempt goes ambiguous, the reissue is definitively refused. + if (s3_definite_count > 0 && !s3_definite_substr.empty() && key.find(s3_definite_substr) != String::npos) + { + --s3_definite_count; +#if USE_AWS_S3 + throw DB::S3Exception("WedgeTestBackend: simulated malformed request", + Aws::S3::S3Errors::UNKNOWN, "MalformedXML"); +#else + throw DB::Exception(DB::ErrorCodes::CORRUPTED_DATA, + "WedgeTestBackend: DefiniteFailure requires S3 error classification (USE_AWS_S3 off)"); +#endif + } + if (conflict_count > 0 && !conflict_substr.empty() && key.find(conflict_substr) != String::npos) + { + --conflict_count; + CountingBackend::putIfAbsent(key, conflict_bytes, meta); + throw Poco::TimeoutException("WedgeTestBackend: a successor's object landed; our response was lost"); + } + { + std::unique_lock lk(block_mutex); + if (block_armed && !block_substr.empty() && key.find(block_substr) != String::npos) + { + block_entered = true; + block_cv.notify_all(); + /// Bounded so a wiring bug bounds the wait instead of hanging the suite. + block_cv.wait_for(lk, std::chrono::seconds(20), [&] { return !block_armed; }); + } + } + return CountingBackend::putIfAbsent(key, bytes, meta); + } + +private: + std::mutex block_mutex; + std::condition_variable block_cv; + String block_substr; + bool block_armed = false; + bool block_entered = false; +}; + +/// The `_log/` key prefix of one namespace -- what every fault seam here matches on. +String logPrefix(const PoolPtr & store, const RootNamespace & ns) +{ + return store->layout().namespaceStreamPrefix(DB::Cas::tests::fixture::fixtureLife(ns)) + "_log/"; +} + +CatalogEntry catalogEntryOrThrow(Backend & backend, const Layout & layout, const RootNamespace & ns) +{ + const RefCatalog catalog = CasRefCatalog::read(backend, layout).catalog; + const auto it = std::find_if(catalog.entries.begin(), catalog.entries.end(), [&](const CatalogEntry & entry) + { + return entry.ns == ns; + }); + if (it == catalog.entries.end()) + throw std::runtime_error("catalog entry missing from wedge fixture"); + return *it; +} + +/// Wedge tests address raw ref-log keys at Stage A's deterministic sentinel identity, but their +/// catalog fixture must still use production's `Creating -> _ckpt -> Live` birth order. A fixed +/// creator identity makes the durable genesis checkpoint deterministic too. +void admitProperlyBornEntry(Backend & backend, const Layout & layout, const RootNamespace & ns) +{ + const CatalogEntry creating{ + .ns = ns, + .state = NsState::Creating, + .incarnation = DB::Cas::tests::fixture::fixtureLife(ns).incarnation, + .creator = CreatorFence{.server_root_id = "test", .writer_epoch = 1, .fence_generation = 1}, + }; + CasRefCatalog::casAdmitEntry(backend, layout, /*gc_shards=*/1, creating); + + const CkptDeadline deadline{.now_ms = [] { return uint64_t{1000}; }, .deadline_ms = 60000}; + ASSERT_EQ( + CasRefCatalog::completeCreation( + backend, layout, creating, /*admitted_generation=*/1, [](uint64_t) {}, deadline), + CasRefCatalog::NamespaceCreationOutcome::Live); +} + +CatalogEntry replaceCatalogLifeForWedgeRace( + Backend & backend, const Layout & layout, const CatalogEntry & predecessor, UInt128 successor_incarnation) +{ + const CasRefCatalog::Snapshot before_delete = CasRefCatalog::read(backend, layout); + RefCatalog without_predecessor = before_delete.catalog; + std::erase_if(without_predecessor.entries, [&](const CatalogEntry & entry) + { + return entry.ns == predecessor.ns && entry.incarnation == predecessor.incarnation; + }); + if (backend.casPut(layout.refCatalogKey(), encodeRefCatalog(without_predecessor), before_delete.token).outcome + != CasOutcome::Committed) + throw std::runtime_error("test failed to retire exact predecessor catalog life"); + + CatalogEntry successor{ + .ns = predecessor.ns, + .state = NsState::Live, + .incarnation = successor_incarnation, + .creator = std::nullopt}; + const CasRefCatalog::Snapshot after_delete = CasRefCatalog::read(backend, layout); + RefCatalog reborn = after_delete.catalog; + reborn.entries.push_back(successor); + if (backend.casPut(layout.refCatalogKey(), encodeRefCatalog(reborn), after_delete.token).outcome + != CasOutcome::Committed) + throw std::runtime_error("test failed to publish successor catalog life"); + return successor; +} + +/// Decode the ref-log object at `id`, through the SAME codec the writer's recovery uses (never a +/// hand-rolled parse), so an assertion about `prev_epoch_seal` is an assertion about the WIRE. +RefLogTxn readRefLogTxn(Backend & backend, const Layout & layout, const RootNamespace & ns, const RefTxnId & id) +{ + const auto got = backend.get(layout.refLogKey(DB::Cas::tests::fixture::fixtureLife(ns), id)); + if (!got) + throw DB::Exception(DB::ErrorCodes::CORRUPTED_DATA, "no ref-log object at {}-{}", id.writer_epoch, id.ref_sequence); + return decodeRefLogTxn(openObject(FormatId::RefLog, got->bytes), ns.string(), id); +} + +/// The bytes of a real `EpochSeal` transaction closing `id.writer_epoch` at `id` -- what a SUCCESSOR +/// writes into the dead epoch's next slot (spec INV-2). Grammar: exactly one `EpochSeal` op, and +/// `prev_epoch_seal` on sequence 1 only, so callers pass it exactly when `id.ref_sequence == 1`. +String epochSealBytes(const RootNamespace & ns, const RefTxnId & id, std::optional prev_epoch_seal = std::nullopt) +{ + RefOp op; + op.kind = RefOpKind::EpochSeal; + const RefLogTxn txn{ns.string(), id, {op}, prev_epoch_seal}; + return sealObject(FormatId::RefLog, encodeRefLogTxn(txn)); +} + +/// Re-arm the mount fence WITHOUT a self-remount: the generation moves (twice -- trip, then re-arm) +/// while the cached runtime survives, which is what isolates the generation check as the sole +/// detector. A real self-remount also quiesces the runtimes; that path is Task 6's. +void bumpFenceGeneration(const PoolPtr & store, uint64_t writer_epoch) +{ + store->tripMountLost(); + store->armMountFence(DB::UInt128{0, 1}, writer_epoch, store->bootMsNow() + 600000); + /// The fence re-arm alone moves the GENERATION; the live incarnation's writer epoch is a separate + /// publication (`tryRemountOnce` does both), and the append lane derives its ids from that one. + store->setLiveWriterEpochForTest(writer_epoch); +} + +/// Arm a one-shot throw inside the post-durable install regions. The exception is built OUTSIDE the +/// region (building it inside would trip `DENY_ALLOCATIONS_IN_SCOPE` and test the guard instead), and +/// `MEMORY_LIMIT_EXCEEDED` is what a real tracked allocation failure raises. Same shape as +/// `gtest_cas_ref_install_safety.cpp`'s helper. +void armOneShotInstallFailure(const PoolPtr & store) +{ + auto planned = std::make_exception_ptr(DB::Exception(DB::ErrorCodes::MEMORY_LIMIT_EXCEEDED, + "simulated allocation failure inside the post-durable install region")); + auto fired = std::make_shared>(false); + store->setInstallRegionProbeForTest([planned, fired] + { + if (fired->exchange(true)) + return; + ALLOW_ALLOCATIONS_IN_SCOPE; + std::rethrow_exception(planned); + }); +} + +} + +/// =================================================================================== +/// The every-attempt rule: an ambiguous attempt is resolved by a bounded CREATE, not a read +/// =================================================================================== + +/// The headline change. Nothing landed, so the old bare-GET resolution reported "absent" forever and +/// the lane never recovered without a remount. One conditional create of the SAME bytes settles it: +/// the object becomes durable and the wedged transaction is adopted -- applied EXACTLY once, before +/// the flush that resolved it allocates any new id. +TEST(CASRefWedgeEveryAttempt, AmbiguousPutWedgesTheLaneAndTheNextFlushsCreateAdoptsItExactlyOnce) +{ + auto backend = std::make_shared(); + auto store = openPool(backend, singleAttemptBudget()); + const RootNamespace ns{"srv1/wedge_created"}; + /// Stage B (Task 4-C): `logPrefix` below computes its fault-injection match at the sentinel; + /// pinning `ns` there BEFORE the first real touch keeps the real production birth landing on the + /// same key the fault targets. + admitProperlyBornEntry(*backend, store->layout(), ns); + publishEmptyPart(store, ns, "x"); + publishEmptyPart(store, ns, "y"); + + backend->ambiguous_substr = logPrefix(store, ns); + backend->ambiguous_count = 1; + + expectThrowsCode(DB::ErrorCodes::NETWORK_ERROR, [&] { store->dropRef(ns, "x"); }); + ASSERT_TRUE(store->refLaneWedgedForTest(ns)); + ASSERT_TRUE(store->resolveRef(ns, "x").has_value()) << "a wedged transaction is not applied"; + const String wedged_key = store->wedgedKeyForTest(ns); + ASSERT_FALSE(backend->get(wedged_key).has_value()) << "the ambiguous attempt wrote nothing"; + const size_t tail_before = store->tailSinceSnapshotCountForTest(ns); + + /// The next caller's flush resolves the wedge with ONE create, adopts it, and only then carves and + /// commits its own transaction. + EXPECT_NO_THROW(store->dropRef(ns, "y")); + + EXPECT_FALSE(store->refLaneWedgedForTest(ns)); + EXPECT_FALSE(store->resolveRef(ns, "x").has_value()) << "the adopted wedge applied its drop"; + EXPECT_FALSE(store->resolveRef(ns, "y").has_value()) << "the resolving flush committed its own drop"; + EXPECT_TRUE(backend->get(wedged_key).has_value()) << "the wedged transaction is durable at its own key"; + EXPECT_EQ(store->tailSinceSnapshotCountForTest(ns), tail_before + 2) + << "the adopted wedge and the ordinary commit must each join the tail exactly once"; + EXPECT_EQ(store->laneStateForTest(ns), RefLaneState::Ready); +} + +TEST(CASRefWedgeEveryAttempt, DurableCreatedWedgeNeedsRecoveryWhenItsFrontierCannotBePublished) +{ + auto backend = std::make_shared(); + auto store = openPool(backend, singleAttemptBudget()); + const RootNamespace ns{"srv1/wedge_created_frontier_failed"}; + admitProperlyBornEntry(*backend, store->layout(), ns); + publishEmptyPart(store, ns, "x"); + publishEmptyPart(store, ns, "y"); + + backend->ambiguous_substr = logPrefix(store, ns); + backend->ambiguous_count = 1; + expectThrowsCode(DB::ErrorCodes::NETWORK_ERROR, [&] { store->dropRef(ns, "x"); }); + ASSERT_TRUE(store->refLaneWedgedForTest(ns)); + const String wedged_key = store->wedgedKeyForTest(ns); + ASSERT_FALSE(backend->get(wedged_key)); + const size_t tail_before = store->tailSinceSnapshotCountForTest(ns); + + const NamespaceLifeId life = *store->refTableLifeForTest(ns); + const String ckpt_key = store->layout().refCkptKey(life); + const RefCkpt ckpt_before = decodeRefCkpt(backend->get(ckpt_key)->bytes); + backend->fail_cas_substr = ckpt_key; + backend->fail_cas_count = 200; + + expectThrowsCode(DB::ErrorCodes::NETWORK_ERROR, [&] { store->dropRef(ns, "y"); }); + + EXPECT_TRUE(backend->get(wedged_key)) << "the exact wedged log was proven durable"; + EXPECT_EQ(store->laneStateForTest(ns), RefLaneState::NeedsRecovery) + << "a durable log without a confirmed frontier must not return to Ready"; + EXPECT_EQ(store->tailSinceSnapshotCountForTest(ns), tail_before) + << "the unfrontiered wedge must not be installed into the resident table"; + EXPECT_EQ(decodeRefCkpt(backend->get(ckpt_key)->bytes), ckpt_before); +} + +TEST(CASRefWedgeEveryAttempt, RetiredLifeRefusesWedgeRetryBeforeAnyRequestOrAdoption) +{ + auto backend = std::make_shared(); + auto store = openPool(backend, singleAttemptBudget()); + const RootNamespace ns{"srv1/wedge-retired-before-retry"}; + admitProperlyBornEntry(*backend, store->layout(), ns); + publishEmptyPart(store, ns, "x"); + publishEmptyPart(store, ns, "y"); + const CatalogEntry predecessor = catalogEntryOrThrow(*backend, store->layout(), ns); + const NamespaceLifeId predecessor_life + = NamespaceLifeId::fromCatalogEntry(predecessor.ns, predecessor.incarnation); + + backend->ambiguous_substr = logPrefix(store, ns); + backend->ambiguous_count = 1; + expectThrowsCode(DB::ErrorCodes::NETWORK_ERROR, [&] { store->dropRef(ns, "x"); }); + ASSERT_TRUE(store->refLaneWedgedForTest(ns)); + const String wedged_key = store->wedgedKeyForTest(ns); + ASSERT_FALSE(backend->get(wedged_key)); + + std::mutex mutex; + std::condition_variable cv; + bool paused = false; + bool resume = false; + store->setWedgeBeforeSlotOccupyHookForTest([&] + { + std::unique_lock lock(mutex); + paused = true; + cv.notify_all(); + cv.wait(lock, [&] { return resume; }); + }); + + std::exception_ptr retry_error; + std::thread retry([&] + { + try + { + store->dropRef(ns, "y"); + } + catch (...) + { + retry_error = std::current_exception(); + } + }); + { + std::unique_lock lock(mutex); + cv.wait(lock, [&] { return paused; }); + } + + const CatalogEntry successor + = replaceCatalogLifeForWedgeRace(*backend, store->layout(), predecessor, UInt128{0x71f2}); + const NamespaceLifeId successor_life + = NamespaceLifeId::fromCatalogEntry(successor.ns, successor.incarnation); + ASSERT_EQ(backend->putIfAbsent(store->layout().refCkptKey(successor_life), encodeRefCkpt(RefCkpt{ + .life_epoch = store->liveWriterEpoch(), + .checkpoint_snapshot_id = std::nullopt, + .last_epoch_seal = std::nullopt})).outcome, PutOutcome::Done); + store->invalidateRemovedCatalogLife(predecessor_life); + backend->resetCounts(); + + { + std::lock_guard lock(mutex); + resume = true; + } + cv.notify_all(); + retry.join(); + store->setWedgeBeforeSlotOccupyHookForTest(nullptr); + + EXPECT_TRUE(retry_error); + EXPECT_EQ(backend->putCount(wedged_key), 0u) << "retirement must refuse before the retry send"; + EXPECT_EQ(backend->getCount(wedged_key), 0u) << "a refused retry needs no occupant resolution read"; + EXPECT_FALSE(backend->get(wedged_key)) << "the predecessor wedge was adopted or made durable"; + EXPECT_NO_THROW((void)store->listRefs(ns)); + ASSERT_TRUE(store->refTableLifeForTest(ns)); + EXPECT_EQ(*store->refTableLifeForTest(ns), successor_life); +} + +/// The other adoption input, and the one that proves the identity rule is about BYTES: our own +/// earlier attempt DID land (only its ack, and the controller's own resolve read, were lost). The +/// retry's create conflicts with our own object, the follow-up read returns bytes equal to the +/// wedge's, and the transaction is adopted -- ONCE, not once per attempt. +TEST(CASRefWedgeEveryAttempt, OwnLandedAttemptIsAdoptedFromOccupiedWithoutDoubleApply) +{ + auto backend = std::make_shared(); + /// Disarmed while the fixture is built: the one-shot fault matches ANY key until a substring is + /// set, and the pool's own bootstrap PUT would otherwise consume it. + backend->fired = true; + auto store = openPool(backend, singleAttemptBudget()); + const RootNamespace ns{"srv1/wedge_occupied_mine"}; + /// Stage B (Task 4-C): pin to the sentinel before the first real touch -- `logPrefix` below matches + /// its fault at that key. + admitProperlyBornEntry(*backend, store->layout(), ns); + publishEmptyPart(store, ns, "x"); + publishEmptyPart(store, ns, "y"); + + backend->key_substr = logPrefix(store, ns); + backend->lose_resolve_read = true; + backend->fired = false; /// armed: the next `_log/` PUT lands and loses its ack + + expectThrowsCode(DB::ErrorCodes::NETWORK_ERROR, [&] { store->dropRef(ns, "x"); }); + ASSERT_TRUE(store->refLaneWedgedForTest(ns)); + const String wedged_key = store->wedgedKeyForTest(ns); + ASSERT_TRUE(backend->get(wedged_key).has_value()) << "this fault LANDS the write; only the ack was lost"; + ASSERT_TRUE(store->resolveRef(ns, "x").has_value()) << "durable, but not applied while wedged"; + const size_t tail_before = store->tailSinceSnapshotCountForTest(ns); + const uint64_t puts_before = backend->putCount(wedged_key); + + EXPECT_NO_THROW(store->dropRef(ns, "y")); + + EXPECT_FALSE(store->refLaneWedgedForTest(ns)); + EXPECT_FALSE(store->resolveRef(ns, "x").has_value()) << "the landed transaction is adopted on resolution"; + EXPECT_EQ(store->tailSinceSnapshotCountForTest(ns), tail_before + 2) + << "adopted exactly once: a double-apply would bump the tail twice for one transaction"; + EXPECT_EQ(backend->putCount(wedged_key), puts_before + 1) + << "the resolution costs exactly ONE conditional create at the wedged key"; +} + +/// `ambiguous-then-definite`, the control the phase-0 model singles out: a definite refusal of a LATER +/// attempt says nothing about the EARLIER ambiguous one, which may still be in flight. The lane must +/// stay wedged -- unwedging here is how an acked-then-lost transaction gets written around. +TEST(CASRefWedgeEveryAttempt, DefiniteRefusalOfARetryAttemptKeepsTheLaneWedged) +{ + auto backend = std::make_shared(); + auto store = openPool(backend, singleAttemptBudget()); + const RootNamespace ns{"srv1/wedge_ambiguous_then_definite"}; + admitProperlyBornEntry(*backend, store->layout(), ns); /// Stage B (Task 4-C): pin to the sentinel before the first real touch + publishEmptyPart(store, ns, "x"); + publishEmptyPart(store, ns, "y"); + + backend->ambiguous_substr = logPrefix(store, ns); + backend->ambiguous_count = 1; + expectThrowsCode(DB::ErrorCodes::NETWORK_ERROR, [&] { store->dropRef(ns, "x"); }); + ASSERT_TRUE(store->refLaneWedgedForTest(ns)); + const String wedged_key = store->wedgedKeyForTest(ns); + const RefTxnId wedged_id = store->layout().parseRefObjectKey(wedged_key)->txn_id; + + /// The retry's own create is definitively refused. + backend->definite_substr = logPrefix(store, ns); + backend->definite_count = 1; + EXPECT_ANY_THROW(store->dropRef(ns, "y")); + + EXPECT_TRUE(store->refLaneWedgedForTest(ns)) << "a definite refusal AFTER an ambiguous attempt must not unwedge"; + EXPECT_EQ(store->wedgedKeyForTest(ns), wedged_key) << "the SAME wedge, not a fresh one"; + EXPECT_TRUE(store->resolveRef(ns, "x").has_value()) << "nothing was adopted"; + EXPECT_FALSE(backend->get(wedged_key).has_value()) << "and nothing became durable"; + EXPECT_EQ(store->laneStateForTest(ns), RefLaneState::Wedged) + << "a wedged lane's steady state is 'may be durable, not applied'"; + + /// Still the same id afterwards: the definite refusal consumed nothing. + backend->definite_count = 0; + EXPECT_NO_THROW(store->dropRef(ns, "y")); + EXPECT_EQ(store->layout().parseRefObjectKey( + store->layout().refLogKey(DB::Cas::tests::fixture::fixtureLife(ns), wedged_id))->txn_id, wedged_id); + EXPECT_FALSE(store->refLaneWedgedForTest(ns)) << "the create-based resolution still settles it afterwards"; +} + +/// The SAME rule one level down, and the level where it was actually broken. The test above splits the +/// two attempts across two CALLS, which the wedge already handles. Inside ONE call the controller used +/// to report the LAST attempt's outcome: an ambiguous attempt followed by a definitively refused reissue +/// came back `DefiniteFailure` -- the verdict that means "the key is provably unwritten". It is not. The +/// refusal proves only that the SECOND request never applied; the first may still be in flight and may +/// still land, and `unresolvedProvesNothingWasSent` is false for exactly that reason. So the CALL is +/// unresolved, and a definite verdict is only ever the whole call's. +/// +/// The new reason lands on the fail-close side of the predicate the ledger acts on. Asserted at compile +/// time, beside the behaviour, because a member added to the enum without classifying it is precisely +/// how the wedge would silently stop happening. +static_assert(!unresolvedProvesNothingWasSent(CasUnresolvedReason::DefiniteFailureAfterAmbiguity)); + +TEST(CASRefWedgeEveryAttempt, ADefiniteRefusalCannotSpeakForAnEarlierAmbiguousAttemptOfTheSameCall) +{ +#if !USE_AWS_S3 + GTEST_SKIP() << "DefiniteFailure classification requires S3 error types (USE_AWS_S3 off)"; +#else + auto backend = std::make_shared(); + CasRequestController controller(backend, twoAttemptBudget()); + const std::function fence_ok = [] { return true; }; + + /// One call, two attempts: ambiguous, then definitively refused. + backend->ambiguous_substr = "key/"; + backend->ambiguous_count = 1; + backend->s3_definite_substr = "key/"; + backend->s3_definite_count = 1; + + CasUnresolvedReason reason = CasUnresolvedReason::NotUnresolved; + const CasWriteOutcome outcome = + controller.putIfAbsentControlled("key/haunted", "bytes", fence_ok, /*out_token=*/nullptr, &reason); + + EXPECT_EQ(outcome, CasWriteOutcome::Unresolved) + << "a definite refusal of the SECOND attempt cannot retire the first attempt's ambiguity"; + EXPECT_EQ(reason, CasUnresolvedReason::DefiniteFailureAfterAmbiguity); + EXPECT_FALSE(unresolvedProvesNothingWasSent(reason)) + << "the caller must keep protecting itself: an earlier attempt was sent and may yet land"; + EXPECT_FALSE(backend->get("key/haunted").has_value()) + << "and the key is still empty -- which is exactly why an absent read settles nothing"; + + /// THE CONTROL. Aggregation must not soften a definite refusal that speaks for the whole call: with + /// no ambiguous predecessor, the first attempt's whitelisted rejection is still `DefiniteFailure`, + /// and the ledger may still free the id on it. + backend->s3_definite_count = 1; + CasUnresolvedReason clean_reason = CasUnresolvedReason::NotUnresolved; + EXPECT_EQ(controller.putIfAbsentControlled("key/clean", "bytes", fence_ok, /*out_token=*/nullptr, &clean_reason), + CasWriteOutcome::DefiniteFailure); + EXPECT_EQ(clean_reason, CasUnresolvedReason::NotUnresolved); +#endif +} + +/// The ledger-side twin of the same call: what the append lane does with that verdict. On +/// `DefiniteFailure` it returns the lane to `Ready` and tells callers the txn id was never used, +/// so the next append re-derives that id -- which, with an earlier attempt still possibly in flight, is +/// how an acked-then-lost transaction gets written around. The lane must wedge instead and stay pending +/// until the key itself resolves. +TEST(CASRefWedgeEveryAttempt, ADefiniteRefusalAfterAnAmbiguousAttemptOfTheSameCallStillWedgesTheLane) +{ +#if !USE_AWS_S3 + GTEST_SKIP() << "DefiniteFailure classification requires S3 error types (USE_AWS_S3 off)"; +#else + auto backend = std::make_shared(); + auto store = openPool(backend, twoAttemptBudget()); + const RootNamespace ns{"srv1/wedge_one_call_ambiguous_then_definite"}; + /// Stage B (Task 4-C): pin to the sentinel before the first real touch -- `logPrefix` below matches + /// its fault at that key. + admitProperlyBornEntry(*backend, store->layout(), ns); + publishEmptyPart(store, ns, "x"); + publishEmptyPart(store, ns, "y"); + + backend->ambiguous_substr = logPrefix(store, ns); + backend->ambiguous_count = 1; + backend->s3_definite_substr = logPrefix(store, ns); + backend->s3_definite_count = 1; + + const uint64_t wedged_before = ProfileEvents::global_counters[ProfileEvents::CASRefAppendWedged].load(); + const uint64_t definite_before = ProfileEvents::global_counters[ProfileEvents::CASRefAppendDefiniteFailure].load(); + + expectThrowsCode(DB::ErrorCodes::NETWORK_ERROR, [&] { store->dropRef(ns, "x"); }); + + EXPECT_TRUE(store->refLaneWedgedForTest(ns)) + << "one call whose first attempt is unresolved leaves an object that may become durable"; + EXPECT_EQ(store->laneStateForTest(ns), RefLaneState::Wedged) + << "the id must NOT be declared never-used: the marker stands until the key itself resolves"; + EXPECT_EQ(ProfileEvents::global_counters[ProfileEvents::CASRefAppendDefiniteFailure].load(), definite_before) + << "this append was never definitively rejected -- only one of its attempts was"; + EXPECT_EQ(ProfileEvents::global_counters[ProfileEvents::CASRefAppendWedged].load(), wedged_before + 1); + EXPECT_TRUE(store->resolveRef(ns, "x").has_value()) << "nothing is applied while the lane is wedged"; + const String wedged_key = store->wedgedKeyForTest(ns); + EXPECT_FALSE(backend->get(wedged_key).has_value()) << "and nothing became durable"; + + /// And it still recovers by the ordinary route: the next flush's bounded create lands the wedged + /// transaction and adopts it, so wedging costs availability only until the next caller arrives. + EXPECT_NO_THROW(store->dropRef(ns, "y")); + EXPECT_FALSE(store->refLaneWedgedForTest(ns)); + EXPECT_FALSE(store->resolveRef(ns, "x").has_value()) << "the adopted wedge applied its drop"; + EXPECT_FALSE(store->resolveRef(ns, "y").has_value()); + EXPECT_EQ(store->laneStateForTest(ns), RefLaneState::Ready); +#endif +} + +/// =================================================================================== +/// A successor's `EpochSeal` is the conclusive rejection (spec INV-2) +/// =================================================================================== + +/// The seal is the ONLY thing that can prove our transaction will never be durable: the key is +/// write-once and a successor put its epoch-closing record there. The operation was never acked, so +/// its callers get a permanent error; the wedge is cleared; and the seal becomes this namespace's +/// `prev_epoch_seal`, which the first append of the NEXT epoch carries on the wire. +TEST(CASRefWedgeEveryAttempt, SuccessorSealAtTheWedgedKeyRejectsConclusivelyAndSourcesPrevEpochSeal) +{ + auto backend = std::make_shared(); + auto store = openPool(backend, singleAttemptBudget()); + const Layout & layout = store->layout(); + const RootNamespace ns{"srv1/wedge_sealed"}; + admitProperlyBornEntry(*backend, store->layout(), ns); /// Stage B (Task 4-C): pin to the sentinel before the first real touch + publishEmptyPart(store, ns, "x"); + publishEmptyPart(store, ns, "y"); + + const uint64_t epoch = store->liveWriterEpoch(); + backend->ambiguous_substr = logPrefix(store, ns); + backend->ambiguous_count = 1; + expectThrowsCode(DB::ErrorCodes::NETWORK_ERROR, [&] { store->dropRef(ns, "x"); }); + ASSERT_TRUE(store->refLaneWedgedForTest(ns)); + const String wedged_key = store->wedgedKeyForTest(ns); + const RefTxnId seal_id = layout.parseRefObjectKey(wedged_key)->txn_id; + ASSERT_EQ(seal_id.writer_epoch, epoch); + ASSERT_GT(seal_id.ref_sequence, 1u) << "this namespace already has records, so its seal is not at sequence 1"; + ASSERT_EQ(store->lastEpochSealForTest(ns), std::nullopt) << "nothing has closed an epoch for this namespace yet"; + const size_t tail_before = store->tailSinceSnapshotCountForTest(ns); + + /// A successor closes our epoch at exactly the slot our attempt was aiming at. + ASSERT_EQ(backend->putAsSuccessor(wedged_key, epochSealBytes(ns, seal_id)).outcome, PutOutcome::Done); + + /// The next caller's resolution meets the seal. Its own items fail -- permanently, not "retry + /// later": nothing about this lane's epoch will ever accept a write again. + expectThrowsCode(DB::ErrorCodes::INVALID_STATE, [&] { store->dropRef(ns, "y"); }); + + EXPECT_FALSE(store->refLaneWedgedForTest(ns)) << "a conclusive rejection clears the wedge"; + EXPECT_TRUE(store->resolveRef(ns, "x").has_value()) << "the rejected transaction was never applied"; + EXPECT_EQ(store->tailSinceSnapshotCountForTest(ns), tail_before) << "and never joined the tail"; + EXPECT_EQ(store->laneStateForTest(ns), RefLaneState::Closed) + << "the successor seal closes this epoch's lane"; + ASSERT_EQ(store->lastEpochSealForTest(ns), std::make_optional(seal_id)) + << "the observed seal is this namespace's epoch-closing record"; + + /// INV-2's fence, stated as behaviour: a dying lane that observed the seal keeps deriving the SAME + /// `T+1` and keeps colliding with it -- it never mints `T+2` and writes its stream past the record + /// that closed its epoch. Ids are state-derived, so this falls out rather than being enforced. + /// + /// And the collision is adjudicated as the CONCLUSIVE REJECTION it is, not as foreign interference: + /// this is the designed path, so it must not fence the mount or raise an anomaly. The append site + /// reads the occupant and tells a seal of this namespace from a genuine breach, exactly as the + /// wedge-resolve site does. + const uint64_t remounts_before = store->scheduleRemountCallCountForTest(); + expectThrowsCode(DB::ErrorCodes::INVALID_STATE, [&] { store->dropRef(ns, "y"); }); + EXPECT_EQ(backend->get(layout.refLogKey(DB::Cas::tests::fixture::fixtureLife(ns), RefTxnId{epoch, seal_id.ref_sequence + 1})), std::nullopt) + << "nothing of ours may exist above the seal in the closed epoch"; + EXPECT_TRUE(store->mayMutate()) << "meeting a successor's seal is the protocol working, not an anomaly"; + EXPECT_EQ(store->scheduleRemountCallCountForTest(), remounts_before) + << "and must not schedule a remount"; + + /// Merely changing the epoch counters does not reopen a cached runtime. Its immutable admitted + /// generation is stale, so the outer retry-safe fence refusal wins before the still-Closed lane is + /// consulted. Production reaches a new epoch through remount, which replaces the runtime and + /// recovers its chain link. + bumpFenceGeneration(store, epoch + 1); + expectThrowsCode(DB::ErrorCodes::NETWORK_ERROR, [&] { store->dropRef(ns, "y"); }); + EXPECT_EQ(store->laneStateForTest(ns), RefLaneState::Closed); +} + +/// The wire round trip of the same rule, driven from the OTHER producer of `last_epoch_seal`: +/// recovery's CAS-walk (Task 6), stood in for here by its test seam. The point is the encode call +/// site, which is this task's. +TEST(CASRefWedgeEveryAttempt, OrdinaryFirstAppendAfterASealedTransitionCarriesTheExactPrevEpochSeal) +{ + auto backend = std::make_shared(); + auto store = openPool(backend); + const Layout & layout = store->layout(); + const RootNamespace ns{"srv1/prev_epoch_seal_roundtrip"}; + /// Stage B (Task 4-C): pin to the sentinel before the first real touch -- `readRefLogTxn` above + /// reads that exact key. + admitProperlyBornEntry(*backend, store->layout(), ns); + + const uint64_t epoch = store->liveWriterEpoch(); + publishEmptyPart(store, ns, "x"); + const RefTxnId seal_id{epoch, 42}; + + /// A recovery that walked the dead epoch installs the seal it wrote; model its later epoch without + /// moving the mount-fence generation. This test is about the wire link, not runtime supersession. + store->setLastEpochSealForTest(ns, seal_id); + store->setLiveWriterEpochForTest(epoch + 1); + + EXPECT_NO_THROW(store->dropRef(ns, "x")); + + const RefLogTxn written = readRefLogTxn(*backend, layout, ns, RefTxnId{epoch + 1, 1}); + EXPECT_EQ(written.prev_epoch_seal, std::make_optional(seal_id)); + + /// And it is carried on sequence 1 ONLY: the next transaction of the same epoch must not repeat it. + publishEmptyPart(store, ns, "z"); + const RefLogTxn second = readRefLogTxn(*backend, layout, ns, RefTxnId{epoch + 1, 2}); + EXPECT_EQ(second.prev_epoch_seal, std::nullopt) + << "prev_epoch_seal is required on sequence 1 of a non-genesis epoch and forbidden everywhere else"; +} + +/// GENESIS: `last_epoch_seal` is `nullopt` exactly for a namespace whose stream starts here, and a +/// genesis birth carries NO `prev_epoch_seal` even though its epoch is far above 1. Nothing about the +/// global epoch number makes a namespace non-genesis -- only a transition of its OWN stream does. +TEST(CASRefWedgeEveryAttempt, GenesisBirthAtAHighEpochCarriesNoPrevEpochSeal) +{ + auto backend = std::make_shared(); + auto store = openPool(backend); + const Layout & layout = store->layout(); + const RootNamespace ns{"srv1/genesis_at_five"}; + /// Stage B (Task 4-C): pin to the sentinel before the first real touch -- `readRefLogTxn` above + /// reads that exact key. + admitProperlyBornEntry(*backend, store->layout(), ns); + + bumpFenceGeneration(store, 5); + ASSERT_EQ(store->liveWriterEpoch(), 5u); + + publishEmptyPart(store, ns, "x"); + + EXPECT_EQ(store->lastEpochSealForTest(ns), std::nullopt) + << "a namespace with no recovered seal, whose greatest applied id is at its own life epoch, is genesis"; + const RefLogTxn birth = readRefLogTxn(*backend, layout, ns, RefTxnId{5, 1}); + EXPECT_EQ(birth.prev_epoch_seal, std::nullopt) << "a genesis stream opens; it does not continue one"; +} + +/// =================================================================================== +/// A foreign occupant is impossible, so it is loud -- and the mount self-heals +/// =================================================================================== + +/// Under mount-lease exclusivity the wedged key is exclusively ours, so a foreign non-seal object at +/// it is corruption or a protocol breach. Fail closed with `CORRUPTED_DATA`, KEEP the wedge for +/// inspection, and route the anomaly so the mount remounts itself rather than staying stuck until +/// someone notices. +TEST(CASRefWedgeEveryAttempt, ForeignNonSealOccupantIsCorruptedDataAndSchedulesARemount) +{ + auto backend = std::make_shared(); + auto store = openPool(backend, singleAttemptBudget()); + const RootNamespace ns{"srv1/wedge_foreign"}; + /// Stage B (Task 4-C): pin to the sentinel before the first real touch -- `logPrefix` below matches + /// its fault at that key. + admitProperlyBornEntry(*backend, store->layout(), ns); + publishEmptyPart(store, ns, "x"); + publishEmptyPart(store, ns, "y"); + + backend->ambiguous_substr = logPrefix(store, ns); + backend->ambiguous_count = 1; + expectThrowsCode(DB::ErrorCodes::NETWORK_ERROR, [&] { store->dropRef(ns, "x"); }); + ASSERT_TRUE(store->refLaneWedgedForTest(ns)); + const String wedged_key = store->wedgedKeyForTest(ns); + const uint64_t remounts_before = store->scheduleRemountCallCountForTest(); + + /// Something that is neither our bytes nor a seal occupies the slot. + ASSERT_EQ(backend->putAsSuccessor(wedged_key, "not a ref-log object at all").outcome, PutOutcome::Done); + + expectThrowsCode(DB::ErrorCodes::CORRUPTED_DATA, [&] { store->dropRef(ns, "y"); }); + + EXPECT_FALSE(store->refLaneWedgedForTest(ns)); + EXPECT_EQ(store->laneStateForTest(ns), RefLaneState::Faulted); + EXPECT_GT(store->scheduleRemountCallCountForTest(), remounts_before) + << "the impossible-interference route must schedule a remount"; +} + +/// I5, the OTHER site with the same shape: the ordinary append's own conditional create can prove a +/// different object sits at the id it derived. Task 3 made that fail closed -- correctly -- but it +/// left the mount stuck there until a manual remount, unlike the wedge-resolution site. Both are the +/// same impossibility and both must self-heal by remount. +TEST(CASRefWedgeEveryAttempt, AppendSiteProvenDifferentObjectAlsoSchedulesARemount) +{ + auto backend = std::make_shared(); + auto store = openPool(backend); + const Layout & layout = store->layout(); + const RootNamespace ns{"srv1/append_site_foreign"}; + admitProperlyBornEntry(*backend, store->layout(), ns); /// Stage B (Task 4-C): pin to the sentinel before the first real touch + publishEmptyPart(store, ns, "x"); + + /// Occupy the id the next append will derive with a foreign object, so its create conflicts and + /// the controller's resolve-before-reissue proves the occupant is not ours. + const RefTxnId next{store->liveWriterEpoch(), 3}; + ASSERT_EQ(backend->putIfAbsent(layout.refLogKey(DB::Cas::tests::fixture::fixtureLife(ns), next), + "a different object entirely").outcome, PutOutcome::Done); + const uint64_t remounts_before = store->scheduleRemountCallCountForTest(); + + expectThrowsCode(DB::ErrorCodes::CORRUPTED_DATA, [&] { store->dropRef(ns, "x"); }); + + EXPECT_FALSE(store->refLaneWedgedForTest(ns)) << "a proven different object is conclusive, never a wedge"; + EXPECT_GT(store->scheduleRemountCallCountForTest(), remounts_before) + << "the append site must route through the same impossible-interference reaction as the wedge site"; +} + +/// =================================================================================== +/// The admission fence +/// =================================================================================== + +/// The old-generation-retry-inert rule. A wedge admitted under one mount incarnation may not send an +/// attempt under another: the retry is refused BEFORE anything reaches the store, so the key is +/// provably untouched and the wedge is intact for whoever recovers the lane properly. +TEST(CASRefWedgeEveryAttempt, RetryUnderAnOlderAdmissionGenerationSendsNothing) +{ + auto backend = std::make_shared(); + auto store = openPool(backend, singleAttemptBudget()); + const RootNamespace ns{"srv1/wedge_old_generation"}; + /// Stage B (Task 4-C): pin to the sentinel before the first real touch -- `logPrefix` below matches + /// its fault at that key. + admitProperlyBornEntry(*backend, store->layout(), ns); + publishEmptyPart(store, ns, "x"); + publishEmptyPart(store, ns, "y"); + + const uint64_t epoch = store->liveWriterEpoch(); + backend->ambiguous_substr = logPrefix(store, ns); + backend->ambiguous_count = 1; + expectThrowsCode(DB::ErrorCodes::NETWORK_ERROR, [&] { store->dropRef(ns, "x"); }); + ASSERT_TRUE(store->refLaneWedgedForTest(ns)); + const String wedged_key = store->wedgedKeyForTest(ns); + ASSERT_EQ(store->wedgedAdmittedGenerationForTest(ns), store->fenceGeneration()) + << "the wedge records the generation it was admitted under"; + const uint64_t puts_before = backend->putCount(wedged_key); + + /// The lease incarnation moves under the wedge; the mount is writable again, but not the same one. + bumpFenceGeneration(store, epoch); + ASSERT_NE(store->wedgedAdmittedGenerationForTest(ns), store->fenceGeneration()); + + EXPECT_ANY_THROW(store->dropRef(ns, "y")); + + EXPECT_EQ(backend->putCount(wedged_key), puts_before) + << "the retry must be refused pre-attempt: nothing may reach the store under a foreign generation"; + EXPECT_TRUE(store->refLaneWedgedForTest(ns)) << "and the wedge is untouched"; + EXPECT_FALSE(backend->get(wedged_key).has_value()); +} + +/// The post-I/O recheck, deterministically. The retry's create is parked mid-flight; while it is +/// parked the fence is lost and re-armed AND a successor seals the slot. The released result is a +/// perfectly real `Occupied`(seal) -- but it belongs to an incarnation that no longer exists, so this +/// runtime must act on NOTHING: no acknowledgement, no unwedge, no install, and no adoption of the +/// seal it just read. +TEST(CASRefWedgeEveryAttempt, ResultReleasedAfterAFenceBumpAndSuccessorSealIsInert) +{ + auto backend = std::make_shared(); + auto store = openPool(backend, singleAttemptBudget()); + const Layout & layout = store->layout(); + const RootNamespace ns{"srv1/wedge_blocked_io"}; + /// Stage B (Task 4-C): pin to the sentinel before the first real touch -- `logPrefix` below matches + /// its fault at that key. + admitProperlyBornEntry(*backend, store->layout(), ns); + publishEmptyPart(store, ns, "x"); + publishEmptyPart(store, ns, "y"); + + const uint64_t epoch = store->liveWriterEpoch(); + backend->ambiguous_substr = logPrefix(store, ns); + backend->ambiguous_count = 1; + expectThrowsCode(DB::ErrorCodes::NETWORK_ERROR, [&] { store->dropRef(ns, "x"); }); + ASSERT_TRUE(store->refLaneWedgedForTest(ns)); + const String wedged_key = store->wedgedKeyForTest(ns); + const RefTxnId seal_id = layout.parseRefObjectKey(wedged_key)->txn_id; + const size_t tail_before = store->tailSinceSnapshotCountForTest(ns); + + backend->armBlock(logPrefix(store, ns)); + std::exception_ptr caller_error; + std::thread resolver([&] + { + try { store->dropRef(ns, "y"); } + catch (...) { caller_error = std::current_exception(); } + }); + backend->awaitBlockEntered(); + + /// Everything that makes this runtime superseded happens INSIDE the I/O window. + ASSERT_EQ(backend->putAsSuccessor(wedged_key, epochSealBytes(ns, seal_id)).outcome, PutOutcome::Done); + bumpFenceGeneration(store, epoch + 1); + backend->releaseBlock(); + resolver.join(); + + ASSERT_TRUE(caller_error != nullptr) << "no acknowledgement: the caller must not be told this succeeded"; + /// And it must be the RETRY-SAFE class. A moved incarnation is usually a routine lease blip, and the + /// storage layer classifies retry-safety on exactly `ABORTED || NETWORK_ERROR` — surfacing the fence + /// check's own `INVALID_STATE` here would turn every blip into a hard failure for the caller. + expectThrowsCode(DB::ErrorCodes::NETWORK_ERROR, [&] { std::rethrow_exception(caller_error); }); + EXPECT_TRUE(store->refLaneWedgedForTest(ns)) << "no unwedge"; + EXPECT_EQ(store->tailSinceSnapshotCountForTest(ns), tail_before) << "no install"; + EXPECT_EQ(store->lastEpochSealForTest(ns), std::nullopt) + << "and no adoption of the seal a superseded runtime happened to read"; +} + +/// The same recheck, on the identity leg rather than the generation leg. The fence never moves; only +/// the installed wedge's BYTES change while the create is parked. Generation-equality alone would let +/// the released result install a candidate built from the OTHER attempt's transaction -- the aliasing +/// bug the phase-0 model found, which is why identity is (generation, id, bytes) and not any one of +/// them. Production cannot reach this (one leader per table mutates a lane), so this is a white-box +/// guard on the rule, driven through the force-wedge seam. +TEST(CASRefWedgeEveryAttempt, ResultReleasedAfterTheWedgeIdentityChangedIsInert) +{ + auto backend = std::make_shared(); + auto store = openPool(backend, singleAttemptBudget()); + const RootNamespace ns{"srv1/wedge_identity_changed"}; + /// Stage B (Task 4-C): pin to the sentinel before the first real touch -- `logPrefix` below matches + /// its fault at that key. + admitProperlyBornEntry(*backend, store->layout(), ns); + publishEmptyPart(store, ns, "x"); + publishEmptyPart(store, ns, "y"); + + backend->ambiguous_substr = logPrefix(store, ns); + backend->ambiguous_count = 1; + expectThrowsCode(DB::ErrorCodes::NETWORK_ERROR, [&] { store->dropRef(ns, "x"); }); + ASSERT_TRUE(store->refLaneWedgedForTest(ns)); + const String wedged_key = store->wedgedKeyForTest(ns); + const RefTxnId wedged_id = store->layout().parseRefObjectKey(wedged_key)->txn_id; + const size_t tail_before = store->tailSinceSnapshotCountForTest(ns); + + backend->armBlock(logPrefix(store, ns)); + std::exception_ptr caller_error; + std::thread resolver([&] + { + try { store->dropRef(ns, "y"); } + catch (...) { caller_error = std::current_exception(); } + }); + backend->awaitBlockEntered(); + + /// Same id, same generation, DIFFERENT bytes. + store->forceWedgeForTest(ns, wedged_id.writer_epoch, wedged_id.ref_sequence, wedged_key, "different attempt bytes"); + backend->releaseBlock(); + resolver.join(); + + ASSERT_TRUE(caller_error != nullptr); + EXPECT_TRUE(store->refLaneWedgedForTest(ns)) << "no unwedge"; + EXPECT_EQ(store->tailSinceSnapshotCountForTest(ns), tail_before) + << "no install: the released result described a wedge that is no longer installed"; + EXPECT_TRUE(store->resolveRef(ns, "x").has_value()); +} + +/// A resolution that proves the exact attempt durable but cannot install it has one successor: +/// `NeedsRecovery`. It drops the attempt and forbids another write until replay catches the cache up. +TEST(CASRefWedgeEveryAttempt, KnownDurableInstallFailureMovesDirectlyToRecovery) +{ + auto backend = std::make_shared(); + backend->fired = true; /// disarmed while the fixture is built (see the adoption test above) + auto store = openPool(backend, singleAttemptBudget()); + const RootNamespace ns{"srv1/wedge_floor"}; + /// Stage B (Task 4-C): pin to the sentinel before the first real touch -- `logPrefix` below matches + /// its fault at that key. + admitProperlyBornEntry(*backend, store->layout(), ns); + publishEmptyPart(store, ns, "x"); + publishEmptyPart(store, ns, "y"); + + backend->key_substr = logPrefix(store, ns); + backend->lose_resolve_read = true; + backend->fired = false; + expectThrowsCode(DB::ErrorCodes::NETWORK_ERROR, [&] { store->dropRef(ns, "x"); }); + ASSERT_TRUE(store->refLaneWedgedForTest(ns)); + const String wedged_key = store->wedgedKeyForTest(ns); + ASSERT_TRUE(backend->get(wedged_key).has_value()) << "the wedged transaction is durable"; + /// The adoption reaches its install region and the install throws. + armOneShotInstallFailure(store); + expectThrowsCode(DB::ErrorCodes::MEMORY_LIMIT_EXCEEDED, [&] { store->dropRef(ns, "y"); }); + store->setInstallRegionProbeForTest(nullptr); + ASSERT_EQ(store->laneStateForTest(ns), RefLaneState::NeedsRecovery); + ASSERT_FALSE(store->refLaneWedgedForTest(ns)) + << "known durability transfers ownership to recovery; no uncertain attempt remains"; + /// Do not call the tail-count seam here: it intentionally forces recovery, which is the transition + /// this assertion is proving has not happened yet. + + /// The next flush first replays the durable drop of `x`, then admits the drop of `y`. + EXPECT_NO_THROW(store->dropRef(ns, "y")); + EXPECT_FALSE(store->resolveRef(ns, "x").has_value()) + << "replay must install the already-durable drop of `x` before the next write"; + EXPECT_FALSE(store->resolveRef(ns, "y").has_value()); + EXPECT_EQ(store->laneStateForTest(ns), RefLaneState::Ready) + << "only completed replay returns the lane to Ready"; +} + +/// =================================================================================== +/// The append site owes the SAME three-way adjudication as the wedge site +/// =================================================================================== + +/// No wedge is involved here at all: an ordinary append derives its next id and finds a successor's +/// epoch seal sitting on it. That is not interference — it is INV-2's designed outcome for a lane that +/// has been deposed without being told, and the lane will keep re-deriving that same id forever. So it +/// must be adjudicated as the conclusive rejection it is: a permanent error for the callers, the seal +/// recorded as this namespace's epoch-closing record, and NO fence and NO remount. +TEST(CASRefWedgeEveryAttempt, AppendSiteMeetingASuccessorSealIsAConclusiveRejectionNotInterference) +{ + auto backend = std::make_shared(); + auto store = openPool(backend); + const Layout & layout = store->layout(); + const RootNamespace ns{"srv1/append_site_seal"}; + admitProperlyBornEntry(*backend, store->layout(), ns); /// Stage B (Task 4-C): pin to the sentinel before the first real touch + publishEmptyPart(store, ns, "x"); + + const uint64_t epoch = store->liveWriterEpoch(); + const RefTxnId next{epoch, 3}; + ASSERT_EQ(store->lastEpochSealForTest(ns), std::nullopt); + const uint64_t remounts_before = store->scheduleRemountCallCountForTest(); + const uint64_t sealed_before = ProfileEvents::global_counters[ProfileEvents::CASRefAppendSealRejected].load(); + + /// The successor's seal lands at exactly the id this table's next append derives. + backend->conflict_substr = layout.refLogKey(DB::Cas::tests::fixture::fixtureLife(ns), next); + backend->conflict_bytes = epochSealBytes(ns, next); + backend->conflict_count = 1; + + expectThrowsCode(DB::ErrorCodes::INVALID_STATE, [&] { store->dropRef(ns, "x"); }); + + EXPECT_FALSE(store->refLaneWedgedForTest(ns)) << "a conclusive rejection is not an uncertain outcome"; + EXPECT_TRUE(store->resolveRef(ns, "x").has_value()) << "the rejected transaction never applied"; + EXPECT_EQ(store->lastEpochSealForTest(ns), std::make_optional(next)) + << "the observed seal IS this namespace's epoch-closing record, whichever site observed it"; + EXPECT_TRUE(store->mayMutate()) << "the designed path must not fence the mount"; + EXPECT_EQ(store->scheduleRemountCallCountForTest(), remounts_before) << "nor schedule a remount"; + EXPECT_EQ(ProfileEvents::global_counters[ProfileEvents::CASRefAppendSealRejected].load(), sealed_before + 1) + << "a deposed writer must still be COUNTED: this is the protocol working, and also the signal " + "that this mount has lost its lease and does not know it"; +} + +/// [CKPT-FAILED-BIRTH-DEBRIS] REVERSED (increment review Critical B; BACKLOG `{#ckpt-failed-birth-debris}` +/// reopened, `{#ckpt-neverborn-gc-backstop}` filed). This test used to pin the OPPOSITE of what it now +/// asserts: Task 3's `cleanupOrphanedBirthCkptBestEffort` deleted `_ckpt` here by a FRESH `head()` read +/// at cleanup time, not a token captured from this attempt's own publish, and every branch that called it +/// -- this one included -- had just PROVEN a different object occupies the derived key, directly +/// contradicting the "reachable only while the ref-log has never durably held anything" argument that +/// made the delete look safe. A successor that legitimately owns the same live incarnation (an ordinary +/// INV-2 epoch-seal handoff, e.g. after a remount) may already have read this SAME `_ckpt` for its own +/// recovery before this cleanup could run, and the delete could destroy the one genesis record +/// (`life_epoch`) that successor's own future recovery still needs, with no way to tell that case apart +/// from ordinary debris at cleanup time. The cleanup was removed entirely rather than patched (see +/// `CasRefLedger.cpp`'s comment at the removed call sites for why a captured token does not close the +/// gap either). The trade, named rather than hidden: a creation `_ckpt` whose first ref-log +/// `NamespaceBirth` is conclusively rejected now SURVIVES -- a drained server root carrying it will refuse +/// decommission (`claimOwnerOrThrow` -> `CORRUPTED_DATA`) until `{#ckpt-neverborn-gc-backstop}` lands -- +/// which is the right side of the trade against an unrecoverable delete of a live successor's only +/// genesis record. +TEST(CASRefWedgeEveryAttempt, CreationCkptSurvivesAConclusiveFirstRefLogRejection) +{ + auto backend = std::make_shared(); + auto store = openPool(backend); + const Layout & layout = store->layout(); + const RootNamespace ns{"srv1/birth_ckpt_debris"}; + admitProperlyBornEntry(*backend, store->layout(), ns); /// Stage B (Task 4-C): pin to the sentinel before the first real touch + + const RefTxnId genesis{store->liveWriterEpoch(), 1}; + const String ckpt_key = layout.refCkptKey(DB::Cas::tests::fixture::fixtureLife(ns)); + const auto ckpt_before = backend->get(ckpt_key); + ASSERT_TRUE(ckpt_before.has_value()) << "the fixture's creation checkpoint must exist before the first ref-log attempt"; + + /// A successor's epoch seal lands at exactly the id this first `NamespaceBirth` transaction derives. + backend->conflict_substr = layout.refLogKey(DB::Cas::tests::fixture::fixtureLife(ns), genesis); + backend->conflict_bytes = epochSealBytes(ns, genesis); + backend->conflict_count = 1; + + expectThrowsCode(DB::ErrorCodes::INVALID_STATE, [&] { publishEmptyPart(store, ns, "x"); }); + + const auto ckpt_after = backend->get(ckpt_key); + ASSERT_TRUE(ckpt_after.has_value()); + EXPECT_EQ(ckpt_after->bytes, ckpt_before->bytes) + << "the creation checkpoint must survive a conclusively rejected first ref-log PUT unchanged"; +} + +/// A creation `_ckpt` belonging to a `Live` namespace must survive no matter how a LATER transaction +/// on that same namespace fails -- true unconditionally now that increment review Critical B removed +/// the only code that ever deleted `_ckpt` on this path at all, but kept as its own pin: the fixture +/// has already made `ns` `Live`; then two initial ref-log chunks (precommit-add, then promote) and a +/// THIRD chunk meet the +/// identical successor-seal conflict the test above exercises -- same conclusive rejection -- and the +/// creation `_ckpt` must survive it byte-for-byte. `_ckpt` has no repair +/// path (BACKLOG `{#ckpt-damage-no-repair-path}`), so this is the row that would catch a future +/// reintroduction of the removed cleanup landing back on an already-Live namespace's `_ckpt`. +TEST(CASRefWedgeEveryAttempt, CreationCkptSurvivesALaterConclusiveRejection) +{ + auto backend = std::make_shared(); + auto store = openPool(backend); + const Layout & layout = store->layout(); + const RootNamespace ns{"srv1/birth_ckpt_survives_live"}; + admitProperlyBornEntry(*backend, store->layout(), ns); /// Stage B (Task 4-C): pin to the sentinel before the first real touch + /// ONE `publishEmptyPart` reaches sequence 2 (the precommit-add chunk at seq 1 carries the first + /// `NamespaceBirth`, the promote chunk lands at seq 2), so `next` + /// below is the SAME `{epoch, 3}` the sibling `AppendSiteMeetingASuccessorSealIsAConclusiveRejectionNotInterference` + /// test derives from the identical one-call setup -- copying THAT test's two-call variant here + /// (from a different test in this file) would derive a different id and never trigger the conflict. + publishEmptyPart(store, ns, "x"); + + const String ckpt_key = layout.refCkptKey(DB::Cas::tests::fixture::fixtureLife(ns)); + const auto ckpt_before = backend->get(ckpt_key); + ASSERT_TRUE(ckpt_before.has_value()) << "the fixture's creation step must have published a real _ckpt"; + + const RefTxnId next{store->liveWriterEpoch(), 3}; + backend->conflict_substr = layout.refLogKey(DB::Cas::tests::fixture::fixtureLife(ns), next); + backend->conflict_bytes = epochSealBytes(ns, next); + backend->conflict_count = 1; + + expectThrowsCode(DB::ErrorCodes::INVALID_STATE, [&] { store->dropRef(ns, "x"); }); + + const auto ckpt_after = backend->get(ckpt_key); + ASSERT_TRUE(ckpt_after.has_value()) << "a Live namespace's _ckpt must never be deleted by this path"; + EXPECT_EQ(ckpt_after->bytes, ckpt_before->bytes) + << "not merely present but UNCHANGED -- no code anywhere on this path deletes _ckpt any more " + "(increment review Critical B), so it must not have been touched at all"; +} + +/// THE OTHER negative row (review C2): the AMBIGUOUS branch -- `Writing` -> `Wedged` -- is deliberately +/// EXCLUDED from the cleanup call (see the lambda's own comment), and that exclusion is the +/// load-bearing half of the whole safety story: it is the one branch where the ref-log bytes MIGHT +/// still have landed. Nothing pinned that exclusion before this row; an edit that added the call here +/// would be caught by no test. A one-shot ambiguous PUT on the first ref-log `NamespaceBirth` +/// (`prepared->birth_contribution` set) writes NOTHING (the response is lost, the key +/// stays absent) and wedges the lane -- `AmbiguousPutWedgesTheLaneAndTheNextFlushsCreateAdoptsItExactlyOnce` +/// is the precedent this mirrors, adapted to a namespace's FIRST-ever transaction instead of its third. +TEST(CASRefWedgeEveryAttempt, CreationCkptSurvivesWhenTheFirstNamespaceBirthIsAmbiguous) +{ + auto backend = std::make_shared(); + auto store = openPool(backend, singleAttemptBudget()); + const Layout & layout = store->layout(); + const RootNamespace ns{"srv1/birth_ckpt_ambiguous"}; + admitProperlyBornEntry(*backend, store->layout(), ns); /// Stage B (Task 4-C): pin to the sentinel before the first real touch + const String ckpt_key = layout.refCkptKey(DB::Cas::tests::fixture::fixtureLife(ns)); + const auto ckpt_before = backend->get(ckpt_key); + ASSERT_TRUE(ckpt_before.has_value()) << "the fixture's creation checkpoint must exist before the first ref-log attempt"; + + backend->ambiguous_substr = logPrefix(store, ns); + backend->ambiguous_count = 1; + + expectThrowsCode(DB::ErrorCodes::NETWORK_ERROR, [&] { publishEmptyPart(store, ns, "x"); }); + + ASSERT_TRUE(store->refLaneWedgedForTest(ns)) << "an ambiguous outcome must WEDGE the lane, not " + "resolve into one of the conclusive branches"; + const auto ckpt_after = backend->get(ckpt_key); + ASSERT_TRUE(ckpt_after.has_value()); + EXPECT_EQ(ckpt_after->bytes, ckpt_before->bytes) + << "the creation checkpoint must survive an ambiguous first ref-log outcome unchanged"; +} + +/// Final review F5: the two other removed call sites, given their own first-`NamespaceBirth` survival rows. +/// The reversed test above pins the `SuccessorSeal` branch; the sibling below it pins the ambiguous +/// branch (never called it in the first place). The remaining two -- occupant-unreadable +/// (`CORRUPTED_DATA` from a failed adjudication read) and genuine foreign interference -- had no +/// first-`NamespaceBirth` row at all: `AppendSiteFaultsWhenTheOccupantCannotBeRead`, +/// `ForeignNonSealOccupantIsCorruptedDataAndSchedulesARemount`, and `WellFormedNonSealOccupantIsStillForeign` +/// all `publishEmptyPart` FIRST, so none of them ever carries a `birth_contribution` -- a reinstated +/// GUARDED cleanup at either of these two sites would pass the whole suite with no first-transaction case to +/// catch it. Mirrors `WellFormedNonSealOccupantIsStillForeign`'s occupant shape (a decodable, well-formed +/// NON-seal transaction at the derived key), moved to sequence 1 of a namespace with no prior ref-log +/// transaction, so this attempt's own PUT is its first. +TEST(CASRefWedgeEveryAttempt, CreationCkptSurvivesWhenTheFirstNamespaceBirthOccupantCannotBeRead) +{ + auto backend = std::make_shared(); + auto store = openPool(backend); + const Layout & layout = store->layout(); + const RootNamespace ns{"srv1/birth_ckpt_occupant_unreadable"}; + admitProperlyBornEntry(*backend, store->layout(), ns); + + const RefTxnId genesis{store->liveWriterEpoch(), 1}; + const String ckpt_key = layout.refCkptKey(DB::Cas::tests::fixture::fixtureLife(ns)); + const auto ckpt_before = backend->get(ckpt_key); + ASSERT_TRUE(ckpt_before.has_value()) << "the fixture's creation checkpoint must exist before the first ref-log attempt"; + + backend->conflict_substr = layout.refLogKey(DB::Cas::tests::fixture::fixtureLife(ns), genesis); + backend->conflict_bytes = epochSealBytes(ns, genesis); + backend->conflict_count = 1; + /// Proper birth makes recovery first probe this absent log key. Skip that probe and the resolve + /// read that PROVES the conflict; fail only the adjudication read after it, so the occupant's + /// identity (seal vs. breach) cannot be determined. + backend->fail_get_substr = layout.refLogKey(DB::Cas::tests::fixture::fixtureLife(ns), genesis); + backend->fail_get_skip = 2; + backend->fail_get_count = 1; + + expectThrowsCode(DB::ErrorCodes::CORRUPTED_DATA, [&] { publishEmptyPart(store, ns, "x"); }); + + const auto ckpt_after = backend->get(ckpt_key); + ASSERT_TRUE(ckpt_after.has_value()); + EXPECT_EQ(ckpt_after->bytes, ckpt_before->bytes) + << "the creation checkpoint must survive an occupant-unreadable first ref-log outcome unchanged"; +} + +/// The other former call site: a genuine breach of write-exclusivity at the first `NamespaceBirth` +/// id, mirroring `WellFormedNonSealOccupantIsStillForeign`'s occupant shape but with no prior ref-log publish. +TEST(CASRefWedgeEveryAttempt, CreationCkptSurvivesFirstNamespaceBirthForeignInterference) +{ + auto backend = std::make_shared(); + auto store = openPool(backend); + const Layout & layout = store->layout(); + const RootNamespace ns{"srv1/birth_ckpt_foreign_interference"}; + admitProperlyBornEntry(*backend, store->layout(), ns); + + const RefTxnId genesis{store->liveWriterEpoch(), 1}; + const String ckpt_key = layout.refCkptKey(DB::Cas::tests::fixture::fixtureLife(ns)); + const auto ckpt_before = backend->get(ckpt_key); + ASSERT_TRUE(ckpt_before.has_value()) << "the fixture's creation checkpoint must exist before the first ref-log attempt"; + + /// A perfectly decodable transaction for this exact namespace and id -- just not an epoch seal, and + /// not this attempt's own birth. + RefOp birth; + birth.kind = RefOpKind::NamespaceBirth; + const RefLogTxn foreign_txn{ns.string(), genesis, {birth}, std::nullopt}; + backend->conflict_substr = layout.refLogKey(DB::Cas::tests::fixture::fixtureLife(ns), genesis); + backend->conflict_bytes = sealObject(FormatId::RefLog, encodeRefLogTxn(foreign_txn)); + backend->conflict_count = 1; + + expectThrowsCode(DB::ErrorCodes::CORRUPTED_DATA, [&] { publishEmptyPart(store, ns, "x"); }); + + const auto ckpt_after = backend->get(ckpt_key); + ASSERT_TRUE(ckpt_after.has_value()); + EXPECT_EQ(ckpt_after->bytes, ckpt_before->bytes) + << "the creation checkpoint must survive a foreign-interference first ref-log outcome unchanged"; +} + +/// The same conflict, but the read that would tell a seal from a breach fails. We must then decide +/// NEITHER: fencing the mount would be a guess, and reporting a conclusive rejection would acknowledge +/// a deposition nobody observed. The id is not consumed, so the next attempt re-derives it and +/// classifies again — deferring costs one round trip and decides nothing wrongly. +TEST(CASRefWedgeEveryAttempt, AppendSiteFaultsWhenTheOccupantCannotBeRead) +{ + auto backend = std::make_shared(); + auto store = openPool(backend); + const Layout & layout = store->layout(); + const RootNamespace ns{"srv1/append_site_unreadable"}; + admitProperlyBornEntry(*backend, store->layout(), ns); /// Stage B (Task 4-C): pin to the sentinel before the first real touch + publishEmptyPart(store, ns, "x"); + + const RefTxnId next{store->liveWriterEpoch(), 3}; + const uint64_t remounts_before = store->scheduleRemountCallCountForTest(); + + backend->conflict_substr = layout.refLogKey(DB::Cas::tests::fixture::fixtureLife(ns), next); + backend->conflict_bytes = epochSealBytes(ns, next); + backend->conflict_count = 1; + /// Skip the resolve read that PROVES the conflict; fail only the adjudication read after it. + backend->fail_get_substr = layout.refLogKey(DB::Cas::tests::fixture::fixtureLife(ns), next); + backend->fail_get_skip = 1; + backend->fail_get_count = 1; + + const uint64_t deferred_before = ProfileEvents::global_counters[ProfileEvents::CASRefAppendOccupantUnreadable].load(); + expectThrowsCode(DB::ErrorCodes::CORRUPTED_DATA, [&] { store->dropRef(ns, "x"); }); + + EXPECT_EQ(ProfileEvents::global_counters[ProfileEvents::CASRefAppendOccupantUnreadable].load(), deferred_before + 1) + << "the deferral is the one quiet arm here -- it must be counted or a starved loud path is invisible"; + EXPECT_TRUE(store->mayMutate()) << "the table faults without guessing that the whole mount is corrupt"; + EXPECT_EQ(store->scheduleRemountCallCountForTest(), remounts_before) << "nor schedule a remount"; + EXPECT_EQ(store->lastEpochSealForTest(ns), std::nullopt) + << "nor record a deposition that was never actually observed"; + EXPECT_FALSE(store->refLaneWedgedForTest(ns)) << "nothing of ours became durable, so nothing is wedged"; + EXPECT_EQ(store->laneStateForTest(ns), RefLaneState::Faulted); + expectThrowsCode(DB::ErrorCodes::INVALID_STATE, [&] { store->dropRef(ns, "x"); }); +} + +/// A WELL-FORMED ref-log transaction of this namespace at this id, which simply is not a seal, must be +/// adjudicated `Foreign` on CONTENT — not because it failed to decode. The sibling test above reaches +/// the same verdict through an undecodable body, so without this one the classifier could be deciding +/// "foreign" purely from decode failures and nothing would notice. +TEST(CASRefWedgeEveryAttempt, WellFormedNonSealOccupantIsStillForeign) +{ + auto backend = std::make_shared(); + auto store = openPool(backend); + const Layout & layout = store->layout(); + const RootNamespace ns{"srv1/append_site_wellformed_foreign"}; + admitProperlyBornEntry(*backend, store->layout(), ns); /// Stage B (Task 4-C): pin to the sentinel before the first real touch + publishEmptyPart(store, ns, "x"); + + const RefTxnId next{store->liveWriterEpoch(), 3}; + const uint64_t remounts_before = store->scheduleRemountCallCountForTest(); + + /// A perfectly decodable transaction for this exact namespace and id — just not an epoch seal. + RefOp birth; + birth.kind = RefOpKind::NamespaceBirth; + const RefLogTxn foreign_txn{ns.string(), next, {birth}, std::nullopt}; + backend->conflict_substr = layout.refLogKey(DB::Cas::tests::fixture::fixtureLife(ns), next); + backend->conflict_bytes = sealObject(FormatId::RefLog, encodeRefLogTxn(foreign_txn)); + backend->conflict_count = 1; + + expectThrowsCode(DB::ErrorCodes::CORRUPTED_DATA, [&] { store->dropRef(ns, "x"); }); + + EXPECT_GT(store->scheduleRemountCallCountForTest(), remounts_before) + << "a well-formed non-seal occupant is still a breach of write-exclusivity"; + EXPECT_EQ(store->lastEpochSealForTest(ns), std::nullopt) << "and is emphatically not an epoch seal"; +} + +/// The deposed-lane self-pointer. A successor that seals an EMPTY epoch writes its record at sequence 1 +/// of that epoch, and a lane still live there re-derives exactly that id. Stamping the seal as its own +/// `prev_epoch_seal` would be a self-pointer, which the structural grammar (strictly-less by +/// construction) refuses at ENCODE — so the lane would fail with a self-inflicted `CORRUPTED_DATA` on +/// every attempt and never reach the seal collision that is supposed to fence it. The stamp is +/// therefore conditioned on the seal's epoch being strictly BELOW the id's. +TEST(CASRefWedgeEveryAttempt, ALiveEpochSealIsNeverStampedAsItsOwnPrevEpochSeal) +{ + auto backend = std::make_shared(); + auto store = openPool(backend); + const Layout & layout = store->layout(); + const RootNamespace ns{"srv1/live_epoch_seal"}; + admitProperlyBornEntry(*backend, store->layout(), ns); /// Stage B (Task 4-C): pin to the sentinel before the first real touch + publishEmptyPart(store, ns, "x"); + + const uint64_t epoch = store->liveWriterEpoch(); + /// Keep this a local wire/encoder test: moving the mount-fence generation would correctly make the + /// immutable runtime stale before the self-pointer guard was reached. + store->setLiveWriterEpochForTest(epoch + 1); + /// A seal of the LIVE epoch — the deposed-lane shape the wedge rejection arm can record. + store->setLastEpochSealForTest(ns, RefTxnId{epoch + 1, 7}); + + /// The lane now holds NOTHING it can legally write. Its next id is sequence 1 of the new epoch, which + /// owes a link to the seal that closed the epoch BELOW -- and the only seal it has is of the epoch it + /// is trying to open. Stamping that one would be a self-pointer the ENCODER refuses; stamping nothing + /// leaves a crossing the READER refuses. So the append fails closed, locally, before anything is sent. + /// + /// That is a strictly better outcome than the one this test originally pinned (stamp nothing, send, + /// and let the successor's seal reject the attempt at the key): the deposed lane spends no request to + /// learn what it can already prove about itself. The property the test exists for is unchanged and is + /// asserted below in its strongest form -- NO object is written at that id at all, so no self-pointer + /// can have been stamped anywhere. + /// The refusal must reach the SAME TERMINAL OUTCOME the collision produced, not merely "an error". + /// Skipping the request must not skip the conclusion, so all four halves are pinned: + /// + /// 1. the class is INVALID_STATE -- the conclusive-rejection class the successor-seal arm uses, + /// NOT the retry-later class. This is the one that matters most: a retryable error here would + /// have every caller re-derive the same impossible transaction forever, and the deposition + /// would never surface anywhere; + /// 2. the message says the lane resumes only under a later epoch -- that IS the deposition, + /// reported to the caller and the operator in the same words the collision reported it; + /// 3. NOTHING is written, so no self-pointer can have been stamped and no request was spent; + /// 4. a SECOND flush behaves identically instead of looping or degrading. + const uint64_t remounts_before = store->scheduleRemountCallCountForTest(); /// NOLINT(clang-analyzer-deadcode.DeadStores) + const size_t puts_before = backend->putCount(layout.refLogKey(DB::Cas::tests::fixture::fixtureLife(ns), RefTxnId{epoch + 1, 1})); /// NOLINT(clang-analyzer-deadcode.DeadStores) + try + { + store->dropRef(ns, "x"); + FAIL() << "the deposed lane must reject conclusively, not succeed"; + } + catch (const DB::Exception & e) + { + EXPECT_EQ(e.code(), DB::ErrorCodes::INVALID_STATE) << "got: " << e.message(); + EXPECT_NE(e.message().find("resumes only under a later epoch"), String::npos) + << "the deposition must be surfaced, not just the failure: " << e.message(); + } + EXPECT_FALSE(backend->get(layout.refLogKey(DB::Cas::tests::fixture::fixtureLife(ns), RefTxnId{epoch + 1, 1})).has_value()) + << "nothing may be written: the lane could not construct a legal transaction, so it sent none"; + EXPECT_EQ(backend->putCount(layout.refLogKey(DB::Cas::tests::fixture::fixtureLife(ns), RefTxnId{epoch + 1, 1})), puts_before) + << "and no request was spent learning what the lane could already prove about itself"; + + /// The second flush: same conclusive answer, still no traffic. A lane that re-derived and re-sent + /// here would be exactly the spin this arm exists to prevent. + expectThrowsCode(DB::ErrorCodes::INVALID_STATE, [&] { store->dropRef(ns, "x"); }); + EXPECT_EQ(backend->putCount(layout.refLogKey(DB::Cas::tests::fixture::fixtureLife(ns), RefTxnId{epoch + 1, 1})), puts_before); + + /// NO remount is scheduled, matching the collision arm exactly. A successor closing our epoch is a + /// legitimate handover, not an anomaly to react to: the mount lease is what resolves it, and + /// scheduling a remount from here would turn every ordinary deposition into a self-inflicted + /// re-claim storm. + EXPECT_EQ(store->scheduleRemountCallCountForTest(), remounts_before); +} diff --git a/src/Disks/tests/gtest_cas_ref_writer.cpp b/src/Disks/tests/gtest_cas_ref_writer.cpp new file mode 100644 index 000000000000..20bf4444cf97 --- /dev/null +++ b/src/Disks/tests/gtest_cas_ref_writer.cpp @@ -0,0 +1,5299 @@ +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +#include +#include +#include +#include +#include +#include +#include + +/// Task 10: the writer's ref persistence on the snapshot+log protocol. Covers the plan's Task 10 +/// failing-test list: empty+birth recovery; snapshot+tail recovery; recovery restart on a vanished +/// object (converging on a newer snapshot); the append lane's wedge semantics (blocks the same table, +/// leaves other tables free, applies a later-observed-durable append before unwedging); invalid batch +/// entries failing in isolation; and the S3 request-cost contract (one create for a warm isolated +/// mutation, one create shared by a compatible batch). + +namespace DB::ErrorCodes +{ +extern const int ABORTED; +extern const int FILE_DOESNT_EXIST; +extern const int CORRUPTED_DATA; +extern const int INVALID_STATE; +extern const int LOGICAL_ERROR; +extern const int NETWORK_ERROR; +extern const int S3_ERROR; +} + +namespace ProfileEvents +{ +extern const Event CASRefSweepDeferred; +extern const Event CASRefSweepRearmed; +extern const Event CASRefStalePrecommitsReclaimed; +extern const Event CASRefSnapshotPutBytes; +extern const Event CASRefSnapshotTailLogs; +extern const Event CASRefSnapshotPublishDispatched; +extern const Event CASRefSnapshotPublishBackoff; +extern const Event CASConditionalWriteFenceLostPostWrite; +extern const Event CASRefRecoveryEpochSealed; +extern const Event CASRefRecoveryRetries; +} + +using namespace DB::Cas; +using DB::Cas::tests::CountingBackend; +using DB::Cas::tests::committedRow; +using DB::Cas::tests::expectThrowsCode; +using DB::Cas::tests::minimalLiveSnapshot; +using DB::Cas::tests::namespaceBirthOp; +using DB::Cas::tests::publishCommittedOps; +using DB::Cas::tests::runRegularRoundReclaiming; +using DB::Cas::tests::writeRefSnapshotRaw; +using DB::Cas::tests::writeSealAt; + +namespace +{ + +/// The operation deadline every SINGLE-ATTEMPT fixture in this file uses, and the reason it is +/// deliberately NOT `attempt_timeout_ms`. +/// +/// Those fixtures exist to make one injected ambiguous response conclusive, and `max_attempts = 1` +/// alone achieves that: with retries allowed the controller would resolve-before-reissue and report a +/// definite outcome instead. The deadline contributes nothing to that -- but setting it EQUAL to the +/// attempt timeout collapses the controller's pre-send gate into a race. The deadline is captured as +/// `now + operation_deadline_ms` and the gate asks `now + attempt_timeout_ms > deadline` +/// (`CasRequestControl.cpp`), so equal values reduce it to `now_2 > now_1`: ONE elapsed millisecond +/// between the two clock reads refuses the operation with NOTHING SENT, the injected fault is never +/// reached, and the product correctly does not wedge -- flipping every wedge expectation downstream. +/// +/// That is not hypothetical. It took down +/// `CASRefWriterStalePrecommitSweep.BoundedBatchesAndInterruptionResumeAcrossMounts` on 5 of 6 sanitizer +/// CI runs (fixed in `8f9e63c7a19`), `CASRefInstallSafety.UncertainPrecommitKeepsItsCleanupOwnerAndItsBody` +/// under parallel-build load, and `CASRefWriterAppendLane.WedgedLaneBlocksSameTableWhileOtherTableProceeds` +/// in a full-binary ASan run -- the last one with the mechanism named verbatim in the thrown message +/// ("refused BEFORE any request was sent ... the operation deadline rejected before the first request"). +/// +/// A wide deadline keeps the request always actually sent, so what the test observes is the fault it +/// injected rather than the machine it ran on. A fixture that genuinely wants the pre-send REFUSAL +/// must drive it deterministically with a frozen clock (see `gtest_cas_ref_install_safety.cpp`'s +/// `openPoolFenceControlled`), never by racing the wall clock. +constexpr uint64_t kSingleAttemptDeadlineMs = 5000; + +/// A `CasEvent` sink safe to hand to `Pool::setEventSink`: the emit runs on whatever thread the pool's +/// background syncer happens to be on, and the test reads the accumulated events afterward from the +/// main test thread with no other ordering between the two -- a bare `std::vector` there is a real data +/// race (the class this file's four `setEventSink` call sites all had, hidden because a debug/ASan build +/// doesn't reliably catch an unsynchronized push_back/iterator-read pair on a small vector). `add` takes +/// the lock only around the push; `snapshot` copies out under the lock and returns, so a caller iterating +/// the result never holds the mutex across anything that could call back into the pool (which an +/// event-sink callback legitimately can, on other seams in this file). +class SynchronizedEventLog +{ +public: + void add(const CasEvent & e) + { + std::lock_guard lock(mutex); + events.push_back(e); + } + std::vector snapshot() const + { + std::lock_guard lock(mutex); + return events; + } +private: + mutable std::mutex mutex; + std::vector events; +}; + +PoolPtr openPool(const BackendPtr & backend, CasRequestBudget budget = {}) +{ + /// Recovery tests seed ref-log/snapshot residue before opening; a pool with such residue always has a + /// `_pool_meta` in production, so establish it first (Task 7's zero-write bootstrap check refuses to + /// mint a fresh identity over residual data — see `seedPoolMetaForRestart`). Idempotent, and a no-op + /// for the fresh-open tests that seed nothing (the subsequent open validates the just-created meta). + DB::Cas::tests::seedPoolMetaForRestart(*backend); + return Pool::open(backend, PoolConfig{.pool_prefix = "p", .server_root_id = "test", .cas_request_budget = budget}); +} + +/// Task 11: like `openPool`, but the caller supplies (and owns) the rest of the config -- snapshot +/// thresholds, grace age, a fake `boot_ms_fn`, etc. `pool_prefix`/`server_root_id` are pinned so every +/// test in this file addresses the same pool shape. +PoolPtr openPoolWithConfig(const BackendPtr & backend, PoolConfig config) +{ + config.pool_prefix = "p"; + config.server_root_id = "test"; + DB::Cas::tests::seedPoolMetaForRestart(*backend); /// see `openPool` above + return Pool::open(backend, std::move(config)); +} + +/// Mirrors gtest_cas_part_write.cpp's startBuildFor/publishOneBlobPart, minus the blob (an empty-entry +/// manifest is a legal, blob-free part -- the ref-writer tests only care about ref/manifest identity). +/// +/// Stage B (Task 4-C): pin `ns` to the sentinel before the first real touch -- ONE choke point for +/// every test in this file, since every real-path setup here funnels through `startBuildFor` (directly, +/// or via `publishEmptyPart` below). Many of this file's tests separately compute an expected key via +/// `DB::Cas::tests::fixture::fixtureLife(ns)` for fault injection/verification; without this the real +/// production birth mints a random incarnation and those computed keys land nowhere real. +PartWriteTxnPtr startBuildFor(const PoolPtr & s, const RootNamespace & ns, const String & ref) +{ + DB::Cas::tests::casAdmitRecoverableEntry(s->backend(), s->layout(), ns, s->liveWriterEpoch()); + PartWriteInfo info; + info.intended_namespace = ns; + info.intended_ref = ns.string() + "/" + ref; + return s->beginPartWrite(info); +} + +ManifestId publishEmptyPart(const PoolPtr & s, const RootNamespace & ns, const String & ref) +{ + auto build = startBuildFor(s, ns, ref); + const ManifestId id = build->stageManifest({}); + build->precommitAdd(ns, ref, id); + build->promote(ns, ref, build->buildId(), id); + return id; +} + +void publishWithProductionBirth(const PoolPtr & store, const RootNamespace & ns, const String & ref) +{ + PartWriteInfo info; + info.intended_namespace = ns; + info.intended_ref = ns.string() + "/" + ref; + auto build = store->beginPartWrite(info); + const ManifestId id = build->stageManifest({}); + build->precommitAdd(ns, ref, id); + build->promote(ns, ref, build->buildId(), id); +} + +CatalogEntry catalogEntryOrThrow(Backend & backend, const Layout & layout, const RootNamespace & ns) +{ + const RefCatalog catalog = CasRefCatalog::read(backend, layout).catalog; + const auto it = std::find_if(catalog.entries.begin(), catalog.entries.end(), [&](const CatalogEntry & entry) + { + return entry.ns == ns; + }); + if (it == catalog.entries.end()) + throw std::runtime_error("catalog entry missing from test fixture"); + return *it; +} + +CatalogEntry replaceCatalogLifeForRuntimeRace( + Backend & backend, const Layout & layout, const CatalogEntry & predecessor, UInt128 successor_incarnation) +{ + const CasRefCatalog::Snapshot before_delete = CasRefCatalog::read(backend, layout); + RefCatalog without_predecessor = before_delete.catalog; + std::erase_if(without_predecessor.entries, [&](const CatalogEntry & entry) + { + return entry.ns == predecessor.ns && entry.incarnation == predecessor.incarnation; + }); + if (backend.casPut(layout.refCatalogKey(), encodeRefCatalog(without_predecessor), before_delete.token).outcome + != CasOutcome::Committed) + throw std::runtime_error("test failed to retire exact predecessor catalog life"); + + CatalogEntry successor{ + .ns = predecessor.ns, + .state = NsState::Live, + .incarnation = successor_incarnation, + .creator = std::nullopt}; + const CasRefCatalog::Snapshot after_delete = CasRefCatalog::read(backend, layout); + RefCatalog reborn = after_delete.catalog; + reborn.entries.push_back(successor); + if (backend.casPut(layout.refCatalogKey(), encodeRefCatalog(reborn), after_delete.token).outcome + != CasOutcome::Committed) + throw std::runtime_error("test failed to publish successor catalog life"); + return successor; +} + +std::optional listGreatestLogIdForTest( + Backend & backend, const Layout & layout, const RootNamespace & ns); + +std::optional listGreatestLogIdForLifeForTest( + Backend & backend, const Layout & layout, const NamespaceLifeId & life) +{ + std::optional greatest; + String cursor; + for (;;) + { + const ListPage page = backend.list(layout.namespaceStreamPrefix(life), cursor, 1000); + for (const ListedKey & listed : page.keys) + { + const auto parsed = layout.parseRefObjectKey(listed.key); + if (parsed && parsed->life_id == life.incarnation && parsed->kind == RefObjectKind::Log + && (!greatest || *greatest < parsed->txn_id)) + greatest = parsed->txn_id; + } + if (page.next_cursor.empty()) + return greatest; + cursor = page.next_cursor; + } +} + +struct CompletedRemovingFixture +{ + CatalogEntry predecessor; + uint64_t writer_epoch = 0; + uint64_t runtime_identity = 0; +}; + +CompletedRemovingFixture prepareResidentRemovalForDrain( + const PoolPtr & store, Backend & backend, const RootNamespace & ns, Gc & gc) +{ + publishWithProductionBirth(store, ns, "predecessor"); + const CatalogEntry predecessor = catalogEntryOrThrow(backend, store->layout(), ns); + const uint64_t writer_epoch = store->liveWriterEpoch(); + const uint64_t runtime_identity = store->refTableRuntimeIdentityForTest(ns); + + if (runRegularRoundReclaiming(gc).deferred) + throw std::runtime_error("fixture publish unexpectedly deferred"); + store->dropNamespace(ns); + const CatalogEntry removing = catalogEntryOrThrow(backend, store->layout(), ns); + if (removing.state != NsState::Removing || removing.incarnation != predecessor.incarnation) + throw std::runtime_error("fixture removal did not publish the expected exact Removing row"); + if (runRegularRoundReclaiming(gc).deferred) + throw std::runtime_error("fixture terminal fold unexpectedly deferred"); + + const GcState state = decodeGcState(backend.get(store->layout().gcStateKey())->bytes); + const CasFoldSeal seal = decodeFoldSeal( + backend.get(store->layout().foldSealKey(state.snap_generation, state.snap_attempt))->bytes); + const auto row = seal.ref_lives.find(predecessor.incarnation); + if (row == seal.ref_lives.end() || !row->second.cleanup_evidence) + throw std::runtime_error("fixture terminal fold produced no cleanup evidence"); + return {predecessor, writer_epoch, runtime_identity}; +} + +ManifestRef manifestRef(uint64_t epoch, uint64_t seq, uint32_t ordinal) +{ + return ManifestRef{epoch, seq, ordinal}; +} + +/// Task 11: an INDEPENDENT ground truth for "cache-replay equivalence" tests -- lists every `_log/` +/// key under `ns` directly off the backend (ignoring any snapshot), decodes and replays them in id +/// order via the SAME shared state machine the writer uses, and returns the resulting state. A +/// published snapshot's bytes must equal `encodeRefTableSnapshot(snapshotOf(replay-through-X, ns))` +/// for this oracle's replay truncated at `X`. +RefTableState independentFullReplayForTest(Backend & backend, const Layout & layout, const RootNamespace & ns, + std::optional up_to = std::nullopt) +{ + std::vector ids; + String cursor; + for (;;) + { + const ListPage page = backend.list(layout.namespaceStreamPrefix(DB::Cas::tests::fixture::fixtureLife(ns)), cursor, 1000); + for (const ListedKey & lk : page.keys) + { + const auto parsed = layout.parseRefObjectKey(lk.key); + if (parsed && parsed->life_id == DB::Cas::tests::fixture::fixtureLife(ns).incarnation && parsed->kind == RefObjectKind::Log + && (!up_to || !(*up_to < parsed->txn_id))) + ids.push_back(parsed->txn_id); + } + if (page.next_cursor.empty()) + break; + cursor = page.next_cursor; + } + std::sort(ids.begin(), ids.end()); + + RefTableState state; + for (const RefTxnId & id : ids) + { + const auto got = backend.get(layout.refLogKey(DB::Cas::tests::fixture::fixtureLife(ns), id)); + applyRefLogTxn(state, decodeRefLogTxn(openObject(FormatId::RefLog, got->bytes), ns.string(), id)); + } + return state; +} + +/// The greatest `_snap/.proto` key currently present for `ns`, found via a fresh LIST (independent +/// of the Pool's own cached bookkeeping). +std::optional listGreatestSnapshotIdForTest(Backend & backend, const Layout & layout, const RootNamespace & ns) +{ + std::optional greatest; + String cursor; + for (;;) + { + const ListPage page = backend.list(layout.namespaceStreamPrefix(DB::Cas::tests::fixture::fixtureLife(ns)), cursor, 1000); + for (const ListedKey & lk : page.keys) + { + const auto parsed = layout.parseRefObjectKey(lk.key); + if (parsed && parsed->life_id == DB::Cas::tests::fixture::fixtureLife(ns).incarnation && parsed->kind == RefObjectKind::Snap + && (!greatest || *greatest < parsed->txn_id)) + greatest = parsed->txn_id; + } + if (page.next_cursor.empty()) + break; + cursor = page.next_cursor; + } + return greatest; +} + +/// A backend that can (a) force one `get()` on a chosen exact key to return absent exactly once +/// (simulating an object vanishing after recovery sampled its exact checkpoint, with an optional side effect +/// fired at that exact moment -- e.g. publishing a covering newer snapshot, mirroring a concurrent GC +/// cleanup+republish race), and (b) force `putIfAbsent` on keys matching a chosen substring to throw an +/// ambiguous (Unresolved-classified) exception a bounded number of times, optionally still capturing +/// the (key, bytes) so a test can later "deliver" it -- simulating a request whose RESPONSE was lost +/// even though the write eventually landed server-side. +class RefWriterTestBackend : public CountingBackend +{ +public: + RefWriterTestBackend() + { + DB::Cas::tests::seedPoolMetaForRestart(*this); + } + + using CountingBackend::get; + using CountingBackend::getStream; + using CountingBackend::putIfAbsent; + using CountingBackend::putIfAbsentStream; + using CountingBackend::putOverwrite; + using CountingBackend::casPut; + + void clearRequestJournal() + { + std::lock_guard lock(request_journal_mutex); + request_journal.clear(); + } + + void recordRequestJournalEvent(String event) + { + std::lock_guard lock(request_journal_mutex); + request_journal.push_back(std::move(event)); + } + + std::vector requestJournal() const + { + std::lock_guard lock(request_journal_mutex); + return request_journal; + } + + std::set vanish_once_keys; + std::function on_vanish_fire; + + enum class CatalogCasFault : uint8_t + { + None, + CommitThenThrow, + OtherWriterReplacement, + }; + CatalogCasFault catalog_cas_fault = CatalogCasFault::None; + String catalog_fault_key; + String catalog_replacement_bytes; + int catalog_resolution_get_fault_count = 0; + bool catalog_cas_fault_fired = false; + /// Fail one selected catalog GET after allowing an exact number of earlier catalog GETs through. + /// This reaches the removal lane's post-close observation without faulting its initial discovery. + int catalog_gets_before_fault = -1; + int catalog_get_fault_count = 0; + + String fault_key_substr; + int fault_count = 0; + /// Let the first `fault_skip` matching PUTs through untouched before `fault_count` starts faulting. + /// Needed now that recovery's in-band epoch seal (INV-2) shares the `_log/` prefix with every other + /// write under a namespace: a test that wants to fault something LATER in the same prefix (e.g. the + /// stale-precommit sweep's removal chunk) must skip past recovery's own seal writes first. Same + /// seam as `ChunkFaultBackend::fault_skip` in `cas_test_helpers.h`. + int fault_skip = 0; + std::optional> pending_delayed_write; + + /// (I1) On a matching `putIfAbsent`, a FOREIGN writer lands a DIFFERENT object at the exact key and + /// then this attempt's response is lost -- so the controller's resolve-before-reissue GET observes + /// different bytes and must raise CORRUPTED_DATA (a proven conflict, never a retry signal). + /// By default the foreign object is the attempt's own bytes plus a trailing marker -- UNDECODABLE + /// for zstd-framed objects (the frame size no longer matches), which is exactly right for tests + /// that pin fail-closed handling of a corrupt object. Tests that instead need a VALID foreign + /// object (e.g. a real cross-process seal to be adopted on retry) set `corrupt_foreign_bytes`. + String corrupt_key_substr; + int corrupt_count = 0; + String corrupt_foreign_bytes; + + String ckpt_conflict_key; + size_t ckpt_conflict_count = 0; + String ckpt_get_hook_key; + std::function ckpt_get_hook; + + /// Force a stream `LIST` to throw a transient object-store error (S3_ERROR) a bounded number of + /// times. Recovery must not consume this injection; callers that intentionally enumerate still do. + int list_fault_count = 0; + + ListPage list(const String & prefix, const String & cursor, size_t limit) override + { + if (list_fault_count > 0) + { + --list_fault_count; + throw DB::Exception(DB::ErrorCodes::S3_ERROR, "RefWriterTestBackend: simulated transient LIST failure"); + } + return CountingBackend::list(prefix, cursor, limit); + } + + std::optional get(const String & key, Range range) override + { + recordRequestJournalEvent("GET " + key); + if (key == ckpt_get_hook_key && ckpt_get_hook) + { + auto hook = std::exchange(ckpt_get_hook, nullptr); + hook(); + } + if (key == catalog_fault_key && catalog_get_fault_count > 0 && catalog_gets_before_fault >= 0) + { + if (catalog_gets_before_fault == 0) + { + --catalog_get_fault_count; + throw std::runtime_error("RefWriterTestBackend: simulated catalog admission read failure"); + } + --catalog_gets_before_fault; + } + if (catalog_cas_fault_fired && key == catalog_fault_key && catalog_resolution_get_fault_count > 0) + { + --catalog_resolution_get_fault_count; + throw std::runtime_error("RefWriterTestBackend: simulated catalog resolution read failure"); + } + const auto it = vanish_once_keys.find(key); + if (it != vanish_once_keys.end()) + { + vanish_once_keys.erase(it); + if (on_vanish_fire) + { + auto fire = std::move(on_vanish_fire); + on_vanish_fire = nullptr; + fire(); + } + return std::nullopt; + } + return CountingBackend::get(key, range); + } + + CasResult casPut( + const String & key, const String & bytes, const std::optional & expected, + const ObjectMeta & meta) override + { + recordRequestJournalEvent("CAS " + key); + if (key == ckpt_conflict_key && ckpt_conflict_count > 0) + { + --ckpt_conflict_count; + return {CasOutcome::Conflict, {}}; + } + if (key == catalog_fault_key && catalog_cas_fault != CatalogCasFault::None) + { + const CatalogCasFault fault = std::exchange(catalog_cas_fault, CatalogCasFault::None); + catalog_cas_fault_fired = true; + if (fault == CatalogCasFault::CommitThenThrow) + { + const CasResult result = CountingBackend::casPut(key, bytes, expected, meta); + if (result.outcome != CasOutcome::Committed) + return result; + throw Poco::TimeoutException( + "RefWriterTestBackend: catalog CAS committed but its response was lost"); + } + + CasResult replacement = CountingBackend::casPut( + key, catalog_replacement_bytes, expected, meta); + if (replacement.outcome != CasOutcome::Committed) + return replacement; + return {CasOutcome::Conflict, {}}; + } + return CountingBackend::casPut(key, bytes, expected, meta); + } + + PutResult putIfAbsent(const String & key, const String & bytes, const ObjectMeta & meta) override + { + recordRequestJournalEvent("PUT " + key); + if (corrupt_count > 0 && !corrupt_key_substr.empty() && key.find(corrupt_key_substr) != String::npos) + { + --corrupt_count; + /// A foreign writer lands a DIFFERENT object at this exact key; then our own response is lost. + CountingBackend::putIfAbsent( + key, corrupt_foreign_bytes.empty() ? bytes + String("\x01_FOREIGN_DIFFERENT") : corrupt_foreign_bytes); + throw Poco::TimeoutException("RefWriterTestBackend: a foreign different object landed; response lost"); + } + if (!fault_key_substr.empty() && key.find(fault_key_substr) != String::npos) + { + if (fault_skip > 0) + { + --fault_skip; + } + else if (fault_count > 0) + { + --fault_count; + pending_delayed_write = {key, bytes}; + throw Poco::TimeoutException("RefWriterTestBackend: simulated ambiguous result (response lost)"); + } + } + { + std::unique_lock lk(block_mutex); + bool block_this = false; + if (block_armed && key.find(block_substr) != String::npos) + { + if (!block_first_match_only) + block_this = true; /// block EVERY matching put (the original mode) + else if (blocked_key.empty()) + { + blocked_key = key; /// first match: capture and block exactly this key + block_this = true; + } + else if (key == blocked_key) + block_this = true; /// the SAME captured key retried: keep blocking it + /// a DIFFERENT matching key under first-match-only mode falls through unblocked + } + /// (I1) Independent per-key blocking: every matching key parks on its OWN release, unlike + /// `block_armed` above (one shared gate released all-at-once). Lets a test park two DISTINCT + /// `_snap/` PUTs concurrently and release them in a chosen order. + if (independent_block_armed && key.contains(independent_block_substr)) + { + independent_blocked_keys.insert(key); + block_cv.notify_all(); + block_cv.wait(lk, [&] { return independent_released_keys.contains(key); }); + } + if (block_this) + { + block_entered = true; + block_cv.notify_all(); + block_cv.wait(lk, [&] { return !block_armed; }); + /// fix-round F3-1a (CRITICAL, unlock-throw race harness): on release, behave like + /// `corrupt_key_substr` above instead of proceeding normally -- a foreign writer landed + /// DIFFERENT bytes at this exact key while we were parked, so our own attempt is a + /// PROVEN conflict once `putIfAbsentControlled`'s resolve-before-reissue GETs it. Lets a + /// test make the recovery seal's PUT throw CORRUPTED_DATA from INSIDE the unlocked + /// window, deterministically, instead of merely returning a non-Committed outcome. + if (block_throw_corrupted_on_release) + { + lk.unlock(); + CountingBackend::putIfAbsent(key, bytes + String("\x01_FOREIGN_DIFFERENT")); + { + std::lock_guard g(block_mutex); + block_call_completed = true; + } + block_cv.notify_all(); + throw Poco::TimeoutException( + "RefWriterTestBackend: a foreign different object landed on release; response lost"); + } + } + } + const PutResult r = CountingBackend::putIfAbsent(key, bytes, meta); + { + std::lock_guard g(block_mutex); + block_call_completed = true; + } + block_cv.notify_all(); + return r; + } + /// See `putIfAbsent`'s `block_this` branch. Set before spawning any thread that could race + /// `putIfAbsent`, like `corrupt_key_substr`/`fault_key_substr` above -- not itself lock-protected. + bool block_throw_corrupted_on_release = false; + + /// "Deliver" the earlier ambiguous write: the request DID eventually land server-side, the caller + /// just never saw the ack. No-op if no fault has fired since the last delivery. + void materializePendingDelayedWrite() + { + if (pending_delayed_write) + { + CountingBackend::putIfAbsent(pending_delayed_write->first, pending_delayed_write->second); + pending_delayed_write.reset(); + } + } + + /// Task 11: blocks EVERY `putIfAbsent()` whose key contains `armed_block_substr` until + /// `releaseBlock()` is called, notifying `awaitBlockEntered()` the first time one is reached. Used + /// to prove snapshot publication never holds up an unrelated concurrent append. + void armPutBlock(const String & substr) + { + std::lock_guard g(block_mutex); + block_substr = substr; + block_armed = true; + block_entered = false; + block_call_completed = false; + block_first_match_only = false; + blocked_key.clear(); + } + + /// Task 11 (monotonic-adoption harness): block ONLY the FIRST `putIfAbsent` whose key contains + /// `substr`, capturing that exact key; every LATER put -- including a DIFFERENT `_snap/` key -- + /// proceeds unblocked. Lets a test pin one in-flight publish's PUT mid-flight while a second, + /// higher-id publish runs to completion, deterministically forcing the out-of-order overlap. + void armPutBlockFirstMatchOnly(const String & substr) + { + std::lock_guard g(block_mutex); + block_substr = substr; + block_armed = true; + block_entered = false; + block_call_completed = false; + block_first_match_only = true; + blocked_key.clear(); + } + void awaitBlockEntered() + { + std::unique_lock lk(block_mutex); + block_cv.wait(lk, [&] { return block_entered; }); + } + void releaseBlock() + { + { + std::lock_guard g(block_mutex); + block_armed = false; + } + block_cv.notify_all(); + } + /// Blocks until the PREVIOUSLY-blocked `putIfAbsent` call has actually RETURNED (not merely been + /// unblocked) -- i.e. its underlying `CountingBackend::putIfAbsent` has completed. Deterministic, + /// sleep-free way to observe a detached background caller's own work finishing when the TEST no + /// longer holds anything (e.g. a Pool handle) that call would otherwise let it wait on. + void awaitBlockedCallCompleted() + { + std::unique_lock lk(block_mutex); + block_cv.wait(lk, [&] { return block_call_completed; }); + } + + /// (I1 regression harness) Arms independent per-key blocking for every `putIfAbsent` matching + /// `substr`: unlike `armPutBlock`/`armPutBlockFirstMatchOnly` (one shared release gate), each + /// blocked key parks on ITS OWN release (`releaseKey`), so two distinct `_snap/` PUTs can be + /// parked concurrently -- both past their capture point, neither yet adopted -- and released in a + /// chosen order. Needed to construct the small-candidate-adopts-before-a-larger-one-already-in-flight + /// ordering that exercises `clampedCounterSub`'s actual clamp branch. + void armPutBlockIndependently(const String & substr) + { + std::lock_guard g(block_mutex); + independent_block_substr = substr; + independent_block_armed = true; + independent_blocked_keys.clear(); + independent_released_keys.clear(); + } + /// Blocks until at least `n` distinct matching keys are currently parked. + void awaitAtLeastNKeysBlocked(size_t n) + { + std::unique_lock lk(block_mutex); + block_cv.wait(lk, [&] { return independent_blocked_keys.size() >= n; }); + } + /// A snapshot of the keys currently parked under independent blocking. + std::set blockedKeysSnapshot() + { + std::lock_guard g(block_mutex); + return independent_blocked_keys; + } + /// Releases exactly the given key; every OTHER independently-blocked key stays parked. + void releaseKey(const String & key) + { + { + std::lock_guard g(block_mutex); + independent_released_keys.insert(key); + } + block_cv.notify_all(); + } + +private: + mutable std::mutex request_journal_mutex; + std::vector request_journal; + std::mutex block_mutex; + std::condition_variable block_cv; + String block_substr; + bool block_armed = false; + bool block_entered = false; + bool block_call_completed = false; + bool block_first_match_only = false; + String blocked_key; + String independent_block_substr; + bool independent_block_armed = false; + std::set independent_blocked_keys; + std::set independent_released_keys; +}; + +} + +/// =================================================================================== +/// Recovery (spec §Recovery / exact checkpoint grounding) +/// =================================================================================== + +TEST(CASRefWriterRecovery, EmptyNamespaceRecoversToEmptyState) +{ + auto backend = std::make_shared(); + auto store = openPool(backend); + const RootNamespace ns{"srv1/never_touched"}; + + EXPECT_TRUE(store->listRefs(ns).empty()); + EXPECT_FALSE(store->resolveRef(ns, "anything").has_value()); + EXPECT_EQ(store->refRecoveryRestartsForTest(ns), 0u); +} + +TEST(CASRefWriterNonMinting, ListRefsOnAbsentNamespaceDoesNotMutateCatalog) +{ + auto backend = std::make_shared(); + auto store = openPool(backend); + const Layout & layout = store->layout(); + const RootNamespace ns{"srv1/list_absent_non_minting"}; + const auto catalog_before = backend->get(layout.refCatalogKey()); + ASSERT_TRUE(catalog_before); + backend->resetCounts(); + + EXPECT_TRUE(store->listRefs(ns).empty()); + + EXPECT_EQ(backend->putCount(layout.refCatalogKey()), 0u); + EXPECT_EQ(backend->putOverwriteCount(layout.refCatalogKey()), 0u); + EXPECT_EQ(backend->casPutCount(layout.refCatalogKey()), 0u); + EXPECT_EQ(backend->deleteCount(layout.refCatalogKey()), 0u); + const auto catalog_after = backend->get(layout.refCatalogKey()); + ASSERT_TRUE(catalog_after); + EXPECT_EQ(catalog_after->bytes, catalog_before->bytes); + EXPECT_EQ(catalog_after->token, catalog_before->token); +} + +TEST(CASRefWriterRuntimeIdentity, ColdReadRejectsCatalogLifeReplacedWithoutLocalInvalidation) +{ + auto backend = std::make_shared(); + auto store = openPool(backend); + const Layout & layout = store->layout(); + const RootNamespace ns{"srv1/cold-read-catalog-aba"}; + DB::Cas::tests::casAdmitRecoverableEntry(*backend, layout, ns, store->liveWriterEpoch()); + const CatalogEntry predecessor = catalogEntryOrThrow(*backend, layout, ns); + ASSERT_EQ(store->refTableRuntimeIdentityForTest(ns), 0u); + + std::mutex mutex; + std::condition_variable cv; + bool paused = false; + bool resume = false; + store->setReadableCatalogAfterObservationHookForTest([&] + { + std::unique_lock lock(mutex); + paused = true; + cv.notify_all(); + cv.wait(lock, [&] { return resume; }); + }); + + std::exception_ptr stale_error; + std::thread stale_reader([&] + { + try + { + (void)store->listRefs(ns); + } + catch (...) + { + stale_error = std::current_exception(); + } + }); + { + std::unique_lock lock(mutex); + cv.wait(lock, [&] { return paused; }); + } + + const CatalogEntry successor + = replaceCatalogLifeForRuntimeRace(*backend, layout, predecessor, UInt128{0xabc002}); + const NamespaceLifeId successor_life + = NamespaceLifeId::fromCatalogEntry(successor.ns, successor.incarnation); + ASSERT_EQ(backend->putIfAbsent(layout.refCkptKey(successor_life), encodeRefCkpt(RefCkpt{ + .life_epoch = store->liveWriterEpoch(), + .checkpoint_snapshot_id = std::nullopt, + .last_epoch_seal = std::nullopt})).outcome, PutOutcome::Done); + { + std::lock_guard lock(mutex); + resume = true; + } + cv.notify_all(); + stale_reader.join(); + store->setReadableCatalogAfterObservationHookForTest(nullptr); + + EXPECT_TRUE(stale_error) << "the stale catalog life was published instead of refused"; + EXPECT_EQ(store->refTableRuntimeIdentityForTest(ns), 0u); + EXPECT_NO_THROW((void)store->listRefs(ns)); + ASSERT_TRUE(store->refTableLifeForTest(ns)); + EXPECT_EQ(*store->refTableLifeForTest(ns), successor_life); +} + +TEST(CASRefWriterRuntimeIdentity, ColdReadRejectsReplacementByExternalPoolActor) +{ + auto backend = std::make_shared(); + auto store = openPool(backend); + PoolConfig external_config{.pool_prefix = "p", .server_root_id = "external-runtime-race"}; + auto external_store = Pool::open(backend, std::move(external_config)); + const Layout & layout = store->layout(); + const RootNamespace ns{"srv1/external-catalog-runtime-publication"}; + DB::Cas::tests::casAdmitRecoverableEntry(*backend, layout, ns, store->liveWriterEpoch()); + const CatalogEntry predecessor = catalogEntryOrThrow(*backend, layout, ns); + ASSERT_EQ(store->refTableRuntimeIdentityForTest(ns), 0u); + + CatalogEntry successor; + store->setReadableCatalogAfterObservationHookForTest([&] + { + successor = replaceCatalogLifeForRuntimeRace( + external_store->backend(), external_store->layout(), predecessor, UInt128{0xabc003}); + const NamespaceLifeId successor_life + = NamespaceLifeId::fromCatalogEntry(successor.ns, successor.incarnation); + if (external_store->backend().putIfAbsent( + external_store->layout().refCkptKey(successor_life), + encodeRefCkpt(RefCkpt{ + .life_epoch = external_store->liveWriterEpoch(), + .checkpoint_snapshot_id = std::nullopt, + .last_epoch_seal = std::nullopt})).outcome != PutOutcome::Done) + throw std::runtime_error("test failed to publish external successor checkpoint"); + }); + + expectThrowsCode(DB::ErrorCodes::NETWORK_ERROR, [&] { (void)store->listRefs(ns); }); + store->setReadableCatalogAfterObservationHookForTest(nullptr); + + EXPECT_EQ(store->refTableRuntimeIdentityForTest(ns), 0u); + EXPECT_NO_THROW((void)store->listRefs(ns)); + ASSERT_TRUE(store->refTableLifeForTest(ns)); + EXPECT_EQ(*store->refTableLifeForTest(ns), + NamespaceLifeId::fromCatalogEntry(successor.ns, successor.incarnation)); +} + +TEST(CASRefWriterRuntimeIdentity, ColdReadRejectsUnrelatedCatalogMutationBetweenObservations) +{ + auto backend = std::make_shared(); + auto store = openPool(backend); + const Layout & layout = store->layout(); + const RootNamespace ns{"srv1/unrelated-catalog-runtime-publication"}; + const RootNamespace unrelated{"srv1/unrelated-catalog-row"}; + DB::Cas::tests::casAdmitRecoverableEntry(*backend, layout, ns, store->liveWriterEpoch()); + ASSERT_EQ(store->refTableRuntimeIdentityForTest(ns), 0u); + + store->setReadableCatalogAfterObservationHookForTest([&] + { + DB::Cas::tests::fixture::admitLive(*backend, layout, unrelated); + }); + + expectThrowsCode(DB::ErrorCodes::NETWORK_ERROR, [&] { (void)store->listRefs(ns); }); + store->setReadableCatalogAfterObservationHookForTest(nullptr); + + EXPECT_EQ(store->refTableRuntimeIdentityForTest(ns), 0u); + EXPECT_NO_THROW((void)store->listRefs(ns)); +} + +TEST(CASRefWriterRuntimeIdentity, WarmReadableRuntimeDoesNotReadCatalog) +{ + auto backend = std::make_shared(); + auto store = openPool(backend); + const Layout & layout = store->layout(); + const RootNamespace ns{"srv1/warm-runtime-zero-catalog-get"}; + DB::Cas::tests::casAdmitRecoverableEntry(*backend, layout, ns, store->liveWriterEpoch()); + + EXPECT_NO_THROW((void)store->listRefs(ns)); + ASSERT_NE(store->refTableRuntimeIdentityForTest(ns), 0u); + backend->resetCounts(); + + EXPECT_NO_THROW((void)store->listRefs(ns)); + EXPECT_EQ(backend->getCount(layout.refCatalogKey()), 0u); +} + +/// `DROP DETACHED PART` reaches this point lookup for a part that may already be absent. Its probe +/// must not turn a missing table namespace into a new catalog life. +TEST(CASRefWriterNonMinting, ResolveRefOnAbsentNamespaceDoesNotMutateCatalog) +{ + auto backend = std::make_shared(); + auto store = openPool(backend); + const Layout & layout = store->layout(); + const RootNamespace ns{"srv1/resolve_absent_non_minting"}; + const auto catalog_before = backend->get(layout.refCatalogKey()); + ASSERT_TRUE(catalog_before); + backend->resetCounts(); + + EXPECT_FALSE(store->resolveRef(ns, "detached_part").has_value()); + + EXPECT_EQ(backend->putCount(layout.refCatalogKey()), 0u); + EXPECT_EQ(backend->putOverwriteCount(layout.refCatalogKey()), 0u); + EXPECT_EQ(backend->casPutCount(layout.refCatalogKey()), 0u); + EXPECT_EQ(backend->deleteCount(layout.refCatalogKey()), 0u); + const auto catalog_after = backend->get(layout.refCatalogKey()); + ASSERT_TRUE(catalog_after); + EXPECT_EQ(catalog_after->bytes, catalog_before->bytes); + EXPECT_EQ(catalog_after->token, catalog_before->token); +} + +TEST(CASRefWriterNonMinting, DropNamespaceOnAbsentNamespaceDoesNotMutateCatalog) +{ + auto backend = std::make_shared(); + auto store = openPool(backend); + const Layout & layout = store->layout(); + const RootNamespace ns{"srv1/drop_absent_non_minting"}; + const auto catalog_before = backend->get(layout.refCatalogKey()); + ASSERT_TRUE(catalog_before); + backend->resetCounts(); + + store->dropNamespace(ns); + + EXPECT_EQ(backend->putCount(layout.refCatalogKey()), 0u); + EXPECT_EQ(backend->putOverwriteCount(layout.refCatalogKey()), 0u); + EXPECT_EQ(backend->casPutCount(layout.refCatalogKey()), 0u); + EXPECT_EQ(backend->deleteCount(layout.refCatalogKey()), 0u); + const auto catalog_after = backend->get(layout.refCatalogKey()); + ASSERT_TRUE(catalog_after); + EXPECT_EQ(catalog_after->bytes, catalog_before->bytes); + EXPECT_EQ(catalog_after->token, catalog_before->token); +} + +/// A table born by a log tail alone (no snapshot yet): `namespace_birth` with nothing else is a legal +/// Live-but-empty table. +TEST(CASRefWriterRecovery, BirthOnlyLogNoSnapshotRecoversToEmptyLiveTable) +{ + auto backend = std::make_shared(); + const Layout layout("p"); + const RootNamespace ns{"srv1/birth_only"}; + + DB::Cas::tests::fixture::writeRefLogRaw(*backend, layout, RefLogTxn{ns.string(), RefTxnId{1, 1}, {namespaceBirthOp()}, std::nullopt}); + const NamespaceLifeId life = *CasRefCatalog::lifeIfCataloged(*backend, layout, ns); + ASSERT_EQ(backend->putIfAbsent(layout.refCkptKey(life), encodeRefCkpt(RefCkpt{ + .life_epoch = 1, + .committed_through = RefTxnId{1, 1}, + .checkpoint_snapshot_id = std::nullopt, + .last_epoch_seal = std::nullopt})).outcome, PutOutcome::Done); + + auto store = openPool(backend); + EXPECT_TRUE(store->listRefs(ns).empty()); +} + +/// Empty base + birth log recovery (spec unit test list): birth and the first precommit->promote span +/// TWO separate log transactions with no snapshot at all. +TEST(CASRefWriterRecovery, BirthPlusPrecommitPromoteAcrossTwoLogsNoSnapshot) +{ + auto backend = std::make_shared(); + const Layout layout("p"); + const RootNamespace ns{"srv1/birth_then_promote"}; + const ManifestRef m1 = manifestRef(1, 1, 1); + + DB::Cas::tests::fixture::writeRefLogRaw(*backend, layout, RefLogTxn{ns.string(), RefTxnId{1, 1}, + {namespaceBirthOp(), publishCommittedOps("part_1", m1)[0]}, std::nullopt}); + DB::Cas::tests::fixture::writeRefLogRaw(*backend, layout, RefLogTxn{ns.string(), RefTxnId{1, 2}, + {publishCommittedOps("part_1", m1)[1]}, std::nullopt}); + const NamespaceLifeId life = *CasRefCatalog::lifeIfCataloged(*backend, layout, ns); + ASSERT_EQ(backend->putIfAbsent(layout.refCkptKey(life), encodeRefCkpt(RefCkpt{ + .life_epoch = 1, + .committed_through = RefTxnId{1, 2}, + .checkpoint_snapshot_id = std::nullopt, + .last_epoch_seal = std::nullopt})).outcome, PutOutcome::Done); + + auto store = openPool(backend); + const auto resolved = store->resolveRef(ns, "part_1"); + ASSERT_TRUE(resolved.has_value()); + EXPECT_EQ(resolved->manifest_id.ref, m1); + EXPECT_EQ(resolved->manifest_id.root_namespace, ns); + + const auto refs = store->listRefs(ns); + ASSERT_EQ(refs.size(), 1u); + EXPECT_TRUE(refs.contains("part_1")); +} + +TEST(CASRefWriterRecovery, TerminalGapBelowCheckpointFrontierIsCorruptionNotSameLifeRebirth) +{ + auto backend = std::make_shared(); + const Layout layout("p"); + const RootNamespace ns{"srv1/writer_terminal_gap"}; + + DB::Cas::tests::fixture::writeRefLogRaw(*backend, layout, RefLogTxn{ + ns.string(), RefTxnId{1, 1}, {namespaceBirthOp()}, std::nullopt}); + RefOp remove; + remove.kind = RefOpKind::RemoveNamespace; + DB::Cas::tests::fixture::writeRefLogRaw(*backend, layout, RefLogTxn{ + ns.string(), RefTxnId{1, 2}, {std::move(remove)}, std::nullopt}); + DB::Cas::tests::fixture::writeRefLogRaw(*backend, layout, RefLogTxn{ + ns.string(), RefTxnId{2, 1}, {namespaceBirthOp()}, std::nullopt}); + writeRecoverableCkptForRawFixture(*backend, layout, ns, RefCkpt{ + .life_epoch = 1, + .committed_through = RefTxnId{2, 1}, + .checkpoint_snapshot_id = std::nullopt, + .last_epoch_seal = RefTxnId{1, 2}, + }); + + const NamespaceLifeId life = *CasRefCatalog::lifeIfCataloged(*backend, layout, ns); + const String next_log_key = layout.refLogKey(life, RefTxnId{2, 2}); + auto store = openPool(backend); + const uint64_t installs_before = store->recoveryInstallCountForTest(); + + expectThrowsCode(DB::ErrorCodes::CORRUPTED_DATA, [&] { (void)store->listRefs(ns); }); + EXPECT_FALSE(store->refTableRecoveredForTest(ns)); + EXPECT_EQ(store->recoveryInstallCountForTest(), installs_before); + + EXPECT_ANY_THROW((void)publishEmptyPart(store, ns, "must_not_allocate")); + EXPECT_EQ(backend->putCount(next_log_key), 0u) + << "an unrecovered malformed life must not allocate the next writer position"; +} + +/// Latest snapshot plus tail recovery (spec unit test list): a snapshot covering ref "a", a tail that +/// drops "a" and publishes "b", and a STALE log at/below the snapshot id that must be ignored (its +/// content, if replayed, would corrupt the result -- proving the "ignore log keys at or below the +/// selected snapshot" rule). +TEST(CASRefWriterRecovery, SnapshotPlusTailRecovery) +{ + auto backend = std::make_shared(); + const Layout layout("p"); + const RootNamespace ns{"srv1/snap_tail"}; + const ManifestRef ma = manifestRef(1, 1, 1); + const ManifestRef mb = manifestRef(1, 2, 1); + + /// A stale log BELOW the snapshot id would, if wrongly replayed, try to add "a" a second time + /// (the snapshot already contains it) and throw -- proving it must be ignored, not merely benign. + DB::Cas::tests::fixture::writeRefLogRaw(*backend, layout, RefLogTxn{ns.string(), RefTxnId{1, 3}, + {namespaceBirthOp(), publishCommittedOps("a", ma)[0], publishCommittedOps("a", ma)[1]}, std::nullopt}); + DB::Cas::tests::fixture::writeRefLogRaw(*backend, layout, RefLogTxn{ + .ns = ns.string(), + .txn_id = RefTxnId{1, 5}, + .ops = publishCommittedOps("a", ma), + .prev_epoch_seal = std::nullopt}); + writeRefSnapshotRaw(*backend, layout, minimalLiveSnapshot(ns.string(), RefTxnId{1, 5}, {committedRow("a", ma)})); + + std::vector tail_ops; + tail_ops.push_back([&] { RefOp op; op.kind = RefOpKind::OwnerTransition; + op.old_binding = RefOwnerBinding{RefOwnerKind::Committed, "a", ma}; return op; }()); + tail_ops.push_back(publishCommittedOps("b", mb)[0]); + tail_ops.push_back(publishCommittedOps("b", mb)[1]); + DB::Cas::tests::fixture::writeRefLogRaw(*backend, layout, RefLogTxn{ns.string(), RefTxnId{1, 6}, tail_ops, std::nullopt}); + const NamespaceLifeId life = *CasRefCatalog::lifeIfCataloged(*backend, layout, ns); + ASSERT_EQ(backend->putIfAbsent(layout.refCkptKey(life), encodeRefCkpt(RefCkpt{ + .life_epoch = 1, + .committed_through = RefTxnId{1, 6}, + .checkpoint_snapshot_id = RefTxnId{1, 5}, + .last_epoch_seal = std::nullopt})).outcome, PutOutcome::Done); + + backend->resetCounts(); + auto store = openPool(backend); + EXPECT_FALSE(store->resolveRef(ns, "a").has_value()); + const auto b = store->resolveRef(ns, "b"); + ASSERT_TRUE(b.has_value()); + EXPECT_EQ(b->manifest_id.ref, mb); + EXPECT_EQ(store->listRefs(ns).size(), 1u); + EXPECT_EQ(backend->getCount(layout.refLogKey(life, RefTxnId{1, 5})), 1u) + << "recovery must validate the selected base's retained ordinary log"; + EXPECT_EQ(backend->getCount(layout.refSnapshotKey(life, RefTxnId{1, 5})), 1u) + << "the fixture must reach and decode the selected base snapshot"; +} + +/// Restart-on-vanish (spec §Recovery): the checkpoint-named snapshot vanishes during its exact GET +/// while concurrent cleanup publishes a newer checkpoint base. Recovery must restart from the newer +/// exact checkpoint, not treat the vanish as corruption. +TEST(CASRefWriterRecovery, RestartOnVanishConvergesOnNewerSnapshot) +{ + auto backend = std::make_shared(); + const Layout layout("p"); + const RootNamespace ns{"srv1/vanish_race"}; + const ManifestRef ma = manifestRef(1, 1, 1); + const ManifestRef mb = manifestRef(1, 2, 1); + + /// Stage B (Task 4-C): pin `ns` to the sentinel before the raw snapshot below -- `store->resolveRef` + /// further down is a real production read that triggers `resolveNamespaceLife`, which for an + /// UNADMITTED namespace mints a fresh RANDOM incarnation rather than adopting the sentinel the raw + /// fixture writes at. + DB::Cas::tests::fixture::admitLive(*backend, layout, ns); + const NamespaceLifeId life = *CasRefCatalog::lifeIfCataloged(*backend, layout, ns); + const RefTxnId snap_x{1, 10}; + DB::Cas::tests::fixture::writeRefLogRaw(*backend, layout, RefLogTxn{ + .ns = ns.string(), + .txn_id = snap_x, + .ops = publishCommittedOps("a", ma), + .prev_epoch_seal = std::nullopt}); + writeRefSnapshotRaw(*backend, layout, minimalLiveSnapshot(ns.string(), snap_x, {committedRow("a", ma)})); + ASSERT_EQ(backend->putIfAbsent(layout.refCkptKey(life), encodeRefCkpt(RefCkpt{ + .life_epoch = 1, + .committed_through = RefTxnId{1, 10}, + .checkpoint_snapshot_id = snap_x, + .last_epoch_seal = std::nullopt})).outcome, PutOutcome::Done); + backend->vanish_once_keys.insert(layout.refSnapshotKey(life, snap_x)); + bool vanish_fired = false; + backend->on_vanish_fire = [&] + { + vanish_fired = true; + const RefTxnId snap_y{1, 20}; + DB::Cas::tests::fixture::writeRefLogRaw(*backend, layout, RefLogTxn{ + .ns = ns.string(), + .txn_id = snap_y, + .ops = publishCommittedOps("b", mb), + .prev_epoch_seal = std::nullopt}); + writeRefSnapshotRaw(*backend, layout, minimalLiveSnapshot(ns.string(), snap_y, {committedRow("b", mb)})); + const auto before = backend->get(layout.refCkptKey(life)); + ASSERT_TRUE(before); + ASSERT_EQ(backend->casPut(layout.refCkptKey(life), encodeRefCkpt(RefCkpt{ + .life_epoch = 1, + .committed_through = RefTxnId{1, 20}, + .checkpoint_snapshot_id = snap_y, + .last_epoch_seal = std::nullopt}), before->token).outcome, CasOutcome::Committed); + }; + + backend->resetCounts(); + auto store = openPool(backend); + const auto b = store->resolveRef(ns, "b"); + ASSERT_TRUE(b.has_value()); + EXPECT_EQ(b->manifest_id.ref, mb); + EXPECT_FALSE(store->resolveRef(ns, "a").has_value()) << "must converge on snapshot Y, not a mix of X and Y"; + EXPECT_EQ(store->refRecoveryRestartsForTest(ns), 1u); + EXPECT_TRUE(vanish_fired) << "the fixture must reach the old snapshot GET and fire the replacement hook"; + EXPECT_FALSE(backend->vanish_once_keys.contains(layout.refSnapshotKey(life, snap_x))); + EXPECT_EQ(backend->getCount(layout.refLogKey(life, snap_x)), 1u); + EXPECT_EQ(backend->getCount(layout.refLogKey(life, RefTxnId{1, 20})), 1u); +} + +/// A DIFFERENT valid object at the exact snapshot key (not merely absent) is corruption, never a +/// restart signal -- pins the boundary between "vanished" (restart) and "corrupt" (fail closed). +TEST(CASRefWriterRecovery, DifferentBytesAtSelectedSnapshotIsCorruptionNotRestart) +{ + auto backend = std::make_shared(); + const Layout layout("p"); + const RootNamespace ns{"srv1/corrupt_snap"}; + const RefTxnId snap_x{1, 10}; + + /// A structurally-valid snapshot BODY, but for a DIFFERENT namespace, placed under `ns`'s own key + /// (a copy-under-the-wrong-prefix scenario) -- decodeRefTableSnapshot's key/body cross-check must + /// reject it, never treat it as a restart signal. + /// Stage B (Task 4-C): pin `ns` to the sentinel before the raw write below -- `store->resolveRef` + /// further down is a real production read that would otherwise mint a fresh RANDOM incarnation + /// for this unadmitted namespace instead of adopting the sentinel the raw fixture writes at. + DB::Cas::tests::fixture::admitLive(*backend, layout, ns); + const NamespaceLifeId life = *CasRefCatalog::lifeIfCataloged(*backend, layout, ns); + const RootNamespace other_ns{"srv1/other"}; + DB::Cas::tests::fixture::writeRefLogRaw(*backend, layout, RefLogTxn{ + .ns = ns.string(), + .txn_id = snap_x, + .ops = publishCommittedOps("anchor", manifestRef(1, 10, 1)), + .prev_epoch_seal = std::nullopt}); + DB::Cas::RefTableSnapshot foreign; + foreign.ns = other_ns.string(); + foreign.snapshot_id = snap_x; + const String snapshot_key = layout.refSnapshotKey(life, snap_x); + ASSERT_EQ(backend->putIfAbsent(snapshot_key, + DB::Cas::sealObject(DB::Cas::FormatId::RefSnapshot, DB::Cas::encodeRefTableSnapshot(foreign))).outcome, + PutOutcome::Done); + ASSERT_EQ(backend->putIfAbsent(layout.refCkptKey(life), encodeRefCkpt(RefCkpt{ + .life_epoch = 1, + .committed_through = snap_x, + .checkpoint_snapshot_id = snap_x, + .last_epoch_seal = std::nullopt})).outcome, PutOutcome::Done); + + auto store = openPool(backend); + backend->resetCounts(); + expectThrowsCode(DB::ErrorCodes::CORRUPTED_DATA, [&] { store->resolveRef(ns, "anything"); }); + EXPECT_EQ(backend->getCount(layout.refLogKey(life, snap_x)), 1u) + << "the matching ordinary log must be validated before the selected snapshot"; + EXPECT_EQ(backend->getCount(snapshot_key), 1u) + << "the corruption must come from decoding the required checkpoint snapshot"; +} + +/// =================================================================================== +/// Append lane: request cost + batching (spec §Common Mutation Path / §Local Batching Queue) +/// =================================================================================== + +TEST(CASRefWriterAppendLane, CommittedChunkPublishesFrontierBeforeInstallAndAck) +{ + auto backend = std::make_shared(); + auto store = openPool(backend); + const RootNamespace ns{"srv1/warm"}; + publishEmptyPart(store, ns, "part_1"); + publishEmptyPart(store, ns, "part_2"); + ASSERT_TRUE(store->resolveRef(ns, "part_1").has_value()); + ASSERT_TRUE(store->resolveRef(ns, "part_2").has_value()); + + const NamespaceLifeId life = CasRefCatalog::lifeIfCataloged(*backend, store->layout(), ns).value(); + const String log_prefix = store->layout().namespaceStreamPrefix(life) + "_log/"; + const String ckpt_key = store->layout().refCkptKey(life); + const auto ckpt_before = readCkpt(*backend, store->layout(), life); + ASSERT_TRUE(ckpt_before); + ASSERT_TRUE(ckpt_before->ckpt.committed_through); + const RefTxnId expected_frontier{ + ckpt_before->ckpt.committed_through->writer_epoch, + ckpt_before->ckpt.committed_through->ref_sequence + 1}; + backend->clearRequestJournal(); + const uint64_t list_before = backend->listTotal(); + const uint64_t put_before = backend->putTotal(); + const uint64_t ckpt_get_before = backend->getCount(ckpt_key); + const uint64_t ckpt_cas_before = backend->casPutCount(ckpt_key); + + std::mutex mutex; + std::condition_variable cv; + bool pre_carve_entered = false; + bool post_install_entered = false; + bool release_post_install = false; + bool follower_returned = false; + store->setRefPreCarveHookForTest([&] + { + std::unique_lock lock(mutex); + if (pre_carve_entered) + return; + pre_carve_entered = true; + cv.notify_all(); + cv.wait(lock, [&] { return store->refQueuePendingForTest(ns) >= 2; }); + }); + store->setCarveHookForTest([&](CasRefLedger::CarvePhaseForTest phase) + { + if (phase != CasRefLedger::CarvePhaseForTest::PostInstallPreAck) + return; + backend->recordRequestJournalEvent("INSTALL"); + std::unique_lock lock(mutex); + post_install_entered = true; + cv.notify_all(); + cv.wait(lock, [&] { return release_post_install; }); + }); + + std::exception_ptr leader_error; + std::exception_ptr follower_error; + std::thread leader([&] + { + try + { + store->dropRef(ns, "part_1"); + } + catch (...) + { + leader_error = std::current_exception(); + } + }); + { + std::unique_lock lock(mutex); + cv.wait(lock, [&] { return pre_carve_entered; }); + } + std::thread follower([&] + { + try + { + store->dropRef(ns, "part_2"); + backend->recordRequestJournalEvent("FOLLOWER ACK"); + { + std::lock_guard lock(mutex); + follower_returned = true; + } + cv.notify_all(); + } + catch (...) + { + follower_error = std::current_exception(); + } + }); + while (store->refQueuePendingForTest(ns) < 2) + std::this_thread::yield(); + cv.notify_all(); + { + std::unique_lock lock(mutex); + cv.wait(lock, [&] { return post_install_entered; }); + } + + bool follower_returned_before_release = false; + { + std::lock_guard lock(mutex); + follower_returned_before_release = follower_returned; + } + std::exception_ptr observation_error; + bool part_1_visible = true; + bool part_2_visible = true; + try + { + part_1_visible = store->resolveRef(ns, "part_1").has_value(); + part_2_visible = store->resolveRef(ns, "part_2").has_value(); + } + catch (...) + { + observation_error = std::current_exception(); + } + { + std::lock_guard lock(mutex); + release_post_install = true; + } + cv.notify_all(); + leader.join(); + follower.join(); + store->setRefPreCarveHookForTest(nullptr); + store->setCarveHookForTest(nullptr); + + EXPECT_FALSE(follower_returned_before_release) + << "a co-batched waiter returned before the installed transaction was acknowledged"; + EXPECT_FALSE(observation_error); + EXPECT_FALSE(part_1_visible); + EXPECT_FALSE(part_2_visible) + << "both co-batched mutations must be visible before either waiter can return success"; + EXPECT_FALSE(leader_error); + EXPECT_FALSE(follower_error); + EXPECT_EQ(backend->listTotal(), list_before) << "a warm mutation performs no LIST"; + EXPECT_EQ(backend->putTotal(), put_before + 1) << "exactly one body PUT with create-if-absent"; + EXPECT_EQ(backend->getCount(ckpt_key), ckpt_get_before + 1) + << "one committed chunk pays exactly one checkpoint GET"; + EXPECT_EQ(backend->casPutCount(ckpt_key), ckpt_cas_before + 1) + << "one committed chunk pays exactly one checkpoint CAS"; + + const std::vector journal = backend->requestJournal(); + ASSERT_EQ(journal.size(), 5u); + EXPECT_EQ(journal[0].find("PUT " + log_prefix), 0u) << journal[0]; + EXPECT_EQ(journal[1], "GET " + ckpt_key); + EXPECT_EQ(journal[2], "CAS " + ckpt_key); + EXPECT_EQ(journal[3], "INSTALL"); + EXPECT_EQ(journal[4], "FOLLOWER ACK"); + + const auto durable_ckpt = readCkpt(*backend, store->layout(), life); + ASSERT_TRUE(durable_ckpt); + EXPECT_EQ(durable_ckpt->ckpt.committed_through, expected_frontier); +} + +TEST(CASRefWriterAppendLane, CheckpointConflictAfterLogCommitRequiresRecoveryWithoutInstall) +{ + auto backend = std::make_shared(); + auto store = openPool(backend); + const RootNamespace ns{"srv1/frontier-conflict"}; + publishEmptyPart(store, ns, "x"); + const NamespaceLifeId life = CasRefCatalog::lifeIfCataloged(*backend, store->layout(), ns).value(); + const String ckpt_key = store->layout().refCkptKey(life); + const auto before = readCkpt(*backend, store->layout(), life); + ASSERT_TRUE(before); + ASSERT_TRUE(before->ckpt.committed_through); + const RefTxnId candidate{before->ckpt.committed_through->writer_epoch, + before->ckpt.committed_through->ref_sequence + 1}; + const size_t tail_before = store->tailSinceSnapshotCountForTest(ns); + + backend->ckpt_conflict_key = ckpt_key; + backend->ckpt_conflict_count = 100; + expectThrowsCode(DB::ErrorCodes::NETWORK_ERROR, [&] { store->dropRef(ns, "x"); }); + + EXPECT_EQ(store->laneStateForTest(ns), RefLaneState::NeedsRecovery); + EXPECT_EQ(store->tailSinceSnapshotCountForTest(ns), tail_before) + << "the durable log was not installed or acknowledged"; + const auto after = readCkpt(*backend, store->layout(), life); + ASSERT_TRUE(after); + EXPECT_EQ(after->ckpt.committed_through, before->ckpt.committed_through); + EXPECT_TRUE(backend->get(store->layout().refLogKey(life, candidate))) + << "the log PUT committed before checkpoint publication failed"; + EXPECT_FALSE(backend->get(store->layout().refLogKey( + life, RefTxnId{candidate.writer_epoch, candidate.ref_sequence + 1}))) + << "no later id may be allocated above an unfrontiered durable transaction"; +} + +TEST(CASRefWriterAppendLane, FenceMovementAtCheckpointPublicationRequiresRecoveryWithoutInstall) +{ + auto backend = std::make_shared(); + auto store = openPool(backend); + const RootNamespace ns{"srv1/frontier-fenced"}; + publishEmptyPart(store, ns, "x"); + const NamespaceLifeId life = CasRefCatalog::lifeIfCataloged(*backend, store->layout(), ns).value(); + const String ckpt_key = store->layout().refCkptKey(life); + const auto before = readCkpt(*backend, store->layout(), life); + ASSERT_TRUE(before); + ASSERT_TRUE(before->ckpt.committed_through); + const RefTxnId candidate{before->ckpt.committed_through->writer_epoch, + before->ckpt.committed_through->ref_sequence + 1}; + const size_t tail_before = store->tailSinceSnapshotCountForTest(ns); + + backend->ckpt_get_hook_key = ckpt_key; + backend->ckpt_get_hook = [&] { store->tripMountLost(); }; + expectThrowsCode(DB::ErrorCodes::NETWORK_ERROR, [&] { store->dropRef(ns, "x"); }); + + EXPECT_EQ(store->laneStateForTest(ns), RefLaneState::NeedsRecovery); + EXPECT_EQ(store->tailSinceSnapshotCountForTest(ns), tail_before) + << "the fenced frontier attempt must not install or acknowledge the durable log"; + const auto after = readCkpt(*backend, store->layout(), life); + ASSERT_TRUE(after); + EXPECT_EQ(after->ckpt.committed_through, before->ckpt.committed_through); + EXPECT_TRUE(backend->get(store->layout().refLogKey(life, candidate))); + EXPECT_FALSE(backend->get(store->layout().refLogKey( + life, RefTxnId{candidate.writer_epoch, candidate.ref_sequence + 1}))); +} + +/// Phase 3 (reftable-cow-map materialization): each of these N +/// publishes is its own isolated (unbatched) flush touching exactly one NEW ref -- if +/// `flushRefBatch` did not materialize `rt->state.committed` after installing each flush's +/// transaction, the overlay would grow by ~1 entry per flush and this would read back ~N, +/// defeating the whole point of the COW map for a long-running table. +TEST(CASRefWriterAppendLane, MaterializeKeepsOverlaySmallAcrossManyIsolatedFlushes) +{ + auto backend = std::make_shared(); + auto store = openPool(backend); + const RootNamespace ns{"srv1/cowmap"}; + + constexpr int kRefs = 20; + for (int i = 0; i < kRefs; ++i) + publishEmptyPart(store, ns, "ref" + std::to_string(i)); + + EXPECT_LE(store->committedOverlayEntriesForTest(ns), 1u); + EXPECT_EQ(store->listRefs(ns).size(), static_cast(kRefs)); /// sanity: all N really committed +} + +/// `B` compatible queued mutations share one create (spec §Writer Budget). +TEST(CASRefWriterAppendLane, CompatibleMutationsShareOneCreate) +{ + auto backend = std::make_shared(); + auto store = openPool(backend); + const RootNamespace ns{"srv1/cobatch"}; + publishEmptyPart(store, ns, "a"); + publishEmptyPart(store, ns, "b"); + ASSERT_TRUE(store->resolveRef(ns, "a").has_value()); + ASSERT_TRUE(store->resolveRef(ns, "b").has_value()); + + std::mutex m; + std::condition_variable cv; + bool entered = false; + store->setRefPreCarveHookForTest([&] + { + std::unique_lock lk(m); + if (entered) + return; /// only the leader's own first carve blocks; a second flush (if any) proceeds + entered = true; + cv.notify_all(); + cv.wait(lk, [&] { return store->refQueuePendingForTest(ns) >= 2; }); + }); + + const uint64_t put_before = backend->putTotal(); + std::thread t_a([&] { store->dropRef(ns, "a"); }); + { + std::unique_lock lk(m); + cv.wait(lk, [&] { return entered; }); + } + std::thread t_b([&] { store->dropRef(ns, "b"); }); + while (store->refQueuePendingForTest(ns) < 2) + std::this_thread::yield(); + cv.notify_all(); /// wakes the pre-carve hook's own wait once its predicate (>=2 pending) holds + t_a.join(); + t_b.join(); + store->setRefPreCarveHookForTest(nullptr); + + EXPECT_EQ(backend->putTotal(), put_before + 1) << "both drops must land in ONE created log object"; + EXPECT_FALSE(store->resolveRef(ns, "a").has_value()); + EXPECT_FALSE(store->resolveRef(ns, "b").has_value()); +} + +/// An invalid queued request returns its own exception without entering the transaction; the +/// co-batched neighbor still lands, in the SAME one create. +TEST(CASRefWriterAppendLane, InvalidBatchEntryGetsOwnExceptionBatchSurvives) +{ + auto backend = std::make_shared(); + auto store = openPool(backend); + const RootNamespace ns{"srv1/invalid_entry"}; + publishEmptyPart(store, ns, "good"); + + std::mutex m; + std::condition_variable cv; + bool entered = false; + store->setRefPreCarveHookForTest([&] + { + std::unique_lock lk(m); + if (entered) + return; + entered = true; + cv.notify_all(); + cv.wait(lk, [&] { return store->refQueuePendingForTest(ns) >= 2; }); + }); + + const uint64_t put_before = backend->putTotal(); + std::exception_ptr bad_error; + std::thread t_bad([&] + { + try { store->dropRef(ns, "does_not_exist"); } + catch (...) { bad_error = std::current_exception(); } + }); + { + std::unique_lock lk(m); + cv.wait(lk, [&] { return entered; }); + } + std::thread t_good([&] { store->dropRef(ns, "good"); }); + while (store->refQueuePendingForTest(ns) < 2) + std::this_thread::yield(); + cv.notify_all(); + t_bad.join(); + t_good.join(); + store->setRefPreCarveHookForTest(nullptr); + + ASSERT_TRUE(bad_error != nullptr) << "the invalid item's OWN caller must receive its exception"; + expectThrowsCode(DB::ErrorCodes::FILE_DOESNT_EXIST, [&] { std::rethrow_exception(bad_error); }); + EXPECT_EQ(backend->putTotal(), put_before + 1) << "the survivor's own transaction still costs one create"; + EXPECT_FALSE(store->resolveRef(ns, "good").has_value()) << "the innocent co-batched drop must land"; +} + +/// =================================================================================== +/// Append lane: wedge semantics (spec §Writer-Side Linearization) +/// =================================================================================== + +TEST(CASRefWriterAppendLane, WedgedLaneBlocksSameTableWhileOtherTableProceeds) +{ + CasRequestBudget budget; + budget.max_attempts = 1; + budget.attempt_timeout_ms = 100; + budget.operation_deadline_ms = kSingleAttemptDeadlineMs; + budget.lease_safety_margin_ms = 100; + + auto backend = std::make_shared(); + auto store = openPool(backend, budget); + const Layout & layout = store->layout(); + const RootNamespace ns_a{"srv1/wedge_a"}; + const RootNamespace ns_b{"srv1/wedge_b"}; + publishEmptyPart(store, ns_a, "x"); + publishEmptyPart(store, ns_a, "x_second"); + publishEmptyPart(store, ns_b, "y"); + + backend->fault_key_substr = layout.namespaceStreamPrefix(DB::Cas::tests::fixture::fixtureLife(ns_a)) + "_log/"; + backend->fault_count = 1; + + expectThrowsCode(DB::ErrorCodes::NETWORK_ERROR, [&] { store->dropRef(ns_a, "x"); }); + EXPECT_TRUE(store->refLaneWedgedForTest(ns_a)); + + /// A different table proceeds normally while ns_a stays wedged. + EXPECT_NO_THROW(store->dropRef(ns_b, "y")); + EXPECT_FALSE(store->resolveRef(ns_b, "y").has_value()); + + /// Retrying ns_a does not allocate a later id -- it re-attempts the SAME one. The wedge's key was + /// never actually written (the fault never wrote through), and under the every-attempt rule the + /// retry is a conditional CREATE of the same bytes rather than a bare read: it lands, which makes + /// the wedged transaction durable and adopts it. That is the point of the rule -- a bare read could + /// only ever report "absent", which is not a rejection, and the lane would stay wedged forever over + /// a key nothing had written. See `gtest_cas_ref_wedge_every_attempt.cpp` for the full rule. + EXPECT_NO_THROW(store->dropRef(ns_a, "x_second")); + EXPECT_FALSE(store->refLaneWedgedForTest(ns_a)) << "the retry's own create resolves the lane"; + EXPECT_FALSE(store->resolveRef(ns_a, "x").has_value()) << "the wedged drop was adopted on resolution"; +} + +TEST(CASRefWriterAppendLane, WedgedAppendObservedDurableAppliesBeforeNextId) +{ + CasRequestBudget budget; + budget.max_attempts = 1; + budget.attempt_timeout_ms = 100; + budget.operation_deadline_ms = kSingleAttemptDeadlineMs; + budget.lease_safety_margin_ms = 100; + + auto backend = std::make_shared(); + auto store = openPool(backend, budget); + const Layout & layout = store->layout(); + const RootNamespace ns{"srv1/wedge_unwedge"}; + publishEmptyPart(store, ns, "x"); + publishEmptyPart(store, ns, "y"); + + backend->fault_key_substr = layout.namespaceStreamPrefix(DB::Cas::tests::fixture::fixtureLife(ns)) + "_log/"; + backend->fault_count = 1; + + expectThrowsCode(DB::ErrorCodes::NETWORK_ERROR, [&] { store->dropRef(ns, "x"); }); + ASSERT_TRUE(store->refLaneWedgedForTest(ns)); + ASSERT_TRUE(store->resolveRef(ns, "x").has_value()) << "not yet applied while wedged"; + + /// The earlier request eventually lands server-side; the caller just never saw the ack. + backend->materializePendingDelayedWrite(); + + /// A later mutation on the SAME table first resolves the wedge (applying "drop x" to cache) BEFORE + /// allocating its own next id (which drops "y"). + EXPECT_NO_THROW(store->dropRef(ns, "y")); + + EXPECT_FALSE(store->refLaneWedgedForTest(ns)); + EXPECT_FALSE(store->resolveRef(ns, "x").has_value()) << "the wedged drop was applied on resolution"; + EXPECT_FALSE(store->resolveRef(ns, "y").has_value()) << "the next mutation committed normally afterward"; +} + +/// Wedge tail-counter accounting across the three states (xhigh review, item F): an UNRESOLVED wedge +/// applied nothing, so it must NOT bump the applied-above-snapshot tail counters; a RESOLVED wedge is a +/// commit like any other and MUST bump them (exactly once) alongside the ordinary commit that resolves +/// it; and the resolution must fold its applied overlay in place (no residual committed overlay). Under +/// the default 256-log / 1 MiB snapshot thresholds this handful of txns never triggers a publish, so the +/// tail counter is a stable running count. +TEST(CASRefWriterAppendLane, WedgeResolutionJoinsTailCountersAndFoldsOverlay) +{ + CasRequestBudget budget; + budget.max_attempts = 1; + budget.attempt_timeout_ms = 100; + budget.operation_deadline_ms = kSingleAttemptDeadlineMs; + budget.lease_safety_margin_ms = 100; + + auto backend = std::make_shared(); + auto store = openPool(backend, budget); + const Layout & layout = store->layout(); + const RootNamespace ns{"srv1/wedge_tail"}; + publishEmptyPart(store, ns, "x"); + publishEmptyPart(store, ns, "y"); + + const size_t tail_after_setup = store->tailSinceSnapshotCountForTest(ns); + + /// Wedge the lane: the single-attempt budget turns the ambiguous log PUT into an Unresolved outcome. + backend->fault_key_substr = layout.namespaceStreamPrefix(DB::Cas::tests::fixture::fixtureLife(ns)) + "_log/"; + backend->fault_count = 1; + expectThrowsCode(DB::ErrorCodes::NETWORK_ERROR, [&] { store->dropRef(ns, "x"); }); + ASSERT_TRUE(store->refLaneWedgedForTest(ns)); + ASSERT_TRUE(store->resolveRef(ns, "x").has_value()) << "not applied while merely wedged"; + EXPECT_EQ(store->tailSinceSnapshotCountForTest(ns), tail_after_setup) + << "an UNRESOLVED wedge applied nothing and must not join the tail counters"; + + /// The wedged PUT actually landed server-side; a later mutation resolves the wedge (applying drop x) + /// before committing its own drop y. + backend->materializePendingDelayedWrite(); + EXPECT_NO_THROW(store->dropRef(ns, "y")); + + EXPECT_FALSE(store->refLaneWedgedForTest(ns)); + EXPECT_FALSE(store->resolveRef(ns, "x").has_value()) << "the wedged drop was applied on resolution"; + EXPECT_FALSE(store->resolveRef(ns, "y").has_value()); + EXPECT_EQ(store->tailSinceSnapshotCountForTest(ns), tail_after_setup + 2) + << "the RESOLVED wedge (drop x) and the ordinary commit (drop y) must each bump the tail once"; + EXPECT_EQ(store->committedOverlayEntriesForTest(ns), 0u) + << "both the wedge resolution and the ordinary commit fold their overlay in place at install"; +} + +/// B3: `Pool::wedgedRefLaneCount()` (the accessor `CasGcScheduler::gcHealth()` reads for +/// `system.cas_mounts.wedged_namespace_count`) must count EXACTLY the tables with a live +/// wedge -- neither a cached-but-healthy table nor an unrelated table's own successful mutation may move +/// it, and it must track the wedge's full lifecycle (0 -> 1 -> 0), not just a one-shot snapshot. +TEST(CASRefWriterAppendLane, WedgedRefLaneCountTracksExactlyTheWedgedTableThroughItsLifecycle) +{ + CasRequestBudget budget; + budget.max_attempts = 1; + budget.attempt_timeout_ms = 100; + budget.operation_deadline_ms = kSingleAttemptDeadlineMs; + budget.lease_safety_margin_ms = 100; + + auto backend = std::make_shared(); + auto store = openPool(backend, budget); + const Layout & layout = store->layout(); + const RootNamespace ns_a{"srv1/wedge_count_a"}; + const RootNamespace ns_b{"srv1/wedge_count_b"}; + publishEmptyPart(store, ns_a, "x"); + publishEmptyPart(store, ns_a, "y"); + publishEmptyPart(store, ns_b, "p"); + ASSERT_EQ(store->wedgedRefLaneCount(), 0u) << "both tables cached and healthy before the fault"; + + backend->fault_key_substr = layout.namespaceStreamPrefix(DB::Cas::tests::fixture::fixtureLife(ns_a)) + "_log/"; + backend->fault_count = 1; + + expectThrowsCode(DB::ErrorCodes::NETWORK_ERROR, [&] { store->dropRef(ns_a, "x"); }); + ASSERT_TRUE(store->refLaneWedgedForTest(ns_a)); + EXPECT_EQ(store->wedgedRefLaneCount(), 1u); + + /// ns_b's own mutation succeeds and must not be swept into the count. + EXPECT_NO_THROW(store->dropRef(ns_b, "p")); + EXPECT_EQ(store->wedgedRefLaneCount(), 1u) << "an unrelated table's successful mutation must not move the count"; + + /// The earlier request eventually lands server-side; resolving ns_a's wedge on its next mutation + /// drops the count back to zero. + backend->materializePendingDelayedWrite(); + EXPECT_NO_THROW(store->dropRef(ns_a, "y")); + EXPECT_FALSE(store->refLaneWedgedForTest(ns_a)); + EXPECT_EQ(store->wedgedRefLaneCount(), 0u); +} + +/// =================================================================================== +/// I1: a CORRUPTED_DATA from the retry controller (resolve-before-reissue observed a DIFFERENT object at +/// the exact key) must be surfaced LOUDLY to the caller and never hang the table's append queue. The +/// unfixed code let the throw propagate through the leader loop with `leader_active` still true, so every +/// queued and future caller for that table blocked forever in `cv.wait`. +/// =================================================================================== + +/// Append-site CORRUPTED_DATA: the offending caller gets the error, the lane is NOT wedged (a proven +/// different-object conflict is conclusive, not uncertain), and no caller HANGS -- the queue's leader +/// bookkeeping is restored, proven by a bounded wait on both a same-table and an independent-table +/// append. +/// +/// The reaction is now the mount's, not the table's [review I5]: a foreign object at a key that +/// mount-lease exclusivity says is exclusively ours contradicts the exclusivity itself, so the append +/// site routes through `reportImpossibleInterference` exactly as the wedge-resolve site does -- fence +/// closed, remount scheduled. Before this task it failed closed and stayed closed, blocking the table +/// until somebody remounted by hand. So there are two separate scopes to keep straight, and this test +/// pins both: +/// the FENCE is mount-wide -- while it is closed EVERY lane is refused, including untouched ones; +/// the DAMAGE is per-namespace -- a real remount replaces both immutable runtimes, then recovery of +/// the damaged stream still refuses while the unrelated table commits normally. +TEST(CASRefWriterAppendLane, I1AppendCorruptionSurfacesAndFencesTheMountForRemount) +{ + auto backend = std::make_shared(); + auto store = openPool(backend); + const Layout & layout = store->layout(); + const RootNamespace ns{"srv1/i1_append"}; + const RootNamespace other{"srv1/i1_other"}; + publishEmptyPart(store, ns, "x"); + publishEmptyPart(store, other, "z"); + + /// The next `_log` PUT for `ns` has a foreign different object land at its key; resolve-before-reissue + /// then observes the mismatch and raises CORRUPTED_DATA. + backend->corrupt_key_substr = layout.namespaceStreamPrefix(DB::Cas::tests::fixture::fixtureLife(ns)) + "_log/"; + backend->corrupt_count = 1; + + expectThrowsCode(DB::ErrorCodes::CORRUPTED_DATA, [&] { store->dropRef(ns, "x"); }); + EXPECT_FALSE(store->refLaneWedgedForTest(ns)) << "a proven different-object conflict must not wedge the lane"; + EXPECT_EQ(store->laneStateForTest(ns), RefLaneState::Faulted); + EXPECT_FALSE(store->mayMutate()) << "the impossible-interference reaction must fence this mount closed"; + EXPECT_EQ(store->scheduleRemountCallCountForTest(), 1u) + << "the append site must schedule the remount that re-derives this table from the durable log"; + + /// Mount-wide while fenced -- and, crucially, PROMPT: a real cv hang would time out this wait, which + /// is the regression this test was written for. + auto fenced = std::async(std::launch::async, [&] { store->dropRef(other, "z"); }); + ASSERT_EQ(fenced.wait_for(std::chrono::seconds(10)), std::future_status::ready) + << "the independent-table append hung -- the queue's leader bookkeeping was not restored"; + expectThrowsCode(DB::ErrorCodes::NETWORK_ERROR, [&] { fenced.get(); }); + + /// Drive the scheduled production recovery boundary. A direct fence re-arm is intentionally NOT a + /// substitute anymore: immutable runtimes retain the generation that admitted them and cannot be + /// rebound to the new one. + const String mount_key = layout.mountKey("test"); + const auto mount = backend->get(mount_key); + ASSERT_TRUE(mount); + MountLease fenced_mount = decodeMountLease(mount->bytes); + fenced_mount.gc_fenced = true; + fenced_mount.seq += 1; + ASSERT_EQ(backend->putOverwrite(mount_key, encodeMountLease(fenced_mount), mount->token).outcome, + PutOutcome::Done); + ASSERT_TRUE(store->tryRemountOnce()); + + auto same = std::async(std::launch::async, [&] { store->dropRef(ns, "x"); }); + ASSERT_EQ(same.wait_for(std::chrono::seconds(10)), std::future_status::ready) + << "the same-table append hung -- the queue's leader bookkeeping was not restored"; + expectThrowsCode(DB::ErrorCodes::CORRUPTED_DATA, [&] { same.get(); }); + + auto indep = std::async(std::launch::async, [&] { store->dropRef(other, "z"); }); + ASSERT_EQ(indep.wait_for(std::chrono::seconds(10)), std::future_status::ready); + indep.get(); + EXPECT_FALSE(store->resolveRef(other, "z").has_value()) + << "an unrelated table's stream is independent and must be entirely unaffected by the damage"; +} + +/// Wedge-resolve-site foreign interference: a wedged lane whose key a foreign writer overwrote must +/// surface the anomaly to the triggering caller and fault the lane, without hanging. +/// rev.6 Task 11 (spec §anomaly-policy): under the mount-lease exclusivity model this is no longer a +/// possible protocol outcome (the wedged key is exclusively ours) -- it routes through +/// `reportImpossibleInterference`, which fences the mount and schedules a remount. +/// +/// It surfaces as `CORRUPTED_DATA`. It was `LOGICAL_ERROR` between rev.6 and the every-attempt rule, +/// and that had a cost this test used to carry: `LOGICAL_ERROR` ABORTS the process in debug/sanitizer +/// builds, so the whole test had to be release-only with a death-test twin standing in elsewhere. +/// Storage-controlled input must never be able to abort the server, so the arm now reports the +/// occupant for what it is -- corruption -- and one test covers every build. +TEST(CASRefWriterAppendLane, I1WedgeResolveCorruptionSurfacesAndFaultsLane) +{ + CasRequestBudget budget; + budget.max_attempts = 1; + budget.attempt_timeout_ms = 100; + budget.operation_deadline_ms = kSingleAttemptDeadlineMs; + budget.lease_safety_margin_ms = 100; + + auto backend = std::make_shared(); + auto store = openPool(backend, budget); + const Layout & layout = store->layout(); + const RootNamespace ns{"srv1/i1_wedge"}; + publishEmptyPart(store, ns, "x"); + publishEmptyPart(store, ns, "y"); + + /// Wedge the lane with an ambiguous PUT that never landed. + backend->fault_key_substr = layout.namespaceStreamPrefix(DB::Cas::tests::fixture::fixtureLife(ns)) + "_log/"; + backend->fault_count = 1; + expectThrowsCode(DB::ErrorCodes::NETWORK_ERROR, [&] { store->dropRef(ns, "x"); }); + ASSERT_TRUE(store->refLaneWedgedForTest(ns)); + + /// A foreign writer lands a DIFFERENT object at the exact wedged key; the next append's wedge resolve + /// observes the mismatch and must raise `CORRUPTED_DATA` to that caller while faulting the lane. + const String wedged_key = store->wedgedKeyForTest(ns); + ASSERT_FALSE(wedged_key.empty()); + ASSERT_EQ(backend->putIfAbsent(wedged_key, "a-different-object").outcome, PutOutcome::Done); + + auto fut = std::async(std::launch::async, [&] + { + expectThrowsCode(DB::ErrorCodes::CORRUPTED_DATA, [&] { store->dropRef(ns, "y"); }); + }); + ASSERT_EQ(fut.wait_for(std::chrono::seconds(10)), std::future_status::ready) + << "the wedge-resolve anomaly hung the queue instead of surfacing to the caller"; + fut.get(); + EXPECT_FALSE(store->refLaneWedgedForTest(ns)); + EXPECT_EQ(store->laneStateForTest(ns), RefLaneState::Faulted) + << "foreign interference is a terminal lane verdict"; + + /// The queue's leader bookkeeping was restored, so a SUBSEQUENT same-table caller does not hang: it + /// observes the terminal state and returns promptly (a real cv hang would time out this bounded + /// wait). This is the leg the unfixed code left blocked forever. + auto fut2 = std::async(std::launch::async, [&] + { + try + { + store->dropRef(ns, "y"); + } + catch (...) // NOLINT(bugprone-empty-catch) + { + /// The anomaly is expected here; this future only verifies that the caller does not hang. + } + }); + ASSERT_EQ(fut2.wait_for(std::chrono::seconds(10)), std::future_status::ready) + << "a later same-table append hung -- the leader bookkeeping was not restored after the anomaly"; + fut2.get(); +} + +/// =================================================================================== +/// rev.6 Task 11: wedge hard contract + anomaly policy (spec §anomaly-policy) +/// =================================================================================== + +/// Foreign bytes at a wedge key (see `I1WedgeResolveCorruptionSurfacesAndFaultsLane` above for the +/// hang-freedom coverage) must ALSO trip the local write fence closed and audit a `ForeignInterference` +/// event -- the full anomaly-policy reaction, not just the throw. It runs in every build now that the +/// arm reports `CORRUPTED_DATA` instead of the process-aborting `LOGICAL_ERROR`; the death twin that +/// used to stand in for debug/sanitizer builds went with it. +TEST(CASAnomalyPolicy, ForeignBytesAtWedgeKeyTripFenceAndRemount) +{ + CasRequestBudget budget; + budget.max_attempts = 1; + budget.attempt_timeout_ms = 100; + budget.operation_deadline_ms = kSingleAttemptDeadlineMs; + budget.lease_safety_margin_ms = 100; + + auto backend = std::make_shared(); + SynchronizedEventLog seen; /// declared BEFORE the Pool so it outlives the background syncer's emits (ASan 2026-07-09) + auto store = openPool(backend, budget); + const Layout & layout = store->layout(); + const RootNamespace ns{"srv1/anomaly_wedge"}; + publishEmptyPart(store, ns, "x"); + publishEmptyPart(store, ns, "y"); + + store->setEventSink([&](const CasEvent & e) { seen.add(e); }); + + /// Wedge the lane with an ambiguous PUT that never landed. + backend->fault_key_substr = layout.namespaceStreamPrefix(DB::Cas::tests::fixture::fixtureLife(ns)) + "_log/"; + backend->fault_count = 1; + expectThrowsCode(DB::ErrorCodes::NETWORK_ERROR, [&] { store->dropRef(ns, "x"); }); + ASSERT_TRUE(store->refLaneWedgedForTest(ns)); + ASSERT_TRUE(store->mayMutate()) << "the fence must not be tripped yet -- only an ordinary Unresolved wedge so far"; + ASSERT_EQ(store->scheduleRemountCallCountForTest(), 0u) << "no remount must have been scheduled yet by the ordinary wedge alone"; + + /// Out-of-band, a foreign writer lands DIFFERENT bytes at the exact wedged key. + const String wedged_key = store->wedgedKeyForTest(ns); + ASSERT_FALSE(wedged_key.empty()); + ASSERT_EQ(backend->putIfAbsent(wedged_key, "a-different-object").outcome, PutOutcome::Done); + + /// The next append's wedge resolve observes the mismatch: CORRUPTED_DATA, the fence trips closed, + /// and a ForeignInterference event is audited. + expectThrowsCode(DB::ErrorCodes::CORRUPTED_DATA, [&] { store->dropRef(ns, "y"); }); + + EXPECT_FALSE(store->refLaneWedgedForTest(ns)); + EXPECT_EQ(store->laneStateForTest(ns), RefLaneState::Faulted) + << "foreign interference must fault the lane"; + EXPECT_FALSE(store->mayMutate()) << "the local write fence must trip closed on the anomaly"; + /// Positively pins that `reportImpossibleInterference` called `scheduleRemount` (not just + /// `tripMountLost`, which alone already accounts for `mayMutate() == false` above). Counted at + /// `scheduleRemount`'s own entry regardless of `background_watermark` -- see that accessor's + /// comment for why this test deliberately does NOT enable `background_watermark` to observe a real + /// spawned thread: doing so was tried and makes the store's self-remount attempt race its own + /// still-live keeper for 30+ seconds per call (confirmed while building this test), which is not + /// something a fast unit test should be driving. + EXPECT_EQ(store->scheduleRemountCallCountForTest(), 1u) + << "reportImpossibleInterference must have called scheduleRemount exactly once"; + + const std::vector observed = seen.snapshot(); + const auto has_event = std::any_of(observed.begin(), observed.end(), + [](const CasEvent & e) { return e.type == CasEventType::ForeignInterference; }); + EXPECT_TRUE(has_event) << "a ForeignInterference CasEvent must be audited"; +} + +/// An impossible non-`Ready` state at new-id allocation must refuse before minting an id, fault the +/// lane, and trigger the anomaly policy. The synthetic wedge is injected after the top-of-flush +/// resolver gate, so it represents an internal lifecycle contradiction rather than a normal wedge. +TEST(CASAnomalyPolicy, NonReadyAtNewIdAllocationFaultsAndFailsClosed) +{ + auto backend = std::make_shared(); + SynchronizedEventLog seen; /// declared BEFORE the Pool so it outlives the background syncer's emits (ASan 2026-07-09) + auto store = openPool(backend); + const Layout & layout = store->layout(); + const RootNamespace ns{"srv1/wedge_contract"}; + publishEmptyPart(store, ns, "x"); + + store->setEventSink([&](const CasEvent & e) { seen.add(e); }); + + store->setRefPreCarveHookForTest([&] + { + store->forceWedgeForTest(ns, /*writer_epoch*/ 1, /*ref_sequence*/ 1, "bogus/_log/key", "bogus-bytes"); + }); + + /// Ground truth: no NEW `_log` object may appear -- the guard must refuse BEFORE any id is minted + /// or PUT attempted. + auto countLogObjects = [&] + { + size_t n = 0; + String cursor; + for (;;) + { + const ListPage page = backend->list(layout.namespaceStreamPrefix(DB::Cas::tests::fixture::fixtureLife(ns)), cursor, 1000); + for (const ListedKey & lk : page.keys) + { + const auto parsed = layout.parseRefObjectKey(lk.key); + if (parsed && parsed->life_id == DB::Cas::tests::fixture::fixtureLife(ns).incarnation && parsed->kind == RefObjectKind::Log) + ++n; + } + if (page.next_cursor.empty()) + break; + cursor = page.next_cursor; + } + return n; + }; + const size_t log_objects_before = countLogObjects(); + + ASSERT_TRUE(store->mayMutate()) << "the fence must be armed BEFORE the wedge-contract violation, or the guard would trivially pass for the wrong reason"; + ASSERT_EQ(store->scheduleRemountCallCountForTest(), 0u) << "no remount must have been scheduled yet"; + + /// BACKLOG `{#lane-terminal-reported-as-retryable}`: `Faulted` is a TERMINAL lane state, the same + /// one every OTHER `Faulted` arm in `commitRefChunk` reports as `CORRUPTED_DATA` -- reporting it as + /// `NETWORK_ERROR`/retry-later would tell the caller a state the lane can never leave on its own is + /// transient. + expectThrowsCode(DB::ErrorCodes::CORRUPTED_DATA, [&] { store->dropRef(ns, "x"); }); + + EXPECT_EQ(countLogObjects(), log_objects_before) << "the release guard must refuse before allocating/PUTting a new _log object"; + EXPECT_EQ(store->laneStateForTest(ns), RefLaneState::Faulted) + << "the invariant violation must have one explicit terminal state"; + EXPECT_FALSE(store->mayMutate()) << "the local write fence must trip closed on the wedge-contract violation"; + /// See the sibling test's comment on why this checks the call-count seam (never `background_watermark` + /// + a real thread -- that combination makes the store's self-remount race its own still-live keeper). + EXPECT_EQ(store->scheduleRemountCallCountForTest(), 1u) + << "reportImpossibleInterference must have called scheduleRemount exactly once"; + + const std::vector observed = seen.snapshot(); + const auto has_event = std::any_of(observed.begin(), observed.end(), + [](const CasEvent & e) { return e.type == CasEventType::ForeignInterference; }); + EXPECT_TRUE(has_event) << "a ForeignInterference CasEvent must be audited"; +} + +/// I3: a conditional write whose attempt classified Committed but whose FINAL post-write fence check +/// failed (the mount fence was lost after the write may have landed) is counted separately, not folded +/// into the generic Unresolved classifier (spec §Late Predecessor PUT best-effort diagnostic). +TEST(CASRequestControllerFenceLoss, I3PostWriteFenceLossIsCounted) +{ + using ProfileEvents::global_counters; + auto backend = std::make_shared(); + CasRequestBudget budget; + budget.max_attempts = 3; + CasRequestController ctrl(backend, budget, [] { return static_cast(0); }); // fixed clock + + /// `fence_ok` holds for the pre-attempt check, then is lost by the post-write check. + int calls = 0; + auto fence_ok = [&calls] { return ++calls <= 1; }; + + const auto before = global_counters[ProfileEvents::CASConditionalWriteFenceLostPostWrite].load(); + const CasWriteOutcome outcome = ctrl.putIfAbsentControlled("k", "v", fence_ok); + EXPECT_EQ(outcome, CasWriteOutcome::Unresolved) << "a post-write fence loss must never be reported as Committed"; + EXPECT_EQ(global_counters[ProfileEvents::CASConditionalWriteFenceLostPostWrite].load(), before + 1); +} + +/// Task B (stageManifest rides the controller): a Committed return surfaces the committed +/// incarnation's token — from the attempt's own PutResult, and equally from a resolve that proves an +/// earlier ambiguous attempt landed — so audit emitters (`PartWriteTxn::stageManifest`'s `ManifestPut` +/// event) keep their token without a follow-up HEAD. +TEST(CASRequestController, CommittedSurfacesTokenFromPutAndFromResolve) +{ + auto backend = std::make_shared(); + CasRequestController ctrl(backend, CasRequestBudget{}, [] { return static_cast(0); }); + const auto fence_ok = [] { return true; }; + + Token direct_token; + ASSERT_EQ(ctrl.putIfAbsentControlled("k1", "v1", fence_ok, &direct_token), CasWriteOutcome::Committed); + EXPECT_EQ(direct_token, backend->head("k1").token) << "the direct-commit token is the PutResult's"; + + /// k2 already holds the IDENTICAL bytes (an earlier ambiguous attempt that landed): the attempt's + /// PreconditionFailed collapses to Unresolved and the resolve GET proves Committed — the token must + /// be the observed incarnation's, and no second incarnation is ever created. + const Token pre_existing = backend->putIfAbsent("k2", "v2").token; + Token resolved_token; + ASSERT_EQ(ctrl.putIfAbsentControlled("k2", "v2", fence_ok, &resolved_token), CasWriteOutcome::Committed); + EXPECT_EQ(resolved_token, pre_existing) << "the resolve-commit token is the observed incarnation's"; +} + +/// =================================================================================== +/// Task 13: whole-table ref-cache eviction (spec §Byte, Memory, And CPU Budget) +/// =================================================================================== + +/// A tiny cache budget forces WHOLE-TABLE eviction: publishing to several tables in turn keeps only the +/// most-recently-touched one resident, and an evicted table re-recovers its exact committed state on the +/// next touch (spec §Startup And Recovery: "Evicting the table drops the entire object; the next access +/// repeats recovery"). +TEST(CASRefTableCacheEviction, WholeTableEvictionUnderBudgetReRecovers) +{ + auto backend = std::make_shared(); + auto store = openPoolWithConfig(backend, + PoolConfig{.pool_prefix = "p", .server_root_id = "test", .ref_table_cache_bytes = 1}); + const RootNamespace ns_a{"srv1/evict_a"}; + const RootNamespace ns_b{"srv1/evict_b"}; + const RootNamespace ns_c{"srv1/evict_c"}; + + publishEmptyPart(store, ns_a, "x"); + publishEmptyPart(store, ns_b, "y"); + publishEmptyPart(store, ns_c, "z"); + + /// A 1-byte budget is below one table's weight, so each new table evicts the prior idle ones: only + /// the last-touched table stays resident (the just-recovered table is never evicted). + EXPECT_EQ(store->refTablesCachedCountForTest(), 1u); + EXPECT_TRUE(store->refTableCachedForTest(ns_c)); + EXPECT_FALSE(store->refTableCachedForTest(ns_a)); + EXPECT_FALSE(store->refTableCachedForTest(ns_b)); + + /// The evicted table re-recovers its exact committed state on next touch. + const auto resolved = store->resolveRef(ns_a, "x"); + ASSERT_TRUE(resolved.has_value()) << "an evicted table must re-recover its committed ref"; + /// That touch, in turn, evicted the previously-resident table under the same budget. + EXPECT_TRUE(store->refTableCachedForTest(ns_a)); + EXPECT_FALSE(store->refTableCachedForTest(ns_c)); +} + +/// A zero budget disables eviction entirely: every touched table stays resident. +TEST(CASRefTableCacheEviction, ZeroBudgetDisablesEviction) +{ + auto backend = std::make_shared(); + auto store = openPoolWithConfig(backend, + PoolConfig{.pool_prefix = "p", .server_root_id = "test", .ref_table_cache_bytes = 0}); + for (const String & n : {String("srv1/keep_a"), String("srv1/keep_b"), String("srv1/keep_c")}) + publishEmptyPart(store, RootNamespace{n}, "x"); + EXPECT_EQ(store->refTablesCachedCountForTest(), 3u); +} + +/// A table with a WEDGED append lane is never evicted, even when idle and over budget: its uncertain +/// in-flight PUT is not reconstructable from the durable objects (spec §Writer-Side Linearization), so +/// re-recovery must not be allowed to drop and re-materialize it (which could re-allocate an id). +TEST(CASRefTableCacheEviction, WedgedTableIsNeverEvicted) +{ + CasRequestBudget budget; + budget.max_attempts = 1; + budget.attempt_timeout_ms = 100; + budget.operation_deadline_ms = kSingleAttemptDeadlineMs; + budget.lease_safety_margin_ms = 100; + + auto backend = std::make_shared(); + auto store = openPoolWithConfig(backend, + PoolConfig{.pool_prefix = "p", .server_root_id = "test", + .cas_request_budget = budget, .ref_table_cache_bytes = 1}); + const Layout & layout = store->layout(); + const RootNamespace ns_w{"srv1/wedged"}; + publishEmptyPart(store, ns_w, "x"); + + /// Wedge ns_w's append lane with one ambiguous (Unresolved) PUT that exhausts the single-attempt budget. + backend->fault_key_substr = layout.namespaceStreamPrefix(DB::Cas::tests::fixture::fixtureLife(ns_w)) + "_log/"; + backend->fault_count = 1; + expectThrowsCode(DB::ErrorCodes::NETWORK_ERROR, [&] { store->dropRef(ns_w, "x"); }); + ASSERT_TRUE(store->refLaneWedgedForTest(ns_w)); + + /// Pressure the cache with other tables. ns_w is idle and over the 1-byte budget, but its wedged lane + /// makes it non-evictable, so its wedge state survives (a fresh runtime would report no wedge). + publishEmptyPart(store, RootNamespace{"srv1/other_a"}, "y"); + publishEmptyPart(store, RootNamespace{"srv1/other_b"}, "z"); + + EXPECT_TRUE(store->refTableCachedForTest(ns_w)) << "a wedged table must never be evicted"; + EXPECT_TRUE(store->refLaneWedgedForTest(ns_w)) << "and its wedge state survives"; +} + +/// =================================================================================== +/// Task 11: snapshot publication (spec §writer-snapshot-publication) +/// =================================================================================== + +/// The count threshold fires a background publish covering the whole retained tail; its bytes must +/// equal an INDEPENDENT oracle's replay of the same logs through the published id (cache-replay +/// equivalence), and the retained tail must be fully pruned afterward (spec: "Publication is +/// background and never blocks an append"). +TEST(CASRefWriterSnapshotPublish, ThresholdTriggerPublishesCacheReplayEquivalentBytes) +{ + auto backend = std::make_shared(); + const Layout layout("p"); + PoolConfig config; + config.snapshot_log_count_threshold = 3; + config.snapshot_log_bytes_threshold = 1ULL << 40; + auto store = openPoolWithConfig(backend, config); + const RootNamespace ns{"srv1/threshold_publish"}; + + publishEmptyPart(store, ns, "a"); /// tail: 2 (birth+add, promote) + publishEmptyPart(store, ns, "b"); /// tail: 3, then 4 (4 > 3 -> dispatches ONE background publish) + + store->waitForSnapshotPublishSettleForTest(ns); + + const auto snap_id = listGreatestSnapshotIdForTest(*backend, layout, ns); + ASSERT_TRUE(snap_id.has_value()) << "the threshold trigger must have published a snapshot"; + EXPECT_TRUE(store->newestPublishedSnapshotIdForTest(ns) == snap_id); + EXPECT_EQ(store->tailSinceSnapshotCountForTest(ns), 0u) << "a snapshot covering everything prunes the whole tail"; + + const auto got = backend->get(layout.refSnapshotKey(DB::Cas::tests::fixture::fixtureLife(ns), *snap_id)); + ASSERT_TRUE(got.has_value()); + + /// The independent oracle: replay every `_log/` object directly, ignoring the snapshot entirely. + const RefTableState oracle = independentFullReplayForTest(*backend, layout, ns, snap_id); + const String expected_bytes = encodeRefTableSnapshot(snapshotOf(oracle, ns.string())); + EXPECT_EQ(openObject(FormatId::RefSnapshot, got->bytes), expected_bytes) + << "published snapshot bytes must equal replay(logs through X)"; +} + +/// A publisher owns the runtime it captured, not the logical name. If that exact life is deleted and +/// the name is reborn while snapshot bytes are still only local, the old attempt must become inert: in +/// particular it must not recreate the predecessor's `_snap` or `_ckpt` after the GC retired them. +TEST(CASRefWriterSnapshotPublish, CapturedPredecessorCannotPublishAfterSameNameRebirth) +{ + auto backend = std::make_shared(); + PoolConfig config; + config.gc_fold_threshold = 1; + config.ref_table_cache_bytes = 0; + config.snapshot_log_count_threshold = 1ULL << 40; + config.snapshot_log_bytes_threshold = 1ULL << 40; + auto store = openPoolWithConfig(backend, config); + const Layout & layout = store->layout(); + const RootNamespace ns{"srv1/publisher-predecessor-rebirth"}; + + publishWithProductionBirth(store, ns, "predecessor"); + const CatalogEntry predecessor = catalogEntryOrThrow(*backend, layout, ns); + const NamespaceLifeId predecessor_life + = NamespaceLifeId::fromCatalogEntry(ns, predecessor.incarnation); + const auto predecessor_snapshot + = listGreatestLogIdForLifeForTest(*backend, layout, predecessor_life); + ASSERT_TRUE(predecessor_snapshot); + + Gc gc(store, UInt128{105}); + ASSERT_FALSE(runRegularRoundReclaiming(gc).deferred); + + std::mutex mutex; + std::condition_variable cv; + bool captured = false; + bool release = false; + store->setSnapshotAfterCaptureHookForTest([&] + { + std::unique_lock lock(mutex); + captured = true; + cv.notify_all(); + cv.wait(lock, [&] { return release; }); + }); + + auto publisher = std::async(std::launch::async, [&]() -> bool + { + return store->tryPublishSnapshotAndAdvanceCheckpointOnce(ns); + }); + SCOPE_EXIT({ + { + std::lock_guard lock(mutex); + release = true; + } + cv.notify_all(); + }); + { + std::unique_lock lock(mutex); + ASSERT_TRUE(cv.wait_for(lock, std::chrono::seconds(10), [&] { return captured; })); + } + + EXPECT_NO_THROW(store->dropNamespace(ns)); + EXPECT_FALSE(runRegularRoundReclaiming(gc).deferred); + EXPECT_TRUE(runRegularRoundReclaiming(gc).deferred); + const RefCatalog after_removal = CasRefCatalog::read(*backend, layout).catalog; + EXPECT_TRUE(std::none_of(after_removal.entries.begin(), after_removal.entries.end(), + [&](const CatalogEntry & entry) { return entry.ns == ns; })); + + publishWithProductionBirth(store, ns, "successor"); + const uint64_t successor_runtime = store->refTableRuntimeIdentityForTest(ns); + const CatalogEntry successor = catalogEntryOrThrow(*backend, layout, ns); + EXPECT_NE(successor.incarnation, predecessor.incarnation); + const auto predecessor_ckpt_before_resume = backend->get(layout.refCkptKey(predecessor_life)); + ASSERT_TRUE(predecessor_ckpt_before_resume) + << "the removal protocol leaves this checkpoint as janitor-owned predecessor debris"; + + { + std::lock_guard lock(mutex); + release = true; + } + cv.notify_all(); + ASSERT_EQ(publisher.wait_for(std::chrono::seconds(10)), std::future_status::ready); + EXPECT_FALSE(publisher.get()); + store->setSnapshotAfterCaptureHookForTest(nullptr); + + EXPECT_FALSE(backend->get(layout.refSnapshotKey(predecessor_life, *predecessor_snapshot))) + << "a stale publisher recreated the retired predecessor snapshot"; + const auto predecessor_ckpt_after_resume = backend->get(layout.refCkptKey(predecessor_life)); + ASSERT_TRUE(predecessor_ckpt_after_resume); + EXPECT_EQ(predecessor_ckpt_after_resume->token, predecessor_ckpt_before_resume->token) + << "a stale publisher replaced the retired predecessor checkpoint"; + EXPECT_EQ(predecessor_ckpt_after_resume->bytes, predecessor_ckpt_before_resume->bytes) + << "a stale publisher changed the retired predecessor checkpoint"; + EXPECT_EQ(store->refTableRuntimeIdentityForTest(ns), successor_runtime); + EXPECT_TRUE(store->resolveRef(ns, "successor")); +} + +/// The runtime admission check belongs inside every retrying `_ckpt` CAS attempt, not merely before +/// calling the checkpoint helper. Retirement in the body-PUT/checkpoint gap leaves the already-written +/// snapshot as harmless debris but must not advance or recreate the predecessor checkpoint. +TEST(CASRefWriterSnapshotPublish, RetiredPredecessorCannotAdvanceCkptAfterSnapshotPut) +{ + auto backend = std::make_shared(); + PoolConfig config; + config.gc_fold_threshold = 1; + config.ref_table_cache_bytes = 0; + config.snapshot_log_count_threshold = 1ULL << 40; + config.snapshot_log_bytes_threshold = 1ULL << 40; + auto store = openPoolWithConfig(backend, config); + const Layout & layout = store->layout(); + const RootNamespace ns{"srv1/publisher-predecessor-ckpt-race"}; + + publishWithProductionBirth(store, ns, "predecessor"); + const CatalogEntry predecessor = catalogEntryOrThrow(*backend, layout, ns); + const NamespaceLifeId predecessor_life + = NamespaceLifeId::fromCatalogEntry(ns, predecessor.incarnation); + const auto candidate_id = listGreatestLogIdForLifeForTest(*backend, layout, predecessor_life); + ASSERT_TRUE(candidate_id); + + Gc gc(store, UInt128{106}); + ASSERT_FALSE(runRegularRoundReclaiming(gc).deferred); + + std::mutex mutex; + std::condition_variable cv; + bool before_ckpt_cas = false; + bool release = false; + store->setSnapshotBeforeCkptCasHookForTest([&] + { + std::unique_lock lock(mutex); + before_ckpt_cas = true; + cv.notify_all(); + cv.wait(lock, [&] { return release; }); + }); + + auto publisher = std::async(std::launch::async, [&]() -> bool + { + return store->tryPublishSnapshotAndAdvanceCheckpointOnce(ns); + }); + SCOPE_EXIT({ + { + std::lock_guard lock(mutex); + release = true; + } + cv.notify_all(); + }); + { + std::unique_lock lock(mutex); + ASSERT_TRUE(cv.wait_for(lock, std::chrono::seconds(10), [&] { return before_ckpt_cas; })); + } + EXPECT_TRUE(backend->get(layout.refSnapshotKey(predecessor_life, *candidate_id))) + << "the hook must run after the snapshot body PUT and immediately before `_ckpt` admission"; + + EXPECT_NO_THROW(store->dropNamespace(ns)); + EXPECT_FALSE(runRegularRoundReclaiming(gc).deferred); + EXPECT_TRUE(runRegularRoundReclaiming(gc).deferred); + const RefCatalog after_removal = CasRefCatalog::read(*backend, layout).catalog; + EXPECT_TRUE(std::none_of(after_removal.entries.begin(), after_removal.entries.end(), + [&](const CatalogEntry & entry) { return entry.ns == ns; })); + + publishWithProductionBirth(store, ns, "successor"); + const uint64_t successor_runtime = store->refTableRuntimeIdentityForTest(ns); + const CatalogEntry successor = catalogEntryOrThrow(*backend, layout, ns); + EXPECT_NE(successor.incarnation, predecessor.incarnation); + const auto predecessor_ckpt_before_resume = backend->get(layout.refCkptKey(predecessor_life)); + ASSERT_TRUE(predecessor_ckpt_before_resume); + + { + std::lock_guard lock(mutex); + release = true; + } + cv.notify_all(); + ASSERT_EQ(publisher.wait_for(std::chrono::seconds(10)), std::future_status::ready); + EXPECT_FALSE(publisher.get()); + store->setSnapshotBeforeCkptCasHookForTest(nullptr); + + const auto predecessor_ckpt_after_resume = backend->get(layout.refCkptKey(predecessor_life)); + ASSERT_TRUE(predecessor_ckpt_after_resume); + EXPECT_EQ(predecessor_ckpt_after_resume->token, predecessor_ckpt_before_resume->token); + EXPECT_EQ(predecessor_ckpt_after_resume->bytes, predecessor_ckpt_before_resume->bytes); + EXPECT_EQ(store->refTableRuntimeIdentityForTest(ns), successor_runtime); + EXPECT_TRUE(store->resolveRef(ns, "successor")); +} + +/// A read that already owns the predecessor runtime does not consult the name slot again after a +/// same-name successor is published. Because removal applies the terminal state before retirement, a +/// reader paused immediately before its state lock resumes with `NotFound`, never successor data. +TEST(CASRefWriterRuntimeIdentity, CapturedReaderCannotRetargetSameNameSuccessor) +{ + auto backend = std::make_shared(); + PoolConfig config; + config.gc_fold_threshold = 1; + config.ref_table_cache_bytes = 0; + config.snapshot_log_count_threshold = 1ULL << 40; + config.snapshot_log_bytes_threshold = 1ULL << 40; + auto store = openPoolWithConfig(backend, config); + const Layout & layout = store->layout(); + const RootNamespace ns{"srv1/captured-reader-rebirth"}; + + publishWithProductionBirth(store, ns, "shared"); + ASSERT_TRUE(store->resolveRef(ns, "shared")); + const CatalogEntry predecessor = catalogEntryOrThrow(*backend, layout, ns); + Gc gc(store, UInt128{107}); + ASSERT_FALSE(runRegularRoundReclaiming(gc).deferred); + + std::mutex mutex; + std::condition_variable cv; + bool captured = false; + bool release = false; + store->setReadBeforeStateLockHookForTest([&] + { + std::unique_lock lock(mutex); + if (captured) + return; + captured = true; + cv.notify_all(); + cv.wait(lock, [&] { return release; }); + }); + auto reader = std::async(std::launch::async, [&] + { + return store->resolveRef(ns, "shared"); + }); + SCOPE_EXIT({ + { + std::lock_guard lock(mutex); + release = true; + } + cv.notify_all(); + }); + { + std::unique_lock lock(mutex); + ASSERT_TRUE(cv.wait_for(lock, std::chrono::seconds(10), [&] { return captured; })); + } + + EXPECT_NO_THROW(store->dropNamespace(ns)); + EXPECT_FALSE(runRegularRoundReclaiming(gc).deferred); + EXPECT_TRUE(runRegularRoundReclaiming(gc).deferred); + publishWithProductionBirth(store, ns, "shared"); + const CatalogEntry successor = catalogEntryOrThrow(*backend, layout, ns); + EXPECT_NE(successor.incarnation, predecessor.incarnation); + const uint64_t successor_runtime = store->refTableRuntimeIdentityForTest(ns); + const auto successor_ref = store->resolveRef(ns, "shared"); + ASSERT_TRUE(successor_ref); + + { + std::lock_guard lock(mutex); + release = true; + } + cv.notify_all(); + ASSERT_EQ(reader.wait_for(std::chrono::seconds(10)), std::future_status::ready); + EXPECT_FALSE(reader.get()) << "the captured predecessor reader retargeted through the name slot"; + store->setReadBeforeStateLockHookForTest(nullptr); + + EXPECT_EQ(store->refTableRuntimeIdentityForTest(ns), successor_runtime); + const auto successor_after = store->resolveRef(ns, "shared"); + ASSERT_TRUE(successor_after); + EXPECT_EQ(successor_after->manifest_id.ref, successor_ref->manifest_id.ref); +} + +/// Ordinary append admission also owns the runtime it captured. If removal and rebirth complete before +/// enqueue, the predecessor's closed lane returns retry-later; it cannot enqueue into or mutate the +/// successor even when the successor deliberately reuses the same logical ref name. +TEST(CASRefWriterRuntimeIdentity, CapturedAppendCannotEnqueueIntoSameNameSuccessor) +{ + auto backend = std::make_shared(); + PoolConfig config; + config.gc_fold_threshold = 1; + config.ref_table_cache_bytes = 0; + config.snapshot_log_count_threshold = 1ULL << 40; + config.snapshot_log_bytes_threshold = 1ULL << 40; + auto store = openPoolWithConfig(backend, config); + const Layout & layout = store->layout(); + const RootNamespace ns{"srv1/captured-append-rebirth"}; + + publishWithProductionBirth(store, ns, "shared"); + const CatalogEntry predecessor = catalogEntryOrThrow(*backend, layout, ns); + Gc gc(store, UInt128{108}); + ASSERT_FALSE(runRegularRoundReclaiming(gc).deferred); + + std::mutex mutex; + std::condition_variable cv; + bool captured = false; + bool release = false; + store->setAppendAfterRuntimeCaptureHookForTest([&] + { + std::unique_lock lock(mutex); + if (captured) + return; + captured = true; + cv.notify_all(); + cv.wait(lock, [&] { return release; }); + }); + auto append = std::async(std::launch::async, [&] + { + store->dropRef(ns, "shared"); + }); + SCOPE_EXIT({ + { + std::lock_guard lock(mutex); + release = true; + } + cv.notify_all(); + }); + { + std::unique_lock lock(mutex); + ASSERT_TRUE(cv.wait_for(lock, std::chrono::seconds(10), [&] { return captured; })); + } + + EXPECT_NO_THROW(store->dropNamespace(ns)); + EXPECT_FALSE(runRegularRoundReclaiming(gc).deferred); + EXPECT_TRUE(runRegularRoundReclaiming(gc).deferred); + publishWithProductionBirth(store, ns, "shared"); + const CatalogEntry successor = catalogEntryOrThrow(*backend, layout, ns); + EXPECT_NE(successor.incarnation, predecessor.incarnation); + const uint64_t successor_runtime = store->refTableRuntimeIdentityForTest(ns); + const auto successor_ref = store->resolveRef(ns, "shared"); + ASSERT_TRUE(successor_ref); + + { + std::lock_guard lock(mutex); + release = true; + } + cv.notify_all(); + ASSERT_EQ(append.wait_for(std::chrono::seconds(10)), std::future_status::ready); + expectThrowsCode(DB::ErrorCodes::NETWORK_ERROR, [&] { append.get(); }); + store->setAppendAfterRuntimeCaptureHookForTest(nullptr); + + EXPECT_EQ(store->refTableRuntimeIdentityForTest(ns), successor_runtime); + const auto successor_after = store->resolveRef(ns, "shared"); + ASSERT_TRUE(successor_after); + EXPECT_EQ(successor_after->manifest_id.ref, successor_ref->manifest_id.ref); +} + +/// Exact retirement is pointer/key scoped. A delayed notification for the predecessor may arrive after +/// its same-name successor is already attached; it must not erase or poison that successor slot. +TEST(CASRefWriterRuntimeIdentity, LatePredecessorInvalidationLeavesSuccessorAttached) +{ + auto backend = std::make_shared(); + PoolConfig config; + config.gc_fold_threshold = 1; + config.ref_table_cache_bytes = 0; + auto store = openPoolWithConfig(backend, config); + const Layout & layout = store->layout(); + const RootNamespace ns{"srv1/late-predecessor-invalidation"}; + + publishWithProductionBirth(store, ns, "predecessor"); + const CatalogEntry predecessor = catalogEntryOrThrow(*backend, layout, ns); + const NamespaceLifeId predecessor_life + = NamespaceLifeId::fromCatalogEntry(ns, predecessor.incarnation); + Gc gc(store, UInt128{109}); + ASSERT_FALSE(runRegularRoundReclaiming(gc).deferred); + store->dropNamespace(ns); + ASSERT_FALSE(runRegularRoundReclaiming(gc).deferred); + ASSERT_TRUE(runRegularRoundReclaiming(gc).deferred); + + publishWithProductionBirth(store, ns, "successor"); + const CatalogEntry successor = catalogEntryOrThrow(*backend, layout, ns); + ASSERT_NE(successor.incarnation, predecessor.incarnation); + const NamespaceLifeId successor_life + = NamespaceLifeId::fromCatalogEntry(ns, successor.incarnation); + const uint64_t successor_runtime = store->refTableRuntimeIdentityForTest(ns); + const auto successor_ref = store->resolveRef(ns, "successor"); + ASSERT_TRUE(successor_ref); + + store->invalidateRemovedCatalogLife(predecessor_life); + + EXPECT_EQ(store->refTableRuntimeIdentityForTest(ns), successor_runtime); + EXPECT_EQ(store->refTableLifeForTest(ns), successor_life); + const auto successor_after = store->resolveRef(ns, "successor"); + ASSERT_TRUE(successor_after); + EXPECT_EQ(successor_after->manifest_id.ref, successor_ref->manifest_id.ref); +} + +/// Task 13 (spec §implementation-impact): a threshold snapshot publish increments the writer-side +/// observability counters -- snapshot PUT bytes and the tail-logs-compacted count +/// (logs-per-table-after-snapshot). Before/after deltas prove both sites fire. +TEST(CASRefWriterSnapshotPublish, PublishIncrementsSnapshotCounters) +{ + using ProfileEvents::global_counters; + const auto bytes_before = global_counters[ProfileEvents::CASRefSnapshotPutBytes].load(); + const auto logs_before = global_counters[ProfileEvents::CASRefSnapshotTailLogs].load(); + + auto backend = std::make_shared(); + const Layout layout("p"); + PoolConfig config; + config.snapshot_log_count_threshold = 3; + config.snapshot_log_bytes_threshold = 1ULL << 40; + auto store = openPoolWithConfig(backend, config); + const RootNamespace ns{"srv1/counter_publish"}; + + publishEmptyPart(store, ns, "a"); + publishEmptyPart(store, ns, "b"); + store->waitForSnapshotPublishSettleForTest(ns); + + ASSERT_TRUE(listGreatestSnapshotIdForTest(*backend, layout, ns).has_value()) + << "the threshold trigger must have published a snapshot"; + EXPECT_GT(global_counters[ProfileEvents::CASRefSnapshotPutBytes].load(), bytes_before); + EXPECT_GT(global_counters[ProfileEvents::CASRefSnapshotTailLogs].load(), logs_before); +} + +/// A fresh mount that recovers a large PRE-EXISTING tail (left by a predecessor whose own thresholds +/// never fired) retains that tail as trigger debt. Recovery ends at a terminal epoch seal, which is not +/// snapshot-serializable; one ordinary successor makes the inherited over-threshold tail publishable. +/// The single successor alone is below the threshold, so the dispatch still proves the mount-time tail +/// was retained rather than forgotten during recovery. +TEST(CASRefWriterSnapshotPublish, MountTimeRecoveredLargeTailPublishesAfterOrdinarySuccessor) +{ + auto backend = std::make_shared(); + const Layout layout("p"); + const RootNamespace ns{"srv1/mount_time_publish"}; + + { + /// Predecessor: default (high) thresholds, so nothing publishes yet. 3 parts -> 6 tail entries. + auto predecessor = openPool(backend); + publishEmptyPart(predecessor, ns, "a"); + publishEmptyPart(predecessor, ns, "b"); + publishEmptyPart(predecessor, ns, "c"); + } /// mount released; the tail is durable but nothing has published it + + PoolConfig config; + config.snapshot_log_count_threshold = 3; + config.snapshot_log_bytes_threshold = 1ULL << 40; + auto successor = openPoolWithConfig(backend, config); + + /// A mere read triggers recovery. The recovered tail is already above threshold, but its greatest + /// applied record is the terminal seal, so there is deliberately no snapshot candidate yet. + EXPECT_EQ(successor->listRefs(ns).size(), 3u); + successor->waitForSnapshotPublishSettleForTest(ns); + EXPECT_GT(successor->tailSinceSnapshotCountForTest(ns), config.snapshot_log_count_threshold) + << "the mount must retain the predecessor's large uncovered tail"; + EXPECT_FALSE(listGreatestSnapshotIdForTest(*backend, layout, ns).has_value()) + << "a terminal recovery seal is not snapshot-serializable"; + + /// One ordinary transaction above the seal reopens the candidate. It cannot cross the threshold + /// by itself; publication therefore depends on the recovered mount-time tail asserted above. + successor->dropRef(ns, "c"); + successor->waitForSnapshotPublishSettleForTest(ns); + + const auto snap_id = listGreatestSnapshotIdForTest(*backend, layout, ns); + ASSERT_TRUE(snap_id.has_value()) + << "the ordinary successor must make the inherited mount-time tail publishable"; + EXPECT_EQ(successor->tailSinceSnapshotCountForTest(ns), 0u); + + const auto got = backend->get(layout.refSnapshotKey(DB::Cas::tests::fixture::fixtureLife(ns), *snap_id)); + ASSERT_TRUE(got.has_value()); + const RefTableState oracle = independentFullReplayForTest(*backend, layout, ns, snap_id); + EXPECT_EQ(openObject(FormatId::RefSnapshot, got->bytes), encodeRefTableSnapshot(snapshotOf(oracle, ns.string()))); +} + +/// =================================================================================== +/// rev.6 Task 10 (spec §publish-from-live): the grace-window machinery +/// (`snapshot_min_log_age_ms`, the tail-replay-from-`snapshot_base_state` copy-once path, +/// `CasRefLatePredecessorObserved`) is DELETED. The Task 8 recovery-seal plus the Task 6 +/// recovery seal already makes a late-arriving predecessor write born-covered for every +/// observer by the time this writer could ever see it, so a young committed txn has nothing left to +/// wait out -- it is immediately publish-eligible, with no time manipulation anywhere below. +/// =================================================================================== + +/// A just-committed txn is covered by a publish forced immediately afterward -- no fake clock, no +/// aging, no waiting: the OLD grace-window code would have published nothing here at all. +TEST(CASRefWriterPublishFromLive, YoungTxnIsCoveredImmediately) +{ + auto backend = std::make_shared(); + const Layout layout("p"); + const RootNamespace ns{"srv1/publish_from_live_young"}; + auto store = openPool(backend); + + /// Setup: birth the namespace and add a precommit (not the txn under test). + auto build = startBuildFor(store, ns, "a"); + const ManifestId id = build->stageManifest({}); + build->precommitAdd(ns, "a", id); + + /// The ONE committed txn under test. + build->promote(ns, "a", build->buildId(), id); + + ASSERT_TRUE(store->tryPublishSnapshotAndAdvanceCheckpointOnce(ns)) + << "publish-from-live: a just-committed txn is immediately coverable, with no grace window"; + const auto snap_id = listGreatestSnapshotIdForTest(*backend, layout, ns); + ASSERT_TRUE(snap_id.has_value()); + const auto got = backend->get(layout.refSnapshotKey(DB::Cas::tests::fixture::fixtureLife(ns), *snap_id)); + ASSERT_TRUE(got.has_value()); + const RefTableSnapshot snap = decodeRefTableSnapshot(openObject(FormatId::RefSnapshot, got->bytes), ns.string(), *snap_id); + ASSERT_EQ(snap.committed.size(), 1u); + EXPECT_EQ(snap.committed.front().ref_name, "a") + << "the published snapshot body contains the just-promoted row"; +} + +/// The count trigger fires purely off the tail counters -- no aging involved -- even under a boot +/// clock that never advances (the old code REQUIRED aging past `snapshot_min_log_age_ms` to fire). +TEST(CASRefWriterSnapshotPublish, TriggerFiresOnCountAboveThresholdWithoutAging) +{ + using ProfileEvents::global_counters; + auto backend = std::make_shared(); + const RootNamespace ns{"srv1/publish_from_live_trigger"}; + uint64_t fake_now = 1'000'000; /// frozen: never advances + + PoolConfig config; + config.snapshot_log_count_threshold = 3; + config.snapshot_log_bytes_threshold = 1ULL << 40; + config.boot_ms_fn = [&fake_now] { return fake_now; }; + config.mount_lease_ttl_ms = std::chrono::milliseconds(10'000'000); + auto store = openPoolWithConfig(backend, config); + + const auto before = global_counters[ProfileEvents::CASRefSnapshotPublishDispatched].load(); + publishEmptyPart(store, ns, "a"); /// tail: 2 + publishEmptyPart(store, ns, "b"); /// tail: 4 > 3 -> dispatches, clock frozen throughout + store->waitForSnapshotPublishSettleForTest(ns); + + EXPECT_GT(global_counters[ProfileEvents::CASRefSnapshotPublishDispatched].load(), before) + << "the count trigger must fire without any aging, even under a frozen clock"; +} + +/// Adoption subtracts EXACTLY the counters captured at copy time, not whatever the counters read at +/// adoption time: while a publish's PUT is in flight (captured count/bytes fixed), more commits land +/// on the live counters. After adoption, the counters must equal precisely the amount appended AFTER +/// the copy -- not zero (would drop the new txns from the next publish trigger) and not negative/ +/// wrapped (an unsigned underflow). +TEST(CASRefWriterSnapshotPublish, AdoptionSubtractsCapturedCountersUnderConcurrentAppends) +{ + auto backend = std::make_shared(); + const RootNamespace ns{"srv1/publish_from_live_adoption"}; + auto store = openPool(backend); + + publishEmptyPart(store, ns, "a"); + ASSERT_EQ(store->tailSinceSnapshotCountForTest(ns), 2u); + + backend->armPutBlock("_snap/"); + std::thread publisher([&] { store->tryPublishSnapshotAndAdvanceCheckpointOnce(ns); }); + backend->awaitBlockEntered(); /// the candidate (count=2) is captured; the PUT is now in flight, no lock held + + publishEmptyPart(store, ns, "b"); /// +2 more commits land WHILE the publish's PUT is in flight + EXPECT_EQ(store->tailSinceSnapshotCountForTest(ns), 4u); + + backend->releaseBlock(); + publisher.join(); + + EXPECT_EQ(store->tailSinceSnapshotCountForTest(ns), 2u) + << "adoption must subtract only the CAPTURED count (2), leaving exactly the 2 txns appended " + "after the copy"; +} + +/// Publication must never block a concurrent append on the SAME table (spec: "Publication is +/// background and never blocks an append"): while a dispatched background publish is stuck mid-PUT, an +/// ordinary mutation on the table must still complete promptly (a real deadlock would hang this test). +TEST(CASRefWriterSnapshotPublish, PublicationNeverBlocksConcurrentAppend) +{ + auto backend = std::make_shared(); + const Layout layout("p"); + const RootNamespace ns{"srv1/publish_no_block"}; + PoolConfig config; + config.snapshot_log_count_threshold = 3; + config.snapshot_log_bytes_threshold = 1ULL << 40; + auto store = openPoolWithConfig(backend, config); + + backend->armPutBlock("_snap/"); + + publishEmptyPart(store, ns, "a"); + publishEmptyPart(store, ns, "b"); /// tail reaches 4 (> 3) -> dispatches a background publish + + backend->awaitBlockEntered(); /// the dispatched attempt is now stuck mid-PUT on the snapshot key + + /// An unrelated mutation on the SAME table must complete without waiting for the stuck publish. + EXPECT_NO_THROW(store->dropRef(ns, "a")); + EXPECT_FALSE(store->resolveRef(ns, "a").has_value()); + + backend->releaseBlock(); + store->waitForSnapshotPublishSettleForTest(ns); + EXPECT_TRUE(listGreatestSnapshotIdForTest(*backend, layout, ns).has_value()); +} + +/// Review caution (T10 review): a dispatched background publish must never outlive the Pool object +/// it operates on -- `maybeScheduleSnapshotPublish` captures `shared_from_this()` BY VALUE into the +/// dispatch lambda specifically to guarantee this (the classic "background thread references a +/// dangling owner" shutdown segfault, avoided here since a shared_ptr copy keeps the object alive for +/// as long as the thread holds it, regardless of what every OTHER holder does). Proves it directly: +/// blocks a dispatched publish mid-PUT, drops the TEST's own (only) Pool handle while still blocked, +/// and confirms via a `weak_ptr` that the Pool demonstrably survives on the blocked thread's own +/// reference alone. Then unblocks it with no live Pool handle anywhere in this test any more -- a +/// dangling-pointer crash here would abort the whole test binary, the strongest possible signal for +/// this specific hazard. +TEST(CASRefWriterSnapshotPublish, PublishThreadOutlivesDroppedPoolHandleWithoutCrashing) +{ + auto backend = std::make_shared(); + const RootNamespace ns{"srv1/publish_outlives_store"}; + PoolConfig config; + config.snapshot_log_count_threshold = 3; + config.snapshot_log_bytes_threshold = 1ULL << 40; + auto store = openPoolWithConfig(backend, config); + + backend->armPutBlock("_snap/"); + publishEmptyPart(store, ns, "a"); + publishEmptyPart(store, ns, "b"); /// tail reaches 4 (> 3) -> dispatches a background publish + backend->awaitBlockEntered(); /// stuck mid-PUT, holding its OWN shared_ptr copy + + std::weak_ptr weak_store = store; + store.reset(); /// drop the ONLY Pool handle this test holds + EXPECT_FALSE(weak_store.expired()) + << "the blocked background thread's own shared_ptr copy must keep the Pool alive"; + + backend->releaseBlock(); + /// Deterministic, sleep-free: waits for the blocked call to actually RETURN (not merely unblock), + /// entirely through the backend -- this test holds no Pool handle to wait on any more. + backend->awaitBlockedCallCompleted(); +} + +/// Review (T11) — CRITICAL: publishes are NOT serialized, so two overlapping attempts can finish out of +/// order. An OLDER-candidate publish that lands its `_snap` PUT AFTER a newer one already adopted must +/// NOT regress `newest_snapshot_id` back to its older id, and its (monotonically-skipped) adoption must +/// NOT touch the tail counters a newer attempt already reset -- either would drop the txns committed in +/// between, so the NEXT published snapshot would silently omit committed transactions and recovery +/// would lose refs. Deterministic, sleep-free: the fake backend blocks publish #1's PUT (capturing +/// exactly its key) while a higher-id publish #2 runs to completion, then unblocks #1. +TEST(CASRefWriterSnapshotPublish, ConcurrentOutOfOrderPublishDoesNotRegressBaseNorDropCommittedTxns) +{ + auto backend = std::make_shared(); + const Layout layout("p"); + const RootNamespace ns{"srv1/concurrent_publish_monotonic"}; + PoolConfig config; + /// High thresholds: NO automatic background dispatch -- we drive + /// `tryPublishSnapshotAndAdvanceCheckpointOnce` directly for full determinism. + config.snapshot_log_count_threshold = 1ULL << 40; + config.snapshot_log_bytes_threshold = 1ULL << 40; + auto store = openPoolWithConfig(backend, config); + + publishEmptyPart(store, ns, "a"); /// tail: 2 (birth+add, promote) + publishEmptyPart(store, ns, "b"); /// tail: 4 -- greatest_applied is publish #1's candidate + + /// Block ONLY publish #1's own `_snap` PUT (its exact key is captured on first match); a later, + /// different `_snap/` key proceeds unblocked. + backend->armPutBlockFirstMatchOnly("_snap/"); + + std::thread publisher1([&] { store->tryPublishSnapshotAndAdvanceCheckpointOnce(ns); }); + backend->awaitBlockEntered(); /// #1 is parked mid-PUT on `_snap/`, holding no lock + + /// While #1 is parked, commit more txns and run publish #2 to COMPLETION: it PUTs a strictly higher + /// `_snap/` (unblocked) and adopts it, resetting the tail counters through its own candidate. + publishEmptyPart(store, ns, "c"); + ASSERT_TRUE(store->tryPublishSnapshotAndAdvanceCheckpointOnce(ns)); + const auto newest_after_2 = store->newestPublishedSnapshotIdForTest(ns); + ASSERT_TRUE(newest_after_2.has_value()); + ASSERT_EQ(store->tailSinceSnapshotCountForTest(ns), 0u) << "publish #2 covers everything committed so far"; + + /// Release #1: on the BUGGY code it now adopts its OLDER candidate, regressing newest below #2 + /// and/or double-subtracting from the counters #2 already reset. The monotonic guard must skip + /// that adoption entirely -- both the `newest_snapshot_id` write and the counter subtraction. + backend->releaseBlock(); + publisher1.join(); + + const auto newest_after_1 = store->newestPublishedSnapshotIdForTest(ns); + ASSERT_TRUE(newest_after_1.has_value()); + EXPECT_FALSE(*newest_after_1 < *newest_after_2) + << "a late-finishing OLDER publish must not regress newest_snapshot_id below the adopted newer one"; + EXPECT_EQ(store->tailSinceSnapshotCountForTest(ns), 0u) + << "publish #1's skipped (monotonically-superseded) adoption must not subtract from counters " + "publish #2 already reset -- an unguarded subtraction here would corrupt or underflow them"; + + /// Independent proof no committed txn was lost: the NEXT publish's bytes must equal a full log replay. + /// A regressed base would omit the txns committed while publish #1 was parked. + publishEmptyPart(store, ns, "d"); + ASSERT_TRUE(store->tryPublishSnapshotAndAdvanceCheckpointOnce(ns)); + const auto snap_id = listGreatestSnapshotIdForTest(*backend, layout, ns); + ASSERT_TRUE(snap_id.has_value()); + const auto got = backend->get(layout.refSnapshotKey(DB::Cas::tests::fixture::fixtureLife(ns), *snap_id)); + ASSERT_TRUE(got.has_value()); + const RefTableState oracle = independentFullReplayForTest(*backend, layout, ns, snap_id); + EXPECT_EQ(openObject(FormatId::RefSnapshot, got->bytes), encodeRefTableSnapshot(snapshotOf(oracle, ns.string()))) + << "published snapshot bytes must equal replay(all logs through X) -- a regressed base drops txns"; +} + +/// (I1, review of commit 9093482176a) `clampedCounterSub`'s actual clamp-to-zero branch -- the exact +/// hazard it exists for -- was previously unpinned: `AdoptionSubtractsCapturedCountersUnderConcurrentAppends` +/// subtracts from a counter that never goes below the captured amount (no clamp needed), and in +/// `ConcurrentOutOfOrderPublish...` above the SMALLER candidate is the one parked, so its adoption is +/// skipped entirely by the T11 monotonic guard BEFORE it would ever reach the subtraction -- the clamp +/// is never exercised either way. This test forces the one ordering the guard does NOT catch: the +/// SMALLER candidate adopts (and subtracts) FIRST, then a LARGER candidate -- captured earlier, while +/// the counter still held the region the smaller one just subtracted -- adopts second. Its captured +/// count therefore double-counts that already-subtracted region, and `clampedCounterSub` must clamp +/// to zero rather than wrap a `uint64_t` to ~`UINT64_MAX` (which would permanently re-latch the C4 +/// storm trigger in a release build -- no `chassert` to catch it). Deterministic, sleep-free: two +/// `_snap` PUTs are parked independently (both past their own capture, neither yet adopted) via +/// `armPutBlockIndependently`, then released in the specific order that reproduces the hazard. +TEST(CASRefWriterSnapshotPublish, ClampedCounterSubClampsInsteadOfUnderflowingOnOutOfOrderAdoption) +{ + using ProfileEvents::global_counters; + auto backend = std::make_shared(); + const RootNamespace ns{"srv1/clamp_out_of_order"}; + PoolConfig config; + /// High thresholds: NO automatic background dispatch -- we drive + /// `tryPublishSnapshotAndAdvanceCheckpointOnce` directly for full determinism. + config.snapshot_log_count_threshold = 1ULL << 40; + config.snapshot_log_bytes_threshold = 1ULL << 40; + auto store = openPoolWithConfig(backend, config); + + publishEmptyPart(store, ns, "a"); /// tail: 2 -- publisher A's (smaller) candidate + + backend->armPutBlockIndependently("_snap/"); + + std::thread publisher_a([&] { store->tryPublishSnapshotAndAdvanceCheckpointOnce(ns); }); + backend->awaitAtLeastNKeysBlocked(1); /// A has captured (candidate=2 txns, count=2) and parked mid-PUT + const String key_a = *backend->blockedKeysSnapshot().begin(); + + publishEmptyPart(store, ns, "b"); /// tail: 4 -- publisher B's (larger) candidate, captured BELOW + + std::thread publisher_b([&] { store->tryPublishSnapshotAndAdvanceCheckpointOnce(ns); }); + backend->awaitAtLeastNKeysBlocked(2); /// B has ALSO captured (candidate=4 txns, count=4) and parked + const auto blocked = backend->blockedKeysSnapshot(); + ASSERT_EQ(blocked.size(), 2u) << "both publishers must be parked past their own capture before either adopts"; + String key_b; + for (const auto & k : blocked) + if (k != key_a) + key_b = k; + ASSERT_FALSE(key_b.empty()); + + /// Release the SMALLER candidate first: its monotonic guard passes (newest is still unset), so it + /// adopts -- newest becomes A's candidate, and the count drops from the live 4 to 2 (4 - captured_A=2). + backend->releaseKey(key_a); + publisher_a.join(); + EXPECT_EQ(store->tailSinceSnapshotCountForTest(ns), 2u) + << "publisher A (smaller candidate) adopts first and subtracts its own captured count safely"; + + /// Release the LARGER candidate: its monotonic guard ALSO passes (newest=A's candidate < B's + /// candidate), so it reaches the subtraction with `captured_count_B == 4` -- but the live counter + /// is now only 2 (A's adoption already removed the overlapping region). A plain `fetch_sub` here + /// would wrap to ~UINT64_MAX; `clampedCounterSub` must clamp to 0 instead. + backend->releaseKey(key_b); + publisher_b.join(); + EXPECT_EQ(store->tailSinceSnapshotCountForTest(ns), 0u) + << "clampedCounterSub must clamp to 0, not underflow/wrap, when B's captured count (4) already " + "includes the region A's earlier adoption already subtracted"; + + /// A wrapped counter would read as ~UINT64_MAX, permanently latching `over_threshold` (the C4 + /// storm regression). With the huge threshold configured above, a dispatch firing here can ONLY + /// mean the counter is corrupted -- a clamped counter of 0 never crosses it. + const auto dispatched_before = global_counters[ProfileEvents::CASRefSnapshotPublishDispatched].load(); + for (int i = 0; i < 5; ++i) + store->resolveRef(ns, "a"); + store->waitForSnapshotPublishSettleForTest(ns); + EXPECT_EQ(global_counters[ProfileEvents::CASRefSnapshotPublishDispatched].load(), dispatched_before) + << "a correctly-clamped counter must never latch the threshold trigger"; +} + +/// =================================================================================== +/// C4: bound the read-triggered snapshot-publish dispatch (spec §writer-snapshot-publication). A +/// fold-heavy reader must not turn every ref read into a re-dispatched full-snapshot encode+PUT: an +/// in-flight gate admits at most one publish per table, and a non-Committed outcome arms a bounded +/// per-table backoff instead of re-triggering on the next read. The unfixed code dispatched a new +/// publish on every trigger and never backed off, producing the soak's 46 GB/hr `_snap` PUT storm. +/// =================================================================================== + +/// Under a saturated backend (every `_snap` PUT is Unresolved), the read path must NOT re-dispatch a +/// publish on each read: the failure arms the backoff, and while it holds no read re-dispatches. +TEST(CASRefWriterSnapshotPublish, C4LatchBoundedUnderSustainedNonCommittedPublish) +{ + using ProfileEvents::global_counters; + auto backend = std::make_shared(); + const RootNamespace ns{"srv1/c4_latch"}; + + CasRequestBudget budget; /// one attempt per publish so a failure is a single PUT + budget.max_attempts = 1; + budget.attempt_timeout_ms = 100; + budget.operation_deadline_ms = kSingleAttemptDeadlineMs; + budget.lease_safety_margin_ms = 100; + + uint64_t fake_now = 1'000'000; + PoolConfig config; + config.snapshot_log_count_threshold = 1; + config.snapshot_log_bytes_threshold = 1ULL << 40; + config.snapshot_publish_backoff_initial_ms = 5000; /// the frozen clock keeps the backoff armed + config.mount_lease_ttl_ms = std::chrono::milliseconds(10'000'000); + config.boot_ms_fn = [&fake_now] { return fake_now; }; + config.cas_request_budget = budget; + auto store = openPoolWithConfig(backend, config); + + /// Every `_snap` PUT throws Unresolved (backend saturated), from the very first publish attempt. + backend->fault_key_substr = "_snap/"; + backend->fault_count = 100000; + + publishEmptyPart(store, ns, "a"); /// crosses the threshold -> one dispatch -> fails -> backoff armed + store->waitForSnapshotPublishSettleForTest(ns); + + const auto dispatched_before = global_counters[ProfileEvents::CASRefSnapshotPublishDispatched].load(); + for (int i = 0; i < 30; ++i) + { + store->resolveRef(ns, "a"); + store->waitForSnapshotPublishSettleForTest(ns); + } + EXPECT_EQ(global_counters[ProfileEvents::CASRefSnapshotPublishDispatched].load(), dispatched_before) + << "reads within the backoff window must not re-dispatch a publish (the storm latch is broken)"; +} + +/// Recovery can leave the runtime at an epoch seal with a tail already above the threshold. The seal +/// is not snapshot-serializable, so admission itself must reject it: letting execution reject it would +/// make settlement immediately dispatch another identical background attempt. A later ordinary record +/// must re-enable the same scheduler. +TEST(CASRefWriterSnapshotPublish, RecoveredSealAboveThresholdDoesNotRedispatchUntilOrdinarySuccessor) +{ + using ProfileEvents::global_counters; + auto backend = std::make_shared(); + const RootNamespace ns{"srv1/recovered_seal_no_storm"}; + + { + PoolConfig predecessor_config; + predecessor_config.snapshot_log_count_threshold = 1ULL << 40; + predecessor_config.snapshot_log_bytes_threshold = 1ULL << 40; + auto predecessor = openPoolWithConfig(backend, predecessor_config); + DB::Cas::tests::fixture::admitLive(*backend, predecessor->layout(), ns); + const NamespaceLifeId life = *CasRefCatalog::lifeIfCataloged(*backend, predecessor->layout(), ns); + ASSERT_EQ(backend->putIfAbsent(predecessor->layout().refCkptKey(life), encodeRefCkpt(RefCkpt{ + .life_epoch = predecessor->liveWriterEpoch(), + .committed_through = std::nullopt, + .checkpoint_snapshot_id = std::nullopt, + .last_epoch_seal = std::nullopt})).outcome, PutOutcome::Done); + publishEmptyPart(predecessor, ns, "before_seal"); + const auto before = backend->get(predecessor->layout().refCkptKey(life)); + ASSERT_TRUE(before); + const RefCkpt before_seal = decodeRefCkpt(before->bytes); + ASSERT_TRUE(before_seal.committed_through); + const RefTxnId seal_id{before_seal.committed_through->writer_epoch, + before_seal.committed_through->ref_sequence + 1}; + writeSealAt(*backend, predecessor->layout(), ns, seal_id); + + RefCkpt recovered_seal = before_seal; + recovered_seal.committed_through = seal_id; + recovered_seal.last_epoch_seal = seal_id; + ASSERT_EQ(backend->casPut(predecessor->layout().refCkptKey(life), encodeRefCkpt(recovered_seal), before->token).outcome, + CasOutcome::Committed); + } + + PoolConfig successor_config; + successor_config.snapshot_log_count_threshold = 0; + successor_config.snapshot_log_bytes_threshold = 1ULL << 40; + auto successor = openPoolWithConfig(backend, successor_config); + + EXPECT_TRUE(successor->resolveRef(ns, "before_seal").has_value()); + successor->waitForSnapshotPublishSettleForTest(ns); + const auto dispatched_at_seal = global_counters[ProfileEvents::CASRefSnapshotPublishDispatched].load(); + for (int i = 0; i < 5; ++i) + EXPECT_TRUE(successor->resolveRef(ns, "before_seal").has_value()); + successor->waitForSnapshotPublishSettleForTest(ns); + EXPECT_EQ(global_counters[ProfileEvents::CASRefSnapshotPublishDispatched].load(), dispatched_at_seal) + << "a recovered seal must not dispatch or re-dispatch an unpublishable snapshot candidate"; + + /// One ordinary append transaction above the recovered seal must reopen the scheduler. `dropRef` + /// is exactly one ordinary ref-log append, unlike `publishEmptyPart`'s two-phase part publication. + successor->dropRef(ns, "before_seal"); + successor->waitForSnapshotPublishSettleForTest(ns); + EXPECT_EQ(global_counters[ProfileEvents::CASRefSnapshotPublishDispatched].load(), dispatched_at_seal + 1) + << "an ordinary successor above the seal must make the threshold candidate publishable again"; +} + +/// While one background publish is in flight (blocked mid-PUT), further reads must NOT dispatch a +/// second: the single-in-flight gate holds `pending_snapshot_publishes` at one per table. +TEST(CASRefWriterSnapshotPublish, C4InFlightGateAdmitsAtMostOne) +{ + auto backend = std::make_shared(); + const RootNamespace ns{"srv1/c4_gate"}; + PoolConfig config; + config.snapshot_log_count_threshold = 0; /// any nonempty tail triggers + config.snapshot_log_bytes_threshold = 1ULL << 40; + auto store = openPoolWithConfig(backend, config); + + publishEmptyPart(store, ns, "a"); + store->waitForSnapshotPublishSettleForTest(ns); /// drain the setup publishes; tail is compacted + + /// Block the first `_snap` PUT so one publisher parks in flight. + backend->armPutBlockFirstMatchOnly("_snap/"); + std::thread mutator([&] { store->dropRef(ns, "a"); }); /// its detached publisher blocks mid-PUT + backend->awaitBlockEntered(); + + /// Many more reads while it is blocked must not admit a second publisher. + for (int i = 0; i < 20; ++i) + store->resolveRef(ns, "a"); + EXPECT_EQ(store->pendingSnapshotPublishesForTest(ns), 1) + << "the in-flight gate must hold background publishes to at most one per table"; + + backend->releaseBlock(); + mutator.join(); + store->waitForSnapshotPublishSettleForTest(ns); +} + +/// A non-Committed publish defers the next dispatch by the backoff, then a read past the backoff +/// deadline dispatches exactly one retry that publishes a durable snapshot (freshness preserved). +TEST(CASRefWriterSnapshotPublish, C4BackoffDefersThenRetriesAndPublishes) +{ + using ProfileEvents::global_counters; + auto backend = std::make_shared(); + const Layout layout("p"); + const RootNamespace ns{"srv1/c4_backoff"}; + + CasRequestBudget budget; + budget.max_attempts = 1; + budget.attempt_timeout_ms = 100; + budget.operation_deadline_ms = kSingleAttemptDeadlineMs; + budget.lease_safety_margin_ms = 100; + + uint64_t fake_now = 1'000'000; + PoolConfig config; + config.snapshot_log_count_threshold = 1; + config.snapshot_log_bytes_threshold = 1ULL << 40; + config.snapshot_publish_backoff_initial_ms = 1000; + config.mount_lease_ttl_ms = std::chrono::milliseconds(10'000'000); + config.boot_ms_fn = [&fake_now] { return fake_now; }; + config.cas_request_budget = budget; + auto store = openPoolWithConfig(backend, config); + + /// Fail ONLY the first `_snap` PUT (arms the backoff); later PUTs succeed. + backend->fault_key_substr = "_snap/"; + backend->fault_count = 1; + + publishEmptyPart(store, ns, "a"); /// dispatch -> publish fails -> backoff armed + store->waitForSnapshotPublishSettleForTest(ns); + EXPECT_FALSE(listGreatestSnapshotIdForTest(*backend, layout, ns).has_value()); + + /// A read within the backoff window (frozen clock) must not re-dispatch. + const auto d1 = global_counters[ProfileEvents::CASRefSnapshotPublishDispatched].load(); + store->resolveRef(ns, "a"); + store->waitForSnapshotPublishSettleForTest(ns); + EXPECT_EQ(global_counters[ProfileEvents::CASRefSnapshotPublishDispatched].load(), d1) + << "a read within the backoff window must not re-dispatch"; + EXPECT_FALSE(listGreatestSnapshotIdForTest(*backend, layout, ns).has_value()); + + /// Advance past the backoff: exactly one retry is dispatched and it publishes. + fake_now += 2000; + store->resolveRef(ns, "a"); + store->waitForSnapshotPublishSettleForTest(ns); + EXPECT_EQ(global_counters[ProfileEvents::CASRefSnapshotPublishDispatched].load(), d1 + 1) + << "after the backoff elapses exactly one retry is dispatched"; + EXPECT_TRUE(listGreatestSnapshotIdForTest(*backend, layout, ns).has_value()) + << "the retry publishes a durable snapshot (freshness preserved)"; +} + +/// =================================================================================== +/// rev.6 Task 10 (spec §publish-from-live): the tail counters count ONLY applied txns strictly above +/// `newest_snapshot_id` -- incremented per commit, subtracted (clamped) exactly by adoption. This +/// pins that a successful publish's adoption RESETS the counters rather than merely reducing them: a +/// buggy "subtract a fixed prune count" scheme could let the table's already-covered history keep +/// contributing to the trigger forever. +/// =================================================================================== + +/// After a successful publish adopts `newest_snapshot_id`, the trigger arithmetic must restart from +/// zero above it, not keep counting the table's already-covered history: 4 covered + 2 fresh entries +/// must read as 2 (below a 3 threshold), never as 6. +TEST(CASRefWriterSnapshotPublish, TriggerIgnoresEntriesCoveredByNewestSnapshot) +{ + using ProfileEvents::global_counters; + auto backend = std::make_shared(); + const Layout layout("p"); + const RootNamespace ns{"srv1/trigger_covered"}; + PoolConfig config; + config.snapshot_log_count_threshold = 3; + config.snapshot_log_bytes_threshold = 1ULL << 40; + auto store = openPoolWithConfig(backend, config); + + const auto d0 = global_counters[ProfileEvents::CASRefSnapshotPublishDispatched].load(); + + /// Drive ONE successful publish: 4 entries (4 > 3). + publishEmptyPart(store, ns, "a"); + publishEmptyPart(store, ns, "b"); + store->waitForSnapshotPublishSettleForTest(ns); + ASSERT_EQ(global_counters[ProfileEvents::CASRefSnapshotPublishDispatched].load(), d0 + 1); + const auto first_snap = listGreatestSnapshotIdForTest(*backend, layout, ns); + ASSERT_TRUE(first_snap.has_value()); + EXPECT_TRUE(store->newestPublishedSnapshotIdForTest(ns) == first_snap); + ASSERT_EQ(store->tailSinceSnapshotCountForTest(ns), 0u); + + /// 2 fresh entries: 2 <= 3, while the covered history (4 entries at/below the snapshot) would push + /// a covered-counting trigger to 6 > 3. Must not dispatch. + publishEmptyPart(store, ns, "c"); + store->waitForSnapshotPublishSettleForTest(ns); + EXPECT_EQ(global_counters[ProfileEvents::CASRefSnapshotPublishDispatched].load(), d0 + 1) + << "entries covered by the newest snapshot must not count toward the trigger"; + EXPECT_TRUE(listGreatestSnapshotIdForTest(*backend, layout, ns) == first_snap); + EXPECT_EQ(store->tailSinceSnapshotCountForTest(ns), 2u); + + /// Crossing the threshold with the fresh tail alone (4 > 3) dispatches exactly one more publish, + /// and it covers the whole uncovered tail. + publishEmptyPart(store, ns, "d"); + store->waitForSnapshotPublishSettleForTest(ns); + EXPECT_EQ(global_counters[ProfileEvents::CASRefSnapshotPublishDispatched].load(), d0 + 2); + const auto second_snap = listGreatestSnapshotIdForTest(*backend, layout, ns); + ASSERT_TRUE(second_snap.has_value()); + EXPECT_TRUE(*first_snap < *second_snap); + EXPECT_EQ(store->tailSinceSnapshotCountForTest(ns), 0u); +} + +/// =================================================================================== +/// Task 11: successor stale-precommit cleanup (spec §Clean Up Old Precommits) +/// =================================================================================== + +/// A predecessor's dangling (never-promoted) precommits are swept by the successor mount's first touch +/// of the table; a precommit the SUCCESSOR itself adds under its OWN (current) epoch must survive. +TEST(CASRefWriterStalePrecommitSweep, SweepsOnlyStaleEpochPrecommitsKeepsCurrentEpoch) +{ + auto backend = std::make_shared(); + const RootNamespace ns{"srv1/precommit_sweep_basic"}; + + { + /// A predecessor writer leaves THREE precommits dangling (a crash before promote). + auto predecessor = openPool(backend); + for (const String & name : {"stale_a", "stale_b", "stale_c"}) + { + auto build = startBuildFor(predecessor, ns, name); + const ManifestId id = build->stageManifest({}); + build->precommitAdd(ns, name, id); + /// no promote -- left dangling, as a crashed build would leave it + } + } /// predecessor destroyed: its mount lease is released + + /// The successor allocates a strictly higher durable writer_epoch; its own FRESH precommit must + /// survive the sweep its very first touch of the table triggers. + auto successor = openPool(backend); + auto build = startBuildFor(successor, ns, "fresh_x"); + const ManifestId fresh_id = build->stageManifest({}); + build->precommitAdd(ns, "fresh_x", fresh_id); /// this call's own appendRefOps hoists the sweep first + + const RefTableState replayed = independentFullReplayForTest(*backend, successor->layout(), ns); + EXPECT_EQ(replayed.getLifecycle(), RefLifecycle::Live); + EXPECT_TRUE(replayed.getCommitted().empty()); + ASSERT_EQ(replayed.getPrecommits().size(), 1u); + EXPECT_EQ(replayed.getPrecommits().begin()->first, "fresh_x"); + EXPECT_EQ(replayed.getPrecommits().begin()->second, fresh_id.ref); +} + +/// The sweep chunks its removal to `ref_txn_max_ops` stale precommits per transaction (spec +/// §Clean Up Old Precommits), and an interruption (an uncertain PUT, wedging the lane) leaves the +/// remainder harmlessly for a LATER mount's own fresh recovery to finish -- "each chunk re-reads the +/// LIVE state, so a partial sweep just leaves fewer stale bindings for the next chunk (a later retry +/// on this mount, or the next mount's recovery) to find." (Same-mount retry is pinned separately by +/// `FailedSweepRearmsAndRetriesUntilClean`.) +TEST(CASRefWriterStalePrecommitSweep, BoundedBatchesAndInterruptionResumeAcrossMounts) +{ + auto backend = std::make_shared(); + const Layout layout("p"); + const RootNamespace ns{"srv1/precommit_sweep_bounded"}; + /// Derived from `ref_txn_max_ops` (not a literal) so a future cap change cannot silently drop this + /// back to a single removal chunk: still > the cap, forcing at least two removal chunks. + constexpr int kTotalStale = static_cast(ref_txn_max_ops) + 200; + + uint64_t e1 = 0; + { + auto predecessor = openPool(backend); + e1 = predecessor->writerEpoch(); + } /// predecessor released; only its epoch is needed -- the stale precommits are seeded raw below + + /// Seed kTotalStale precommits directly (bypassing any Pool) under the predecessor's epoch, + /// spread over two raw log objects (each within the per-transaction op ENCODE cap) so recovery + /// costs only two GETs, not kTotalStale of them. + { + std::vector ops1; + ops1.push_back(namespaceBirthOp()); + for (int i = 0; i < 700; ++i) + { + RefOp op; + op.kind = RefOpKind::OwnerTransition; + op.new_binding = RefOwnerBinding{RefOwnerKind::Precommit, "stale_" + std::to_string(i), manifestRef(e1, static_cast(i) + 1, 1)}; + ops1.push_back(op); + } + DB::Cas::tests::fixture::writeRefLogRaw(*backend, layout, RefLogTxn{ns.string(), RefTxnId{e1, 1}, ops1, std::nullopt}); + + std::vector ops2; + for (int i = 700; i < kTotalStale; ++i) + { + RefOp op; + op.kind = RefOpKind::OwnerTransition; + op.new_binding = RefOwnerBinding{RefOwnerKind::Precommit, "stale_" + std::to_string(i), manifestRef(e1, static_cast(i) + 1, 1)}; + ops2.push_back(op); + } + DB::Cas::tests::fixture::writeRefLogRaw(*backend, layout, RefLogTxn{ns.string(), RefTxnId{e1, 2}, ops2, std::nullopt}); + } + writeRecoverableCkptForRawFixture(*backend, layout, ns, RefCkpt{ + .life_epoch = e1, + .committed_through = RefTxnId{e1, 2}, + .checkpoint_snapshot_id = std::nullopt, + .last_epoch_seal = std::nullopt, + }); + + /// The successor: a tight retry budget so ONE simulated ambiguous response wedges rather than + /// transparently retries away. `8f9e63c7a19` widened `kSingleAttemptDeadlineMs` off a zero-width + /// race (equal attempt/operation deadlines), but it still measures the capture-to-gate window -- + /// encoding the removal chunk (up to `ref_txn_max_ops` ops) -- against the REAL wall clock, so it + /// recurred (3 of 3 sanitizer lanes) once that encode step got slow enough on its own, independent + /// of scheduler contention: msan in particular. `ref_request_controller` reads its clock through + /// the same injectable seam as the mount fence (`CasRefLedger`'s `controller_boot_ms_fn` is the + /// pool's `boot_ms_fn`), so freeze it here instead of racing it -- the fault-injecting PUT below + /// still reaches the backend synchronously; only the deadline arithmetic stops moving. + CasRequestBudget budget; + budget.max_attempts = 1; + budget.attempt_timeout_ms = 100; + budget.operation_deadline_ms = kSingleAttemptDeadlineMs; + budget.lease_safety_margin_ms = 100; + PoolConfig config; + config.cas_request_budget = budget; + config.boot_ms_fn = [] { return uint64_t{0}; }; + auto successor = openPoolWithConfig(backend, config); + + backend->fault_key_substr = layout.namespaceStreamPrefix(DB::Cas::tests::fixture::fixtureLife(ns)) + "_log/"; + /// The successor's own recovery runs first and mints one in-band seal for the dead predecessor + /// epoch `e1` (its durable ids are `{e1,1}` and `{e1,2}`, so the seal lands at `{e1,3}`) -- that PUT + /// shares this same `_log/` prefix, so it would eat the fault before the sweep ever gets a chance. + /// Skip it and land the fault on the sweep's FIRST removal chunk's PUT, as intended. + backend->fault_skip = 1; + backend->fault_count = 1; /// hits exactly the sweep's FIRST removal chunk's PUT + + /// The sweep is piggybacked on this mount's very first touch; its (uncertain) failure is INSULATED + /// from the read (resolveRef/listRefs call `sweepStalePrecommitsForRead`, not + /// `maybeSweepStalePrecommits` directly): the read itself still succeeds, the failure is counted. + const uint64_t deferred_before = ProfileEvents::global_counters[ProfileEvents::CASRefSweepDeferred].load(); + EXPECT_NO_THROW(successor->listRefs(ns)); + const uint64_t deferred_after = ProfileEvents::global_counters[ProfileEvents::CASRefSweepDeferred].load(); + EXPECT_EQ(deferred_after, deferred_before + 1) + << "the read-only caller must observe (and count) the deferred sweep failure, not throw"; + EXPECT_TRUE(successor->refLaneWedgedForTest(ns)); + + /// The first chunk's request actually landed server-side; the caller just never saw the ack. + backend->materializePendingDelayedWrite(); + successor.reset(); /// abandoned mid-sweep WITHOUT ever resolving its own wedge in-memory + + /// A THIRD mount (successor-of-the-successor): fresh recovery replays the two raw seed logs PLUS the + /// first chunk's now-durable removal, sees `needs_stale_precommit_sweep` armed again, and finishes + /// the remaining stale precommits in exactly one further chunk (<= 1000 remain). `successor` was + /// abandoned mid-wedge above -- Task 5's drain fails closed on an unresolved PUT, so no clean + /// farewell was written -> this reclaim is `MountPriorState::UncleanObserved` (rev.6 Task 4), which + /// pays a real ~36.5s token-stability observation wait here. Inject a fake `boot_ms_fn` + + /// `wait_sleep_fn` (mirroring `CASMountOpenWaits.UncleanOpenPaysOnlyTheObservationWindow`) so it + /// resolves instantly. + uint64_t resumer_fake_boot = 0; + PoolConfig resumer_config; + resumer_config.boot_ms_fn = [&resumer_fake_boot] { return resumer_fake_boot; }; + resumer_config.wait_sleep_fn = [&resumer_fake_boot](uint64_t ms) { resumer_fake_boot += ms; }; + auto resumer = openPoolWithConfig(backend, resumer_config); + EXPECT_NO_THROW(resumer->listRefs(ns)); + + const RefTableState final_state = independentFullReplayForTest(*backend, layout, ns); + EXPECT_EQ(final_state.getLifecycle(), RefLifecycle::Live); + EXPECT_TRUE(final_state.getPrecommits().empty()) << "every stale precommit must eventually be swept"; + + /// Bounded batches: exactly THREE NEW `_log/` objects (epoch > e1) were needed -- never kTotalStale + /// individual removals, and one more than before INV-2 went in-band. In order: `{e1+1,1}` the + /// successor's own (delayed-delivered) FIRST removal chunk; `{e1+1,2}` the epoch seal that closes + /// the successor's own epoch once IT becomes dead in turn -- minted by `resumer`'s recovery, since + /// `successor` was abandoned mid-sweep without a clean farewell and never sealed itself; and + /// `{e1+2,1}` the resumer's own SECOND removal chunk, finishing the remaining stale precommits. + size_t new_log_objects = 0; + { + String cursor; + for (;;) + { + const ListPage page = backend->list(layout.namespaceStreamPrefix(DB::Cas::tests::fixture::fixtureLife(ns)), cursor, 1000); + for (const ListedKey & lk : page.keys) + { + const auto parsed = layout.parseRefObjectKey(lk.key); + if (parsed && parsed->life_id == DB::Cas::tests::fixture::fixtureLife(ns).incarnation + && parsed->kind == RefObjectKind::Log && parsed->txn_id.writer_epoch != e1) + ++new_log_objects; + } + if (page.next_cursor.empty()) + break; + cursor = page.next_cursor; + } + } + EXPECT_EQ(new_log_objects, 3u); +} + +/// S13 regression fix (triage `.superpowers/sdd/s13-triage-report.md`, run 20260713T172032_S13_seed42): +/// a FAILED sweep attempt must NOT consume the once-per-mount shot. The failure re-arms +/// `needs_stale_precommit_sweep` (with a bounded backoff, so a saturated backend is not stormed), the +/// read that piggybacked the sweep still succeeds (existing `CASRefSweepDeferred` contract), and a later +/// trigger -- here a mutation -- retries until a pass completes verified clean, clearing the flag +/// permanently. Each reclaimed binding is audited: one `precommit_reclaim` CA-log event + one +/// `CASRefStalePrecommitsReclaimed` increment, exactly per binding. +TEST(CASRefWriterStalePrecommitSweep, FailedSweepRearmsAndRetriesUntilClean) +{ + using ProfileEvents::global_counters; + auto backend = std::make_shared(); + const Layout layout("p"); + const RootNamespace ns{"srv1/precommit_sweep_retry"}; + + /// One shared injected clock for both incarnations. The successor's wait hook below advances this + /// same clock, so both mount observation and the later sweep-backoff deadline are deterministic. + uint64_t fake_now = 1'000'000; + size_t mount_wait_calls = 0; + const auto fake_clock = [&fake_now] { return fake_now; }; + + { + /// A predecessor writer leaves THREE precommits dangling (a crash before promote). + PoolConfig pred_config; + pred_config.mount_lease_ttl_ms = std::chrono::milliseconds(10'000'000); + pred_config.boot_ms_fn = fake_clock; + auto predecessor = openPoolWithConfig(backend, pred_config); + std::vector predecessor_builds; + for (const String & name : {"stale_a", "stale_b", "stale_c"}) + { + auto build = startBuildFor(predecessor, ns, name); + const ManifestId id = build->stageManifest({}); + build->precommitAdd(ns, name, id); + /// no promote -- left dangling, as a crashed build would leave it + predecessor_builds.push_back(std::move(build)); + } + } /// all three cleanup duties remain pending; predecessor publishes no clean farewell + + /// The successor: a tight retry budget so ONE simulated ambiguous response wedges rather than + /// transparently retries away (mirrors the wedge-semantics tests in this file exactly). + CasRequestBudget budget; + budget.max_attempts = 1; + budget.attempt_timeout_ms = 100; + budget.operation_deadline_ms = kSingleAttemptDeadlineMs; + budget.lease_safety_margin_ms = 100; + PoolConfig config; + config.cas_request_budget = budget; + config.mount_lease_ttl_ms = std::chrono::milliseconds(10'000'000); + config.boot_ms_fn = fake_clock; + config.wait_sleep_fn = [&fake_now, &mount_wait_calls](uint64_t ms) + { + ++mount_wait_calls; + fake_now += ms; + }; + SynchronizedEventLog seen; /// declared BEFORE the Pool so it outlives the background syncer's emits (ASan 2026-07-09) + auto successor = openPoolWithConfig(backend, config); + EXPECT_GT(mount_wait_calls, 0u) + << "the unclean predecessor must exercise the injected mount-observation wait"; + + successor->setEventSink([&](const CasEvent & e) { seen.add(e); }); + + backend->fault_key_substr = layout.namespaceStreamPrefix(DB::Cas::tests::fixture::fixtureLife(ns)) + "_log/"; + /// The successor's own recovery runs first and mints one in-band seal for the predecessor's now-dead + /// epoch (its three precommits are its only durable ids, so the seal takes the very next slot) -- + /// that PUT shares this same `_log/` prefix, so it would eat the fault before the sweep gets a turn. + /// Skip it and land the fault on the sweep's FIRST removal chunk's PUT, as intended. + backend->fault_skip = 1; + backend->fault_count = 1; /// hits exactly the sweep's FIRST removal chunk's PUT + + /// FIRST trigger (read path): the sweep's removal PUT is uncertain -> the lane wedges; the read + /// itself still succeeds and counts the deferral (existing contract) -- but the shot must NOT be + /// consumed: the flag is re-armed for a later trigger. + const uint64_t deferred_before = global_counters[ProfileEvents::CASRefSweepDeferred].load(); + const uint64_t rearmed_before = global_counters[ProfileEvents::CASRefSweepRearmed].load(); + const uint64_t reclaimed_before = global_counters[ProfileEvents::CASRefStalePrecommitsReclaimed].load(); + EXPECT_NO_THROW(successor->listRefs(ns)); + EXPECT_EQ(global_counters[ProfileEvents::CASRefSweepDeferred].load(), deferred_before + 1); + EXPECT_TRUE(successor->refLaneWedgedForTest(ns)); + EXPECT_TRUE(successor->needsStalePrecommitSweepForTest(ns)) + << "a failed sweep must re-arm needs_stale_precommit_sweep, not consume the once-per-mount shot"; + EXPECT_EQ(global_counters[ProfileEvents::CASRefSweepRearmed].load(), rearmed_before + 1); + + /// Within the backoff window (the injected clock has not advanced) a read must NOT re-attempt -- + /// the bounded-backoff storm latch: no new deferral, flag still armed. + EXPECT_NO_THROW(successor->listRefs(ns)); + EXPECT_EQ(global_counters[ProfileEvents::CASRefSweepDeferred].load(), deferred_before + 1) + << "within the backoff window the sweep must not re-attempt (PUT-storm latch)"; + EXPECT_TRUE(successor->needsStalePrecommitSweepForTest(ns)); + + /// The lost response later lands server-side; past the backoff deadline the NEXT trigger (a + /// mutation this time) retries: the lane resolves its wedge (the first chunk's removals become + /// durable and applied), the re-pass verifies clean, and the flag clears permanently. + backend->materializePendingDelayedWrite(); + fake_now += 60'000; /// beyond any armed backoff (initial 200 ms, max 30 s) + EXPECT_NO_THROW(publishEmptyPart(successor, ns, "fresh")); + EXPECT_FALSE(successor->refLaneWedgedForTest(ns)); + EXPECT_FALSE(successor->needsStalePrecommitSweepForTest(ns)) + << "a verified-clean sweep clears the flag permanently"; + + /// Ground truth: every stale binding reclaimed; the successor's own committed work intact. + const RefTableState final_state = independentFullReplayForTest(*backend, layout, ns); + EXPECT_EQ(final_state.getLifecycle(), RefLifecycle::Live); + EXPECT_TRUE(final_state.getPrecommits().empty()); + EXPECT_TRUE(final_state.getCommitted().contains("fresh")); + + /// Audit (INTROSPECTION-1): exactly ONE `precommit_reclaim` event per reclaimed stale binding -- + /// this is what makes the S13 card's "abandoned precommits reclaimed" counter falsifiable. + std::vector reclaimed_refs; + for (const CasEvent & e : seen.snapshot()) + if (e.type == CasEventType::PrecommitReclaim) + reclaimed_refs.push_back(e.ref_name); + std::sort(reclaimed_refs.begin(), reclaimed_refs.end()); + EXPECT_EQ(reclaimed_refs, (std::vector{"stale_a", "stale_b", "stale_c"})); + EXPECT_EQ(global_counters[ProfileEvents::CASRefStalePrecommitsReclaimed].load(), reclaimed_before + 3); +} + +/// Verified-clean semantics: a sweep that finds NOTHING stale clears the flag on its very first pass +/// and emits no reclaim event (so "no abandons" and "reclaim broken" stay distinguishable in the +/// audit log). +TEST(CASRefWriterStalePrecommitSweep, VerifiedCleanSweepClearsFlagWithoutEvents) +{ + using ProfileEvents::global_counters; + auto backend = std::make_shared(); + const RootNamespace ns{"srv1/precommit_sweep_clean"}; + + { + auto predecessor = openPool(backend); + publishEmptyPart(predecessor, ns, "committed_x"); /// committed work only; nothing dangles + } + + SynchronizedEventLog seen; /// declared BEFORE the Pool so it outlives the background syncer's emits (ASan 2026-07-09) + auto successor = openPool(backend); + successor->setEventSink([&](const CasEvent & e) { seen.add(e); }); + + const uint64_t deferred_before = ProfileEvents::global_counters[ProfileEvents::CASRefSweepDeferred].load(); + const uint64_t reclaimed_before = global_counters[ProfileEvents::CASRefStalePrecommitsReclaimed].load(); + EXPECT_NO_THROW(successor->listRefs(ns)); + EXPECT_FALSE(successor->needsStalePrecommitSweepForTest(ns)) + << "a clean first pass IS the verified-clean sweep: the flag clears without any removal"; + EXPECT_EQ(ProfileEvents::global_counters[ProfileEvents::CASRefSweepDeferred].load(), deferred_before); + EXPECT_EQ(global_counters[ProfileEvents::CASRefStalePrecommitsReclaimed].load(), reclaimed_before); + const std::vector observed = seen.snapshot(); + EXPECT_EQ(std::count_if(observed.begin(), observed.end(), + [](const CasEvent & e) { return e.type == CasEventType::PrecommitReclaim; }), 0); +} + +/// =================================================================================== +/// C1: self-remount establishes a fresh ref-protocol incarnation (spec §Startup And Recovery / +/// §write-fence). A self-remount bumps the durable writer_epoch, so every ref transaction it stamps +/// afterward sorts strictly above any log a dead-incarnation or same-uuid twin left durable under an +/// older epoch, and it drops its stale in-memory cache so the next touch re-recovers under the new +/// epoch. The unfixed code kept the open-time `process_epoch` and the cached tables across the fence-out. +/// =================================================================================== + +namespace +{ + +/// Fence out the mount lease so `tryRemountOnce` reclaims a fresh incarnation (mirrors +/// gtest_cas_pool.cpp's fenceOutMount, without its ASSERT_ macros so it can run outside a fixture). +void fenceOutRefMount(Backend & backend, const String & mount_key) +{ + const auto got = backend.get(mount_key); + MountLease m = decodeMountLease(got->bytes); + m.gc_fenced = true; + m.seq += 1; + backend.putOverwrite(mount_key, encodeMountLease(m), got->token); +} + +/// The greatest `_log/` transaction id currently present for `ns` (independent of any Pool cache). +std::optional listGreatestLogIdForTest(Backend & backend, const Layout & layout, const RootNamespace & ns) +{ + std::optional greatest; + String cursor; + for (;;) + { + const ListPage page = backend.list(layout.namespaceStreamPrefix(DB::Cas::tests::fixture::fixtureLife(ns)), cursor, 1000); + for (const ListedKey & lk : page.keys) + { + const auto parsed = layout.parseRefObjectKey(lk.key); + if (parsed && parsed->life_id == DB::Cas::tests::fixture::fixtureLife(ns).incarnation && parsed->kind == RefObjectKind::Log + && (!greatest || *greatest < parsed->txn_id)) + greatest = parsed->txn_id; + } + if (page.next_cursor.empty()) + break; + cursor = page.next_cursor; + } + return greatest; +} + +/// Seed a same-uuid TWIN incarnation that bumped the durable writer_epoch and durably DROPPED `ref_name` +/// (its committed binding `old_ref`) at `{twin_epoch, 1}` -- an id that sorts strictly above every log a +/// Pool wrote under its own (lower) open-time epoch. Returns the twin's epoch. +/// `prev_epoch_seal` is NOT optional decoration here. The twin's drop is sequence 1 of a new epoch, so +/// INV-2's grammar requires it to name the seal that closed the epoch below -- and the reader enforces +/// that, so a twin seeded without it describes a stream with an uncertified epoch boundary, which is +/// exactly what recovery must refuse. The link names the id the recovering pool's own CAS-walk will mint +/// for the dead epoch: one past that epoch's greatest durable id, which is what `seal_of_previous_epoch` +/// derives by listing rather than hard-coding, so the fixture cannot drift from the walk's arithmetic. +uint64_t seedTwinDrop(Backend & backend, const Layout & layout, const RootNamespace & ns, + const String & ref_name, const ManifestRef & old_ref) +{ + uint64_t greatest_in_previous_epoch = 0; + uint64_t previous_epoch = 0; + forEachListedKey(backend, layout.namespaceStreamPrefix(DB::Cas::tests::fixture::fixtureLife(ns)), [&](const ListedKey & lk) + { + const auto parsed = layout.parseRefObjectKey(lk.key); + if (!parsed || parsed->kind != RefObjectKind::Log) + return; + if (parsed->txn_id.writer_epoch > previous_epoch + || (parsed->txn_id.writer_epoch == previous_epoch && parsed->txn_id.ref_sequence > greatest_in_previous_epoch)) + { + previous_epoch = parsed->txn_id.writer_epoch; + greatest_in_previous_epoch = parsed->txn_id.ref_sequence; + } + }, 1000); + + const uint64_t twin_epoch = allocateWriterEpoch(backend, layout, "test", EpochMintPolicy::NormalMount, 0, [] { return RefCatalog{}; }); + RefLogTxn twin; + twin.ns = ns.string(); + twin.txn_id = RefTxnId{twin_epoch, 1}; + twin.prev_epoch_seal = RefTxnId{previous_epoch, greatest_in_previous_epoch + 1}; + RefOp drop; + drop.kind = RefOpKind::OwnerTransition; + drop.old_binding = RefOwnerBinding{RefOwnerKind::Committed, ref_name, old_ref}; + twin.ops = {drop}; + DB::Cas::tests::fixture::writeRefLogRaw(backend, layout, twin); + return twin_epoch; +} + +} + +/// A fence-loss generation is a rejection marker, not a runtime admission token. If remount then loses +/// to a foreign owner, neither a warm name nor a never-seen name may select/materialize a runtime under +/// that intermediate generation; the predecessor remains only as a detached diagnostic object. +TEST(CASRefWriterRemount, FailedRemountPublishesNoRuntimeUnderFenceLossGeneration) +{ + auto backend = std::make_shared(); + auto store = openPool(backend); + const Layout & layout = store->layout(); + const RootNamespace existing{"srv1/failed-remount-existing"}; + const RootNamespace never_seen{"srv1/failed-remount-never-seen"}; + + publishWithProductionBirth(store, existing, "a"); + const uint64_t predecessor_runtime = store->refTableRuntimeIdentityForTest(existing); + const uint64_t predecessor_generation + = store->refTableRuntimeAdmittedFenceGenerationForTest(existing); + const size_t cached_before = store->refTablesCachedCountForTest(); + ASSERT_NE(predecessor_runtime, 0u); + ASSERT_EQ(predecessor_generation, store->fenceGeneration()); + + const String mount_key = layout.mountKey("test"); + const auto got = backend->get(mount_key); + ASSERT_TRUE(got); + MountLease foreign = decodeMountLease(got->bytes); + foreign.server_uuid = foreign.server_uuid + UInt128{1}; + foreign.seq += 1; + ASSERT_EQ(backend->putOverwrite(mount_key, encodeMountLease(foreign), got->token).outcome, + PutOutcome::Done); + + store->tripMountLost(); + const uint64_t rejected_generation = store->fenceGeneration(); + ASSERT_NE(rejected_generation, predecessor_generation); + EXPECT_FALSE(store->tryRemountOnce()); + EXPECT_FALSE(store->mayMutate()); + + expectThrowsCode(DB::ErrorCodes::NETWORK_ERROR, [&] { (void)store->resolveRef(existing, "a"); }); + expectThrowsCode(DB::ErrorCodes::NETWORK_ERROR, [&] { (void)store->resolveRef(never_seen, "a"); }); + EXPECT_EQ(store->refTablesCachedCountForTest(), cached_before); + EXPECT_EQ(store->refTableRuntimeIdentityForTest(existing), predecessor_runtime); + EXPECT_EQ(store->refTableRuntimeAdmittedFenceGenerationForTest(existing), predecessor_generation); + EXPECT_EQ(store->refTableRuntimeIdentityForTest(never_seen), 0u); + EXPECT_NE(store->refTableRuntimeAdmittedFenceGenerationForTest(existing), rejected_generation); + + /// Make the foreign occupant terminal before teardown; it remains foreign and is never taken over. + fenceOutRefMount(*backend, mount_key); +} + +/// C1/N1 (stale cache): a warm table whose committed ref a twin durably dropped must re-recover to the +/// twin's view after a self-remount. The unfixed code kept the stale cache and still resolved the ref. +TEST(CASRefWriterRemount, ReRecoversStaleCacheToTwinDrop) +{ + auto backend = std::make_shared(); + auto store = openPool(backend); + const Layout & layout = store->layout(); + const RootNamespace ns{"srv1/remount_twin_view"}; + + const ManifestId a_id = publishEmptyPart(store, ns, "a"); + ASSERT_TRUE(store->resolveRef(ns, "a").has_value()); + const uint64_t e1 = store->liveWriterEpoch(); + const uint64_t predecessor_runtime = store->refTableRuntimeIdentityForTest(ns); + const NamespaceLifeId predecessor_life = *store->refTableLifeForTest(ns); + const uint64_t predecessor_generation = store->refTableRuntimeAdmittedFenceGenerationForTest(ns); + + /// A same-uuid twin bumped the durable epoch and durably dropped "a"; this Pool's warm cache never + /// observed it. + const uint64_t twin_epoch = seedTwinDrop(*backend, layout, ns, "a", a_id.ref); + ASSERT_GT(twin_epoch, e1); + ASSERT_TRUE(store->resolveRef(ns, "a").has_value()) << "precondition: the warm cache is stale"; + + fenceOutRefMount(*backend, layout.mountKey("test")); + ASSERT_TRUE(store->tryRemountOnce()); + EXPECT_GT(store->liveWriterEpoch(), twin_epoch); + + /// The remount dropped the stale runtime: the next read re-recovers from the durable objects and + /// adopts the twin's drop -- "a" is gone. + EXPECT_FALSE(store->resolveRef(ns, "a").has_value()) + << "a self-remount must re-recover the table under the new epoch, adopting the twin's drop"; + EXPECT_NE(store->refTableRuntimeIdentityForTest(ns), predecessor_runtime); + EXPECT_EQ(store->refTableLifeForTest(ns), predecessor_life); + EXPECT_NE(store->refTableRuntimeAdmittedFenceGenerationForTest(ns), predecessor_generation); + EXPECT_EQ(store->refTableRuntimeAdmittedFenceGenerationForTest(ns), store->fenceGeneration()); +} + +/// C1/N2 (epoch routing + ordering): a post-remount append must stamp its log with the fresh +/// incarnation's live epoch, landing strictly above a twin's durable log (the pagination premise +/// "a new log is never inserted at or below an already durable table log id"). The unfixed code stamped +/// the stale open-time epoch, which sorts BELOW a higher-epoch twin log. +TEST(CASRefWriterRemount, PostRemountAppendCarriesLiveEpochSortingAboveTwinLogs) +{ + auto backend = std::make_shared(); + auto store = openPool(backend); + const Layout & layout = store->layout(); + const RootNamespace ns{"srv1/remount_epoch_order"}; + + const ManifestId a_id = publishEmptyPart(store, ns, "a"); + const uint64_t e1 = store->liveWriterEpoch(); + const uint64_t twin_epoch = seedTwinDrop(*backend, layout, ns, "a", a_id.ref); + ASSERT_GT(twin_epoch, e1); + + fenceOutRefMount(*backend, layout.mountKey("test")); + ASSERT_TRUE(store->tryRemountOnce()); + const uint64_t e2 = store->liveWriterEpoch(); + ASSERT_GT(e2, twin_epoch); + + publishEmptyPart(store, ns, "b"); + const auto greatest = listGreatestLogIdForTest(*backend, layout, ns); + ASSERT_TRUE(greatest.has_value()); + EXPECT_EQ(greatest->writer_epoch, e2) + << "the newest ref log must carry the fresh incarnation's epoch and be the greatest id"; + EXPECT_GT(*greatest, (RefTxnId{twin_epoch, 1})) + << "the post-remount append must sort strictly above the twin's log"; +} + +/// C1 (wedge disposition): a wedged append lane's runtime (and its wedge) is dropped on a self-remount, +/// the next touch re-recovers a clean lane, and appends resume without hanging. The unfixed code kept the +/// wedged runtime cached across the remount. The drop is a plain cache detach and needs to certify +/// nothing: the undecided PUT the wedge describes is settled by the seal the next recovery writes into +/// its slot -- see `quiesceRefTablesForRemount`'s doc comment (`CasPool.h`). +TEST(CASRefWriterRemount, DiscardsWedgeAndLaneRemainsUsable) +{ + CasRequestBudget budget; + budget.max_attempts = 1; + budget.attempt_timeout_ms = 100; + budget.operation_deadline_ms = kSingleAttemptDeadlineMs; + budget.lease_safety_margin_ms = 100; + + auto backend = std::make_shared(); + /// The self-remount below blocks on nothing (see + /// `CASRemountWaits.UnresolvedWedgeRemountPaysNoWaitEither`, `gtest_cas_pool.cpp`); the injected + /// `boot_ms_fn`/`wait_sleep_fn` keep this test off the real clock anyway. + uint64_t fake_boot = 0; + PoolConfig config; + config.cas_request_budget = budget; + config.boot_ms_fn = [&fake_boot] { return fake_boot; }; + config.wait_sleep_fn = [&fake_boot](uint64_t ms) { fake_boot += ms; }; + auto store = openPoolWithConfig(backend, config); + const Layout & layout = store->layout(); + const RootNamespace ns{"srv1/remount_wedge"}; + publishEmptyPart(store, ns, "x"); + publishEmptyPart(store, ns, "y"); + + /// Wedge the lane with an ambiguous PUT that never landed server-side. + backend->fault_key_substr = layout.namespaceStreamPrefix(DB::Cas::tests::fixture::fixtureLife(ns)) + "_log/"; + backend->fault_count = 1; + expectThrowsCode(DB::ErrorCodes::NETWORK_ERROR, [&] { store->dropRef(ns, "x"); }); + ASSERT_TRUE(store->refLaneWedgedForTest(ns)); + + fenceOutRefMount(*backend, layout.mountKey("test")); + ASSERT_TRUE(store->tryRemountOnce()); + + EXPECT_FALSE(store->refLaneWedgedForTest(ns)) + << "a self-remount discards the in-memory wedge with the detached runtime"; + + /// The lane is usable, not hung: a fresh append completes and carries the live epoch. + EXPECT_NO_THROW(store->dropRef(ns, "y")); + EXPECT_FALSE(store->resolveRef(ns, "y").has_value()); + const auto greatest = listGreatestLogIdForTest(*backend, layout, ns); + ASSERT_TRUE(greatest.has_value()); + EXPECT_EQ(greatest->writer_epoch, store->liveWriterEpoch()); +} + +/// C1 residual: a flush leader that passed the top-of-flush gate BEFORE a self-remount and stalled +/// mid-flush (here parked at the pre-carve hook, post-top-gate / pre-allocate) across the whole +/// fence-loss + remount window must NOT, on resume, allocate an id and PUT a transaction validated +/// against its now-stale detached cache. The pre-allocate `superseded_by_remount` re-check fails it +/// closed: the caller gets the failure and no backend `_log` object is created. +TEST(CASRefWriterRemount, SupersededLeaderMidFlushFailsClosedCreatesNoObject) +{ + auto backend = std::make_shared(); + /// This test parks a flush leader (`leader_active` stays true) across the ENTIRE `tryRemountOnce` + /// call below by construction (`release` is only set AFTER `tryRemountOnce` returns). Keep the + /// request budget at the file's usual tiny-wedge-test values so every bounded wait on that path + /// stays well under a second. + CasRequestBudget budget; + budget.attempt_timeout_ms = 100; + budget.lease_safety_margin_ms = 100; + uint64_t fake_boot = 0; + PoolConfig config; + config.cas_request_budget = budget; + config.boot_ms_fn = [&fake_boot] { return fake_boot; }; + config.wait_sleep_fn = [&fake_boot](uint64_t ms) { fake_boot += ms; }; + auto store = openPoolWithConfig(backend, config); + const Layout & layout = store->layout(); + const RootNamespace ns{"srv1/remount_midflush"}; + publishEmptyPart(store, ns, "x"); + const auto greatest_before = listGreatestLogIdForTest(*backend, layout, ns); + ASSERT_TRUE(greatest_before.has_value()); + + /// Park the next flush leader at the pre-carve hook (post-top-gate, pre-allocate). Fires once. + std::mutex m; + std::condition_variable cv; + bool entered = false; + bool release = false; + std::atomic hook_fired{false}; + store->setRefPreCarveHookForTest([&] + { + if (hook_fired.exchange(true)) + return; + std::unique_lock lk(m); + entered = true; + cv.notify_all(); + cv.wait(lk, [&] { return release; }); + }); + + auto fut = std::async(std::launch::async, [&]() -> std::string + { + try { store->dropRef(ns, "x"); return "committed"; } + catch (const DB::Exception & e) { return e.message(); } + }); + { std::unique_lock lk(m); cv.wait(lk, [&] { return entered; }); } /// leader parked mid-flush + + /// The remount completes while the leader is parked (the quiesce does not wait for leaders); it marks + /// the table superseded and re-arms the fence. + fenceOutRefMount(*backend, layout.mountKey("test")); + ASSERT_TRUE(store->tryRemountOnce()); + + /// Unpark: the leader resumes, re-checks the flag before allocating, and fails closed. + { std::lock_guard lk(m); release = true; } + cv.notify_all(); + + ASSERT_EQ(fut.wait_for(std::chrono::seconds(10)), std::future_status::ready) + << "the superseded leader hung instead of failing closed"; + const std::string result = fut.get(); + EXPECT_NE(result.find("superseded by a self-remount"), std::string::npos) + << "expected a superseded fail-closed, got: " << result; + + /// No new ref-log object was created: the greatest durable log id is unchanged. + const auto greatest_after = listGreatestLogIdForTest(*backend, layout, ns); + ASSERT_TRUE(greatest_after.has_value()); + EXPECT_EQ(*greatest_after, *greatest_before) + << "a superseded leader must allocate no id and PUT no object"; +} + +/// =================================================================================== +/// Task 11: namespace removal (spec §Namespace Removal) +/// =================================================================================== + +/// A cached writer paused after its ordinary gates must re-check the exact catalog life immediately +/// before id allocation. A concurrent `Live -> Removing` transition therefore admits no late owner. +TEST(CASRefWriterNamespaceRemoval, CachedPositiveWriterCannotAppendAfterRemovingIsPublished) +{ + auto backend = std::make_shared(); + auto store = openPool(backend); + const Layout & layout = store->layout(); + const RootNamespace ns{"srv1/removing_blocks_cached_writer"}; + publishEmptyPart(store, ns, "existing"); + + const CasRefCatalog::Snapshot before = CasRefCatalog::read(*backend, layout); + const auto observed = std::find_if(before.catalog.entries.begin(), before.catalog.entries.end(), + [&](const CatalogEntry & entry) { return entry.ns == ns; }); + ASSERT_NE(observed, before.catalog.entries.end()); + ASSERT_EQ(observed->state, NsState::Live); + const CatalogEntry & exact_live = *observed; + const auto greatest_before = listGreatestLogIdForTest(*backend, layout, ns); + ASSERT_TRUE(greatest_before); + + std::mutex mutex; + std::condition_variable cv; + bool entered = false; + bool release = false; + std::atomic hook_fired{false}; + store->setRefPreCarveHookForTest([&] + { + if (hook_fired.exchange(true)) + return; + std::unique_lock lock(mutex); + entered = true; + cv.notify_all(); + cv.wait(lock, [&] { return release; }); + }); + + auto writer = std::async(std::launch::async, [&]() -> String + { + try + { + publishEmptyPart(store, ns, "late"); + return "committed"; + } + catch (const DB::Exception & e) + { + return e.message(); + } + }); + + bool writer_parked = false; + { + std::unique_lock lock(mutex); + writer_parked = cv.wait_for(lock, std::chrono::seconds(10), [&] { return entered; }); + } + if (writer_parked) + { + CasRefCatalog::casUpdate(*backend, layout, [&](const RefCatalog & current) + { + RefCatalog next = current; + const auto it = std::find(next.entries.begin(), next.entries.end(), exact_live); + if (it == next.entries.end()) + throw DB::Exception(DB::ErrorCodes::LOGICAL_ERROR, "exact Live row changed during test transition"); + it->state = NsState::Removing; + it->removal_started_round = 0; + return next; + }); + } + { + std::lock_guard lock(mutex); + release = true; + } + cv.notify_all(); + + EXPECT_TRUE(writer_parked) << "cached writer did not reach the deterministic pre-carve seam"; + ASSERT_EQ(writer.wait_for(std::chrono::seconds(10)), std::future_status::ready); + const String result = writer.get(); + store->setRefPreCarveHookForTest(nullptr); + + EXPECT_NE(result, "committed") << "cached positive ownership appended after `Removing` became visible"; + EXPECT_FALSE(store->resolveRef(ns, "late")); + EXPECT_EQ(listGreatestLogIdForTest(*backend, layout, ns), greatest_before); +} + +/// dropNamespace's ONE body transaction names an exact removal for every committed ref AND every +/// dangling precommit, with `remove_namespace` as the FINAL op -- never any other shape. +TEST(CASRefWriterNamespaceRemoval, TxnNamesEveryOwnerThenRemoveNamespace) +{ + auto backend = std::make_shared(); + auto store = openPool(backend); + const Layout & layout = store->layout(); + const RootNamespace ns{"srv1/remove_shape"}; + + publishEmptyPart(store, ns, "committed_1"); + publishEmptyPart(store, ns, "committed_2"); + /// One precommit left dangling (never promoted) so the removal txn must ALSO name it. + auto build = startBuildFor(store, ns, "dangling"); + const ManifestId dangling_id = build->stageManifest({}); + build->precommitAdd(ns, "dangling", dangling_id); + + store->dropNamespace(ns); + + /// The newest `_log/` object for `ns` is the removal transaction. + std::optional newest_log; + { + String cursor; + for (;;) + { + const ListPage page = backend->list(layout.namespaceStreamPrefix(DB::Cas::tests::fixture::fixtureLife(ns)), cursor, 1000); + for (const ListedKey & lk : page.keys) + { + const auto parsed = layout.parseRefObjectKey(lk.key); + if (parsed && parsed->life_id == DB::Cas::tests::fixture::fixtureLife(ns).incarnation && parsed->kind == RefObjectKind::Log + && (!newest_log || *newest_log < parsed->txn_id)) + newest_log = parsed->txn_id; + } + if (page.next_cursor.empty()) + break; + cursor = page.next_cursor; + } + } + ASSERT_TRUE(newest_log.has_value()); + const auto got = backend->get(layout.refLogKey(DB::Cas::tests::fixture::fixtureLife(ns), *newest_log)); + ASSERT_TRUE(got.has_value()); + const RefLogTxn removal_txn = decodeRefLogTxn(openObject(FormatId::RefLog, got->bytes), ns.string(), *newest_log); + + ASSERT_FALSE(removal_txn.ops.empty()); + EXPECT_EQ(removal_txn.ops.back().kind, RefOpKind::RemoveNamespace); + size_t owner_removals = 0; + for (size_t i = 0; i + 1 < removal_txn.ops.size(); ++i) + { + const RefOp & op = removal_txn.ops[i]; + EXPECT_EQ(op.kind, RefOpKind::OwnerTransition); + EXPECT_TRUE(op.old_binding.has_value()); + EXPECT_FALSE(op.new_binding.has_value()); + ++owner_removals; + } + EXPECT_EQ(owner_removals, 3u) << "2 committed + 1 dangling precommit"; +} + +/// The terminal transaction is the only durable removal record. Generation 7 never publishes a +/// terminal `Removed` snapshot; the ordinary cleanup/janitor paths own old immutable stream debris. +TEST(CASRefWriterNamespaceRemoval, RemovalPublishesTerminalLogWithoutTerminalSnapshot) +{ + auto backend = std::make_shared(); + auto store = openPool(backend); + const Layout & layout = store->layout(); + const RootNamespace ns{"srv1/remove_snapshot"}; + + publishEmptyPart(store, ns, "a"); + const auto snapshot_before = store->newestPublishedSnapshotIdForTest(ns); + store->dropNamespace(ns); + + EXPECT_EQ(store->newestPublishedSnapshotIdForTest(ns), snapshot_before) + << "removal must not publish a terminal snapshot"; + EXPECT_GT(store->tailSinceSnapshotCountForTest(ns), 0u) + << "the terminal transaction remains ordinary immutable stream work until GC folds it"; + + size_t terminal_logs = 0; + for (const ListedKey & listed : backend->list(layout.namespaceStreamPrefix(DB::Cas::tests::fixture::fixtureLife(ns)), "", 1000).keys) + { + const auto parsed = layout.parseRefObjectKey(listed.key); + if (!parsed || parsed->kind != RefObjectKind::Log) + continue; + const auto got = backend->get(listed.key); + ASSERT_TRUE(got.has_value()); + const RefLogTxn txn = decodeRefLogTxn(openObject(FormatId::RefLog, got->bytes), ns.string(), parsed->txn_id); + if (!txn.ops.empty() && txn.ops.back().kind == RefOpKind::RemoveNamespace) + ++terminal_logs; + } + EXPECT_EQ(terminal_logs, 1u); +} + +/// Review fix (prerequisite to this task's dropNamespace rewiring): `flushRefBatch`'s per-item +/// validation previously previewed each op as its OWN single-op trial transaction, so a +/// whole-transaction-shape rule ("remove_namespace must be the FINAL op") trivially passed on every +/// singleton slice regardless of an item's REAL combined shape -- a malformed item would only have +/// been caught by the post-persist apply, AFTER its transaction object was already durable (bricking +/// the table on every future recovery and permanently wedging this table's lane). Drives +/// `appendRefOps` directly with a deliberately malformed multi-op item (remove_namespace not last) to +/// prove the whole-item shape check now rejects it BEFORE any backend object is created. +TEST(CASRefWriterNamespaceRemoval, MalformedShapeWithRemoveNamespaceNotFinalRejectedBeforeAnyCreate) +{ + auto backend = std::make_shared(); + auto store = openPool(backend); + const RootNamespace ns{"srv1/malformed_shape"}; + publishEmptyPart(store, ns, "a"); /// births the table so the malformed item isn't ALSO rejected + /// for the unrelated reason "namespace_birth was needed first" + + const uint64_t put_before = backend->putTotal(); + expectThrowsCode(DB::ErrorCodes::CORRUPTED_DATA, [&] + { + store->appendRefOps(ns, MutationScope::wholeShard(), + [](const RefTableState &) -> std::vector + { + RefOp remove_ns_1; + remove_ns_1.kind = RefOpKind::RemoveNamespace; + RefOp remove_ns_2; + remove_ns_2.kind = RefOpKind::RemoveNamespace; + return {remove_ns_1, remove_ns_2}; /// remove_namespace NOT the final op -- malformed + }, + /// Deliberately mislabel the malformed terminal as an ordinary mutation: this bypasses + /// the public removal-capability preflight and proves the txn-wide shape check itself + /// rejects the object before the later capability check or any backend mutation. + RootMutationOrigin::Writer, RootMutationKind::Publish); + }); + + EXPECT_EQ(backend->putTotal(), put_before) << "the malformed shape must be rejected before any object is created"; + ASSERT_TRUE(store->resolveRef(ns, "a").has_value()) << "the malformed attempt left no trace on the table"; +} + +/// A caller cannot turn the generic append surface into a second namespace-removal capability, even +/// when it disguises terminal operations as an ordinary mutation kind. Only `dropNamespace` may carry +/// the exact runtime ownership established by the durable `Live -> Removing` transition. +TEST(CASRefWriterNamespaceRemoval, GenericAppendCannotWriteTerminalWhileCatalogIsLive) +{ + auto backend = std::make_shared(); + auto store = openPool(backend); + const Layout & layout = store->layout(); + const RootNamespace ns{"srv1/unauthorized_terminal"}; + publishEmptyPart(store, ns, "owned"); + const CatalogEntry live = catalogEntryOrThrow(*backend, layout, ns); + ASSERT_EQ(live.state, NsState::Live); + const auto greatest_before = listGreatestLogIdForLifeForTest( + *backend, layout, NamespaceLifeId::fromCatalogEntry(ns, live.incarnation)); + ASSERT_TRUE(greatest_before); + + expectThrowsCode(DB::ErrorCodes::CORRUPTED_DATA, [&] + { + store->appendRefOps(ns, MutationScope::wholeShard(), + [](const RefTableState & state) -> std::vector + { + std::vector ops; + for (const auto [ref_name, row] : state.getCommitted()) + { + RefOp remove_owner; + remove_owner.kind = RefOpKind::OwnerTransition; + remove_owner.old_binding = RefOwnerBinding{ + RefOwnerKind::Committed, ref_name, row.manifest_ref}; + ops.push_back(std::move(remove_owner)); + } + RefOp terminal; + terminal.kind = RefOpKind::RemoveNamespace; + ops.push_back(terminal); + return ops; + }, + RootMutationOrigin::Writer, RootMutationKind::Publish, + /*skip_stale_precommit_sweep=*/true); + }); + + EXPECT_EQ(catalogEntryOrThrow(*backend, layout, ns), live); + EXPECT_EQ(listGreatestLogIdForLifeForTest( + *backend, layout, NamespaceLifeId::fromCatalogEntry(ns, live.incarnation)), greatest_before) + << "an unauthorized terminal must allocate no id and create no ref-log object"; + EXPECT_TRUE(store->resolveRef(ns, "owned")); +} + +/// The public generic surface must reject the terminal-capable operation kind before resolving or +/// creating a life. Otherwise an absent name can acquire a catalog row and checkpoint before the +/// internal terminal capability check rejects the actual operations. +TEST(CASRefWriterNamespaceRemoval, GenericTerminalOnAbsentNamePerformsZeroDurableMutation) +{ + auto backend = std::make_shared(); + auto store = openPool(backend); + const RootNamespace ns{"srv1/absent_unauthorized_terminal"}; + const CasRefCatalog::Snapshot catalog_before = CasRefCatalog::read(*backend, store->layout()); + + backend->resetCounts(); + expectThrowsCode(DB::ErrorCodes::CORRUPTED_DATA, [&] + { + store->appendRefOps(ns, MutationScope::wholeShard(), + [](const RefTableState &) -> std::vector + { + RefOp terminal; + terminal.kind = RefOpKind::RemoveNamespace; + return {terminal}; + }, + RootMutationOrigin::Writer, RootMutationKind::DropNamespace, + /*skip_stale_precommit_sweep=*/true); + }); + + EXPECT_EQ(backend->putTotal(), 0u); + EXPECT_EQ(backend->putOverwriteTotal(), 0u); + EXPECT_EQ(backend->casPutTotal(), 0u); + EXPECT_EQ(backend->deleteTotal(), 0u); + const CasRefCatalog::Snapshot catalog_after = CasRefCatalog::read(*backend, store->layout()); + EXPECT_EQ(catalog_after.token, catalog_before.token); + EXPECT_EQ(catalog_after.catalog, catalog_before.catalog); + EXPECT_FALSE(store->refTableLifeForTest(ns)); +} + +/// A namespace file births a catalog life and checkpoint without necessarily creating a ref stream. +/// Removing that table must still publish terminal evidence and let GC retire the catalog row. +TEST(CASRefWriterNamespaceRemoval, CatalogedNamespaceFilesOnlyLifeCompletesRemoval) +{ + auto backend = std::make_shared(); + PoolConfig config; + config.gc_fold_threshold = 1; + config.gc_fold_max_defer_rounds = 0; + auto store = openPoolWithConfig(backend, config); + const Layout & layout = store->layout(); + const RootNamespace ns{"srv1/files_only"}; + const NamespaceLifeId life = store->namespaceLife(ns); + store->putNamespaceFile(life, "format_version.txt", "1\n"); + ASSERT_TRUE(backend->list(layout.namespaceStreamPrefix(life), "", 100).keys.empty()); + ASSERT_EQ(catalogEntryOrThrow(*backend, layout, ns).state, NsState::Live); + + EXPECT_NO_THROW(store->dropNamespace(ns)); + ASSERT_EQ(catalogEntryOrThrow(*backend, layout, ns).state, NsState::Removing); + + const ListPage terminal_page = backend->list(layout.namespaceStreamPrefix(life), "", 100); + ASSERT_EQ(terminal_page.keys.size(), 1u); + const auto parsed = layout.parseRefObjectKey(terminal_page.keys.front().key); + ASSERT_TRUE(parsed); + const auto terminal_body = backend->get(terminal_page.keys.front().key); + ASSERT_TRUE(terminal_body); + const RefLogTxn terminal = decodeRefLogTxn( + openObject(FormatId::RefLog, terminal_body->bytes), ns.string(), parsed->txn_id); + ASSERT_EQ(terminal.ops.size(), 2u); + EXPECT_EQ(terminal.ops[0].kind, RefOpKind::NamespaceBirth); + EXPECT_EQ(terminal.ops[1].kind, RefOpKind::RemoveNamespace); + + Gc gc(store, UInt128{181}); + ASSERT_FALSE(runRegularRoundReclaiming(gc).deferred); + (void)runRegularRoundReclaiming(gc); + const RefCatalog after = CasRefCatalog::read(*backend, layout).catalog; + EXPECT_TRUE(std::none_of(after.entries.begin(), after.entries.end(), [&](const CatalogEntry & entry) + { + return entry.ns == ns; + })); +} + +/// If the first catalog read after closing the positive lane fails, the catch-side authoritative read +/// is still allowed to prove the exact original `Live` row and reopen admission. +TEST(CASRefWriterNamespaceRemoval, PredurableCatalogReadFailureReopensExactLiveLane) +{ + auto backend = std::make_shared(); + auto store = openPool(backend); + const Layout & layout = store->layout(); + const RootNamespace ns{"srv1/predurable_read_failure"}; + publishEmptyPart(store, ns, "owned"); + const CatalogEntry live = catalogEntryOrThrow(*backend, layout, ns); + + backend->catalog_fault_key = layout.refCatalogKey(); + backend->catalog_gets_before_fault = 1; /// initial discovery succeeds; post-close observation fails + backend->catalog_get_fault_count = 1; + EXPECT_THROW(store->dropNamespace(ns), std::runtime_error); + EXPECT_EQ(catalogEntryOrThrow(*backend, layout, ns), live); + + EXPECT_NO_THROW(store->updateRefPublishedAt(ns, "owned", [](RefPublishedAtUpdate & update) + { + update.published_at_ms = 17; + })) << "a fresh exact Live observation must reopen the lane after a pre-durable failure"; + EXPECT_EQ(catalogEntryOrThrow(*backend, layout, ns).state, NsState::Live); +} + +/// spec §Namespace Removal (writer, line 666): "After the transaction is durable, it applies the same +/// operations to memory, cancels local builds, and rejects further ordinary mutations." An in-flight +/// build for the removed namespace must be cancelled once (and only once) the removal is durable: its +/// next operation throws (ABORTED, from requireAlive) rather than promoting a fresh committed ref into +/// the just-removed namespace. +TEST(CASRefWriterNamespaceRemoval, DropNamespaceCancelsInFlightBuildAndNextOpThrows) +{ + auto backend = std::make_shared(); + auto store = openPool(backend); + const RootNamespace ns{"srv1/remove_cancels_build"}; + + publishEmptyPart(store, ns, "committed"); /// births the table + one committed ref + + /// An in-flight build for ns: staged + precommit-added, never promoted. + auto build = startBuildFor(store, ns, "inflight"); + const ManifestId id = build->stageManifest({}); + build->precommitAdd(ns, "inflight", id); + + store->dropNamespace(ns); + + /// The build is cancelled: EVERY subsequent operation fails fast at `requireAlive` with + /// NETWORK_ERROR (fix #37 phase 2's CAS write-retry-later reroute). `stageManifest` is the + /// discriminator -- it has NO namespace-lifecycle gate, so an UN-cancelled build would happily + /// execute it (staging more debris into a dead namespace); only cancellation stops it. + expectThrowsCode(DB::ErrorCodes::NETWORK_ERROR, [&] { build->stageManifest({}); }); + /// And it certainly cannot promote a fresh committed ref into the removed namespace (the important + /// invariant -- though the old WPromote "precommit removed" guard also blocked this, less directly). + expectThrowsCode(DB::ErrorCodes::NETWORK_ERROR, [&] { build->promote(ns, "inflight", build->buildId(), id); }); + + /// The cancelled build did not resurrect anything into the removed namespace. + EXPECT_FALSE(store->resolveRef(ns, "inflight").has_value()); + EXPECT_FALSE(store->resolveRef(ns, "committed").has_value()) << "the whole namespace was removed"; +} + +/// The catalog transition precedes the terminal append. If that append is unresolved, the namespace +/// remains `Removing`, positive ownership is refused, and a retry of the same removal resolves the wedge. +TEST(CASRefWriterNamespaceRemoval, RemovalAppendFailureLeavesRemovingAndRetryCompletes) +{ + CasRequestBudget budget; + budget.max_attempts = 1; + budget.attempt_timeout_ms = 100; + budget.operation_deadline_ms = kSingleAttemptDeadlineMs; + budget.lease_safety_margin_ms = 100; + + auto backend = std::make_shared(); + auto store = openPool(backend, budget); + const Layout & layout = store->layout(); + const RootNamespace ns{"srv1/remove_fault_keeps_build"}; + + publishEmptyPart(store, ns, "committed"); + + auto build = startBuildFor(store, ns, "inflight"); + const ManifestId id = build->stageManifest({}); + build->precommitAdd(ns, "inflight", id); + + backend->fault_key_substr = layout.namespaceStreamPrefix(DB::Cas::tests::fixture::fixtureLife(ns)) + "_log/"; + backend->fault_count = 1; + + expectThrowsCode(DB::ErrorCodes::NETWORK_ERROR, [&] { store->dropNamespace(ns); }); + + EXPECT_EQ(catalogEntryOrThrow(*backend, layout, ns).state, NsState::Removing); + EXPECT_FALSE(store->resolveRef(ns, "committed")) + << "a fresh name lookup must not expose a catalog-Removing life"; + /// The build was NOT cancelled: a non-append operation (`stageManifest` -- it never touches the now + /// wedged ref-append lane) still succeeds; it would throw ABORTED had the build been cancelled. + EXPECT_NO_THROW(build->stageManifest({})); + expectThrowsCode(DB::ErrorCodes::NETWORK_ERROR, [&] + { + build->promote(ns, "inflight", build->buildId(), id); + }); + + EXPECT_NO_THROW(store->dropNamespace(ns)); + expectThrowsCode(DB::ErrorCodes::NETWORK_ERROR, [&] { build->stageManifest({}); }); +} + +/// `namespaceStillLogicallyPresent` must stay `true` for the entire window between the catalog's +/// durable `Live -> Removing` transition and the terminal `remove_namespace` append actually landing -- +/// the crash-shaped case the fix exists for. Reuses the injected stream-write fault shape from +/// `RemovalAppendFailureLeavesRemovingAndRetryCompletes`. +TEST(CASRefWriterNamespaceRemoval, PresenceProbeStaysTrueThroughRemovingUntilTerminalRetrySucceeds) +{ + CasRequestBudget budget; + budget.max_attempts = 1; + budget.attempt_timeout_ms = 100; + budget.operation_deadline_ms = kSingleAttemptDeadlineMs; + budget.lease_safety_margin_ms = 100; + + auto backend = std::make_shared(); + auto store = openPool(backend, budget); + const Layout & layout = store->layout(); + const RootNamespace ns{"srv1/presence_removing_no_terminal"}; + + publishEmptyPart(store, ns, "committed"); + EXPECT_TRUE(store->namespaceStillLogicallyPresent(ns)); + + backend->fault_key_substr = layout.namespaceStreamPrefix(DB::Cas::tests::fixture::fixtureLife(ns)) + "_log/"; + backend->fault_count = 1; + expectThrowsCode(DB::ErrorCodes::NETWORK_ERROR, [&] { store->dropNamespace(ns); }); + + ASSERT_EQ(catalogEntryOrThrow(*backend, layout, ns).state, NsState::Removing); + EXPECT_TRUE(store->namespaceStillLogicallyPresent(ns)) + << "the catalog transitioned but the terminal append never landed -- cleanup is unproven"; + + EXPECT_NO_THROW(store->dropNamespace(ns)); + EXPECT_FALSE(store->namespaceStillLogicallyPresent(ns)) + << "the retried removal's terminal is now durable"; +} + +/// A `Creating` row is conservative in both directions -- present, and removal refuses to cancel it +/// while its creator fence cannot be proven dead, then succeeds once a terminal certificate (here, a +/// GC-fenced lease for the same server root) makes the fence provably terminal. +TEST(CASRefWriterNamespaceRemoval, PresenceProbeCreatingIsPresentAndRemovalWaitsForCreatorFenceTerminality) +{ + auto backend = std::make_shared(); + auto store = openPool(backend); + const Layout & layout = store->layout(); + const RootNamespace ns{"srv1/presence_still_creating"}; + + CatalogEntry entry; + entry.ns = ns; + entry.state = NsState::Creating; + entry.incarnation = UInt128(99); + entry.creator = CreatorFence{.server_root_id = "srv1", .writer_epoch = store->liveWriterEpoch(), .fence_generation = 1}; + CasRefCatalog::casAdmitEntry(*backend, layout, 1, entry); + + EXPECT_TRUE(store->namespaceStillLogicallyPresent(ns)); + + /// The creator fence names an unmounted server root: `isCreatorFenceTerminal` cannot certify it + /// dead (absence proves nothing), so removal fails closed rather than cancelling a `Creating` row a + /// live writer might still publish into. + expectThrowsCode(DB::ErrorCodes::NETWORK_ERROR, [&] { store->dropNamespace(ns); }); + EXPECT_EQ(catalogEntryOrThrow(*backend, layout, ns).state, NsState::Creating); + EXPECT_TRUE(store->namespaceStillLogicallyPresent(ns)); + + /// Publish a GC-fenced lease for the SAME server root -- one of `isCreatorFenceTerminal`'s accepted + /// certificates -- and removal now cancels the row outright (no `Removing` transition for a + /// namespace that never reached `Live`). + MountLease dead; + dead.writer_epoch = store->liveWriterEpoch(); + dead.gc_fenced = true; + dead.seq = 1; + backend->putIfAbsent(layout.mountKey("srv1"), encodeMountLease(dead)); + + EXPECT_NO_THROW(store->dropNamespace(ns)); + EXPECT_FALSE(store->namespaceStillLogicallyPresent(ns)); +} + +/// A "no catalog row" observation must never be turned into `false` by a race. Pausing the probe +/// right after its first catalog read and admitting a fresh `Creating` row before it resumes must +/// surface as a typed retry, not a stale absent answer; an unchanged catalog across both reads +/// legitimately settles on absent. +TEST(CASRefWriterNamespaceRemoval, PresenceProbeNoRowObservationRevalidatesRatherThanRacingToAbsent) +{ + auto backend = std::make_shared(); + auto store = openPool(backend); + const Layout & layout = store->layout(); + const RootNamespace racing{"srv1/presence_no_row_races_birth"}; + const RootNamespace stable{"srv1/presence_no_row_stays_absent"}; + + std::mutex mutex; + std::condition_variable cv; + bool paused = false; + bool resume = false; + store->setNamespacePresenceProbeAfterFirstReadHookForTest([&] + { + std::unique_lock lock(mutex); + paused = true; + cv.notify_all(); + cv.wait(lock, [&] { return resume; }); + }); + + std::exception_ptr raced_error; + std::thread racer([&] + { + try + { + (void)store->namespaceStillLogicallyPresent(racing); + } + catch (...) + { + raced_error = std::current_exception(); + } + }); + { + std::unique_lock lock(mutex); + cv.wait(lock, [&] { return paused; }); + } + + CatalogEntry born; + born.ns = racing; + born.state = NsState::Creating; + born.incarnation = UInt128(1234); + born.creator = CreatorFence{.server_root_id = "srv1", .writer_epoch = store->liveWriterEpoch(), .fence_generation = 1}; + CasRefCatalog::casAdmitEntry(*backend, layout, 1, born); + + { + std::lock_guard lock(mutex); + resume = true; + } + cv.notify_all(); + racer.join(); + store->setNamespacePresenceProbeAfterFirstReadHookForTest(nullptr); + + ASSERT_TRUE(raced_error) << "a namespace born after the first read must never resolve to a stale absent"; + EXPECT_THROW(std::rethrow_exception(raced_error), DB::Exception); + + /// Negative control: an unraced, genuinely absent namespace settles on `false`. + EXPECT_FALSE(store->namespaceStillLogicallyPresent(stable)); +} + +/// Every unreadable or ambiguous observation must throw, never answer `false`. Covers a catalog `GET` +/// failure on the probe's very first read, and a lost mount fence discovered mid-probe. Not covered +/// here: a missing checkpoint for a `Removing` row, and an ambiguous incarnation -- both would need a +/// raw-catalog-write test helper this suite does not currently expose. +TEST(CASRefWriterNamespaceRemoval, PresenceProbeCatalogReadFailurePropagatesRatherThanAnsweringAbsent) +{ + auto backend = std::make_shared(); + auto store = openPool(backend); + const RootNamespace ns{"srv1/presence_catalog_read_fault"}; + + backend->catalog_fault_key = store->layout().refCatalogKey(); + backend->catalog_gets_before_fault = 0; + backend->catalog_get_fault_count = 1; + EXPECT_THROW((void)store->namespaceStillLogicallyPresent(ns), std::runtime_error); +} + +TEST(CASRefWriterNamespaceRemoval, PresenceProbeFenceLossPropagatesRatherThanAnsweringAbsent) +{ + auto backend = std::make_shared(); + auto store = openPool(backend); + const RootNamespace ns{"srv1/presence_fence_loss"}; + publishEmptyPart(store, ns, "x"); + + const String mount_key = store->layout().mountKey("test"); + const auto got = backend->get(mount_key); + ASSERT_TRUE(got); + MountLease foreign = decodeMountLease(got->bytes); + foreign.server_uuid = foreign.server_uuid + UInt128{1}; + foreign.seq += 1; + ASSERT_EQ(backend->putOverwrite(mount_key, encodeMountLease(foreign), got->token).outcome, PutOutcome::Done); + store->tripMountLost(); + + expectThrowsCode(DB::ErrorCodes::NETWORK_ERROR, [&] { (void)store->namespaceStillLogicallyPresent(ns); }); +} + +/// Pin all three facade states against each other on one namespace -- `Live` (present, content +/// readable), incomplete `Removing` (present, content deliberately unreadable), and terminal `Removing` +/// (absent, immediately, no GC). +TEST(CASRefWriterNamespaceRemoval, PresenceProbeFacadeConsistencyAcrossRemovalLifecycle) +{ + CasRequestBudget budget; + budget.max_attempts = 1; + budget.attempt_timeout_ms = 100; + budget.operation_deadline_ms = kSingleAttemptDeadlineMs; + budget.lease_safety_margin_ms = 100; + + auto backend = std::make_shared(); + auto store = openPool(backend, budget); + const Layout & layout = store->layout(); + const RootNamespace ns{"srv1/presence_facade_consistency"}; + + publishEmptyPart(store, ns, "committed"); + EXPECT_TRUE(store->namespaceStillLogicallyPresent(ns)); + EXPECT_FALSE(store->listRefs(ns).empty()); + + backend->fault_key_substr = layout.namespaceStreamPrefix(DB::Cas::tests::fixture::fixtureLife(ns)) + "_log/"; + backend->fault_count = 1; + expectThrowsCode(DB::ErrorCodes::NETWORK_ERROR, [&] { store->dropNamespace(ns); }); + + EXPECT_TRUE(store->namespaceStillLogicallyPresent(ns)) + << "present for cleanup, even though content below is about to prove unreadable"; + EXPECT_FALSE(store->namespaceFilesLifeIfReadable(ns).has_value()); + EXPECT_FALSE(store->resolveRef(ns, "committed").has_value()); + + EXPECT_NO_THROW(store->dropNamespace(ns)); + EXPECT_FALSE(store->namespaceStillLogicallyPresent(ns)); + EXPECT_FALSE(store->namespaceFilesLifeIfReadable(ns).has_value()); +} + +/// Fix-verify review finding: `namespaceStillLogicallyPresent`'s `Removing` branch proved the OBSERVED +/// incarnation's terminal and returned `false` for the name without re-checking whether the catalog had +/// moved on since. Proving one incarnation terminal is not proof the CURRENT logical namespace is +/// absent: GC can delete the now-terminal row and a successor can be born under the same name while the +/// probe's own recovery call (real I/O, no upper bound) is still in flight. Drives that exact +/// interleaving deterministically via the terminal-proven hook: pause right after the predecessor's +/// terminal is proven, drain GC to actually delete its row, birth a successor under the same name, then +/// resume and require `true` (present) -- never the stale `false` the unfixed probe would answer. +TEST(CASRefWriterNamespaceRemoval, PresenceProbeRevalidatesAfterTerminalProvenRatherThanRacingToStaleAbsent) +{ + auto backend = std::make_shared(); + PoolConfig config; + config.gc_fold_threshold = 1; + config.gc_fold_max_defer_rounds = 8; + config.ref_table_cache_bytes = 0; + auto store = openPoolWithConfig(backend, config); + const Layout & layout = store->layout(); + const RootNamespace ns{"srv1/presence_terminal_revalidate"}; + const UInt128 gc_id = hexToU128("00000000000000000000000000000001"); + + const auto catalog_entry = [&]() -> std::optional + { + const RefCatalog catalog = CasRefCatalog::read(*backend, layout).catalog; + const auto it = std::find_if(catalog.entries.begin(), catalog.entries.end(), [&](const CatalogEntry & entry) + { + return entry.ns == ns; + }); + return it == catalog.entries.end() ? std::nullopt : std::optional{*it}; + }; + + /// `publishEmptyPart` pins its catalog entry to `fixture::fixtureLife(ns)`, a life derived from + /// `ns` alone -- the SAME value every time for the same name, which is exactly wrong for a test + /// whose whole point is that the successor's incarnation must differ from the predecessor's. + /// `publishWithProductionBirth` goes through the real birth path (`resolveNamespaceLife`'s random + /// mint), so both incarnations below are independently random. + publishWithProductionBirth(store, ns, "predecessor"); + const std::optional predecessor = catalog_entry(); + ASSERT_TRUE(predecessor.has_value()); + + Gc gc(store, gc_id); + store->dropNamespace(ns); /// terminal durable; catalog row still Removing until GC deletes it + + /// Pause the probe right after it proves the predecessor's terminal, before its revalidation read. + std::mutex mutex; + std::condition_variable cv; + bool paused = false; + bool resume = false; + store->setNamespacePresenceProbeAfterTerminalProvenHookForTest([&] + { + std::unique_lock lock(mutex); + paused = true; + cv.notify_all(); + cv.wait(lock, [&] { return resume; }); + }); + + std::optional probe_result; + std::exception_ptr probe_error; + std::thread prober([&] + { + try + { + probe_result = store->namespaceStillLogicallyPresent(ns); + } + catch (...) + { + probe_error = std::current_exception(); + } + }); + /// A fatal assertion below (predecessor/successor state, GC round shape) must not skip joining + /// `prober` -- it is still blocked on `cv` at that point, and destructing a joinable `std::thread` + /// calls `std::terminate`, aborting the whole binary and hiding every test queued after this one. + bool prober_joined = false; + SCOPE_EXIT({ + if (!prober_joined) + { + { + std::lock_guard lock(mutex); + resume = true; + } + cv.notify_all(); + prober.join(); + store->setNamespacePresenceProbeAfterTerminalProvenHookForTest(nullptr); + } + }); + { + std::unique_lock lock(mutex); + cv.wait(lock, [&] { return paused; }); + } + + /// While the probe is paused: drain GC to actually delete the predecessor's catalog row (fold the + /// terminal, then a drain-only round to adopt the evidence and delete the row -- same two-round + /// shape `SameNameSameWriterEpochRebirth...` uses), then birth a successor under the SAME name. The + /// row must be gone before a fresh creation is admitted at all, so this also proves the row really + /// was deleted, not merely that the test raced ahead of production's own invariants. + ASSERT_FALSE(runRegularRoundReclaiming(gc).deferred) << "the terminal delta must fold"; + ASSERT_TRUE(runRegularRoundReclaiming(gc).deferred) << "the drain-only round must adopt the evidence seal"; + ASSERT_FALSE(catalog_entry().has_value()) << "control: the predecessor's row is really gone before rebirth"; + publishWithProductionBirth(store, ns, "successor"); + const std::optional successor = catalog_entry(); + ASSERT_TRUE(successor.has_value()); + ASSERT_NE(successor->incarnation, predecessor->incarnation); + + { + std::lock_guard lock(mutex); + resume = true; + } + cv.notify_all(); + prober.join(); + store->setNamespacePresenceProbeAfterTerminalProvenHookForTest(nullptr); + prober_joined = true; + + ASSERT_FALSE(probe_error) << "a successor born under the same name must never surface as an error"; + ASSERT_TRUE(probe_result.has_value()); + EXPECT_TRUE(*probe_result) + << "the predecessor's proven terminal must not answer false once a successor occupies its name"; +} + +/// `StorageJoin`/`StorageSet::truncate` call `disk->removeRecursive(path)` then `disk->createDirectories +/// (path)`. `createDirectories` is a CAS no-op (`ContentAddressedTransaction::createDirectory` only +/// checks write admission, it never touches the catalog), so the actual re-mint happens lazily on the +/// FIRST subsequent write, which resolves through `namespaceLife` exactly like this test does directly. +/// Right after `TRUNCATE` the catalog row is still `Removing` -- GC has not yet folded and deleted it -- +/// so that first write throws a typed retry-later error rather than silently wedging or corrupting +/// anything: the same self-healing window the presence-probe revalidation above depends on. Before the +/// `existsDirectory` fix, the directory never reported as present in the first place, so `TRUNCATE` +/// silently skipped `removeRecursive` entirely and the table kept its OLD contents -- a different, +/// quieter wrong answer than this one, not a newly introduced break. +TEST(CASRefWriterNamespaceRemoval, FilesOnlyNamespaceTruncateThrowsRetryLaterUntilGcReclaimsThenRebirths) +{ + auto backend = std::make_shared(); + PoolConfig config; + config.gc_fold_threshold = 1; + config.gc_fold_max_defer_rounds = 8; + config.ref_table_cache_bytes = 0; + auto store = openPoolWithConfig(backend, config); + const RootNamespace ns{"srv1/truncate_retry_then_rebirth"}; + const UInt128 gc_id = hexToU128("00000000000000000000000000000002"); + + /// Birth a files-only namespace: `StorageJoin`/`StorageSet` never publish a MergeTree part, their + /// table root only ever carries plain table files (`putNamespaceFile`'s shape, not a manifest ref). + const NamespaceLifeId predecessor_life = store->namespaceLife(ns); + store->putNamespaceFile(predecessor_life, "data.bin", "predecessor-contents"); + + Gc gc(store, gc_id); + store->dropNamespace(ns); /// the TRUNCATE-shaped removeRecursive: terminal durable, row still Removing + + /// The very next write CAS would attempt (the `createDirectories` no-op already ran; this is the + /// first real write) must not be told the namespace is gone, and must not silently mint into a + /// row still occupied by the predecessor -- it throws retry-later. + expectThrowsCode(DB::ErrorCodes::NETWORK_ERROR, [&] { (void)store->namespaceLife(ns); }); + + /// Drain GC (fold the terminal, then a drain-only round to delete the now-evidenced row) -- same + /// two-round shape the presence-probe revalidation test above uses. + ASSERT_FALSE(runRegularRoundReclaiming(gc).deferred) << "the terminal delta must fold"; + ASSERT_TRUE(runRegularRoundReclaiming(gc).deferred) << "the drain-only round must adopt the evidence seal"; + + /// Self-healed: the same logical name now mints a fresh incarnation and accepts writes again, + /// exactly what a retried `INSERT` (or a retried `TRUNCATE`) after the CAS write's retry-later gets. + const NamespaceLifeId successor_life = store->namespaceLife(ns); + EXPECT_NE(successor_life.incarnation, predecessor_life.incarnation); + store->putNamespaceFile(successor_life, "data.bin", "successor-contents"); + const auto successor_contents = store->getNamespaceFile(successor_life, "data.bin"); + ASSERT_TRUE(successor_contents.has_value()); + EXPECT_EQ(*successor_contents, "successor-contents"); +} + +/// Cancellation is namespace-scoped: dropping namespace N must not cancel an in-flight build targeting a +/// DIFFERENT namespace M -- that build promotes normally. +TEST(CASRefWriterNamespaceRemoval, DropNamespaceDoesNotCancelBuildsInOtherNamespaces) +{ + auto backend = std::make_shared(); + auto store = openPool(backend); + const RootNamespace ns_dropped{"srv1/remove_me"}; + const RootNamespace ns_other{"srv1/keep_me"}; + + publishEmptyPart(store, ns_dropped, "x"); + + /// An in-flight build in a DIFFERENT namespace. + auto other_build = startBuildFor(store, ns_other, "y"); + const ManifestId id = other_build->stageManifest({}); + other_build->precommitAdd(ns_other, "y", id); + + store->dropNamespace(ns_dropped); + + /// The other namespace's build is untouched: it promotes successfully and its ref resolves. + EXPECT_NO_THROW(other_build->promote(ns_other, "y", other_build->buildId(), id)); + EXPECT_TRUE(store->resolveRef(ns_other, "y").has_value()); +} + +/// A writer-side create/resolution cannot reuse the predecessor while its catalog row is `Removing`. +/// Both the resident-runtime and fresh-runtime paths return typed retry-later without a durable write. +TEST(CASRefWriterNamespaceRemoval, CreateAgainstRemovingRetriesWithoutMutation) +{ + auto backend = std::make_shared(); + const RootNamespace ns{"srv1/create-while-removing"}; + + { + auto store = openPool(backend); + publishEmptyPart(store, ns, "predecessor"); + store->dropNamespace(ns); + + backend->resetCounts(); + expectThrowsCode(DB::ErrorCodes::NETWORK_ERROR, [&] { (void)store->namespaceLife(ns); }); + EXPECT_EQ(backend->putTotal(), 0u); + EXPECT_EQ(backend->putOverwriteTotal(), 0u); + EXPECT_EQ(backend->casPutTotal(), 0u); + } + + auto fresh_store = openPool(backend); + backend->resetCounts(); + expectThrowsCode(DB::ErrorCodes::NETWORK_ERROR, [&] { (void)fresh_store->namespaceLife(ns); }); + EXPECT_EQ(backend->putTotal(), 0u); + EXPECT_EQ(backend->putOverwriteTotal(), 0u); + EXPECT_EQ(backend->casPutTotal(), 0u); +} + +/// Same-name rebirth must not inherit the predecessor's physical life or folded cursor even when the +/// writer mount and its per-name runtime stay resident throughout the complete real removal sequence. +TEST(CASRefWriterNamespaceRemoval, SameNameSameWriterEpochRebirthInvalidatesResidentLifeAndStartsAtZeroCoverage) +{ + auto backend = std::make_shared(); + PoolConfig config; + config.gc_fold_threshold = 1; + config.gc_fold_max_defer_rounds = 8; + config.ref_table_cache_bytes = 0; /// unbounded: no eviction can explain a fresh resolution + auto store = openPoolWithConfig(backend, config); + const Layout & layout = store->layout(); + const RootNamespace ns{"srv1/same-name-rebirth"}; + const UInt128 gc_id = hexToU128("00000000000000000000000000000001"); + + const auto publish_without_fixture_admission = [&](const String & ref) + { + PartWriteInfo info; + info.intended_namespace = ns; + info.intended_ref = ns.string() + "/" + ref; + auto build = store->beginPartWrite(info); + const ManifestId id = build->stageManifest({}); + build->precommitAdd(ns, ref, id); + build->promote(ns, ref, build->buildId(), id); + }; + const auto catalog_entry = [&]() -> std::optional + { + const RefCatalog catalog = CasRefCatalog::read(*backend, layout).catalog; + const auto it = std::find_if(catalog.entries.begin(), catalog.entries.end(), [&](const CatalogEntry & entry) + { + return entry.ns == ns; + }); + return it == catalog.entries.end() ? std::nullopt : std::optional{*it}; + }; + + publish_without_fixture_admission("predecessor"); + const CatalogEntry predecessor = *catalog_entry(); + const uint64_t writer_epoch = store->liveWriterEpoch(); + ASSERT_EQ(store->refTableLifeForTest(ns)->incarnation, predecessor.incarnation); + const uint64_t runtime_identity = store->refTableRuntimeIdentityForTest(ns); + ASSERT_NE(runtime_identity, 0u); + + Gc gc(store, gc_id); + ASSERT_FALSE(runRegularRoundReclaiming(gc).deferred); + GcState state = decodeGcState(backend->get(layout.gcStateKey())->bytes); + CasFoldSeal seal = decodeFoldSeal(backend->get(layout.foldSealKey(state.snap_generation, state.snap_attempt))->bytes); + const auto predecessor_row = seal.ref_lives.find(predecessor.incarnation); + ASSERT_NE(predecessor_row, seal.ref_lives.end()); + ASSERT_NE(predecessor_row->second.coverage.last_folded_ref_id, RefTxnId{}); + + const uint64_t puts_before_drop = backend->putTotal(); + store->dropNamespace(ns); + ASSERT_GT(backend->putTotal(), puts_before_drop) + << "control: the real removal call returned after durably writing its terminal artifacts"; + std::optional terminal_id; + for (const ListedKey & listed : backend->list( + layout.namespaceStreamPrefix(NamespaceLifeId::fromCatalogEntry(ns, predecessor.incarnation)), + "", 1000).keys) + { + const auto parsed = layout.parseRefObjectKey(listed.key); + if (parsed && parsed->kind == RefObjectKind::Log + && (!terminal_id || *terminal_id < parsed->txn_id)) + terminal_id = parsed->txn_id; + } + ASSERT_TRUE(terminal_id.has_value()); + const auto terminal_body = backend->get(layout.refLogKey( + NamespaceLifeId::fromCatalogEntry(ns, predecessor.incarnation), *terminal_id)); + ASSERT_TRUE(terminal_body.has_value()); + const RefLogTxn terminal = decodeRefLogTxn( + openObject(FormatId::RefLog, terminal_body->bytes), ns.string(), *terminal_id); + ASSERT_FALSE(terminal.ops.empty()); + ASSERT_EQ(terminal.ops.back().kind, RefOpKind::RemoveNamespace) + << "control: the newest old-life log is the production terminal record"; + const std::optional removing = catalog_entry(); + ASSERT_TRUE(removing.has_value()); + ASSERT_EQ(removing->state, NsState::Removing) + << "the real terminal returned durable, but its catalog row stayed Live"; + ASSERT_EQ(removing->incarnation, predecessor.incarnation); + ASSERT_EQ(store->refTableRuntimeIdentityForTest(ns), runtime_identity) + << "the removal path must invalidate the resident runtime's life, not pass through eviction"; + + ASSERT_FALSE(runRegularRoundReclaiming(gc).deferred) << "the terminal delta must fold"; + state = decodeGcState(backend->get(layout.gcStateKey())->bytes); + seal = decodeFoldSeal(backend->get(layout.foldSealKey(state.snap_generation, state.snap_attempt))->bytes); + ASSERT_TRUE(seal.ref_lives.at(predecessor.incarnation).cleanup_evidence.has_value()); + + const RoundReport drain = runRegularRoundReclaiming(gc); + ASSERT_TRUE(drain.deferred) << "the drain-only idle invocation must leave the evidence seal adopted"; + ASSERT_FALSE(catalog_entry().has_value()); + ASSERT_EQ(store->refTableRuntimeIdentityForTest(ns), 0u) + << "catalog deletion must detach the predecessor from the name slot"; + + publish_without_fixture_admission("successor"); + const CatalogEntry successor = *catalog_entry(); + ASSERT_EQ(successor.ns, predecessor.ns) << "the exact same logical namespace must be reused"; + ASSERT_NE(successor.incarnation, predecessor.incarnation); + ASSERT_EQ(store->liveWriterEpoch(), writer_epoch) << "rebirth must use the same mounted writer epoch"; + ASSERT_NE(store->refTableRuntimeIdentityForTest(ns), runtime_identity) + << "rebirth must publish a distinct successor runtime, not reset the predecessor"; + ASSERT_EQ(store->refTableLifeForTest(ns)->incarnation, successor.incarnation); + + const NamespaceLifeId successor_life = NamespaceLifeId::fromCatalogEntry(ns, successor.incarnation); + const ListPage successor_stream = backend->list(layout.namespaceStreamPrefix(successor_life), "", 1000); + ASSERT_FALSE(successor_stream.keys.empty()) << "the real successor writer produced foldable stream work"; + std::vector successor_phases; + gc.setPhaseSink([&](const GcPhaseRecord & phase) { successor_phases.push_back(phase); }); + const RoundReport successor_round = runRegularRoundReclaiming(gc); + const auto decision = std::find_if(successor_phases.begin(), successor_phases.end(), [](const GcPhaseRecord & phase) + { + return phase.phase == "defer_decision"; + }); + ASSERT_NE(decision, successor_phases.end()); + ASSERT_FALSE(successor_round.deferred) + << "successor stream keys=" << successor_stream.keys.size() + << ", changed_shards=" << decision->metrics.at("changed_shards") + << ", dead_life_debris=" << decision->metrics.at("dead_life_debris"); + state = decodeGcState(backend->get(layout.gcStateKey())->bytes); + seal = decodeFoldSeal(backend->get(layout.foldSealKey(state.snap_generation, state.snap_attempt))->bytes); + EXPECT_FALSE(seal.ref_lives.contains(predecessor.incarnation)); + const auto successor_row = seal.ref_lives.find(successor.incarnation); + ASSERT_NE(successor_row, seal.ref_lives.end()); + EXPECT_EQ(successor_row->second.coverage.last_folded_ref_id.writer_epoch, writer_epoch); + EXPECT_EQ(successor_row->second.coverage.last_folded_ref_id.ref_sequence, 2u) + << "the successor starts at its own birth+publish stream, not the predecessor's cursor"; + for (const String & key : backend->touchedKeys()) + EXPECT_EQ(key.find("/_cleanup/"), String::npos) << key; +} + +/// Losing the response to an erase that committed must not strand the same resident writer runtime +/// behind its old removal-admission gate. A complete resolution read proves the exact old row absent, +/// so the same name can be born immediately under a fresh incarnation without inheriting coverage. +TEST(CASRefWriterNamespaceRemoval, CommitThenThrowEraseResolvesAndRebindsResidentRuntimeImmediately) +{ + auto backend = std::make_shared(); + PoolConfig config; + config.gc_fold_threshold = 1; + config.ref_table_cache_bytes = 0; + auto store = openPoolWithConfig(backend, config); + const Layout & layout = store->layout(); + const RootNamespace ns{"srv1/removal-erase-lost-response"}; + Gc gc(store, UInt128{101}); + const CompletedRemovingFixture ready = prepareResidentRemovalForDrain(store, *backend, ns, gc); + + backend->catalog_fault_key = layout.refCatalogKey(); + backend->catalog_cas_fault = RefWriterTestBackend::CatalogCasFault::CommitThenThrow; + EXPECT_NO_THROW((void)runRegularRoundReclaiming(gc)); + + const RefCatalog after_erase = CasRefCatalog::read(*backend, layout).catalog; + EXPECT_TRUE(std::none_of(after_erase.entries.begin(), after_erase.entries.end(), [&](const CatalogEntry & entry) + { + return entry.ns == ns && entry.incarnation == ready.predecessor.incarnation; + })); + EXPECT_EQ(store->refTableRuntimeIdentityForTest(ns), 0u); + + EXPECT_NO_THROW(publishWithProductionBirth(store, ns, "successor")); + const CatalogEntry successor = catalogEntryOrThrow(*backend, layout, ns); + EXPECT_NE(successor.incarnation, ready.predecessor.incarnation); + EXPECT_EQ(store->liveWriterEpoch(), ready.writer_epoch); + EXPECT_NE(store->refTableRuntimeIdentityForTest(ns), ready.runtime_identity); + + ASSERT_FALSE(runRegularRoundReclaiming(gc).deferred); + const GcState state = decodeGcState(backend->get(layout.gcStateKey())->bytes); + const CasFoldSeal seal = decodeFoldSeal( + backend->get(layout.foldSealKey(state.snap_generation, state.snap_attempt))->bytes); + EXPECT_FALSE(seal.ref_lives.contains(ready.predecessor.incarnation)); + ASSERT_TRUE(seal.ref_lives.contains(successor.incarnation)); + EXPECT_EQ(seal.ref_lives.at(successor.incarnation).coverage.last_folded_ref_id, + (RefTxnId{ready.writer_epoch, 2})); +} + +/// If another actor wins the erase race by replacing the exact old row, `EntryChanged` still proves +/// the predecessor life dead and must invalidate its resident runtime. +TEST(CASRefWriterNamespaceRemoval, OtherWinnerReplacementInvalidatesExactPredecessorLife) +{ + auto backend = std::make_shared(); + PoolConfig config; + config.gc_fold_threshold = 1; + config.ref_table_cache_bytes = 0; + auto store = openPoolWithConfig(backend, config); + const Layout & layout = store->layout(); + const RootNamespace ns{"srv1/removal-other-winner-replacement"}; + Gc gc(store, UInt128{102}); + const CompletedRemovingFixture ready = prepareResidentRemovalForDrain(store, *backend, ns, gc); + + const CatalogEntry replacement{ + .ns = ns, + .state = NsState::Live, + .incarnation = UInt128{0xfeed}, + .creator = std::nullopt}; + ASSERT_NE(replacement.incarnation, ready.predecessor.incarnation); + const NamespaceLifeId replacement_life = NamespaceLifeId::fromCatalogEntry(ns, replacement.incarnation); + ASSERT_EQ(backend->putIfAbsent(layout.refCkptKey(replacement_life), encodeRefCkpt(RefCkpt{ + .life_epoch = ready.writer_epoch, + .checkpoint_snapshot_id = std::nullopt, + .last_epoch_seal = std::nullopt})).outcome, PutOutcome::Done); + backend->catalog_fault_key = layout.refCatalogKey(); + backend->catalog_replacement_bytes = encodeRefCatalog(RefCatalog{.entries = {replacement}}); + backend->catalog_cas_fault = RefWriterTestBackend::CatalogCasFault::OtherWriterReplacement; + + EXPECT_NO_THROW((void)runRegularRoundReclaiming(gc)); + EXPECT_EQ(store->namespaceLife(ns), replacement_life); + EXPECT_NE(store->refTableRuntimeIdentityForTest(ns), ready.runtime_identity); +} + +/// Failure to read the catalog while resolving a lost erase response is not success. A later fresh +/// name lookup nevertheless observes the old exact row absent and reconciles the resident runtime. +TEST(CASRefWriterNamespaceRemoval, LaterNameLookupReconcilesAfterEraseResolutionReadFailure) +{ + auto backend = std::make_shared(); + PoolConfig config; + config.gc_fold_threshold = 1; + config.ref_table_cache_bytes = 0; + auto store = openPoolWithConfig(backend, config); + const Layout & layout = store->layout(); + const RootNamespace ns{"srv1/removal-resolution-read-failure-lookup"}; + Gc gc(store, UInt128{103}); + const CompletedRemovingFixture ready = prepareResidentRemovalForDrain(store, *backend, ns, gc); + + backend->catalog_fault_key = layout.refCatalogKey(); + backend->catalog_cas_fault = RefWriterTestBackend::CatalogCasFault::CommitThenThrow; + backend->catalog_resolution_get_fault_count = 1; + EXPECT_THROW((void)runRegularRoundReclaiming(gc), std::runtime_error); + EXPECT_TRUE(CasRefCatalog::read(*backend, layout).catalog.entries.empty()); + + std::optional successor; + EXPECT_NO_THROW(successor = store->namespaceLife(ns)); + ASSERT_TRUE(successor.has_value()); + EXPECT_NE(successor->incarnation, ready.predecessor.incarnation); + EXPECT_NE(store->refTableRuntimeIdentityForTest(ns), ready.runtime_identity); +} + +/// The normal post-LIST catalog cut is also a reconciliation point. It repairs a missed local +/// invalidation before any later writer touches the name. +TEST(CASRefWriterNamespaceRemoval, PostListCatalogCutReconcilesMissedEraseInvalidation) +{ + auto backend = std::make_shared(); + PoolConfig config; + config.gc_fold_threshold = 1; + config.ref_table_cache_bytes = 0; + auto store = openPoolWithConfig(backend, config); + const Layout & layout = store->layout(); + const RootNamespace ns{"srv1/removal-resolution-read-failure-post-list"}; + Gc gc(store, UInt128{104}); + const CompletedRemovingFixture ready = prepareResidentRemovalForDrain(store, *backend, ns, gc); + + backend->catalog_fault_key = layout.refCatalogKey(); + backend->catalog_cas_fault = RefWriterTestBackend::CatalogCasFault::CommitThenThrow; + backend->catalog_resolution_get_fault_count = 1; + EXPECT_THROW((void)runRegularRoundReclaiming(gc), std::runtime_error); + EXPECT_TRUE(CasRefCatalog::read(*backend, layout).catalog.entries.empty()); + + EXPECT_NO_THROW((void)runRegularRoundReclaiming(gc)); + EXPECT_NO_THROW(publishWithProductionBirth(store, ns, "successor")); + EXPECT_NE(catalogEntryOrThrow(*backend, layout, ns).incarnation, ready.predecessor.incarnation); + EXPECT_NE(store->refTableRuntimeIdentityForTest(ns), ready.runtime_identity); +} + +/// =================================================================================== +/// Task 11: namespace birth / the recreation gate (spec §Namespace Birth) +/// =================================================================================== + +/// The writer assignment site may pin an already-`Live` catalog life, but recovering that empty life +/// performs no catalog or stream mutation. It must install the exact incarnation from the observed row. +TEST(CASRefWriterNamespaceBirth, ExistingLiveCatalogRowPinsExactLifeWithoutMutation) +{ + auto backend = std::make_shared(); + auto store = openPool(backend); + const RootNamespace ns{"srv1/existing-live-assignment"}; + CasRefCatalog::casAdmitEntry(*backend, store->layout(), 1, CatalogEntry{ + .ns = ns, .state = NsState::Live, .incarnation = UInt128{41}}); + DB::Cas::tests::writeRecoverableCkptForRawFixture(*backend, store->layout(), ns, RefCkpt{ + .life_epoch = store->liveWriterEpoch(), + .committed_through = std::nullopt, + .checkpoint_snapshot_id = std::nullopt, + .last_epoch_seal = std::nullopt, + }); + + backend->resetCounts(); + const NamespaceLifeId life = store->namespaceLife(ns); + EXPECT_EQ(life.incarnation, UInt128{41}); + ASSERT_TRUE(store->refTableLifeForTest(ns)); + EXPECT_EQ(store->refTableLifeForTest(ns)->incarnation, UInt128{41}); + EXPECT_EQ(backend->putTotal(), 0u); + EXPECT_EQ(backend->putOverwriteTotal(), 0u); + EXPECT_EQ(backend->casPutTotal(), 0u); +} + +/// A read of a never-born name may observe the catalog, but it must not allocate the local name slot +/// or a life runtime. Otherwise arbitrary read traffic can fill the cache with identity-less runtimes, +/// and a later birth has to mutate one of those objects into a different identity. +TEST(CASRefWriterNamespaceBirth, NeverBornReadAllocatesNoRuntime) +{ + auto backend = std::make_shared(); + auto store = openPool(backend); + const RootNamespace ns{"srv1/never-born-read-no-runtime"}; + + ASSERT_EQ(store->refTablesCachedCountForTest(), 0u); + EXPECT_FALSE(store->resolveRef(ns, "missing").has_value()); + EXPECT_EQ(store->refTablesCachedCountForTest(), 0u) + << "catalog absence must be decided before a runtime is constructed"; + EXPECT_EQ(store->refTableRuntimeIdentityForTest(ns), 0u); +} + +/// A never-born namespace follows the ordinary catalog-first birth path. +TEST(CASRefWriterNamespaceBirth, BirthFromNeverBornUsesOrdinaryPath) +{ + auto backend = std::make_shared(); + auto store = openPool(backend); + const RootNamespace ns{"srv1/virgin"}; + + EXPECT_NO_THROW(publishEmptyPart(store, ns, "first")); + EXPECT_TRUE(store->resolveRef(ns, "first").has_value()); +} + +/// Coverage gap (Task 13a): the "one op per ref name per batch" cut in `flushRefBatch` (the `seen_refs` +/// guard, `CASRefBatchScopeCuts`) had no test after the shard-lane `CasShardQueue.SameRefMutations +/// SplitAcrossFlushes` was retired. Two payload mutations of the SAME committed ref, made co-pending by +/// the pre-carve hook (mirrors `CompatibleMutationsShareOneCreate`), must NOT co-batch: per-request undo +/// validates each op against the pre-batch state, so the batch carries at most one op per ref name and +/// the two flush as two separate `_log` objects. +TEST(CASRefWriterAppendLane, SameRefMutationsSplitAcrossFlushes) +{ + auto backend = std::make_shared(); + auto store = openPool(backend); + const RootNamespace ns{"srv1/samerefsplit"}; + publishEmptyPart(store, ns, "a"); + ASSERT_TRUE(store->resolveRef(ns, "a").has_value()); + + std::mutex m; + std::condition_variable cv; + bool entered = false; + store->setRefPreCarveHookForTest([&] + { + std::unique_lock lk(m); + if (entered) + return; /// only the leader's own first carve blocks; the second flush proceeds + entered = true; + cv.notify_all(); + cv.wait(lk, [&] { return store->refQueuePendingForTest(ns) >= 2; }); + }); + + const uint64_t put_before = backend->putTotal(); + std::thread t_a([&] { store->updateRefPublishedAt(ns, "a", [](RefPublishedAtUpdate & r) { r.published_at_ms = 1; }); }); + { + std::unique_lock lk(m); + cv.wait(lk, [&] { return entered; }); + } + std::thread t_b([&] { store->updateRefPublishedAt(ns, "a", [](RefPublishedAtUpdate & r) { r.published_at_ms = 2; }); }); + while (store->refQueuePendingForTest(ns) < 2) + std::this_thread::yield(); + cv.notify_all(); + t_a.join(); + t_b.join(); + store->setRefPreCarveHookForTest(nullptr); + + EXPECT_EQ(backend->putTotal(), put_before + 2) << "same-ref mutations must flush as two separate logs"; + /// Neither mutation was lost or corrupted -- the ref still resolves with one of the two writes. + const auto resolved = store->resolveRef(ns, "a"); + ASSERT_TRUE(resolved.has_value()); + EXPECT_TRUE(resolved->published_at_ms == 1 || resolved->published_at_ms == 2); +} + +/// =================================================================================== +/// rev.6 Task 8: the recovery seal (spec §recovery-seal / §seal-id / §seal-soundness). At an UNCLEAN +/// mount, `ensureRefTableRecovered` must close every dead epoch it discovers with an immediate +/// snapshot -- published at the UPPER BOUND of the dead-epoch region, `{liveWriterEpoch() - 1, +/// UINT64_MAX}` -- BEFORE the table is exposed as recovered, so no late predecessor PUT from any dead +/// epoch can ever surface to a cold fold or a fresh recovery. +/// =================================================================================== + +namespace +{ + +/// Seeds crash-style predecessor debris for the seal tests: two DEAD epochs (1 and 2) of durable logs +/// under `ns` -- epoch 1 births ref "a", epoch 2 adds ref "b" -- with no snapshot, and burns the +/// durable epoch counter to exactly 2 so a subsequent `Pool::open` allocates epoch 3 (both dead +/// epochs land strictly below the fresh writer's own, as `dead_region_nonempty` requires). +void seedSealFixtureDeadEpochs(Backend & backend, const Layout & layout, const RootNamespace & ns) +{ + allocateWriterEpoch(backend, layout, "test", EpochMintPolicy::NormalMount, 0, [] { return RefCatalog{}; }); /// burns epoch 1 + allocateWriterEpoch(backend, layout, "test", EpochMintPolicy::NormalMount, 0, [] { return RefCatalog{}; }); /// burns epoch 2 + + RefLogTxn birth; + birth.ns = ns.string(); + birth.txn_id = RefTxnId{1, 1}; + birth.ops = {namespaceBirthOp(), publishCommittedOps("a", manifestRef(1, 1, 1))[0], + publishCommittedOps("a", manifestRef(1, 1, 1))[1]}; + DB::Cas::tests::fixture::writeRefLogRaw(backend, layout, birth); + + RefLogTxn mut; + mut.ns = ns.string(); + mut.txn_id = RefTxnId{2, 1}; + /// Sequence 1 of a new epoch names the seal that closed the one below -- `{1,2}`, the slot right + /// after epoch 1's only durable id, which is where the recovering mount's CAS-walk puts it. The seal + /// OBJECT is deliberately not seeded: this fixture's subject is a recovery that has to mint it. + mut.prev_epoch_seal = RefTxnId{1, 2}; + mut.ops = {publishCommittedOps("b", manifestRef(2, 1, 1))[0], + publishCommittedOps("b", manifestRef(2, 1, 1))[1]}; + DB::Cas::tests::fixture::writeRefLogRaw(backend, layout, mut); + DB::Cas::tests::writeRecoverableCkptForRawFixture(backend, layout, ns, RefCkpt{ + /// The namespace was born in epoch 1 and only `{1,1}` is fronted initially. Recovery must mint + /// the missing required seal `{1,2}` before it may adopt the already durable `{2,1}` successor. + .life_epoch = 1, + .committed_through = RefTxnId{1, 1}, + .checkpoint_snapshot_id = std::nullopt, + .last_epoch_seal = std::nullopt, + }); +} + +/// Plants a same-uuid, UNCLEAN (crash-style, no farewell) predecessor mount lease at `epoch`: a bare +/// `claimMount` followed by a GC fence-out -- mirrors `CASMountOpenWaits.FencedPriorReclaimsWithoutAnyWait`. A fenced +/// prior is an immediate certificate of death (`claimMountAwaitingExpiry` reclaims it on its FIRST +/// attempt, no observation polling), so a fake-clocked successor `Pool::open` above it becomes +/// unclean deterministically, without any real sleep. +void seedUncleanPredecessorMount(Backend & backend, const Layout & layout, uint64_t epoch) +{ + claimMount(backend, layout, "test", UInt128(1), epoch, /*now_ms=*/1000, /*ttl_ms=*/500); + fenceOutRefMount(backend, layout.mountKey("test")); +} + +/// The budget every seal test's successor `Pool::open` uses: a 500ms lease TTL needs a scaled-down +/// budget (RFC cas-s3-timeout-retry-control §required-timeout-model: attempt_timeout + safety_margin < +/// lease TTL) -- mirrors `CASMountOpenWaits.FencedPriorReclaimsWithoutAnyWait` exactly. +CasRequestBudget sealTestTinyBudget() +{ + return CasRequestBudget{ + .attempt_timeout_ms = 50, .operation_deadline_ms = kSingleAttemptDeadlineMs, .max_attempts = 1, + .lease_safety_margin_ms = 50}; +} + +} + +/// The `RefWriterRecoverySeal` suite is RETIRED with the sentinel seal it pinned, and the replacement is +/// `gtest_cas_ref_recovery_cas_walk.cpp` (`CASRefRecoveryCasWalk`), which covers the same duties against +/// the in-band mechanism: a dead epoch closed at `{E, T+1}`, a concurrent recoverer's seal adopted, a +/// straggler adopted and re-sealed at the new `T+1`, chained seals across burned epochs, and genesis. +/// +/// Three of its properties changed MEANING rather than mechanism, and a reader looking for them here +/// should know where they went: +/// +/// - "a clean boundary does not seal" is GONE as a rule. Sealing is now decided by `epoch < live_epoch` +/// alone, never by how the predecessor died: the seal is the chain link that makes a MISSING epoch +/// detectable, and a chain with holes in it wherever a mount shut down cleanly is not a chain. +/// - "a late log below the seal is invisible to recovery" is replaced by something stronger, and the +/// replacement is what makes the detector unnecessary: the seal occupies the ghost's own log key, so +/// a late PUT is REFUSED by the store instead of landing somewhere a reader must learn to ignore. +/// - the `sealed_from` inventory assertions are gone with the field; the chain link recovery installs +/// is `last_epoch_seal`, asserted in the new suite and in `CASRecoveryStreaming`'s inventory test. +/// +/// The fixtures above (`seedSealFixtureDeadEpochs`, `seedUncleanPredecessorMount`, `sealTestTinyBudget`) +/// are KEPT: the recovery-retry suite below drives the same dead-epoch shape. + +/// =================================================================================== +/// Layer 1 of the stuck-table-load fix: `ensureRefTableRecovered` retries a whole recovery attempt +/// after a TRANSIENT object-store NETWORK_ERROR (bounded by `recovery_retry_budget_ms`), instead of +/// failing the table's async load permanently. Non-transient errors and the terminal vanish-race +/// brake still fail fast. +/// =================================================================================== + +TEST(CASRefWriterRecoveryRetry, TransientSealFailureIsRetriedThenSucceeds) +{ + auto backend = std::make_shared(); + const Layout layout("p"); + const RootNamespace ns{"srv1/retry_ok"}; + + seedSealFixtureDeadEpochs(*backend, layout, ns); + seedUncleanPredecessorMount(*backend, layout, /*epoch=*/2); + + uint64_t fake_now = 1'000'000; + + PoolConfig config; + config.server_id = UInt128(1); + config.mount_lease_ttl_ms = std::chrono::milliseconds(500); + config.cas_request_budget = sealTestTinyBudget(); + config.cas_request_budget.recovery_retry_budget_ms = 120000; + config.cas_request_budget.recovery_retry_initial_backoff_ms = 1000; + config.cas_request_budget.recovery_retry_max_backoff_ms = 30000; + config.boot_ms_fn = [&fake_now] { return fake_now; }; + config.wait_sleep_fn = [](uint64_t) {}; + auto store = openPoolWithConfig(backend, config); + ASSERT_TRUE(store); + ASSERT_EQ(store->liveWriterEpoch(), 3u); + + /// No-op backoff and a frozen clock: retries run until the transient faults are exhausted, and the + /// frozen clock keeps the mount fence alive across them (advancing it past the tiny lease TTL would + /// drop the fence and abort recovery -- exercising the fence path, which is the budget test's job). + store->setCasRetrySleepForTest([](uint64_t) {}); + + /// Fail the epoch seal's conditional create twice with a transient (timeout) error; the third + /// attempt lands. The seal is a LOG transaction at `{2,2}` -- the slot after the dead epoch's last + /// durable id -- because INV-2 closes an epoch in-band, at the key a straggler would have taken. + const RefTxnId seal_id{2, 2}; + backend->fault_key_substr = layout.refLogKey(DB::Cas::tests::fixture::fixtureLife(ns), seal_id); + backend->fault_count = 2; + + using ProfileEvents::global_counters; + const auto retries_before = global_counters[ProfileEvents::CASRefRecoveryRetries].load(); + const auto sealed_before = global_counters[ProfileEvents::CASRefRecoveryEpochSealed].load(); + + EXPECT_EQ(store->listRefs(ns).size(), 2u) << "recovery must succeed after retrying past the faults"; + + EXPECT_EQ(global_counters[ProfileEvents::CASRefRecoveryRetries].load(), retries_before + 2); + /// TWO dead epochs (1 and 2) are closed by this walk, and a whole attempt is re-driven per transient + /// failure -- so the seals of the epochs a failed attempt already closed are ADOPTED on the retry + /// rather than minted again. Exactly two are minted in total. + EXPECT_EQ(global_counters[ProfileEvents::CASRefRecoveryEpochSealed].load(), sealed_before + 2); +} + +TEST(CASRefWriterRecoveryRetry, RecoveryDoesNotEnumerateItsStream) +{ + /// A recovery stream LIST used to be a transient failure leg. The checkpoint now names both the + /// base and frontier, so the same injected failures must remain untouched while recovery seals. + auto backend = std::make_shared(); + const Layout layout("p"); + const RootNamespace ns{"srv1/retry_list"}; + + seedSealFixtureDeadEpochs(*backend, layout, ns); + seedUncleanPredecessorMount(*backend, layout, /*epoch=*/2); + + uint64_t fake_now = 1'000'000; + + PoolConfig config; + config.server_id = UInt128(1); + config.mount_lease_ttl_ms = std::chrono::milliseconds(500); + config.cas_request_budget = sealTestTinyBudget(); + config.cas_request_budget.recovery_retry_budget_ms = 120000; + config.boot_ms_fn = [&fake_now] { return fake_now; }; + config.wait_sleep_fn = [](uint64_t) {}; + auto store = openPoolWithConfig(backend, config); + ASSERT_TRUE(store); + ASSERT_EQ(store->liveWriterEpoch(), 3u); + + store->setCasRetrySleepForTest([](uint64_t) {}); + + /// If recovery ever reintroduces a stream LIST, this injection turns the attempt into a retry and + /// consumes the counter. `namespaceFilesLifeIfReadable` reaches writer recovery without performing + /// the unrelated user-facing `listRefs` enumeration. + backend->list_fault_count = 2; + + using ProfileEvents::global_counters; + const auto retries_before = global_counters[ProfileEvents::CASRefRecoveryRetries].load(); + const auto sealed_before = global_counters[ProfileEvents::CASRefRecoveryEpochSealed].load(); + + ASSERT_TRUE(store->namespaceFilesLifeIfReadable(ns)); + EXPECT_EQ(backend->list_fault_count, 2); + EXPECT_EQ(global_counters[ProfileEvents::CASRefRecoveryRetries].load(), retries_before); + EXPECT_EQ(global_counters[ProfileEvents::CASRefRecoveryEpochSealed].load(), sealed_before + 2) + << "two dead epochs (1 and 2) are closed without enumerating their stream"; +} + +TEST(CASRefWriterRecoveryRetry, TransientFailureLongerThanBudgetPropagates) +{ + auto backend = std::make_shared(); + const Layout layout("p"); + const RootNamespace ns{"srv1/retry_budget"}; + + seedSealFixtureDeadEpochs(*backend, layout, ns); + seedUncleanPredecessorMount(*backend, layout, /*epoch=*/2); + + uint64_t fake_now = 1'000'000; + + PoolConfig config; + config.server_id = UInt128(1); + /// Lease TTL >> the recovery budget so the CLOCK-advancing backoff below trips the budget check, + /// not the mount fence -- this test specifically exercises the budget-exhaustion path. + config.mount_lease_ttl_ms = std::chrono::milliseconds(600000); + config.cas_request_budget = sealTestTinyBudget(); + config.cas_request_budget.recovery_retry_budget_ms = 5000; /// small, deterministic + config.cas_request_budget.recovery_retry_initial_backoff_ms = 1000; + config.cas_request_budget.recovery_retry_max_backoff_ms = 30000; + config.boot_ms_fn = [&fake_now] { return fake_now; }; + config.wait_sleep_fn = [](uint64_t) {}; + auto store = openPoolWithConfig(backend, config); + ASSERT_TRUE(store); + store->setCasRetrySleepForTest([&fake_now](uint64_t ms) { fake_now += ms; }); + + /// The seal is an in-band LOG transaction at the slot after the dead epoch's last durable id, not a + /// snapshot at a synthetic id: epoch 1 closes at `{1,2}`, which is the FIRST write the walk attempts. + const RefTxnId seal_id{1, 2}; + backend->fault_key_substr = layout.refLogKey(DB::Cas::tests::fixture::fixtureLife(ns), seal_id); + backend->fault_count = 1000; /// never stops failing within the budget + + expectThrowsCode(DB::ErrorCodes::NETWORK_ERROR, [&] { store->listRefs(ns); }); +} + +TEST(CASRefWriterRecoveryRetry, NonNetworkErrorIsNotRetried) +{ + auto backend = std::make_shared(); + const Layout layout("p"); + const RootNamespace ns{"srv1/retry_fatal"}; + + seedSealFixtureDeadEpochs(*backend, layout, ns); + seedUncleanPredecessorMount(*backend, layout, /*epoch=*/2); + + PoolConfig config; + config.server_id = UInt128(1); + config.mount_lease_ttl_ms = std::chrono::milliseconds(500); + config.cas_request_budget = sealTestTinyBudget(); + config.wait_sleep_fn = [](uint64_t) {}; + auto store = openPoolWithConfig(backend, config); + ASSERT_TRUE(store); + + size_t sleep_calls = 0; + store->setCasRetrySleepForTest([&sleep_calls](uint64_t) { ++sleep_calls; }); + + /// A foreign writer lands DIFFERENT valid bytes at the seal key; resolve-before-reissue then throws + /// CORRUPTED_DATA (a real cross-process seal conflict), which must NOT be retried. + const RefTxnId seal_id{1, 2}; + backend->corrupt_key_substr = layout.refLogKey(DB::Cas::tests::fixture::fixtureLife(ns), seal_id); + backend->corrupt_count = 1; + + expectThrowsCode(DB::ErrorCodes::CORRUPTED_DATA, [&] { store->listRefs(ns); }); + EXPECT_EQ(backend->corrupt_count, 0) + << "the test must reach the injected foreign seal conflict, not fail on fixture validation"; + EXPECT_EQ(sleep_calls, 0u) << "a non-transient error must fail fast with zero backoff sleeps"; +} + +TEST(CASRefWriterRecoveryRetry, VanishBrakeStaysTerminalNotRetried) +{ + auto backend = std::make_shared(); + const Layout layout("p"); + const RootNamespace ns{"srv1/retry_vanish"}; + const ManifestRef ma = manifestRef(1, 1, 1); + + /// Stage B (Task 4-C): pin `ns` to the sentinel before the raw snapshot below -- `store->listRefs` + /// further down is a real production read that would otherwise mint a fresh RANDOM incarnation for + /// this unadmitted namespace instead of adopting the sentinel the raw fixture writes at. + DB::Cas::tests::fixture::admitLive(*backend, layout, ns); + const RefTxnId snap_x{1, 10}; + std::vector base_ops{namespaceBirthOp()}; + const auto publish_a = publishCommittedOps("a", ma); + base_ops.insert(base_ops.end(), publish_a.begin(), publish_a.end()); + DB::Cas::tests::fixture::writeRefLogRaw(*backend, layout, RefLogTxn{ns.string(), snap_x, std::move(base_ops), std::nullopt}); + writeRefSnapshotRaw(*backend, layout, minimalLiveSnapshot(ns.string(), snap_x, {committedRow("a", ma)})); + writeRecoverableCkptForRawFixture(*backend, layout, ns, RefCkpt{ + .life_epoch = 1, + .committed_through = snap_x, + .checkpoint_snapshot_id = snap_x, + .last_epoch_seal = std::nullopt, + }); + + auto store = openPool(backend); + + size_t sleep_calls = 0; + store->setCasRetrySleepForTest([&sleep_calls](uint64_t) { ++sleep_calls; }); + + /// A checkpoint-named snapshot belongs to the caller's immutable authority cut. If that exact + /// object is absent, recovery must report corruption immediately; it must neither reinterpret a + /// transient disappearance as a new authority cut nor enter the outer transient-retry loop. + const String vkey = layout.refSnapshotKey(DB::Cas::tests::fixture::fixtureLife(ns), snap_x); + backend->vanish_once_keys.insert(vkey); + + using ProfileEvents::global_counters; + const auto retries_before = global_counters[ProfileEvents::CASRefRecoveryRetries].load(); + + expectThrowsCode(DB::ErrorCodes::CORRUPTED_DATA, [&] { store->listRefs(ns); }); + + EXPECT_FALSE(backend->vanish_once_keys.contains(vkey)) + << "the test must reach the checkpoint-named snapshot GET, not fail on earlier fixture validation"; + EXPECT_EQ(global_counters[ProfileEvents::CASRefRecoveryRetries].load(), retries_before) + << "missing immutable checkpoint authority is terminal; the outer transient-retry loop must NOT re-drive it"; + EXPECT_EQ(sleep_calls, 0u) << "no backoff sleep for missing immutable checkpoint authority"; +} + +TEST(CASRefWriterRecoveryRetry, ThrowingBackoffSleepDoesNotWedgeRecovery) +{ + /// If the backoff sleep itself throws (e.g. a clock syscall failure), the retry loop must re-acquire + /// state_mutex before unwinding so the SCOPE_EXIT that clears `recovery_in_progress` runs LOCKED -- + /// otherwise a later touch would hang forever on the never-cleared flag. This drives that path and + /// then proves a second touch can still recover (the lane is not wedged). + auto backend = std::make_shared(); + const Layout layout("p"); + const RootNamespace ns{"srv1/retry_sleep_throw"}; + + seedSealFixtureDeadEpochs(*backend, layout, ns); + seedUncleanPredecessorMount(*backend, layout, /*epoch=*/2); + + PoolConfig config; + config.server_id = UInt128(1); + config.mount_lease_ttl_ms = std::chrono::milliseconds(500); + config.cas_request_budget = sealTestTinyBudget(); + config.cas_request_budget.recovery_retry_budget_ms = 120000; + config.wait_sleep_fn = [](uint64_t) {}; + auto store = openPoolWithConfig(backend, config); + ASSERT_TRUE(store); + + /// First touch: the seal PUT fails transiently -> the loop enters backoff -> the sleep THROWS. + bool sleep_should_throw = true; + store->setCasRetrySleepForTest([&sleep_should_throw](uint64_t) + { + if (sleep_should_throw) + throw std::runtime_error("injected backoff-sleep failure"); + }); + const RefTxnId seal_id{1, 2}; + backend->fault_key_substr = layout.refLogKey(DB::Cas::tests::fixture::fixtureLife(ns), seal_id); + backend->fault_count = 1; + + EXPECT_ANY_THROW(store->listRefs(ns)); /// the sleep failure propagates + + /// The lane must NOT be wedged: with the fault now spent and the sleep no longer throwing, a second + /// touch recovers cleanly. If recovery_in_progress had leaked (SCOPE_EXIT run unlocked / not run), a + /// concurrent-safe second recovery would deadlock or mis-behave. + sleep_should_throw = false; + EXPECT_EQ(store->listRefs(ns).size(), 2u) << "a second touch must recover; the retry lane is not wedged"; +} + +/// =================================================================================== +/// Task 16: `hasAnyRefWithPrefix` -- pure existence probe, same recovery preamble as `listRefs` but +/// without materializing the full ref map (an early-exit scan). +/// =================================================================================== + +TEST(CASRefWriterListRefs, HasAnyRefWithPrefixMatchesListRefsEmptiness) +{ + auto backend = std::make_shared(); + auto store = openPool(backend); + const RootNamespace ns{"srv1/prefix_probe"}; + const RootNamespace empty_ns{"srv1/prefix_probe_empty"}; + + EXPECT_FALSE(store->hasAnyRefWithPrefix(empty_ns, "")) << "a never-touched namespace has no refs"; + + publishEmptyPart(store, ns, "all_1_1_0"); + publishEmptyPart(store, ns, "detached-x"); + + EXPECT_TRUE(store->hasAnyRefWithPrefix(ns, "")) << "empty prefix means \"any ref at all\""; + EXPECT_TRUE(store->hasAnyRefWithPrefix(ns, "detached-")); + EXPECT_FALSE(store->hasAnyRefWithPrefix(ns, "moving-")) << "no ref carries this prefix"; + + store->dropNamespace(ns); + EXPECT_FALSE(store->hasAnyRefWithPrefix(ns, "")) << "a tombstoned namespace has no committed refs"; +} diff --git a/src/Disks/tests/gtest_cas_repoint.cpp b/src/Disks/tests/gtest_cas_repoint.cpp new file mode 100644 index 000000000000..9044a8eda895 --- /dev/null +++ b/src/Disks/tests/gtest_cas_repoint.cpp @@ -0,0 +1,107 @@ +#include +#include +#include +#include +#include + +/// Task 3 (all-tree-part-files plan, 2026-07-15): `CachedPartFolderAccess::repointRef` -- the audited +/// primitive a standalone write/remove on an already-COMMITTED part must go through once the mutable +/// per-part file set is empty. It republishes +/// the whole manifest with the new entry set, riding `PartWriteTxn::promote`'s `allow_repoint` mode (Task 2). + +namespace ProfileEvents +{ +extern const Event CASRefRepoint; +} + +using namespace DB::Cas; + +namespace +{ + +ManifestEntry inlineEntry(const String & path, const String & bytes) +{ + ManifestEntry e; + e.path = path; + e.placement = EntryPlacement::Inline; + e.ref = BlobRef{BlobHashAlgo::CityHash128, DB::Cas::BlobDigest::fromU128(DB::Cas::tests::u128Of(bytes))}; + + e.blob_size = bytes.size(); + e.inline_bytes = bytes; + return e; +} + +/// Publish `entries` as committed ref `ns/ref` through the real writer protocol. +ManifestId publishPart(const PoolPtr & store, const RootNamespace & ns, const String & ref, + std::vector entries) +{ + auto build = store->beginPartWrite(PartWriteInfo{.intended_ref = ns.string() + "/" + ref, + .intended_namespace = ns, .op = ProvenanceOp::Insert}); + const ManifestId id = build->stageManifest(entries); + build->precommitAdd(ns, ref, id); + build->promote(ns, ref, build->buildId(), id); + return id; +} + +} + +/// Byte-equal candidate: the exact same entries republished onto an already-committed ref must be a +/// ZERO-mutation no-op -- no fresh manifest staged, no ref-log record appended, no `RefRepoint` event. +/// `stageManifest` mints a non-content-derived `ManifestRef` AND durably PUTs the body on every call +/// (CasPartWriteTxn.cpp), so this can only hold if the no-op check compares candidate `entries` directly +/// against the currently-committed manifest's DECODED entries -- never by staging first (the same +/// structural comparison `republishRef`'s BUG 1c fix uses). +TEST(CASRepoint, ByteEqualIsNoOp) +{ + auto backend = std::make_shared(); + auto store = DB::Cas::tests::openPoolForTest(backend); + const RootNamespace ns{"srv/t1"}; + DB::Cas::CachedPartFolderAccess access(store); + const auto id = publishPart(store, ns, "part_1", {inlineEntry("checksums.txt", "cs")}); + const DB::Cas::PartRefKey key{ns, "part_1"}; + + backend->resetCounts(); + const uint64_t repoints_before = ProfileEvents::global_counters[ProfileEvents::CASRefRepoint].load(); + const DB::Cas::CommitOutcome oc = access.repointRef(key, {inlineEntry("checksums.txt", "cs")}, ProvenanceOp::Other); + EXPECT_FALSE(oc.created); + EXPECT_EQ(oc.manifest_ref, id.ref) << "the byte-equal outcome must name the manifest ALREADY committed, unchanged"; + + EXPECT_EQ(backend->putTotal(), 0u) << "byte-equal repoint must perform ZERO pool mutations"; + EXPECT_EQ(store->resolveRef(ns, "part_1")->manifest_id, id) + << "the committed manifest identity must be untouched"; + EXPECT_EQ(ProfileEvents::global_counters[ProfileEvents::CASRefRepoint].load(), repoints_before); +} + +/// A genuinely different entry set on an already-committed ref republishes the manifest: the returned +/// `CommitOutcome` names a FRESH manifest (`created` still false -- the ref was already committed), +/// the new content resolves, and the repoint is loud (ProfileEvent + the ref's cached view erased so a +/// subsequent read serves the new manifest, not a stale retained one). +TEST(CASRepoint, AddFileRepoints) +{ + auto backend = std::make_shared(); + auto store = DB::Cas::tests::openPoolForTest(backend); + const RootNamespace ns{"srv/t1"}; + DB::Cas::CachedPartFolderAccess access( + store, {.cache_bytes = 64ULL << 20, .max_entries = 10000, .max_entry_bytes = 16ULL << 20, + .explain_enabled = false, .validate = {}}); + const auto id_before = publishPart(store, ns, "part_1", {inlineEntry("checksums.txt", "cs")}); + const DB::Cas::PartRefKey key{ns, "part_1"}; + /// Warm the retained view so the erase-on-success cache discipline is actually exercised. + ASSERT_NE(access.getView(key, DB::Cas::Freshness::CachedForLoad), nullptr); + + const uint64_t repoints_before = ProfileEvents::global_counters[ProfileEvents::CASRefRepoint].load(); + const std::vector new_entries{inlineEntry("checksums.txt", "cs"), inlineEntry("metadata_version.txt", "7")}; + const DB::Cas::CommitOutcome oc = access.repointRef(key, new_entries, ProvenanceOp::Other); + EXPECT_FALSE(oc.created); + EXPECT_NE(oc.manifest_ref, id_before.ref); + + const auto resolved = store->resolveRef(ns, "part_1"); + ASSERT_TRUE(resolved.has_value()); + EXPECT_NE(resolved->manifest_id, id_before) << "a genuine content change must mint a fresh manifest"; + EXPECT_EQ(ProfileEvents::global_counters[ProfileEvents::CASRefRepoint].load(), repoints_before + 1); + + /// The view a caller reads next must reflect the new file, not a stale retained one. + auto view = access.getView(key, DB::Cas::Freshness::CachedForLoad); + ASSERT_NE(view, nullptr); + EXPECT_TRUE(view->hasFile("metadata_version.txt")); +} diff --git a/src/Disks/tests/gtest_cas_request_control.cpp b/src/Disks/tests/gtest_cas_request_control.cpp new file mode 100644 index 000000000000..d3acdf287599 --- /dev/null +++ b/src/Disks/tests/gtest_cas_request_control.cpp @@ -0,0 +1,833 @@ +#include + +#include "config.h" + +#include +#include + +#include + +#if USE_AWS_S3 +#include +#include +#include +#include +#include +#endif + +using namespace DB::Cas; + +namespace DB::ErrorCodes +{ + extern const int NETWORK_ERROR; + extern const int ABORTED; +} + +namespace ProfileEvents +{ + extern const Event CASConditionalWriteAttempts; + extern const Event CASConditionalWriteCommitted; + extern const Event CASConditionalWriteDefiniteFailure; + extern const Event CASConditionalWriteUnresolved; +} + +#if USE_AWS_S3 +namespace DB::ErrorCodes +{ + extern const int LOGICAL_ERROR; + extern const int CORRUPTED_DATA; + extern const int BAD_ARGUMENTS; + extern const int NOT_IMPLEMENTED; + extern const int UNKNOWN_EXCEPTION; +} +#endif + +/// The success path (buf.finalize() returned without throwing) is always Committed. No exception +/// object is needed — the caller distinguishes success from failure before calling either overload. +TEST(CASRequestControl, SuccessIsAlwaysCommitted) +{ + EXPECT_EQ(classifyConditionalWriteResult(), CasWriteOutcome::Committed); +} + +/// Fix #37 phase 2: the retry-later throw must be NETWORK_ERROR, never ABORTED -- ABORTED is silently +/// swallowed by ReplicatedMergeMutateTaskBase (no backoff, no last_exception), which is exactly the +/// defect this fix closes. +TEST(CASWriteRetryLater, ThrowsNetworkErrorNotAborted) +{ + bool threw = false; + try + { + throwCasWriteRetryLater("test cause"); + FAIL() << "throwCasWriteRetryLater must always throw"; + } + catch (const DB::Exception & e) + { + threw = true; + EXPECT_EQ(e.code(), DB::ErrorCodes::NETWORK_ERROR); + EXPECT_NE(e.code(), DB::ErrorCodes::ABORTED); + EXPECT_NE(e.message().find("test cause"), String::npos) << e.message(); + EXPECT_NE(e.message().find("retrying later"), String::npos) << e.message(); + } + EXPECT_TRUE(threw); +} + +/// The exception_ptr twin (for call sites that fail a pending future/promise rather than throw +/// directly, e.g. CasRefLedger's queued-append completion paths) must carry the SAME classification. +TEST(CASWriteRetryLater, ExceptionPtrVariantCarriesSameClassification) +{ + const std::exception_ptr eptr = makeCasWriteRetryLaterExceptionPtr("another cause"); + bool threw = false; + try + { + std::rethrow_exception(eptr); + FAIL() << "expected a thrown exception"; + } + catch (const DB::Exception & e) + { + threw = true; + EXPECT_EQ(e.code(), DB::ErrorCodes::NETWORK_ERROR); + EXPECT_NE(e.message().find("another cause"), String::npos) << e.message(); + } + EXPECT_TRUE(threw); +} + +#if USE_AWS_S3 + +/// One row per RFC cas-s3-timeout-retry-control §operation-classes classification. PreconditionFailed +/// is NEVER DefiniteFailure — it means the key exists, not that the request was rejected — and every +/// unrecognized/ambiguous error also falls to Unresolved, never to a false DefiniteFailure. +TEST(CASRequestControl, ClassifiesPreconditionFailedAsUnresolved) +{ + DB::S3Exception e("412 from backend", Aws::S3::S3Errors::UNKNOWN, "PreconditionFailed"); + EXPECT_EQ(classifyConditionalWriteResult(e), CasWriteOutcome::Unresolved); +} + +TEST(CASRequestControl, ClassifiesTimeoutAsUnresolved) +{ + Poco::TimeoutException e("simulated client-side receive timeout"); + EXPECT_EQ(classifyConditionalWriteResult(e), CasWriteOutcome::Unresolved); +} + +TEST(CASRequestControl, ClassifiesConnectionResetAsUnresolved) +{ + Poco::Net::ConnectionResetException e("simulated connection reset"); + EXPECT_EQ(classifyConditionalWriteResult(e), CasWriteOutcome::Unresolved); +} + +TEST(CASRequestControl, Classifies5xxAsUnresolved) +{ + DB::S3Exception e("simulated internal error", Aws::S3::S3Errors::INTERNAL_FAILURE, "InternalError"); + EXPECT_EQ(classifyConditionalWriteResult(e), CasWriteOutcome::Unresolved); + /// SlowDown / ServiceUnavailable are also 5xx-class and equally Unresolved. + DB::S3Exception slow_down("simulated throttle", Aws::S3::S3Errors::SLOW_DOWN, "SlowDown"); + EXPECT_EQ(classifyConditionalWriteResult(slow_down), CasWriteOutcome::Unresolved); +} + +TEST(CASRequestControl, ClassifiesMalformedRequestAsDefiniteFailure) +{ + DB::S3Exception e("bad xml", Aws::S3::S3Errors::UNKNOWN, "MalformedXML"); + EXPECT_EQ(classifyConditionalWriteResult(e), CasWriteOutcome::DefiniteFailure); + /// The modeled-enum path (no canonical name attached) must classify identically. + DB::S3Exception by_code("bad argument", Aws::S3::S3Errors::INVALID_REQUEST); + EXPECT_EQ(classifyConditionalWriteResult(by_code), CasWriteOutcome::DefiniteFailure); +} + +TEST(CASRequestControl, ClassifiesEntityTooLargeAsDefiniteFailure) +{ + DB::S3Exception e("body exceeds the maximum object size", Aws::S3::S3Errors::UNKNOWN, "EntityTooLarge"); + EXPECT_EQ(classifyConditionalWriteResult(e), CasWriteOutcome::DefiniteFailure); +} + +TEST(CASRequestControl, ClassifiesAccessDeniedAsDefiniteFailure) +{ + DB::S3Exception e("simulated 403", Aws::S3::S3Errors::ACCESS_DENIED, "AccessDenied"); + EXPECT_EQ(classifyConditionalWriteResult(e), CasWriteOutcome::DefiniteFailure); + /// The modeled-enum path (no canonical name attached) must classify identically. + DB::S3Exception by_code("simulated 403, no name", Aws::S3::S3Errors::ACCESS_DENIED); + EXPECT_EQ(classifyConditionalWriteResult(by_code), CasWriteOutcome::DefiniteFailure); +} + +/// Anything the classifier does not recognize (an unmodeled/unnamed S3 error, or an entirely +/// unrelated exception type) must fail toward Unresolved — never toward a false DefiniteFailure or a +/// false Committed (RFC §resolve-before-reissuing: ambiguity always resolves toward "resolve before +/// reissuing"). +TEST(CASRequestControl, UnrecognizedErrorsFailSafeToUnresolved) +{ + DB::S3Exception unknown_named("weird service error", Aws::S3::S3Errors::UNKNOWN, "SomeFutureErrorCode"); + EXPECT_EQ(classifyConditionalWriteResult(unknown_named), CasWriteOutcome::Unresolved); + + /// UNKNOWN_EXCEPTION (not LOGICAL_ERROR): any arbitrary non-S3 exception type works here -- the + /// point is that the classifier doesn't recognize it, not which specific code it carries. + /// LOGICAL_ERROR would abort the whole process under debug/sanitizer builds merely by being + /// constructed (Exception's constructor calls handle_error_code unconditionally). + DB::Exception unrelated(DB::ErrorCodes::UNKNOWN_EXCEPTION, "not an S3 error at all"); + EXPECT_EQ(classifyConditionalWriteResult(unrelated), CasWriteOutcome::Unresolved); +} + +/// recordConditionalWriteAttemptStarted / recordConditionalWriteOutcome bump the per-class counters +/// (RFC §observability): attempts, and exactly one of Committed/DefiniteFailure/Unresolved per call. +TEST(CASRequestControl, CountersHookupIncrementsPerClass) +{ + using ProfileEvents::global_counters; + const auto attempts_before = global_counters[ProfileEvents::CASConditionalWriteAttempts].load(); + const auto committed_before = global_counters[ProfileEvents::CASConditionalWriteCommitted].load(); + const auto definite_before = global_counters[ProfileEvents::CASConditionalWriteDefiniteFailure].load(); + const auto unresolved_before = global_counters[ProfileEvents::CASConditionalWriteUnresolved].load(); + + recordConditionalWriteAttemptStarted(); + recordConditionalWriteOutcome(CasWriteOutcome::Committed); + recordConditionalWriteAttemptStarted(); + recordConditionalWriteOutcome(CasWriteOutcome::DefiniteFailure); + recordConditionalWriteAttemptStarted(); + recordConditionalWriteOutcome(CasWriteOutcome::Unresolved); + +#if !WITH_COVERAGE + EXPECT_EQ(global_counters[ProfileEvents::CASConditionalWriteAttempts].load() - attempts_before, 3u); + EXPECT_EQ(global_counters[ProfileEvents::CASConditionalWriteCommitted].load() - committed_before, 1u); + EXPECT_EQ(global_counters[ProfileEvents::CASConditionalWriteDefiniteFailure].load() - definite_before, 1u); + EXPECT_EQ(global_counters[ProfileEvents::CASConditionalWriteUnresolved].load() - unresolved_before, 1u); +#else + (void)attempts_before; (void)committed_before; (void)definite_before; (void)unresolved_before; +#endif +} + +/// Wiring smoke test: a real conditional write through ObjectStorageBackend (Native mode) counts one +/// attempt and one Committed outcome via the SAME instrumented call site nativeConditionalPut uses — +/// see finalizeConditionalWriteInstrumented in CasObjectStorageBackend.cpp. +TEST(CASRequestControl, NativeConditionalPutCountsOneAttemptAndCommitted) +{ + using ProfileEvents::global_counters; + const auto attempts_before = global_counters[ProfileEvents::CASConditionalWriteAttempts].load(); + const auto committed_before = global_counters[ProfileEvents::CASConditionalWriteCommitted].load(); + + auto b = std::make_shared( + DB::Cas::tests::makeLocalObjectStorageForTest(), ObjectStorageBackend::Mode::Native); + EXPECT_EQ(b->putIfAbsent("p/rc/one", "v1").outcome, PutOutcome::Done); + +#if !WITH_COVERAGE + EXPECT_EQ(global_counters[ProfileEvents::CASConditionalWriteAttempts].load() - attempts_before, 1u); + EXPECT_EQ(global_counters[ProfileEvents::CASConditionalWriteCommitted].load() - committed_before, 1u); +#else + (void)attempts_before; (void)committed_before; +#endif +} + +/// Mechanism property (RFC §disable-transparent-conditional-write-retries), tested at the layer +/// actually reachable from a unit-test binary: NO live/fake S3 endpoint is available here (the Native +/// conditional-write path is exercised end-to-end only at M-W against RustFS — see the HONEST NOTE in +/// CasObjectStorageBackend.cpp), so driving a real socket-level retry against a real client is not +/// reachable from this binary. What IS reachable and asserted here: every Native conditional write +/// selects the SingleAttempt object-storage retry profile, and a non-S3 backend such as +/// LocalObjectStorage reports it as UNSUPPORTED via IObjectStorage::supportsRetryProfile — the property +/// checkConditionalWriteSingleAttemptSupport's fail-closed mount-time gate relies on. +TEST(CASRequestControl, SingleAttemptProfileRequestedAndLocalBackendRejected) +{ + auto b = std::make_shared( + DB::Cas::tests::makeLocalObjectStorageForTest(), ObjectStorageBackend::Mode::Native); + const auto ws = b->conditionalWriteSettingsForTest(); + EXPECT_EQ(ws.object_storage_retry_profile, DB::ObjectStorageRetryProfile::SingleAttempt); + /// LocalObjectStorage does not implement the profile — the capability check must say no. + EXPECT_FALSE(DB::Cas::tests::makeLocalObjectStorageForTest()->supportsRetryProfile(DB::ObjectStorageRetryProfile::SingleAttempt)); +} + +/// The SECOND retry-affecting layer above the S3 client (review finding): WriteBufferFromS3's OWN +/// makeSinglepartUpload/completeMultipartUpload retry loop reissues the identical conditional request +/// on a NO_SUCH_KEY response, driven by S3RequestSetting::max_unexpected_write_error_retries (default +/// 4) — a client-level override alone does not bound it (see WriteSettings:: +/// s3_max_unexpected_write_error_retries_override). Asserted at the reachable seam: no live/fake S3 +/// endpoint exists in this binary to drive the retry loop itself, so this proves the settings +/// plumbing conditionalWriteSettings() -> WriteSettings produces the override value that +/// S3ObjectStorage::writeObject then applies to request_settings — NOT a real single-attempt +/// assertion against a live wire attempt. +TEST(CASRequestControl, ConditionalWriteSettingsForceSingleUnexpectedWriteErrorRetry) +{ + auto b = std::make_shared( + DB::Cas::tests::makeLocalObjectStorageForTest(), ObjectStorageBackend::Mode::Native); + const auto ws = b->conditionalWriteSettingsForTest(); + EXPECT_EQ(ws.s3_max_unexpected_write_error_retries_override, 1u); +} + +/// ================================================================================================ +/// Task 5: CasRequestController — retry controller (deadlines, fence gating, exact-key resolution) +/// ================================================================================================ + +namespace +{ + +/// A per-call scripted Backend for CasRequestController tests: `putIfAbsent` optionally throws a +/// caller-supplied exception (models one classified HTTP-attempt outcome) or returns a forced +/// `PutOutcome` directly (models a `PreconditionFailed` observed WITHOUT an exception); with neither +/// set it delegates to the real in-memory conditional-write semantics. `get` optionally returns a +/// forced result, independent of what `putIfAbsent` actually did, so a test can drive exact-key +/// resolution (identical / different / absent) without the scripted put and the resolve GET needing to +/// agree on a shared, real backing store. +class ScriptedControllerBackend : public InMemoryBackend +{ +public: + std::function put_thrower; + std::optional put_forced_outcome; + std::atomic put_attempts{0}; + + std::function put_overwrite_thrower; + std::optional put_overwrite_forced_outcome; + std::atomic put_overwrite_attempts{0}; + + bool get_overridden = false; + std::optional get_override_value; /// meaningful only when get_overridden + + void setGetOverride(std::optional value) + { + get_overridden = true; + get_override_value = std::move(value); + } + + PutResult putIfAbsent(const String & key, const String & bytes, const ObjectMeta & meta) override + { + ++put_attempts; + if (put_thrower) + put_thrower(); + if (put_forced_outcome) + return {*put_forced_outcome, {}}; + return InMemoryBackend::putIfAbsent(key, bytes, meta); + } + + PutResult putOverwrite(const String & key, const String & bytes, const Token & expected, const ObjectMeta & meta) override + { + ++put_overwrite_attempts; + if (put_overwrite_thrower) + put_overwrite_thrower(); + if (put_overwrite_forced_outcome) + return {*put_overwrite_forced_outcome, {}}; + return InMemoryBackend::putOverwrite(key, bytes, expected, meta); + } + + std::optional get(const String & key, Range range) override + { + if (get_overridden) + return get_override_value; + return InMemoryBackend::get(key, range); + } +}; + +GetResult resultWithBytes(const String & bytes) +{ + return GetResult{.bytes = bytes, .token = Token{"t", TokenType::Emulated}, .attributes = {}}; +} + +} + +TEST(CASRequestController, UncertainResolvesIdenticalAsCommitted) +{ + auto backend = std::make_shared(); + backend->put_thrower = [] { throw Poco::TimeoutException("scripted: ambiguous"); }; + backend->setGetOverride(resultWithBytes("payload")); + + CasRequestController controller(backend, CasRequestBudget{}); + const auto outcome = controller.putIfAbsentControlled("k", "payload", [] { return true; }); + EXPECT_EQ(outcome, CasWriteOutcome::Committed); + EXPECT_EQ(backend->put_attempts.load(), 1u); +} + +TEST(CASRequestController, UncertainResolvesDifferentThrowsCorruption) +{ + auto backend = std::make_shared(); + backend->put_thrower = [] { throw Poco::TimeoutException("scripted: ambiguous"); }; + backend->setGetOverride(resultWithBytes("someone-elses-bytes")); + + CasRequestController controller(backend, CasRequestBudget{}); + DB::Cas::tests::expectThrowsCode(DB::ErrorCodes::CORRUPTED_DATA, [&] + { + controller.putIfAbsentControlled("k", "payload", [] { return true; }); + }); +} + +/// GET-absent NEVER yields DefiniteFailure (spec §writer-side-linearization): the SAME (key, bytes) is +/// retried up to `max_attempts`, and only THEN does the call give up with Unresolved. +TEST(CASRequestController, UncertainResolvesAbsentRetriesSameKeyWithinBudget) +{ + auto backend = std::make_shared(); + backend->put_thrower = [] { throw Poco::TimeoutException("scripted: ambiguous"); }; + backend->setGetOverride(std::nullopt); /// absent on every resolve + + CasRequestBudget budget; + budget.max_attempts = 3; + budget.retry_initial_backoff_ms = 0; /// backoff behavior is pinned by its own tests below + CasRequestController controller(backend, budget); + const auto outcome = controller.putIfAbsentControlled("k", "payload", [] { return true; }); + EXPECT_EQ(outcome, CasWriteOutcome::Unresolved); + EXPECT_EQ(backend->put_attempts.load(), 3u); /// every attempt targeted the SAME key/bytes +} + +/// The operation deadline — not just the attempt-count budget — cuts a retry loop short: a fake clock +/// advances by a fixed step per now_ms() call (no sleeps), and max_attempts is generous enough that only +/// the deadline check can be what stops the loop. +TEST(CASRequestController, OperationDeadlineExhaustionReturnsUnresolvedBeforeMaxAttempts) +{ + auto backend = std::make_shared(); + backend->put_thrower = [] { throw Poco::TimeoutException("scripted: ambiguous"); }; + backend->setGetOverride(std::nullopt); /// absent on every resolve + + uint64_t clock = 0; + auto now_ms = [&clock]() -> uint64_t { const uint64_t t = clock; clock += 200; return t; }; + + CasRequestBudget budget; + budget.max_attempts = 10; + budget.attempt_timeout_ms = 50; + budget.operation_deadline_ms = 450; + budget.retry_initial_backoff_ms = 0; /// isolate the deadline check from the backoff's own deadline guard + CasRequestController controller(backend, budget, now_ms); + const auto outcome = controller.putIfAbsentControlled("k", "payload", [] { return true; }); + EXPECT_EQ(outcome, CasWriteOutcome::Unresolved); + EXPECT_EQ(backend->put_attempts.load(), 2u); /// cut off well before the 10-attempt budget +} + +/// WHY `attempt_timeout_ms == operation_deadline_ms` IS REJECTED AT STARTUP, demonstrated on the +/// mechanism itself before the rejection is asserted below. +/// +/// The deadline is captured as `now + operation_deadline_ms` and the pre-send gate asks +/// `now + attempt_timeout_ms > deadline`. Equal values collapse that to `now_2 > now_1`: ONE elapsed +/// millisecond between the capture and the gate refuses the whole operation with NOTHING SENT. That is +/// not a bounded operation, it is a coin flip on the scheduler -- "mostly works, occasionally refuses +/// having sent nothing", which is the flakiness class validation exists to prevent. Single-attempt +/// semantics is what `max_attempts = 1` is for; the equality contributes only the race. +/// +/// The controller is constructed DIRECTLY here, bypassing `validateCasRequestBudget`, because the +/// point is to show the behaviour the validator now forbids. Three tests were flaky on exactly this +/// before it was forbidden: `8f9e63c7a19`'s sweep-interruption test, +/// `CASRefInstallSafety.UncertainPrecommitKeepsItsCleanupOwnerAndItsBody`, and +/// `CASRefWriterAppendLane.WedgedLaneBlocksSameTableWhileOtherTableProceeds`. +TEST(CASRequestController, EqualAttemptTimeoutAndDeadlineWouldRefuseAfterASingleTick) +{ + auto backend = std::make_shared(); + + /// The smallest possible passage of time: one millisecond per clock read. + uint64_t clock = 0; + auto now_ms = [&clock]() -> uint64_t { const uint64_t t = clock; clock += 1; return t; }; + + CasRequestBudget budget; + budget.max_attempts = 1; + budget.attempt_timeout_ms = 100; + budget.operation_deadline_ms = 100; + CasRequestController razor(backend, budget, now_ms); + EXPECT_EQ(razor.putIfAbsentControlled("k", "payload", [] { return true; }), CasWriteOutcome::Unresolved); + EXPECT_EQ(backend->put_attempts.load(), 0u) + << "the refusal came from the clock, not from the backend: nothing was sent at all"; + + /// STRICTLY LESS -- the shape the validator now requires -- sends the request over the SAME one-tick + /// clock. So what the inequality buys is the request actually happening, not merely a bigger number. + clock = 0; + budget.operation_deadline_ms = 5000; + CasRequestController wide(backend, budget, now_ms); + EXPECT_EQ(wide.putIfAbsentControlled("k", "payload", [] { return true; }), CasWriteOutcome::Committed); + EXPECT_EQ(backend->put_attempts.load(), 1u); +} + +/// And the same equality is refused at startup, so no budget can reach the controller in that shape. +/// The boundary is asserted from BOTH sides: equality throws, one millisecond more is accepted. +TEST(CASRequestController, ValidateBudgetRejectsAttemptTimeoutEqualToOperationDeadline) +{ + CasRequestBudget budget; + budget.attempt_timeout_ms = 5000; + budget.operation_deadline_ms = 5000; + budget.lease_safety_margin_ms = 1000; + DB::Cas::tests::expectThrowsCode(DB::ErrorCodes::BAD_ARGUMENTS, [&] + { + validateCasRequestBudget(budget, /*mount_lease_ttl_ms=*/30000, /*mount_renew_period_ms=*/10000); + }); + + budget.operation_deadline_ms = 5001; + EXPECT_NO_THROW(validateCasRequestBudget(budget, /*mount_lease_ttl_ms=*/30000, /*mount_renew_period_ms=*/10000)) + << "one millisecond of headroom is the whole requirement -- the rule is strictness, not size"; +} + +TEST(CASRequestController, OverwriteAmbiguousResolvesIntendedBytesAsCommitted) +{ + auto backend = std::make_shared(); + bool first_attempt = true; + backend->put_overwrite_thrower = [&first_attempt] + { + if (first_attempt) + { + first_attempt = false; + throw Poco::TimeoutException("scripted: ambiguous"); + } + }; + backend->setGetOverride(resultWithBytes("new-payload")); + + CasRequestController controller(backend, CasRequestBudget{}); + const auto result = controller.putOverwriteControlled( + "k", "new-payload", Token{"old", TokenType::Emulated}, [] { return true; }); + EXPECT_EQ(result.outcome, CasOverwriteOutcome::Committed); + EXPECT_EQ(backend->put_overwrite_attempts.load(), 1u); + EXPECT_EQ(result.token, (Token{"t", TokenType::Emulated})); +} + +TEST(CASRequestController, OverwriteAmbiguousResolvesExpectedTokenAndRetriesWithinBudget) +{ + auto backend = std::make_shared(); + backend->put_overwrite_thrower = [] { throw Poco::TimeoutException("scripted: ambiguous"); }; + const Token expected{"old", TokenType::Emulated}; + backend->setGetOverride(GetResult{.bytes = "old-payload", .token = expected, .attributes = {}}); + + CasRequestBudget budget; + budget.max_attempts = 3; + budget.retry_initial_backoff_ms = 0; + CasRequestController controller(backend, budget); + const auto result = controller.putOverwriteControlled("k", "new-payload", expected, [] { return true; }); + EXPECT_EQ(result.outcome, CasOverwriteOutcome::Unresolved); + EXPECT_EQ(backend->put_overwrite_attempts.load(), 3u); +} + +TEST(CASRequestController, OverwriteAmbiguousResolvesDifferentTokenAndBytesAsConflict) +{ + auto backend = std::make_shared(); + bool first_attempt = true; + backend->put_overwrite_thrower = [&first_attempt] + { + if (first_attempt) + { + first_attempt = false; + throw Poco::TimeoutException("scripted: ambiguous"); + } + }; + backend->setGetOverride(GetResult{ + .bytes = "someone-elses-payload", .token = Token{"other", TokenType::Emulated}, .attributes = {}}); + + CasRequestController controller(backend, CasRequestBudget{}); + const auto result = controller.putOverwriteControlled( + "k", "new-payload", Token{"old", TokenType::Emulated}, [] { return true; }); + EXPECT_EQ(result.outcome, CasOverwriteOutcome::Conflict); + EXPECT_EQ(backend->put_overwrite_attempts.load(), 1u); +} + +TEST(CASRequestController, OverwriteOperationDeadlineExhaustionReturnsUnresolvedBeforeMaxAttempts) +{ + auto backend = std::make_shared(); + backend->put_overwrite_thrower = [] { throw Poco::TimeoutException("scripted: ambiguous"); }; + const Token expected{"old", TokenType::Emulated}; + backend->setGetOverride(GetResult{.bytes = "old-payload", .token = expected, .attributes = {}}); + + uint64_t clock = 0; + auto now_ms = [&clock]() -> uint64_t { const uint64_t t = clock; clock += 200; return t; }; + + CasRequestBudget budget; + budget.max_attempts = 10; + budget.attempt_timeout_ms = 50; + budget.operation_deadline_ms = 450; + budget.retry_initial_backoff_ms = 0; + CasRequestController controller(backend, budget, now_ms); + const auto result = controller.putOverwriteControlled("k", "new-payload", expected, [] { return true; }); + EXPECT_EQ(result.outcome, CasOverwriteOutcome::Unresolved); + EXPECT_EQ(backend->put_overwrite_attempts.load(), 2u); +} + +TEST(CASRequestController, FenceLostBeforeAttemptSendsNoAttempt) +{ + auto backend = std::make_shared(); + CasRequestController controller(backend, CasRequestBudget{}); + const auto outcome = controller.putIfAbsentControlled("k", "payload", [] { return false; }); + EXPECT_EQ(outcome, CasWriteOutcome::Unresolved); + EXPECT_EQ(backend->put_attempts.load(), 0u); +} + +/// The write itself may have landed, but a fence lost between the write and this call's own final +/// check must never surface as Committed (RFC §ack-and-cache-rules: no ACK, no cache update on that +/// path) — the caller sees Unresolved and must not treat the operation as acknowledged. +TEST(CASRequestController, FenceLostAfterWriteNeverReturnsCommitted) +{ + auto backend = std::make_shared(); /// real in-memory commit path + int fence_calls = 0; + auto fence_ok = [&fence_calls] { return fence_calls++ == 0; }; /// true once, then false + + CasRequestController controller(backend, CasRequestBudget{}); + const auto outcome = controller.putIfAbsentControlled("k", "payload", fence_ok); + EXPECT_EQ(outcome, CasWriteOutcome::Unresolved); + EXPECT_EQ(backend->put_attempts.load(), 1u); /// the write itself DID happen + EXPECT_TRUE(backend->head("k").exists); /// ...it is durable; never claimed as Committed here +} + +TEST(CASRequestController, DefiniteFailurePropagatesImmediatelyWithoutResolve) +{ + auto backend = std::make_shared(); + backend->put_thrower = [] { throw DB::S3Exception("scripted: malformed", Aws::S3::S3Errors::UNKNOWN, "MalformedXML"); }; + + CasRequestController controller(backend, CasRequestBudget{}); + const auto outcome = controller.putIfAbsentControlled("k", "payload", [] { return true; }); + EXPECT_EQ(outcome, CasWriteOutcome::DefiniteFailure); + EXPECT_EQ(backend->put_attempts.load(), 1u); /// no retry, no resolve GET issued +} + +/// ================================================================================================ +/// Inter-attempt backoff (chaos-tolerance-report §Task B follow-up / stagefix-review M3): the +/// controller paces reissues with a capped-exponential, fence-gated, deadline-aware sleep instead of +/// hammering a recovering store with immediate retries. +/// ================================================================================================ + +/// The full event-ordered schedule: fence checked before EVERY attempt AND before EVERY sleep, sleeps +/// strictly between attempts, capped exponential (initial 100ms, cap 200ms), no sleep after the final +/// attempt. The exact interleaving is the contract — a sleep served before its fence check would keep +/// a fenced writer dozing past its lease. +TEST(CASRequestControllerBackoff, CappedExponentialSleepsAreFenceCheckedAndOrdered) +{ + auto backend = std::make_shared(); + std::vector events; + backend->put_thrower = [&] { events.emplace_back("put"); throw Poco::TimeoutException("scripted: ambiguous"); }; + backend->setGetOverride(std::nullopt); /// absent on every resolve + + CasRequestBudget budget; + budget.max_attempts = 5; + budget.attempt_timeout_ms = 1; + budget.operation_deadline_ms = 1000000; /// never the binding constraint here + budget.retry_initial_backoff_ms = 100; + budget.retry_max_backoff_ms = 200; + CasRequestController controller( + backend, budget, + /*now_ms=*/[] { return static_cast(0); }, + /*sleep_ms=*/[&](uint64_t ms) { events.push_back("sleep:" + std::to_string(ms)); }); + + const auto fence_ok = [&] { events.emplace_back("fence"); return true; }; + const auto outcome = controller.putIfAbsentControlled("k", "payload", fence_ok); + EXPECT_EQ(outcome, CasWriteOutcome::Unresolved); + EXPECT_EQ(backend->put_attempts.load(), 5u); + + const std::vector expected{ + "fence", "put", "fence", "sleep:100", + "fence", "put", "fence", "sleep:200", + "fence", "put", "fence", "sleep:200", + "fence", "put", "fence", "sleep:200", + "fence", "put"}; /// budget spent: no fence-for-sleep, no sleep after the last attempt + EXPECT_EQ(events, expected); +} + +/// A fence lost between an ambiguous attempt's resolve and its backoff sleep aborts INSTANTLY: no +/// sleep is served, no further attempt is sent, and the outcome is Unresolved (never a false +/// Committed, never a retry under a lost lease). +TEST(CASRequestControllerBackoff, FenceLostBeforeSleepAbortsWithoutSleeping) +{ + auto backend = std::make_shared(); + backend->put_thrower = [] { throw Poco::TimeoutException("scripted: ambiguous"); }; + backend->setGetOverride(std::nullopt); + + CasRequestBudget budget; + budget.max_attempts = 5; + budget.retry_initial_backoff_ms = 100; + budget.retry_max_backoff_ms = 200; + uint64_t sleeps = 0; + int fence_calls = 0; + CasRequestController controller( + backend, budget, /*now_ms=*/[] { return static_cast(0); }, + /*sleep_ms=*/[&](uint64_t) { ++sleeps; }); + + /// True for the pre-attempt check (call 1), lost by the pre-sleep check (call 2). + const auto fence_ok = [&fence_calls] { return ++fence_calls <= 1; }; + const auto outcome = controller.putIfAbsentControlled("k", "payload", fence_ok); + EXPECT_EQ(outcome, CasWriteOutcome::Unresolved); + EXPECT_EQ(backend->put_attempts.load(), 1u) << "no attempt may be sent after the fence is lost"; + EXPECT_EQ(sleeps, 0u) << "a fence lost mid-backoff must abort BEFORE the sleep, not after it"; + EXPECT_EQ(fence_calls, 2); +} + +/// A backoff sleep the operation deadline cannot afford is never served: when sleep + one more +/// attempt would cross the deadline, the loop gives up immediately (Unresolved) instead of sleeping +/// into a guaranteed exhaustion. +TEST(CASRequestControllerBackoff, SleepThatWouldCrossOperationDeadlineIsSkipped) +{ + auto backend = std::make_shared(); + backend->put_thrower = [] { throw Poco::TimeoutException("scripted: ambiguous"); }; + backend->setGetOverride(std::nullopt); + + uint64_t clock = 0; + CasRequestBudget budget; + budget.max_attempts = 10; + budget.attempt_timeout_ms = 10; + budget.operation_deadline_ms = 100; + budget.retry_initial_backoff_ms = 1000; /// any sleep would blow the 100ms deadline + budget.retry_max_backoff_ms = 1000; + uint64_t sleeps = 0; + CasRequestController controller( + backend, budget, /*now_ms=*/[&clock] { return clock; }, + /*sleep_ms=*/[&](uint64_t) { ++sleeps; }); + + const auto outcome = controller.putIfAbsentControlled("k", "payload", [] { return true; }); + EXPECT_EQ(outcome, CasWriteOutcome::Unresolved); + EXPECT_EQ(backend->put_attempts.load(), 1u); + EXPECT_EQ(sleeps, 0u) << "the deadline guard must refuse the sleep, not serve it and then fail"; +} + +/// THE ENVELOPE CONTRACT (chaos-tolerance-report §Task B follow-up): the DEFAULT budget rides a +/// simulated 60-second S3 outage — every conditional-write attempt fails (≈3s adaptive first-attempt +/// timeout each, the observed incident shape) until the store recovers at t=60s, then the next +/// attempt commits, all inside the default 90s operation deadline and 16-attempt budget. The fake +/// clock advances 3s per failed attempt and by each backoff sleep, so this test pins the arithmetic +/// documented on CasRequestBudget without any wall-clock waiting. +TEST(CASRequestControllerBackoff, DefaultBudgetRidesSixtySecondOutage) +{ + auto backend = std::make_shared(); + uint64_t clock = 0; + backend->put_thrower = [&clock] + { + if (clock < 60000) + { + clock += 3000; /// the failed attempt's own ~3s adaptive receive timeout + throw Poco::TimeoutException("scripted: store paused"); + } + /// store recovered: fall through to the real in-memory conditional write (Done) + }; + backend->setGetOverride(std::nullopt); /// nothing ever landed while the store was paused + + CasRequestController controller( + backend, CasRequestBudget{}, /*now_ms=*/[&clock] { return clock; }, + /*sleep_ms=*/[&clock](uint64_t ms) { clock += ms; }); + + const auto outcome = controller.putIfAbsentControlled("k", "payload", [] { return true; }); + EXPECT_EQ(outcome, CasWriteOutcome::Committed) << "the default budget must absorb a 60s outage"; + /// Schedule: attempts fail at 3s each with sleeps 0.2,0.4,0.8,1.6,3.2 then 5s (cap); the first + /// attempt scheduled at clock >= 60000 (attempt 11, t=61.2s) commits — well inside 16 attempts + /// and the 90s deadline. + EXPECT_EQ(backend->put_attempts.load(), 11u); + EXPECT_LT(clock, CasRequestBudget{}.operation_deadline_ms); +} + +/// Availfix review M1: deterministic CALLER/local bugs — `LOGICAL_ERROR` (a broken source), +/// `NOT_IMPLEMENTED` (a mode/capability guard, e.g. promoteStaged on a backend without a native +/// conditional copy), `BAD_ARGUMENTS` (a deterministic encode rejection escaping buildHeader's second +/// encode), `CORRUPTED_DATA` (integrity) — propagate INSTANTLY from the create retry loop: exactly one +/// attempt, no occupancy resolve, no backoff sleep. Retrying a deterministic failure only replays it +/// (~12 minutes at the default budget through putBlob's outer loop) and buries the root cause behind a +/// retryable ABORTED — the exact class the `PutBlobWrongSizeFailsClosed` sweep regression exposed. +TEST(CASRequestControllerCreate, DeterministicLocalFailuresPropagateInstantly) +{ + /// LOGICAL_ERROR aborts the whole process in debug/sanitizer builds instead of behaving like a + /// catchable exception, so it's excluded from this loop there -- + /// CASRequestControllerCreateDeathTest below proves the same instant-propagate contract for it + /// positively via EXPECT_DEATH instead. +#ifdef DEBUG_OR_SANITIZER_BUILD + const std::vector codes = {DB::ErrorCodes::NOT_IMPLEMENTED, DB::ErrorCodes::BAD_ARGUMENTS, DB::ErrorCodes::CORRUPTED_DATA}; +#else + const std::vector codes = {DB::ErrorCodes::LOGICAL_ERROR, DB::ErrorCodes::NOT_IMPLEMENTED, + DB::ErrorCodes::BAD_ARGUMENTS, DB::ErrorCodes::CORRUPTED_DATA}; +#endif + for (const int code : codes) + { + SCOPED_TRACE("error code " + std::to_string(code)); + auto backend = std::make_shared(); + uint64_t sleeps = 0; + int attempts = 0; + CasRequestController controller( + backend, CasRequestBudget{}, /*now_ms=*/[] { return static_cast(0); }, + /*sleep_ms=*/[&](uint64_t) { ++sleeps; }); + + bool threw = false; + try + { + controller.conditionalCreateControlled("k", + [&]() -> PutResult + { + ++attempts; + throw DB::Exception(code, "scripted deterministic local failure"); + }, + [] { return true; }); + } + catch (const DB::Exception & e) + { + threw = true; + EXPECT_EQ(e.code(), code) << "the ORIGINAL exception must propagate, not a mapped outcome"; + } + EXPECT_TRUE(threw) << "a deterministic local failure must propagate, never return an outcome"; + EXPECT_EQ(attempts, 1) << "no reissue: retrying a deterministic failure only replays it"; + EXPECT_EQ(sleeps, 0u) << "no backoff sleep may be served for a deterministic failure"; + } +} + +#if defined(DEBUG_OR_SANITIZER_BUILD) +/// Debug/sanitizer-build counterpart to DeterministicLocalFailuresPropagateInstantly's LOGICAL_ERROR +/// case, excluded from that loop above: LOGICAL_ERROR aborts the process here instead of throwing a +/// catchable exception, so the check must be a death test (same pattern as CASBlobDigestDeathTest in +/// gtest_cas_blob_digest.cpp). +TEST(CASRequestControllerCreateDeathTest, LogicalErrorPropagatesInstantlyAborts) +{ + auto backend = std::make_shared(); + CasRequestController controller( + backend, CasRequestBudget{}, /*now_ms=*/[] { return static_cast(0); }, + /*sleep_ms=*/[](uint64_t) {}); + EXPECT_DEATH( + { + controller.conditionalCreateControlled("k", + [&]() -> PutResult { throw DB::Exception(DB::ErrorCodes::LOGICAL_ERROR, "scripted deterministic local failure"); }, + [] { return true; }); + }, ""); +} +#endif + +/// Startup validation (RFC §required-timeout-model): a consistent default budget is accepted silently; +/// either inequality violated on its own is rejected with BAD_ARGUMENTS. +TEST(CASRequestController, ValidateBudgetAcceptsConsistentDefaults) +{ + EXPECT_NO_THROW(validateCasRequestBudget(CasRequestBudget{}, /*mount_lease_ttl_ms=*/30000, /*mount_renew_period_ms=*/10000)); +} + +TEST(CASRequestController, ValidateBudgetRejectsAttemptTimeoutPlusMarginAtOrAboveLeaseTtl) +{ + CasRequestBudget budget; + budget.attempt_timeout_ms = 25000; + budget.lease_safety_margin_ms = 5000; /// sums to EXACTLY the lease TTL below — not strictly less + DB::Cas::tests::expectThrowsCode(DB::ErrorCodes::BAD_ARGUMENTS, [&] + { + validateCasRequestBudget(budget, /*mount_lease_ttl_ms=*/30000, /*mount_renew_period_ms=*/10000); + }); +} + +TEST(CASRequestController, ValidateBudgetRejectsAttemptTimeoutAboveOperationDeadline) +{ + CasRequestBudget budget; + budget.attempt_timeout_ms = 6000; + budget.operation_deadline_ms = 5000; + budget.lease_safety_margin_ms = 1000; + DB::Cas::tests::expectThrowsCode(DB::ErrorCodes::BAD_ARGUMENTS, [&] + { + validateCasRequestBudget(budget, /*mount_lease_ttl_ms=*/30000, /*mount_renew_period_ms=*/10000); + }); +} + +/// max_attempts == 0 would let putIfAbsentControlled return Unresolved without ever sending an +/// attempt — reject at startup rather than silently accepting a no-op budget. +TEST(CASRequestController, ValidateBudgetRejectsZeroMaxAttempts) +{ + CasRequestBudget budget; + budget.max_attempts = 0; + DB::Cas::tests::expectThrowsCode(DB::ErrorCodes::BAD_ARGUMENTS, [&] + { + validateCasRequestBudget(budget, /*mount_lease_ttl_ms=*/30000, /*mount_renew_period_ms=*/10000); + }); +} + +/// A capped-exponential backoff whose cap sits below its own starting value is inconsistent — reject +/// at startup (0/0 disables backoff and stays accepted, covered by the defaults test above since the +/// defaults are nonzero and consistent). +TEST(CASRequestController, ValidateBudgetRejectsInitialBackoffAboveMaxBackoff) +{ + CasRequestBudget budget; + budget.retry_initial_backoff_ms = 500; + budget.retry_max_backoff_ms = 100; + DB::Cas::tests::expectThrowsCode(DB::ErrorCodes::BAD_ARGUMENTS, [&] + { + validateCasRequestBudget(budget, /*mount_lease_ttl_ms=*/30000, /*mount_renew_period_ms=*/10000); + }); +} + +/// attempt_timeout_ms + lease_safety_margin_ms must not be computed by a wrapping uint64 sum: absurd +/// config values near UINT64_MAX must fail validation (correctly, as inconsistent), never wrap around +/// to a spuriously small sum that would pass the "< lease TTL" check. +TEST(CASRequestController, ValidateBudgetRejectsOverflowingSumRatherThanWrapping) +{ + CasRequestBudget budget; + budget.attempt_timeout_ms = std::numeric_limits::max() - 10; + budget.lease_safety_margin_ms = 20; /// sum would wrap past UINT64_MAX to a tiny value + budget.operation_deadline_ms = std::numeric_limits::max(); + DB::Cas::tests::expectThrowsCode(DB::ErrorCodes::BAD_ARGUMENTS, [&] + { + validateCasRequestBudget(budget, /*mount_lease_ttl_ms=*/30000, /*mount_renew_period_ms=*/10000); + }); +} + +#endif diff --git a/src/Disks/tests/gtest_cas_retirement_sweep.cpp b/src/Disks/tests/gtest_cas_retirement_sweep.cpp new file mode 100644 index 000000000000..c058fa87bbbc --- /dev/null +++ b/src/Disks/tests/gtest_cas_retirement_sweep.cpp @@ -0,0 +1,420 @@ +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include + +/// THE RETIREMENT SWEEP's executable half. +/// +/// Two mechanisms of the pre-v9 ref protocol lost their premise when the ref stream became a +/// contiguous, arithmetically-walkable chain, and this file is where each retirement is proved rather +/// than asserted in a comment: +/// +/// 1. PROBE A's ABORT. The detector compared the round's two enumerations of `cas/ns/stream/` and, on any +/// disagreement, aborted ref folding for the whole round -- because the fold ITERATED the listing, +/// so a hole in it meant a record was about to be skipped forever. The intake reads by exact key +/// now, so a hole folds through; a detector that still aborted would be halting a round that is +/// provably doing the right thing. It was demoted to a sampled store-quality detector and then +/// deleted outright: the round enumerates `cas/ns/stream/` exactly once, on every round. +/// 2. THE MATERIALIZATION GRACE (`T_mat`). A post-reclaim sleep, long enough for a straggler +/// conditional `PUT` from a dying epoch to land or exhaust its retries BEFORE the successor +/// trusted its recovery LISTINGS. Recovery does not trust listings; it closes every dead epoch +/// with an in-band `EpochSeal` written as a conditional create, and the straggler's own create +/// loses to it. The wait is deleted outright, setting and all -- the feature never shipped, so +/// there is no config to protect and no parsed-but-inert period to serve. +/// +/// The retirement rationale (premise / verdict / replacement / evidence, one row per retired item) +/// is captured in `docs/en/antalya/cas/architecture/design-history.md`. + +namespace DB::ErrorCodes +{ + extern const int NETWORK_ERROR; +} + +using namespace DB::Cas; +using DB::Cas::tests::idOf; +using DB::Cas::tests::u128Of; + +namespace +{ + +/// A backend that drops ONE chosen key from ONE chosen `list` call while exact `get`/`head` of that key +/// keep working: the minimal realisation of "the store returned an incomplete answer". WHICH call is +/// load-bearing here, because the round enumerates the ref prefix exactly once -- so `nth = 0` is +/// always the round's own enumeration, the one the fold regroups. +/// +/// `nth` counts only those `list` calls that WOULD have returned the key, so unrelated prefix +/// enumerations cannot shift it; arm it AFTER every seeding write, since the writer's own namespace +/// listings would otherwise consume a qualifying call. +class HoleyListBackend : public InMemoryBackend +{ +public: + void omitFromNthListCall(const String & key, size_t nth) + { + std::lock_guard lock(m); + omitted = key; + target_call = nth; + seen_calls = 0; + served = false; + } + + /// Whether the hole was actually served. Asserted by every test that plants one, so a mistyped key + /// or a miscounted `nth` cannot let a test pass vacuously. + bool holeServed() const + { + std::lock_guard lock(m); + return served; + } + + ListPage list(const String & prefix, const String & cursor, size_t limit) override + { + ListPage page = InMemoryBackend::list(prefix, cursor, limit); + std::lock_guard lock(m); + if (omitted.empty()) + return page; + auto it = std::find_if(page.keys.begin(), page.keys.end(), + [&](const ListedKey & k) { return k.key == omitted; }); + if (it == page.keys.end()) + return page; /// not a qualifying call -- do not count it + if (seen_calls++ != target_call) + return page; + page.keys.erase(it); + served = true; + omitted.clear(); /// one hole only + return page; + } + +private: + mutable std::mutex m; + String omitted; + size_t target_call = 0; + size_t seen_calls = 0; + bool served = false; +}; + +/// Counts full enumerations of the ref prefix -- one increment per `list` call whose prefix is EXACTLY +/// `cas/ns/stream/`, which is how "the round lists this prefix once" becomes an assertion instead of a +/// claim. `janitor_prefix_lists` counts the bounded `cas/ns/` janitor page separately, by the same exact +/// match: the two prefixes are distinct strings (`cas/ns/stream/` vs `cas/ns/`), so a hot scan and the +/// janitor's own bounded page can never be conflated by this counter. +class RefPrefixListCountingBackend : public InMemoryBackend +{ +public: + String refs_prefix; + String janitor_prefix; + std::atomic ref_prefix_lists{0}; + std::atomic janitor_prefix_lists{0}; + + ListPage list(const String & prefix, const String & cursor, size_t limit) override + { + if (!refs_prefix.empty() && prefix == refs_prefix) + ++ref_prefix_lists; + if (!janitor_prefix.empty() && prefix == janitor_prefix) + ++janitor_prefix_lists; + return InMemoryBackend::list(prefix, cursor, limit); + } +}; + +/// Forces the FIRST `putIfAbsent` whose key contains `fault_key_substr` to throw an ambiguous +/// (Unresolved-classified) exception, `fault_count` times -- the minimal fault injection needed to drive +/// a ref-log append into the `Unresolved`/wedge outcome, with `max_attempts = 1` in the budget so the +/// single failed attempt exhausts the retry budget immediately. (Same shape as `gtest_cas_pool.cpp`'s +/// file-local backend of the same name; both are three lines of `throw` over `InMemoryBackend`, and +/// hoisting a shared one would couple two suites' fault models for no gain.) +class UnresolvedPutBackend final : public InMemoryBackend +{ +public: + using Backend::putIfAbsent; + + String fault_key_substr; + int fault_count = 0; + + PutResult putIfAbsent(const String & key, const String & bytes, const ObjectMeta & meta) override + { + if (fault_count > 0 && !fault_key_substr.empty() && key.find(fault_key_substr) != String::npos) + { + --fault_count; + throw Poco::TimeoutException("UnresolvedPutBackend: simulated ambiguous result (response lost)"); + } + return InMemoryBackend::putIfAbsent(key, bytes, meta); + } +}; + +/// GC's fence-out applied directly to the mount lease: preserve the body, set `gc_fenced`, bump `seq` +/// (token-guarded). A subsequent `tryRemountOnce` then reclaims a fresh incarnation. +void fenceOutMount(Backend & backend, const String & mount_key) +{ + const auto got = backend.get(mount_key); + ASSERT_TRUE(got.has_value()); + MountLease m = decodeMountLease(got->bytes); + m.gc_fenced = true; + m.seq += 1; + ASSERT_EQ(backend.putOverwrite(mount_key, encodeMountLease(m), got->token).outcome, PutOutcome::Done); +} + +/// Publish one part `ref` with a single content blob whose payload is `payload`. +ManifestId publishOneBlobPart(const PoolPtr & s, const RootNamespace & ns, const String & ref, + const String & payload) +{ + PartWriteInfo info; + info.intended_ref = ns.string() + "/" + ref; + auto build = s->beginPartWrite(info); + build->putBlob(idOf(payload), BlobSource::fromString(payload)); + + ManifestEntry e; + e.path = "data.bin"; + e.placement = EntryPlacement::Blob; + e.ref = BlobRef{BlobHashAlgo::CityHash128, BlobDigest::fromU128(u128Of(payload))}; + e.blob_size = payload.size(); + + const ManifestId id = build->stageManifest({e}); + build->precommitAdd(ns, ref, id); + build->promote(ns, ref, build->buildId(), id); + return id; +} + +/// Every ref-log key of `ns` currently listed, in key order. +std::set listRefLogKeys(Backend & b, const Layout & l, const RootNamespace & ns) +{ + std::set out; + String cursor; + while (true) + { + const ListPage page = b.list(l.namespaceStreamPrefix(DB::Cas::tests::fixture::fixtureLife(ns)), cursor, 1000); + for (const ListedKey & k : page.keys) + if (const auto parsed = l.parseRefObjectKey(k.key); parsed && parsed->kind == RefObjectKind::Log) + out.insert(k.key); + if (page.next_cursor.empty()) + break; + cursor = page.next_cursor; + } + return out; +} + +/// The greatest ref-log id of `ns` in the current listing. +RefTxnId greatestLoggedId(Backend & b, const Layout & l, const RootNamespace & ns) +{ + RefTxnId best{}; + for (const String & key : listRefLogKeys(b, l, ns)) + if (const auto parsed = l.parseRefObjectKey(key); parsed && best < parsed->txn_id) + best = parsed->txn_id; + return best; +} + +Poco::AutoPtr makeDiskConfig(const std::string & inner) +{ + std::istringstream iss("" + inner + ""); + return new Poco::Util::XMLConfiguration(iss); +} + +} + +/// The blob the hidden removal releases must actually be reclaimed. This is the retention half of the +/// skipped-transaction class, and it is the outcome the abort used to buy at the price of a lost round: +/// under arithmetic intake the very round that was served the hole folds the removal, so the blob dies +/// on the normal schedule rather than waiting for a listing to become honest again. +TEST(CASRetirementSweep, AHiddenRemovalStillReclaimsItsBlob) +{ + auto backend = std::make_shared(); + auto store = Pool::open(backend, PoolConfig{ + .pool_prefix = "p", .server_root_id = "test", + .gc_fold_max_defer_rounds = 0, + }); + const Layout & layout = store->layout(); + const RootNamespace ns{"srv/tbl"}; + const String payload = "reclaimed-payload"; + /// Stage B (Task 4-C): pin to the sentinel before the first real touch -- `listRefLogKeys` below + /// lists at that exact prefix. The raw `Live` row also needs the same empty checkpoint authority + /// as a completed production birth before `publishOneBlobPart` invokes recovery. + DB::Cas::tests::fixture::admitLive(*backend, layout, ns); + DB::Cas::tests::writeRecoverableCkptForRawFixture(*backend, layout, ns, RefCkpt{ + .life_epoch = 1, + .committed_through = std::nullopt, + .checkpoint_snapshot_id = std::nullopt, + .last_epoch_seal = std::nullopt, + }); + + publishOneBlobPart(store, ns, "part_a", payload); + Gc gc(store, hexToU128("00000000000000000000000000000012")); + /// Reclaiming rounds: Stage A's destructive gate refuses a universe it cannot enumerate, and this + /// test's subject IS the reclamation (see `runRegularRoundReclaiming`). + ASSERT_TRUE(DB::Cas::tests::runRegularRoundReclaiming(gc).acquired_lease); + store->renewWatermarkOnce(); + const String blob_key = layout.blobKey(BlobRef{BlobHashAlgo::CityHash128, + BlobDigest::fromU128(u128Of(payload))}); + ASSERT_TRUE(backend->head(blob_key).exists); + + const std::set before_drop = listRefLogKeys(*backend, layout, ns); + store->dropRef(ns, "part_a"); + String removal_key; + for (const String & k : listRefLogKeys(*backend, layout, ns)) + if (!before_drop.contains(k)) + removal_key = k; + ASSERT_FALSE(removal_key.empty()); + store->renewWatermarkOnce(); + + backend->omitFromNthListCall(removal_key, /*nth=*/0); + + /// condemn -> graduate -> exact-token delete needs several rounds; the first of them is the one + /// served the hole. + for (int i = 0; i < 12; ++i) + { + ASSERT_TRUE(DB::Cas::tests::runRegularRoundReclaiming(gc).acquired_lease); + store->renewWatermarkOnce(); + } + ASSERT_TRUE(backend->holeServed()) << "the sabotage never fired"; + + EXPECT_FALSE(backend->head(blob_key).exists) + << "the removal was hidden from one enumeration and never folded -- the retention half of the " + "skipped-transaction class, which arithmetic intake is supposed to close"; +} + +/// THE RETIREMENT's whole point, made observable: a folding round enumerates `cas/ns/stream/` exactly +/// ONCE, on EVERY round of a multi-round run -- not just the first, since a regression that quietly +/// reintroduced a second enumeration only on a LATER round would pass a one-round check for the wrong +/// reason. The bounded `cas/ns/` janitor page is a separate exact-string prefix and runs every round +/// too; it must never be counted as, or mistaken for, a hot scan of the ref prefix. 32 rounds exercises +/// the deleted detector's own cadence (every 16th folding round) twice over, so a regression that only +/// reintroduces the second enumeration on that cadence cannot hide inside a shorter run. +TEST(CASRetirementSweep, TheRoundEnumeratesTheRefPrefixExactlyOnce) +{ + auto backend = std::make_shared(); + auto store = Pool::open(backend, PoolConfig{ + .pool_prefix = "p", .server_root_id = "test", + .gc_fold_max_defer_rounds = 0, + }); + backend->refs_prefix = store->layout().casRefsPrefix(); + backend->janitor_prefix = store->layout().namespaceRootPrefix(); + const RootNamespace ns{"srv/tbl"}; + publishOneBlobPart(store, ns, "part_a", "counted-payload"); + store->renewWatermarkOnce(); + + Gc gc(store, hexToU128("00000000000000000000000000000013")); + for (int round = 0; round < 32; ++round) + { + backend->ref_prefix_lists.store(0); + backend->janitor_prefix_lists.store(0); + const RoundReport report = gc.runRegularRound(); + ASSERT_TRUE(report.acquired_lease); + ASSERT_FALSE(report.deferred); + EXPECT_EQ(backend->ref_prefix_lists.load(), 1u) + << "round " << round << " enumerated cas/ns/stream/ a number of times other than once"; + EXPECT_GT(backend->janitor_prefix_lists.load(), 0u) + << "round " << round << " never took the bounded cas/ns/ janitor page"; + store->renewWatermarkOnce(); + } +} + + +/// ==================== item 2: the materialization grace, retired ==================== + +/// THE MECHANISM THAT REPLACED THE WAIT, tested directly. A ref lane is left holding an UNDECIDED +/// conditional `PUT` when the fence trips -- the exact state `T_mat` was introduced to wait out. The +/// remount proceeds with no wait at all, the next recovery closes the dead epoch with an in-band +/// `EpochSeal` at the slot the straggler would have taken, and the straggler's own conditional create +/// then LOSES to it. +/// +/// The assertion is the conflict itself, not the absence of damage: "nothing bad happened" would also +/// be true of a run where the straggler simply never arrived. +TEST(CASRetirementSweep, AStragglerFromTheDyingEpochLosesItsCreateToTheRecoverySeal) +{ + CasRequestBudget budget; + budget.max_attempts = 1; + budget.attempt_timeout_ms = 100; + budget.operation_deadline_ms = 5000; + budget.lease_safety_margin_ms = 100; + + auto backend = std::make_shared(); + uint64_t fake_boot = 1'000'000; + std::vector waits; + auto store = Pool::open(backend, PoolConfig{ + .pool_prefix = "p", .server_root_id = "test", + .mount_lease_ttl_ms = std::chrono::milliseconds(30000), + .cas_request_budget = budget, + .boot_ms_fn = [&] { return fake_boot; }, + .wait_sleep_fn = [&](uint64_t ms) { fake_boot += ms; waits.push_back(ms); }, + }); + ASSERT_TRUE(store); + const Layout & layout = store->layout(); + const RootNamespace ns{"srv/straggler"}; + /// Pin to the transition life before the first real touch, and give that raw `Live` row the exact + /// empty checkpoint authority that production birth would have published before recovery. + DB::Cas::tests::fixture::admitLive(*backend, layout, ns); + DB::Cas::tests::writeRecoverableCkptForRawFixture(*backend, layout, ns, RefCkpt{ + .life_epoch = 1, + .committed_through = std::nullopt, + .checkpoint_snapshot_id = std::nullopt, + .last_epoch_seal = std::nullopt, + }); + + publishOneBlobPart(store, ns, "x", "straggler-payload"); + ASSERT_EQ(store->liveWriterEpoch(), 1u); + + /// Drive the next ref-log append into the Unresolved/wedge outcome: the single attempt the budget + /// allows fails ambiguously, so this process can never learn whether its conditional PUT landed. + /// That undecidability is the whole reason the resolution is a conditional CREATE and not a GET. + backend->fault_key_substr = layout.namespaceStreamPrefix(DB::Cas::tests::fixture::fixtureLife(ns)) + "_log/"; + backend->fault_count = 1; + DB::Cas::tests::expectThrowsCode(DB::ErrorCodes::NETWORK_ERROR, [&] { store->dropRef(ns, "x"); }); + ASSERT_TRUE(store->refLaneWedgedForTest(ns)); + + /// The id the straggler would occupy: one past the greatest record that is actually durable in the + /// dying epoch. That is also, by construction, where the recovery seal goes. + const RefTxnId greatest = greatestLoggedId(*backend, layout, ns); + ASSERT_EQ(greatest.writer_epoch, 1u); + const RefTxnId straggler_slot{greatest.writer_epoch, greatest.ref_sequence + 1}; + ASSERT_FALSE(backend->head(layout.refLogKey(DB::Cas::tests::fixture::fixtureLife(ns), straggler_slot)).exists) + << "the slot must be empty before recovery -- otherwise this test proves nothing about who won"; + + /// Fence and remount. No wait: this is the case that used to cost 30 seconds. + fake_boot += 30001; + fenceOutMount(*backend, layout.mountKey("test")); + ASSERT_TRUE(store->tryRemountOnce()); + ASSERT_EQ(store->liveWriterEpoch(), 2u); + EXPECT_TRUE(waits.empty()) + << "the remount blocked on an operator-configured wait; the grace is supposed to be gone"; + + /// Touch the namespace so it re-recovers under the new epoch: the walk closes epoch 1 in band. The + /// ref itself is still THERE -- the removal's PUT was the undecided one and (in this fixture) never + /// landed, which is precisely the state that leaves a straggler outstanding. + backend->fault_key_substr.clear(); + EXPECT_EQ(store->listRefs(ns).size(), 1u); + ASSERT_TRUE(backend->head(layout.refLogKey(DB::Cas::tests::fixture::fixtureLife(ns), straggler_slot)).exists) + << "recovery did not seal the dead epoch at the slot a straggler would take -- without that " + "seal there is nothing for the straggler's create to lose to"; + + /// THE STRAGGLER ARRIVES. Its conditional create is refused, whenever it happens to land. + const PutResult put = backend->putIfAbsent(layout.refLogKey(DB::Cas::tests::fixture::fixtureLife(ns), straggler_slot), "ghost-body"); + EXPECT_EQ(put.outcome, PutOutcome::PreconditionFailed) + << "the dying epoch's straggler overwrote (or joined) a slot the successor had already sealed"; +} + +/// THE FAIL-CLOSE THAT REPLACES THE DELETED SETTING. `materialization_grace_ms` is gone from the +/// settings table outright -- no parsed-but-inert period, no deprecation log -- so a config that still +/// asks for the wait is refused at disk open by the generic unknown-key path, loudly, instead of being +/// silently ignored by a server that no longer honours it. The feature never shipped, so there is no +/// deployed config this can break. +TEST(CASRetirementSweep, AConfigStillAskingForTheMaterializationGraceIsRejected) +{ + auto cfg = makeDiskConfig( + "srv130000"); + DB::ContentAddressedSettings s; + EXPECT_THROW( + s.loadFromConfig(*cfg, "disk", "/scratch", "/scratch", [](const std::string & v) { return v; }), + DB::Exception) + << "a retired setting must fail the disk open, not be quietly accepted and ignored"; +} diff --git a/src/Disks/tests/gtest_cas_s3_staging.cpp b/src/Disks/tests/gtest_cas_s3_staging.cpp new file mode 100644 index 000000000000..120bd8c5f5b4 --- /dev/null +++ b/src/Disks/tests/gtest_cas_s3_staging.cpp @@ -0,0 +1,773 @@ +#include +#include "cas_test_helpers.h" +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include + +#include +#include +#include +#include +#include + +/// Task 0 of the S3-native staging plan: pure config plumbing, ZERO behavior change. +/// `staging_backend` (default `local`) is parsed +/// from the CAS disk config; the parsed `StagingBackend` is exposed via +/// `ContentAddressedMetadataStorage::stagingBackend()`. `::conditionalCopySupported()` is a stored +/// bool, defaulting to `false` until a later task wires the mount-time capability probe. +/// +/// The global constraint (OFF BY DEFAULT) is the DEFAULT arm below: absent config keys must parse to +/// `StagingBackend::Local` with `conditionalCopySupported()==false`. + +namespace DB::ContentAddressedSetting +{ + extern const ContentAddressedSettingsString staging_backend; +} + +namespace +{ + +/// Build a `Poco::Util::XMLConfiguration` with `inner_xml` nested under a `` element (mirrors +/// the shape a real CAS disk config has under `storage_configuration.disks.`, so +/// `config_prefix = "disk"` reads exactly like the disk factory's `config_prefix`). +Poco::AutoPtr configWithDiskSection(const std::string & inner_xml) +{ + std::istringstream xml_stream( // STYLE_CHECK_ALLOW_STD_STRING_STREAM + "" + inner_xml + ""); + return new Poco::Util::XMLConfiguration(xml_stream); +} + +/// A test-only `LocalObjectStorage` subclass whose `copyObjectConditional` is configurable, so the +/// Task 3 selection logic (`DB::Cas::probeConditionalCopy`) can be exercised without a live S3/RustFS +/// backend (live enforcement is Task 7). `LocalObjectStorage` already implements every OTHER pure +/// virtual (`writeObject`, `removeObjectIfExists`, `exists`, `copyObject`, ...) against real files +/// under a fresh temp root, so overriding just `copyObjectConditional` is enough to fake either an +/// ENFORCING or a NON-ENFORCING backend; a THROWING (default `NOT_IMPLEMENTED`) backend needs no +/// fake at all — a plain `LocalObjectStorage` already exercises that path (see +/// `DefaultCopyObjectConditionalThrowsNotImplemented` above). +class FakeConditionalCopyObjectStorage : public DB::LocalObjectStorage +{ +public: + enum class Mode + { + /// Real write-once semantics: creates the destination iff it was absent; a destination that + /// already exists is REJECTED (created=false), no bytes touched. + Enforcing, + /// A backend that silently ignores `If-None-Match`: every call overwrites the destination + /// and reports created=true, even when the destination already existed. + NonEnforcing, + }; + + FakeConditionalCopyObjectStorage(DB::LocalObjectStorageSettings settings_, Mode mode_) + : DB::LocalObjectStorage(std::move(settings_)), mode(mode_) + { + } + + DB::ConditionalCopyResult copyObjectConditional( + const DB::StoredObject & object_from, + const DB::StoredObject & object_to, + const DB::ReadSettings & read_settings, + const DB::WriteSettings & write_settings, + std::optional object_to_attributes) override + { + ++call_count; + if (mode == Mode::Enforcing && exists(object_to)) + return {.created = false, .dest_etag = {}}; + + copyObject(object_from, object_to, read_settings, write_settings, object_to_attributes); + return {.created = true, .dest_etag = "fake-etag"}; + } + + int callCount() const { return call_count; } + +private: + Mode mode; + int call_count = 0; +}; + +/// Build a `FakeConditionalCopyObjectStorage` rooted at a fresh, unique temp directory (mirrors +/// `DB::Cas::tests::makeLocalObjectStorageForTest`). +std::shared_ptr makeFakeConditionalCopyStorage(FakeConditionalCopyObjectStorage::Mode mode) +{ + static std::atomic counter{0}; + const auto unique = std::to_string(::getpid()) + "_" + std::to_string(counter.fetch_add(1)); + const auto root = (std::filesystem::temp_directory_path() / ("cas_s3_staging_probe_" + unique)).string(); + + std::error_code ec; + std::filesystem::remove_all(root, ec); + std::filesystem::create_directories(root, ec); + + DB::LocalObjectStorageSettings settings("test", root, /*read_only_=*/false); + return std::make_shared(std::move(settings), mode); +} + +/// A fake object-store sink for Task 4 of the S3-native staging plan (`DB::Cas::CaContentWriteBuffer`'s +/// S3-staging constructor): an in-memory `WriteBufferFromFileBase` that records every byte written to +/// it, plus whether `cancelImpl`/`finalizeImpl` ran. This is enough to prove the S3-staging mode +/// streams to the SINK (not to a local temp file) while hashing, without needing a real object storage +/// — the end-to-end wiring (`writeFile` choosing this mode, the promote path) lands in later tasks. +class FakeStagingSink : public DB::WriteBufferFromFileBase +{ +public: + explicit FakeStagingSink(std::string key_) + : DB::WriteBufferFromFileBase(/*buf_size=*/8192, nullptr, 0), key(std::move(key_)) + { + } + + void sync() override {} + std::string getFileName() const override { return key; } + + const std::string & writtenBytes() const { return written; } + bool wasCancelled() const { return cancelled; } + bool wasFinalizedForTest() const { return did_finalize; } + +protected: + void nextImpl() override + { + if (!offset()) + return; + written.append(working_buffer.begin(), offset()); + } + + void finalizeImpl() override + { + next(); + did_finalize = true; + } + + void cancelImpl() noexcept override + { + cancelled = true; + } + +private: + std::string key; + std::string written; + bool cancelled = false; + bool did_finalize = false; +}; + +/// Task 5 of the S3-native staging plan: the promote path (`PartWriteTxn::putBlob` with +/// `BlobSource::server_side_copy_from` set) drives a WRITE-ONCE conditional server-side copy through +/// the SAME condemn/resurrect gate the streaming path uses. This backend is a `DB::Cas::InMemoryBackend` +/// (which models conditional create, so it honors both the write-once `promoteStaged` and the +/// unconditional `resurrect` contracts) that RECORDS every server-side-copy call so a test can +/// assert the copy source/destination and the conditional-vs-unconditional distinction — in particular +/// that a condemned-blob RESURRECT copies FROM the staging key, NEVER from the condemned blob key +/// (`feedback_ca_resurrect_invariant`), and that a live blob is NEVER unconditionally overwritten. +class RecordingStagingBackend : public DB::Cas::InMemoryBackend +{ +public: + struct CopyCall + { + std::string from; + std::string to; + bool conditional; /// true = promoteStaged (write-once); false = resurrect (unconditional) + }; + + std::vector copy_calls; + + DB::Cas::PutResult promoteStaged(const String & staging_key, const String & blob_key) override + { + copy_calls.push_back({staging_key, blob_key, /*conditional=*/true}); + return DB::Cas::InMemoryBackend::promoteStaged(staging_key, blob_key); + } + + DB::Cas::Token resurrect(DB::ReadBuffer & payload, uint64_t payload_size, const String & blob_key, + const String & fresh_header) override + { + /// The source is no longer an argument -- the caller opens the reader -- so `from` is recorded + /// as empty here and the "never the condemned key" invariant is asserted through `reads_of` + /// below, which counts what was actually READ. That is the stronger check: it observes the I/O + /// rather than a parameter the backend was told about. + copy_calls.push_back({String{}, blob_key, /*conditional=*/false}); + return DB::Cas::InMemoryBackend::resurrect(payload, payload_size, blob_key, fresh_header); + } + + /// Every key READ AS A STREAM, with a count. The resurrect opens its source with `getStream`, so + /// this counts exactly the reads that path performs -- and deliberately not the materializing + /// `get`, which the assertions themselves use to inspect bodies. + std::map reads_of; + + using DB::Cas::InMemoryBackend::getStream; + std::optional getStream(const String & key, DB::Cas::Range range) override + { + ++reads_of[key]; + return DB::Cas::InMemoryBackend::getStream(key, range); + } + + + /// Every unconditional (resurrect) copy this backend saw — empty iff no live/condemned body was ever + /// overwritten. `assertNeverOverwritesLiveBlob` reads this to enforce invariant (d). + size_t unconditionalCopyCount() const + { + size_t n = 0; + for (const CopyCall & c : copy_calls) + n += c.conditional ? 0 : 1; + return n; + } +}; + +DB::Cas::PoolPtr openStagingPool(const std::shared_ptr & b) +{ + return DB::Cas::Pool::open(b, DB::Cas::PoolConfig{.pool_prefix = "p", .server_root_id = "test"}); +} + +/// A build whose owning manifest namespace / final ref name are `ns`/`ref` (mirrors gtest_cas_build's +/// `startBuildFor`: promote/stageManifest derive the namespace by splitting `intended_ref` on the LAST '/'). +DB::Cas::PartWriteTxnPtr startStagingBuild(const DB::Cas::PoolPtr & s, const DB::Cas::RootNamespace & ns, const String & ref) +{ + DB::Cas::PartWriteInfo info; + info.intended_ref = ns.string() + "/" + ref; + return s->beginPartWrite(info); +} + +/// Stage a one-blob manifest and precommit it (so the EDGE-BEFORE-OBSERVE fail-closed check in +/// `observeAndAdmit` holds), returning the build ready for a `putBlob` promote of `hash`. +DB::Cas::PartWriteTxnPtr precommittedBuildFor( + const DB::Cas::PoolPtr & s, const DB::Cas::RootNamespace & ns, const String & ref, + const DB::UInt128 & hash, uint64_t blob_size) +{ + DB::Cas::PartWriteTxnPtr build = startStagingBuild(s, ns, ref); + const DB::Cas::ManifestId id = build->stageManifest({DB::Cas::tests::blobEntryFor("col.bin", hash, blob_size)}); + build->precommitAdd(ns, ref, id); + return build; +} + +/// A `BlobSource` that promotes via the S3 server-side-copy path (no local `open`). +DB::Cas::BlobSource serverSideCopySource(const std::string & staging_key, uint64_t size) +{ + DB::Cas::BlobSource source; + source.size = size; + source.server_side_copy_from = staging_key; + return source; +} + +} + +TEST(CASS3Staging, ParsesS3BackendFromConfig) +{ + auto config = configWithDiskSection("s3"); + + EXPECT_EQ(DB::ContentAddressedMetadataStorage::parseStagingBackend(*config, "disk"), DB::Cas::StagingBackend::S3); +} + +TEST(CASS3Staging, DefaultConfigParsesToLocalBackend) +{ + /// No `staging_backend` key at all — the OFF BY DEFAULT arm. + auto config = configWithDiskSection("/tmp/whatever"); + + EXPECT_EQ(DB::ContentAddressedMetadataStorage::parseStagingBackend(*config, "disk"), DB::Cas::StagingBackend::Local); +} + +TEST(CASS3Staging, UnknownBackendValueThrows) +{ + auto config = configWithDiskSection("nfs"); + EXPECT_THROW(DB::ContentAddressedMetadataStorage::parseStagingBackend(*config, "disk"), DB::Exception); +} + +TEST(CASS3Staging, DefaultConstructedStorageReportsLocalAndNoConditionalCopy) +{ + /// Constructed with no staging-related args at all (mirrors the existing gtest call sites, e.g. + /// gtest_ca_wiring.cpp, which stop at `context_`): the accessors must reflect the same + /// byte-for-byte-current-behavior defaults the config parser produces above. + auto settings = DB::Cas::tests::makeSettingsForTest( + "test", std::filesystem::temp_directory_path() / "cas_s3_staging_default_scratch"); + auto storage = std::make_shared( + DB::Cas::tests::makeLocalObjectStorageForTest(), "pool", "srv1", "", nullptr, settings); + + EXPECT_EQ(storage->stagingBackend(), DB::Cas::StagingBackend::Local); + EXPECT_FALSE(storage->conditionalCopySupported()); +} + +/// Task 2 of the S3-native staging plan: `IObjectStorage::copyObjectConditional` (write-once +/// conditional server-side copy) — the interface-level contract. Backends without an enforced, +/// native conditional copy MUST NOT override the default: it fail-closes with `NOT_IMPLEMENTED`, +/// exactly like the existing `IObjectStorage::removeObjectIfTokenMatches` default (never silently +/// falls back to an unconditional overwrite). `LocalObjectStorage` (used by +/// `makeLocalObjectStorageForTest`) does not override `copyObjectConditional`, so it exercises the +/// base-class default directly. Live 412-vs-created S3 semantics are covered by the Task 7 +/// integration test (with_rustfs); this is deliberately just the fail-closed contract test. +TEST(CASS3Staging, DefaultCopyObjectConditionalThrowsNotImplemented) +{ + auto storage = DB::Cas::tests::makeLocalObjectStorageForTest(); + + const DB::StoredObject from{"cas_s3_staging_conditional_copy_from"}; + const DB::StoredObject to{"cas_s3_staging_conditional_copy_to"}; + + DB::Cas::tests::expectThrowsCode(DB::ErrorCodes::NOT_IMPLEMENTED, [&] + { + storage->copyObjectConditional(from, to, DB::ReadSettings{}, DB::WriteSettings{}); + }); +} + +/// Task 3 of the S3-native staging plan: the mount-time capability probe (`DB::Cas::probeConditionalCopy`) +/// for the OPTIONAL conditional-copy capability (distinct from the mandatory `runCapabilityProbe` +/// battery). These three tests cover the fail-close SELECTION logic with fakes — live 412-vs-created +/// enforcement against a real backend is Task 7 (with_rustfs integration test). + +TEST(CASS3Staging, ProbeConditionalCopyReturnsTrueForEnforcingBackend) +{ + auto storage = makeFakeConditionalCopyStorage(FakeConditionalCopyObjectStorage::Mode::Enforcing); + + EXPECT_TRUE(DB::Cas::probeConditionalCopy(*storage, "probe_prefix")); + /// Both the "fresh destination" and the "already-existing destination" conditional copies ran. + EXPECT_EQ(storage->callCount(), 2); +} + +TEST(CASS3Staging, ProbeConditionalCopyReturnsFalseForNonEnforcingBackend) +{ + auto storage = makeFakeConditionalCopyStorage(FakeConditionalCopyObjectStorage::Mode::NonEnforcing); + + /// The backend silently overwrites the destination on the second call (created=true again) — + /// it does not enforce If-None-Match, so the probe must fail closed. + EXPECT_FALSE(DB::Cas::probeConditionalCopy(*storage, "probe_prefix")); +} + +TEST(CASS3Staging, ProbeConditionalCopyReturnsFalseWhenCopyObjectConditionalThrows) +{ + /// A plain `LocalObjectStorage` does not override `copyObjectConditional` at all — it falls + /// through to the base-class default, which throws NOT_IMPLEMENTED (exactly what a real backend + /// without conditional-copy support does). The probe must never propagate this: it fails closed. + auto storage = DB::Cas::tests::makeLocalObjectStorageForTest(); + + EXPECT_FALSE(DB::Cas::probeConditionalCopy(*storage, "probe_prefix")); +} + +/// Task 4 of the S3-native staging plan: `CaContentWriteBuffer`'s S3-staging constructor streams +/// directly to an already-opened object-store sink while hashing, instead of spilling to a local temp +/// file (see the constructor's doc comment in ContentAddressedWriteBuffers.h). These two tests +/// exercise the buffer directly over a `FakeStagingSink` — no real object storage, disk, or +/// `ContentAddressedTransaction` needed; `writeFile` choosing this mode is exercised together with the +/// promote path in later tasks (S3 mode is off by default and not enabled by any existing test). + +TEST(CASS3Staging, ContentWriteBufferS3ModeStreamsToSinkAndFinalizes) +{ + const std::string staging_key = "staging/mount1/abc123.tmp"; + auto * sink_ptr = new FakeStagingSink(staging_key); + std::unique_ptr sink(sink_ptr); + + std::string got_hash_hex; + size_t got_size = 0; + std::string got_key; + int on_finalized_calls = 0; + + /// S3-native staging fix 2026-07-11: the S3 constructor takes a fixed-length envelope header that is + /// written to the sink FIRST, UNHASHED and excluded from the reported size. A distinctive 256-byte + /// filler stands in for the real CABL header here (this test exercises the buffer mechanics, not the + /// envelope encoder). + const std::string envelope_header(256, 'H'); + + auto buf = std::make_unique( + std::move(sink), + staging_key, + envelope_header, + DB::Cas::BlobHashAlgo::CityHash128, + /*buf_size=*/8192, + /*use_adaptive_buffer_size=*/false, + /*adaptive_buffer_initial_size=*/0, + [&](const std::string & hash_hex, size_t size, const std::string & key) + { + ++on_finalized_calls; + got_hash_hex = hash_hex; + got_size = size; + got_key = key; + }); + + /// Write in two chunks (exercises more than one nextImpl flush) and finalize. + const std::string payload_part1(4000, 'x'); + const std::string payload_part2(1234, 'y'); + buf->write(payload_part1.data(), payload_part1.size()); + buf->write(payload_part2.data(), payload_part2.size()); + buf->finalize(); + + const std::string payload = payload_part1 + payload_part2; + + /// (a) the sink received the ENVELOPE HEADER FIRST, then EXACTLY the payload bytes — the staging + /// object holds `[header][payload]` so the promote can stay a verbatim server-side copy. + EXPECT_EQ(sink_ptr->writtenBytes(), envelope_header + payload); + EXPECT_TRUE(sink_ptr->wasFinalizedForTest()); + EXPECT_FALSE(sink_ptr->wasCancelled()); + + /// (b) on_finalized fired exactly once with the correct cityHash128 hex, size, and staging key. + /// The pool-wide content hash is the STREAMING `HashingWriteBuffer` convention (chunked + /// cityHash128, block = 2048 B), which diverges from a one-shot `CityHash_v1_0_2::CityHash128` + /// call for a payload spanning more than one block (see `gtest_cas_part_write.cpp`'s + /// `CopyForwardMultiBlockPayloadVerifies`, which documents and exercises the same divergence). + /// This payload (5234 bytes) spans multiple 2048-byte blocks, so the expected hash must be + /// recomputed with the SAME streaming convention via `HashingReadBuffer`, not a one-shot call. + DB::ReadBufferFromMemory expected_in(payload.data(), payload.size()); + DB::HashingReadBuffer expected_hashing(expected_in); + expected_hashing.ignoreAll(); + const std::string expected_hash_hex = getHexUIntLowercase(expected_hashing.getHash()); + EXPECT_EQ(on_finalized_calls, 1); + EXPECT_EQ(got_hash_hex, expected_hash_hex); + EXPECT_EQ(got_size, payload.size()); + EXPECT_EQ(got_key, staging_key); + EXPECT_EQ(buf->getFileName(), staging_key); +} + +TEST(CASS3Staging, ContentWriteBufferS3ModeCancelCancelsSinkAndSkipsFinalize) +{ + const std::string staging_key = "staging/mount1/cancelled.tmp"; + auto * sink_ptr = new FakeStagingSink(staging_key); + std::unique_ptr sink(sink_ptr); + + bool on_finalized_called = false; + + auto buf = std::make_unique( + std::move(sink), + staging_key, + /*envelope_header=*/std::string(256, 'H'), + DB::Cas::BlobHashAlgo::CityHash128, + /*buf_size=*/8192, + /*use_adaptive_buffer_size=*/false, + /*adaptive_buffer_initial_size=*/0, + [&](const std::string &, size_t, const std::string &) + { + on_finalized_called = true; + }); + + const std::string payload = "some bytes that must never be promoted"; + buf->write(payload.data(), payload.size()); + buf->cancel(); + + /// (c) cancel() before finalize cancels the sink and on_finalized is NEVER called — no partial + /// finalize (no promote-worthy hash/size is ever handed to the transaction for cancelled bytes). + EXPECT_TRUE(sink_ptr->wasCancelled()); + EXPECT_FALSE(sink_ptr->wasFinalizedForTest()); + EXPECT_FALSE(on_finalized_called); + + /// The buffer's destructor calls cancel() again (defensive backstop) — already-cancelled, so this + /// must stay a no-op: still no on_finalized call, and no attempt to fs::remove a remote key. + buf.reset(); + EXPECT_FALSE(on_finalized_called); +} + +/// Task 5 of the S3-native staging plan: the promote path. `PartWriteTxn::putBlob` with +/// `BlobSource::server_side_copy_from` set drives a WRITE-ONCE conditional SERVER-SIDE COPY through the +/// SAME condemn/resurrect gate as the streaming path (spec 2026-07-11-cas-s3-native-staging §5/§9). The +/// four cases below use `RecordingStagingBackend` (an emulated backend that models conditional create +/// and records every server-side-copy call). Live 412-vs-created enforcement against a real backend is +/// Task 7 (with_rustfs integration test). + +/// (a) Fresh blob key ⇒ the write-once conditional copy CREATES it; the tokened Blob dep is recorded at +/// the copy's destination token (the new incarnation token). No unconditional copy is ever issued. +TEST(CASS3Staging, PromoteViaServerSideCopyCreatesFreshBlobTokenedDep) +{ + auto backend = std::make_shared(); + auto store = openStagingPool(backend); + const DB::Cas::RootNamespace ns{"srv1/nsA"}; + const std::string ref = "part_a"; + + const DB::UInt128 hash = DB::Cas::tests::u128Of("payload-A"); + const DB::Cas::BlobRef blob_id{DB::Cas::BlobHashAlgo::CityHash128, DB::Cas::BlobDigest::fromU128(hash)}; + const std::string blob_key = store->layout().blobKey(blob_id); + const std::string staging_key = "p/staging/mount1/aaa.tmp"; + const std::string payload(300, 'a'); + backend->putIfAbsent(staging_key, payload); + + auto build = precommittedBuildFor(store, ns, ref, hash, payload.size()); + const DB::Cas::PutBlobResult bref = build->putBlob(blob_id, serverSideCopySource(staging_key, payload.size())); + + /// EXACTLY one CONDITIONAL server-side copy staging->blobKey; zero unconditional copies. + ASSERT_EQ(backend->copy_calls.size(), 1u); + EXPECT_TRUE(backend->copy_calls[0].conditional); + EXPECT_EQ(backend->copy_calls[0].from, staging_key); + EXPECT_EQ(backend->copy_calls[0].to, blob_key); + EXPECT_EQ(backend->unconditionalCopyCount(), 0u); + + /// A TOKENED Blob dep was recorded (created path); the created blob carries the copy's dest token. + EXPECT_TRUE(build->depIsTokened(DB::Cas::BlobRef{DB::Cas::BlobHashAlgo::CityHash128, DB::Cas::BlobDigest::fromU128(hash)})); + const DB::Cas::HeadResult hr = backend->head(blob_key); + ASSERT_TRUE(hr.exists); + EXPECT_FALSE(hr.token.empty()); + EXPECT_EQ(bref.size, payload.size()); + + /// The promoted blob body IS the staging bytes (server-side copy moved them verbatim). + const auto got = backend->get(blob_key); + ASSERT_TRUE(got.has_value()); + EXPECT_EQ(got->bytes, payload); +} + +/// (b) Blob key already exists and is CLEAN ⇒ the conditional copy 412s and the writer ADOPTS the +/// existing incarnation. No copy of any kind lands over the live blob. Also covers invariant (d): NO +/// unconditional copy is ever issued over a live (non-condemned) blob. +TEST(CASS3Staging, PromoteOverExistingCleanBlobAdoptsAndNeverOverwrites) +{ + auto backend = std::make_shared(); + auto store = openStagingPool(backend); + const DB::Cas::RootNamespace ns{"srv1/nsB"}; + const std::string ref = "part_b"; + + const DB::UInt128 hash = DB::Cas::tests::u128Of("payload-B"); + const DB::Cas::BlobRef blob_id{DB::Cas::BlobHashAlgo::CityHash128, DB::Cas::BlobDigest::fromU128(hash)}; + const std::string blob_key = store->layout().blobKey(blob_id); + const std::string staging_key = "p/staging/mount1/bbb.tmp"; + backend->putIfAbsent(staging_key, std::string(300, 'b')); + + /// A pre-existing, well-formed, CLEAN blob (envelope + payload) already at the content key. + DB::Cas::tests::writeBlobBody(*backend, store->layout(), hash); + DB::Cas::tests::writeMetaClean(*backend, store->layout(), hash, /*size=*/1); + const DB::Cas::HeadResult before = backend->head(blob_key); + ASSERT_TRUE(before.exists); + + auto build = precommittedBuildFor(store, ns, ref, hash, 300); + build->putBlob(blob_id, serverSideCopySource(staging_key, 300)); + + /// Exactly one CONDITIONAL promote (which 412s) — then ADOPT. Invariant (d): zero unconditional copies. + ASSERT_EQ(backend->copy_calls.size(), 1u); + EXPECT_TRUE(backend->copy_calls[0].conditional); + EXPECT_EQ(backend->unconditionalCopyCount(), 0u); + + /// The existing incarnation is untouched: same token, same bytes. + const DB::Cas::HeadResult after = backend->head(blob_key); + EXPECT_EQ(after.token, before.token); + + /// The adopt recorded a TOKENED dep at the observed (existing) incarnation's token. + EXPECT_TRUE(build->depIsTokened(DB::Cas::BlobRef{DB::Cas::BlobHashAlgo::CityHash128, DB::Cas::BlobDigest::fromU128(hash)})); +} + +/// (c) Blob key exists but is CONDEMNED ⇒ the writer RESURRECTS by re-uploading its OWN staging PAYLOAD +/// under a FRESH-tagged envelope header — NEVER a read/copy of the condemned blob key +/// (`feedback_ca_resurrect_invariant`) — and the resurrected body DIFFERS from the condemned incarnation +/// (INV-NO-RETURN: a verbatim copy would reproduce identical bytes ⇒ identical ETag ⇒ the queued +/// exact-token delete of the condemned incarnation would kill the live resurrection = data loss). +TEST(CASS3Staging, PromoteOverCondemnedBlobResurrectsWithFreshTagNotVerbatim) +{ + auto backend = std::make_shared(); + auto store = openStagingPool(backend); + const DB::Cas::RootNamespace ns{"srv1/nsC"}; + const std::string ref = "part_c"; + + const DB::UInt128 hash = DB::Cas::tests::u128Of("payload-C"); + const DB::Cas::BlobRef blob_id{DB::Cas::BlobHashAlgo::CityHash128, DB::Cas::BlobDigest::fromU128(hash)}; + const std::string blob_key = store->layout().blobKey(blob_id); + const std::string staging_key = "p/staging/mount1/ccc.tmp"; + const std::string payload(300, 'c'); + + /// The staging object holds `[header][payload]` (as `writeFile` now emits it). The staging header is + /// a fixed 256-byte CABL envelope with its OWN incarnation_tag. + DB::Cas::EnvelopeHeader staging_h; + staging_h.kind = DB::Cas::ObjectKind::Blob; + staging_h.incarnation_tag = DB::UInt128(0xC0FFEE); /// the create-time tag + const std::string staging_header = DB::Cas::encodeEnvelopeHeader( + staging_h, static_cast(store->poolMeta().blob_header_len)); + ASSERT_EQ(staging_header.size(), store->poolMeta().blob_header_len); + const std::string staging_bytes = staging_header + payload; + backend->putIfAbsent(staging_key, staging_bytes); + + /// Seed the condemned blob body = EXACTLY what a verbatim promote of this staging object would have + /// produced (the writer's OWN create, later observed condemned). This is the adversarial shape: a + /// verbatim resurrect WOULD reproduce these identical bytes ⇒ identical ETag ⇒ collision. + backend->putIfAbsent(blob_key, staging_bytes); + DB::Cas::tests::writeMetaClean(*backend, store->layout(), hash, /*size=*/payload.size()); + DB::Cas::tests::condemnMeta(*backend, store->layout(), hash, /*condemn_round=*/5); + const DB::Cas::HeadResult before = backend->head(blob_key); + ASSERT_TRUE(before.exists); + + auto build = precommittedBuildFor(store, ns, ref, hash, payload.size()); + build->putBlob(blob_id, serverSideCopySource(staging_key, payload.size())); + + /// A CONDITIONAL promote (412 — blob present) FOLLOWED BY exactly one UNCONDITIONAL resurrect + /// whose SOURCE is the staging key, NEVER the condemned blob key. + ASSERT_EQ(backend->copy_calls.size(), 2u); + EXPECT_TRUE(backend->copy_calls[0].conditional); + EXPECT_EQ(backend->copy_calls[0].to, blob_key); + EXPECT_FALSE(backend->copy_calls[1].conditional); + EXPECT_EQ(backend->copy_calls[1].to, blob_key); + /// INV: the resurrect reads the STAGING object and never the condemned blob key. Asserted on the + /// reads themselves rather than on a source argument, because the caller now opens the reader. + EXPECT_GT(backend->reads_of[staging_key], 0u) << "the resurrect must read the writer's own staging object"; + EXPECT_EQ(backend->reads_of[blob_key], 0u) << "the condemned blob key must never be read"; + EXPECT_EQ(backend->unconditionalCopyCount(), 1u); + + /// The incarnation token is REFRESHED (a fresh incarnation displaced the condemned one). + const DB::Cas::HeadResult after = backend->head(blob_key); + EXPECT_NE(after.token, before.token); + ASSERT_TRUE(after.exists); + + const auto got = backend->get(blob_key); + ASSERT_TRUE(got.has_value()); + const uint64_t header_len = store->poolMeta().blob_header_len; + + /// INV-NO-RETURN — THE fresh-tag property: the resurrected body is NOT byte-identical to the + /// condemned incarnation (a verbatim copy would have been). The PAYLOAD is preserved exactly (the + /// resurrect read it from OUR staging object, skipping the staging header), but the envelope HEADER + /// differs — the writer minted a FRESH incarnation_tag — so on a real content-addressed store the + /// resurrected ETag differs and the queued exact-token delete of the condemned incarnation cannot + /// match the live resurrection. + EXPECT_NE(got->bytes, staging_bytes); + ASSERT_GE(got->bytes.size(), header_len); + EXPECT_EQ(got->bytes.substr(header_len), payload); /// payload preserved + EXPECT_NE(got->bytes.substr(0, header_len), staging_header); /// header freshly re-tagged + + /// The resurrect recorded a tokened dep and flipped the meta back to Clean. + EXPECT_TRUE(build->depIsTokened(DB::Cas::BlobRef{DB::Cas::BlobHashAlgo::CityHash128, DB::Cas::BlobDigest::fromU128(hash)})); + const auto lm = DB::Cas::tests::loadMetaForTest(*backend, store->layout(), hash); + ASSERT_TRUE(lm.has_value()); + EXPECT_EQ(lm->meta.state, DB::Cas::MetaState::Clean); +} + +/// =========================================================================================== +/// Task 6 of the S3-native staging plan: staging cleanup after commit, read-your-writes over an S3 +/// pending blob, and the mount-lease-scoped sweeper (`CASStagingSweeper.h`). +/// +/// The wiring-level tests below (cleanup-after-commit, read-your-writes) drive the REAL +/// `ContentAddressedMetadataStorage` + `ContentAddressedTransaction` over `FakeConditionalCopyObjectStorage` +/// in `Enforcing` mode. The fake's REAL `getType()` stays `Local`, so `Cas::ObjectStorageBackend` selects +/// `EmulatedSingleProcess` for the CAS core protocol — the same fully-supported combination every other +/// `gtest_ca_wiring.cpp` test uses — while the mount-time conditional-copy PROBE +/// (`Cas::probeConditionalCopy`) is decoupled from that (it exercises `copyObjectConditional` directly +/// against the object storage), so it reports S3 staging as usable independent of the backend mode. This +/// lets `writeFile` take the S3-staging code path (stream to a staging object while hashing) WITHOUT +/// needing a live/native conditional-copy backend for the promote: `PartWriteTxn::putBlob`'s +/// `promoteStaged`/`resurrect` seams (Native-mode only — see `CasObjectStorageBackend.cpp`) are +/// already covered directly against `Cas::PartWriteTxn`/`RecordingStagingBackend` above (Task 5) and against a +/// live backend in Task 7's `with_rustfs` integration test; these two tests only ever exercise an S3 +/// pending blob that is either NEVER referenced (the B189 orphan shape — publishStaging skips its +/// `putBlob`) or read BEFORE commit, so `promoteStaged` is never reached here. + +namespace +{ + +/// Construct a `ContentAddressedMetadataStorage` with `staging_backend=s3` over `object_storage`, +/// mirroring `DefaultConstructedStorageReportsLocalAndNoConditionalCopy`'s settings defaults for every +/// field this test suite does not care about — only `server_root_id` (the mount identity that names +/// the staging prefix) and `staging_backend` differ. +std::shared_ptr makeS3StagingMetadataStorageForTest( + const DB::ObjectStoragePtr & object_storage, const std::string & server_root_id) +{ + static std::atomic counter{0}; + const auto unique = std::to_string(::getpid()) + "_" + std::to_string(counter.fetch_add(1)); + const auto scratch = std::filesystem::temp_directory_path() + / ("cas_s3_staging_wiring_" + server_root_id + "_" + unique); + auto settings = DB::Cas::tests::makeSettingsForTest(server_root_id, scratch); + settings[DB::ContentAddressedSetting::staging_backend] = "s3"; + settings.validate(); + return std::make_shared( + object_storage, "pool", "srv1", /*disk_name_=*/"", /*context_=*/nullptr, settings); +} + +/// Mirrors gtest_ca_wiring.cpp's helper of the same shape. +void writeThroughS3Transaction(DB::ContentAddressedTransaction & tx, const std::string & path, const std::string & bytes) +{ + auto buf = tx.writeFile(path, 65536, DB::WriteMode::Rewrite, {}); + buf->write(bytes.data(), bytes.size()); + buf->finalize(); +} + +} + +/// (a) A successful commit removes the S3 staging object of a pending blob it staged. Uses the B189 +/// orphan shape (the pending blob's entry is unlinked before commit) so `publishStaging` never calls +/// `putBlob` for it — only `cleanupPendingTempFiles`'s Task 6 branch ever touches this staging object, +/// which is exactly the seam this test targets. +TEST(CASS3Staging, SuccessfulCommitRemovesOrphanedS3StagingObject) +{ + auto object_storage = makeFakeConditionalCopyStorage(FakeConditionalCopyObjectStorage::Mode::Enforcing); + auto metadata_storage = makeS3StagingMetadataStorageForTest(object_storage, "mountA"); + metadata_storage->startup(); + ASSERT_TRUE(metadata_storage->conditionalCopySupported()); + + auto tx = metadata_storage->createTransaction(); + auto & ca_tx = dynamic_cast(*tx); + + /// orphan.bin forces the S3-staging blob path (a ".bin" suffix always stays a blob, per + /// `partFileMustStayBlob`); it is unlinked below before commit. + writeThroughS3Transaction(ca_tx, "a11/a11a11a1-1111-4111-8111-111111111111/all_1_1_0/orphan.bin", std::string(300, 'x')); + /// checksums.txt is small and NOT blob-forcing: an INLINE entry that gives the part's PartWriteTxn a real + /// (non-orphaned) manifest entry, so `publishStaging` takes its normal path (not the early-return + /// mutable-only/no-PartWriteTxn branch). + writeThroughS3Transaction(ca_tx, "a11/a11a11a1-1111-4111-8111-111111111111/all_1_1_0/checksums.txt", "sums"); + + DB::RelativePathsWithMetadata staged_before; + object_storage->listObjects(metadata_storage->stagingKeyPrefix(), staged_before, /*max_keys=*/0); + ASSERT_EQ(staged_before.size(), 1u) << "exactly orphan.bin's S3 staging object should exist pre-commit"; + + tx->unlinkFile("a11/a11a11a1-1111-4111-8111-111111111111/all_1_1_0/orphan.bin", false, false); + + tx->commit(DB::NoCommitOptions{}); + + DB::RelativePathsWithMetadata staged_after; + object_storage->listObjects(metadata_storage->stagingKeyPrefix(), staged_after, /*max_keys=*/0); + EXPECT_TRUE(staged_after.empty()) + << "cleanupPendingTempFiles must remove the orphaned S3 staging object after a successful commit"; +} + +/// (b) Read-your-writes over an S3 pending blob (before commit) returns the staged bytes from the S3 +/// staging object, not a local temp file. +TEST(CASS3Staging, ReadYourWritesReturnsStagedBytesFromS3StagingObject) +{ + auto object_storage = makeFakeConditionalCopyStorage(FakeConditionalCopyObjectStorage::Mode::Enforcing); + auto metadata_storage = makeS3StagingMetadataStorageForTest(object_storage, "mountB"); + metadata_storage->startup(); + ASSERT_TRUE(metadata_storage->conditionalCopySupported()); + + auto tx = metadata_storage->createTransaction(); + auto & ca_tx = dynamic_cast(*tx); + + const std::string path = "a11/a11a11a1-1111-4111-8111-111111111111/all_1_1_0/data.bin"; + const std::string payload(5000, 'z'); + writeThroughS3Transaction(ca_tx, path, payload); + + auto read_buf = tx->tryReadFileInFlight(path, DB::ReadSettings{}, {}); + ASSERT_NE(read_buf, nullptr); + std::string got; + DB::readStringUntilEOF(got, *read_buf); + EXPECT_EQ(got, payload); +} + +/// (c) `sweepOwnMountStaging` removes only objects under the given mount prefix and leaves a DIFFERENT +/// mount's staging objects untouched (the lease-fence — `CASStagingSweeper.h`). +TEST(CASStagingSweeper, RemovesOnlyObjectsUnderGivenMountPrefix) +{ + auto storage = DB::Cas::tests::makeLocalObjectStorageForTest(); + const std::string root = storage->getCommonKeyPrefix(); + + auto put = [&](const std::string & key, const std::string & bytes) + { + auto buf = storage->writeObject(DB::StoredObject(key), DB::WriteMode::Rewrite); + buf->write(bytes.data(), bytes.size()); + buf->finalize(); + }; + + put(root + "/p/staging/mountA/one.tmp", "a1"); + put(root + "/p/staging/mountA/two.tmp", "a2"); + put(root + "/p/staging/mountB/three.tmp", "b1"); /// a DIFFERENT mount's staging — must survive + + DB::Cas::sweepOwnMountStaging(*storage, root + "/p/staging/mountA/"); + + EXPECT_FALSE(storage->exists(DB::StoredObject(root + "/p/staging/mountA/one.tmp"))); + EXPECT_FALSE(storage->exists(DB::StoredObject(root + "/p/staging/mountA/two.tmp"))); + EXPECT_TRUE(storage->exists(DB::StoredObject(root + "/p/staging/mountB/three.tmp"))); +} + +/// (d) GC's blob-discovery LISTs ONLY `Layout::blobsPrefix()` (`/blobs/`) — a top-level prefix +/// strictly disjoint from the S3-staging area (`/staging//`), so a staging object can +/// never be listed, HEAD'd, or condemned as an orphan blob by GC's fold (`CasGc.cpp`, `CasFsck.cpp`). +/// This is a prefix-separation assertion (the GC fold itself is not unit-testable in isolation from a +/// full round — see `gtest_cas_gc_fold.cpp` for that machinery); it pins the invariant a refactor that +/// nested `staging/` under `blobs/` (or vice versa) would violate. +TEST(CASS3Staging, GcBlobDiscoveryPrefixExcludesStagingObjects) +{ + const DB::Cas::Layout layout("p"); + const std::string blobs_prefix = layout.blobsPrefix(); + const std::string staging_prefix = "p/staging/mountA/"; + const std::string staging_key = staging_prefix + "aaa.tmp"; + + EXPECT_EQ(blobs_prefix, "p/blobs/"); + EXPECT_FALSE(staging_prefix.starts_with(blobs_prefix)); + EXPECT_FALSE(blobs_prefix.starts_with(staging_prefix)); + EXPECT_FALSE(staging_key.starts_with(blobs_prefix)); +} diff --git a/src/Disks/tests/gtest_cas_sentinel_probe.cpp b/src/Disks/tests/gtest_cas_sentinel_probe.cpp new file mode 100644 index 000000000000..e4a50db58958 --- /dev/null +++ b/src/Disks/tests/gtest_cas_sentinel_probe.cpp @@ -0,0 +1,264 @@ +#include + +#include "config.h" + +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include + +#if USE_AWS_S3 +#include +#endif + +using namespace DB::Cas; + +/// Task 3 (spec §2): the typed sentinel probe below `Backend` must never conflate a transport error +/// with absence. These tests exercise the free-function entry point `probeSentinel` against the generic +/// `Backend::probeSentinelRaw` default (via `InMemoryBackend`, the "Emulated"-style in-memory backend used +/// by CAS tests) and against `ObjectStorageBackend`'s `EmulatedSingleProcess` override, which is the REAL +/// production mode for a content-addressed disk over `object_storage_type=local`. + +namespace +{ + +using DB::Cas::tests::nativeKeyUnder; + +/// A Backend decorator whose head/get/list all throw an untyped runtime error when armed — modelling +/// a backend with no sharper evidence than "something went wrong" (a network timeout, a 5xx, an +/// unclassifiable failure). Mirrors the existing MetaWriteFaultBackend fault-injection pattern +/// (cas_test_helpers.h): every other operation delegates to InMemoryBackend unchanged. +class TransportFaultBackend final : public InMemoryBackend +{ +public: + /// Unhide the base convenience overloads, matching every other Backend subclass in this suite. + using Backend::get; + using Backend::getStream; + using Backend::putIfAbsent; + using Backend::putIfAbsentStream; + using Backend::putOverwrite; + using Backend::casPut; + + HeadResult head(const String & key) override + { + if (fail.load()) + throw std::runtime_error("injected fault: transport error"); + return InMemoryBackend::head(key); + } + + std::optional get(const String & key, Range range) override + { + if (fail.load()) + throw std::runtime_error("injected fault: transport error"); + return InMemoryBackend::get(key, range); + } + + ListPage list(const String & prefix, const String & cursor, size_t limit) override + { + if (fail.load()) + throw std::runtime_error("injected fault: transport error"); + return InMemoryBackend::list(prefix, cursor, limit); + } + + std::atomic fail{true}; +}; + +} + +/// (a) A present key probes Present and carries the materialized body. +TEST(CASSentinelProbe, PresentKeyReturnsPresentWithBody) +{ + InMemoryBackend backend; + ASSERT_EQ(backend.putIfAbsent("k", "hello").outcome, PutOutcome::Done); + + const auto result = probeSentinel(backend, "k"); + EXPECT_EQ(result.outcome, ProbeOutcome::Present); + ASSERT_TRUE(result.body.has_value()); + EXPECT_EQ(*result.body, "hello"); +} + +/// (b) A deleted (never-written) key probes KeyAbsent while the container/backend is otherwise alive. +TEST(CASSentinelProbe, AbsentKeyWithContainerAliveReturnsKeyAbsent) +{ + InMemoryBackend backend; + ASSERT_EQ(backend.putIfAbsent("other", "x").outcome, PutOutcome::Done); // proves the backend is alive + + const auto result = probeSentinel(backend, "missing"); + EXPECT_EQ(result.outcome, ProbeOutcome::KeyAbsent); + EXPECT_FALSE(result.body.has_value()); +} + +/// (c) `ObjectStorageBackend::EmulatedSingleProcess` is the REAL production backend for a +/// content-addressed disk over `object_storage_type=local` (ContentAddressedMetadataStorage.cpp +/// selects it whenever the underlying storage is Local). Removing the WHOLE configured container +/// directory (the disk root) must probe `ContainerAbsent`, distinct from an ordinary absent key — +/// `LocalObjectStorage::listObjects` silently reports zero children for BOTH a missing directory and +/// an empty one, so the distinction only exists because `probeSentinelRaw` stats the container first. +TEST(CASSentinelProbe, ContainerDirectoryRemovedReturnsContainerAbsent) +{ + auto storage = tests::makeLocalObjectStorageForTest(); + ObjectStorageBackend backend(storage, ObjectStorageBackend::Mode::EmulatedSingleProcess); + + ASSERT_EQ(backend.putIfAbsent("k", "hello").outcome, PutOutcome::Done); + + /// Sanity, container alive: Present vs. KeyAbsent are genuinely distinct before we remove anything. + EXPECT_EQ(probeSentinel(backend, "k").outcome, ProbeOutcome::Present); + EXPECT_EQ(probeSentinel(backend, "missing").outcome, ProbeOutcome::KeyAbsent); + + std::filesystem::remove_all(storage->getCommonKeyPrefix()); + + const auto result = probeSentinel(backend, "k"); + EXPECT_EQ(result.outcome, ProbeOutcome::ContainerAbsent); + EXPECT_FALSE(result.body.has_value()); +} + +/// `ObjectStorageBackend::Mode::Native` over a plain `LocalObjectStorage` (the same construction +/// `gtest_cas_backend.cpp`'s Native-mode tests use to exercise the Native code path without a live S3 +/// endpoint): a present key must probe `Present` and carry the materialized body via the raw-HEAD -> +/// `get` path, not just the EmulatedSingleProcess path already covered above. +TEST(CASSentinelProbe, NativePresentKeyReturnsPresentWithBody) +{ + auto storage = tests::makeLocalObjectStorageForTest(); + ObjectStorageBackend backend(storage, ObjectStorageBackend::Mode::Native); + const String key = nativeKeyUnder(storage, "some/key"); + + ASSERT_EQ(backend.putIfAbsent(key, "native body").outcome, PutOutcome::Done); + + const auto result = probeSentinel(backend, key); + EXPECT_EQ(result.outcome, ProbeOutcome::Present); + ASSERT_TRUE(result.body.has_value()); + EXPECT_EQ(*result.body, "native body"); +} + +/// (d) A backend forced to throw a transport error must probe Indeterminate — NEVER KeyAbsent, even +/// though the failure looks superficially like "nothing there" from the caller's point of view. +TEST(CASSentinelProbe, TransportErrorNeverClassifiesAsAbsent) +{ + TransportFaultBackend backend; + const auto result = probeSentinel(backend, "k"); + EXPECT_EQ(result.outcome, ProbeOutcome::Indeterminate); + EXPECT_FALSE(result.body.has_value()); +} + +#if USE_AWS_S3 + +namespace +{ + +/// A `LocalObjectStorage` whose `getObjectMetadata` can be armed to throw a configurable synthetic +/// `S3Exception` — the same technique `gtest_cas_backend.cpp`'s `NativeReadThrowsNoSuchKeyObjectStorage` +/// uses to exercise S3 error codes without a live S3 endpoint. Constructing `ObjectStorageBackend` in +/// `Mode::Native` over this fake is the established pattern for testing the Native/S3 raw-error classifier +/// in isolation (see also `gtest_cas_backend.cpp`'s `NativeRejectsWrongDialectTokenBeforeTouchingTheWire`). +class ThrowingS3MetadataObjectStorage final : public DB::LocalObjectStorage +{ +public: + using DB::LocalObjectStorage::LocalObjectStorage; + + void throwOnGetObjectMetadata(Aws::S3::S3Errors code) { metadata_error = code; } + + DB::ObjectMetadata getObjectMetadata(const std::string & path, bool with_tags) const override + { + if (metadata_error) + throw DB::S3Exception("injected fault: " + path, *metadata_error); + return DB::LocalObjectStorage::getObjectMetadata(path, with_tags); + } + +private: + std::optional metadata_error; +}; + +DB::ObjectStoragePtr makeThrowingS3MetadataStorageForTest() +{ + static std::atomic counter{0}; + const auto unique = std::to_string(::getpid()) + "_" + std::to_string(counter.fetch_add(1)); + const auto root = (std::filesystem::temp_directory_path() / ("cas_sentinel_probe_unit_" + unique)).string(); + + std::error_code ec; + std::filesystem::remove_all(root, ec); + std::filesystem::create_directories(root, ec); + + DB::LocalObjectStorageSettings settings("test", root, /*read_only_=*/false); + return std::make_shared(std::move(settings)); +} + +} + +/// The full S3 IAM permutation table (spec §2): a raw NO_SUCH_KEY/NO_SUCH_BUCKET/ACCESS_DENIED HEAD +/// error must classify EXACTLY, and anything unmodeled must fail closed to Indeterminate. +TEST(CASSentinelProbe, NativeClassifiesNoSuchKeyAsKeyAbsent) +{ + auto storage = std::static_pointer_cast(makeThrowingS3MetadataStorageForTest()); + storage->throwOnGetObjectMetadata(Aws::S3::S3Errors::NO_SUCH_KEY); + ObjectStorageBackend backend(storage, ObjectStorageBackend::Mode::Native); + + EXPECT_EQ(probeSentinel(backend, nativeKeyUnder(storage, "some/key")).outcome, ProbeOutcome::KeyAbsent); +} + +/// A real S3 HEAD's 404 has no response body, so the SDK cannot parse a `NoSuchKey` `` and +/// instead derives `RESOURCE_NOT_FOUND` straight from the HTTP status (see `isNotFoundError`, +/// `src/IO/S3/getObjectInfo.cpp`) — THIS is the code a genuinely absent key throws on real S3, not +/// `NO_SUCH_KEY`. Without classifying it, every real-S3 absence would be `Indeterminate` forever. +TEST(CASSentinelProbe, NativeClassifiesResourceNotFoundAsKeyAbsent) +{ + auto storage = std::static_pointer_cast(makeThrowingS3MetadataStorageForTest()); + storage->throwOnGetObjectMetadata(Aws::S3::S3Errors::RESOURCE_NOT_FOUND); + ObjectStorageBackend backend(storage, ObjectStorageBackend::Mode::Native); + + EXPECT_EQ(probeSentinel(backend, nativeKeyUnder(storage, "some/key")).outcome, ProbeOutcome::KeyAbsent); +} + +TEST(CASSentinelProbe, NativeClassifiesNoSuchBucketAsContainerAbsent) +{ + auto storage = std::static_pointer_cast(makeThrowingS3MetadataStorageForTest()); + storage->throwOnGetObjectMetadata(Aws::S3::S3Errors::NO_SUCH_BUCKET); + ObjectStorageBackend backend(storage, ObjectStorageBackend::Mode::Native); + + EXPECT_EQ(probeSentinel(backend, nativeKeyUnder(storage, "some/key")).outcome, ProbeOutcome::ContainerAbsent); +} + +TEST(CASSentinelProbe, NativeClassifiesAccessDeniedAsAccessDenied) +{ + auto storage = std::static_pointer_cast(makeThrowingS3MetadataStorageForTest()); + storage->throwOnGetObjectMetadata(Aws::S3::S3Errors::ACCESS_DENIED); + ObjectStorageBackend backend(storage, ObjectStorageBackend::Mode::Native); + + EXPECT_EQ(probeSentinel(backend, nativeKeyUnder(storage, "some/key")).outcome, ProbeOutcome::AccessDenied); +} + +TEST(CASSentinelProbe, NativeClassifiesUnmodeledErrorAsIndeterminate) +{ + auto storage = std::static_pointer_cast(makeThrowingS3MetadataStorageForTest()); + storage->throwOnGetObjectMetadata(Aws::S3::S3Errors::SERVICE_UNAVAILABLE); + ObjectStorageBackend backend(storage, ObjectStorageBackend::Mode::Native); + + EXPECT_EQ(probeSentinel(backend, nativeKeyUnder(storage, "some/key")).outcome, ProbeOutcome::Indeterminate); +} + +/// Production wiring (`Pool::open`) ALWAYS wraps the real backend in `InstrumentedBackend` before +/// anything calls it. `InstrumentedBackend` must forward `probeSentinelRaw` to `inner`, not fall +/// through to `Backend::probeSentinelRaw`'s generic head/get-based default — the default would derive +/// its answer from THIS object's own (correctly delegating, but non-typed) `head`/`get` overrides, +/// silently discarding `ObjectStorageBackend`'s real S3-error classification. NO_SUCH_BUCKET is chosen +/// deliberately: the generic default cannot produce `ContainerAbsent` at all (it only ever returns +/// Present/KeyAbsent/Indeterminate), so this test can ONLY pass if the typed override is actually +/// reached through the wrapper. +TEST(CASSentinelProbe, InstrumentedBackendForwardsToInnerClassification) +{ + auto storage = std::static_pointer_cast(makeThrowingS3MetadataStorageForTest()); + storage->throwOnGetObjectMetadata(Aws::S3::S3Errors::NO_SUCH_BUCKET); + auto inner = std::make_shared(storage, ObjectStorageBackend::Mode::Native); + InstrumentedBackend instrumented(inner); + + EXPECT_EQ(probeSentinel(instrumented, nativeKeyUnder(storage, "some/key")).outcome, ProbeOutcome::ContainerAbsent); +} + +#endif diff --git a/src/Disks/tests/gtest_cas_server_root_format.cpp b/src/Disks/tests/gtest_cas_server_root_format.cpp new file mode 100644 index 000000000000..95bd33ad2d45 --- /dev/null +++ b/src/Disks/tests/gtest_cas_server_root_format.cpp @@ -0,0 +1,94 @@ +#include "cas_format_test_battery.h" +#include +#include +#include +#include + +using namespace DB::Cas; + +namespace DB::ErrorCodes +{ + extern const int CORRUPTED_DATA; +} + +TEST(CASFormatBattery, Owner) +{ + OwnerObject o; + o.server_uuid = hexToU128("0123456789abcdeffedcba9876543210"); + const String golden = currentFormatHeader("cas_owner") + + "{\"su\":\"0123456789abcdeffedcba9876543210\"}\n"; + EXPECT_EQ(encodeOwner(o), golden); + EXPECT_FALSE(decodeOwner(golden).retired_at_ms.has_value()); + runFormatBattery({FormatId::Owner, + [&] { return sealObject(FormatId::Owner, encodeOwner(o)); }, + [](std::string_view s) { decodeOwner(std::string(openObject(FormatId::Owner, s))); }, + golden}); +} + +TEST(CASOwnerFormat, RetiredAtRoundTrip) +{ + OwnerObject o; + o.server_uuid = hexToU128("0123456789abcdeffedcba9876543210"); + o.retired_at_ms = 1752537600000ULL; + + const OwnerObject back = decodeOwner(encodeOwner(o)); + EXPECT_EQ(back.server_uuid, o.server_uuid); + EXPECT_EQ(back.retired_at_ms, o.retired_at_ms); +} + +TEST(CASFormatBattery, ServerEpoch) +{ + ServerEpoch e; + e.next_writer_epoch = 7; + runFormatBattery({FormatId::ServerEpoch, + [&] { return sealObject(FormatId::ServerEpoch, encodeServerEpoch(e)); }, + [](std::string_view s) { decodeServerEpoch(std::string(openObject(FormatId::ServerEpoch, s))); }, + currentFormatHeader("cas_epoch") + "{\"nwe\":\"7\"}\n"}); +} + +TEST(CASFormatBattery, MountLease) +{ + MountLease m{hexToU128("0123456789abcdeffedcba9876543210"), 7, "host-1", 4242, + 1752537600000ULL, 5, 1752537630000ULL, 9, false}; + runFormatBattery({FormatId::MountLease, + [&] { return sealObject(FormatId::MountLease, encodeMountLease(m)); }, + [](std::string_view s) { decodeMountLease(std::string(openObject(FormatId::MountLease, s))); }, + currentFormatHeader("cas_mount_lease") + + "{\"su\":\"0123456789abcdeffedcba9876543210\",\"we\":\"7\",\"hn\":\"host-1\",\"pid\":4242," + "\"sat\":1752537600000,\"seq\":\"5\",\"eat\":1752537630000,\"ma\":\"9\",\"fen\":false}\n"}); +} + +TEST(CASMountLeaseFormat, FarewellSentinelAndFencedSurvive) +{ + MountLease m{hexToU128("0123456789abcdeffedcba9876543210"), 7, "h", 1, + 1, 5, 2, std::numeric_limits::max(), true}; + const MountLease back = decodeMountLease(encodeMountLease(m)); + EXPECT_EQ(back.min_active, std::numeric_limits::max()); + EXPECT_TRUE(back.gc_fenced); + EXPECT_EQ(back.hostname, "h"); + EXPECT_EQ(back.writer_epoch, 7u); + EXPECT_EQ(back.seq, 5u); +} + +TEST(CASMountLeaseFormat, RejectsMissingIdentityFields) +{ + const String header = "{\"type\":\"cas_mount_lease\",\"v\":3}\n"; + const String fields = "\"hn\":\"host-1\",\"pid\":4242,\"sat\":1752537600000," + "\"seq\":\"5\",\"eat\":1752537630000,\"ma\":\"9\",\"fen\":false}"; + + const auto expectCorrupted = [](const String & data) + { + try + { + decodeMountLease(data); + FAIL() << "expected CORRUPTED_DATA"; + } + catch (const DB::Exception & e) + { + EXPECT_EQ(e.code(), DB::ErrorCodes::CORRUPTED_DATA); + } + }; + + expectCorrupted(header + R"({"we":"7",)" + fields + "\n"); + expectCorrupted(header + R"({"su":"0123456789abcdeffedcba9876543210",)" + fields + "\n"); +} diff --git a/src/Disks/tests/gtest_cas_settings.cpp b/src/Disks/tests/gtest_cas_settings.cpp new file mode 100644 index 000000000000..124982d44cc6 --- /dev/null +++ b/src/Disks/tests/gtest_cas_settings.cpp @@ -0,0 +1,146 @@ +#include +#include +#include +#include +#include +#include + +using namespace DB; + +namespace DB::ErrorCodes +{ + extern const int NO_ELEMENTS_IN_CONFIG; + extern const int BAD_ARGUMENTS; +} + +/// Per-TU extern declarations for the `ContentAddressedSetting` entries this file uses -- the +/// established pattern for `BaseSettings`-derived classes in this codebase (see e.g. +/// `RegisterDiskCache.cpp`'s `namespace FileCacheSetting` block): the entries are DEFINED once in +/// `ContentAddressedSettings.cpp`, and each consumer TU declares only the ones it references. +namespace DB::ContentAddressedSetting +{ + extern const ContentAddressedSettingsUInt64 gc_shards; + extern const ContentAddressedSettingsUInt64 gc_interval_sec; + extern const ContentAddressedSettingsUInt64 deduplication_cache_bytes; + extern const ContentAddressedSettingsString scratch_path; +} + +namespace +{ +Poco::AutoPtr makeConfig(const std::string & inner) +{ + std::istringstream iss("" + inner + ""); + return new Poco::Util::XMLConfiguration(iss); +} +const auto identity_macros = [](const std::string & s) { return s; }; +} + +TEST(CASContentAddressedSettings, DefaultsAndOverridesLand) +{ + auto cfg = makeConfig("srv14"); + ContentAddressedSettings s; + s.loadFromConfig(*cfg, "disk", "/data", "/data/default_scratch", identity_macros); + EXPECT_EQ(s[ContentAddressedSetting::gc_shards].value, 4u); + EXPECT_EQ(s[ContentAddressedSetting::gc_interval_sec].value, 60u); /// table default + EXPECT_EQ(s[ContentAddressedSetting::deduplication_cache_bytes].value, 64ULL << 20); /// table default + /// Absent key -> the verbatim default (never touches the anchor). + EXPECT_EQ(s[ContentAddressedSetting::scratch_path].value, "/data/default_scratch"); +} + +TEST(CASContentAddressedSettings, UnknownKeyRejected) +{ + auto cfg = makeConfig("srv14"); + ContentAddressedSettings s; + EXPECT_THROW(s.loadFromConfig(*cfg, "disk", "/scratch", "/scratch", identity_macros), Exception); +} + +TEST(CASContentAddressedSettings, ObjectStorageKeysSkipped) +{ + auto cfg = makeConfig( + "casobject_storage" + "s3http://x/y" + "ks" + "srv1" + /// Regression pin (stateless-lane startup fix): the local-object-storage CAS disk config + /// (`tests/config/config.d/cas_storage_policy_for_merge_tree_by_default.xml`) + /// sets `path` -- the generic local-object-storage pool root, consumed by + /// `ObjectStorageFactory`/`IDisk`, same class as `endpoint`/`access_key_id` above -- and it + /// was missing from `non_cas_keys`, which threw `UNKNOWN_SETTING` at server startup. + "cas_pool/" + /// Regression pin: `name`, read generically by `DiskFromAST` for the inline SQL `disk(...)` + /// form used by the `05002`-`05015` CAS stateless tests, was likewise missing and threw + /// `UNKNOWN_SETTING` for every one of those tests (only the XML-config `path` gap was fixed + /// first; `name` surfaced once the stateless lane actually ran end to end). + "cas_test_disk" + /// Regression pin: `use_fake_transaction`, validated generically in + /// `RegisterDiskObjectStorage.cpp` for every metadata type that needs a real transaction (not + /// a CAS-specific check), must reach that check rather than being rejected here as unknown -- + /// `05015_cas_reject_fake_transaction` depends on it doing so. + "1"); + ContentAddressedSettings s; + EXPECT_NO_THROW(s.loadFromConfig(*cfg, "disk", "/scratch", "/scratch", identity_macros)); +} + +TEST(CASContentAddressedSettings, ValidateFailsClosed) +{ + { /// missing server_root_id: ABSENT key -> typed NO_ELEMENTS_IN_CONFIG (distinct from a + /// present-but-invalid value, checked below), mirroring the pre-F4b factory behavior. + auto cfg = makeConfig("1"); + ContentAddressedSettings s; + try + { + s.loadFromConfig(*cfg, "disk", "/scratch", "/scratch", identity_macros); + FAIL() << "expected an exception"; + } + catch (const Exception & e) + { + EXPECT_EQ(e.code(), ErrorCodes::NO_ELEMENTS_IN_CONFIG); + } + } + { /// present but invalid (empty) server_root_id -> BAD_ARGUMENTS from + /// `Cas::validateServerRootId`, not NO_ELEMENTS_IN_CONFIG. + auto cfg = makeConfig(""); + ContentAddressedSettings s; + try + { + s.loadFromConfig(*cfg, "disk", "/scratch", "/scratch", identity_macros); + FAIL() << "expected an exception"; + } + catch (const Exception & e) + { + EXPECT_EQ(e.code(), ErrorCodes::BAD_ARGUMENTS); + } + } + { /// zero gc_shards + auto cfg = makeConfig("srv10"); + ContentAddressedSettings s; + EXPECT_THROW(s.loadFromConfig(*cfg, "disk", "/scratch", "/scratch", identity_macros), Exception); + } + { /// unknown blob_hash spelling + auto cfg = makeConfig("srv1md5"); + ContentAddressedSettings s; + EXPECT_THROW(s.loadFromConfig(*cfg, "disk", "/scratch", "/scratch", identity_macros), Exception); + } +} + +TEST(CASContentAddressedSettings, RelativeScratchPathAnchored) +{ + /// Reproduces the pre-F4b factory's anchor behavior (review finding, Critical): a relative + /// `scratch_path` override is anchored to the SERVER DATA PATH (`scratch_path_anchor_if_relative`) + /// -- NOT to `default_scratch_path`, which is itself a per-disk subdirectory + /// (`.../disks//cas_scratch`) that must never leak into an override's resolved path. + auto cfg = makeConfig("srv1rel/dir"); + ContentAddressedSettings s; + s.loadFromConfig(*cfg, "disk", "/data", "/data/disks/x/cas_scratch", identity_macros); + EXPECT_EQ(s[ContentAddressedSetting::scratch_path].value, "/data/rel/dir"); +} + +TEST(CASContentAddressedSettings, AbsentScratchPathUsesDefaultVerbatim) +{ + /// Absent key -> `default_scratch_path` verbatim, unaffected by the anchor (the per-disk default + /// already lives under the server data path; only an explicit relative OVERRIDE needs anchoring). + auto cfg = makeConfig("srv1"); + ContentAddressedSettings s; + s.loadFromConfig(*cfg, "disk", "/data", "/data/disks/x/cas_scratch", identity_macros); + EXPECT_EQ(s[ContentAddressedSetting::scratch_path].value, "/data/disks/x/cas_scratch"); +} diff --git a/src/Disks/tests/gtest_cas_slot_occupy.cpp b/src/Disks/tests/gtest_cas_slot_occupy.cpp new file mode 100644 index 000000000000..6e65bfe333bf --- /dev/null +++ b/src/Disks/tests/gtest_cas_slot_occupy.cpp @@ -0,0 +1,334 @@ +#include + +#include "config.h" + +#include +#include +#include +#include +#include + +using namespace DB::Cas; +using DB::Cas::tests::CountingBackend; +using DB::Cas::tests::ChunkFaultBackend; +using DB::Cas::tests::LandedButAckLostOnceBackend; + +namespace DB::ErrorCodes +{ + extern const int BAD_ARGUMENTS; +} + +/// ================================================================================================ +/// Task 2 (2026-07-28 CAS ref-chain Stage A streams, spec INV-2): CasRequestController::slotOccupy -- +/// the dedicated RAW slot-occupy primitive every seal writer and wedge retry uses. ONE conditional +/// create; on conflict, ONE raw exact GET of the occupant -- NEVER retries internally, NEVER lists, +/// and NEVER composes putIfAbsentControlled (which retries the same (key, bytes) internally) or +/// resolveByExactGet (which compares against an expected body and throws CORRUPTED_DATA on a +/// mismatch) [codex finding 3]. Adjudicating whether an Occupied occupant is "mine" is entirely the +/// CALLER's job (Task 4/6, the CaCasMountCore `mine` contract) -- these tests only pin the +/// primitive's own three-way outcome and its op-count contract (Created=1, Occupied=2, +/// Unresolved<=2 backend ops). +/// ================================================================================================ + +namespace +{ + +/// Deletes the key the INSTANT its own conditional create conflicts, modelling "the occupant that +/// caused the conflict vanished before slotOccupy's single resolve GET" -- a race a real backend can +/// produce (e.g. GC reclaiming an already-condemned object) that the primitive must survive by +/// reporting Unresolved, NEVER a fabricated Created. +class VanishOnConflictBackend : public CountingBackend +{ +public: + using CountingBackend::putIfAbsent; + + PutResult putIfAbsent(const String & key, const String & bytes, const ObjectMeta & meta) override + { + PutResult result = CountingBackend::putIfAbsent(key, bytes, meta); + if (result.outcome == PutOutcome::PreconditionFailed) + { + const HeadResult h = head(key); + if (h.exists) + deleteExact(key, h.token); + } + return result; + } +}; + +/// Throws a deterministic LOCAL failure (BAD_ARGUMENTS, in isDeterministicLocalFailure's set) on the +/// first putIfAbsent -- models a backend-level programming bug, distinct from ChunkFaultBackend's +/// Mode::Definite below, which is a whitelisted SYNCHRONOUS REJECTION +/// (classifyConditionalWriteResult's DefiniteFailure). slotOccupy must rethrow both, unchanged, never +/// folding either into Unresolved (SlotOccupyResult::Kind has no DefiniteFailure member to carry it). +class LocalFailureOnceBackend : public CountingBackend +{ +public: + using CountingBackend::putIfAbsent; + bool fail_once = true; + + PutResult putIfAbsent(const String & key, const String & bytes, const ObjectMeta & meta) override + { + if (fail_once) + { + fail_once = false; + throw DB::Exception(DB::ErrorCodes::BAD_ARGUMENTS, "scripted deterministic local failure"); + } + return CountingBackend::putIfAbsent(key, bytes, meta); + } +}; + +/// (`LandedButAckLostOnceBackend` -- "the write LANDS, then the ack is lost" -- was lifted into +/// `cas_test_helpers.h` for Task 4, whose wedge-adoption tests need the identical seam through a whole +/// Pool. Its `key_substr` defaults to empty, which is exactly this file's original behaviour: fault the +/// first `putIfAbsent` of any key.) +/// Delegates the FIRST putIfAbsent for a key to CountingBackend -- so the write actually LANDS -- and +/// only THEN throws an ambiguous exception, modelling "our own PUT committed but its response was lost" +/// (the Task-4 adoption input: plan's "Occupied + bytes == wedge.bytes -> an earlier attempt landed -> +/// adopt"). Distinct from InMemoryBackend::injectAmbiguousPutIfAbsent, which never touches the store at +/// all -- that hook models an attempt that did NOT land; this one models an attempt that DID. +/// One-shot per backend instance: review finding I2 asked specifically for a ~10-line local backend rather than +/// reusing ChunkFaultBackend::Mode::LandedThenLost, which also arms a one-shot lost-GET fault that would +/// obscure whether slotOccupy's OWN immediate resolve (not just a later caller's retry) is correct too. + +} + +/// ---- Step 1 required scenarios ---- + +TEST(CASSlotOccupy, AbsentKeyCreatesWithOneOp) +{ + auto backend = std::make_shared(); + CasRequestController controller(backend, CasRequestBudget{}); + + const auto result = controller.slotOccupy("k", "payload", [] { return true; }); + EXPECT_EQ(result.kind, SlotOccupyResult::Kind::Created); + EXPECT_TRUE(result.occupant_bytes.empty()); + EXPECT_TRUE(result.occupant_token.empty()) << "occupant_token is Occupied-only; must stay default on Created"; + EXPECT_EQ(result.unresolved_reason, CasUnresolvedReason::NotUnresolved); + + EXPECT_EQ(backend->putCount("k"), 1u); + EXPECT_EQ(backend->getCount("k"), 0u); + EXPECT_EQ(backend->headCount("k"), 0u); + + const auto landed = backend->get("k"); + ASSERT_TRUE(landed.has_value()); + EXPECT_EQ(landed->bytes, "payload"); +} + +TEST(CASSlotOccupy, PreExistingKeyOccupiedWithExactBytesAndTokenTwoOps) +{ + auto backend = std::make_shared(); + const PutResult seeded = backend->putIfAbsent("k", "occupant-bytes"); + ASSERT_EQ(seeded.outcome, PutOutcome::Done); + backend->resetCounts(); + + CasRequestController controller(backend, CasRequestBudget{}); + const auto result = controller.slotOccupy("k", "my-attempt-bytes", [] { return true; }); + EXPECT_EQ(result.kind, SlotOccupyResult::Kind::Occupied); + EXPECT_EQ(result.occupant_bytes, "occupant-bytes"); + EXPECT_EQ(result.occupant_token, seeded.token); + + EXPECT_EQ(backend->putCount("k"), 1u); + EXPECT_EQ(backend->getCount("k"), 1u); + EXPECT_EQ(backend->headCount("k"), 0u) << "exactly PUT+GET -- a HEAD-then-GET implementation must fail this"; + + /// A conflict never overwrites or appends -- the pre-existing object is untouched. + const auto current = backend->get("k"); + ASSERT_TRUE(current.has_value()); + EXPECT_EQ(current->bytes, "occupant-bytes"); +} + +TEST(CASSlotOccupy, InjectedAmbiguousPutResolvesUnresolvedWhenGetFindsNothing) +{ + auto backend = std::make_shared(); + backend->injectAmbiguousPutIfAbsent("k"); + + CasRequestController controller(backend, CasRequestBudget{}); + const auto result = controller.slotOccupy("k", "payload", [] { return true; }); + EXPECT_EQ(result.kind, SlotOccupyResult::Kind::Unresolved); + /// An attempt WAS sent (the ambiguous PUT itself) -- this is never the pre-attempt NoAttemptSent + /// case. Of the existing CasUnresolvedReason values, AttemptsExhausted is the one documented as + /// "the genuine case the 'retry budget exhausted' wording describes" -- exactly this call's single + /// (and only) attempt having nothing left to give once its resolve GET came up empty. + EXPECT_EQ(result.unresolved_reason, CasUnresolvedReason::AttemptsExhausted); + EXPECT_FALSE(unresolvedProvesNothingWasSent(result.unresolved_reason)); + + EXPECT_EQ(backend->putCount("k"), 1u); + EXPECT_EQ(backend->getCount("k"), 1u); + EXPECT_FALSE(backend->head("k").exists) << "the injected fault must not actually create anything"; +} + +TEST(CASSlotOccupy, ConflictThenVanishResolvesUnresolved) +{ + auto backend = std::make_shared(); + const auto seeded = backend->putIfAbsent("k", "occupant-bytes"); + ASSERT_EQ(seeded.outcome, PutOutcome::Done); + backend->resetCounts(); + + CasRequestController controller(backend, CasRequestBudget{}); + const auto result = controller.slotOccupy("k", "my-attempt-bytes", [] { return true; }); + EXPECT_EQ(result.kind, SlotOccupyResult::Kind::Unresolved); + EXPECT_EQ(result.unresolved_reason, CasUnresolvedReason::AttemptsExhausted); + + EXPECT_EQ(backend->putCount("k"), 1u); + EXPECT_EQ(backend->getCount("k"), 1u); + /// No headCount assertion here (unlike the sibling Occupied test above): VanishOnConflictBackend's + /// OWN fixture issues a HEAD internally (to fetch the token before deleteExact) -- that HEAD belongs + /// to the test's vanish mechanism, not to slotOccupy, so asserting headCount==0 would be wrong, not + /// stronger. slotOccupy itself never calls head(); only put+get are its own ops. + EXPECT_FALSE(backend->head("k").exists) << "the occupant vanished between the conflict and the resolve GET"; +} + +TEST(CASSlotOccupy, FenceFlipMidCallRefusesPreAttemptNeverLiesCreated) +{ + auto backend = std::make_shared(); + CasRequestController controller(backend, CasRequestBudget{}); + + const auto result = controller.slotOccupy("k", "payload", [] { return false; }); + EXPECT_EQ(result.kind, SlotOccupyResult::Kind::Unresolved); + /// The pre-attempt reason: fence_ok refused before anything was sent to the backend. + EXPECT_EQ(result.unresolved_reason, CasUnresolvedReason::NoAttemptSent); + EXPECT_TRUE(unresolvedProvesNothingWasSent(result.unresolved_reason)); + + EXPECT_EQ(backend->putTotal(), 0u); + EXPECT_EQ(backend->getTotal(), 0u); + EXPECT_FALSE(backend->head("k").exists) << "never a lie of Created -- the key must be untouched"; +} + +/// ---- Bonus coverage: the deadline pre-gate (the OTHER half of "fence/deadline-gated"), and the two +/// rethrow paths this primitive shares with its sibling controlled ops. ---- + +/// The deadline gate is the SAME pre-attempt refusal as the fence gate above -- a fake clock proves it +/// fires from elapsed time alone, with a fence that always says yes. +TEST(CASSlotOccupy, OperationDeadlineExhaustedRefusesPreAttempt) +{ + auto backend = std::make_shared(); + uint64_t clock = 0; + auto now_ms = [&clock]() -> uint64_t { const uint64_t t = clock; clock += 1000; return t; }; + + CasRequestBudget budget; + budget.attempt_timeout_ms = 50; + budget.operation_deadline_ms = 500; /// entry now_ms()==0 -> deadline_ms=500; the gate's OWN + /// now_ms() call then returns 1000 -> 1000+50 > 500 -> refuse + CasRequestController controller(backend, budget, now_ms); + + const auto result = controller.slotOccupy("k", "payload", [] { return true; }); + EXPECT_EQ(result.kind, SlotOccupyResult::Kind::Unresolved); + EXPECT_EQ(result.unresolved_reason, CasUnresolvedReason::NoAttemptSent); + EXPECT_EQ(backend->putTotal(), 0u); + EXPECT_EQ(backend->getTotal(), 0u) << "zero ops total -- the deadline gate must refuse before any I/O, same as the fence gate"; +} + +/// A whitelisted synchronous rejection (classifyConditionalWriteResult's DefiniteFailure) PROVES the +/// request was never applied -- slotOccupy must surface it unchanged rather than resolving or folding +/// it into Unresolved. Guarded to USE_AWS_S3 builds ONLY [review M6]: DefiniteFailure classification is +/// structurally unreachable without it (classifyConditionalWriteResult's whitelist is entirely inside +/// its own `#if USE_AWS_S3`), so on a no-S3 build ChunkFaultBackend::Mode::Definite instead throws a +/// plain CORRUPTED_DATA DB::Exception -- which is in isDeterministicLocalFailure's set, meaning this +/// test would silently exercise the SAME slotOccupy branch as DeterministicLocalFailurePropagatesWithoutResolve +/// below rather than the DefiniteFailure branch it claims to cover. Better a visibly-absent test on that +/// config than a passing one that isn't testing what its name says. +#if USE_AWS_S3 +TEST(CASSlotOccupy, DefiniteFailurePropagatesWithoutResolve) +{ + auto backend = std::make_shared(); + backend->fault_substr = "k"; + backend->mode = ChunkFaultBackend::Mode::Definite; + backend->fault_count = 1; + + CasRequestController controller(backend, CasRequestBudget{}); + EXPECT_THROW(controller.slotOccupy("k", "payload", [] { return true; }), DB::Exception); + /// ChunkFaultBackend's fault check throws BEFORE delegating to CountingBackend::putIfAbsent, so + /// putCount stays 0 on this path -- fault_count reaching 0 is this backend's own proof the (one) + /// attempt was made and consumed the fault. + EXPECT_EQ(backend->fault_count, 0); + EXPECT_EQ(backend->getCount("k"), 0u) << "a whitelisted definite rejection must never trigger a resolve GET"; +} +#endif + +/// A deterministic LOCAL failure (isDeterministicLocalFailure's set) is the OTHER rethrow path -- +/// distinct from DefiniteFailure above, and checked first in the implementation, so it needs its own +/// backend-level fault to prove both branches are wired, not just one masking the other. +TEST(CASSlotOccupy, DeterministicLocalFailurePropagatesWithoutResolve) +{ + auto backend = std::make_shared(); + CasRequestController controller(backend, CasRequestBudget{}); + + bool threw = false; + try + { + controller.slotOccupy("k", "payload", [] { return true; }); + } + catch (const DB::Exception & e) + { + threw = true; + EXPECT_EQ(e.code(), DB::ErrorCodes::BAD_ARGUMENTS) << "the ORIGINAL exception must propagate unchanged"; + } + EXPECT_TRUE(threw) << "a deterministic local failure must propagate, never return an outcome"; + /// LocalFailureOnceBackend throws BEFORE delegating to CountingBackend::putIfAbsent (same shape as + /// ChunkFaultBackend above), so putCount stays 0 here too -- fail_once flipping is this backend's + /// own proof the attempt was made. + EXPECT_FALSE(backend->fail_once); + EXPECT_EQ(backend->getCount("k"), 0u); +} + +/// ---- Fix round 1 (review findings I1, I2): the two gaps the reviewer required landed before Task 4 +/// consumes this primitive. Both guard the design decisions the review approved -- see +/// task-2-review.md concern (a) and finding I2's Task-4-adoption note. ---- + +/// I1: pins the single-fence_ok()-call design (concern (a)) so a future contributor cannot silently +/// "fix the inconsistency" by re-adding the sibling ops' post-write fence recheck -- that change would +/// break Task 4's old-generation-retry semantics (resolveWedgeOnce deliberately calls slotOccupy under +/// the wedge's ORIGINAL admitted_fence_generation, and relies on ITS OWN post-I/O checkFenceOrThrow, +/// not a second internal check here, to decide whether the result is still relevant). A counting +/// fence_ok that only answers true on its FIRST call: if slotOccupy ever called it again after the +/// write landed, this test would see Unresolved instead of Created, OR (if the outcome happened to +/// still read Created some other way) the call-count assertion below would catch the extra invocation +/// either way. +TEST(CASSlotOccupy, CreatedNeverRechecksFenceAfterTheWrite) +{ + auto backend = std::make_shared(); + CasRequestController controller(backend, CasRequestBudget{}); + + int fence_calls = 0; + const auto fence_ok = [&fence_calls] + { + ++fence_calls; + return fence_calls == 1; + }; + + const auto result = controller.slotOccupy("k", "payload", fence_ok); + EXPECT_EQ(result.kind, SlotOccupyResult::Kind::Created); + EXPECT_EQ(fence_calls, 1) << "slotOccupy must call fence_ok() exactly ONCE (pre-attempt only) -- " + "a post-write recheck would falsely report Unresolved here (fence_calls's " + "SECOND answer is false) and would break Task 4's old-generation-retry design"; +} + +/// I2: proves Occupied is reachable for an occupant that is OUR OWN earlier ambiguous write, not only +/// for a foreign pre-seeded one (PreExistingKeyOccupiedWithExactBytesAndTokenTwoOps above always seeds +/// via a plain, unambiguous putIfAbsent). This is the exact input shape Task 4's resolveWedgeOnce +/// adjudicates: "Occupied + bytes == wedge.bytes -> an earlier attempt landed -> adopt" (plan :329). +TEST(CASSlotOccupy, OwnLandedAmbiguousWriteObservedAsOccupiedOnRetry) +{ + auto backend = std::make_shared(); + CasRequestController controller(backend, CasRequestBudget{}); + + /// Call 1 -- the original attempt: the PUT's own response is lost, but the write DID commit, and + /// THIS call's own resolve GET (unfaulted) observes it immediately -- Occupied with OUR bytes, + /// proving the same-call resolve path works for a landed ambiguous write, not only a foreign one. + const auto first = controller.slotOccupy("k", "my-bytes", [] { return true; }); + EXPECT_EQ(first.kind, SlotOccupyResult::Kind::Occupied); + EXPECT_EQ(first.occupant_bytes, "my-bytes"); + EXPECT_EQ(backend->putCount("k"), 1u); + EXPECT_EQ(backend->getCount("k"), 1u); + + /// Call 2 -- Task 4's resolveWedgeOnce pattern: a LATER caller's flush resolving the SAME logical + /// attempt via a FRESH slotOccupy call. The fault is already consumed (one-shot), so this PUT + /// conflicts cleanly (PreconditionFailed) and the resolve GET observes OUR OWN earlier bytes again -- + /// the exact adoption input Task 4 is built on, and the SAME incarnation both calls saw. + const auto second = controller.slotOccupy("k", "my-bytes", [] { return true; }); + EXPECT_EQ(second.kind, SlotOccupyResult::Kind::Occupied); + EXPECT_EQ(second.occupant_bytes, "my-bytes"); + EXPECT_EQ(second.occupant_token, first.occupant_token) << "both calls must observe the SAME landed incarnation"; + EXPECT_EQ(backend->putCount("k"), 2u); + EXPECT_EQ(backend->getCount("k"), 2u); +} diff --git a/src/Disks/tests/gtest_cas_sweep_deletion_premise.cpp b/src/Disks/tests/gtest_cas_sweep_deletion_premise.cpp new file mode 100644 index 000000000000..a5af5889e495 --- /dev/null +++ b/src/Disks/tests/gtest_cas_sweep_deletion_premise.cpp @@ -0,0 +1,433 @@ +#include + +#include +#include +#include +#include "cas_sweep_test_support.h" +#include "cas_test_helpers.h" + +#include + +using namespace DB::Cas; +using namespace DB::Cas::tests; + +/// Spec §6, the sweep deletion premise. A manifest of an epoch-`E` build is deletable only when the +/// namespace cursor has consumed epoch `E`'s seal AND no unconsumed tail record above the cursor names +/// it as a removal target; on ANY uncertainty the sweep RETAINS and says why. +/// +/// WHY THE CURSOR AND NOT A LISTING. The sweep's pre-existing protection view is assembled from an +/// enumeration of the namespace's ref objects, and arithmetic ref intake demoted exactly that +/// enumeration to a hint: a store may omit a durable key from a `LIST`. A hidden `+1` above the cursor +/// therefore makes an owned manifest look unowned, and deleting it is data loss; a hidden `-1` makes a +/// removal target look unprotected, and deleting it clamps the fold forever on the missing body. The +/// premise closes the first by arithmetic (grants do not cross epochs, and an epoch is left only over +/// its consumed seal) and the second by refusing whenever the tail is not decidable. +namespace +{ + +/// The build's epoch. The namespace's seeded ref log lives at writer epoch 1 (`appendRefLogSeed`), so +/// naming the build's epoch 1 as well keeps the fixture coherent: a cursor at `{2, _}` is then a cursor +/// that genuinely crossed epoch 1's closing seal, not an invented number above an unrelated stream. +constexpr uint64_t kBuildEpoch = 1; +const String kServerRoot = "00"; + +ManifestRef ref(uint64_t seq, uint64_t ordinal) +{ + return ManifestRef{.writer_epoch = kBuildEpoch, .build_sequence = seq, + .manifest_ordinal = static_cast(ordinal)}; +} + +BuildPrefix buildPrefix(uint64_t seq) +{ + return BuildPrefix{.writer_epoch = kBuildEpoch, .build_sequence = seq}; +} + +/// A pool with ONE eligible-but-unowned manifest body under build sequence 5: the shape the sweep is +/// meant to reclaim, so that every test below differs only in the durable fold state. +struct OrphanFixture +{ + std::shared_ptr backend = std::make_shared(); + PoolPtr store; + RootNamespace ns{"00/aa@cas@"}; + ManifestRef orphan = ref(5, 0xAB); + + OrphanFixture() + { + store = openPoolForTest(backend); + /// This fixture has no ref transaction, but it is a normal empty catalog life rather than the + /// deliberate missing-checkpoint corruption shape. State that empty recovery frontier before + /// exercising the independent sweep-deletion premise. + casAdmitRecoverableEntry(*backend, store->layout(), ns); + writeManifestRaw(*backend, store->layout(), ns, orphan, {blobEntryFor("a", DB::UInt128(1))}); + /// min_active 6 > build_sequence 5: the durable watermark fact makes the prefix ELIGIBLE, which + /// is the half the premise sits on top of. + setWatermarkMinActive(*backend, store->layout(), kServerRoot, kBuildEpoch, /*min_active*/6); + } + + String orphanKey() const { return store->layout().manifestKey(ManifestId{ns, orphan}); } + bool orphanExists() const { return backend->head(orphanKey()).exists; } +}; + +} + +/// Rule (1), the load-bearing case. The cursor is still INSIDE the build's own epoch, so epoch 1's +/// closing seal is not proven consumed and an unfolded `+1` naming this build may still exist above the +/// cursor. The body survives, and the sweep says so through its `warnings` out-param. +TEST(CASSweepDeletionPremise, AnUnconsumedEpochSealRetainsTheBuildsManifests) +{ + OrphanFixture f; + seedFoldCursorForTest(*f.backend, f.store->layout(), f.ns, RefTxnId{kBuildEpoch, 3}); + + std::vector warnings; + const uint64_t deleted = sweepNamespace(*f.store, f.ns, buildPrefix(5), &warnings); + + EXPECT_EQ(deleted, 0u); + EXPECT_TRUE(f.orphanExists()) + << "the cursor sits at {1,3}, inside the build's own epoch -- epoch 1's closing seal is not " + "consumed, so a grant naming this build may still be unfolded above the cursor"; + ASSERT_EQ(warnings.size(), 1u) << "a retained manifest is a visible decision, not a silent one"; + EXPECT_NE(warnings[0].find(f.orphanKey()), String::npos); + EXPECT_NE(warnings[0].find("seal"), String::npos); +} + +/// Rule (1) satisfied and the tail clean: the ordinary reclaim still happens, by exact token. +TEST(CASSweepDeletionPremise, AConsumedEpochSealWithACleanTailDeletes) +{ + OrphanFixture f; + /// A cursor at `{2, 1}` is in an epoch strictly above the build's. An epoch is left ONLY over its + /// consumed `EpochSeal`, so this cursor is durable proof that every epoch-1 record is folded. + seedFoldCursorForTest(*f.backend, f.store->layout(), f.ns, RefTxnId{kBuildEpoch + 1, 1}); + + std::vector warnings; + const uint64_t deleted = sweepNamespace(*f.store, f.ns, buildPrefix(5), &warnings); + + EXPECT_EQ(deleted, 1u); + EXPECT_FALSE(f.orphanExists()); + EXPECT_TRUE(warnings.empty()) << "nothing was retained, so nothing is warned about"; +} + +/// Uncertainty rule, hold arm. The cursor HAS consumed epoch 1's seal, so rule (1) alone would let the +/// body go -- but the namespace is held, which means the fold could not account for everything at or +/// above the held position. A held namespace retains everything under it. +TEST(CASSweepDeletionPremise, AHeldNamespaceRetainsEvenAboveAConsumedSeal) +{ + OrphanFixture f; + const RefHold hold{.reason = HoldReason::GapBelowWitness, + .offending_position = RefTxnId{kBuildEpoch + 1, 4}, + .retry_count = 2, .next_retry_round = 9}; + seedFoldCursorForTest(*f.backend, f.store->layout(), f.ns, RefTxnId{kBuildEpoch + 1, 3}, hold); + + std::vector warnings; + const uint64_t deleted = sweepNamespace(*f.store, f.ns, buildPrefix(5), &warnings); + + EXPECT_EQ(deleted, 0u); + EXPECT_TRUE(f.orphanExists()); + ASSERT_EQ(warnings.size(), 1u); + EXPECT_NE(warnings[0].find("held"), String::npos); + EXPECT_NE(warnings[0].find(String{holdReasonToWord(HoldReason::GapBelowWitness)}), String::npos) + << "the retain reason names WHAT stopped the namespace, not just that something did"; +} + +/// Uncertainty rule, unreached-frontier arm in its most complete form: the adopted seal carries no row +/// for this namespace at all, so no round has ever sealed a cursor for it and nothing about its ref +/// stream is proven. This is also the state of a pool whose GC has never run. +TEST(CASSweepDeletionPremise, ANamespaceWithNoSealedCursorRetains) +{ + OrphanFixture f; + /// A seal exists and is adopted, but it covers a DIFFERENT namespace. + seedFoldCursorForTest(*f.backend, f.store->layout(), RootNamespace{"00/zz@cas@"}, + RefTxnId{kBuildEpoch + 1, 1}); + + std::vector warnings; + const uint64_t deleted = sweepNamespace(*f.store, f.ns, buildPrefix(5), &warnings); + + EXPECT_EQ(deleted, 0u); + EXPECT_TRUE(f.orphanExists()); + ASSERT_EQ(warnings.size(), 1u); + EXPECT_NE(warnings[0].find("coverage"), String::npos); +} + +/// Rule (2). Removals cross epochs, so a record in a LATER epoch can name an earlier epoch's build as a +/// removal target; deleting the body before that `-1` folds clamps the fold forever on the missing body. +/// The predicate is exercised directly here because the sweep's own protection view already spares a +/// listed tail removal before the premise is ever consulted -- the point of the rule is that the SAME +/// answer is reached by the predicate both paths share, so neither path can lose it. +TEST(CASSweepDeletionPremise, AnUnconsumedTailRemovalRetainsItsTarget) +{ + OrphanFixture f; + const String key = f.orphanKey(); + + NamespaceFoldView view; + RefCoverage cov; + cov.classification = 2; + cov.last_folded_ref_id = RefTxnId{kBuildEpoch + 1, 1}; /// rule (1) satisfied + view.coverage = cov; + view.tail_removal_targets.insert(key); + + String reason; + EXPECT_FALSE(manifestDeletionPremise(view, ManifestKey{key, buildPrefix(5)}, &reason)); + EXPECT_NE(reason.find("removal"), String::npos); + + /// The same view without the removal target admits the deletion, so the retention above is the + /// removal target's doing and nothing else's. + view.tail_removal_targets.clear(); + reason.clear(); + EXPECT_TRUE(manifestDeletionPremise(view, ManifestKey{key, buildPrefix(5)}, &reason)); + EXPECT_TRUE(reason.empty()); +} + +/// Both sweep paths call the ONE predicate: the cursor-paced page must refuse the same body the +/// per-namespace sweep refuses, for the same reason. +TEST(CASSweepDeletionPremise, TheCursorPagePathHonoursTheSamePremise) +{ + OrphanFixture f; + seedFoldCursorForTest(*f.backend, f.store->layout(), f.ns, RefTxnId{kBuildEpoch, 3}); + + const ManifestSweepResult held = sweepManifestCursorPageForTest(*f.store, "", /*list_budget*/100, /*delete_budget*/10); + EXPECT_EQ(held.deleted, 0u); + EXPECT_GE(held.skipped, 1u); + EXPECT_TRUE(f.orphanExists()); + + /// Consume the seal and the very same page deletes it. + seedFoldCursorForTest(*f.backend, f.store->layout(), f.ns, RefTxnId{kBuildEpoch + 1, 1}); + const ManifestSweepResult freed = sweepManifestCursorPageForTest(*f.store, "", /*list_budget*/100, /*delete_budget*/10); + EXPECT_EQ(freed.deleted, 1u); + EXPECT_FALSE(f.orphanExists()); +} + +/// WHAT THE PREMISE COSTS, pinned so it is a stated behaviour rather than something a later reader +/// discovers. The pure pre-precommit orphan -- a manifest body staged by a writer that crashed before +/// appending any ref record for it -- lives under a namespace whose ref stream may not exist at all. +/// Such a namespace never enters the fold's universe, so no round ever seals a cursor for it, so no +/// epoch's closing seal is ever consumed for it, so the premise retains its debris INDEFINITELY. That +/// is the safe direction and it is deliberate, but it is not "delay": reclaiming this class needs the +/// sweep's own rework (registers R2/R3, Stage B) -- the writer duty queue that knows what it staged, and +/// the nomination path. The premise ships as the safety floor, not as the reclaim policy. +TEST(CASSweepDeletionPremise, DebrisUnderANamespaceTheFoldNeverWalksIsRetainedIndefinitely) +{ + OrphanFixture f; + /// No ref stream, no coverage row -- repeated passes change nothing. + std::vector warnings; + for (int pass = 0; pass < 3; ++pass) + EXPECT_EQ(sweepNamespace(*f.store, f.ns, buildPrefix(5), &warnings), 0u) << "pass " << pass; + + EXPECT_TRUE(f.orphanExists()); + EXPECT_EQ(warnings.size(), 3u) << "every pass reports the retention rather than going quiet"; +} + +/// RETENTION IS VISIBLE ON THE PATH THAT ACTUALLY SWEEPS. `planManifestCursorPage` has no `warnings` +/// out-param -- the background sweep answers to nobody but its phase row -- so the premise's refusals +/// have to leave the process as COUNTERS or not at all. In Stage A that is nearly the whole story of +/// the sweep, because rule (1) is satisfiable only for a closed-and-folded epoch. +/// +/// Non-vacuous by construction: two namespaces on ONE page are retained for DIFFERENT reasons, so a +/// counter wired to the wrong class, or one bucket catching everything, changes the answer. A +/// single-reason page would pass against a single mislabelled counter. +TEST(CASSweepDeletionPremise, DistinctRetainReasonsLandInDistinctCounters) +{ + auto backend = std::make_shared(); + auto store = openPoolForTest(backend); + const Layout & layout = store->layout(); + + /// Namespace A: a cursor still INSIDE the build's epoch -> rule (1), `unconsumed_seal`. + const RootNamespace ns_a{"00/aa@cas@"}; + const ManifestRef ref_a = ref(5, 0xA1); + casAdmitRecoverableEntry(*backend, layout, ns_a); + writeManifestRaw(*backend, layout, ns_a, ref_a, {blobEntryFor("a", DB::UInt128(1))}); + seedFoldCursorForTest(*backend, layout, ns_a, RefTxnId{kBuildEpoch, 3}); + + /// Namespace B: a HELD row whose cursor is well above the build's epoch, so rule (1) is satisfied + /// and the hold is demonstrably what retained it. + const RootNamespace ns_b{"00/bb@cas@"}; + const ManifestRef ref_b = ref(5, 0xB1); + casAdmitRecoverableEntry(*backend, layout, ns_b); + writeManifestRaw(*backend, layout, ns_b, ref_b, {blobEntryFor("b", DB::UInt128(2))}); + const RefHold hold{.reason = HoldReason::BodyUndecodable, + .offending_position = RefTxnId{kBuildEpoch + 1, 9}, + .retry_count = 1, .next_retry_round = 4}; + seedFoldCursorForTest(*backend, layout, ns_b, RefTxnId{kBuildEpoch + 1, 8}, hold); + + setWatermarkMinActive(*backend, layout, kServerRoot, kBuildEpoch, /*min_active*/6); + + const ManifestSweepResult result = sweepManifestCursorPageForTest(*store, "", /*list_budget*/100, /*delete_budget*/10); + + EXPECT_EQ(result.deleted, 0u); + EXPECT_EQ(result.retained_unconsumed_seal, 1u) << "namespace A's cursor is inside its build's epoch"; + EXPECT_EQ(result.retained_hold, 1u) << "namespace B is held"; + EXPECT_EQ(result.retained_no_coverage, 0u); + EXPECT_EQ(result.retained_tail_removal, 0u); + EXPECT_GE(result.skipped, 2u) << "both retentions are also ordinary skips"; + + /// The rollup an operator reads. The two classes tie at one each and the tie resolves by enum + /// order, which is what keeps an unchanged pool reporting an unchanged verdict pass after pass. + const auto top = result.topRetainReason(); + EXPECT_EQ(top.second, 1u); + EXPECT_EQ(top.first, SweepRetainClass::Hold); + EXPECT_EQ(String{sweepRetainClassName(SweepRetainClass::UnconsumedSeal)}, "unconsumed_seal"); + + /// A page with no candidates reports nothing: the counters carry the premise's own refusals, not + /// ordinary skips. + auto empty_backend = std::make_shared(); + auto empty_store = openPoolForTest(empty_backend); + const ManifestSweepResult nothing = + sweepManifestCursorPageForTest(*empty_store, "", /*list_budget*/100, /*delete_budget*/10); + EXPECT_EQ(nothing.topRetainReason().first, SweepRetainClass::None); + EXPECT_EQ(nothing.topRetainReason().second, 0u); +} + +/// STAGE B SEAM (registers R2/R3). The premise is the per-manifest SAFETY floor and nothing else: it +/// says when a body may go, never who nominates it or when. The sweep's own rework -- the writer duty +/// queue that reclaims its own live epoch's debris, and the nomination path -- attaches here, and must +/// satisfy this predicate rather than replace it. + +/// Uncertainty rule, budget arm. A candidate the page never DECIDED on -- the delete budget ran out +/// before it -- is retained, and the cursor must not step over it: the sweep's cursor is a +/// cleanup-progress hint whose skipped range is not revisited until a full wrap, so advancing past an +/// undecided candidate converts "retained this round" into "unexamined for a whole cycle". +TEST(CASSweepDeletionPremise, AnExhaustedDeleteBudgetRetainsAndDoesNotStepOverTheRest) +{ + OrphanFixture f; + const ManifestRef second = ref(5, 0xAC); + const ManifestRef third = ref(5, 0xAD); + writeManifestRaw(*f.backend, f.store->layout(), f.ns, second, {blobEntryFor("b", DB::UInt128(2))}); + writeManifestRaw(*f.backend, f.store->layout(), f.ns, third, {blobEntryFor("c", DB::UInt128(3))}); + seedFoldCursorForTest(*f.backend, f.store->layout(), f.ns, RefTxnId{kBuildEpoch + 1, 1}); + + const ManifestSweepResult first = sweepManifestCursorPageForTest(*f.store, "", /*list_budget*/100, /*delete_budget*/1); + EXPECT_EQ(first.deleted, 1u); + EXPECT_FALSE(first.wrapped) + << "the page stopped on an exhausted budget with candidates left, so it did not reach the end"; + ASSERT_FALSE(first.next_cursor.empty()); + + /// Resume: the two survivors are still ahead of the cursor, so a budgeted continuation reaches them. + const ManifestSweepResult second_page = + sweepManifestCursorPageForTest(*f.store, first.next_cursor, /*list_budget*/100, /*delete_budget*/10); + EXPECT_EQ(second_page.deleted, 2u) + << "the cursor must not have stepped over the candidates the exhausted budget left undecided"; + + size_t surviving = 0; + for (const ManifestRef & r : {f.orphan, second, third}) + if (f.backend->head(f.store->layout().manifestKey(ManifestId{f.ns, r})).exists) + ++surviving; + EXPECT_EQ(surviving, 0u); +} + +/// MANDATORY liveness proof: a namespace whose committed-tail recovery walk +/// can never finish within one round's `sweep_recovery_op_budget` must not wedge the cursor page for +/// every subsequent round. Six eligible candidates share ONE namespace whose tail is ~200 unrelated +/// committed transactions above the fold cursor -- far more than the tiny per-round recovery-op budget +/// can traverse -- so `activeManifestKeys` reports `recovery_incomplete` on every attempt, every one of +/// this namespace's candidates is retained (never nominated, never deleted), yet the page still DECIDES +/// them (a retained candidate is a decision) and the cursor advances across pages until the whole +/// keyspace is covered. +TEST(CASSweepDeletionPremise, RecoveryWorkBudgetRetainsAndConvergesWithoutWedgingTheCursor) +{ + auto backend = std::make_shared(); + auto store = openPoolForTest(backend); + const Layout & layout = store->layout(); + const RootNamespace ns{"00/aa@cas@"}; + /// `casAdmitEntry` (bare, no `_ckpt`) rather than `casAdmitRecoverableEntry` (which pre-seeds an + /// EMPTY `_ckpt`, `committed_through = nullopt`): the tail below is built entirely from real + /// `publishCommittedTransition` calls, whose first call needs `readCkpt` to see NOTHING yet so it + /// takes the fresh-`_ckpt` `putIfAbsent` path instead of `advanceRecoverableCkptForRawFixture`'s + /// monotonic-advance-from-existing-value path (which throws on a null `committed_through`). + casAdmitEntry(*backend, layout, ns); + setWatermarkMinActive(*backend, layout, kServerRoot, kBuildEpoch, /*min_active*/1000); + + /// Six orphan candidates, all eligible (build_sequence << min_active), none owned by any ref. + constexpr int kCandidates = 6; + for (int i = 1; i <= kCandidates; ++i) + writeManifestRaw(*backend, layout, ns, ref(i, 1), + {blobEntryFor("c" + std::to_string(i), DB::UInt128(static_cast(i)))}); + + /// A committed tail of ~200 UNRELATED transactions above the fold cursor. None of these need a + /// manifest body of their own -- the recovery walk only GETs and decodes the ref-log transactions, + /// never the bodies they name. + constexpr int kTailSize = 200; + for (int i = 0; i < kTailSize; ++i) + publishCommittedTransition(*backend, layout, ns, "tail" + std::to_string(i), std::nullopt, ref(2000 + i, 1)); + seedFoldCursorForTest(*backend, layout, ns, RefTxnId{kBuildEpoch, 1}); + + uint64_t total_retained_work_budget = 0; + uint64_t total_skipped = 0; + uint64_t total_deleted = 0; + String cursor; + bool wrapped = false; + int pages = 0; + for (; pages < 10 && !wrapped; ++pages) + { + /// A FRESH budget every page, exactly like production's one-instance-per-round contract -- + /// the same namespace's recovery walk re-attempts and re-exhausts every time, by design (a + /// pathological namespace does not get to starve every OTHER page of budget forever). + GcRoundWorkBudget budget; + budget.max_sweep_recovery_ops = 5; /// far below the ~200-record tail + const ManifestSweepResult result = sweepManifestCursorPageForTest(*store, cursor, /*list_budget*/3, /*delete_budget*/10, &budget); + + /// LIVENESS: every page decides at least one candidate (a retained one counts) or wraps. + EXPECT_TRUE(result.skipped > 0 || result.deleted > 0 || result.wrapped) + << "page " << pages << " decided nothing and did not wrap -- a wedge"; + /// Every namespace this page touches hits the recovery-op-exhausted cause AT LEAST once -- + /// only the FIRST candidate of an errored namespace on a page carries the specific retain-class + /// counter (the SAME pre-existing convention `retained_no_coverage`/`retained_hold` already + /// use); every other candidate of that namespace still lands in the generic `skipped` tally. + EXPECT_GE(result.retained_work_budget, 1u) + << "page " << pages << " never attributed a candidate to the recovery-budget cause"; + + total_retained_work_budget += result.retained_work_budget; + total_skipped += result.skipped; + total_deleted += result.deleted; + wrapped = result.wrapped; + ASSERT_NE(cursor, result.next_cursor) << "page " << pages << " made no cursor progress"; + cursor = result.next_cursor; + } + + EXPECT_TRUE(wrapped) << "the whole small keyspace must be fully covered well within 10 pages"; + EXPECT_EQ(total_deleted, 0u) << "the pathological namespace's candidates are never safe to nominate"; + EXPECT_EQ(total_skipped, static_cast(kCandidates)) + << "every one of the six candidates was decided (skipped), none silently dropped from the page"; + EXPECT_GE(total_retained_work_budget, 1u); + for (int i = 1; i <= kCandidates; ++i) + EXPECT_TRUE(backend->head(layout.manifestKey(ManifestId{ns, ref(i, 1)})).exists) + << "candidate " << i << " must survive: it was never proven safe to delete"; +} + +/// The per-page NAMESPACE cap. Two otherwise-independently-deletable +/// namespaces share one page; with `max_sweep_namespaces = 1`, only the first namespace this page +/// touches gets a protection view built at all -- the second is retained under the work-budget cause, +/// never given a partial or best-effort view. +TEST(CASSweepDeletionPremise, NamespaceWorkBudgetCapsDistinctViewsPerPage) +{ + auto backend = std::make_shared(); + auto store = openPoolForTest(backend); + const Layout & layout = store->layout(); + + const RootNamespace ns_a{"00/aa@cas@"}; + const RootNamespace ns_b{"00/bb@cas@"}; + const ManifestRef ref_a = ref(5, 0xA1); + const ManifestRef ref_b = ref(5, 0xB1); + casAdmitRecoverableEntry(*backend, layout, ns_a); + casAdmitRecoverableEntry(*backend, layout, ns_b); + writeManifestRaw(*backend, layout, ns_a, ref_a, {blobEntryFor("a", DB::UInt128(1))}); + writeManifestRaw(*backend, layout, ns_b, ref_b, {blobEntryFor("b", DB::UInt128(2))}); + /// Both namespaces satisfy rule (1) (cursor past the build's own epoch) and have no committed tail + /// at all (`_ckpt.committed_through` unset), so absent the namespace cap BOTH would delete. + seedFoldCursorForTest(*backend, layout, ns_a, RefTxnId{kBuildEpoch + 1, 1}); + seedFoldCursorForTest(*backend, layout, ns_b, RefTxnId{kBuildEpoch + 1, 1}); + setWatermarkMinActive(*backend, layout, kServerRoot, kBuildEpoch, /*min_active*/6); + + GcRoundWorkBudget budget; + budget.max_sweep_namespaces = 1; + + const ManifestSweepResult result = sweepManifestCursorPageForTest(*store, "", /*list_budget*/100, /*delete_budget*/10, &budget); + + EXPECT_EQ(result.deleted, 1u) << "exactly one namespace's view could be built this page"; + EXPECT_EQ(result.retained_work_budget, 1u) + << "the other namespace's candidate is retained, never decided from a missing view"; + EXPECT_EQ(budget.sweep_namespaces_used, 1u); + + size_t surviving = 0; + for (const auto & p : std::vector>{{ns_a, ref_a}, {ns_b, ref_b}}) + if (backend->head(layout.manifestKey(ManifestId{p.first, p.second})).exists) + ++surviving; + EXPECT_EQ(surviving, 1u) << "exactly one candidate remains -- the one whose namespace had no budget left"; +} diff --git a/src/Disks/tests/gtest_cas_text_format.cpp b/src/Disks/tests/gtest_cas_text_format.cpp new file mode 100644 index 000000000000..4371afb3f9e8 --- /dev/null +++ b/src/Disks/tests/gtest_cas_text_format.cpp @@ -0,0 +1,292 @@ +#include +#include +#include +#include +#include +#include +#include +#include +#include + +using namespace DB::Cas; + +namespace DB::ErrorCodes +{ + extern const int CORRUPTED_DATA; + extern const int UNKNOWN_FORMAT_VERSION; +} + +namespace +{ +/// Run `f` and require a DB::Exception with exactly `code`. +template +void expectCode(int code, F && f) +{ + try + { + f(); + FAIL() << "expected exception code " << code; + } + catch (const DB::Exception & e) + { + EXPECT_EQ(e.code(), code); + } +} +} + +/// ---- Task 2: FormatId entries for refsnaplog / blob meta / heartbeat ---- + +TEST(CASFormatIds, NewIdsExistWithFrozenValues) +{ + EXPECT_EQ(static_cast(FormatId::RefLog), 19); + EXPECT_EQ(static_cast(FormatId::RefSnapshot), 20); + EXPECT_EQ(static_cast(FormatId::BlobMeta), 21); + EXPECT_EQ(static_cast(FormatId::GcHeartbeat), 22); + /// Every id, old and new, has a change-point ladder (BASELINE until a real bump). + for (auto id : {FormatId::RefLog, FormatId::RefSnapshot, FormatId::BlobMeta, FormatId::GcHeartbeat}) + EXPECT_FALSE(changePoints(id).empty()); +} + +/// ---- Task 3: per-format traits registry ---- + +TEST(CASFormatTraits, CompleteUniqueAndGated) +{ + /// Completeness: every FormatId except the reserved Roster has traits. + const FormatId all[] = {FormatId::Blob, FormatId::GcState, FormatId::PoolMeta, + FormatId::GcOutcomes, FormatId::PartManifest, FormatId::RunFile, + FormatId::FoldSeal, FormatId::Owner, FormatId::ServerEpoch, FormatId::MountLease, + FormatId::RefLog, FormatId::RefSnapshot, FormatId::BlobMeta, FormatId::GcHeartbeat, + FormatId::RefCkpt, FormatId::RefCatalog, FormatId::GcMaintenanceState}; + std::set types; + for (FormatId id : all) + { + const FormatTraits & t = traitsFor(id); + EXPECT_EQ(t.id, id); + EXPECT_TRUE(t.type.starts_with("cas_")) << t.type; + EXPECT_TRUE(types.insert(t.type).second) << "duplicate type " << t.type; + EXPECT_EQ(traitsForType(t.type), &t); + } + EXPECT_EQ(traitsForType("cas_nope"), nullptr); +#ifndef DEBUG_OR_SANITIZER_BUILD + /// traitsFor(Roster) throws LOGICAL_ERROR (a reserved/unreachable FormatId), which aborts the + /// whole process in debug/sanitizer builds instead of behaving like a catchable exception -- + /// CASFormatTraitsDeathTest below proves the abort positively in those builds instead. + EXPECT_THROW(traitsFor(FormatId::Roster), DB::Exception); +#endif + /// Deterministic formats are pinned raw + strict; spot-check the two. + EXPECT_EQ(traitsFor(FormatId::RunFile).compression, CompressionPolicy::PinnedRaw); + EXPECT_EQ(traitsFor(FormatId::RunFile).strictness, KeyStrictness::Strict); + EXPECT_EQ(traitsFor(FormatId::FoldSeal).compression, CompressionPolicy::PinnedRaw); + EXPECT_EQ(traitsFor(FormatId::FoldSeal).strictness, KeyStrictness::Strict); + /// .zst key suffix is exactly the Always set (can-grow-large types). + EXPECT_EQ(storedSuffix(FormatId::RefSnapshot), ".zst"); + EXPECT_EQ(storedSuffix(FormatId::RefLog), ".zst"); + EXPECT_EQ(storedSuffix(FormatId::PartManifest), ".zst"); + EXPECT_EQ(storedSuffix(FormatId::GcOutcomes), ".zst"); + EXPECT_EQ(storedSuffix(FormatId::PoolMeta), ""); + EXPECT_EQ(storedSuffix(FormatId::FoldSeal), ""); + EXPECT_EQ(storedSuffix(FormatId::RunFile), ""); +} + +#if defined(DEBUG_OR_SANITIZER_BUILD) +/// Debug/sanitizer-build counterpart to CompleteUniqueAndGated's Roster check: LOGICAL_ERROR aborts +/// the process here instead of throwing a catchable exception, so the check must be a death test +/// (same pattern as CASBlobDigestDeathTest in gtest_cas_blob_digest.cpp). +TEST(CASFormatTraitsDeathTest, TraitsForRosterAborts) +{ + EXPECT_DEATH({ (void)traitsFor(FormatId::Roster); }, ""); +} +#endif + +/// ---- Task 4: JSON micro-vocabulary + JsonObjectReader ---- + +TEST(CASJsonVocab, WriteAndReadBack) +{ + CasJsonWriter out; + bool first = true; + writeKey(out, "tag", first); + writeHex128Value(out, hexToU128("000102030405060708090a0b0c0d0e0f")); + writeKey(out, "seq", first); + writeU64StringValue(out, 18446744073709551615ULL); + writeKey(out, "n", first); + writeIntText(7, out); + writeKey(out, "ref", first); + writeStringValue(out, "t-1/all_1_2_0\n\"quoted\""); + closeObject(out, first); + const String rendered = std::move(out).take(); + EXPECT_EQ(rendered.substr(0, 45), R"({"tag":"000102030405060708090a0b0c0d0e0f","se)"); + + DB::ReadBufferFromMemory in(rendered.data(), rendered.size()); + JsonObjectReader r(in, KeyStrictness::Strict, "test"); + String key; + ASSERT_TRUE(r.nextKey(key)); EXPECT_EQ(key, "tag"); + EXPECT_EQ(r.readHex128(), hexToU128("000102030405060708090a0b0c0d0e0f")); + ASSERT_TRUE(r.nextKey(key)); EXPECT_EQ(key, "seq"); + EXPECT_EQ(r.readU64String(), 18446744073709551615ULL); + ASSERT_TRUE(r.nextKey(key)); EXPECT_EQ(key, "n"); + EXPECT_EQ(r.readU64Number(), 7u); + ASSERT_TRUE(r.nextKey(key)); EXPECT_EQ(key, "ref"); + EXPECT_EQ(r.readString(), "t-1/all_1_2_0\n\"quoted\""); + EXPECT_FALSE(r.nextKey(key)); +} + +TEST(CASJsonVocab, FailClosedRules) +{ + auto reader = [](std::string_view text, KeyStrictness s, auto && consume) + { + DB::ReadBufferFromMemory in(text.data(), text.size()); + JsonObjectReader r(in, s, "test"); + consume(r); + }; + /// duplicate key + expectCode(DB::ErrorCodes::CORRUPTED_DATA, [&] { reader(R"({"a":1,"a":2})", KeyStrictness::Tolerant, [](auto & r) + { + String k; + while (r.nextKey(k)) r.readU64Number(); + }); }); + /// unknown key: Tolerant skips (nested value), Strict rejects + reader(R"({"zz":{"deep":[1,2]},"n":5})", KeyStrictness::Tolerant, [](auto & r) + { + String k; + ASSERT_TRUE(r.nextKey(k)); r.skipUnknown(k); + ASSERT_TRUE(r.nextKey(k)); EXPECT_EQ(r.readU64Number(), 5u); + EXPECT_FALSE(r.nextKey(k)); + }); + expectCode(DB::ErrorCodes::CORRUPTED_DATA, [&] { reader(R"({"zz":1})", KeyStrictness::Strict, [](auto & r) + { + String k; + ASSERT_TRUE(r.nextKey(k)); r.skipUnknown(k); + }); }); + /// critical key fails closed regardless of strictness + expectCode(DB::ErrorCodes::UNKNOWN_FORMAT_VERSION, [&] { reader(R"({"!x":1})", KeyStrictness::Tolerant, [](auto & r) + { + String k; + ASSERT_TRUE(r.nextKey(k)); r.skipUnknown(k); + }); }); + /// whitespace is not canonical + expectCode(DB::ErrorCodes::CORRUPTED_DATA, [&] { reader(R"({ "a":1})", KeyStrictness::Tolerant, [](auto & r) + { + String k; + r.nextKey(k); + }); }); + /// bad hex width / junk in u64 string + expectCode(DB::ErrorCodes::CORRUPTED_DATA, [&] { reader(R"({"h":"0102"})", KeyStrictness::Tolerant, [](auto & r) + { + String k; + r.nextKey(k); r.readHex128(); + }); }); + expectCode(DB::ErrorCodes::CORRUPTED_DATA, [&] { reader(R"({"s":"12x"})", KeyStrictness::Tolerant, [](auto & r) + { + String k; + r.nextKey(k); r.readU64String(); + }); }); +} + +/// ---- Task 5: header line, trailer line, readLine ---- + +TEST(CASTextHeader, WriteExpectSniffGate) +{ + CasJsonWriter out; + writeHeaderLine(out, FormatId::PoolMeta); + const String rendered = std::move(out).take(); + EXPECT_EQ(rendered, fmt::format("{{\"type\":\"cas_pool_meta\",\"v\":{}}}\n", currentCompatibilityVersion())); + + DB::ReadBufferFromMemory in(rendered.data(), rendered.size()); + const TextHeader h = expectHeaderLine(in, FormatId::PoolMeta); + EXPECT_EQ(h.type, "cas_pool_meta"); + EXPECT_EQ(h.v, currentCompatibilityVersion()); + EXPECT_TRUE(in.eof()); + + const auto sniffed = sniffHeaderLine(rendered); + ASSERT_TRUE(sniffed.has_value()); + EXPECT_EQ(sniffed->type, "cas_pool_meta"); + EXPECT_FALSE(sniffHeaderLine("PAR1 not a cas object").has_value()); + + /// wrong type -> CORRUPTED_DATA; future v -> UNKNOWN_FORMAT_VERSION + /// `v:3` is deliberate and must NOT follow a future `G_BUILD` bump: any version <= G_BUILD passes + /// the header gate, which is the point — the BODY is what has to fail here. + const String wrong = "{\"type\":\"cas_owner\",\"v\":3}\n"; + DB::ReadBufferFromMemory in2(wrong.data(), wrong.size()); + expectCode(DB::ErrorCodes::CORRUPTED_DATA, [&] { expectHeaderLine(in2, FormatId::PoolMeta); }); + const String future = fmt::format("{{\"type\":\"cas_pool_meta\",\"v\":{}}}\n", currentCompatibilityVersion() + 1); + DB::ReadBufferFromMemory in3(future.data(), future.size()); + expectCode(DB::ErrorCodes::UNKNOWN_FORMAT_VERSION, [&] { expectHeaderLine(in3, FormatId::PoolMeta); }); + + const String out_of_range = "{\"type\":\"cas_pool_meta\",\"v\":4294967299}\n"; + DB::ReadBufferFromMemory in4(out_of_range.data(), out_of_range.size()); + expectCode(DB::ErrorCodes::CORRUPTED_DATA, [&] { expectHeaderLine(in4, FormatId::PoolMeta); }); +} + +TEST(CASTextLines, ReadLineAndTrailer) +{ + CasJsonWriter out; + writeTrailerLine(out, 42); + EXPECT_EQ(std::move(out).take(), "{\"n\":42}\n"); + + const String two = "abc\ndef\n"; + DB::ReadBufferFromMemory in(two.data(), two.size()); + EXPECT_EQ(readLine(in, 16, "test"), "abc"); + EXPECT_EQ(readLine(in, 16, "test"), "def"); + /// missing terminator and over-cap both fail closed + const String noterm = "abc"; + DB::ReadBufferFromMemory in2(noterm.data(), noterm.size()); + expectCode(DB::ErrorCodes::CORRUPTED_DATA, [&] { readLine(in2, 16, "test"); }); + DB::ReadBufferFromMemory in3(two.data(), two.size()); + expectCode(DB::ErrorCodes::CORRUPTED_DATA, [&] { readLine(in3, 2, "test"); }); +} + +/// ---- Task 6: the zstd arm ---- + +TEST(CASZstdArm, SealOpenPolicyAndCaps) +{ + /// Always types compress regardless of size (no threshold — the .zst key must be + /// constructible without knowing the body); a raw body is still readable (repair path). + /// `v:3` here is NOT the "any version <= G_BUILD passes" case the other negative bodies rely on: + /// `cas_ref_snap`'s own `changePoints` floor is generation 4, so a generation-3 ref snapshot is not + /// readable by this build in principle. It passes the header gate only because nothing consults + /// `changePoints` at decode time yet -- the gate is `v > G_BUILD` alone. Once a per-class floor is + /// wired in, this literal must move to `G_BUILD`; the test's subject is the truncated BODY, not the + /// version. + const String small = "{\"type\":\"cas_ref_snap\",\"v\":3}\n{}\n"; + const String sealed_small = sealObject(FormatId::RefSnapshot, small); + ASSERT_TRUE(looksZstd(sealed_small)); + EXPECT_EQ(openObject(FormatId::RefSnapshot, sealed_small), small); + EXPECT_EQ(openObject(FormatId::RefSnapshot, small), small); + + String big = "{\"type\":\"cas_ref_snap\",\"v\":3}\n{\"pad\":\""; + big += String(8192, 'a'); + big += "\"}\n"; + const String sealed = sealObject(FormatId::RefSnapshot, big); + ASSERT_TRUE(looksZstd(sealed)); + EXPECT_LT(sealed.size(), big.size()); + EXPECT_EQ(openObject(FormatId::RefSnapshot, sealed), big); + + /// Never and PinnedRaw formats never compress on write and reject compressed input on read. + EXPECT_EQ(sealObject(FormatId::FoldSeal, big), big); + EXPECT_EQ(sealObject(FormatId::PoolMeta, big), big); + expectCode(DB::ErrorCodes::CORRUPTED_DATA, [&] { openObject(FormatId::FoldSeal, sealed); }); + expectCode(DB::ErrorCodes::CORRUPTED_DATA, [&] { openObject(FormatId::PoolMeta, sealed); }); + + /// Declared content size over the cap fails BEFORE the output allocation: 65 MiB of text + /// against RefSnapshot's 64 MiB cap (compresses to ~nothing, so the test is cheap on disk + /// bytes; the 65 MiB source string is the only big allocation). + const String over(65 * 1024 * 1024, 'b'); + const String sealed_over = sealObject(FormatId::RefSnapshot, over); + ASSERT_TRUE(looksZstd(sealed_over)); + expectCode(DB::ErrorCodes::CORRUPTED_DATA, [&] { openObject(FormatId::RefSnapshot, sealed_over); }); + + /// A flipped byte inside the frame is caught by zstd (frame checksum is on). + String corrupted = sealed; + corrupted[corrupted.size() / 2] ^= 0x01; + expectCode(DB::ErrorCodes::CORRUPTED_DATA, [&] { openObject(FormatId::RefSnapshot, corrupted); }); +} + +TEST(CASTextValueEscaping, ForwardSlashPinnedUnescaped) +{ + /// Goes RED if the global escape_forward_slashes default ever leaks back into CAS string values. + /// CAS values are dense with '/' (ref-paths, fold-seal keys); their bytes must be CAS-owned so + /// cas_fold_seal byte-determinism and every golden text file are independent of the global default. + CasJsonWriter out; + writeStringValue(out, "ns/shard/all_1_2_0"); + EXPECT_EQ(std::move(out).take(), "\"ns/shard/all_1_2_0\""); +} diff --git a/src/Disks/tests/gtest_cas_truncate_reclaim.cpp b/src/Disks/tests/gtest_cas_truncate_reclaim.cpp new file mode 100644 index 000000000000..0a76c338cdb5 --- /dev/null +++ b/src/Disks/tests/gtest_cas_truncate_reclaim.cpp @@ -0,0 +1,282 @@ +#include +#include +#include +#include +#include +#include +#include + +#include +#include + +/// B140 regression guard. The soak's Phase-1 sync run did a `TRUNCATE TABLE` at op 450 and then +/// observed fsck `unreachable` STUCK above zero (1751) while the incremental GC reported +/// `candidates=0` — i.e. the GC believed it was done while orphaned blobs remained. This file +/// reproduces the soak shape at the CORE level (no server, no docker): publish many parts that +/// SHARE blobs (dedup), interleave regular GC rounds with the publishes (so trees get expanded +/// into the durable snap exactly as they would during a steady-state insert workload), then +/// perform the SAME removal a Replicated TRUNCATE issues — a per-ref `dropRef` for every part — +/// and drive the GC to a fixpoint. The invariant under test: after the drops are folded and the +/// cascade runs, `runFsck().unreachable` reaches 0 (every shared blob is reclaimed). +/// +/// A `dropNamespace` variant is included as well (the path `removeRecursive` takes for a whole +/// table dir, e.g. DROP TABLE): it journals one Remove per former ref, so the cascade should fold +/// it identically. + +using namespace DB::Cas; +using DB::Cas::tests::idOf; +using DB::Cas::tests::u128Of; + +namespace +{ + +PoolPtr openTestPool(std::shared_ptr & out_backend) +{ + out_backend = std::make_shared(); + return Pool::open(out_backend, PoolConfig{.pool_prefix = "p", .server_root_id = "test"}); +} + +/// Publish one part `ref` with TWO content files whose payloads are passed in. Identical payloads +/// across parts dedup to the SAME blob object (the soak's dedup_ratio ~3.8 comes from exactly this +/// sharing). Returns the manifest id. +ManifestId publishPart2( + const PoolPtr & s, const String & ns, const String & ref, + const String & payload_a, const String & payload_b) +{ + const RootNamespace nsr{ns}; + PartWriteInfo info; + info.intended_ref = ns + "/" + ref; + auto build = s->beginPartWrite(info); + + ManifestEntry ea; + ea.path = "data.bin"; + ea.placement = EntryPlacement::Blob; + ea.ref = DB::Cas::BlobRef{DB::Cas::BlobHashAlgo::CityHash128, DB::Cas::BlobDigest::fromU128(u128Of(payload_a))}; + + ea.blob_size = payload_a.size(); + + ManifestEntry eb; + eb.path = "data.cmrk3"; + eb.placement = EntryPlacement::Blob; + eb.ref = DB::Cas::BlobRef{DB::Cas::BlobHashAlgo::CityHash128, DB::Cas::BlobDigest::fromU128(u128Of(payload_b))}; + + eb.blob_size = payload_b.size(); + + /// Wiring order (EDGE-BEFORE-OBSERVE): stageManifest -> precommitAdd -> putBlob -> promote. + const ManifestId id = build->stageManifest({ea, eb}); + build->precommitAdd(nsr, ref, id); + build->putBlob(idOf(payload_a), BlobSource::fromString(payload_a)); + build->putBlob(idOf(payload_b), BlobSource::fromString(payload_b)); + build->promote(nsr, ref, build->buildId(), id); + return id; +} + +/// Whether the CURRENT retired list (any gc-shard) still holds an entry — the ack-floor deletion pipeline +/// (condemn -> graduate -> delete) is in flight while this is true. +bool anyRetiredPending(const PoolPtr & s) +{ + /// Retired-in-snapshot (T4): condemned state rides the adopted fold seal's kCondemned rows, not a + /// separate retired list — reconstruct the in-flight set from the seal. + return DB::Cas::tests::anyCondemnedInSeal(s->backend(), s->layout()); +} + +/// Run regular GC rounds until a fixpoint over the ACK-FLOOR round. A condemned blob is deleted only a +/// few rounds after its removal folds (condemn -> graduate once the ack floor passes it -> delete), so the +/// loop advances the store's own mount ack after each round (`renewWatermarkOnce` runs the beat) and stays +/// alive while ANY work counter is nonzero OR the current retired list still holds an in-flight entry. +size_t runGcToFixpoint(const PoolPtr & s, Gc & gc, size_t max_rounds = 64) +{ + size_t rounds = 0; + for (; rounds < max_rounds; ++rounds) + { + const RoundReport rep = DB::Cas::tests::runRegularRoundReclaiming(gc); + if (!rep.acquired_lease) + continue; + s->renewWatermarkOnce(); + const bool no_work = rep.candidates == 0 && rep.deleted == 0 && rep.absent == 0 + && rep.replaced == 0 && rep.spared == 0; + if (no_work && !anyRetiredPending(s)) + break; + } + return rounds; +} + +} + +/// The faithful soak repro: many parts sharing blobs, GC interleaved with the publishes, then a +/// per-ref drop of EVERY ref (Replicated TRUNCATE), then GC to a fixpoint. fsck.unreachable must +/// reach 0 — no orphaned blob may survive. +TEST(CASTruncateReclaim, PerRefDropOfSharedBlobsReclaimsToZero) +{ + std::shared_ptr b; + auto s = openTestPool(b); + const RootNamespace ns{"srv1/tbl"}; + + constexpr int N = 32; + + /// Publish N parts. Payloads are chosen so blobs are SHARED across parts: data.bin cycles + /// through 8 distinct contents, data.cmrk3 through 4 — heavy dedup, like the soak. + std::vector refs; + for (int i = 0; i < N; ++i) + { + const String ref = "all_" + std::to_string(i) + "_" + std::to_string(i) + "_0"; + refs.push_back(ref); + const String pa = "data-" + std::to_string(i % 8); + const String pb = "mark-" + std::to_string(i % 4); + publishPart2(s, ns.string(), ref, pa, pb); + + /// Interleave a GC round every few publishes, so the live trees get EXPANDED into the + /// durable snap during the insert phase (steady-state GC, as in the soak). + if (i % 5 == 4) + { + Gc gc(s, hexToU128("00000000000000000000000000000001")); + DB::Cas::tests::runRegularRoundReclaiming(gc); + } + } + + /// Steady-state GC has nothing to reclaim while the refs are live. + { + Gc gc(s, hexToU128("00000000000000000000000000000001")); + runGcToFixpoint(s, gc); + const FsckReport before = runFsck(*s, /*detail=*/false); + EXPECT_EQ(before.unreachable, 0u) << "live pool must have no unreachable debris"; + EXPECT_EQ(before.dangling, 0u); + EXPECT_GT(before.reachable, 0u); + } + + /// TRUNCATE: a Replicated TRUNCATE removes each part dir, which routes to dropRef per ref. + for (const String & ref : refs) + s->dropRef(ns, ref); + + /// Every publishing build finished; advance the durable watermark floor past their seqs so the + /// Task 10 build-watermark guard no longer spares the now-dropped objects (production does this + /// via the background renewer ~2s; here the renewer is off, so drive it explicitly). + s->renewWatermarkOnce(); + + /// Drive GC to a fixpoint and require full reclamation — this is the B140 assertion. + { + Gc gc(s, hexToU128("00000000000000000000000000000001")); + const size_t rounds = runGcToFixpoint(s, gc); + const FsckReport after = runFsck(*s, /*detail=*/false); + EXPECT_EQ(after.dangling, 0u) << "TRUNCATE must never lose a reachable object"; + EXPECT_EQ(after.unreachable, 0u) + << "B140: orphaned blobs survived TRUNCATE after " << rounds + << " GC rounds (reachable=" << after.reachable + << ", unreachable=" << after.unreachable << ")"; + EXPECT_EQ(after.reachable, 0u) << "no refs remain, so nothing should be reachable"; + } +} + +/// Mirrors the soak exactly: TRUNCATE at "op 450" (drop every live ref), then CONTINUE inserting +/// (the soak's ops 451..599 had min_op=451) while the GC keeps running, then a final drive to a +/// fixpoint. The post-truncate inserts must not stall reclamation of the pre-truncate orphans. +/// Also asserts a TIGHT bound on the number of rounds reclamation needs (the soak's 180s budget at +/// gc_interval=30s only buys ~6 rounds, so the core must reach a fixpoint well inside that). +TEST(CASTruncateReclaim, TruncateThenKeepInsertingStillReclaims) +{ + std::shared_ptr b; + auto s = openTestPool(b); + const RootNamespace ns{"srv1/tbl"}; + + /// Pre-truncate generation (the soak's ops < 451). + std::vector pre_refs; + for (int i = 0; i < 24; ++i) + { + const String ref = "pre_" + std::to_string(i); + pre_refs.push_back(ref); + publishPart2(s, ns.string(), ref, "p-data-" + std::to_string(i % 6), "p-mark-" + std::to_string(i % 3)); + if (i % 5 == 4) + { + Gc gc(s, hexToU128("00000000000000000000000000000001")); + DB::Cas::tests::runRegularRoundReclaiming(gc); + } + } + + /// TRUNCATE: drop every pre-truncate ref (per-ref dropRef). + for (const String & ref : pre_refs) + s->dropRef(ns, ref); + + /// Continue inserting AFTER the truncate (the soak's ops 451..599), interleaving GC rounds. + for (int i = 0; i < 24; ++i) + { + publishPart2(s, ns.string(), "post_" + std::to_string(i), + "q-data-" + std::to_string(i % 6), "q-mark-" + std::to_string(i % 3)); + if (i % 5 == 4) + { + Gc gc(s, hexToU128("00000000000000000000000000000001")); + DB::Cas::tests::runRegularRoundReclaiming(gc); + } + } + + /// All publishing builds finished; advance the durable watermark floor past their seqs so the + /// Task 10 build-watermark guard no longer spares the dropped objects (the background renewer is + /// off in this test, so drive it explicitly — production renews ~2s off the write path). + s->renewWatermarkOnce(); + + /// Drive to a fixpoint. unreachable must reach 0 (the pre-truncate orphans are gone) while the + /// post-truncate refs stay reachable. + Gc gc(s, hexToU128("00000000000000000000000000000001")); + const size_t rounds = runGcToFixpoint(s, gc); + const FsckReport after = runFsck(*s, /*detail=*/false); + EXPECT_EQ(after.dangling, 0u); + EXPECT_EQ(after.unreachable, 0u) + << "B140: pre-truncate orphans survived after " << rounds << " GC rounds"; + EXPECT_GT(after.reachable, 0u) << "post-truncate refs must stay reachable"; + /// Round bound: the ack-floor pipeline adds a bounded, constant number of rounds over the old + /// fold+delete (condemn -> graduate once the ack floor passes -> delete, with the ack kept current + /// each round). The dead subgraph still drains in a small, constant number of rounds — not O(orphans). + EXPECT_LE(rounds, 8u) << "reclamation took too many rounds (ack-floor pipeline is a small constant)"; +} + +/// The DROP TABLE path: removeRecursive of a table dir calls dropNamespace, which journals one +/// Remove per former ref. Same reclamation invariant. +TEST(CASTruncateReclaim, DropNamespaceLeavesSharedBlobDebrisForPerpetualSweep) +{ + std::shared_ptr b; + auto s = openTestPool(b); + const RootNamespace ns{"srv1/tbl"}; + + constexpr int N = 32; + for (int i = 0; i < N; ++i) + { + const String ref = "all_" + std::to_string(i) + "_" + std::to_string(i) + "_0"; + const String pa = "data-" + std::to_string(i % 8); + const String pb = "mark-" + std::to_string(i % 4); + publishPart2(s, ns.string(), ref, pa, pb); + if (i % 5 == 4) + { + Gc gc(s, hexToU128("00000000000000000000000000000001")); + DB::Cas::tests::runRegularRoundReclaiming(gc); + } + } + + { + Gc gc(s, hexToU128("00000000000000000000000000000001")); + runGcToFixpoint(s, gc); + } + + /// DROP TABLE: the whole namespace is tombstoned at once (one Remove per ref in the journal). + s->dropNamespace(ns); + + /// Every publishing build finished; advance the durable watermark floor past their seqs so the + /// Task 10 build-watermark guard no longer spares the dropped objects (renewer off here). + s->renewWatermarkOnce(); + + { + Gc gc(s, hexToU128("00000000000000000000000000000001")); + const size_t rounds = runGcToFixpoint(s, gc); + const FsckReport after = runFsck(*s, /*detail=*/false); + EXPECT_EQ(after.dangling, 0u); + /// Removal still performs no lifecycle-specific physical cleanup -- the perpetual sweep and the + /// janitor own the orphaned bytes. What changed is that they can now FINISH: dropping the last + /// namespace leaves an authoritative catalog that decodes to zero entries, which is a positive + /// proof of no live edge rather than the vacuous 0 == 0, so the round's frontier completes and + /// the sweep is no longer suppressed on an emptied pool. + EXPECT_EQ(after.unreachable, 0u) + << "an emptied pool must drain instead of standing still; the sweep owned these blobs and " + "reclaimed them within " << rounds << " GC rounds"; + EXPECT_EQ(after.reachable, 0u); + EXPECT_FALSE(CasRefCatalog::lifeIfCataloged(s->backend(), s->layout(), ns)) + << "physical debris must not keep the logical namespace life cataloged"; + } +} diff --git a/src/Disks/tests/gtest_cas_txn_apply_ledger.cpp b/src/Disks/tests/gtest_cas_txn_apply_ledger.cpp new file mode 100644 index 000000000000..80d262854555 --- /dev/null +++ b/src/Disks/tests/gtest_cas_txn_apply_ledger.cpp @@ -0,0 +1,159 @@ +#include +#include +#include +#include +#include +#include + +using namespace DB::Cas; + +namespace +{ +BlobRef bh(uint64_t n) { return BlobRef{BlobHashAlgo::CityHash128, BlobDigest::fromU128(DB::UInt128(n))}; } +DB::UInt128 s(uint64_t n) { return DB::UInt128(n); } +} + +/// The ledger is pure round-local bookkeeping; test it directly rather than trying to fabricate a +/// lost bucket inside a real fold. The fold-side wiring is covered by the gate: every existing GC +/// test now runs with the ledger armed and would throw if a delta went missing. +TEST(CASTxnApplyLedger, HealthyRoundReportsNothingUnapplied) +{ + TxnApplyLedger ledger; + const uint32_t a = ledger.open(RootNamespace{"ns"}, RefTxnId{1, 1}); + const uint32_t b = ledger.open(RootNamespace{"ns"}, RefTxnId{1, 2}); + ledger.markProduced(a); + ledger.markCommitted(a); + ledger.markApplied(a); + ledger.markCommitted(b); /// committed but produced no blob deltas — legitimate + EXPECT_TRUE(ledger.unapplied().empty()); +} + +TEST(CASTxnApplyLedger, CommittedAndProducedButNeverAppliedIsReported) +{ + TxnApplyLedger ledger; + const uint32_t a = ledger.open(RootNamespace{"ns"}, RefTxnId{1, 1}); + ledger.markProduced(a); + ledger.markCommitted(a); + ASSERT_EQ(ledger.unapplied().size(), 1u); + EXPECT_EQ(ledger.unapplied().front(), a); +} + +TEST(CASTxnApplyLedger, ClampedTransactionIsNotReported) +{ + /// A clamped log emits deltas into the per-log staging buffer that is then DISCARDED; it is never + /// committed, so it must not be reported unapplied. + TxnApplyLedger ledger; + const uint32_t a = ledger.open(RootNamespace{"ns"}, RefTxnId{1, 1}); + ledger.markProduced(a); + EXPECT_TRUE(ledger.unapplied().empty()); +} + +/// The reducers mark `applied` by indexing the raw vector with `BlobDelta::txn_ordinal`, so the +/// ledger's own vectors must stay index-parallel with the ordinals it hands out. Pin that: the +/// ordinal is the position, and every parallel vector grows with it. +TEST(CASTxnApplyLedger, OrdinalsIndexTheParallelVectors) +{ + TxnApplyLedger ledger; + EXPECT_EQ(ledger.open(RootNamespace{"a"}, RefTxnId{1, 7}), 0u); + EXPECT_EQ(ledger.open(RootNamespace{"b"}, RefTxnId{2, 3}), 1u); + ASSERT_EQ(ledger.applied.size(), 2u); + ASSERT_EQ(ledger.produced.size(), 2u); + ASSERT_EQ(ledger.committed.size(), 2u); + ASSERT_EQ(ledger.namespaces.size(), 2u); + EXPECT_EQ(ledger.namespaces[1], "b"); + EXPECT_EQ(ledger.txns[1], (RefTxnId{2, 3})); +} + +/// PROBE B2's reach, pinned as a property rather than left to prose: a delta consumed by a reducer +/// clears its transaction, and only the transaction whose ordinal was never written stays reported. +/// This is the exact shape a delta lost in gc-shard routing produces. +TEST(CASTxnApplyLedger, OnlyTheTransactionWhoseDeltasVanishedIsReported) +{ + TxnApplyLedger ledger; + const uint32_t routed = ledger.open(RootNamespace{"ns"}, RefTxnId{1, 1}); + const uint32_t lost = ledger.open(RootNamespace{"ns"}, RefTxnId{1, 2}); + for (const uint32_t o : {routed, lost}) + { + ledger.markProduced(o); + ledger.markCommitted(o); + } + /// The reducer's own write: a raw byte at the delta's ordinal, exactly as + /// `foldDeltasIntoGeneration` performs it. + ledger.applied[routed] = 1; + + ASSERT_EQ(ledger.unapplied().size(), 1u); + EXPECT_EQ(ledger.unapplied().front(), lost); +} + +/// The reducer-side half of probe B2, proven POSITIVELY rather than by the absence of a throw. The +/// three tests above exercise the ledger's own arithmetic; this one exercises the write that +/// `foldDeltasIntoGeneration` performs inside its delta-consumption loop — the only new code on the +/// fold's hot path — and pins that a routed delta marks its ordinal while an ordinal no delta carries +/// stays unmarked. Without this the fold-side wiring would only ever be covered negatively (the gate +/// does not throw), which cannot distinguish "the probe is correct" from "the probe is inert". +TEST(CASTxnApplyLedger, ReducerMarksTheOrdinalOfEveryDeltaItConsumes) +{ + InMemoryBackend backend; + Layout layout{"pool"}; + + TxnApplyLedger ledger; + const uint32_t routed = ledger.open(RootNamespace{"ns"}, RefTxnId{1, 1}); + const uint32_t absent = ledger.open(RootNamespace{"ns"}, RefTxnId{1, 2}); + + /// Only `routed`'s transaction emitted deltas. `absent`'s ordinal is live in the ledger but no + /// delta carries it — exactly the shape a delta lost before the reducer produces. + std::vector deltas{ + {bh(1), s(1), /*remove*/false, routed}, + {bh(2), s(1), /*remove*/false, routed}, + }; + std::vector runs; + foldDeltasIntoGeneration(backend, layout, /*prior_runs*/{}, /*new_generation*/1, /*attempt*/0, + /*shard*/0, deltas, runs, + /*current_round*/0, /*condemn_round*/0, /*head_blob*/{}, /*peek_head*/{}, + /*confirm_condemned_marker*/{}, /*out_retired*/nullptr, + /*suppress_destructive*/false, &ledger.applied); + + EXPECT_EQ(ledger.applied[routed], 1) << "the reducer consumed this transaction's deltas but did " + "not mark its ordinal — probe B2 is inert"; + EXPECT_EQ(ledger.applied[absent], 0) << "an ordinal no delta carries must never be marked"; + + /// And the verdict follows from those bits: a committed+produced transaction whose deltas never + /// arrived is the one reported. + for (const uint32_t o : {routed, absent}) + { + ledger.markProduced(o); + ledger.markCommitted(o); + } + ASSERT_EQ(ledger.unapplied().size(), 1u); + EXPECT_EQ(ledger.unapplied().front(), absent); +} + +/// The reducer must mark a REMOVAL delta too. Removals are the direction that can legitimately +/// collapse to nothing inside the set merge (an unmatched `-1` changes no state and emits no row), so +/// a mark placed at run flush instead of at consumption would silently skip exactly this case and +/// report a healthy round as lossy. +TEST(CASTxnApplyLedger, ReducerMarksAnUnmatchedRemovalDelta) +{ + InMemoryBackend backend; + Layout layout{"pool"}; + + TxnApplyLedger ledger; + const uint32_t removal = ledger.open(RootNamespace{"ns"}, RefTxnId{1, 1}); + ledger.markProduced(removal); + ledger.markCommitted(removal); + + /// A `-1` for an edge no prior run ever activated: a per-key no-op by design. + std::vector deltas{{bh(1), s(1), /*remove*/true, removal}}; + std::vector runs; + RetiredMergeResult merged; + foldDeltasIntoGeneration(backend, layout, /*prior_runs*/{}, /*new_generation*/1, /*attempt*/0, + /*shard*/0, deltas, runs, + /*current_round*/0, /*condemn_round*/0, /*head_blob*/{}, /*peek_head*/{}, + /*confirm_condemned_marker*/{}, &merged, + /*suppress_destructive*/false, &ledger.applied); + + EXPECT_EQ(merged.unmatched_removes, 1u) << "the fixture must actually stage an unmatched removal"; + EXPECT_EQ(ledger.applied[removal], 1); + EXPECT_TRUE(ledger.unapplied().empty()) + << "a legitimate no-op removal must not read as a lost transaction"; +} diff --git a/src/Disks/tests/gtest_cas_upload_detached.cpp b/src/Disks/tests/gtest_cas_upload_detached.cpp new file mode 100644 index 000000000000..d28ead312dbb --- /dev/null +++ b/src/Disks/tests/gtest_cas_upload_detached.cpp @@ -0,0 +1,640 @@ +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +using namespace DB::Cas; +using DB::Cas::tests::idOf; +using DB::Cas::tests::u128Of; +using DB::Cas::tests::loadMetaForTest; +using DB::Cas::tests::writeMetaClean; +using DB::Cas::tests::condemnMeta; +using DB::Cas::tests::blobEntryFor; +using DB::Cas::tests::expectThrowsCode; // NOLINT(misc-unused-using-decls): only used inside `#ifndef DEBUG_OR_SANITIZER_BUILD` -- unused in a sanitizer build's TU, used in a release build's + +namespace DB::ErrorCodes +{ +extern const int LOGICAL_ERROR; +} + +namespace +{ + +/// Open a Pool over `b`. `head_first_min_bytes` steers the HEAD-before-PUT size trigger: the default +/// (1 MiB) keeps the trigger off for the small test payloads (so a branch is reached only via the dedup +/// cache), while a value of 1 forces the HEAD-first path for any non-empty blob (the `HeadFirstHit` leg). +PoolPtr openUploadPool(const std::shared_ptr & b, uint64_t head_first_min_bytes = (1ULL << 20)) +{ + return Pool::open(b, PoolConfig{.pool_prefix = "p", .server_root_id = "test", + .deduplication_head_first_min_bytes = head_first_min_bytes}); +} + +/// Stage a one-blob manifest for `payload` and precommit it, so `observeAndAdmit`'s EDGE-BEFORE-OBSERVE +/// fail-closed check holds on the adopt branches. Returns the build ready for an upload of `idOf(payload)`. +PartWriteTxnPtr precommitBuildFor( + const PoolPtr & s, const RootNamespace & ns, const String & ref, const String & payload) +{ + PartWriteInfo info; + info.intended_ref = ns.string() + "/" + ref; + PartWriteTxnPtr build = s->beginPartWrite(std::move(info)); + const ManifestId id = build->stageManifest({blobEntryFor("col.bin", u128Of(payload), payload.size())}); + build->precommitAdd(ns, ref, id); + return build; +} + +/// Seed a present, well-formed blob body whose LOGICAL bytes are exactly `payload` (a fixed envelope +/// header followed by the payload), so a later HEAD returns a token and a logical size of `payload.size()`. +void seedPresentBody( + InMemoryBackend & b, const Layout & layout, const PoolMeta & pm, const BlobRef & ref, const String & payload) +{ + EnvelopeHeader h; + h.kind = ObjectKind::Blob; + h.incarnation_tag = DB::UInt128(0xABCD); + h.build_id = DB::UInt128(0x1111); + const String head = encodeEnvelopeHeader(h, static_cast(pm.blob_header_len)); + b.putIfAbsent(layout.blobKey(ref), head + payload); +} + +/// The logical payload stored at `key` (object body minus the fixed blob header), or empty when absent. +String logicalPayloadAt(InMemoryBackend & b, const String & key, uint64_t header_len) +{ + const auto got = b.get(key); + if (!got || got->bytes.size() < header_len) + return {}; + return got->bytes.substr(header_len); +} + +/// The blob's meta state, or nullopt when the meta object is absent. +std::optional metaStateAt(InMemoryBackend & b, const Layout & layout, const String & payload) +{ + const auto lm = loadMetaForTest(b, layout, u128Of(payload)); + return lm ? std::optional(lm->meta.state) : std::nullopt; +} + +} + +/// dedup-cache hit: the ref is known-present in the dedup cache ⇒ HEAD-first ⇒ present ⇒ adopt. +/// The returned result is a complete tokened adopt dep with the `DeduplicationCacheHit` outcome; the build's +/// dep set stays untouched; the backend body/meta match the serial `putBlob` for the same input. +TEST(CASUploadDetached, DeduplicationCacheHitAdoptsBuildUntouched) +{ + const RootNamespace ns{"srv1/nsDedup"}; + const String ref_name = "part"; + const String payload = "dedup-cache-hit-payload"; + const BlobRef blob = idOf(payload); + + auto arrange = [&](std::shared_ptr & b, PoolPtr & s, PartWriteTxnPtr & build) + { + b = std::make_shared(); + s = openUploadPool(b); + seedPresentBody(*b, s->layout(), s->poolMeta(), blob, payload); + writeMetaClean(*b, s->layout(), u128Of(payload), payload.size()); + s->dedupCacheAdd(blob); + build = precommitBuildFor(s, ns, ref_name, payload); + }; + + std::shared_ptr b1; + PoolPtr s1; + PartWriteTxnPtr build1; + arrange(b1, s1, build1); + const String key = s1->layout().blobKey(blob); + + EXPECT_FALSE(build1->depIsTokened(blob)); + + const BlobUploadResult r = build1->uploadBlobDetached( + BlobUploadRequest{blob, BlobSource::fromString(payload), payload.size()}); + + EXPECT_EQ(r.outcome, BlobUploadOutcome::DeduplicationCacheHit); + EXPECT_EQ(r.ref, blob); + EXPECT_EQ(r.dep.kind, ObjectKind::Blob); + ASSERT_TRUE(r.dep.token.has_value()); + EXPECT_FALSE(r.dep.token->value.empty()); + EXPECT_FALSE(r.dep.adopted); + EXPECT_EQ(r.dep.size, payload.size()); + + /// Build UNTOUCHED: the detached primitive folded no dep. + EXPECT_FALSE(build1->depIsTokened(blob)); + + /// Serial reference on an identically-arranged world: putBlob folds the dep; end-state matches. + std::shared_ptr b2; + PoolPtr s2; + PartWriteTxnPtr build2; + arrange(b2, s2, build2); + const PutBlobResult pr = build2->putBlob(blob, BlobSource::fromString(payload)); + EXPECT_TRUE(build2->depIsTokened(blob)); + EXPECT_EQ(pr.size, r.dep.size); + + EXPECT_EQ(logicalPayloadAt(*b1, key, s1->poolMeta().blob_header_len), + logicalPayloadAt(*b2, key, s2->poolMeta().blob_header_len)); + EXPECT_EQ(metaStateAt(*b1, s1->layout(), payload), metaStateAt(*b2, s2->layout(), payload)); + EXPECT_EQ(metaStateAt(*b1, s1->layout(), payload), std::optional(MetaState::Clean)); +} + +/// HEAD-first hit (size-triggered, not cached): a present body under the size trigger is adopted with +/// the `HeadHit` outcome. Distinct from the dedup-cache leg by NOT being in the dedup cache. +TEST(CASUploadDetached, HeadFirstHitAdopts) +{ + const RootNamespace ns{"srv1/nsHead"}; + const String ref_name = "part"; + const String payload = "head-first-hit-payload"; + const BlobRef blob = idOf(payload); + + auto arrange = [&](std::shared_ptr & b, PoolPtr & s, PartWriteTxnPtr & build) + { + b = std::make_shared(); + s = openUploadPool(b, /*head_first_min_bytes=*/1); /// force HEAD-first without the dedup cache + seedPresentBody(*b, s->layout(), s->poolMeta(), blob, payload); + writeMetaClean(*b, s->layout(), u128Of(payload), payload.size()); + build = precommitBuildFor(s, ns, ref_name, payload); + }; + + std::shared_ptr b1; + PoolPtr s1; + PartWriteTxnPtr build1; + arrange(b1, s1, build1); + const String key = s1->layout().blobKey(blob); + + EXPECT_FALSE(build1->depIsTokened(blob)); + + const BlobUploadResult r = build1->uploadBlobDetached( + BlobUploadRequest{blob, BlobSource::fromString(payload), payload.size()}); + + EXPECT_EQ(r.outcome, BlobUploadOutcome::HeadHit); + ASSERT_TRUE(r.dep.token.has_value()); + EXPECT_FALSE(r.dep.adopted); + EXPECT_EQ(r.dep.size, payload.size()); + + EXPECT_FALSE(build1->depIsTokened(blob)); + + std::shared_ptr b2; + PoolPtr s2; + PartWriteTxnPtr build2; + arrange(b2, s2, build2); + build2->putBlob(blob, BlobSource::fromString(payload)); + EXPECT_TRUE(build2->depIsTokened(blob)); + + EXPECT_EQ(logicalPayloadAt(*b1, key, s1->poolMeta().blob_header_len), + logicalPayloadAt(*b2, key, s2->poolMeta().blob_header_len)); + EXPECT_EQ(metaStateAt(*b1, s1->layout(), payload), std::optional(MetaState::Clean)); +} + +/// HEAD-first miss, then a live adopt via the conditional-create 412 path, with the meta point-read +/// finding no meta and backfilling `Clean`. Outcome `HeadMissAdopted`. +TEST(CASUploadDetached, HeadMissLiveAdoptBackfills) +{ + const RootNamespace ns{"srv1/nsAdopt"}; + const String ref_name = "part"; + const String payload = "head-miss-adopt-payload"; + const BlobRef blob = idOf(payload); + + auto arrange = [&](std::shared_ptr & b, PoolPtr & s, PartWriteTxnPtr & build) + { + b = std::make_shared(); + s = openUploadPool(b); /// default trigger off ⇒ no HEAD-first + seedPresentBody(*b, s->layout(), s->poolMeta(), blob, payload); /// body present, NO meta ⇒ backfill + build = precommitBuildFor(s, ns, ref_name, payload); + }; + + std::shared_ptr b1; + PoolPtr s1; + PartWriteTxnPtr build1; + arrange(b1, s1, build1); + const String key = s1->layout().blobKey(blob); + + ASSERT_FALSE(metaStateAt(*b1, s1->layout(), payload).has_value()); /// precondition: meta absent + EXPECT_FALSE(build1->depIsTokened(blob)); + + const BlobUploadResult r = build1->uploadBlobDetached( + BlobUploadRequest{blob, BlobSource::fromString(payload), payload.size()}); + + EXPECT_EQ(r.outcome, BlobUploadOutcome::HeadMissAdopted); + ASSERT_TRUE(r.dep.token.has_value()); + EXPECT_FALSE(r.dep.adopted); + EXPECT_EQ(r.dep.size, payload.size()); + + EXPECT_FALSE(build1->depIsTokened(blob)); + /// The point-read backfilled a Clean meta. + EXPECT_EQ(metaStateAt(*b1, s1->layout(), payload), std::optional(MetaState::Clean)); + + std::shared_ptr b2; + PoolPtr s2; + PartWriteTxnPtr build2; + arrange(b2, s2, build2); + build2->putBlob(blob, BlobSource::fromString(payload)); + EXPECT_TRUE(build2->depIsTokened(blob)); + + EXPECT_EQ(logicalPayloadAt(*b1, key, s1->poolMeta().blob_header_len), + logicalPayloadAt(*b2, key, s2->poolMeta().blob_header_len)); + EXPECT_EQ(metaStateAt(*b1, s1->layout(), payload), metaStateAt(*b2, s2->layout(), payload)); +} + +/// Fresh local streaming: nothing present ⇒ the write-once conditional create streams the body and +/// creates the Clean meta. Outcome `FreshUpload`, a tokened dep sized to the source. +TEST(CASUploadDetached, FreshLocalStreaming) +{ + const RootNamespace ns{"srv1/nsFresh"}; + const String ref_name = "part"; + const String payload = "fresh-local-streaming-payload"; + const BlobRef blob = idOf(payload); + + auto arrange = [&](std::shared_ptr & b, PoolPtr & s, PartWriteTxnPtr & build) + { + b = std::make_shared(); + s = openUploadPool(b); + build = precommitBuildFor(s, ns, ref_name, payload); + }; + + std::shared_ptr b1; + PoolPtr s1; + PartWriteTxnPtr build1; + arrange(b1, s1, build1); + const String key = s1->layout().blobKey(blob); + + ASSERT_FALSE(b1->head(key).exists); /// precondition: absent + EXPECT_FALSE(build1->depIsTokened(blob)); + + const BlobUploadResult r = build1->uploadBlobDetached( + BlobUploadRequest{blob, BlobSource::fromString(payload), payload.size()}); + + EXPECT_EQ(r.outcome, BlobUploadOutcome::FreshUpload); + EXPECT_EQ(r.ref, blob); + ASSERT_TRUE(r.dep.token.has_value()); + EXPECT_FALSE(r.dep.token->value.empty()); + EXPECT_FALSE(r.dep.adopted); + EXPECT_EQ(r.dep.size, payload.size()); + + EXPECT_FALSE(build1->depIsTokened(blob)); + EXPECT_TRUE(b1->head(key).exists); + EXPECT_EQ(logicalPayloadAt(*b1, key, s1->poolMeta().blob_header_len), payload); + EXPECT_EQ(metaStateAt(*b1, s1->layout(), payload), std::optional(MetaState::Clean)); + + std::shared_ptr b2; + PoolPtr s2; + PartWriteTxnPtr build2; + arrange(b2, s2, build2); + const PutBlobResult pr = build2->putBlob(blob, BlobSource::fromString(payload)); + EXPECT_TRUE(build2->depIsTokened(blob)); + EXPECT_EQ(pr.size, r.dep.size); + + /// The envelope's fresh incarnation tag differs per upload, but the LOGICAL payload and meta match. + EXPECT_EQ(logicalPayloadAt(*b1, key, s1->poolMeta().blob_header_len), + logicalPayloadAt(*b2, key, s2->poolMeta().blob_header_len)); + EXPECT_EQ(metaStateAt(*b1, s1->layout(), payload), metaStateAt(*b2, s2->layout(), payload)); +} + +/// S3-native staging promotion: the source carries a server-side-copy descriptor and the blob key is +/// absent ⇒ a write-once conditional server-side copy creates the blob. Outcome `StagingPromoted`. +TEST(CASUploadDetached, S3StagingPromotion) +{ + const RootNamespace ns{"srv1/nsStaging"}; + const String ref_name = "part"; + const String payload = "s3-staging-promotion-payload"; + const BlobRef blob = idOf(payload); + const String staging_key = "p/staging/mount1/promote.tmp"; + + auto arrange = [&](std::shared_ptr & b, PoolPtr & s, PartWriteTxnPtr & build, String & staging_bytes) + { + b = std::make_shared(); + s = openUploadPool(b); + /// The staging object holds [header][payload], exactly as the S3-staging writer emits it. + EnvelopeHeader h; + h.kind = ObjectKind::Blob; + h.incarnation_tag = DB::UInt128(0xC0FFEE); + staging_bytes = encodeEnvelopeHeader(h, static_cast(s->poolMeta().blob_header_len)) + payload; + b->putIfAbsent(staging_key, staging_bytes); + build = precommitBuildFor(s, ns, ref_name, payload); + }; + + auto stagingSource = [&]() -> BlobSource + { + BlobSource src; + src.size = payload.size(); + src.server_side_copy_from = staging_key; + return src; + }; + + std::shared_ptr b1; + PoolPtr s1; + PartWriteTxnPtr build1; + String staging_bytes1; + arrange(b1, s1, build1, staging_bytes1); + const String key = s1->layout().blobKey(blob); + + ASSERT_FALSE(b1->head(key).exists); + EXPECT_FALSE(build1->depIsTokened(blob)); + + const BlobUploadResult r = build1->uploadBlobDetached( + BlobUploadRequest{blob, stagingSource(), payload.size()}); + + EXPECT_EQ(r.outcome, BlobUploadOutcome::StagingPromoted); + ASSERT_TRUE(r.dep.token.has_value()); + EXPECT_FALSE(r.dep.token->value.empty()); + EXPECT_FALSE(r.dep.adopted); + EXPECT_EQ(r.dep.size, payload.size()); + + EXPECT_FALSE(build1->depIsTokened(blob)); + ASSERT_TRUE(b1->head(key).exists); + /// The server-side copy moved the staging bytes verbatim to the blob key. + const auto got = b1->get(key); + ASSERT_TRUE(got.has_value()); + EXPECT_EQ(got->bytes, staging_bytes1); + + std::shared_ptr b2; + PoolPtr s2; + PartWriteTxnPtr build2; + String staging_bytes2; + arrange(b2, s2, build2, staging_bytes2); + build2->putBlob(blob, stagingSource()); + EXPECT_TRUE(build2->depIsTokened(blob)); + + const auto got2 = b2->get(key); + ASSERT_TRUE(got2.has_value()); + EXPECT_EQ(got->bytes, got2->bytes); +} + +/// Condemned-local resurrection: a present body observed condemned via the meta point-read is displaced +/// by a fresh incarnation streamed from the writer's OWN source (`putOverwrite`), never a read of the +/// dying object. Outcome `ResurrectedLocal`; the token is refreshed and the meta returns to Clean. +TEST(CASUploadDetached, CondemnedLocalResurrection) +{ + const RootNamespace ns{"srv1/nsResLocal"}; + const String ref_name = "part"; + const String payload = "condemned-local-resurrect-payload"; + const BlobRef blob = idOf(payload); + + auto arrange = [&](std::shared_ptr & b, PoolPtr & s, PartWriteTxnPtr & build) + { + b = std::make_shared(); + s = openUploadPool(b); + seedPresentBody(*b, s->layout(), s->poolMeta(), blob, payload); + writeMetaClean(*b, s->layout(), u128Of(payload), payload.size()); + condemnMeta(*b, s->layout(), u128Of(payload), /*condemn_round=*/7); + build = precommitBuildFor(s, ns, ref_name, payload); + }; + + std::shared_ptr b1; + PoolPtr s1; + PartWriteTxnPtr build1; + arrange(b1, s1, build1); + const String key = s1->layout().blobKey(blob); + const Token condemned_token = b1->head(key).token; + + ASSERT_EQ(metaStateAt(*b1, s1->layout(), payload), std::optional(MetaState::Condemned)); + EXPECT_FALSE(build1->depIsTokened(blob)); + + const BlobUploadResult r = build1->uploadBlobDetached( + BlobUploadRequest{blob, BlobSource::fromString(payload), payload.size()}); + + EXPECT_EQ(r.outcome, BlobUploadOutcome::ResurrectedLocal); + ASSERT_TRUE(r.dep.token.has_value()); + EXPECT_FALSE(r.dep.adopted); + EXPECT_EQ(r.dep.size, payload.size()); + + EXPECT_FALSE(build1->depIsTokened(blob)); + /// The condemned incarnation was displaced by a fresh one (token changed) and the meta is Clean again. + const Token after_token = b1->head(key).token; + EXPECT_NE(after_token.value, condemned_token.value); + EXPECT_EQ(metaStateAt(*b1, s1->layout(), payload), std::optional(MetaState::Clean)); + EXPECT_EQ(logicalPayloadAt(*b1, key, s1->poolMeta().blob_header_len), payload); + + std::shared_ptr b2; + PoolPtr s2; + PartWriteTxnPtr build2; + arrange(b2, s2, build2); + build2->putBlob(blob, BlobSource::fromString(payload)); + EXPECT_TRUE(build2->depIsTokened(blob)); + + EXPECT_EQ(logicalPayloadAt(*b1, key, s1->poolMeta().blob_header_len), + logicalPayloadAt(*b2, key, s2->poolMeta().blob_header_len)); + EXPECT_EQ(metaStateAt(*b1, s1->layout(), payload), metaStateAt(*b2, s2->layout(), payload)); +} + +/// Condemned-S3 resurrection: a present body observed condemned with an S3 staging source is displaced +/// by an unconditional server-side copy from the SAME staging object under a fresh-tagged header, never +/// a read/copy of the condemned blob key. Outcome `ResurrectedS3`. +TEST(CASUploadDetached, CondemnedS3Resurrection) +{ + const RootNamespace ns{"srv1/nsResS3"}; + const String ref_name = "part"; + const String payload = "condemned-s3-resurrect-payload"; + const BlobRef blob = idOf(payload); + const String staging_key = "p/staging/mount1/resurrect.tmp"; + + auto arrange = [&](std::shared_ptr & b, PoolPtr & s, PartWriteTxnPtr & build) + { + b = std::make_shared(); + s = openUploadPool(b); + EnvelopeHeader h; + h.kind = ObjectKind::Blob; + h.incarnation_tag = DB::UInt128(0xC0FFEE); + const String staging_bytes = encodeEnvelopeHeader(h, static_cast(s->poolMeta().blob_header_len)) + payload; + b->putIfAbsent(staging_key, staging_bytes); + /// Seed the condemned blob body = exactly a verbatim promote of the staging object would produce. + b->putIfAbsent(s->layout().blobKey(blob), staging_bytes); + writeMetaClean(*b, s->layout(), u128Of(payload), payload.size()); + condemnMeta(*b, s->layout(), u128Of(payload), /*condemn_round=*/9); + build = precommitBuildFor(s, ns, ref_name, payload); + }; + + auto stagingSource = [&]() -> BlobSource + { + BlobSource src; + src.size = payload.size(); + src.server_side_copy_from = staging_key; + return src; + }; + + std::shared_ptr b1; + PoolPtr s1; + PartWriteTxnPtr build1; + arrange(b1, s1, build1); + const String key = s1->layout().blobKey(blob); + const Token condemned_token = b1->head(key).token; + + ASSERT_EQ(metaStateAt(*b1, s1->layout(), payload), std::optional(MetaState::Condemned)); + EXPECT_FALSE(build1->depIsTokened(blob)); + + const BlobUploadResult r = build1->uploadBlobDetached( + BlobUploadRequest{blob, stagingSource(), payload.size()}); + + EXPECT_EQ(r.outcome, BlobUploadOutcome::ResurrectedS3); + ASSERT_TRUE(r.dep.token.has_value()); + EXPECT_FALSE(r.dep.adopted); + EXPECT_EQ(r.dep.size, payload.size()); + + EXPECT_FALSE(build1->depIsTokened(blob)); + /// A fresh incarnation displaced the condemned one (INV-NO-RETURN: fresh tag ⇒ different token). + const Token after_token = b1->head(key).token; + EXPECT_NE(after_token.value, condemned_token.value); + EXPECT_EQ(metaStateAt(*b1, s1->layout(), payload), std::optional(MetaState::Clean)); + + std::shared_ptr b2; + PoolPtr s2; + PartWriteTxnPtr build2; + arrange(b2, s2, build2); + build2->putBlob(blob, stagingSource()); + EXPECT_TRUE(build2->depIsTokened(blob)); + + EXPECT_EQ(metaStateAt(*b1, s1->layout(), payload), metaStateAt(*b2, s2->layout(), payload)); +} + +/// `mergeBlobUploadResults` folds N detached results in ONE call to EXACTLY the same deps a serial +/// putBlob fold would produce. Both worlds run the identical sequence of backend calls (same +/// precommit, same blobs in the same order), so their independently-minted tokens line up too -- the +/// merge path adds no backend calls of its own, only in-memory bookkeeping, so a DEEP dep-map +/// comparison (tokens included) is exact, not just per-ref. +TEST(CASUploadDetached, MergeAppliesAllDeps) +{ + const RootNamespace ns{"srv1/nsMergeAll"}; + const String ref_name = "part"; + const std::vector payloads = {"merge-fresh-a", "merge-fresh-b", "merge-fresh-c"}; + + auto arrange = [&](std::shared_ptr & b, PoolPtr & s, PartWriteTxnPtr & build) + { + b = std::make_shared(); + s = openUploadPool(b); + build = precommitBuildFor(s, ns, ref_name, "manifest-seed"); + }; + + std::shared_ptr b1; + PoolPtr s1; + PartWriteTxnPtr build1; + arrange(b1, s1, build1); + + std::vector results; + for (const auto & payload : payloads) + { + const BlobRef blob = idOf(payload); + EXPECT_FALSE(build1->depIsTokened(blob)); + results.push_back(build1->uploadBlobDetached( + BlobUploadRequest{blob, BlobSource::fromString(payload), payload.size()})); + } + /// Still untouched before the merge -- uploadBlobDetached folds nothing. + for (const auto & payload : payloads) + EXPECT_FALSE(build1->depIsTokened(idOf(payload))); + + build1->mergeBlobUploadResults(results); + + for (const auto & payload : payloads) + EXPECT_TRUE(build1->depIsTokened(idOf(payload))); + + std::shared_ptr b2; + PoolPtr s2; + PartWriteTxnPtr build2; + arrange(b2, s2, build2); + for (const auto & payload : payloads) + build2->putBlob(idOf(payload), BlobSource::fromString(payload)); + + EXPECT_EQ(build1->depsSnapshotForTest(), build2->depsSnapshotForTest()); +} + +/// Merge exception safety (spec Test 16): a hook injected between per-result applications throws +/// after the FIRST result would have applied; the SECOND result must never reach `deps`, and neither +/// may a PRE-EXISTING unrelated dep be disturbed -- a DEEP snapshot (the whole map, not one ref probed +/// at a time) proves the build is byte-for-byte at its pre-merge state, all-or-nothing observed. +TEST(CASUploadDetached, MergeFailureLeavesBuildUntouched) +{ + const RootNamespace ns{"srv1/nsMergeFail"}; + const String ref_name = "part"; + const String payload_existing = "merge-fail-existing"; + const String payload_a = "merge-fail-a"; + const String payload_b = "merge-fail-b"; + + auto b = std::make_shared(); + auto s = openUploadPool(b); + auto build = precommitBuildFor(s, ns, ref_name, "manifest-seed"); + + /// A pre-existing folded dep the merge must leave completely alone. + build->putBlob(idOf(payload_existing), BlobSource::fromString(payload_existing)); + ASSERT_TRUE(build->depIsTokened(idOf(payload_existing))); + + std::vector results; + results.push_back(build->uploadBlobDetached( + BlobUploadRequest{idOf(payload_a), BlobSource::fromString(payload_a), payload_a.size()})); + results.push_back(build->uploadBlobDetached( + BlobUploadRequest{idOf(payload_b), BlobSource::fromString(payload_b), payload_b.size()})); + + const auto pre_merge_snapshot = build->depsSnapshotForTest(); + ASSERT_EQ(pre_merge_snapshot.size(), 1u); /// only the pre-existing dep; the detached uploads folded nothing + + build->setMergeHookForTest([](size_t applied_so_far) + { + if (applied_so_far == 1) + throw std::bad_alloc(); + }); + + EXPECT_THROW(build->mergeBlobUploadResults(results), std::bad_alloc); + + EXPECT_EQ(build->depsSnapshotForTest(), pre_merge_snapshot); + EXPECT_FALSE(build->depIsTokened(idOf(payload_a))); + EXPECT_FALSE(build->depIsTokened(idOf(payload_b))); +} + +/// Duplicate-grouping consistency: two results for the SAME ref with conflicting sizes are rejected +/// as a staging bug (LOGICAL_ERROR) BEFORE any result applies -- the fan-out's one-task-per-unique-ref +/// invariant means this should never happen upstream, so merge itself is the backstop. LOGICAL_ERROR +/// aborts the whole process in debug/sanitizer builds instead of behaving like a catchable exception +/// (`Common/Exception.cpp`'s `handle_error_code`) -- `CASUploadDetachedDeathTest` below proves the +/// abort positively in those builds instead (it cannot also verify the build-untouched postcondition, +/// since there is no continuation after a real abort). +#ifndef DEBUG_OR_SANITIZER_BUILD +TEST(CASUploadDetached, MergeValidatesSizes) +{ + const RootNamespace ns{"srv1/nsMergeSizes"}; + const String ref_name = "part"; + const String payload = "merge-size-conflict"; + const BlobRef blob = idOf(payload); + + auto b = std::make_shared(); + auto s = openUploadPool(b); + auto build = precommitBuildFor(s, ns, ref_name, "manifest-seed"); + + const BlobUploadResult r = build->uploadBlobDetached( + BlobUploadRequest{blob, BlobSource::fromString(payload), payload.size()}); + ASSERT_FALSE(build->depIsTokened(blob)); + + BlobUploadResult conflicting = r; + conflicting.dep.size = r.dep.size + 1; /// same ref, conflicting declared size + + const auto pre_merge_snapshot = build->depsSnapshotForTest(); + + expectThrowsCode(DB::ErrorCodes::LOGICAL_ERROR, [&] + { + build->mergeBlobUploadResults(std::vector{r, conflicting}); + }); + + EXPECT_EQ(build->depsSnapshotForTest(), pre_merge_snapshot); + EXPECT_FALSE(build->depIsTokened(blob)); +} +#endif + +#if defined(DEBUG_OR_SANITIZER_BUILD) +TEST(CASUploadDetachedDeathTest, MergeValidatesSizesAborts) +{ + const RootNamespace ns{"srv1/nsMergeSizes"}; + const String ref_name = "part"; + const String payload = "merge-size-conflict"; + const BlobRef blob = idOf(payload); + + auto b = std::make_shared(); + auto s = openUploadPool(b); + auto build = precommitBuildFor(s, ns, ref_name, "manifest-seed"); + + const BlobUploadResult r = build->uploadBlobDetached( + BlobUploadRequest{blob, BlobSource::fromString(payload), payload.size()}); + + BlobUploadResult conflicting = r; + conflicting.dep.size = r.dep.size + 1; /// same ref, conflicting declared size + + EXPECT_DEATH( + { build->mergeBlobUploadResults(std::vector{r, conflicting}); }, ""); +} +#endif diff --git a/src/Disks/tests/gtest_cas_upload_fanout.cpp b/src/Disks/tests/gtest_cas_upload_fanout.cpp new file mode 100644 index 000000000000..941c4f66c035 --- /dev/null +++ b/src/Disks/tests/gtest_cas_upload_fanout.cpp @@ -0,0 +1,940 @@ +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +using namespace DB::Cas; +using DB::Cas::tests::idOf; +using DB::Cas::tests::u128Of; +using DB::Cas::tests::blobEntryFor; +using DB::Cas::tests::writeMetaClean; +using DB::Cas::tests::condemnMeta; +using DB::Cas::tests::loadMetaForTest; +using DB::Cas::tests::expectThrowsCode; +using DB::Cas::tests::runRoundsUntilAbsent; +using DB::Cas::tests::blobAbsent; +using DB::Cas::tests::CountingBackend; + +namespace DB::ErrorCodes +{ +extern const int LOGICAL_ERROR; +extern const int INCORRECT_DATA; +} + +namespace CurrentMetrics +{ + extern const Metric LocalThread; + extern const Metric LocalThreadActive; + extern const Metric LocalThreadScheduled; +} + +namespace +{ + +/// A local upload pool of a chosen size. Task 5 takes the pool as a parameter (rather than reaching +/// for the server-wide `Cas::blobUploadPool()`) precisely so a test can run the SAME fan-out through a +/// size-1 pool (the serial reference) and a size-N pool (the fanned-out world) in ONE process -- the +/// server-wide pool is once-only per binary and cannot be re-sized. The calling thread only submits +/// and joins (it never occupies a pool slot), so size 1 is a valid fully-serial configuration. +std::unique_ptr makePool(size_t size) +{ + return std::make_unique( + CurrentMetrics::LocalThread, CurrentMetrics::LocalThreadActive, CurrentMetrics::LocalThreadScheduled, size); +} + +/// Open a Pool over any InMemoryBackend-derived backend (the plain one, or the CountingBackend that +/// records per-key GET counts). `head_first_min_bytes` steers the HEAD-before-PUT size trigger, exactly +/// as in `gtest_cas_upload_detached.cpp`. +PoolPtr openPool(const std::shared_ptr & b, uint64_t head_first_min_bytes = (1ULL << 20)) +{ + return Pool::open(b, PoolConfig{.pool_prefix = "p", .server_root_id = "test", + .deduplication_head_first_min_bytes = head_first_min_bytes}); +} + +/// Stage a one-blob seed manifest and precommit it, so every adopt branch of `uploadBlobDetached` +/// passes its EDGE-BEFORE-OBSERVE fail-closed gate (which only checks the `precommitted` flag). One +/// precommit covers an arbitrary number of subsequently-uploaded blobs, mirroring +/// `precommitBuildFor`/`MergeAppliesAllDeps` in the detached suite. +PartWriteTxnPtr precommitBuildFor(const PoolPtr & s, const RootNamespace & ns, const String & ref) +{ + PartWriteInfo info; + info.intended_ref = ns.string() + "/" + ref; + PartWriteTxnPtr build = s->beginPartWrite(std::move(info)); + const String seed = "seed-manifest-" + ns.string() + "/" + ref; + const ManifestId id = build->stageManifest({blobEntryFor("col.bin", u128Of(seed), seed.size())}); + build->precommitAdd(ns, ref, id); + return build; +} + +/// Seed a present, well-formed blob body whose LOGICAL bytes are exactly `payload`. +void seedPresentBody(InMemoryBackend & b, const Layout & layout, const PoolMeta & pm, const String & payload) +{ + EnvelopeHeader h; + h.kind = ObjectKind::Blob; + h.incarnation_tag = DB::UInt128(0xABCD); + h.build_id = DB::UInt128(0x1111); + const String head = encodeEnvelopeHeader(h, static_cast(pm.blob_header_len)); + b.putIfAbsent(layout.blobKey(idOf(payload)), head + payload); +} + +/// The logical payload stored at a blob key (object body minus the fixed blob header), or empty when absent. +String logicalPayloadAt(InMemoryBackend & b, const String & key, uint64_t header_len) +{ + const auto got = b.get(key); + if (!got || got->bytes.size() < header_len) + return {}; + return got->bytes.substr(header_len); +} + +/// The blob's meta state, or nullopt when the meta object is absent. +std::optional metaStateAt(InMemoryBackend & b, const Layout & layout, const String & payload) +{ + const auto lm = loadMetaForTest(b, layout, u128Of(payload)); + return lm ? std::optional(lm->meta.state) : std::nullopt; +} + +/// A local streaming source for `payload`, exactly as `ContentAddressedTransaction::uploadPendingBlobs` +/// builds for a Local-staging pending blob. +BlobUploadRequest localRequest(const String & payload) +{ + return BlobUploadRequest{idOf(payload), BlobSource::fromString(payload), payload.size()}; +} + +/// An S3-staging source: the bytes already live at `staging_key` and the upload is a server-side copy. +BlobUploadRequest s3Request(const String & payload, const String & staging_key) +{ + BlobSource src; + src.size = payload.size(); + src.server_side_copy_from = staging_key; + return BlobUploadRequest{idOf(payload), std::move(src), payload.size()}; +} + +/// The stable, deterministic part of a build's dep set: (kind, size, adopted, has-token) per ref. +/// The token VALUE itself is intentionally excluded -- the InMemoryBackend mints tokens from ONE +/// monotonic counter, so a fanned-out world's fresh/resurrect uploads land their tokens in a +/// non-deterministic order relative to the serial world. Adopt branches reuse the seeded token, but +/// keying the comparison off (kind,size,adopted,has-token) plus the backend end state (below) captures +/// the behavioral equivalence without depending on token-mint ordering. +using StableDep = std::tuple; +std::map stableDeps(const PartWriteTxn & build) +{ + std::map out; + for (const auto & [ref, dep] : build.depsSnapshotForTest()) + out.emplace(ref, StableDep{dep.kind, dep.size, dep.adopted, dep.token.has_value()}); + return out; +} + +/// The backend end state for a set of blob refs: (logical payload, meta state) per ref. Deterministic +/// (content is the payload; meta settles to Clean), so it is compared byte-for-byte across worlds. +using BackendState = std::map>>; +BackendState backendState(InMemoryBackend & b, const PoolPtr & s, const std::vector & payloads) +{ + BackendState out; + for (const auto & p : payloads) + out.emplace(idOf(p), + std::make_pair(logicalPayloadAt(b, s->layout().blobKey(idOf(p)), s->poolMeta().blob_header_len), + metaStateAt(b, s->layout(), p))); + return out; +} + +/// A one-shot event with a BOUNDED wait. Not a sleep-sequencer: the wait blocks only until the event +/// fires; the bound exists solely so a design regression surfaces as a fast test failure instead of an +/// infinite hang. +struct BoundedEvent +{ + std::mutex m; + std::condition_variable cv; + bool fired = false; + void fire() + { + { + std::lock_guard l(m); + fired = true; + } + cv.notify_all(); + } + bool wait(std::chrono::milliseconds bound) + { + std::unique_lock l(m); + return cv.wait_for(l, bound, [&] { return fired; }); + } +}; + +/// Records the peak number of tasks simultaneously "inside" the rendezvous. A task calls `enter(want)` +/// from the fan-out's in-task seam; it blocks (BOUNDED) until `want` tasks are inside together, OR every +/// dispatched task has entered (so a final straggler is never stranded when the pool cannot form another +/// pair), OR the bound elapses. A pool that CANNOT muster `want` concurrent tasks (size 1, where THIS +/// task occupies the single worker) times out on the first waiter, marks the run serial, and every later +/// task skips the wait -- so a too-small pool fails FAST and the whole run stays bounded, never +/// deadlocked. `total` (the dispatched task count) is set before dispatch. +struct ConcurrencyProbe +{ + std::mutex m; + std::condition_variable cv; + int current = 0; + int peak = 0; + int entered = 0; + int total = 0; + bool timed_out = false; + void enter(int want, std::chrono::milliseconds bound) + { + std::unique_lock l(m); + ++current; + ++entered; + peak = std::max(peak, current); + cv.notify_all(); + const bool ok = cv.wait_for(l, bound, + [&] { return current >= want || entered == total || timed_out; }); + if (!ok) + timed_out = true; /// the pool cannot reach `want`; later tasks skip the wait + cv.notify_all(); + --current; + } +}; + +} + +/// Test 1 (spec §1 "serial-vs-parallel equivalence for successful runs"): a multi-blob part that +/// exercises every branch of `uploadBlobDetached` produces IDENTICAL recorded deps and IDENTICAL backend +/// end state whether the fan-out runs serially (pool size 1) or in parallel (pool size 4). The seven +/// branches split across two HEAD-first configurations (a single pool config cannot reach both the +/// size-triggered `HeadHit` and the 412-path `HeadMissAdopted`), so the equivalence is proven under +/// each config: pass A (HEAD-first off) covers dedup-cache-hit / head-miss-adopt / fresh / staging / +/// condemned-local / condemned-S3; pass B (HEAD-first forced) covers the size-triggered head-hit. +namespace +{ + +/// Arrange pass A's six-branch world (HEAD-first off) and return the payloads it uploads. Every branch +/// is seeded on a DISTINCT ref so the one-task-per-unique-ref fan-out runs six independent tasks. +struct WorldA +{ + std::shared_ptr b; + PoolPtr s; + PartWriteTxnPtr build; + std::vector requests; + std::vector payloads; +}; + +const char * const kDedup = "fanoutA-dedup-cache-hit"; +const char * const kAdopt = "fanoutA-head-miss-adopt"; +const char * const kFresh = "fanoutA-fresh-local"; +const char * const kStaging = "fanoutA-s3-staging"; +const char * const kResLocal = "fanoutA-condemned-local"; +const char * const kResS3 = "fanoutA-condemned-s3"; + +WorldA arrangeWorldA() +{ + WorldA w; + w.b = std::make_shared(); + w.s = openPool(w.b); /// default trigger: HEAD-first only on a dedup-cache hit + const RootNamespace ns{"srv1/nsFanoutA"}; + w.build = precommitBuildFor(w.s, ns, "part"); + + /// dedup-cache hit: present body + Clean meta + cache membership. + seedPresentBody(*w.b, w.s->layout(), w.s->poolMeta(), kDedup); + writeMetaClean(*w.b, w.s->layout(), u128Of(kDedup), std::string(kDedup).size()); + w.s->dedupCacheAdd(idOf(kDedup)); + + /// HEAD-miss then 412-path live adopt with meta backfill: present body, NO meta, not cached. + seedPresentBody(*w.b, w.s->layout(), w.s->poolMeta(), kAdopt); + + /// fresh local streaming: nothing present. + + /// S3-native staging promotion: bytes live in a staging object, blob key absent. + { + EnvelopeHeader h; + h.kind = ObjectKind::Blob; + h.incarnation_tag = DB::UInt128(0xC0FFEE); + const String staging = encodeEnvelopeHeader(h, static_cast(w.s->poolMeta().blob_header_len)) + kStaging; + w.b->putIfAbsent("p/staging/mount1/A-staging.tmp", staging); + } + + /// condemned-local resurrection: present body + condemned meta, local source. + seedPresentBody(*w.b, w.s->layout(), w.s->poolMeta(), kResLocal); + writeMetaClean(*w.b, w.s->layout(), u128Of(kResLocal), std::string(kResLocal).size()); + condemnMeta(*w.b, w.s->layout(), u128Of(kResLocal), /*condemn_round=*/7); + + /// condemned-S3 resurrection: present body (= a verbatim promote of the staging object) + condemned + /// meta, S3 staging source. + { + EnvelopeHeader h; + h.kind = ObjectKind::Blob; + h.incarnation_tag = DB::UInt128(0xC0FFEE); + const String staging = encodeEnvelopeHeader(h, static_cast(w.s->poolMeta().blob_header_len)) + kResS3; + w.b->putIfAbsent("p/staging/mount1/A-resurrect.tmp", staging); + w.b->putIfAbsent(w.s->layout().blobKey(idOf(kResS3)), staging); + writeMetaClean(*w.b, w.s->layout(), u128Of(kResS3), std::string(kResS3).size()); + condemnMeta(*w.b, w.s->layout(), u128Of(kResS3), /*condemn_round=*/9); + } + + w.requests = { + localRequest(kDedup), + localRequest(kAdopt), + localRequest(kFresh), + s3Request(kStaging, "p/staging/mount1/A-staging.tmp"), + localRequest(kResLocal), + s3Request(kResS3, "p/staging/mount1/A-resurrect.tmp"), + }; + w.payloads = {kDedup, kAdopt, kFresh, kStaging, kResLocal, kResS3}; + return w; +} + +} + +TEST(CASUploadFanout, DepsEquivalenceAcrossBranches) +{ + /// Serial reference: pool size 1. + WorldA serial = arrangeWorldA(); + auto serial_pool = makePool(1); + fanOutBlobUploads(*serial.build, serial.requests, *serial_pool); + const auto serial_deps = stableDeps(*serial.build); + const auto serial_backend = backendState(*serial.b, serial.s, serial.payloads); + + /// Fanned-out: pool size 4, same inputs, freshly arranged world. + WorldA fanned = arrangeWorldA(); + auto fanned_pool = makePool(4); + fanOutBlobUploads(*fanned.build, fanned.requests, *fanned_pool); + const auto fanned_deps = stableDeps(*fanned.build); + const auto fanned_backend = backendState(*fanned.b, fanned.s, fanned.payloads); + + EXPECT_EQ(serial_deps.size(), 6u) << "one dep per unique ref"; + EXPECT_EQ(serial_deps, fanned_deps) << "recorded deps must match across serial and fanned runs"; + EXPECT_EQ(serial_backend, fanned_backend) << "backend end state must match across serial and fanned runs"; + + /// Every dep is a complete tokened blob dep (no branch left its effect behind as a side effect). + for (const auto & [ref, dep] : serial_deps) + { + EXPECT_EQ(std::get<0>(dep), ObjectKind::Blob); + EXPECT_TRUE(std::get<3>(dep)) << "every merged fan-out dep carries a token"; + } + for (const auto & p : serial.payloads) + EXPECT_EQ(metaStateAt(*fanned.b, fanned.s->layout(), p), std::optional(MetaState::Clean)); + + /// Pass B: the size-triggered HeadHit branch, proven equivalent under its own (HEAD-first forced) config. + auto arrangeB = [](std::shared_ptr & b, PoolPtr & s, PartWriteTxnPtr & build) + { + b = std::make_shared(); + s = openPool(b, /*head_first_min_bytes=*/1); + const RootNamespace ns{"srv1/nsFanoutB"}; + build = precommitBuildFor(s, ns, "part"); + seedPresentBody(*b, s->layout(), s->poolMeta(), "fanoutB-head-hit"); + writeMetaClean(*b, s->layout(), u128Of("fanoutB-head-hit"), std::string("fanoutB-head-hit").size()); + }; + + std::shared_ptr b1; + PoolPtr s1; + PartWriteTxnPtr build1; + arrangeB(b1, s1, build1); + std::vector reqB{localRequest("fanoutB-head-hit")}; + auto b_serial_pool = makePool(1); + fanOutBlobUploads(*build1, reqB, *b_serial_pool); + + std::shared_ptr b2; + PoolPtr s2; + PartWriteTxnPtr build2; + arrangeB(b2, s2, build2); + auto b_fanned_pool = makePool(4); + fanOutBlobUploads(*build2, reqB, *b_fanned_pool); + + EXPECT_EQ(stableDeps(*build1), stableDeps(*build2)) << "HeadHit branch: deps match serial vs fanned"; + EXPECT_EQ(backendState(*b1, s1, {"fanoutB-head-hit"}), backendState(*b2, s2, {"fanoutB-head-hit"})) + << "HeadHit branch: backend end state matches serial vs fanned"; +} + +/// Test 1, GET-observability (routed from T3 review (a)): the resurrect invariant is that a condemned +/// object is NEVER GET (revival is a fresh re-upload from the writer's own source). With a +/// CountingBackend, assert ZERO get/getStream against the condemned blob keys through the whole fan-out. +TEST(CASUploadFanout, CondemnedBranchesNeverGet) +{ + auto counting = std::make_shared(); + auto s = openPool(counting); + const RootNamespace ns{"srv1/nsNoGet"}; + auto build = precommitBuildFor(s, ns, "part"); + + const String local_payload = "noget-condemned-local"; + const String s3_payload = "noget-condemned-s3"; + const String s3_staging = "p/staging/mount1/noget-resurrect.tmp"; + + seedPresentBody(*counting, s->layout(), s->poolMeta(), local_payload); + writeMetaClean(*counting, s->layout(), u128Of(local_payload), local_payload.size()); + condemnMeta(*counting, s->layout(), u128Of(local_payload), /*condemn_round=*/3); + + { + EnvelopeHeader h; + h.kind = ObjectKind::Blob; + h.incarnation_tag = DB::UInt128(0xC0FFEE); + const String staging = encodeEnvelopeHeader(h, static_cast(s->poolMeta().blob_header_len)) + s3_payload; + counting->putIfAbsent(s3_staging, staging); + counting->putIfAbsent(s->layout().blobKey(idOf(s3_payload)), staging); + writeMetaClean(*counting, s->layout(), u128Of(s3_payload), s3_payload.size()); + condemnMeta(*counting, s->layout(), u128Of(s3_payload), /*condemn_round=*/5); + } + + counting->resetCounts(); /// count only the fan-out's own backend traffic + + std::vector reqs{localRequest(local_payload), s3Request(s3_payload, s3_staging)}; + auto pool = makePool(2); + fanOutBlobUploads(*build, reqs, *pool); + + /// INV-1: revival is a fresh re-upload from the writer's own source; the condemned BODY object is + /// never read. (The fan-out DOES read the two condemned-META objects -- the meta point-read is how it + /// LEARNS an incarnation is condemned -- so the invariant is per-body-key, not a global GET count.) + const String local_key = s->layout().blobKey(idOf(local_payload)); + const String s3_key = s->layout().blobKey(idOf(s3_payload)); + EXPECT_EQ(counting->getCount(local_key), 0u) << "INV-1: the condemned local body is never read"; + EXPECT_EQ(counting->getStreamCount(local_key), 0u) << "INV-1: the condemned local body is never streamed"; + EXPECT_EQ(counting->getCount(s3_key), 0u) << "INV-1: the condemned S3 body is never read"; + EXPECT_EQ(counting->getStreamCount(s3_key), 0u) << "INV-1: the condemned S3 body is never streamed"; + + /// Both resurrections still completed to Clean. + EXPECT_EQ(metaStateAt(*counting, s->layout(), local_payload), std::optional(MetaState::Clean)); + EXPECT_EQ(metaStateAt(*counting, s->layout(), s3_payload), std::optional(MetaState::Clean)); +} + +/// Test 2: duplicate refs (staged-hardlink copies push a duplicate PendingBlob record) collapse to ONE +/// task, and the merged build records exactly one dep for the ref. +TEST(CASUploadFanout, DuplicateRefsLaunchOneTask) +{ + auto b = std::make_shared(); + auto s = openPool(b); + const RootNamespace ns{"srv1/nsDup"}; + auto build = precommitBuildFor(s, ns, "part"); + + const String payload = "dup-fresh-payload"; + std::vector reqs{localRequest(payload), localRequest(payload)}; /// same ref twice + + std::atomic dispatched{0}; + std::map per_ref; + std::mutex per_ref_m; + BlobUploadFanoutHooksForTest hooks; + hooks.on_dispatch = [&](const BlobRef & ref) + { + ++dispatched; + std::lock_guard l(per_ref_m); + ++per_ref[ref]; + }; + + auto pool = makePool(4); + fanOutBlobUploads(*build, reqs, *pool, &hooks); + + EXPECT_EQ(dispatched.load(), 1) << "two pending-blob records for one ref launch exactly one task"; + EXPECT_EQ(per_ref[idOf(payload)], 1); + EXPECT_TRUE(build->depIsTokened(idOf(payload))) << "the one task's dep was merged"; + EXPECT_EQ(build->depsSnapshotForTest().size(), 1u) << "exactly one dep for the unique ref"; +} + +/// Test 2, conflicting-size backstop: two records for the SAME ref with different declared sizes are a +/// staging bug -- rejected with LOGICAL_ERROR before any task runs. LOGICAL_ERROR aborts under +/// debug/sanitizer builds, so the abort is proven positively there (DeathTest) and the exception + +/// build-untouched postcondition in a release build. +#ifndef DEBUG_OR_SANITIZER_BUILD +TEST(CASUploadFanout, ConflictingDuplicateSizesRejected) +{ + auto b = std::make_shared(); + auto s = openPool(b); + const RootNamespace ns{"srv1/nsDupConflict"}; + auto build = precommitBuildFor(s, ns, "part"); + + const String payload = "dup-conflict-payload"; + BlobUploadRequest a = localRequest(payload); + BlobUploadRequest c = localRequest(payload); + c.declared_size = a.declared_size + 1; /// same ref, conflicting declared size + c.source.size = c.declared_size; /// keep declared == source so only the group conflict trips + + const auto before = build->depsSnapshotForTest(); + auto pool = makePool(4); + expectThrowsCode(DB::ErrorCodes::LOGICAL_ERROR, [&] + { + fanOutBlobUploads(*build, std::vector{a, c}, *pool); + }); + EXPECT_EQ(build->depsSnapshotForTest(), before) << "a rejected fan-out merges nothing"; +} +#endif + +#if defined(DEBUG_OR_SANITIZER_BUILD) +TEST(CASUploadFanoutDeathTest, ConflictingDuplicateSizesAbort) +{ + auto b = std::make_shared(); + auto s = openPool(b); + const RootNamespace ns{"srv1/nsDupConflict"}; + auto build = precommitBuildFor(s, ns, "part"); + + const String payload = "dup-conflict-payload"; + BlobUploadRequest a = localRequest(payload); + BlobUploadRequest c = localRequest(payload); + c.declared_size = a.declared_size + 1; + c.source.size = c.declared_size; + + auto pool = makePool(4); + EXPECT_DEATH({ fanOutBlobUploads(*build, std::vector{a, c}, *pool); }, ""); +} +#endif + +/// Test 2, declared_size == source.size fail-close (routed from T3 review (b)): a request whose grouping +/// key (declared_size) disagrees with its streaming authority (source.size) is a wiring bug -- rejected +/// with LOGICAL_ERROR before dispatch (DeathTest split for debug/sanitizer builds). +#ifndef DEBUG_OR_SANITIZER_BUILD +TEST(CASUploadFanout, DeclaredSizeMustMatchSourceSize) +{ + auto b = std::make_shared(); + auto s = openPool(b); + const RootNamespace ns{"srv1/nsDeclared"}; + auto build = precommitBuildFor(s, ns, "part"); + + BlobUploadRequest r = localRequest("declared-mismatch-payload"); + r.declared_size = r.source.size + 7; /// diverge the grouping key from the streaming authority + + auto pool = makePool(2); + expectThrowsCode(DB::ErrorCodes::LOGICAL_ERROR, [&] + { + fanOutBlobUploads(*build, std::vector{r}, *pool); + }); +} +#endif + +#if defined(DEBUG_OR_SANITIZER_BUILD) +TEST(CASUploadFanoutDeathTest, DeclaredSizeMismatchAborts) +{ + auto b = std::make_shared(); + auto s = openPool(b); + const RootNamespace ns{"srv1/nsDeclared"}; + auto build = precommitBuildFor(s, ns, "part"); + + BlobUploadRequest r = localRequest("declared-mismatch-payload"); + r.declared_size = r.source.size + 7; + + auto pool = makePool(2); + EXPECT_DEATH({ fanOutBlobUploads(*build, std::vector{r}, *pool); }, ""); +} +#endif + +/// The condemned-LOCAL displacement, end to end on the new unconditional streaming shape: the +/// resurrected body is [fresh_header][payload], its token differs from the condemned one, and the +/// meta flips back to Clean -- which is exactly what a later attempt reads to adopt instead of +/// re-writing. +TEST(CASUploadFanout, CondemnedLocalResurrectStreamsAndFlipsMetaClean) +{ + auto b = std::make_shared(); + auto s = openPool(b); + const RootNamespace ns{"srv1/nsResLocalStream"}; + auto build = precommitBuildFor(s, ns, "part"); + + const String payload = "condemned-local-streamed-payload"; + seedPresentBody(*b, s->layout(), s->poolMeta(), payload); + writeMetaClean(*b, s->layout(), u128Of(payload), payload.size()); + condemnMeta(*b, s->layout(), u128Of(payload), /*condemn_round=*/13); + + const String blob_key = s->layout().blobKey(idOf(payload)); + const Token condemned_token = b->head(blob_key).token; + + std::vector reqs{localRequest(payload)}; + auto pool = makePool(2); + fanOutBlobUploads(*build, reqs, *pool, nullptr); + + /// A fresh incarnation displaced the condemned one; INV-NO-RETURN: the queued exact-token delete + /// of the condemned incarnation must miss the resurrection. + const HeadResult after = b->head(blob_key); + ASSERT_TRUE(after.exists); + EXPECT_NE(after.token, condemned_token); + EXPECT_EQ(b->deleteExact(blob_key, condemned_token).kind, DeleteOutcome::Kind::TokenMismatch); + EXPECT_TRUE(b->head(blob_key).exists); + + /// The payload survived verbatim under the fresh header. + const auto got = b->get(blob_key); + ASSERT_TRUE(got.has_value()); + EXPECT_EQ(got->bytes.substr(s->poolMeta().blob_header_len), payload); + + /// The meta flipped back to Clean -- the signal a later attempt adopts on. + const auto lm = loadMetaForTest(*b, s->layout(), u128Of(payload)); + ASSERT_TRUE(lm.has_value()); + EXPECT_EQ(lm->meta.state, MetaState::Clean); +} + +/// `open` is the per-attempt unit of re-readability, and this pins the attempt count for the +/// present-condemned shape: open #1 is the ordinary conditional-create attempt (streamed, refused at +/// finalize because the body exists), open #2 is the resurrect itself. Anything ABOVE two would mean +/// a hidden materialization pass or a mid-write re-open crept in; anything below would mean the +/// create attempt stopped streaming (a protocol change, not an optimization to make silently). +TEST(CASUploadFanout, CondemnedLocalResurrectOpensTheSourcePerAttempt) +{ + auto b = std::make_shared(); + auto s = openPool(b); + const RootNamespace ns{"srv1/nsResLocalOpens"}; + auto build = precommitBuildFor(s, ns, "part"); + + const String payload = "condemned-local-open-count-payload"; + seedPresentBody(*b, s->layout(), s->poolMeta(), payload); + writeMetaClean(*b, s->layout(), u128Of(payload), payload.size()); + condemnMeta(*b, s->layout(), u128Of(payload), /*condemn_round=*/17); + + int opens = 0; + BlobSource source; + source.size = payload.size(); + source.open = [&opens, payload]() -> std::unique_ptr + { + ++opens; + return std::make_unique(payload); + }; + + std::vector reqs{BlobUploadRequest{idOf(payload), std::move(source), payload.size()}}; + auto pool = makePool(2); + fanOutBlobUploads(*build, reqs, *pool, nullptr); + + EXPECT_EQ(opens, 2) << "conditional-create attempt + resurrect: exactly one open each, nothing extra"; + EXPECT_TRUE(build->depIsTokened(idOf(payload))); +} + +/// Test 2, condemned-S3 duplicate pair resurrects content-correctly: two duplicate S3-staging records +/// for one condemned ref collapse to ONE resurrect task; the fresh incarnation displaces the condemned +/// one (token changes, meta returns to Clean) and the content is the staging object's payload. +TEST(CASUploadFanout, DuplicateCondemnedS3ResurrectsCorrectly) +{ + auto b = std::make_shared(); + auto s = openPool(b); + const RootNamespace ns{"srv1/nsDupResS3"}; + auto build = precommitBuildFor(s, ns, "part"); + + const String payload = "dup-condemned-s3-payload"; + const String staging_key = "p/staging/mount1/dup-resurrect.tmp"; + EnvelopeHeader h; + h.kind = ObjectKind::Blob; + h.incarnation_tag = DB::UInt128(0xC0FFEE); + const String staging_bytes = encodeEnvelopeHeader(h, static_cast(s->poolMeta().blob_header_len)) + payload; + b->putIfAbsent(staging_key, staging_bytes); + b->putIfAbsent(s->layout().blobKey(idOf(payload)), staging_bytes); + writeMetaClean(*b, s->layout(), u128Of(payload), payload.size()); + condemnMeta(*b, s->layout(), u128Of(payload), /*condemn_round=*/11); + + const Token condemned_token = b->head(s->layout().blobKey(idOf(payload))).token; + + std::atomic dispatched{0}; + BlobUploadFanoutHooksForTest hooks; + hooks.on_dispatch = [&](const BlobRef &) { ++dispatched; }; + + std::vector reqs{s3Request(payload, staging_key), s3Request(payload, staging_key)}; + auto pool = makePool(4); + fanOutBlobUploads(*build, reqs, *pool, &hooks); + + EXPECT_EQ(dispatched.load(), 1) << "duplicate condemned records collapse to one resurrect task"; + EXPECT_TRUE(build->depIsTokened(idOf(payload))); + const Token after_token = b->head(s->layout().blobKey(idOf(payload))).token; + EXPECT_NE(after_token.value, condemned_token.value) << "a fresh incarnation displaced the condemned one"; + EXPECT_EQ(metaStateAt(*b, s->layout(), payload), std::optional(MetaState::Clean)); + EXPECT_EQ(logicalPayloadAt(*b, s->layout().blobKey(idOf(payload)), s->poolMeta().blob_header_len), payload); +} + +/// Test 3: one task fails (a poisoned source), one sibling succeeds. Merge-nothing means the build stays +/// at its pre-fan-out state; the abandoned precommit turns the successful sibling's uploaded body into +/// ORDINARY GC-reclaimable debris (NOT a new orphan class) -- a GC round reclaims it. +TEST(CASUploadFanout, MergeNothingOnFailure) +{ + auto b = std::make_shared(); + auto s = openPool(b); + const RootNamespace ns{"srv1/nsMergeNothing"}; + + const String good = "merge-nothing-good-sibling"; + const String poisoned = "merge-nothing-poisoned"; + + PartWriteInfo info; + info.intended_ref = ns.string() + "/part"; + auto build = s->beginPartWrite(info); + /// The precommit names BOTH blobs (the durable manifest edge the real writer establishes before any + /// upload), so the successful sibling's body is edge-protected until the precommit is abandoned. + const ManifestId id = build->stageManifest({blobEntryFor("data.bin", u128Of(good), good.size()), + blobEntryFor("data.cmrk3", u128Of(poisoned), poisoned.size())}); + build->precommitAdd(ns, "part", id); + + /// Poison the failing sibling via the in-task seam: throw a plain (non-LOGICAL, non-ABORTED) + /// exception so it is neither retried nor an abort under sanitizer builds. + BlobUploadFanoutHooksForTest hooks; + hooks.in_task = [&](const BlobRef & ref) + { + if (ref == idOf(poisoned)) + throw DB::Exception(DB::ErrorCodes::INCORRECT_DATA, "poisoned upload source (test)"); + }; + + std::vector reqs{localRequest(good), localRequest(poisoned)}; + auto pool = makePool(4); + expectThrowsCode(DB::ErrorCodes::INCORRECT_DATA, [&] + { + fanOutBlobUploads(*build, reqs, *pool, &hooks); + }); + + /// Merge-nothing: the build recorded NO dep, even though the good sibling's body was uploaded. + EXPECT_FALSE(build->depIsTokened(idOf(good))); + EXPECT_FALSE(build->depIsTokened(idOf(poisoned))); + EXPECT_EQ(build->depsSnapshotForTest().size(), 0u); + + /// Abandon the precommit (the existing failure path), then GC reclaims the orphaned sibling body. + build->abandon(); + s->renewWatermarkOnce(); + Gc gc(s, DB::Cas::hexToU128("00000000000000000000000000000001")); + EXPECT_TRUE(runRoundsUntilAbsent(s, gc, *b, s->layout(), u128Of(good))) + << "the successful sibling's body is ordinary GC-reclaimable debris after abandon"; + EXPECT_TRUE(blobAbsent(*b, s->layout(), u128Of(poisoned))) << "the poisoned sibling never uploaded a body"; +} + +/// Test 4: two DISTINCT-ref fresh uploads, latch-crossed so both are inside `uploadBlobDetached` (hence +/// both touching the ONE shared dedup cache) at once. The cache's internal locking makes the concurrent +/// insertion correct; on the TSan lane this pins that there is no data race on the shared cache. +TEST(CASUploadFanout, ConcurrentDeduplicationCacheInsertion) +{ + auto b = std::make_shared(); + auto s = openPool(b); + const RootNamespace ns{"srv1/nsCacheRace"}; + auto build = precommitBuildFor(s, ns, "part"); + + const String pa = "cache-race-a"; + const String pb = "cache-race-b"; + + /// A latch of 2 crossed with a pool of 2 cannot hang: both tasks are guaranteed to run concurrently + /// (the calling thread never occupies a pool slot), so both reach the latch and release together. + std::latch both_in{2}; + BlobUploadFanoutHooksForTest hooks; + hooks.in_task = [&](const BlobRef &) { both_in.arrive_and_wait(); }; + + std::vector reqs{localRequest(pa), localRequest(pb)}; + auto pool = makePool(2); + fanOutBlobUploads(*build, reqs, *pool, &hooks); + + EXPECT_TRUE(build->depIsTokened(idOf(pa))); + EXPECT_TRUE(build->depIsTokened(idOf(pb))); + EXPECT_TRUE(s->dedupCacheContains(idOf(pa))) << "concurrent insertion left both keys present"; + EXPECT_TRUE(s->dedupCacheContains(idOf(pb))); +} + +/// Test 5: pool saturation is bounded. Eight blobs run through a pool of 2 (peak concurrency 2 is +/// observed) and a pool of 1 (peak concurrency 1 -- the single worker cannot self-wait for a second +/// concurrent task, so it FAILS FAST via the bounded wait). Both configurations complete every upload: +/// pool size 1 correctly degenerates to serial without deadlock. +TEST(CASUploadFanout, PoolSaturationBounded) +{ + constexpr int kBlobs = 8; + /// Bounds are DECOUPLED by pool size. Pool 2 will reach the 2-task rendezvous in microseconds under + /// any realistic load, so its bound is generous (10s) purely as a hang guard -- it is essentially + /// never waited out (and `entered == total` releases any final straggler). Pool 1 CANNOT form a pair + /// (its single worker is occupied by the waiting task while the caller thread only joins), so its + /// first waiter must time out; 500ms is far above the microseconds a real pair needs, yet keeps the + /// serial run fast. + auto runEight = [](size_t pool_size, ConcurrencyProbe & probe, std::chrono::milliseconds bound) + { + auto b = std::make_shared(); + auto s = openPool(b); + const RootNamespace ns{"srv1/nsSaturate"}; + auto build = precommitBuildFor(s, ns, "part"); + + std::vector reqs; + std::vector payloads; + for (int i = 0; i < kBlobs; ++i) + { + payloads.push_back("saturate-payload-" + std::to_string(i)); + reqs.push_back(localRequest(payloads.back())); + } + probe.total = kBlobs; + + BlobUploadFanoutHooksForTest hooks; + hooks.in_task = [&, bound](const BlobRef &) { probe.enter(2, bound); }; + + auto pool = makePool(pool_size); + fanOutBlobUploads(*build, reqs, *pool, &hooks); + + for (const auto & p : payloads) + EXPECT_TRUE(build->depIsTokened(idOf(p))) << "every blob uploaded (pool_size=" << pool_size << ")"; + }; + + ConcurrencyProbe probe2; + runEight(2, probe2, std::chrono::seconds(10)); + EXPECT_EQ(probe2.peak, 2) << "pool of 2 runs two blob uploads concurrently"; + EXPECT_FALSE(probe2.timed_out) << "pool of 2 forms a pair without hitting the bound"; + + ConcurrencyProbe probe1; + runEight(1, probe1, std::chrono::milliseconds(500)); + EXPECT_EQ(probe1.peak, 1) << "pool of 1 degenerates to serial (never occupies the caller thread's slot)"; + EXPECT_TRUE(probe1.timed_out) << "the single worker fails fast on the bounded wait instead of hanging"; +} + +/// Test 6a: even when one task fails immediately, the join drains EVERY task before the failure surfaces. +/// A failing task counts down an event and throws; a sibling waits for that event, then uploads. The +/// fan-out rethrows only after the join, so the sibling's body is present in the backend by the time the +/// caller observes the failure. +TEST(CASUploadFanout, DrainPrecedesUnwind) +{ + auto b = std::make_shared(); + auto s = openPool(b); + const RootNamespace ns{"srv1/nsDrain"}; + auto build = precommitBuildFor(s, ns, "part"); + + const String failing = "drain-failing"; + const String slow = "drain-slow-sibling"; + + BoundedEvent failing_threw; + BlobUploadFanoutHooksForTest hooks; + hooks.in_task = [&](const BlobRef & ref) + { + if (ref == idOf(failing)) + { + failing_threw.fire(); + throw DB::Exception(DB::ErrorCodes::INCORRECT_DATA, "drain-test failing task"); + } + else + { + /// 5-second bound: the failing task fires the event in microseconds; the bound only guards + /// against a hang if the failing task never runs (a design regression). + (void)failing_threw.wait(std::chrono::seconds(5)); + } + }; + + std::vector reqs{localRequest(failing), localRequest(slow)}; + auto pool = makePool(2); + expectThrowsCode(DB::ErrorCodes::INCORRECT_DATA, [&] + { + fanOutBlobUploads(*build, reqs, *pool, &hooks); + }); + + EXPECT_TRUE(b->head(s->layout().blobKey(idOf(slow))).exists) + << "the sibling's upload was drained by the join before the failure surfaced"; + EXPECT_FALSE(build->depIsTokened(idOf(slow))) << "merge-nothing: the drained sibling's dep is not merged"; +} + +/// Test 6b: a throw injected DURING the dispatch loop (before all tasks are enqueued) still drains the +/// tasks already scheduled -- the fan-out drains every already-scheduled task on the unwinding path +/// before the captured storage is destroyed (the B90 lesson). The first task's body is present after the +/// dispatch throw is caught. +/// +/// The throw is GATED on the first task actually entering its body: the runner marks tasks that are +/// still SCHEDULED as CANCELLED during unwind and skips waiting for a cancelled task, so a throw fired +/// before the first task's body ran could cancel it and leave its body ABSENT -- a real flakiness the +/// gate removes. Once the first task's `in_task` hook has fired, that task is past SCHEDULED (RUNNING), +/// so it can no longer be cancelled and the drain deterministically waits for its upload. +TEST(CASUploadFanout, DispatchThrowStillDrains) +{ + auto b = std::make_shared(); + auto s = openPool(b); + const RootNamespace ns{"srv1/nsDispatchThrow"}; + auto build = precommitBuildFor(s, ns, "part"); + + const String first = "dispatch-throw-first"; + const String second = "dispatch-throw-second"; + /// Dispatch runs in ascending-ref order, so the SMALLER-ref payload is the one enqueued before the + /// second dispatch throws. + const String enqueued = (idOf(first) < idOf(second)) ? first : second; + + BoundedEvent first_task_running; + std::atomic dispatch_calls{0}; + BlobUploadFanoutHooksForTest hooks; + hooks.in_task = [&](const BlobRef & ref) + { + if (ref == idOf(enqueued)) + first_task_running.fire(); + }; + hooks.on_dispatch = [&](const BlobRef &) + { + if (++dispatch_calls == 2) + { + /// Wait until the first task's body has entered before throwing, so it is RUNNING (not + /// SCHEDULED) and the unwind cannot cancel it. Pool size 2 guarantees the first task gets a + /// worker while this (dispatch) thread waits; 10s is a pure hang guard, never a sequencer. + EXPECT_TRUE(first_task_running.wait(std::chrono::seconds(10))) + << "the first dispatched task must reach its body before the dispatch throw"; + throw DB::Exception(DB::ErrorCodes::INCORRECT_DATA, "dispatch-loop throw (test)"); + } + }; + + std::vector reqs{localRequest(first), localRequest(second)}; + auto pool = makePool(2); + expectThrowsCode(DB::ErrorCodes::INCORRECT_DATA, [&] + { + fanOutBlobUploads(*build, reqs, *pool, &hooks); + }); + + EXPECT_EQ(dispatch_calls.load(), 2) << "the throw fired on the second dispatch"; + /// The already-RUNNING first task was drained before the stack unwound, so its body is present + /// although nothing was merged. + EXPECT_TRUE(b->head(s->layout().blobKey(idOf(enqueued))).exists) + << "the already-dispatched task was drained before the stack unwound"; + EXPECT_EQ(build->depsSnapshotForTest().size(), 0u) << "merge-nothing on a dispatch throw"; +} + + + + +/// Test 6c (codex stage-1 review, Critical): a throw at the TRACKING-PUBLICATION seam still drains every +/// already-scheduled task before the captured `results` storage is destroyed. In the broken form a task +/// could be scheduled-but-untracked at the throw and run later against freed `results` (a +/// heap-use-after-free); the fix schedules-and-tracks in ONE no-throw step (pre-reserved handle vector) +/// and joins via a scope-exit drain guard, so a seam throw finds every scheduled task already tracked and +/// drains it. The throw is gated on the first task RUNNING (same reason as `DispatchThrowStillDrains`) so +/// its drain is deterministic; under ASan this run is UAF-clean -- the regression signature of a +/// scheduled-but-untracked task is a heap-use-after-free on `results` here. +TEST(CASUploadFanout, TrackingSeamThrowStillDrains) +{ + auto b = std::make_shared(); + auto s = openPool(b); + const RootNamespace ns{"srv1/nsTrackSeam"}; + auto build = precommitBuildFor(s, ns, "part"); + + const String first = "track-seam-first"; + const String second = "track-seam-second"; + /// Dispatch is ascending-ref, so the smaller ref is enqueued first. + const String smaller = (idOf(first) < idOf(second)) ? first : second; + + BoundedEvent first_task_running; + std::atomic enqueue_calls{0}; + BlobUploadFanoutHooksForTest hooks; + hooks.in_task = [&](const BlobRef & ref) + { + if (ref == idOf(smaller)) + first_task_running.fire(); + }; + hooks.after_enqueue = [&](const BlobRef &) + { + /// Throw at the tracking seam of the SECOND enqueue -- by then both tasks are scheduled and (in + /// the fixed code) tracked, so the drain guard must join both. Gate on the first task RUNNING so + /// the drain cannot race a still-SCHEDULED cancellation; 10s is a pure hang guard. + if (++enqueue_calls == 2) + { + EXPECT_TRUE(first_task_running.wait(std::chrono::seconds(10))) + << "the first task must be RUNNING before the tracking-seam throw"; + throw DB::Exception(DB::ErrorCodes::INCORRECT_DATA, "tracking-seam throw (test)"); + } + }; + + std::vector reqs{localRequest(first), localRequest(second)}; + auto pool = makePool(2); + expectThrowsCode(DB::ErrorCodes::INCORRECT_DATA, [&] + { + fanOutBlobUploads(*build, reqs, *pool, &hooks); + }); + + /// The already-scheduled first task was drained before `results` was destroyed, so its body is + /// present; nothing was merged (merge-nothing on any fan-out throw). + EXPECT_TRUE(b->head(s->layout().blobKey(idOf(smaller))).exists) + << "an already-scheduled task was not drained before the stack unwound"; + EXPECT_EQ(build->depsSnapshotForTest().size(), 0u) << "merge-nothing on a tracking-seam throw"; +} diff --git a/src/Disks/tests/gtest_cas_wire_vocab.cpp b/src/Disks/tests/gtest_cas_wire_vocab.cpp new file mode 100644 index 000000000000..efbe3da7a0ae --- /dev/null +++ b/src/Disks/tests/gtest_cas_wire_vocab.cpp @@ -0,0 +1,53 @@ +#include +#include +#include +#include +#include + +using namespace DB::Cas; + +namespace DB::ErrorCodes { extern const int CORRUPTED_DATA; } + +TEST(CASWireVocab, EnumWordsRoundTrip) +{ + for (TokenType t : {TokenType::ETag, TokenType::Generation, TokenType::Emulated}) + EXPECT_EQ(tokenTypeFromWord(tokenTypeToWord(t), "t"), t); + for (BlobHashAlgo a : {BlobHashAlgo::CityHash128, BlobHashAlgo::XXH3_128, BlobHashAlgo::Sha256}) + EXPECT_EQ(blobHashAlgoFromWord(blobHashAlgoName(a), "a"), a); + EXPECT_EQ(objectKindFromWord(objectKindToWord(ObjectKind::Blob), "k"), ObjectKind::Blob); + EXPECT_THROW(tokenTypeFromWord("nope", "t"), DB::Exception); + EXPECT_THROW(blobHashAlgoFromWord("nope", "a"), DB::Exception); +} + +TEST(CASWireVocab, SiblingFieldsWriteAndReadBack) +{ + CasJsonWriter out; + bool first = true; + writeTokenFields(out, first, Token{"etag-abc\"x", TokenType::ETag}); + const BlobRef ref{BlobHashAlgo::CityHash128, BlobDigest::fromU128(hexToU128("00112233445566778899aabbccddeeff"))}; + writeBlobRefFields(out, first, ref); + closeObject(out, first); + const String rendered = std::move(out).take(); + EXPECT_EQ(rendered, + R"({"tt":"etag","tv":"etag-abc\"x","ha":"ch128","h":"00112233445566778899aabbccddeeff"})"); + + DB::ReadBufferFromMemory in(rendered.data(), rendered.size()); + JsonObjectReader r(in, KeyStrictness::Tolerant, "t"); + String key; + String tv; + String ha; + String h; + TokenType tt{}; + while (r.nextKey(key)) + { + if (key == "tt") tt = tokenTypeFromWord(r.readString(), "t"); + else if (key == "tv") tv = r.readString(); + else if (key == "ha") ha = r.readString(); + else if (key == "h") h = r.readString(); + else r.skipUnknown(key); + } + EXPECT_EQ(tt, TokenType::ETag); + EXPECT_EQ(tv, "etag-abc\"x"); + const BlobRef back{blobHashAlgoFromWord(ha, "a"), codecFor(blobHashAlgoFromWord(ha, "a")).fromHex(h)}; + EXPECT_EQ(back, ref); +} diff --git a/src/Disks/tests/gtest_cas_writer_duties.cpp b/src/Disks/tests/gtest_cas_writer_duties.cpp new file mode 100644 index 000000000000..662c0daa165e --- /dev/null +++ b/src/Disks/tests/gtest_cas_writer_duties.cpp @@ -0,0 +1,545 @@ +#include + +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include + +namespace DB::ErrorCodes +{ +extern const int NETWORK_ERROR; +} + +using namespace DB::Cas; + +namespace +{ + +PoolConfig singleAttemptConfig() +{ + PoolConfig config{ + .pool_prefix = "p", + .server_root_id = "test", + .background_watermark = false, + }; + config.cas_request_budget.max_attempts = 1; + config.cas_request_budget.attempt_timeout_ms = 100; + config.cas_request_budget.operation_deadline_ms = 5000; + config.cas_request_budget.lease_safety_margin_ms = 100; + return config; +} + +PoolPtr openSingleAttemptPool(const BackendPtr & backend) +{ + DB::Cas::tests::seedPoolMetaForRestart(*backend); + return Pool::open(backend, singleAttemptConfig()); +} + +PoolPtr openFrozenSingleAttemptPool(const BackendPtr & backend) +{ + DB::Cas::tests::seedPoolMetaForRestart(*backend); + PoolConfig config = singleAttemptConfig(); + config.boot_ms_fn = [] { return uint64_t{0}; }; + config.mount_renew_period = std::chrono::hours{1}; + return Pool::open(backend, config); +} + +PartWriteTxnPtr stageEmptyManifest( + const PoolPtr & store, const RootNamespace & ns, const String & ref_name, ManifestId & id) +{ + PartWriteInfo info; + info.intended_namespace = ns; + info.intended_ref = ns.string() + "/" + ref_name; + auto build = store->beginPartWrite(std::move(info)); + id = build->stageManifest({}); + return build; +} + +void publishEmptyRef(const PoolPtr & store, const RootNamespace & ns, const String & ref_name) +{ + ManifestId id; + auto build = stageEmptyManifest(store, ns, ref_name, id); + build->precommitAdd(ns, ref_name, id); + build->promote(ns, ref_name, build->buildId(), id); +} + +uint64_t leaveRejectedCleanupDuty(const PoolPtr & store, const RootNamespace & ns) +{ + ManifestId rejected_id; + auto rejected = stageEmptyManifest(store, ns, "rejected", rejected_id); + const uint64_t rejected_seq = rejected->buildSeq(); + + store->setMountDeadline(100); + DB::Cas::tests::expectThrowsCode( + DB::ErrorCodes::NETWORK_ERROR, + [&] { rejected->precommitAdd(ns, "rejected", rejected_id); }); + EXPECT_EQ(rejected->precommitState(), PartWriteTxn::PrecommitState::Uncertain); + + rejected.reset(); + EXPECT_EQ(store->minActive(), rejected_seq); + store->setMountDeadline(30000); + return rejected_seq; +} + +} + +/// Removing the deferred-cleanup transfer from `~PartWriteTxn` makes this test fail at the first +/// `minActive` assertion: the old unconditional destructor retirement advances the build floor while +/// the owner-grant outcome is still unknown. The later assertions pin the other half of the duty: the +/// next mutation resolves the durable wedge, removes the exact old precommit, and only then retires it. +TEST(CASWriterDuties, UncertainAdoptedGrantStaysActiveUntilTheNextMutationRemovesIt) +{ + auto backend = std::make_shared(); + auto store = openSingleAttemptPool(backend); + const RootNamespace ns{"srv1/writer_duty_adopt"}; + DB::Cas::tests::casAdmitRecoverableEntry(*backend, store->layout(), ns, store->liveWriterEpoch()); + + ManifestId abandoned_id; + auto abandoned = stageEmptyManifest(store, ns, "abandoned", abandoned_id); + const uint64_t abandoned_seq = abandoned->buildSeq(); + const String abandoned_manifest_key = store->layout().manifestKey(abandoned_id); + + backend->fault_substr = store->layout().namespaceStreamPrefix(DB::Cas::tests::fixture::fixtureLife(ns)) + "_log/"; + backend->mode = DB::Cas::tests::ChunkFaultBackend::Mode::LandedThenLost; + backend->fault_count = 1; + DB::Cas::tests::expectThrowsCode( + DB::ErrorCodes::NETWORK_ERROR, + [&] { abandoned->precommitAdd(ns, "abandoned", abandoned_id); }); + ASSERT_TRUE(store->refLaneWedgedForTest(ns)); + ASSERT_EQ(abandoned->precommitState(), PartWriteTxn::PrecommitState::Uncertain); + + abandoned.reset(); + EXPECT_EQ(store->minActive(), abandoned_seq) + << "an unresolved owner grant must keep its build active after the transaction object is gone"; + + backend->mode = DB::Cas::tests::ChunkFaultBackend::Mode::None; + ManifestId successor_id; + auto successor = stageEmptyManifest(store, ns, "successor", successor_id); + const uint64_t successor_seq = successor->buildSeq(); + successor->precommitAdd(ns, "successor", successor_id); + + EXPECT_FALSE(store->refLaneWedgedForTest(ns)); + EXPECT_EQ(store->minActive(), successor_seq) + << "the abandoned build retires only after its exact cleanup duty settles"; + EXPECT_EQ( + store->livePrecommitsForTest(ns), + (std::set>{{"successor", successor_id.ref}})); + EXPECT_TRUE(backend->head(abandoned_manifest_key).exists) + << "the removed precommit body remains GC-owned until its decrement is sealed"; + + successor->abandon(); + EXPECT_TRUE(store->livePrecommitsForTest(ns).empty()); +} + +/// Removing the absent-owner arm makes the deferred duty either stay forever or try to remove an owner +/// that was never transmitted. A controller pre-attempt refusal proves the grant absent; the next +/// healthy mutation must drain that duty as a no-op and retire the old build before publishing itself. +TEST(CASWriterDuties, ProvenAbsentGrantDrainsAsNoOpBeforeTheNextMutation) +{ + auto backend = std::make_shared(); + DB::Cas::tests::seedPoolMetaForRestart(*backend); + PoolConfig config = singleAttemptConfig(); + config.boot_ms_fn = [] { return uint64_t{0}; }; + config.mount_renew_period = std::chrono::hours{1}; + auto store = Pool::open(backend, config); + const RootNamespace ns{"srv1/writer_duty_reject"}; + + ManifestId rejected_id; + auto rejected = stageEmptyManifest(store, ns, "rejected", rejected_id); + const uint64_t rejected_seq = rejected->buildSeq(); + + store->setMountDeadline(100); + DB::Cas::tests::expectThrowsCode( + DB::ErrorCodes::NETWORK_ERROR, + [&] { rejected->precommitAdd(ns, "rejected", rejected_id); }); + ASSERT_FALSE(store->refLaneWedgedForTest(ns)); + ASSERT_EQ(rejected->precommitState(), PartWriteTxn::PrecommitState::Uncertain); + + rejected.reset(); + EXPECT_EQ(store->minActive(), rejected_seq) + << "the destructor cannot retire even an uncertain grant whose rejection has not been consumed"; + + store->setMountDeadline(30000); + ManifestId successor_id; + auto successor = stageEmptyManifest(store, ns, "successor", successor_id); + const uint64_t successor_seq = successor->buildSeq(); + successor->precommitAdd(ns, "successor", successor_id); + + EXPECT_EQ(store->minActive(), successor_seq); + EXPECT_EQ( + store->livePrecommitsForTest(ns), + (std::set>{{"successor", successor_id.ref}})); + + successor->abandon(); + EXPECT_TRUE(store->livePrecommitsForTest(ns).empty()); +} + +/// The model gate recorded both wedge-resolution witnesses (adopt and reject); the C++ suite drove +/// only the adopt arm above. This drives an uncertain grant into an ACTUAL wedged lane -- unlike +/// `ProvenAbsentGrantDrainsAsNoOpBeforeTheNextMutation`'s controller pre-attempt refusal, which never +/// wedges at all -- and resolves it as REJECT: `Mode::Unresolved` lands nothing, so the next attempt's +/// resolve-before-reissue GET proves the key absent. The duty must then drain as a no-op: no +/// `OwnerTransition` removal is owed for an absent precommit, the wedge clears, and `minActive` advances +/// past the rejected build exactly as the no-wedge reject arm does. +TEST(CASWriterDuties, WedgeResolvedAsRejectDrainsTheDutyAsNoOp) +{ + auto backend = std::make_shared(); + auto store = openSingleAttemptPool(backend); + const RootNamespace ns{"srv1/writer_duty_wedge_reject"}; + DB::Cas::tests::casAdmitRecoverableEntry(*backend, store->layout(), ns, store->liveWriterEpoch()); + + ManifestId rejected_id; + auto rejected = stageEmptyManifest(store, ns, "rejected", rejected_id); + const uint64_t rejected_seq = rejected->buildSeq(); + + backend->fault_substr = store->layout().namespaceStreamPrefix(DB::Cas::tests::fixture::fixtureLife(ns)) + "_log/"; + backend->mode = DB::Cas::tests::ChunkFaultBackend::Mode::Unresolved; + backend->fault_count = 1; + DB::Cas::tests::expectThrowsCode( + DB::ErrorCodes::NETWORK_ERROR, + [&] { rejected->precommitAdd(ns, "rejected", rejected_id); }); + ASSERT_TRUE(store->refLaneWedgedForTest(ns)); + ASSERT_EQ(rejected->precommitState(), PartWriteTxn::PrecommitState::Uncertain); + + rejected.reset(); + EXPECT_EQ(store->minActive(), rejected_seq) + << "an unresolved owner grant must keep its build active after the transaction object is gone"; + + backend->mode = DB::Cas::tests::ChunkFaultBackend::Mode::None; + ManifestId successor_id; + auto successor = stageEmptyManifest(store, ns, "successor", successor_id); + const uint64_t successor_seq = successor->buildSeq(); + successor->precommitAdd(ns, "successor", successor_id); + + EXPECT_FALSE(store->refLaneWedgedForTest(ns)); + EXPECT_EQ(store->minActive(), successor_seq) + << "the rejected build retires only after its exact cleanup duty settles as a no-op"; + EXPECT_EQ( + store->livePrecommitsForTest(ns), + (std::set>{{"successor", successor_id.ref}})); + + successor->abandon(); + EXPECT_TRUE(store->livePrecommitsForTest(ns).empty()); +} + +/// Removing `mutateRefsAfterWriterCleanup` from the `dropRef` delegate leaves the rejected build at +/// `minActive` even though the ref removal succeeds. The observable floor proves the direct API +/// serviced the inherited cleanup duty before performing its own mutation. +TEST(CASWriterDuties, DropRefServicesPendingDutyBeforeRemovingTheRef) +{ + auto backend = std::make_shared(); + auto store = openFrozenSingleAttemptPool(backend); + const RootNamespace ns{"srv1/writer_duty_drop_ref"}; + publishEmptyRef(store, ns, "target"); + leaveRejectedCleanupDuty(store, ns); + + store->dropRef(ns, "target"); + + EXPECT_FALSE(store->resolveRef(ns, "target").has_value()); + EXPECT_EQ(store->minActive(), store->peekNextBuildSeq()); +} + +/// Removing the shared drain seam from `updateRefPublishedAt` lets the timestamp mutation overtake a +/// pending writer duty. The update remains observable, while the independent watermark assertion +/// catches that bypass. +TEST(CASWriterDuties, UpdateRefPublishedAtServicesPendingDutyBeforeUpdatingTheRef) +{ + auto backend = std::make_shared(); + auto store = openFrozenSingleAttemptPool(backend); + const RootNamespace ns{"srv1/writer_duty_update_ref"}; + publishEmptyRef(store, ns, "target"); + leaveRejectedCleanupDuty(store, ns); + + store->updateRefPublishedAt(ns, "target", [](RefPublishedAtUpdate & update) { update.published_at_ms = 17; }); + + const auto resolved = store->resolveRef(ns, "target"); + ASSERT_TRUE(resolved.has_value()); + EXPECT_EQ(resolved->published_at_ms, 17); + EXPECT_EQ(store->minActive(), store->peekNextBuildSeq()); +} + +/// Each public namespace-removal overload has its own Pool delegate. Omitting the shared seam from +/// either one still removes the namespace but strands the rejected build at the active floor, so the +/// two independent cases protect both forwarding paths. +TEST(CASWriterDuties, DropNamespaceOverloadsServicePendingDutyBeforeRemoval) +{ + { + auto backend = std::make_shared(); + auto store = openFrozenSingleAttemptPool(backend); + const RootNamespace ns{"srv1/writer_duty_drop_namespace"}; + publishEmptyRef(store, ns, "target"); + leaveRejectedCleanupDuty(store, ns); + + store->dropNamespace(ns); + + EXPECT_TRUE(store->listRefs(ns).empty()); + EXPECT_EQ(store->minActive(), store->peekNextBuildSeq()); + } + + { + auto backend = std::make_shared(); + auto store = openFrozenSingleAttemptPool(backend); + const RootNamespace ns{"srv1/writer_duty_drop_namespace_life"}; + publishEmptyRef(store, ns, "target"); + const NamespaceLifeId life = store->namespaceLife(ns); + leaveRejectedCleanupDuty(store, ns); + + store->dropNamespace(life); + + EXPECT_TRUE(store->listRefs(ns).empty()); + EXPECT_EQ(store->minActive(), store->peekNextBuildSeq()); + } +} + +/// The explicit snapshot/checkpoint attempt is the audited sibling mutation: without the common seam +/// it may publish ledger state while leaving the older writer duty pinned. Its return value is allowed +/// to be false; advancing the active floor is the cleanup contract under test. +TEST(CASWriterDuties, SnapshotAttemptServicesPendingDutyBeforePublishingLedgerState) +{ + auto backend = std::make_shared(); + auto store = openFrozenSingleAttemptPool(backend); + const RootNamespace ns{"srv1/writer_duty_snapshot"}; + publishEmptyRef(store, ns, "target"); + leaveRejectedCleanupDuty(store, ns); + + static_cast(store->tryPublishSnapshotAndAdvanceCheckpointOnce(ns)); + + EXPECT_EQ(store->minActive(), store->peekNextBuildSeq()); +} + +/// Removing the pending-duty term from `Pool` teardown makes this test fail at the farewell +/// assertion: a clean marker would falsely certify that the durable precommit below has no remaining +/// writer work. The unclean handoff forces a fresh writer epoch; its arithmetic recovery seal then +/// makes the ordinary stale-precommit sweep the crash-remnant cleanup path. +TEST(CASWriterDuties, PendingDutySkipsCleanFarewellAndSuccessorSweepsTheCrashRemnant) +{ + auto backend = std::make_shared(); + DB::Cas::tests::seedPoolMetaForRestart(*backend); + const CasRequestBudget budget{ + .attempt_timeout_ms = 50, + .operation_deadline_ms = 500, + .max_attempts = 1, + .lease_safety_margin_ms = 50, + }; + const RootNamespace ns{"srv1/writer_duty_crash"}; + + auto predecessor = Pool::open(backend, PoolConfig{ + .pool_prefix = "p", + .server_id = UInt128(1), + .server_root_id = "test", + .background_watermark = false, + .mount_lease_ttl_ms = std::chrono::milliseconds(500), + .mount_renew_period = std::chrono::milliseconds(100), + .cas_request_budget = budget, + }); + DB::Cas::tests::casAdmitRecoverableEntry( + *backend, predecessor->layout(), ns, predecessor->liveWriterEpoch()); + + ManifestId abandoned_id; + auto abandoned = stageEmptyManifest(predecessor, ns, "abandoned", abandoned_id); + abandoned->precommitAdd(ns, "abandoned", abandoned_id); + const uint64_t predecessor_epoch = predecessor->writerEpoch(); + const Layout layout = predecessor->layout(); + const String mount_key = layout.mountKey("test"); + + abandoned.reset(); + predecessor.reset(); + + const auto mount = backend->get(mount_key); + ASSERT_TRUE(mount.has_value()); + EXPECT_NE(decodeMountLease(mount->bytes).min_active, std::numeric_limits::max()) + << "a live writer-cleanup duty forbids the clean-release certificate"; + + uint64_t fake_boot = 0; + std::vector waits; + auto successor_store = Pool::open(backend, PoolConfig{ + .pool_prefix = "p", + .server_id = UInt128(1), + .server_root_id = "test", + .background_watermark = false, + .mount_lease_ttl_ms = std::chrono::milliseconds(500), + .mount_renew_period = std::chrono::milliseconds(100), + .cas_request_budget = budget, + .boot_ms_fn = [&] { return fake_boot; }, + .wait_sleep_fn = [&](uint64_t ms) { fake_boot += ms; waits.push_back(ms); }, + }); + ASSERT_GT(successor_store->writerEpoch(), predecessor_epoch); + ASSERT_FALSE(waits.empty()) << "the predecessor supplied no clean-death certificate"; + + ManifestId successor_id; + auto successor = stageEmptyManifest(successor_store, ns, "successor", successor_id); + successor->precommitAdd(ns, "successor", successor_id); + + EXPECT_EQ( + successor_store->livePrecommitsForTest(ns), + (std::set>{{"successor", successor_id.ref}})); + const auto seal = successor_store->lastEpochSealForTest(ns); + ASSERT_TRUE(seal.has_value()); + EXPECT_EQ(seal->writer_epoch, predecessor_epoch); + + successor->abandon(); +} + +/// The duty queue above only ever resolves the ref +/// table's precommit BINDING -- a rejected grant's manifest BODY is orphan from birth (no owner ever +/// named it, so the edge-before-observe `+1` a durable precommit would have folded never landed +/// either) and its reclaim is entirely the orphan sweep's job, gated on the one thing the duty queue +/// cannot give it: the build's own epoch durably closed. This drives that closure (the same crash +/// pattern as `PendingDutySkipsCleanFarewellAndSuccessorSweepsTheCrashRemnant`, but the predecessor's +/// build is REJECTED rather than adopted) and then runs real GC rounds until the body is gone. +TEST(CASWriterDuties, RejectedAttemptBodyIsEventuallyNominatedAndSwept) +{ + auto backend = std::make_shared(); + DB::Cas::tests::seedPoolMetaForRestart(*backend); + const CasRequestBudget budget{ + .attempt_timeout_ms = 50, + .operation_deadline_ms = 500, + .max_attempts = 1, + .lease_safety_margin_ms = 50, + }; + /// Rooted under the POOL's OWN `server_root_id` ("test", unlike this file's other fixtures, which + /// stay under "srv1" precisely because they never drive the orphan sweep): `prefixEligible`'s + /// watermark floor is looked up by walking the NAMESPACE's own prefix segments for a live mount + /// lease, so a namespace rooted under any other server-root would find no floor and retain forever + /// regardless of epoch/coverage. + const RootNamespace ns{"test/writer_duty_rejected_sweep"}; + + auto predecessor = Pool::open(backend, PoolConfig{ + .pool_prefix = "p", + .server_id = UInt128(1), + .server_root_id = "test", + .manifest_sweep_list_budget_keys = 100, + .manifest_sweep_delete_budget_keys = 100, + .gc_fold_max_defer_rounds = 0, + .background_watermark = false, + .mount_lease_ttl_ms = std::chrono::milliseconds(500), + .mount_renew_period = std::chrono::milliseconds(100), + .cas_request_budget = budget, + }); + + /// A real, fully-promoted ref through the ordinary production write path (no seeded catalog/ckpt) + /// gives the namespace genuine epoch-1 content, so the successor's recovery below has something + /// real to close -- unlike a pre-attempt-refused grant, which never touches the backend at all and + /// so leaves the namespace's fold coverage exactly where it started. + publishEmptyRef(predecessor, ns, "anchor"); + + ManifestId rejected_id; + auto rejected = stageEmptyManifest(predecessor, ns, "rejected", rejected_id); + const String rejected_manifest_key = predecessor->layout().manifestKey(rejected_id); + ASSERT_TRUE(backend->head(rejected_manifest_key).exists) + << "stageManifest's body write is unconditional; only the owner grant is refused below"; + + /// `Unresolved` lands nothing, so the wedge it leaves resolves as a conclusive REJECT once the + /// successor's own recovery walks past it -- unlike the ADOPT-arm crash-remnant test, this + /// manifest never becomes a live owner in any epoch. `anchor`'s real birth just above minted a + /// genuine (random) incarnation, so the fault key is computed from the namespace's ACTUAL life, + /// not the deterministic `fixtureLife` fallback a raw, never-touched fixture would use. + backend->fault_substr = predecessor->layout().namespaceStreamPrefix(predecessor->namespaceLife(ns)) + "_log/"; + backend->mode = DB::Cas::tests::ChunkFaultBackend::Mode::Unresolved; + backend->fault_count = 1; + DB::Cas::tests::expectThrowsCode( + DB::ErrorCodes::NETWORK_ERROR, + [&] { rejected->precommitAdd(ns, "rejected", rejected_id); }); + ASSERT_TRUE(predecessor->refLaneWedgedForTest(ns)); + ASSERT_EQ(rejected->precommitState(), PartWriteTxn::PrecommitState::Uncertain); + const uint64_t predecessor_epoch = predecessor->writerEpoch(); + + rejected.reset(); + predecessor.reset(); + + uint64_t fake_boot = 0; + std::vector waits; + auto successor_store = Pool::open(backend, PoolConfig{ + .pool_prefix = "p", + .server_id = UInt128(1), + .server_root_id = "test", + .manifest_sweep_list_budget_keys = 100, + .manifest_sweep_delete_budget_keys = 100, + .gc_fold_max_defer_rounds = 0, + .background_watermark = false, + .mount_lease_ttl_ms = std::chrono::milliseconds(500), + .mount_renew_period = std::chrono::milliseconds(100), + .cas_request_budget = budget, + .boot_ms_fn = [&] { return fake_boot; }, + .wait_sleep_fn = [&](uint64_t ms) { fake_boot += ms; waits.push_back(ms); }, + }); + ASSERT_GT(successor_store->writerEpoch(), predecessor_epoch); + ASSERT_FALSE(waits.empty()) << "the predecessor supplied no clean-death certificate"; + + /// An ordinary successor mutation both drains the inherited duty as a no-op (the rejected grant + /// was never durable) and forces the predecessor's dead epoch to close with an arithmetic seal -- + /// the fact rule (1) of the sweep's deletion premise reads. + ManifestId successor_id; + auto successor = stageEmptyManifest(successor_store, ns, "successor", successor_id); + successor->precommitAdd(ns, "successor", successor_id); + + EXPECT_EQ( + successor_store->livePrecommitsForTest(ns), + (std::set>{{"successor", successor_id.ref}})); + const auto seal = successor_store->lastEpochSealForTest(ns); + ASSERT_TRUE(seal.has_value()); + EXPECT_EQ(seal->writer_epoch, predecessor_epoch); + + Gc gc(successor_store, hexToU128("000000000000000000000000000000e1")); + for (int round = 0; round < 16 && backend->head(rejected_manifest_key).exists; ++round) + DB::Cas::tests::runRegularRoundReclaiming(gc); + + EXPECT_FALSE(backend->head(rejected_manifest_key).exists) + << "the rejected attempt's orphan manifest must eventually be nominated and swept once its " + "build epoch is durably closed"; + + successor->abandon(); +} + +/// The settlement's own ordering is load-bearing: append the exact `OwnerTransition` removal (or +/// observe conclusive absence), only then retire the build seq, only then drop the duty -- a throw +/// between those steps must leave the duty owned by nobody but the queue. Faulting the SETTLEMENT's +/// append (not the original grant, which is a plain pre-attempt refusal here) proves the retry path +/// directly: the duty survives the throw and the mutation it was blocking aborts with it, then the +/// very next drain -- once the fault clears -- settles the duty and lets that mutation proceed. +TEST(CASWriterDuties, DutySurvivesSettlementFailureForRetry) +{ + auto backend = std::make_shared(); + auto store = openFrozenSingleAttemptPool(backend); + const RootNamespace ns{"srv1/writer_duty_settlement_retry"}; + DB::Cas::tests::casAdmitRecoverableEntry(*backend, store->layout(), ns, store->liveWriterEpoch()); + publishEmptyRef(store, ns, "target"); + + /// A plain, unfaulted precommit that is simply destroyed without promote/abandon: Durable, never + /// settled, and (unlike a proven-absent grant) its duty's own settlement owes a REAL + /// `OwnerTransition` removal -- exactly the append this test needs to fault. + ManifestId durable_id; + auto durable = stageEmptyManifest(store, ns, "durable", durable_id); + const uint64_t durable_seq = durable->buildSeq(); + durable->precommitAdd(ns, "durable", durable_id); + durable.reset(); + ASSERT_TRUE(store->writerCleanupDutiesPendingForTest()); + + backend->fault_substr = store->layout().namespaceStreamPrefix(DB::Cas::tests::fixture::fixtureLife(ns)) + "_log/"; + backend->mode = DB::Cas::tests::ChunkFaultBackend::Mode::Unresolved; + backend->fault_count = 1; + DB::Cas::tests::expectThrowsCode( + DB::ErrorCodes::NETWORK_ERROR, + [&] { store->dropRef(ns, "target"); }); + + EXPECT_TRUE(store->writerCleanupDutiesPendingForTest()) + << "a settlement that throws must retain the duty for retry, never lose it"; + EXPECT_TRUE(store->resolveRef(ns, "target").has_value()) + << "the settlement's failure must abort the mutation it was blocking too, not just its own append"; + EXPECT_EQ(store->minActive(), durable_seq); + + backend->mode = DB::Cas::tests::ChunkFaultBackend::Mode::None; + store->dropRef(ns, "target"); + + EXPECT_FALSE(store->writerCleanupDutiesPendingForTest()); + EXPECT_FALSE(store->resolveRef(ns, "target").has_value()); + EXPECT_EQ(store->minActive(), store->peekNextBuildSeq()) + << "the retried drain settles the retained duty and lets the mutation proceed"; +} diff --git a/src/IO/ReadBufferFromFileView.cpp b/src/IO/ReadBufferFromFileView.cpp index 1304f372df06..22de4731673f 100644 --- a/src/IO/ReadBufferFromFileView.cpp +++ b/src/IO/ReadBufferFromFileView.cpp @@ -19,11 +19,13 @@ ReadBufferFromFileView::ReadBufferFromFileView( , file_offset_of_buffer_end(left_bound_) , original_working_buffer(working_buffer) { - /// Seek to the begin of file. + /// Seek to the begin of file. The impl still owns its native buffer state here (no swap yet), + /// so its buffer-end offset can be read directly after the seek. impl->seek(left_bound, SEEK_SET); + const size_t impl_buffer_end = impl->getPosition() + impl->available(); swap(*impl); - file_offset_of_buffer_end += available(); + file_offset_of_buffer_end = impl_buffer_end; original_working_buffer = working_buffer; resizeWorkingBuffer(); } @@ -40,14 +42,31 @@ void ReadBufferFromFileView::setReadUntilPosition(size_t position) throw Exception(ErrorCodes::ARGUMENT_OUT_OF_BOUND, "Cannot read until position: {}. File size is {}", position, getFileSize()); - executeWithOriginalBuffer([&]{ impl->setReadUntilPosition(*read_until_position); }); + /// The impl is allowed to DISCARD its working buffer here (e.g. `ReadBufferFromS3` rebases its + /// offset to the consumer position and resets the buffer when the range changes), so the view's + /// buffer-end offset MUST be rebased from the impl's post-op state - keeping the stale value + /// over a replaced buffer silently shifts the reported position by the discarded bytes. + size_t impl_buffer_end = 0; + executeWithOriginalBuffer([&] + { + impl->setReadUntilPosition(*read_until_position); + impl_buffer_end = impl->getPosition() + impl->available(); + }); + file_offset_of_buffer_end = impl_buffer_end; resizeWorkingBuffer(); } void ReadBufferFromFileView::setReadUntilEnd() { read_until_position.reset(); - executeWithOriginalBuffer([&]{ impl->setReadUntilPosition(right_bound); }); + /// Same rebase contract as setReadUntilPosition. + size_t impl_buffer_end = 0; + executeWithOriginalBuffer([&] + { + impl->setReadUntilPosition(right_bound); + impl_buffer_end = impl->getPosition() + impl->available(); + }); + file_offset_of_buffer_end = impl_buffer_end; resizeWorkingBuffer(); } @@ -63,11 +82,19 @@ bool ReadBufferFromFileView::nextImpl() return false; bool result = false; - executeWithOriginalBuffer([&] { result = impl->next(); }); + size_t impl_buffer_end = 0; + executeWithOriginalBuffer([&] + { + result = impl->next(); + impl_buffer_end = impl->getPosition() + impl->available(); + }); if (result) { - file_offset_of_buffer_end += available(); + /// Rebase from the impl's own accounting instead of incrementing: the view's previous + /// buffer-end may have been clamped by resizeWorkingBuffer below the impl's real one, and + /// the impl continues from ITS position - incrementing would mislabel the new chunk. + file_offset_of_buffer_end = impl_buffer_end; resizeWorkingBuffer(); } @@ -87,7 +114,12 @@ off_t ReadBufferFromFileView::seek(off_t off, int whence) throw Exception(ErrorCodes::ARGUMENT_OUT_OF_BOUND, "ReadBufferFromFileView::seek expects SEEK_SET or SEEK_CUR as whence"); off_t result = 0; - executeWithOriginalBuffer([&] { result = impl->seek(new_pos, SEEK_SET); }); + size_t impl_buffer_end = 0; + executeWithOriginalBuffer([&] + { + result = impl->seek(new_pos, SEEK_SET); + impl_buffer_end = impl->getPosition() + impl->available(); + }); if (result < 0) throw Exception(ErrorCodes::SEEK_POSITION_OUT_OF_BOUND, "Seek position ({}) underflow", result); @@ -96,7 +128,7 @@ off_t ReadBufferFromFileView::seek(off_t off, int whence) throw Exception(ErrorCodes::SEEK_POSITION_OUT_OF_BOUND, "Seek position ({}) is out of bound. Available range: [{}, {}]", result, left_bound, right_bound); - file_offset_of_buffer_end = result + available(); + file_offset_of_buffer_end = impl_buffer_end; resizeWorkingBuffer(); return result - left_bound; @@ -110,7 +142,20 @@ void ReadBufferFromFileView::executeWithOriginalBuffer(Op && op) /// Set working buffer and other internal into impl. swap(*impl); - op(); + try + { + op(); + } + catch (...) + { + /// The swap MUST be undone even if `op` throws — otherwise `this` and `impl` are left holding + /// each other's working buffers (and a stale `original_working_buffer`), so any subsequent + /// read or seek over-reads / serves wrong bytes. `op` can throw (e.g. setReadUntilPosition / + /// seek bound checks), so restore-on-exception is required for the view to stay consistent. + swap(*impl); + original_working_buffer = working_buffer; + throw; + } swap(*impl); original_working_buffer = working_buffer; diff --git a/src/IO/ReadBufferFromMemory.cpp b/src/IO/ReadBufferFromMemory.cpp index 882f8b6a07d3..9f3c20fc51e2 100644 --- a/src/IO/ReadBufferFromMemory.cpp +++ b/src/IO/ReadBufferFromMemory.cpp @@ -76,7 +76,10 @@ ReadBufferFromMemoryFileBase::ReadBufferFromMemoryFileBase(bool owns_memory, { chassert(data.size() == internal_buffer.size()); - if (owns_memory) + /// memcpy's pointers are __attribute__((nonnull)) even when the length is 0. An empty file yields + /// data.data() == nullptr, so guard on non-empty to avoid the nonnull-attribute UB the asan_ubsan + /// lane aborts on (STID 5930-5afa). Nothing to copy when empty. + if (owns_memory && !data.empty()) std::memcpy(internal_buffer.begin(), data.data(), data.size()); working_buffer = internal_buffer; diff --git a/src/IO/ReadBufferFromS3.cpp b/src/IO/ReadBufferFromS3.cpp index f2d184fd8e71..34f1f2261891 100644 --- a/src/IO/ReadBufferFromS3.cpp +++ b/src/IO/ReadBufferFromS3.cpp @@ -16,6 +16,7 @@ #include #include #include +#include #include #include @@ -353,6 +354,12 @@ bool ReadBufferFromS3::processException(size_t read_offset, size_t attempt) cons bucket, key, version_id.empty() ? "Latest" : version_id, read_offset, attempt, request_settings[S3RequestSetting::max_single_read_retries].value, getCurrentExceptionMessage(/* with_stacktrace = */ false)); + /// Stop retrying once the query is cancelled (B117): otherwise a killed query's reads keep + /// retrying a transient error (e.g. a dropped connection) for many attempts with backoff, + /// zombying for minutes and adding load. The SDK's own RetryStrategy makes the same check + /// (src/IO/S3/Client.cpp), but this outer ReadBufferFromS3 retry loop did not. + if (CurrentThread::isInitialized() && CurrentThread::get().isQueryCanceled()) + return false; if (auto * s3_exception = current_exception_cast()) { diff --git a/src/IO/ReadPipeline.cpp b/src/IO/ReadPipeline.cpp index fa2e1f075110..1d7a1be480a6 100644 --- a/src/IO/ReadPipeline.cpp +++ b/src/IO/ReadPipeline.cpp @@ -10,6 +10,7 @@ #include #include #include +#include #include #include #include @@ -157,6 +158,17 @@ void ReadPipeline::needDecryption(String path, size_t buffer_size, KeyFinderFunc .key_finder = std::move(key_finder)}); } +void ReadPipeline::needFileView(String file_name, size_t left_bound, size_t right_bound) +{ + if (right_bound < left_bound) + throw Exception(ErrorCodes::BAD_ARGUMENTS, + "ReadPipeline: file view right bound ({}) is below the left bound ({})", right_bound, left_bound); + file_view = FileViewStage{ + .file_name = std::move(file_name), + .left_bound = left_bound, + .right_bound = right_bound}; +} + std::unique_ptr ReadPipeline::build() const { if (!source) @@ -182,7 +194,8 @@ std::unique_ptr ReadPipeline::build() const impl = wrapMemoryCache(std::move(impl)); // Stage 4 impl = wrapAsyncPrefetch(std::move(impl)); // Stage 5 - impl = wrapDecryption(std::move(impl)); // Stage 6 (encryption) + impl = wrapFileView(std::move(impl)); // Stage 6 (byte window) + impl = wrapDecryption(std::move(impl)); // Stage 7 (encryption) return impl; } @@ -193,14 +206,15 @@ std::unique_ptr ReadPipeline::tryBuildReaderExecutor() c if (!settings.use_reader_executor) return nullptr; - /// The executor does not implement caches, decryption, async prefetch, or the - /// distributed cache, so fall back rather than silently drop a configured stage. + /// The executor does not implement caches, decryption, async prefetch, the + /// distributed cache, or a file_view byte window, so fall back rather than + /// silently drop a configured stage. if (distributed_cache || memory_cache || !filesystem_caches.empty() - || !decryption_stages.empty() || async_prefetch) + || !decryption_stages.empty() || async_prefetch || file_view) { LOG_DEBUG(log, "use_reader_executor: falling back to the legacy read path " - "(caches/decryption not yet supported by the executor)"); + "(caches/decryption/file_view not yet supported by the executor)"); return nullptr; } @@ -673,6 +687,19 @@ std::unique_ptr ReadPipeline::wrapAsyncPrefetch(std::uni async_prefetch->prefetches_log); } +std::unique_ptr ReadPipeline::wrapFileView(std::unique_ptr impl) const +{ + /// -- Stage 6: File view -- + /// The view translates the consumer's positions/right bounds by `left_bound` and forwards + /// them down the chain, so `MergeTreeReaderStream::adjustRightMark` bounds reach the + /// object-storage reader and its range requests stay drainable (connection-pool friendly). + if (!file_view) + return impl; + + return std::make_unique( + std::move(impl), file_view->file_name, file_view->left_bound, file_view->right_bound); +} + std::unique_ptr ReadPipeline::wrapDecryption(std::unique_ptr impl) const { /// -- Stage 6: Decryption (may have multiple layers for double encryption) -- @@ -733,6 +760,8 @@ String ReadPipeline::describe() const append("MemoryCache"); if (async_prefetch) append("AsyncPrefetch"); + if (file_view) + append("FileView"); if (!decryption_stages.empty()) append("Decrypt"); diff --git a/src/IO/ReadPipeline.h b/src/IO/ReadPipeline.h index 61b5fa506351..586212be4598 100644 --- a/src/IO/ReadPipeline.h +++ b/src/IO/ReadPipeline.h @@ -47,7 +47,8 @@ using FilesystemReadPrefetchesLogPtr = std::shared_ptr build() const; @@ -215,6 +225,13 @@ class ReadPipeline KeyFinderFunc key_finder; }; + struct FileViewStage + { + String file_name; + size_t left_bound = 0; + size_t right_bound = 0; + }; + struct DistributedCacheStage { @@ -228,6 +245,7 @@ class ReadPipeline std::optional distributed_cache; std::optional async_prefetch; VectorWithMemoryTracking decryption_stages; + std::optional file_view; LoggerPtr log = getLogger("ReadPipeline"); @@ -246,6 +264,7 @@ class ReadPipeline std::unique_ptr buildSingleObjectStage(const std::string & query_id) const; std::unique_ptr wrapMemoryCache(std::unique_ptr impl) const; std::unique_ptr wrapAsyncPrefetch(std::unique_ptr impl) const; + std::unique_ptr wrapFileView(std::unique_ptr impl) const; std::unique_ptr wrapDecryption(std::unique_ptr impl) const; }; diff --git a/src/IO/S3/Client.cpp b/src/IO/S3/Client.cpp index af6f0a2e6894..d1c5c733ec9d 100644 --- a/src/IO/S3/Client.cpp +++ b/src/IO/S3/Client.cpp @@ -25,8 +25,10 @@ #include #include +#include #include +#include #include #include #include @@ -65,6 +67,8 @@ namespace ProfileEvents extern const Event S3Clients; extern const Event TinyS3Clients; + + extern const Event S3SingleAttemptRetryConsultations; } namespace CurrentMetrics @@ -102,6 +106,13 @@ bool Client::RetryStrategy::ShouldRetry(const Aws::Client::AWSError= config.max_retries) return false; @@ -181,6 +192,13 @@ void Client::RetryStrategy::RequestBookkeeping( RequestBookkeeping(httpResponseOutcome); } +/// NOLINTNEXTLINE(google-runtime-int) +bool SingleAttemptRetryStrategy::ShouldRetry(const Aws::Client::AWSError &, long) const +{ + ProfileEvents::increment(ProfileEvents::S3SingleAttemptRetryConsultations); + return false; +} + namespace { @@ -289,7 +307,14 @@ Client::Client( /// find credential keys we can simply behave as the underlying storage is S3 /// otherwise, we need to be aware we are making requests to GCS /// and replace all headers with a valid prefix when needed - if (credentials_provider) + if (Poco::toLower(client_configuration.http_client) == "gcs_hmac") + { + /// GOOG4-HMAC mode: all requests are re-signed with x-goog headers at the HTTP layer, + /// so the SDK-side GCS accommodations (x-amz header renames, x-amz-api-version + /// deletion) must be active even though credentials are present. + api_mode = ApiMode::GCS; + } + else if (credentials_provider) { auto credentials = credentials_provider->GetAWSCredentials(); if (credentials.IsEmpty()) @@ -506,6 +531,12 @@ Model::GetObjectTaggingOutcome Client::GetObjectTagging(GetObjectTaggingRequest doRequest(request, [this](const Model::GetObjectTaggingRequest & req) { return GetObjectTagging(req); })); } +Model::GetBucketVersioningOutcome Client::GetBucketVersioning(GetBucketVersioningRequest & request) const +{ + return processRequestResult( + doRequest(request, [this](const Model::GetBucketVersioningRequest & req) { return GetBucketVersioning(req); })); +} + Model::ListObjectsV2Outcome Client::ListObjectsV2(ListObjectsV2Request & request) const { return doRequestWithRetryNetworkErrors( @@ -1274,6 +1305,14 @@ std::unique_ptr ClientFactory::create( // NOLINT auto credentials_provider = getCredentialsProvider(client_configuration, credentials, credentials_configuration); + if (Poco::toLower(client_configuration.http_client) == "gcs_hmac") + { + client_configuration.gcs_conditional_dialect = true; + client_configuration.gcs_hmac_credentials_provider = credentials_provider; + } + else if (Poco::toLower(client_configuration.http_client) == "gcp_oauth") + client_configuration.gcs_conditional_dialect = true; + /// Disable per-thread retry loops if global retry coordination is in use. if (client_configuration.s3_slow_all_threads_after_retryable_error) { diff --git a/src/IO/S3/Client.h b/src/IO/S3/Client.h index ad4d685d88a2..5a6847799c29 100644 --- a/src/IO/S3/Client.h +++ b/src/IO/S3/Client.h @@ -208,6 +208,7 @@ class Client : private Aws::S3::S3Client Model::HeadObjectOutcome HeadObject(HeadObjectRequest & request) const; Model::GetObjectTaggingOutcome GetObjectTagging(GetObjectTaggingRequest & request) const; + Model::GetBucketVersioningOutcome GetBucketVersioning(GetBucketVersioningRequest & request) const; Model::ListObjectsV2Outcome ListObjectsV2(ListObjectsV2Request & request) const; Model::ListObjectsOutcome ListObjects(ListObjectsRequest & request) const; Model::GetObjectOutcome GetObject(GetObjectRequest & request) const; @@ -252,6 +253,10 @@ class Client : private Aws::S3::S3Client const PocoHTTPClientConfiguration & getClientConfiguration() const { return client_configuration; } + /// True when this client's HTTP layer runs the GCS conditional dialect (http_client = + /// gcs_hmac or gcp_oauth): conditional tokens are GCS generations riding the ETag plumbing. + bool usesGcsConditionalDialect() const { return client_configuration.gcs_conditional_dialect; } + /// For testing purposes only ClientCache * getRawCache() const { return cache.get(); } @@ -273,6 +278,7 @@ class Client : private Aws::S3::S3Client /// otherwise region and endpoint redirection won't work using Aws::S3::S3Client::HeadObject; using Aws::S3::S3Client::GetObjectTagging; + using Aws::S3::S3Client::GetBucketVersioning; using Aws::S3::S3Client::ListObjectsV2; using Aws::S3::S3Client::ListObjects; using Aws::S3::S3Client::GetObject; @@ -346,6 +352,17 @@ class Client : private Aws::S3::S3Client LoggerPtr log; }; +/// Refuses every SDK-transparent retry and counts each consultation. Used by the +/// ObjectStorageRetryProfile::SingleAttempt per-write profile (conditional writes whose retry +/// decisions live ABOVE the SDK: the caller must resolve an uncertain PUT before reissuing). +class SingleAttemptRetryStrategy final : public Aws::Client::RetryStrategy +{ +public: + bool ShouldRetry(const Aws::Client::AWSError &, long) const override; // NOLINT(google-runtime-int) + long CalculateDelayBeforeNextRetry(const Aws::Client::AWSError &, long) const override { return 0; } // NOLINT(google-runtime-int) + long GetMaxAttempts() const override { return 1; } // NOLINT(google-runtime-int) +}; + class ClientFactory { public: diff --git a/src/IO/S3/GCSConditionalDialect.cpp b/src/IO/S3/GCSConditionalDialect.cpp new file mode 100644 index 000000000000..5c153ce44c2e --- /dev/null +++ b/src/IO/S3/GCSConditionalDialect.cpp @@ -0,0 +1,104 @@ +#include + +#if USE_AWS_S3 + +#include +#include +#include + +#include +#include + +namespace DB::ErrorCodes +{ + extern const int LOGICAL_ERROR; +} + +namespace DB::S3 +{ + +namespace +{ + +bool isAllDigits(const std::string & s) +{ + return !s.empty() && std::all_of(s.begin(), s.end(), [](char c) { return c >= '0' && c <= '9'; }); +} + +std::string stripQuotes(const std::string & s) +{ + if (s.size() >= 2 && s.front() == '"' && s.back() == '"') + return s.substr(1, s.size() - 2); + return s; +} + +} + +void applyGcsConditionalDialectToRequest(Aws::Http::HttpRequest & request) +{ + const auto query_params = request.GetUri().GetQueryStringParameters(); + const bool is_complete_multipart = request.GetMethod() == Aws::Http::HttpMethod::HTTP_POST + && query_params.contains("uploadId") && !query_params.contains("partNumber"); + + /// --- Conditional headers -> x-goog-if-generation-match --- + std::optional generation_match; + if (request.HasHeader("if-none-match")) + { + const auto value = request.GetHeaderValue("if-none-match"); + if (value != "*") + throw Exception(ErrorCodes::LOGICAL_ERROR, + "GCS conditional dialect: If-None-Match with a value other than '*' has no GCS " + "equivalent (got '{}') — refusing to silently change semantics", value); + generation_match = "0"; + request.DeleteHeader("if-none-match"); + } + if (request.HasHeader("if-match")) + { + const auto value = stripQuotes(request.GetHeaderValue("if-match")); + if (!isAllDigits(value)) + throw Exception(ErrorCodes::LOGICAL_ERROR, + "GCS conditional dialect: If-Match value '{}' is not a generation number — an " + "ETag-kind token leaked into a generation-dialect client (mixed-mode misconfiguration)", + value); + generation_match = value; + request.DeleteHeader("if-match"); + } + if (generation_match) + { + if (is_complete_multipart) + throw Exception(ErrorCodes::LOGICAL_ERROR, + "GCS conditional dialect: a CONDITIONAL CompleteMultipartUpload was about to be sent. " + "GCS silently ignores preconditions on CompleteMultipartUpload (measured 2026-07-03) — " + "this would be silent data loss. Conditional writes must use the single-PUT path."); + request.SetHeaderValue("x-goog-if-generation-match", *generation_match); + } + + /// --- AWS auth artifacts: drop (the GCS-mode client re-authenticates after this call) --- + for (const auto * header : {"authorization", "x-amz-date", "x-amz-content-sha256", + "x-amz-security-token", "x-amz-api-version"}) + request.DeleteHeader(header); + + /// --- Rename every remaining x-amz-* header to x-goog-* (mixing is rejected by GCS) --- + std::vector> renamed; + for (const auto & [name, value] : request.GetHeaders()) + { + if (name.starts_with("x-amz-")) + renamed.emplace_back("x-goog-" + name.substr(6), value); + } + for (const auto & [goog_name, value] : renamed) + { + request.DeleteHeader(("x-amz-" + goog_name.substr(7)).c_str()); + request.SetHeaderValue(goog_name.c_str(), value); + } +} + +std::optional gcsGenerationETagOverride(const Poco::Net::HTTPResponse & response) +{ + if (!response.has("x-goog-generation")) + return std::nullopt; + return "\"" + response.get("x-goog-generation") + "\""; +} + +} + +#endif diff --git a/src/IO/S3/GCSConditionalDialect.h b/src/IO/S3/GCSConditionalDialect.h new file mode 100644 index 000000000000..f63f331ae508 --- /dev/null +++ b/src/IO/S3/GCSConditionalDialect.h @@ -0,0 +1,36 @@ +#pragma once +#include "config.h" +#if USE_AWS_S3 + +#include +#include + +namespace Aws::Http { class HttpRequest; } +namespace Poco::Net { class HTTPResponse; } + +namespace DB::S3 +{ + +/// The GCS conditional dialect, request side (spec: 2026-07-03-cas-gcs-generation-binding-design). +/// Applied at the wire boundary by the GCS-mode Poco HTTP clients, so everything above keeps +/// speaking AWS. Translations: +/// - AWS auth artifacts (`authorization`, `x-amz-date`, `x-amz-content-sha256`, +/// `x-amz-security-token`, `x-amz-api-version`) are DROPPED (the caller re-authenticates); +/// - every remaining `x-amz-*` header is renamed to `x-goog-*`; +/// - `If-None-Match: *` becomes `x-goog-if-generation-match: 0`; +/// - `If-Match: ""` (quotes optional) becomes `x-goog-if-generation-match: `. +/// Fail-close guards (throw LOGICAL_ERROR, the request never leaves the process): +/// - `If-None-Match` with any value other than `*` (no GCS equivalent); +/// - a non-numeric `If-Match` (an ETag-kind token leaked into a generation dialect); +/// - a CONDITIONAL CompleteMultipartUpload (POST with `uploadId` and no `partNumber`): GCS +/// silently ignores preconditions there (measured live 2026-07-03) — silent data loss. +void applyGcsConditionalDialectToRequest(Aws::Http::HttpRequest & request); + +/// The dialect, response side: when the response carries `x-goog-generation`, returns it QUOTED — +/// the caller substitutes it for the `ETag` response header, making the generation ride the +/// entire existing ETag/token plumbing unchanged. Returns nullopt when no generation is present. +std::optional gcsGenerationETagOverride(const Poco::Net::HTTPResponse & response); + +} + +#endif diff --git a/src/IO/S3/GOOG4Signer.cpp b/src/IO/S3/GOOG4Signer.cpp new file mode 100644 index 000000000000..740b65d85c44 --- /dev/null +++ b/src/IO/S3/GOOG4Signer.cpp @@ -0,0 +1,149 @@ +#include + +#if USE_AWS_S3 + +#include +#include +#include +#include +#include +#include + +#include +#include + +#include +#include + +namespace DB::ErrorCodes +{ + extern const int LOGICAL_ERROR; +} + +namespace DB::S3 +{ + +namespace +{ + +constexpr auto UNSIGNED_PAYLOAD = "UNSIGNED-PAYLOAD"; + +std::string hmacSHA256(const std::string & key, const std::string & message) +{ + unsigned char out[SHA256_DIGEST_LENGTH]; + unsigned int out_len = 0; + HMAC(EVP_sha256(), + key.data(), static_cast(key.size()), + reinterpret_cast(message.data()), message.size(), + out, &out_len); + return std::string(reinterpret_cast(out), out_len); +} + +std::string sha256Hex(const std::string & data) +{ + unsigned char out[SHA256_DIGEST_LENGTH]; + SHA256(reinterpret_cast(data.data()), data.size(), out); + return hexString(out, SHA256_DIGEST_LENGTH); +} + +} + +void signRequestGOOG4( + Aws::Http::HttpRequest & request, + const Aws::Auth::AWSCredentials & credentials, + std::chrono::system_clock::time_point now) +{ + const std::time_t now_t = std::chrono::system_clock::to_time_t(now); + std::tm tm_utc{}; + gmtime_r(&now_t, &tm_utc); + const std::string timestamp = fmt::format( + "{:04}{:02}{:02}T{:02}{:02}{:02}Z", + tm_utc.tm_year + 1900, tm_utc.tm_mon + 1, tm_utc.tm_mday, + tm_utc.tm_hour, tm_utc.tm_min, tm_utc.tm_sec); + const std::string datestamp = timestamp.substr(0, 8); + + request.SetHeaderValue("x-goog-date", timestamp); + request.SetHeaderValue("x-goog-content-sha256", UNSIGNED_PAYLOAD); + + /// Canonical headers: `host` + every x-goog-* header, lowercase names, sorted. + /// std::map keeps them sorted for us. + std::map signed_headers_map; + for (const auto & [name, value] : request.GetHeaders()) + { + std::string lower = Aws::Utils::StringUtils::ToLower(name.c_str()); + if (lower == "host" || lower.starts_with("x-goog-")) + signed_headers_map.emplace(std::move(lower), value); + } + if (!signed_headers_map.contains("host")) + throw Exception(ErrorCodes::LOGICAL_ERROR, "GOOG4 signing requires a Host header on the request"); + + std::string canonical_headers; + std::string signed_headers; + for (const auto & [name, value] : signed_headers_map) + { + canonical_headers += name + ":" + value + "\n"; + if (!signed_headers.empty()) + signed_headers += ";"; + signed_headers += name; + } + + /// Canonical query string: URL-encoded key=value pairs sorted by key; a parameter without a + /// value still gets a trailing `=` (e.g. `versioning=`). + /// + /// `Aws::Http::URI` has no ready-made helper for this: `CanonicalizeQueryString` only rewrites + /// the query string when it already contains an `=`, so a bare flag like `?versioning` (no `=`) + /// passes through unsorted and unencoded. `GetQueryStringParameters` doesn't help either — for + /// a valueless flag it has no `=` to split on, so it treats the whole `key` as the `value` too + /// (`versioning` becomes `versioning=versioning`, not `versioning=`). Parse the raw query string + /// by hand instead, splitting each `key[=value]` pair on the first `=` with an empty value when + /// absent, then URL-encode and join sorted `key=value` pairs with `&`. + std::map query_params; + { + const std::string raw_query = request.GetUri().GetQueryString(); + size_t pos = raw_query.empty() ? std::string::npos : 1; /// skip leading '?' + while (pos != std::string::npos && pos < raw_query.size()) + { + const size_t amp = raw_query.find('&', pos); + const std::string pair = raw_query.substr(pos, amp == std::string::npos ? std::string::npos : amp - pos); + const size_t eq = pair.find('='); + std::string key = eq == std::string::npos ? pair : pair.substr(0, eq); + std::string value = eq == std::string::npos ? std::string() : pair.substr(eq + 1); + query_params.emplace( + Aws::Utils::StringUtils::URLDecode(key.c_str()), + Aws::Utils::StringUtils::URLDecode(value.c_str())); + pos = amp == std::string::npos ? std::string::npos : amp + 1; + } + } + std::string canonical_query; + for (const auto & [key, value] : query_params) + { + if (!canonical_query.empty()) + canonical_query += "&"; + canonical_query += Aws::Utils::StringUtils::URLEncode(key.c_str()) + "=" + Aws::Utils::StringUtils::URLEncode(value.c_str()); + } + const std::string canonical_uri = request.GetUri().GetURLEncodedPath(); + + const std::string method = Aws::Http::HttpMethodMapper::GetNameForHttpMethod(request.GetMethod()); + + const std::string canonical_request = fmt::format( + "{}\n{}\n{}\n{}\n{}\n{}", + method, canonical_uri, canonical_query, canonical_headers, signed_headers, UNSIGNED_PAYLOAD); + + const std::string scope = fmt::format("{}/auto/storage/goog4_request", datestamp); + const std::string string_to_sign = fmt::format( + "GOOG4-HMAC-SHA256\n{}\n{}\n{}", timestamp, scope, sha256Hex(canonical_request)); + + std::string key = hmacSHA256("GOOG4" + credentials.GetAWSSecretKey(), datestamp); + key = hmacSHA256(key, "auto"); + key = hmacSHA256(key, "storage"); + key = hmacSHA256(key, "goog4_request"); + const std::string signature = hexString(hmacSHA256(key, string_to_sign).data(), SHA256_DIGEST_LENGTH); + + request.SetHeaderValue("authorization", fmt::format( + "GOOG4-HMAC-SHA256 Credential={}/{}, SignedHeaders={}, Signature={}", + credentials.GetAWSAccessKeyId(), scope, signed_headers, signature)); +} + +} + +#endif diff --git a/src/IO/S3/GOOG4Signer.h b/src/IO/S3/GOOG4Signer.h new file mode 100644 index 000000000000..4b1f4c1b89c0 --- /dev/null +++ b/src/IO/S3/GOOG4Signer.h @@ -0,0 +1,30 @@ +#pragma once +#include "config.h" +#if USE_AWS_S3 + +#include + +namespace Aws::Http { class HttpRequest; } +namespace Aws::Auth { class AWSCredentials; } + +namespace DB::S3 +{ + +/// Sign `request` in place with GOOG4-HMAC-SHA256 — Google Cloud Storage's native V4 HMAC scheme +/// for the XML API. Structurally sigv4 with renamed constants: key prefix `GOOG4`, scope +/// terminator `goog4_request`, headers `x-goog-date` / `x-goog-content-sha256`. Bodies are never +/// hashed (`UNSIGNED-PAYLOAD`), so streaming uploads sign in O(1). +/// +/// Signs the `host` header plus EVERY `x-goog-*` header present on the request (GCS requires all +/// x-goog headers to be signed); other headers ride unsigned. `now` is injected so unit tests can +/// pin the timestamp to fixed vectors. +/// +/// Live-validated against GCS 2026-07-03 (see `utils/ca-soak/scripts/gcs_goog4_probe.py`, 12/12). +void signRequestGOOG4( + Aws::Http::HttpRequest & request, + const Aws::Auth::AWSCredentials & credentials, + std::chrono::system_clock::time_point now); + +} + +#endif diff --git a/src/IO/S3/PocoHTTPClient.cpp b/src/IO/S3/PocoHTTPClient.cpp index 2b76300bbbc9..319d7375f62d 100644 --- a/src/IO/S3/PocoHTTPClient.cpp +++ b/src/IO/S3/PocoHTTPClient.cpp @@ -7,6 +7,8 @@ #if USE_AWS_S3 #include +#include +#include #include #include @@ -24,6 +26,7 @@ #include #include +#include #include #include #include @@ -89,6 +92,7 @@ namespace DB::ErrorCodes extern const int DNS_ERROR; extern const int AUTHENTICATION_FAILED; extern const int BAD_ARGUMENTS; + extern const int LOGICAL_ERROR; } namespace HistogramMetrics @@ -221,11 +225,13 @@ PocoHTTPClient::PocoHTTPClient(const PocoHTTPClientConfiguration & client_config , remote_host_filter(client_configuration.remote_host_filter) , s3_max_redirects(client_configuration.s3_max_redirects) , s3_use_adaptive_timeouts(client_configuration.s3_use_adaptive_timeouts) + , expect_continue_min_bytes(client_configuration.expect_continue_min_bytes) , http_max_fields(client_configuration.http_max_fields) , http_max_field_name_size(client_configuration.http_max_field_name_size) , http_max_field_value_size(client_configuration.http_max_field_value_size) , enable_s3_requests_logging(client_configuration.enable_s3_requests_logging) , for_disk_s3(client_configuration.for_disk_s3) + , gcs_conditional_dialect(client_configuration.gcs_conditional_dialect) , request_throttler(client_configuration.request_throttler) , extra_headers(client_configuration.extra_headers) { @@ -617,6 +623,42 @@ void PocoHTTPClient::makeRequestInternalImpl( Stopwatch watch; + /// A conditional write (`If-None-Match` / `If-Match`) that loses its precondition can waste + /// a LARGE body: streaming multi-MB into a request the server has already decided to reject + /// makes some stores (e.g. RustFS) close mid-upload or answer a retryable 500, which the SDK + /// then RETRIES up to `s3_retry_attempts` (500) times — a ~40-min stall that hangs CA INSERTs + /// (see B118). `Expect: 100-continue` lets the server reject (e.g. 412) BEFORE the body, so we + /// skip the doomed upload. + /// + /// `expect_continue_min_bytes` is the negotiation gate: `0` (the default, carried by every + /// non-CAS S3 client) DISABLES it entirely — non-CAS conditional PUTs keep upstream wire + /// behaviour and this whole block, INCLUDING the body-size probe, is skipped. A positive value + /// negotiates Expect for a conditional PUT whose body is at least that many bytes; only a CAS + /// conditional-write client raises it (the single-attempt client built in `ObjectStorageBackend`), + /// so the scope is exactly CAS-owned conditional writes. `x-goog-if-generation-match` is the GCS + /// conditional dialect's rename of If-None-Match / If-Match (applied BEFORE this point). + bool conditional_write = false; + if (expect_continue_min_bytes > 0 + && method == Poco::Net::HTTPRequest::HTTP_PUT + && (poco_request.has("if-none-match") || poco_request.has("if-match") + || poco_request.has("x-goog-if-generation-match"))) + { + size_t content_body_size = 0; + if (const auto & content_body = request.GetContentBody()) + { + content_body->clear(); + content_body->seekg(0, std::ios_base::end); + const auto end_pos = content_body->tellg(); + content_body->clear(); + content_body->seekg(0, std::ios_base::beg); + if (end_pos > 0) + content_body_size = static_cast(end_pos); + } + conditional_write = content_body_size >= expect_continue_min_bytes; + } + if (conditional_write) + poco_request.setExpectContinue(true); + auto & request_body_stream = session->sendRequest(poco_request, &connect_time, &first_byte_time); /// We record connect time here and not earlier, so that if an exception occurs while sending a request, /// we won't record the same latency twice. @@ -624,7 +666,20 @@ void PocoHTTPClient::makeRequestInternalImpl( observeLatency(request, first_byte_latency_type, static_cast(first_byte_time)); latency_recorded = true; - if (request.GetContentBody()) + /// With `Expect: 100-continue`, peek the interim response after the headers. `true` means + /// the server sent `100 Continue` (proceed with the body); `false` means it already sent a + /// FINAL response (now in `poco_response`) and the body must NOT be sent. `receiveResponse` + /// below is still called in both cases (Poco contract) and skips re-reading the headers. + bool skip_body = false; + if (conditional_write) + { + setTimeouts(*session, getTimeouts(method, first_attempt, /*first_byte*/ true)); + skip_body = !session->peekResponse(poco_response); + if (enable_s3_requests_logging) + LOG_TEST(log, "Expect: 100-continue peek -> {}", skip_body ? "final response, skipping body" : "100 Continue"); + } + + if (request.GetContentBody() && !skip_body) { if (enable_s3_requests_logging) LOG_TEST(log, "Writing request body."); @@ -693,6 +748,17 @@ void PocoHTTPClient::makeRequestInternalImpl( response->SetResponseCode(static_cast(status_code)); response->SetContentType(poco_response.getContentType()); + auto apply_gcs_generation_etag_override = [&] + { + if (gcs_conditional_dialect) + { + /// The generation IS the incarnation token on GCS: surface it as the ETag so the + /// entire existing ETag/token plumbing works unchanged (see GCSConditionalDialect.h). + if (auto etag_override = gcsGenerationETagOverride(poco_response)) + response->AddHeader("ETag", *etag_override); + } + }; + if (enable_s3_requests_logging) { WriteBufferFromOwnString headers_ss; @@ -701,12 +767,14 @@ void PocoHTTPClient::makeRequestInternalImpl( response->AddHeader(header_name, header_value); headers_ss << header_name << ": " << header_value << "; "; } + apply_gcs_generation_etag_override(); LOG_TEST(log, "Received headers: {}", headers_ss.str()); } else { for (const auto & [header_name, header_value] : poco_response) response->AddHeader(header_name, header_value); + apply_gcs_generation_etag_override(); } /// Request is successful but for some special requests we can have actual error message in body @@ -835,6 +903,9 @@ void PocoHTTPClientGCPOAuth::makeRequestInternal( Aws::Utils::RateLimits::RateLimiterInterface * readLimiter, Aws::Utils::RateLimits::RateLimiterInterface * writeLimiter) const { + if (gcs_conditional_dialect) + applyGcsConditionalDialectToRequest(request); + { std::lock_guard lock(mutex); if (!bearer_token || std::chrono::system_clock::now() > bearer_token->is_valid_to) @@ -922,6 +993,26 @@ PocoHTTPClientGCPOAuth::BearerToken PocoHTTPClientGCPOAuth::requestBearerTokenFr }; } +PocoHTTPClientGCSHMAC::PocoHTTPClientGCSHMAC(const PocoHTTPClientConfiguration & client_configuration) + : PocoHTTPClient(client_configuration) + , credentials_provider(client_configuration.gcs_hmac_credentials_provider) +{ + if (!credentials_provider) + throw Exception(ErrorCodes::LOGICAL_ERROR, + "PocoHTTPClientGCSHMAC requires a credentials provider (http_client = gcs_hmac wiring bug)"); +} + +void PocoHTTPClientGCSHMAC::makeRequestInternal( + Aws::Http::HttpRequest & request, + std::shared_ptr & response, + Aws::Utils::RateLimits::RateLimiterInterface * readLimiter, + Aws::Utils::RateLimits::RateLimiterInterface * writeLimiter) const +{ + applyGcsConditionalDialectToRequest(request); + signRequestGOOG4(request, credentials_provider->GetAWSCredentials(), std::chrono::system_clock::now()); + PocoHTTPClient::makeRequestInternal(request, response, readLimiter, writeLimiter); +} + } #endif diff --git a/src/IO/S3/PocoHTTPClient.h b/src/IO/S3/PocoHTTPClient.h index e3182a508be9..d3154892919f 100644 --- a/src/IO/S3/PocoHTTPClient.h +++ b/src/IO/S3/PocoHTTPClient.h @@ -30,6 +30,11 @@ namespace Aws::Http::Standard class StandardHttpResponse; } +namespace Aws::Auth +{ +class AWSCredentialsProvider; +} + namespace DB { class Context; @@ -73,6 +78,14 @@ struct PocoHTTPClientConfiguration : public Aws::Client::ClientConfiguration HTTPHeaderEntries extra_headers; String http_client; + /// GCS conditional dialect (spec 2026-07-03-cas-gcs-generation-binding-design): translate + /// AWS-style conditional headers and x-amz-* prefixes to the x-goog dialect at the wire + /// boundary, and surface x-goog-generation as the response ETag. Set for http_client values + /// `gcs_hmac` and `gcp_oauth`; never set for plain AWS-compatible endpoints. + bool gcs_conditional_dialect = false; + /// Credentials for the GOOG4-HMAC signer (http_client = gcs_hmac only): the same provider + /// chain the AWS path builds (inline keys, use_environment_credentials, ...). + std::shared_ptr gcs_hmac_credentials_provider; String service_account; String metadata_service; String request_token_path; @@ -82,6 +95,10 @@ struct PocoHTTPClientConfiguration : public Aws::Client::ClientConfiguration /// See PoolBase::BehaviourOnLimit bool s3_use_adaptive_timeouts = true; + /// Conditional PUT (If-None-Match / If-Match) bodies >= this negotiate Expect: 100-continue (B118). + /// `0` (the default) disables it, so non-CAS S3 clients keep upstream behaviour; only a CAS + /// conditional-write client raises it (see the single-attempt client in `ObjectStorageBackend`). + size_t expect_continue_min_bytes = DEFAULT_EXPECT_CONTINUE_MIN_BYTES; size_t http_keep_alive_timeout = DEFAULT_HTTP_KEEP_ALIVE_TIMEOUT; size_t http_keep_alive_max_requests = DEFAULT_HTTP_KEEP_ALIVE_MAX_REQUEST; @@ -224,11 +241,13 @@ class PocoHTTPClient : public Aws::Http::HttpClient const RemoteHostFilter & remote_host_filter; unsigned int s3_max_redirects = DEFAULT_MAX_REDIRECTS; bool s3_use_adaptive_timeouts = true; + size_t expect_continue_min_bytes = DEFAULT_EXPECT_CONTINUE_MIN_BYTES; const UInt64 http_max_fields = 1000000; const UInt64 http_max_field_name_size = 128 * 1024; const UInt64 http_max_field_value_size = 128 * 1024; bool enable_s3_requests_logging = false; bool for_disk_s3 = false; + bool gcs_conditional_dialect = false; HTTPRequestThrottler request_throttler; @@ -268,6 +287,25 @@ class PocoHTTPClientGCPOAuth : public PocoHTTPClient BearerToken requestBearerTokenFromADC() const; }; +/// GCS with HMAC credentials over the XML API, signed with Google's native GOOG4-HMAC-SHA256 — +/// the ONLY way HMAC credentials get enforced conditional semantics on GCS (the S3-compatible +/// sigv4 surface silently ignores If-None-Match / If-Match; measured 2026-07-03). Applies the GCS +/// conditional dialect, then signs. Selected by `http_client = gcs_hmac`. +class PocoHTTPClientGCSHMAC : public PocoHTTPClient +{ +public: + explicit PocoHTTPClientGCSHMAC(const PocoHTTPClientConfiguration & client_configuration); + +private: + void makeRequestInternal( + Aws::Http::HttpRequest & request, + std::shared_ptr & response, + Aws::Utils::RateLimits::RateLimiterInterface * readLimiter, + Aws::Utils::RateLimits::RateLimiterInterface * writeLimiter) const override; + + std::shared_ptr credentials_provider; +}; + } #endif diff --git a/src/IO/S3/PocoHTTPClientFactory.cpp b/src/IO/S3/PocoHTTPClientFactory.cpp index 0fb1cf40d93c..b7599290adbf 100644 --- a/src/IO/S3/PocoHTTPClientFactory.cpp +++ b/src/IO/S3/PocoHTTPClientFactory.cpp @@ -23,6 +23,9 @@ PocoHTTPClientFactory::CreateHttpClient(const Aws::Client::ClientConfiguration & if (Poco::toLower(poco_client_configuration.http_client) == "gcp_oauth") return std::make_shared(poco_client_configuration); + if (Poco::toLower(poco_client_configuration.http_client) == "gcs_hmac") + return std::make_shared(poco_client_configuration); + return std::make_shared(poco_client_configuration); } diff --git a/src/IO/S3/Requests.h b/src/IO/S3/Requests.h index aa21602674f0..0f55ac33296e 100644 --- a/src/IO/S3/Requests.h +++ b/src/IO/S3/Requests.h @@ -13,6 +13,7 @@ #include #include #include +#include #include #include #include @@ -158,6 +159,7 @@ using ListObjectsV2Request = ExtendedRequest; using ListObjectsRequest = ExtendedRequest; using GetObjectRequest = ExtendedRequest; using GetObjectTaggingRequest = ExtendedRequest; +using GetBucketVersioningRequest = ExtendedRequest; class UploadPartRequest : public ExtendedRequest { diff --git a/src/IO/S3/copyS3File.cpp b/src/IO/S3/copyS3File.cpp index 4a54d7d53ec9..f0aa8cde9b18 100644 --- a/src/IO/S3/copyS3File.cpp +++ b/src/IO/S3/copyS3File.cpp @@ -81,7 +81,9 @@ namespace const std::optional & object_metadata_, ThreadPoolCallbackRunnerUnsafe schedule_, BlobStorageLogWriterPtr blob_storage_log_, - const LoggerPtr log_) + const LoggerPtr log_, + const std::optional & if_none_match_ = {}, + String * out_dest_etag_ = nullptr) : client_ptr(client_ptr_) , dest_bucket(dest_bucket_) , dest_key(dest_key_) @@ -90,6 +92,8 @@ namespace , schedule(schedule_) , blob_storage_log(blob_storage_log_) , log(log_) + , if_none_match(if_none_match_) + , out_dest_etag(out_dest_etag_) , num_parts(0) , normal_part_size(0) { @@ -107,6 +111,13 @@ namespace BlobStorageLogWriterPtr blob_storage_log; const LoggerPtr log; + /// If set, passed as the `If-None-Match` precondition on the destination write of a copy + /// (`CopyObject` and, for large objects, `CompleteMultipartUpload`), making the copy write-once + /// conditional. Only meaningful for copyS3File() (CopyFileHelper); unused by copyDataToS3File(). + const std::optional if_none_match; + /// If non-null, filled in with the destination object's ETag on a successful copy. + String * out_dest_etag; + /// Represents a task uploading a single part. /// Keep this struct small because there can be thousands of parts. /// For example, `UploadPartTask` must not contain a read buffer or `S3::UploadPartRequest` @@ -197,6 +208,9 @@ namespace request.SetMultipartUpload(multipart_upload); + if (if_none_match.has_value()) + request.SetIfNoneMatch(*if_none_match); + size_t max_retries = std::max(request_settings[S3RequestSetting::max_unexpected_write_error_retries].value, 1UL); for (size_t retries = 1;; ++retries) { @@ -216,6 +230,8 @@ namespace if (outcome.IsSuccess()) { + if (out_dest_etag) + *out_dest_etag = outcome.GetResult().GetETag(); LOG_TRACE(log, "Multipart upload has completed. Bucket: {}, Key: {}, Upload_id: {}, Parts: {}", dest_bucket, dest_key, multipart_upload_id, multipart_tags.size()); break; } @@ -228,10 +244,17 @@ namespace continue; /// will retry } ProfileEvents::increment(ProfileEvents::WriteBufferFromS3RequestsErrors, 1); + /// Preserve the S3 exception name (e.g. `PreconditionFailed` for a rejected + /// `If-None-Match` on `CompleteMultipartUpload`) on the thrown exception: `GetErrorType()` + /// alone maps an unmodeled error like a 412 to `UNKNOWN`, so callers that need to detect + /// a specific condition (a write-once conditional copy losing the race) must be able to + /// read `S3Exception::getExceptionName()`, mirroring how `S3ObjectStorage::removeObjectIfTokenMatches` + /// reads `AWSError::GetExceptionName()` directly off the (not-yet-thrown) outcome. throw S3Exception( + PreformattedMessage::create("Message: {}, Key: {}, Bucket: {}, Tags: {}", + outcome.GetError().GetMessage(), dest_key, dest_bucket, fmt::join(multipart_tags.begin(), multipart_tags.end(), " ")), outcome.GetError().GetErrorType(), - "Message: {}, Key: {}, Bucket: {}, Tags: {}", - outcome.GetError().GetMessage(), dest_key, dest_bucket, fmt::join(multipart_tags.begin(), multipart_tags.end(), " ")); + outcome.GetError().GetExceptionName()); } } @@ -613,7 +636,9 @@ namespace const std::optional & object_metadata_, ThreadPoolCallbackRunnerUnsafe schedule_, BlobStorageLogWriterPtr blob_storage_log_, - std::function fallback_method_) + std::function fallback_method_, + const std::optional & if_none_match_ = {}, + String * out_dest_etag_ = nullptr) : UploadHelper( client_ptr_, dest_bucket_, @@ -622,7 +647,9 @@ namespace object_metadata_, schedule_, blob_storage_log_, - getLogger("copyS3File")) + getLogger("copyS3File"), + if_none_match_, + out_dest_etag_) , src_bucket(src_bucket_) , src_key(src_key_) , offset(src_offset_) @@ -676,6 +703,9 @@ namespace request.SetMetadataDirective(Aws::S3::Model::MetadataDirective::REPLACE); } + if (if_none_match.has_value()) + request.SetIfNoneMatch(*if_none_match); + const auto & storage_class_name = request_settings[S3RequestSetting::storage_class_name]; if (!storage_class_name.value.empty()) request.SetStorageClass(Aws::S3::Model::StorageClassMapper::GetStorageClassForName(storage_class_name)); @@ -698,6 +728,8 @@ namespace auto outcome = client_ptr->CopyObject(request); if (outcome.IsSuccess()) { + if (out_dest_etag) + *out_dest_etag = outcome.GetResult().GetCopyObjectResultDetails().GetETag(); LOG_TRACE( log, "Single operation copy has completed. Bucket: {}, Key: {}, Object size: {}", @@ -715,6 +747,12 @@ namespace { if (!supports_multipart_copy || outcome.GetError().GetExceptionName() == "AccessDenied") { + if (if_none_match.has_value()) + throw S3Exception( + outcome.GetError().GetMessage(), + outcome.GetError().GetErrorType(), + outcome.GetError().GetExceptionName()); + LOG_INFO( log, "Multipart upload using copy is not supported, will try regular upload for Bucket: {}, Key: {}, Object size: " @@ -753,13 +791,17 @@ namespace continue; /// will retry } + /// Preserve the S3 exception name for the same reason as the `CompleteMultipartUpload` + /// throw in `completeMultipartUpload()` above (a 412 on a conditional `CopyObject` maps + /// to `S3Errors::UNKNOWN`; the exception name is the only reliable discriminator). throw S3Exception( + PreformattedMessage::create("Message: {}, Key: {}, Bucket: {}, Object size: {}", + outcome.GetError().GetMessage(), + dest_key, + dest_bucket, + size), outcome.GetError().GetErrorType(), - "Message: {}, Key: {}, Bucket: {}, Object size: {}", - outcome.GetError().GetMessage(), - dest_key, - dest_bucket, - size); + outcome.GetError().GetExceptionName()); } } @@ -774,6 +816,9 @@ namespace if (e.getS3ErrorCode() != Aws::S3::S3Errors::ACCESS_DENIED) throw; + if (if_none_match.has_value()) + throw; + tryLogCurrentException(log, "Multi part copy failed, trying with regular upload"); fallback_method(); } @@ -855,7 +900,9 @@ void copyS3File( BlobStorageLogWriterPtr blob_storage_log, ThreadPoolCallbackRunnerUnsafe schedule, const CreateReadBuffer& fallback_file_reader, - const std::optional & object_metadata) + const std::optional & object_metadata, + std::optional if_none_match, + String * out_dest_etag) { if (!dest_s3_client) dest_s3_client = src_s3_client; @@ -895,7 +942,9 @@ void copyS3File( object_metadata, schedule, blob_storage_log, - std::move(fallback_method)}; + std::move(fallback_method), + if_none_match, + out_dest_etag}; helper.performCopy(); } diff --git a/src/IO/S3/copyS3File.h b/src/IO/S3/copyS3File.h index c61437585d4d..a67dfaa9b4b3 100644 --- a/src/IO/S3/copyS3File.h +++ b/src/IO/S3/copyS3File.h @@ -30,6 +30,16 @@ using CreateReadBuffer = std::function()>; /// (copyDataToS3File()). /// /// read_settings - is used for throttling in case of native copy is not possible +/// +/// If `if_none_match` is set, it is passed as the `If-None-Match` precondition on the destination +/// write (both the single-operation `CopyObject` request and, for large objects, the multipart +/// `CompleteMultipartUpload` request), turning the copy into a write-once conditional copy. A `412 +/// Precondition Failed` response (destination already exists) is not swallowed: it surfaces as a +/// thrown `S3Exception`. This precondition is only honored on the native server-side copy path; it +/// is not applied if the copy falls back to the non-native read-write copy (`copyDataToS3File`). +/// +/// If `out_dest_etag` is non-null, it is filled in with the ETag of the destination object as +/// reported by the copy response, on success. void copyS3File( std::shared_ptr src_s3_client, const String & src_bucket, @@ -44,7 +54,9 @@ void copyS3File( BlobStorageLogWriterPtr blob_storage_log, ThreadPoolCallbackRunnerUnsafe schedule, const CreateReadBuffer& fallback_file_reader, - const std::optional & object_metadata = std::nullopt); + const std::optional & object_metadata = std::nullopt, + std::optional if_none_match = {}, + String * out_dest_etag = nullptr); /// Copies data from any seekable source to S3. /// The same functionality can be done by using the function copyData() and the class WriteBufferFromS3 diff --git a/src/IO/S3/tests/gtest_aws_s3_client.cpp b/src/IO/S3/tests/gtest_aws_s3_client.cpp index b3d8f50135bf..d86eba30b75f 100644 --- a/src/IO/S3/tests/gtest_aws_s3_client.cpp +++ b/src/IO/S3/tests/gtest_aws_s3_client.cpp @@ -20,13 +20,16 @@ #include #include #include +#include #include +#include #include #include #include #include #include +#include #include #include #include @@ -42,6 +45,11 @@ namespace DB::S3RequestSetting extern const S3RequestSettingsUInt64 max_unexpected_write_error_retries; } +namespace ProfileEvents +{ + extern const Event S3SingleAttemptRetryConsultations; +} + /* * When all tests are executed together, `Context::getGlobalContextInstance()` is not null. Global context is used by * ProxyResolvers to get proxy configuration (used by S3 clients). If global context does not have a valid ConfigRef, it relies on @@ -197,6 +205,135 @@ static void testServerSideEncryption( EXPECT_EQ(content, expected_headers); } +TEST(IOTestAwsS3Client, DoesNotRetryPreconditionFailed) +{ + /// B166: a 412 Precondition Failed (conditional CAS/dedup writes of the content-addressed + /// backend) must NOT be retried, even when the SDK marks it retryable because an S3-compatible + /// server (e.g. RustFS) returned a body whose ExceptionName it could not parse. Retrying it is a + /// storm that stalls the write path. + DB::S3::Client::RetryStrategy strategy(DB::S3::PocoHTTPClientConfiguration::RetryStrategy{.max_retries = 10}); + + Aws::Client::AWSError precondition(Aws::Client::CoreErrors::UNKNOWN, /*isRetryable=*/true); + precondition.SetResponseCode(Aws::Http::HttpResponseCode::PRECONDITION_FAILED); + EXPECT_FALSE(strategy.ShouldRetry(precondition, /*attemptedRetries=*/0)); + EXPECT_TRUE(DB::S3::isPreconditionFailedError(precondition)); // one policy: agrees via response code + + /// A genuinely transient error is still retried (the guard is specific to 412). + Aws::Client::AWSError unavailable(Aws::Client::CoreErrors::SLOW_DOWN, /*isRetryable=*/true); + unavailable.SetResponseCode(Aws::Http::HttpResponseCode::SERVICE_UNAVAILABLE); + EXPECT_TRUE(strategy.ShouldRetry(unavailable, /*attemptedRetries=*/0)); + EXPECT_FALSE(DB::S3::isPreconditionFailedError(unavailable)); + + /// The one 412 policy also matches on the canonical name / raw body (the two CA conditional + /// ops see an error whose ExceptionName the SDK DID parse, or whose body carries the token). + Aws::Client::AWSError named(Aws::S3::S3Errors::UNKNOWN, "PreconditionFailed", "precondition failed", false); + EXPECT_TRUE(DB::S3::isPreconditionFailedError(named)); + + /// Typed-exception surface (the conditional copy / finalize catch an S3Exception): name and message. + EXPECT_TRUE(DB::S3Exception("boom", Aws::S3::S3Errors::UNKNOWN, "PreconditionFailed").isPreconditionFailed()); + EXPECT_FALSE(DB::S3Exception("boom", Aws::S3::S3Errors::NO_SUCH_KEY, "NoSuchKey").isPreconditionFailed()); +} + +/// Every consultation is counted, not just the first: simulating two retryable 5xx decisions in a row +/// proves the counter tracks each SDK consultation rather than being fixed/clamped at 1, which is what +/// makes it a live tripwire ("SDK-level retries must remain zero for conditional writes") rather than a +/// value nothing ever touches. +TEST(IOTestAwsS3Client, SingleAttemptRetryStrategyRefusesAndCounts) +{ + using ProfileEvents::global_counters; + const auto before = global_counters[ProfileEvents::S3SingleAttemptRetryConsultations].load(); + DB::S3::SingleAttemptRetryStrategy strategy; + const Aws::Client::AWSError retryable_5xx( + Aws::Client::CoreErrors::INTERNAL_FAILURE, /*isRetryable=*/true); + EXPECT_FALSE(strategy.ShouldRetry(retryable_5xx, /*attempted=*/0)); + EXPECT_FALSE(strategy.ShouldRetry(retryable_5xx, /*attempted=*/1)); + EXPECT_EQ(strategy.GetMaxAttempts(), 1); + EXPECT_EQ(global_counters[ProfileEvents::S3SingleAttemptRetryConsultations].load() - before, 2u); +} + +/// Drive a single-part conditional PUT (`If-None-Match: *`) with `body_size` bytes through a real +/// S3 client whose `expect_continue_min_bytes` gate is `threshold`, against the mock HTTP server, and +/// report whether the request that reached the wire carried an `Expect: 100-continue` header. +static bool conditionalPutNegotiatesExpectContinue(uint64_t threshold, size_t body_size) +{ + TestPocoHTTPServer http; + + DB::RemoteHostFilter remote_host_filter; + DB::S3::URI uri(http.getUrl() + "/IOTestAwsS3ClientExpectContinue/test.txt"); + + DB::S3::PocoHTTPClientConfiguration client_configuration = DB::S3::ClientFactory::instance().createClientConfiguration( + "us-east-1", + remote_host_filter, + /* s3_max_redirects = */ 100, + DB::S3::PocoHTTPClientConfiguration::RetryStrategy{.max_retries = 0}, + /* s3_slow_all_threads_after_network_error = */ true, + /* s3_slow_all_threads_after_retryable_error = */ true, + /* enable_s3_requests_logging = */ false, + /* for_disk_s3 = */ false, + /* opt_disk_name = */ {}, + /* request_throttler = */ {}, + uri.uri.getScheme()); + + client_configuration.endpointOverride = uri.endpoint; + client_configuration.expect_continue_min_bytes = threshold; + + DB::S3::ClientSettings client_settings{ + .use_virtual_addressing = uri.is_virtual_hosted_style, + .disable_checksum = false, + .gcs_issue_compose_request = false, + .is_s3express_bucket = false, + }; + + std::shared_ptr client = DB::S3::ClientFactory::instance().create( + client_configuration, + client_settings, + /* access_key_id = */ "ACCESS_KEY_ID", + /* secret_access_key = */ "SECRET_ACCESS_KEY", + /* server_side_encryption_customer_key_base64 = */ "", + /* sse_kms_config = */ {}, + /* headers = */ {}, + DB::S3::CredentialsConfiguration{ + .use_environment_credentials = false, + .use_insecure_imds_request = false, + }); + + DB::S3::S3RequestSettings request_settings; + request_settings[DB::S3RequestSetting::max_unexpected_write_error_retries] = 1; + + DB::WriteSettings write_settings; + write_settings.object_storage_write_if_none_match = "*"; + + DB::WriteBufferFromS3 write_buffer( + client, + uri.bucket, + uri.key, + DB::DBMS_DEFAULT_BUFFER_SIZE, + request_settings, + /* blob_log = */ nullptr, + /* object_metadata = */ std::nullopt, + /* schedule = */ {}, + write_settings); + + const std::string body(body_size, 'x'); + write_buffer.write(body.data(), body.size()); + write_buffer.finalize(); + + return http.getLastRequestHeader().has("Expect"); +} + +TEST(IOTestAwsS3Client, ExpectContinueOnlyWhenThresholdPositive) +{ + /// RExpect: `Expect: 100-continue` (B118) is scoped to CAS-owned conditional writes. A non-CAS S3 + /// client carries the default threshold 0 (disabled) and must NOT negotiate Expect on a conditional + /// PUT — that is the upstream wire behaviour a non-CAS disk (e.g. Iceberg's If-None-Match commits) + /// must keep. A CAS conditional-write client raises the threshold (see the single-attempt client in + /// ObjectStorageBackend) and DOES negotiate it for a body at least that large. + EXPECT_FALSE(conditionalPutNegotiatesExpectContinue(/*threshold=*/0, /*body_size=*/64)); + EXPECT_TRUE(conditionalPutNegotiatesExpectContinue(/*threshold=*/8, /*body_size=*/64)); + /// A positive threshold still excludes a body below it (only large bodies warrant the round-trip). + EXPECT_FALSE(conditionalPutNegotiatesExpectContinue(/*threshold=*/128, /*body_size=*/64)); +} + TEST(IOTestAwsS3Client, AppendExtraSSECHeadersRead) { /// See https://github.com/ClickHouse/ClickHouse/pull/19748 diff --git a/src/IO/S3/tests/gtest_gcs_conditional_dialect.cpp b/src/IO/S3/tests/gtest_gcs_conditional_dialect.cpp new file mode 100644 index 000000000000..30d1117a0c60 --- /dev/null +++ b/src/IO/S3/tests/gtest_gcs_conditional_dialect.cpp @@ -0,0 +1,159 @@ +#include "config.h" +#if USE_AWS_S3 +#include +#include +#include +#include +#include +#include /// DEBUG_OR_SANITIZER_BUILD + +using namespace DB::S3; + +static Aws::Http::Standard::StandardHttpRequest makeRequest( + const char * url = "https://storage.googleapis.com/b/k", + Aws::Http::HttpMethod method = Aws::Http::HttpMethod::HTTP_PUT) +{ + Aws::Http::Standard::StandardHttpRequest request{Aws::Http::URI(url), method}; + request.SetHeaderValue("host", "storage.googleapis.com"); + return request; +} + +TEST(GCSConditionalDialect, IfNoneMatchStarBecomesGenerationZero) +{ + auto r = makeRequest(); + r.SetHeaderValue("if-none-match", "*"); + applyGcsConditionalDialectToRequest(r); + EXPECT_FALSE(r.HasHeader("if-none-match")); + EXPECT_EQ(r.GetHeaderValue("x-goog-if-generation-match"), "0"); +} + +TEST(GCSConditionalDialect, IfMatchDigitsMappedQuotesStripped) +{ + auto r = makeRequest(); + r.SetHeaderValue("if-match", "\"1783078552147137\""); + applyGcsConditionalDialectToRequest(r); + EXPECT_FALSE(r.HasHeader("if-match")); + EXPECT_EQ(r.GetHeaderValue("x-goog-if-generation-match"), "1783078552147137"); +} + +TEST(GCSConditionalDialect, IfMatchUnquotedDigitsAlsoAccepted) +{ + auto r = makeRequest(); + r.SetHeaderValue("if-match", "1783078552147137"); + applyGcsConditionalDialectToRequest(r); + EXPECT_EQ(r.GetHeaderValue("x-goog-if-generation-match"), "1783078552147137"); +} + +#ifndef DEBUG_OR_SANITIZER_BUILD +TEST(GCSConditionalDialect, NonNumericIfMatchThrows) +{ + /// The guard throws LOGICAL_ERROR (a broken-invariant signal: an S3-style ETag reached a + /// generation-dialect client). Under abort_on_logical_error that aborts at construction instead of + /// being catchable, so GCSConditionalDialectDeathTest.NonNumericIfMatchAborts proves it there. + auto r = makeRequest(); + r.SetHeaderValue("if-match", "\"6654c734ccab8f440ff0825eb443dc7f\""); /// an ETag leaked into a generation dialect + EXPECT_THROW(applyGcsConditionalDialectToRequest(r), DB::Exception); +} +#endif + +#if defined(DEBUG_OR_SANITIZER_BUILD) +TEST(GCSConditionalDialectDeathTest, NonNumericIfMatchAborts) +{ + auto r = makeRequest(); + r.SetHeaderValue("if-match", "\"6654c734ccab8f440ff0825eb443dc7f\""); + EXPECT_DEATH({ applyGcsConditionalDialectToRequest(r); }, ""); +} +#endif + +#ifndef DEBUG_OR_SANITIZER_BUILD +TEST(GCSConditionalDialect, NonStarIfNoneMatchThrows) +{ + /// LOGICAL_ERROR (broken invariant); aborts under abort_on_logical_error -- see the DeathTest below. + auto r = makeRequest(); + r.SetHeaderValue("if-none-match", "\"123\""); + EXPECT_THROW(applyGcsConditionalDialectToRequest(r), DB::Exception); +} +#endif + +#if defined(DEBUG_OR_SANITIZER_BUILD) +TEST(GCSConditionalDialectDeathTest, NonStarIfNoneMatchAborts) +{ + auto r = makeRequest(); + r.SetHeaderValue("if-none-match", "\"123\""); + EXPECT_DEATH({ applyGcsConditionalDialectToRequest(r); }, ""); +} +#endif + +TEST(GCSConditionalDialect, AmzHeadersRenamedAuthArtifactsDropped) +{ + auto r = makeRequest(); + r.SetHeaderValue("authorization", "AWS4-HMAC-SHA256 ..."); + r.SetHeaderValue("x-amz-date", "20260703T000000Z"); + r.SetHeaderValue("x-amz-content-sha256", "deadbeef"); + r.SetHeaderValue("x-amz-security-token", "tok"); + r.SetHeaderValue("x-amz-api-version", "2006-03-01"); + r.SetHeaderValue("x-amz-meta-foo", "bar"); + r.SetHeaderValue("x-amz-storage-class", "STANDARD"); + applyGcsConditionalDialectToRequest(r); + EXPECT_FALSE(r.HasHeader("authorization")); + EXPECT_FALSE(r.HasHeader("x-amz-date")); + EXPECT_FALSE(r.HasHeader("x-amz-content-sha256")); + EXPECT_FALSE(r.HasHeader("x-amz-security-token")); + EXPECT_FALSE(r.HasHeader("x-amz-api-version")); + EXPECT_FALSE(r.HasHeader("x-amz-meta-foo")); + EXPECT_FALSE(r.HasHeader("x-amz-storage-class")); + EXPECT_EQ(r.GetHeaderValue("x-goog-meta-foo"), "bar"); + EXPECT_EQ(r.GetHeaderValue("x-goog-storage-class"), "STANDARD"); +} + +#ifndef DEBUG_OR_SANITIZER_BUILD +TEST(GCSConditionalDialect, ConditionalCompleteMultipartUploadThrows) +{ + /// GCS silently IGNORES preconditions on CompleteMultipartUpload (measured live 2026-07-03) -- + /// sending one would be silent data loss, so the dialect fails closed client-side with a + /// LOGICAL_ERROR; aborts under abort_on_logical_error -- see the DeathTest below. + auto r = makeRequest("https://storage.googleapis.com/b/k?uploadId=abc", Aws::Http::HttpMethod::HTTP_POST); + r.SetHeaderValue("if-none-match", "*"); + EXPECT_THROW(applyGcsConditionalDialectToRequest(r), DB::Exception); +} +#endif + +#if defined(DEBUG_OR_SANITIZER_BUILD) +TEST(GCSConditionalDialectDeathTest, ConditionalCompleteMultipartUploadAborts) +{ + auto r = makeRequest("https://storage.googleapis.com/b/k?uploadId=abc", Aws::Http::HttpMethod::HTTP_POST); + r.SetHeaderValue("if-none-match", "*"); + EXPECT_DEATH({ applyGcsConditionalDialectToRequest(r); }, ""); +} +#endif + +TEST(GCSConditionalDialect, UnconditionalCompleteMultipartUploadPasses) +{ + auto r = makeRequest("https://storage.googleapis.com/b/k?uploadId=abc", Aws::Http::HttpMethod::HTTP_POST); + EXPECT_NO_THROW(applyGcsConditionalDialectToRequest(r)); +} + +TEST(GCSConditionalDialect, UploadPartIsNotComplete) +{ + /// PUT ?partNumber=N&uploadId=... is an UploadPart, not a Complete — must not trip the guard. + auto r = makeRequest("https://storage.googleapis.com/b/k?partNumber=1&uploadId=abc", Aws::Http::HttpMethod::HTTP_PUT); + EXPECT_NO_THROW(applyGcsConditionalDialectToRequest(r)); +} + +TEST(GCSConditionalDialect, ResponseGenerationOverridesETag) +{ + Poco::Net::HTTPResponse response; + response.set("ETag", "\"6654c734ccab8f440ff0825eb443dc7f\""); + response.set("x-goog-generation", "1783078552147137"); + auto override_etag = gcsGenerationETagOverride(response); + ASSERT_TRUE(override_etag.has_value()); + EXPECT_EQ(*override_etag, "\"1783078552147137\""); +} + +TEST(GCSConditionalDialect, ResponseWithoutGenerationNoOverride) +{ + Poco::Net::HTTPResponse response; + response.set("ETag", "\"abc\""); + EXPECT_FALSE(gcsGenerationETagOverride(response).has_value()); +} +#endif diff --git a/src/IO/S3/tests/gtest_goog4_signer.cpp b/src/IO/S3/tests/gtest_goog4_signer.cpp new file mode 100644 index 000000000000..f3e1a95aad6a --- /dev/null +++ b/src/IO/S3/tests/gtest_goog4_signer.cpp @@ -0,0 +1,62 @@ +#include "config.h" +#if USE_AWS_S3 +#include +#include +#include +#include + +using namespace DB::S3; + +static std::chrono::system_clock::time_point fixedNow() +{ + /// 2026-07-03 00:00:00 UTC + return std::chrono::system_clock::from_time_t(1783036800); +} + +TEST(GOOG4Signer, PutWithGenerationPrecondition) +{ + Aws::Http::Standard::StandardHttpRequest request( + Aws::Http::URI("https://storage.googleapis.com/test-bucket/dir/obj.txt"), Aws::Http::HttpMethod::HTTP_PUT); + request.SetHeaderValue("host", "storage.googleapis.com"); + request.SetHeaderValue("x-goog-if-generation-match", "0"); + + signRequestGOOG4(request, Aws::Auth::AWSCredentials("GOOGTESTACCESSKEY", "testsecretkey"), fixedNow()); + + EXPECT_EQ(request.GetHeaderValue("x-goog-date"), "20260703T000000Z"); + EXPECT_EQ(request.GetHeaderValue("x-goog-content-sha256"), "UNSIGNED-PAYLOAD"); + EXPECT_EQ(request.GetHeaderValue("authorization"), + "GOOG4-HMAC-SHA256 Credential=GOOGTESTACCESSKEY/20260703/auto/storage/goog4_request, " + "SignedHeaders=host;x-goog-content-sha256;x-goog-date;x-goog-if-generation-match, " + "Signature=4f82e49c69753329afd4768ccf1db6b472dbbd86d082a08b5b9f9fe368fb6ef6"); +} + +TEST(GOOG4Signer, GetWithQueryString) +{ + Aws::Http::Standard::StandardHttpRequest request( + Aws::Http::URI("https://storage.googleapis.com/test-bucket/?versioning"), Aws::Http::HttpMethod::HTTP_GET); + request.SetHeaderValue("host", "storage.googleapis.com"); + + signRequestGOOG4(request, Aws::Auth::AWSCredentials("GOOGTESTACCESSKEY", "testsecretkey"), fixedNow()); + + EXPECT_EQ(request.GetHeaderValue("authorization"), + "GOOG4-HMAC-SHA256 Credential=GOOGTESTACCESSKEY/20260703/auto/storage/goog4_request, " + "SignedHeaders=host;x-goog-content-sha256;x-goog-date, " + "Signature=28a981c32acff334738b9ea1a0f82c28c9a1ccff5b6dc8fb92a2e6622c8db73f"); +} + +TEST(GOOG4Signer, NonGoogHeadersAreNotSigned) +{ + Aws::Http::Standard::StandardHttpRequest request( + Aws::Http::URI("https://storage.googleapis.com/test-bucket/dir/obj.txt"), Aws::Http::HttpMethod::HTTP_PUT); + request.SetHeaderValue("host", "storage.googleapis.com"); + request.SetHeaderValue("x-goog-if-generation-match", "0"); + request.SetHeaderValue("content-type", "binary/octet-stream"); + request.SetHeaderValue("amz-sdk-invocation-id", "whatever"); + + signRequestGOOG4(request, Aws::Auth::AWSCredentials("GOOGTESTACCESSKEY", "testsecretkey"), fixedNow()); + + /// Unsigned headers must not perturb the signature: same vector as PutWithGenerationPrecondition. + EXPECT_NE(request.GetHeaderValue("authorization").find( + "Signature=4f82e49c69753329afd4768ccf1db6b472dbbd86d082a08b5b9f9fe368fb6ef6"), std::string::npos); +} +#endif diff --git a/src/IO/S3AuthSettings.cpp b/src/IO/S3AuthSettings.cpp index 2bcfe965e84c..7946dccbeb2c 100644 --- a/src/IO/S3AuthSettings.cpp +++ b/src/IO/S3AuthSettings.cpp @@ -25,6 +25,7 @@ namespace DB DECLARE(Bool, no_sign_request, S3::DEFAULT_NO_SIGN_REQUEST, "", 0) \ DECLARE(Bool, use_insecure_imds_request, false, "", 0) \ DECLARE(Bool, use_adaptive_timeouts, S3::DEFAULT_USE_ADAPTIVE_TIMEOUTS, "", 0) \ + DECLARE(UInt64, expect_continue_min_bytes, S3::DEFAULT_EXPECT_CONTINUE_MIN_BYTES, "", 0) \ DECLARE(Bool, is_virtual_hosted_style, false, "", 0) \ DECLARE(Bool, disable_checksum, S3::DEFAULT_DISABLE_CHECKSUM, "", 0) \ DECLARE(Bool, gcs_issue_compose_request, false, "", 0) \ diff --git a/src/IO/S3Common.cpp b/src/IO/S3Common.cpp index be42b184589b..47f7c53386f9 100644 --- a/src/IO/S3Common.cpp +++ b/src/IO/S3Common.cpp @@ -43,6 +43,51 @@ bool S3Exception::isAccessTokenExpiredError() const return code == Aws::S3::S3Errors::INVALID_ACCESS_KEY_ID || code == Aws::S3::S3Errors::ACCESS_DENIED || code == Aws::S3::S3Errors::INVALID_SIGNATURE || code == Aws::S3::S3Errors::UNKNOWN; } +bool S3Exception::isPreconditionFailed() const +{ + /// See `S3::isPreconditionFailedError`. The thrown exception no longer carries the HTTP status, so + /// only the name and raw message are available here — fail-safe: matching too broadly maps a hard + /// error to a retryable re-validate, never a false success. + return exception_name == "PreconditionFailed" + || message().find("PreconditionFailed") != std::string::npos; +} + +namespace S3 +{ + +/// A synchronous rejection PROVING the request was never applied — matched by the canonical S3 error +/// code STRING (many of these are UNKNOWN in the SDK's modeled enum, mirroring +/// ObjectStorageBackend::finalizeConditionalWrite's own name-first matching) plus the modeled enum +/// value where one exists, belt-and-suspenders. +bool isMalformedRequestError(const S3Exception & e) +{ + const String & name = e.getExceptionName(); + return name == "MalformedXML" || name == "MalformedPOSTRequest" || name == "InvalidArgument" + || name == "InvalidRequest" || name == "InvalidBucketName" || name == "KeyTooLongError" + || e.getS3ErrorCode() == Aws::S3::S3Errors::INVALID_PARAMETER_VALUE + || e.getS3ErrorCode() == Aws::S3::S3Errors::INVALID_REQUEST + || e.getS3ErrorCode() == Aws::S3::S3Errors::VALIDATION; +} + +bool isEntityTooLargeError(const S3Exception & e) +{ + /// No modeled enum value for this error — name-only match, same as PreconditionFailed elsewhere. + return e.getExceptionName() == "EntityTooLarge"; +} + +bool isAccessDeniedError(const S3Exception & e) +{ + const String & name = e.getExceptionName(); + return name == "AccessDenied" || name == "InvalidAccessKeyId" || name == "SignatureDoesNotMatch" + || name == "InvalidToken" || name == "ExpiredToken" || name == "AccountProblem" + || e.getS3ErrorCode() == Aws::S3::S3Errors::ACCESS_DENIED + || e.getS3ErrorCode() == Aws::S3::S3Errors::INVALID_ACCESS_KEY_ID + || e.getS3ErrorCode() == Aws::S3::S3Errors::SIGNATURE_DOES_NOT_MATCH + || e.getS3ErrorCode() == Aws::S3::S3Errors::INVALID_CLIENT_TOKEN_ID; +} + +} + } #endif diff --git a/src/IO/S3Common.h b/src/IO/S3Common.h index 27532ca851e7..8c1a4d524294 100644 --- a/src/IO/S3Common.h +++ b/src/IO/S3Common.h @@ -37,9 +37,18 @@ class S3Exception : public Exception { } - S3Exception(const std::string & msg, Aws::S3::S3Errors code_) + S3Exception(const std::string & msg, Aws::S3::S3Errors code_, String exception_name_ = {}) : Exception(msg, ErrorCodes::S3_ERROR) , code(code_) + , exception_name(std::move(exception_name_)) + {} + + /// Preserves the static format string (system.text_log / system.errors grouping) while also + /// carrying the canonical S3 error name — build msg with PreformattedMessage::create. + S3Exception(PreformattedMessage && msg, Aws::S3::S3Errors code_, String exception_name_) + : Exception(std::move(msg), ErrorCodes::S3_ERROR) + , code(code_) + , exception_name(std::move(exception_name_)) {} Aws::S3::S3Errors getS3ErrorCode() const @@ -47,15 +56,57 @@ class S3Exception : public Exception return code; } + /// The canonical S3 error code string from the response XML `` (e.g. "PreconditionFailed", + /// "NoSuchKey") as reported by `Aws::Client::AWSError::GetExceptionName`. Errors unmodeled by the + /// SDK (a conditional-PUT 412 is one) have `getS3ErrorCode` == UNKNOWN, so this name is the only + /// machine-readable discriminator. Empty when the throw site did not attach it. + /// Not `Exception::name`; this is the AWS `` string. + const String & getExceptionName() const + { + return exception_name; + } + bool isRetryableError() const; bool isAccessTokenExpiredError() const; + /// True for a conditional-request 412 (a lost `If-Match`/`If-None-Match`). The thrown exception + /// discards the HTTP status, so it matches on the canonical `` name and the raw message — + /// see `S3::isPreconditionFailedError` for the full (response-code-aware) policy. + bool isPreconditionFailed() const; + S3Exception * clone() const override { return new S3Exception(*this); } void rethrow() const override { throw *this; } /// NOLINT(cert-err60-cpp) private: Aws::S3::S3Errors code; + String exception_name; }; + +namespace S3 +{ + +/// One policy for "is this error a conditional-request 412 (`PreconditionFailed`)?", shared by the +/// retry strategy and the CA conditional delete/copy paths. The HTTP status is authoritative — a +/// non-AWS body (e.g. RustFS) leaves the SDK-parsed `ExceptionName` empty — with the canonical `` +/// name and the raw message as fallbacks. Fail-safe by direction: over-matching only forces a caller +/// re-validate, never a false success. +template +inline bool isPreconditionFailedError(const Aws::Client::AWSError & error) +{ + return error.GetResponseCode() == Aws::Http::HttpResponseCode::PRECONDITION_FAILED + || error.GetExceptionName() == "PreconditionFailed" + || error.GetMessage().find("PreconditionFailed") != std::string::npos; +} + +/// Error-name classifiers factored out of the CAS conditional-write outcome mapping +/// (`CasRequestControl.cpp`), so the name lists live next to the other S3 error classifiers here +/// and are available for reuse. +bool isMalformedRequestError(const S3Exception & e); +bool isEntityTooLargeError(const S3Exception & e); +bool isAccessDeniedError(const S3Exception & e); + +} + } #endif diff --git a/src/IO/S3Defines.h b/src/IO/S3Defines.h index 228758e57f44..8d13dd036b9a 100644 --- a/src/IO/S3Defines.h +++ b/src/IO/S3Defines.h @@ -33,6 +33,13 @@ inline static constexpr uint64_t DEFAULT_LIST_OBJECT_KEYS_SIZE = 1000; inline static constexpr uint64_t DEFAULT_MAX_SINGLE_READ_TRIES = 4; inline static constexpr uint64_t DEFAULT_MAX_UNEXPECTED_WRITE_ERROR_RETRIES = 4; inline static constexpr uint64_t DEFAULT_MAX_REDIRECTS = 10; +/// Gate for the `Expect: 100-continue` negotiation on a conditional write (If-None-Match / If-Match): +/// `0` = disabled (never negotiate Expect); a positive `N` negotiates Expect for a conditional `PUT` +/// whose body is at least `N` bytes, so the server can reject (e.g. 412) BEFORE the body is streamed +/// (B118). The default is DISABLED: only a CAS conditional-write client raises this (see the +/// single-attempt client built in `ObjectStorageBackend`), so non-CAS S3 traffic keeps upstream +/// behaviour instead of negotiating Expect on large conditional PUTs it never negotiated before. +inline static constexpr uint64_t DEFAULT_EXPECT_CONTINUE_MIN_BYTES = 0; inline static constexpr uint64_t DEFAULT_RETRY_ATTEMPTS = 500; inline static constexpr uint64_t DEFAULT_RETRY_INITIAL_DELAY_MS = 25; inline static constexpr uint64_t DEFAULT_RETRY_MAX_DELAY_MS = 5000; diff --git a/src/IO/WriteBufferFromFileBase.h b/src/IO/WriteBufferFromFileBase.h index 47dd4f5ed7ae..b60e951d8edf 100644 --- a/src/IO/WriteBufferFromFileBase.h +++ b/src/IO/WriteBufferFromFileBase.h @@ -1,5 +1,6 @@ #pragma once +#include #include #include @@ -15,6 +16,12 @@ class WriteBufferFromFileBase : public BufferWithOwnMemory void sync() override = 0; virtual std::string getFileName() const = 0; + + /// The object-storage ETag/token the write produced, if any (e.g. the S3 PutObject / + /// CompleteMultipartUpload response ETag). Empty for backends that do not return a write-time + /// ETag (local files, etc.). Valid only after a successful finalize(). Lets content-addressed + /// callers record the just-written incarnation's token WITHOUT a follow-up HEAD. + virtual std::optional getResultObjectETag() const { return {}; } }; } diff --git a/src/IO/WriteBufferFromFileDecorator.h b/src/IO/WriteBufferFromFileDecorator.h index 07f843986bb0..cc05743642f5 100644 --- a/src/IO/WriteBufferFromFileDecorator.h +++ b/src/IO/WriteBufferFromFileDecorator.h @@ -19,6 +19,15 @@ class WriteBufferFromFileDecorator : public WriteBufferFromFileBase void preFinalize() override; + /// Forward the wrapped buffer's write-time ETag (if it is a file buffer that produced one), so a + /// decorated S3 buffer still lets content-addressed callers skip the post-write HEAD. + std::optional getResultObjectETag() const override + { + if (const auto * file_buf = dynamic_cast(impl.get())) + return file_buf->getResultObjectETag(); + return {}; + } + const WriteBuffer & getImpl() const { return *impl; } protected: diff --git a/src/IO/WriteBufferFromS3.cpp b/src/IO/WriteBufferFromS3.cpp index 8021a1f6cd04..81677edf6ee5 100644 --- a/src/IO/WriteBufferFromS3.cpp +++ b/src/IO/WriteBufferFromS3.cpp @@ -64,6 +64,7 @@ namespace ErrorCodes extern const int S3_ERROR; extern const int INVALID_CONFIG_PARAMETER; extern const int LOGICAL_ERROR; + extern const int NOT_IMPLEMENTED; } struct WriteBufferFromS3::PartData @@ -405,6 +406,15 @@ void WriteBufferFromS3::writeMultipartUpload() void WriteBufferFromS3::createMultipartUpload() { + if (write_settings.s3_force_single_part_upload) + throw Exception(ErrorCodes::NOT_IMPLEMENTED, + "A conditional write would start a MULTIPART upload, but the target store enforces no " + "preconditions on CompleteMultipartUpload (GCS, measured 2026-07-03) — refusing " + "(silent-data-loss risk). The single-PUT budget is governed by the disk setting " + "gcs_max_conditional_put_bytes; the production-grade path for bigger blobs " + "(unconditional multipart to a temp key + conditional Compose) is not implemented yet. {}", + getShortLogDetails()); + LOG_TEST(limited_log, "Create multipart upload. {}", getShortLogDetails()); S3::CreateMultipartUploadRequest req; @@ -678,6 +688,7 @@ bool WriteBufferFromS3::completeMultipartUpload() if (outcome.IsSuccess()) { + object_etag = outcome.GetResult().GetETag(); LOG_TRACE(limited_log, "Multipart upload has completed. {}, Parts: {}", getShortLogDetails(), multipart_tags.size()); return true; } @@ -692,10 +703,13 @@ bool WriteBufferFromS3::completeMultipartUpload() } else { + /// Pass the canonical S3 error name: a conditional-write 412 is UNMODELED for the SDK + /// (the error type is UNKNOWN), so the name is the caller's only typed signal. throw S3Exception( + PreformattedMessage::create("Message: {}, Key: {}, Bucket: {}, Tags: {}", + outcome.GetError().GetMessage(), key, bucket, fmt::join(multipart_tags.begin(), multipart_tags.end(), " ")), outcome.GetError().GetErrorType(), - "Message: {}, Key: {}, Bucket: {}, Tags: {}", - outcome.GetError().GetMessage(), key, bucket, fmt::join(multipart_tags.begin(), multipart_tags.end(), " ")); + outcome.GetError().GetExceptionName()); } } @@ -770,6 +784,7 @@ void WriteBufferFromS3::makeSinglepartUpload(WriteBufferFromS3::PartData && data if (outcome.IsSuccess()) { + object_etag = outcome.GetResult().GetETag(); LOG_TRACE(limited_log, "Single part upload has completed. {}, size {}", getShortLogDetails(), content_length); return; } @@ -785,17 +800,20 @@ void WriteBufferFromS3::makeSinglepartUpload(WriteBufferFromS3::PartData && data else { /// PreconditionFailed is an expected response for conditional writes (e.g. If-None-Match: *), - /// not a genuine error — the caller handles it. - if (outcome.GetError().GetExceptionName() == "PreconditionFailed") + /// not a genuine error — the caller handles it (see `S3::isPreconditionFailedError`). + if (S3::isPreconditionFailedError(outcome.GetError())) LOG_INFO(log, "S3Exception name {}, Message: {}, bucket {}, key {}, object size {}", outcome.GetError().GetExceptionName(), outcome.GetError().GetMessage(), bucket, key, content_length); else LOG_ERROR(log, "S3Exception name {}, Message: {}, bucket {}, key {}, object size {}", outcome.GetError().GetExceptionName(), outcome.GetError().GetMessage(), bucket, key, content_length); + /// Pass the canonical S3 error name: a conditional-write 412 is UNMODELED for the SDK + /// (the error type is UNKNOWN), so the name is the caller's only typed signal. throw S3Exception( + PreformattedMessage::create("Message: {}, bucket {}, key {}, object size {}", + outcome.GetError().GetMessage(), bucket, key, content_length), outcome.GetError().GetErrorType(), - "Message: {}, bucket {}, key {}, object size {}", - outcome.GetError().GetMessage(), bucket, key, content_length); + outcome.GetError().GetExceptionName()); } } diff --git a/src/IO/WriteBufferFromS3.h b/src/IO/WriteBufferFromS3.h index 6a5e88875f27..fbce538c0a43 100644 --- a/src/IO/WriteBufferFromS3.h +++ b/src/IO/WriteBufferFromS3.h @@ -51,6 +51,10 @@ class WriteBufferFromS3 final : public WriteBufferFromFileBase void preFinalize() override; std::string getFileName() const override { return key; } void sync() override { next(); } + /// The object ETag from the PutObject / CompleteMultipartUpload response, captured on a + /// successful upload. Lets content-addressed callers record the written incarnation's token + /// without a follow-up HEAD. Valid only after a successful finalize(). + std::optional getResultObjectETag() const override { return object_etag; } private: /// Receives response from the server after sending all data. @@ -88,6 +92,10 @@ class WriteBufferFromS3 final : public WriteBufferFromFileBase const WriteSettings write_settings; const std::shared_ptr client_ptr; const std::optional object_metadata; + /// Set from the PutObject / CompleteMultipartUpload response ETag on a successful upload; read + /// by getResultObjectETag() after finalize(). Written by the upload worker, read after the + /// finalize barrier (happens-before), so no extra synchronization is needed. + std::optional object_etag; LoggerPtr log = getLogger("WriteBufferFromS3"); LogSeriesLimiterPtr limited_log = std::make_shared(log, 1, 5); diff --git a/src/IO/WriteSettings.h b/src/IO/WriteSettings.h index a3a3cf204f7f..591f803e6400 100644 --- a/src/IO/WriteSettings.h +++ b/src/IO/WriteSettings.h @@ -4,9 +4,22 @@ #include #include +#include + namespace DB { +/// Per-write retry-behavior selector, resolved by the object storage that executes the write. +/// SingleAttempt: exactly one HTTP attempt, no SDK-transparent retries — for conditional writes +/// whose retry loop lives above the storage client (it must resolve an uncertain PUT before +/// reissuing). Backends without a SingleAttempt implementation report it via +/// IObjectStorage::supportsRetryProfile; writers must fail closed rather than fall through. +enum class ObjectStorageRetryProfile : uint8_t +{ + Default, + SingleAttempt, +}; + /// Settings to be passed to IDisk::writeFile() struct WriteSettings { @@ -23,6 +36,12 @@ struct WriteSettings size_t filesystem_cache_reserve_space_wait_lock_timeout_milliseconds = 1000; bool s3_allow_parallel_part_upload = true; + /// Overrides S3RequestSetting::check_objects_after_upload for this write (nullopt = no + /// override). Writers of CAS-MUTABLE keys (content-addressed shard manifests) set `false`: + /// such a key is legitimately replaced by a concurrent conditional PUT between this upload and + /// the check's HEAD, so the size comparison false-positives ("it's a bug in S3") under normal + /// contention. Integrity for those keys is the conditional PUT outcome + token, not a recheck. + std::optional s3_check_objects_after_upload_override; bool azure_allow_parallel_part_upload = true; bool use_adaptive_write_buffer = false; @@ -36,6 +55,26 @@ struct WriteSettings std::string object_storage_write_if_none_match; /// Supported only for S3-like object storages. std::string object_storage_write_if_match; /// Supported only for S3-like object storages. + /// A conditional write on a generation-token store (GCS) must never take the multipart path: + /// GCS enforces no preconditions on CompleteMultipartUpload (measured 2026-07-03). When set, + /// WriteBufferFromS3 throws instead of starting a multipart upload. + bool s3_force_single_part_upload = false; + /// Companion cap: raises max_single_part_upload_size / min_upload_part_size in the request + /// settings so bodies up to this size stay in ONE part (RAM-buffered). 0 = no override. + size_t s3_single_part_upload_max_bytes_override = 0; + + /// Overrides S3RequestSetting::max_unexpected_write_error_retries (default 4) for this write. + /// WriteBufferFromS3::makeSinglepartUpload/completeMultipartUpload run their OWN retry loop above + /// the S3 client that reissues the identical request (WITH its If-None-Match/If-Match condition) + /// on a NO_SUCH_KEY response — a second retry-affecting layer a client-level override + /// (a client-level profile override) does not reach. A CAS conditional write sets this to 1 for + /// exactly one attempt at this layer too (RFC cas-s3-timeout-retry-control). 0 = no override. + size_t s3_max_unexpected_write_error_retries_override = 0; + + /// Selects the retry profile the object storage should execute this write under; see + /// ObjectStorageRetryProfile. + ObjectStorageRetryProfile object_storage_retry_profile = ObjectStorageRetryProfile::Default; + bool operator==(const WriteSettings & other) const = default; }; diff --git a/src/IO/tests/gtest_read_buffer_from_file_view.cpp b/src/IO/tests/gtest_read_buffer_from_file_view.cpp new file mode 100644 index 000000000000..b4a433361856 --- /dev/null +++ b/src/IO/tests/gtest_read_buffer_from_file_view.cpp @@ -0,0 +1,280 @@ +#include + +#include +#include + +#include + +using namespace DB; + +namespace +{ + +/// How the inner buffer reacts to setReadUntilPosition - the axis that broke B115. +enum class InnerMode : uint8_t +{ + /// Like local file descriptors: setReadUntilPosition is a no-op, the buffer is kept. + FileLike, + /// Like ReadBufferFromS3: a range change rebases the offset to the CONSUMER position and + /// DISCARDS the working buffer (the next nextImpl re-fetches from the consumer position). + RemoteLike, +}; + +/// A seekable ReadBufferFromFileBase over a string, reading at most `chunk` bytes per nextImpl, +/// with selectable setReadUntilPosition semantics. Mirrors the state conventions of real +/// implementations: `file_offset` is the absolute offset of working_buffer.end(). +class FakeInnerBuffer : public ReadBufferFromFileBase +{ +public: + FakeInnerBuffer(String data_, size_t chunk, InnerMode mode_) + : ReadBufferFromFileBase(chunk, nullptr, 0) + , data(std::move(data_)) + , mode(mode_) + { + } + + String getFileName() const override { return "fake_inner"; } + std::optional tryGetFileSize() override { return data.size(); } + size_t getFileOffsetOfBufferEnd() const override { return file_offset; } + off_t getPosition() override { return file_offset - available(); } + + off_t seek(off_t off, int whence) override + { + EXPECT_EQ(whence, SEEK_SET); + const size_t target = static_cast(off); + /// In-buffer seek (both real local and S3 buffers do this). + if (!working_buffer.empty() && target + working_buffer.size() >= file_offset && target < file_offset) + { + pos = working_buffer.end() - (file_offset - target); + return off; + } + resetWorkingBuffer(); + file_offset = target; + return off; + } + + void setReadUntilPosition(size_t position) override + { + if (read_until && *read_until == position) + return; + if (mode == InnerMode::RemoteLike) + { + /// ReadBufferFromS3: offset = getPosition(); resetWorkingBuffer(); impl.reset(); + file_offset = getPosition(); + resetWorkingBuffer(); + } + read_until = position; + } + + void setReadUntilEnd() override { setReadUntilPosition(data.size()); } + +private: + bool nextImpl() override + { + const size_t limit = read_until ? std::min(*read_until, data.size()) : data.size(); + if (file_offset >= limit) + return false; + const size_t to_read = std::min(limit - file_offset, internal_buffer.size()); + memcpy(internal_buffer.begin(), data.data() + file_offset, to_read); + working_buffer = Buffer(internal_buffer.begin(), internal_buffer.begin() + to_read); + file_offset += to_read; + return true; + } + + String data; + InnerMode mode; + size_t file_offset = 0; + std::optional read_until; +}; + +constexpr size_t kHeader = 256; /// the view's left bound (the CHCA envelope size in production) + +String makePayload(size_t size) +{ + String s(size, 0); + for (size_t i = 0; i < size; ++i) + s[i] = static_cast((i * 131 + 7) % 251); + return s; +} + +std::unique_ptr makeView(const String & payload, size_t chunk, InnerMode mode) +{ + String object = String(kHeader, '\xee') + payload; + auto inner = std::make_unique(std::move(object), chunk, mode); + return std::make_unique(std::move(inner), "viewed", kHeader, kHeader + payload.size()); +} + +String readExact(ReadBuffer & buf, size_t n) +{ + String out(n, 0); + buf.readStrict(out.data(), n); + return out; +} + +struct Case +{ + size_t chunk; + InnerMode mode; +}; + +class ReadBufferFromFileViewTest : public ::testing::TestWithParam +{ +}; + +} + +TEST_P(ReadBufferFromFileViewTest, SequentialReadWholeView) +{ + const auto [chunk, mode] = GetParam(); + const auto payload = makePayload(1000); + auto view = makeView(payload, chunk, mode); + + EXPECT_EQ(readExact(*view, payload.size()), payload); + EXPECT_TRUE(view->eof()); + EXPECT_EQ(view->getPosition(), static_cast(payload.size())); +} + +TEST_P(ReadBufferFromFileViewTest, SeekAndRead) +{ + const auto [chunk, mode] = GetParam(); + const auto payload = makePayload(1000); + auto view = makeView(payload, chunk, mode); + + for (size_t target : {size_t(0), size_t(700), size_t(20), size_t(21), size_t(999), size_t(5)}) + { + EXPECT_EQ(view->seek(target, SEEK_SET), static_cast(target)); + EXPECT_EQ(view->getPosition(), static_cast(target)); + EXPECT_EQ(readExact(*view, 1), payload.substr(target, 1)); + EXPECT_EQ(view->getPosition(), static_cast(target + 1)); + } +} + +/// B115 regression. The in-order MergeTree reader adjusts the right mark (setReadUntilPosition) +/// while the consumer is mid-buffer. A remote-like inner buffer legitimately discards its working +/// buffer on the range change; the view MUST keep reporting the consumer's position - before the +/// fix it teleported forward by the discarded bytes, so the next seek was treated as "already +/// there" and a stale block was re-served (duplicated + missing granules at the SQL level). +TEST_P(ReadBufferFromFileViewTest, SetReadUntilPositionMidBufferKeepsPosition) +{ + const auto [chunk, mode] = GetParam(); + const auto payload = makePayload(1000); + auto view = makeView(payload, chunk, mode); + + EXPECT_EQ(readExact(*view, 36), payload.substr(0, 36)); + EXPECT_EQ(view->getPosition(), 36); + + view->setReadUntilPosition(72); + EXPECT_EQ(view->getPosition(), 36) << "position must survive a right-bound change"; + + /// The consumer's next seek to its current position must be a no-op... + EXPECT_EQ(view->seek(36, SEEK_SET), 36); + /// ...and the bytes must continue from 36, not from a stale buffer. + EXPECT_EQ(readExact(*view, 36), payload.substr(36, 36)); +} + +/// Truncate-then-extend: the right bound shrinks below already-buffered data, the consumer reads +/// up to it, the bound is extended again. The continuation must produce the file's real bytes +/// (before the fix the view's incremental buffer-end accounting drifted from the inner buffer's). +TEST_P(ReadBufferFromFileViewTest, SetReadUntilTruncateThenExtend) +{ + const auto [chunk, mode] = GetParam(); + const auto payload = makePayload(1000); + auto view = makeView(payload, chunk, mode); + + EXPECT_EQ(readExact(*view, 10), payload.substr(0, 10)); + + view->setReadUntilPosition(30); + EXPECT_EQ(view->getPosition(), 10); + EXPECT_EQ(readExact(*view, 20), payload.substr(10, 20)); + EXPECT_TRUE(view->eof()); + EXPECT_EQ(view->getPosition(), 30); + + view->setReadUntilPosition(500); + EXPECT_EQ(view->getPosition(), 30); + EXPECT_EQ(readExact(*view, 100), payload.substr(30, 100)); + + view->setReadUntilEnd(); + EXPECT_EQ(readExact(*view, payload.size() - 130), payload.substr(130)); + EXPECT_TRUE(view->eof()); +} + +/// The exact shape of the failing compact-part in-order read: per granule, adjust the right +/// mark, seek to the granule's block, read it. Every block must contain its own bytes. +TEST_P(ReadBufferFromFileViewTest, GranulePatternRegression) +{ + const auto [chunk, mode] = GetParam(); + constexpr size_t block = 36; + constexpr size_t blocks = 20; + const auto payload = makePayload(block * blocks); + auto view = makeView(payload, chunk, mode); + + for (size_t g = 0; g < blocks; ++g) + { + view->setReadUntilPosition(std::min((g + 2) * block, payload.size())); + EXPECT_EQ(view->seek(g * block, SEEK_SET), static_cast(g * block)); + EXPECT_EQ(readExact(*view, block), payload.substr(g * block, block)) << "block " << g; + } +} + +/// Randomized conformance battery against a golden model. +TEST_P(ReadBufferFromFileViewTest, RandomizedOps) +{ + const auto [chunk, mode] = GetParam(); + const auto payload = makePayload(2000); + + for (unsigned seed = 1; seed <= 5; ++seed) + { + auto view = makeView(payload, chunk, mode); + size_t model_pos = 0; + size_t model_until = payload.size(); + unsigned rng = seed; + auto next_rand = [&rng] { rng = rng * 1103515245 + 12345; return (rng >> 8) % 1000; }; + + for (int step = 0; step < 300; ++step) + { + switch (next_rand() % 3) + { + case 0: /// read up to the current until-bound + { + const size_t want = next_rand() % 64; + const size_t n = std::min(want, model_until - model_pos); + if (n) + { + ASSERT_EQ(readExact(*view, n), payload.substr(model_pos, n)) << "seed " << seed << " step " << step; + model_pos += n; + } + break; + } + case 1: /// seek (never beyond the current until-bound - the consumer contract: + /// the right mark always covers the ranges being read) + { + const size_t target = next_rand() % (model_until + 1); + ASSERT_EQ(view->seek(target, SEEK_SET), static_cast(target)); + model_pos = target; + break; + } + case 2: /// move the right bound (never below the consumer position) + { + const size_t until = model_pos + next_rand() % (payload.size() - model_pos + 1); + view->setReadUntilPosition(until); + model_until = until; + break; + } + default: + UNREACHABLE(); + } + ASSERT_EQ(view->getPosition(), static_cast(model_pos)) << "seed " << seed << " step " << step; + } + } +} + +INSTANTIATE_TEST_SUITE_P( + ChunksAndModes, + ReadBufferFromFileViewTest, + ::testing::Values( + Case{7, InnerMode::FileLike}, + Case{7, InnerMode::RemoteLike}, + Case{108, InnerMode::FileLike}, + Case{108, InnerMode::RemoteLike}, + Case{1 << 20, InnerMode::FileLike}, + Case{1 << 20, InnerMode::RemoteLike})); diff --git a/src/IO/tests/gtest_read_buffer_from_memory.cpp b/src/IO/tests/gtest_read_buffer_from_memory.cpp new file mode 100644 index 000000000000..b7955f816e79 --- /dev/null +++ b/src/IO/tests/gtest_read_buffer_from_memory.cpp @@ -0,0 +1,19 @@ +#include + +#include + +#include + +using namespace DB; + +/// An empty file materialized into an OWNED in-memory buffer must construct without undefined +/// behaviour: std::memcpy's pointer arguments are __attribute__((nonnull)), so memcpy(dst, nullptr, 0) +/// -- which an empty std::string_view (data() == nullptr) produces -- is UB that the asan_ubsan lane +/// aborts on (STID 5930-5afa, PR #2073). The buffer must construct and be immediately at EOF. +TEST(ReadBufferFromMemoryFileBase, EmptyOwnedBufferConstructsWithoutUB) +{ + /// ReadBufferFromMemoryFileBase's constructor is protected; ReadBufferFromOwnMemoryFile is the + /// public concrete class that always passes owns_memory=true, exercising the guarded memcpy path. + ReadBufferFromOwnMemoryFile buf("empty", std::string_view{}); + EXPECT_TRUE(buf.eof()); +} diff --git a/src/IO/tests/gtest_writebuffer_s3.cpp b/src/IO/tests/gtest_writebuffer_s3.cpp index 997ff354a535..fb09907fe6bd 100644 --- a/src/IO/tests/gtest_writebuffer_s3.cpp +++ b/src/IO/tests/gtest_writebuffer_s3.cpp @@ -18,6 +18,9 @@ #include #include #include +#include +#include +#include #include #include @@ -28,12 +31,16 @@ #include #include #include +#include +#include #include #include #include +#include #include +#include #include @@ -184,6 +191,9 @@ struct EventCounts size_t multiUploadAbort = 0; size_t uploadParts = 0; size_t writtenSize = 0; + size_t copyObject = 0; + size_t deleteObject = 0; + size_t getBucketVersioning = 0; size_t totalRequestsCount() const { @@ -208,6 +218,9 @@ struct InjectionModel DeclareInjectCall(CompleteMultipartUpload) DeclareInjectCall(AbortMultipartUpload) DeclareInjectCall(UploadPart) + DeclareInjectCall(CopyObject) + DeclareInjectCall(DeleteObject) + DeclareInjectCall(GetBucketVersioning) #undef DeclareInjectCall }; @@ -277,6 +290,7 @@ struct Client : DB::S3::Client Aws::S3::Model::PutObjectOutcome outcome; Aws::S3::Model::PutObjectResult result(outcome.GetResultWithOwnership()); + result.SetETag("etag-singlepart-" + request.GetKey()); return result; } @@ -392,6 +406,7 @@ struct Client : DB::S3::Client bStore.CompleteMPU(request.GetKey(), request.GetUploadId(), etags); Aws::S3::Model::CompleteMultipartUploadResult result; + result.SetETag("etag-multipart-" + request.GetKey()); return Aws::S3::Model::CompleteMultipartUploadOutcome(result); } @@ -414,6 +429,69 @@ struct Client : DB::S3::Client return Aws::S3::Model::AbortMultipartUploadOutcome(result); } + Aws::S3::Model::CopyObjectOutcome CopyObject(const Aws::S3::Model::CopyObjectRequest & request) const override + { + ++counters.copyObject; + + if (injections) + { + if (auto opt_val = injections->call(request)) + return std::move(*opt_val); + } + + /// CopySource is "/"; parse it back apart to look the source object up + /// (both source and destination live in the same S3MemStrore in these tests). + const std::string & copy_source = request.GetCopySource(); + const size_t sep = copy_source.find('/'); + chassert(sep != std::string::npos); + const std::string src_bucket_name = copy_source.substr(0, sep); + const std::string src_key = copy_source.substr(sep + 1); + + auto & src_store = store->GetBucketStore(src_bucket_name); + const std::string data = src_store.objects.at(src_key); + + auto & dst_store = store->GetBucketStore(request.GetBucket()); + dst_store.PutObject(request.GetKey(), data); + + Aws::S3::Model::CopyObjectResult result; + Aws::S3::Model::CopyObjectResultDetails details; + details.SetETag("etag-copy-" + request.GetKey()); + result.SetCopyObjectResultDetails(details); + return Aws::S3::Model::CopyObjectOutcome(result); + } + + Aws::S3::Model::DeleteObjectOutcome DeleteObject(const Aws::S3::Model::DeleteObjectRequest & request) const override + { + ++counters.deleteObject; + + if (injections) + { + if (auto opt_val = injections->call(request)) + return std::move(*opt_val); + } + + auto & bStore = store->GetBucketStore(request.GetBucket()); + bStore.objects.erase(request.GetKey()); + + Aws::S3::Model::DeleteObjectResult result; + return Aws::S3::Model::DeleteObjectOutcome(result); + } + + Aws::S3::Model::GetBucketVersioningOutcome GetBucketVersioning(const Aws::S3::Model::GetBucketVersioningRequest & request) const override + { + ++counters.getBucketVersioning; + + if (injections) + { + if (auto opt_val = injections->call(request)) + return std::move(*opt_val); + } + + Aws::S3::Model::GetBucketVersioningResult result; + result.SetStatus(Aws::S3::Model::BucketVersioningStatus::Enabled); + return Aws::S3::Model::GetBucketVersioningOutcome(result); + } + std::shared_ptr store; mutable EventCounts counters; mutable std::shared_ptr injections; @@ -460,6 +538,39 @@ struct UploadPartFailIngection: InjectionModel } }; +/// Injects an arbitrary AWSError on DeleteObject -- used to drive the conditional-remove +/// (`removeObjectIfTokenMatches`) outcome mapping: a 412-shaped error (exception name "PreconditionFailed", +/// matched by `S3::isPreconditionFailedError`) must map to `ConditionalRemoveOutcome::TokenMismatch`, and a +/// 404-shaped error (a `NO_SUCH_KEY`/`RESOURCE_NOT_FOUND`/`NO_SUCH_BUCKET` error type, matched by +/// `S3::isNotFoundError`) must map to `ConditionalRemoveOutcome::NotFound`. +struct DeleteObjectErrorInjection: InjectionModel +{ + explicit DeleteObjectErrorInjection(Aws::Client::AWSError error_) : error(std::move(error_)) {} + + std::optional call(const Aws::S3::Model::DeleteObjectRequest & /*request*/) override + { + return error; + } + + Aws::Client::AWSError error; +}; + +/// Injects an arbitrary AWSError on CopyObject -- used to drive `copyObjectConditional` / +/// `copyS3File`'s `If-None-Match` handling: a "PreconditionFailed" error is the expected "lost the +/// race" signal, while an "AccessDenied" error must propagate as a genuine failure rather than being +/// swallowed into the unconditional-copy fallback (see `copyS3File.cpp`'s `processCopyRequest`). +struct CopyObjectErrorInjection: InjectionModel +{ + explicit CopyObjectErrorInjection(Aws::Client::AWSError error_) : error(std::move(error_)) {} + + std::optional call(const Aws::S3::Model::CopyObjectRequest & /*request*/) override + { + return error; + } + + Aws::Client::AWSError error; +}; + struct BaseSyncPolicy { virtual ~BaseSyncPolicy() = default; @@ -952,6 +1063,33 @@ TEST_F(WBS3Test, PrefinalizeCalledMultipleTimes) { #endif } +// The object ETag from the PutObject / CompleteMultipartUpload response is surfaced via +// getResultObjectETag() after a successful finalize() — lets content-addressed callers record the +// just-written incarnation's token WITHOUT a follow-up HEAD (CA head-after-put elimination). +TEST_F(WBS3Test, ResultObjectETagIsCaptured) { + // Singlepart upload: the PutObject response ETag. + { + auto buffer = getWriteBuffer("singlepart-file"); + writeAsOneBlock(*buffer, 10); + getAsyncPolicy().setAutoExecute(true); + buffer->finalize(); + ASSERT_TRUE(buffer->getResultObjectETag().has_value()); + ASSERT_EQ(*buffer->getResultObjectETag(), "etag-singlepart-singlepart-file"); + } + + // Multipart upload: the final object ETag comes from CompleteMultipartUpload, NOT a per-part tag. + { + getSettings()[Setting::s3_max_single_part_upload_size] = 0; // no single part — force multipart + getSettings()[Setting::s3_min_upload_part_size] = 1; + auto buffer = getWriteBuffer("multipart-file"); + writeAsOneBlock(*buffer, 10); + getAsyncPolicy().setAutoExecute(true); + buffer->finalize(); + ASSERT_TRUE(buffer->getResultObjectETag().has_value()); + ASSERT_EQ(*buffer->getResultObjectETag(), "etag-multipart-multipart-file"); + } +} + TEST_P(SyncAsync, EmptyFile) { getSettings()[Setting::s3_check_objects_after_upload] = true; @@ -1186,6 +1324,188 @@ TEST_P(SyncAsync, StrictUploadPartSize) { } } +/// Mock-S3 coverage for the content-addressed conditional-write primitives: `removeObjectIfTokenMatches` +/// (`If-Match` `DeleteObject`) and `copyObjectConditional` (`If-None-Match: *` `CopyObject`), plus the +/// fallback-disable guarantee in `copyS3File` when a conditional copy is requested. +class S3ObjectStorageConditionalOpsTest : public ::testing::Test +{ +public: + const String bucket = "cond-ops-bucket"; + const String disk_name = "cond-ops-disk"; + + std::shared_ptr object_storage; + MockS3::Client * mock_client = nullptr; + std::shared_ptr store; + +protected: + void SetUp() override + { + /// removeObjectIfTokenMatches()/copyObjectConditional() unconditionally call + /// BlobStorageLogWriter::create(), which falls back to Context::getGlobalContextInstance() + /// when there is no query context. Force that global context to exist (harmless -- blob + /// storage logging stays off by default) regardless of which other gtest TU ran first. + (void)getContext(); + + store = std::make_shared(); + store->CreateBucket(bucket); + + auto owned_client = std::make_unique(store); + mock_client = owned_client.get(); + + S3::URI uri; + uri.bucket = bucket; + S3Capabilities capabilities; + ObjectStorageKeyGeneratorPtr key_generator; + + object_storage = std::make_shared( + std::move(owned_client), std::make_unique(), std::move(uri), capabilities, key_generator, disk_name); + } + + void TearDown() override + { + object_storage.reset(); + mock_client = nullptr; + store.reset(); + } +}; + +TEST_F(S3ObjectStorageConditionalOpsTest, RemoveObjectIfTokenMatchesSuccess) +{ + store->GetBucketStore(bucket).PutObject("key1", "data"); + + auto result = object_storage->removeObjectIfTokenMatches(StoredObject("key1"), "etag-1"); + + ASSERT_EQ(result.outcome, ConditionalRemoveOutcome::Removed); + ASSERT_EQ(mock_client->counters.deleteObject, 1); +} + +TEST_F(S3ObjectStorageConditionalOpsTest, RemoveObjectIfTokenMatchesPreconditionFailedIsTokenMismatch) +{ + mock_client->setInjectionModel(std::make_shared( + Aws::Client::AWSError(Aws::S3::S3Errors::UNKNOWN, "PreconditionFailed", "precondition failed", false))); + + auto result = object_storage->removeObjectIfTokenMatches(StoredObject("key1"), "stale-etag"); + + ASSERT_EQ(result.outcome, ConditionalRemoveOutcome::TokenMismatch); +} + +TEST_F(S3ObjectStorageConditionalOpsTest, RemoveObjectIfTokenMatchesNotFoundIsNotFound) +{ + mock_client->setInjectionModel(std::make_shared( + Aws::Client::AWSError(Aws::S3::S3Errors::NO_SUCH_KEY, "NoSuchKey", "not found", false))); + + auto result = object_storage->removeObjectIfTokenMatches(StoredObject("missing-key"), "any-etag"); + + ASSERT_EQ(result.outcome, ConditionalRemoveOutcome::NotFound); +} + +TEST_F(S3ObjectStorageConditionalOpsTest, CopyObjectConditionalSuccess) +{ + store->GetBucketStore(bucket).PutObject("src-key", "hello-world"); + + auto result = object_storage->copyObjectConditional( + StoredObject("src-key"), StoredObject("dst-key"), ReadSettings{}, WriteSettings{}, std::nullopt); + + ASSERT_TRUE(result.created); + ASSERT_FALSE(result.dest_etag.empty()); + ASSERT_EQ(store->GetBucketStore(bucket).objects.at("dst-key"), "hello-world"); +} + +TEST_F(S3ObjectStorageConditionalOpsTest, CopyObjectConditionalPreconditionFailedIsNotCreated) +{ + store->GetBucketStore(bucket).PutObject("src-key", "hello-world"); + + mock_client->setInjectionModel(std::make_shared( + Aws::Client::AWSError(Aws::S3::S3Errors::UNKNOWN, "PreconditionFailed", "precondition failed", false))); + + auto result = object_storage->copyObjectConditional( + StoredObject("src-key"), StoredObject("dst-key"), ReadSettings{}, WriteSettings{}, std::nullopt); + + /// "Lost the race": not an error, just created == false and no destination etag. + ASSERT_FALSE(result.created); + ASSERT_TRUE(result.dest_etag.empty()); +} + +/// B166-adjacent: `copyS3File`'s `If-None-Match` conditional copy must not silently fall back to an +/// unconditional read-write copy on an `AccessDenied` `CopyObject` response -- that would defeat the +/// write-once guarantee the CA promote path relies on. The exception must propagate, and the fallback +/// (an unconditional `PutObject` upload of the source data) must never run. +TEST_F(WBS3Test, CopyS3FileConditionalAccessDeniedPropagatesWithoutFallback) +{ + client->store->GetBucketStore(bucket).PutObject("src-key", "hello"); + + setInjectionModel(std::make_shared( + Aws::Client::AWSError(Aws::S3::S3Errors::ACCESS_DENIED, "AccessDenied", "access denied", false))); + + client->resetCounters(); + + S3::S3RequestSettings request_settings; + ReadSettings read_settings; + bool fallback_called = false; + auto fallback_reader = [&]() -> std::unique_ptr + { + fallback_called = true; + return nullptr; + }; + + EXPECT_THROW({ + try + { + String dest_etag; + copyS3File(client, bucket, "src-key", 0, 5, client, bucket, "dst-key", + request_settings, read_settings, nullptr, getAsyncPolicy().getScheduler(), + fallback_reader, std::nullopt, String("*"), &dest_etag); + } + catch (const DB::S3Exception & e) + { + EXPECT_FALSE(e.isPreconditionFailed()); + EXPECT_EQ(e.getExceptionName(), "AccessDenied"); + throw; + } + }, DB::S3Exception); + + EXPECT_FALSE(fallback_called); + EXPECT_EQ(client->counters.copyObject, 1); + EXPECT_EQ(client->counters.putObject, 0); +} + +/// The other half of the same guard: a losing conditional copy (412) is a distinct, recognizable +/// outcome (`S3Exception::isPreconditionFailed()`), still without ever running the fallback. +TEST_F(WBS3Test, CopyS3FileConditionalPreconditionFailedSurfacesAsException) +{ + client->store->GetBucketStore(bucket).PutObject("src-key", "world"); + + setInjectionModel(std::make_shared( + Aws::Client::AWSError(Aws::S3::S3Errors::UNKNOWN, "PreconditionFailed", "precondition failed", false))); + + client->resetCounters(); + + S3::S3RequestSettings request_settings; + ReadSettings read_settings; + auto fallback_reader = []() -> std::unique_ptr + { + ADD_FAILURE() << "fallback must not run on a losing conditional copy"; + return nullptr; + }; + + EXPECT_THROW({ + try + { + String dest_etag; + copyS3File(client, bucket, "src-key", 0, 5, client, bucket, "dst-key", + request_settings, read_settings, nullptr, getAsyncPolicy().getScheduler(), + fallback_reader, std::nullopt, String("*"), &dest_etag); + } + catch (const DB::S3Exception & e) + { + EXPECT_TRUE(e.isPreconditionFailed()); + throw; + } + }, DB::S3Exception); + + EXPECT_EQ(client->counters.putObject, 0); +} + [[maybe_unused]] static String fillStringWithPattern(String pattern, int n) { String data; diff --git a/src/Interpreters/ContentAddressedGarbageCollectionLog.cpp b/src/Interpreters/ContentAddressedGarbageCollectionLog.cpp new file mode 100644 index 000000000000..fde269c4c999 --- /dev/null +++ b/src/Interpreters/ContentAddressedGarbageCollectionLog.cpp @@ -0,0 +1,114 @@ +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace DB +{ + +ColumnsDescription ContentAddressedGarbageCollectionLogElement::getColumnsDescription() +{ + auto type_enum = std::make_shared(DataTypeEnum8::Values{ + {"Start", static_cast(START)}, {"Finish", static_cast(FINISH)}, + {"Phase", static_cast(PHASE)}}); + auto outcome_enum = std::make_shared(DataTypeEnum8::Values{ + {"Unknown", static_cast(UNKNOWN)}, {"Success", static_cast(SUCCESS)}, + {"NotALeader", static_cast(NOT_A_LEADER)}, {"Error", static_cast(FAILED)}, + {"Deferred", static_cast(DEFERRED)}}); + auto trigger_enum = std::make_shared(DataTypeEnum8::Values{ + {"Scheduled", static_cast(SCHEDULED)}, {"Manual", static_cast(MANUAL)}}); + auto lc_string = std::make_shared(std::make_shared()); + + return ColumnsDescription + { + {"hostname", lc_string, "Host name of the server executing the round."}, + {"event_date", std::make_shared(), "Event date."}, + {"event_time", std::make_shared(), "Event time."}, + {"event_time_microseconds", std::make_shared(6), "Event time with microseconds."}, + {"event_type", type_enum, "Start or Finish of a GC round, or one Phase of it."}, + {"disk_name", lc_string, "Content-addressed disk the round ran on."}, + {"server_root_id", lc_string, "Identifies the mount whose GC scheduler ran this round. Distinguishes concurrent mounters of the same shared pool; join on this column when correlating rounds against `system.cas_mounts`."}, + {"gc_id", std::make_shared(), "GC scheduler instance id (which mounter)."}, + {"trigger", trigger_enum, "Scheduled (background tick) or Manual (SYSTEM command)."}, + {"round", std::make_shared(), "GC round number (0 on Start)."}, + {"outcome", outcome_enum, "Unknown (Start) / Success (led, folded, and completed) / NotALeader (another replica holds the GC lease) / Deferred (led but took the skip-unchanged fast path -- no fold ran) / Error (the round threw)."}, + {"candidates_marked", std::make_shared(), "Objects retired (marked) this round."}, + {"objects_deleted", std::make_shared(), "Objects physically deleted this round."}, + {"objects_absent", std::make_shared(), "Retire candidates found already absent."}, + {"objects_replaced", std::make_shared(), "412-saves (a resurrection won the race)."}, + {"objects_spared", std::make_shared(), "Candidates spared (in-degree > 0 at recheck)."}, + {"manifests_deleted", std::make_shared(), "Owner-removed manifest bodies physically deleted this round (counted separately from blob deletes, B11)."}, + {"entries_condemned", std::make_shared(), "Retired entries newly condemned this round (retired-cursor pipeline stage 1)."}, + {"entries_graduated", std::make_shared(), "Retired entries newly floor-passed and republished delete_pending this round (stage 2; deleted the NEXT round)."}, + {"entries_redeleted", std::make_shared(), "Pending exact-token blob deletes executed this round (stage 3)."}, + {"fence_outs", std::make_shared(), "Expired mounts fenced out by this round's heartbeat floor."}, + {"anomalies", std::make_shared(), "Fold clamps surfaced (and survived) this round; steady >0 warrants a look at the round log details."}, + {"duration_ms", std::make_shared(), "Round wall-clock duration (Finish)."}, + {"error", std::make_shared(), "Exception text when outcome = Error."}, + {"ProfileEvents", std::make_shared(lc_string, std::make_shared()), + "On a Start/Finish row: the per-round ProfileEvents delta (the Cas* counters and S3 events for this round). On a Phase row: THAT PHASE's delta, so `GROUP BY phase` over `ProfileEvents['S3ListObjects']` attributes the round's LIST budget to the phase that spent it. Empty on the `meta_pool_wait` row by construction — that phase's work runs on other threads (read its `phase_metrics` instead)."}, + {"round_id", std::make_shared(), + "Correlator for every row of one round attempt (its Start, each Phase, and its Finish). Minted per attempt; unlike `round` it exists even for a round that never committed and for a round that never led. Group by this column to reconstruct one round."}, + {"phase", lc_string, + "The GC phase this row describes (empty on Start/Finish), in execution order: lease, pre_fold_ref_drain, heartbeat_floor, defer_decision, parent_seal_read, fold_ref_group, fold_seal_read, fold_ref_intake, fold_reduce, fold_seal_write, pending_deletes, meta_pool_wait, round_commit, handoff_reclaim, manifest_deletes, namespace_cleanup, ref_object_cleanup, orphan_sweep. A round that defers, or that never acquires the lease, emits only the phases it reached."}, + {"phase_duration_microseconds", std::make_shared(), + "Wall-clock duration of this phase in microseconds (Phase rows only). Microseconds because several phases are routinely sub-millisecond and the point is to see when they are not. Phase durations do not sum to the round's `duration_ms`: the round also does untimed bookkeeping between phases."}, + {"phase_metrics", std::make_shared(lc_string, std::make_shared()), + "Phase-specific semantic counts a phase computes for itself and no ProfileEvent can supply (Phase rows only) — for example `changed_shards` on defer_decision, `logs_accounted`/`logs_applied` on fold_ref_intake, `transactions_unapplied` on fold_reduce, `jobs_scheduled`/`jobs_completed` on meta_pool_wait. The verb counts ride the `ProfileEvents` column of the same row."}, + }; +} + +void ContentAddressedGarbageCollectionLogElement::appendToBlock(MutableColumns & columns) const +{ + size_t i = 0; + columns[i++]->insert(getFQDNOrHostName()); + columns[i++]->insert(DateLUT::instance().toDayNum(event_time).toUnderType()); + columns[i++]->insert(event_time); + columns[i++]->insert(event_time_microseconds); + columns[i++]->insert(static_cast(event_type)); + columns[i++]->insert(disk_name); + columns[i++]->insert(srid); + columns[i++]->insert(gc_id); + columns[i++]->insert(static_cast(trigger)); + columns[i++]->insert(round); + columns[i++]->insert(static_cast(outcome)); + columns[i++]->insert(candidates_marked); + columns[i++]->insert(objects_deleted); + columns[i++]->insert(objects_absent); + columns[i++]->insert(objects_replaced); + columns[i++]->insert(objects_spared); + columns[i++]->insert(manifests_deleted); + columns[i++]->insert(entries_condemned); + columns[i++]->insert(entries_graduated); + columns[i++]->insert(entries_redeleted); + columns[i++]->insert(fence_outs); + columns[i++]->insert(anomalies); + columns[i++]->insert(duration_ms); + columns[i++]->insert(error); + { + Map map; + map.reserve(profile_events.size()); + for (const auto & [k, v] : profile_events) + map.push_back(Tuple{k, v}); + columns[i++]->insert(map); + } + columns[i++]->insert(round_id); + columns[i++]->insert(phase); + columns[i++]->insert(phase_duration_microseconds); + { + Map map; + map.reserve(phase_metrics.size()); + for (const auto & [k, v] : phase_metrics) + map.push_back(Tuple{k, v}); + columns[i++]->insert(map); + } +} + +} diff --git a/src/Interpreters/ContentAddressedGarbageCollectionLog.h b/src/Interpreters/ContentAddressedGarbageCollectionLog.h new file mode 100644 index 000000000000..9cbdbd3525f6 --- /dev/null +++ b/src/Interpreters/ContentAddressedGarbageCollectionLog.h @@ -0,0 +1,63 @@ +#pragma once +#include +#include +#include +#include + +namespace DB +{ + +struct ContentAddressedGarbageCollectionLogElement +{ + /// `PHASE`: one row per GC phase, emitted between the round's `START` and `FINISH` and correlated + /// with them by `round_id`. + enum EventType : int8_t { START = 1, FINISH = 2, PHASE = 3 }; + /// `DEFERRED`: the round acquired the GC lease and took the skip-unchanged fast path -- no fold, no + /// pre-CAS deletes, no `gc/state` CAS. Kept distinct from `SUCCESS` so a query against this table can + /// tell a round that genuinely folded and found nothing apart from one that never folded at all. + enum Outcome : int8_t { UNKNOWN = 1, SUCCESS = 2, NOT_A_LEADER = 3, FAILED = 4, DEFERRED = 5 }; + enum Trigger : int8_t { SCHEDULED = 1, MANUAL = 2 }; + + time_t event_time = 0; + Decimal64 event_time_microseconds = 0; + + EventType event_type = START; + String disk_name; + String srid; /// server_root_id of the mount whose GC scheduler ran this round + String gc_id; + Trigger trigger = SCHEDULED; + + UInt64 round = 0; + Outcome outcome = UNKNOWN; /// UNKNOWN on START; set to SUCCESS/NOT_A_LEADER/FAILED on FINISH + UInt64 candidates_marked = 0; + UInt64 objects_deleted = 0; + UInt64 objects_absent = 0; + UInt64 objects_replaced = 0; + UInt64 objects_spared = 0; + UInt64 manifests_deleted = 0; /// owner-removed manifest bodies deleted (B11 — distinct from blob deletes) + UInt64 entries_condemned = 0; /// retired-cursor pipeline: entries newly condemned this round + UInt64 entries_graduated = 0; /// retired-cursor pipeline: entries newly round-passed (delete_pending) this round + UInt64 entries_redeleted = 0; /// retired-cursor pipeline: pending exact-token blob deletes executed this round + UInt64 fence_outs = 0; /// expired mounts fenced-out by the round's heartbeat floor + UInt64 anomalies = 0; /// fold clamps surfaced this round + UInt64 duration_ms = 0; + String error; + std::map profile_events; /// per-round delta (FINISH); per-phase delta (PHASE) + + String round_id; /// correlator for every row of one round attempt + String phase; /// empty on START/FINISH + UInt64 phase_duration_microseconds = 0; /// PHASE rows only + std::map phase_metrics; /// PHASE rows only + + static std::string name() { return "ContentAddressedGarbageCollectionLog"; } + static ColumnsDescription getColumnsDescription(); + static NamesAndAliases getNamesAndAliases() { return {}; } + void appendToBlock(MutableColumns & columns) const; +}; + +class ContentAddressedGarbageCollectionLog : public SystemLog +{ + using SystemLog::SystemLog; +}; + +} diff --git a/src/Interpreters/ContentAddressedLog.cpp b/src/Interpreters/ContentAddressedLog.cpp new file mode 100644 index 000000000000..9ae6506ea326 --- /dev/null +++ b/src/Interpreters/ContentAddressedLog.cpp @@ -0,0 +1,73 @@ +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace DB +{ + +ColumnsDescription ContentAddressedLogElement::getColumnsDescription() +{ + auto lc_string = std::make_shared(std::make_shared()); + return ColumnsDescription + { + {"hostname", lc_string, "Host name of the server that emitted the event."}, + {"event_date", std::make_shared(), "Event date."}, + {"event_time", std::make_shared(), "Event time."}, + {"event_time_microseconds", std::make_shared(6), "Event time with microseconds."}, + {"event_type", lc_string, "The CA decision/event (blob_put, blob_reuse_adopt, root_remove, indegree_zero, gc_retire_decision, gc_recheck_verdict, blob_delete, dangling_access, corrupt_dangle, ...)."}, + {"disk_name", lc_string, "Content-addressed disk / pool the event belongs to."}, + {"namespace", std::make_shared(), "roots/ (server/table), empty if N/A."}, + {"ref_name", std::make_shared(), "Part name / ref the event concerns, empty if N/A."}, + {"object_kind", lc_string, "none/blob/manifest/root/snapshot."}, + {"object_hash", std::make_shared(), "Content hash (lowercase hex) of the object, empty if N/A."}, + {"token", std::make_shared(), "Incarnation token (ETag) involved, empty if N/A."}, + {"round", std::make_shared(), "GC round (0 if N/A)."}, + {"generation", std::make_shared(), "GC snapshot generation (0 if N/A)."}, + {"at_version", std::make_shared(), "Manifest shard_version of the driving journal record (0 if N/A)."}, + {"outcome", lc_string, "Decision outcome (ok/adopt/resurrect/deleted/replaced/spared/absent/zeroed/skipped/...)."}, + {"reason", lc_string, "Human-readable WHY of the decision (the rationale) -- templated across rows, so LowCardinality."}, + {"thread_id", std::make_shared(), "OS thread that emitted the event."}, + {"query_id", std::make_shared(), "Query id for correlation with system.query_log (empty if N/A)."}, + {"detail", std::make_shared(lc_string, std::make_shared()), + "Structured event-specific facts (e.g. condemn_round, superseded_token, code, site)."}, + }; +} + +void ContentAddressedLogElement::appendToBlock(MutableColumns & columns) const +{ + size_t i = 0; + columns[i++]->insert(getFQDNOrHostName()); + columns[i++]->insert(DateLUT::instance().toDayNum(event_time).toUnderType()); + columns[i++]->insert(event_time); + columns[i++]->insert(event_time_microseconds); + columns[i++]->insert(event_type); + columns[i++]->insert(disk_name); + columns[i++]->insert(namespace_); + columns[i++]->insert(ref_name); + columns[i++]->insert(object_kind); + columns[i++]->insert(object_hash); + columns[i++]->insert(token); + columns[i++]->insert(round); + columns[i++]->insert(gen); + columns[i++]->insert(at_version); + columns[i++]->insert(outcome); + columns[i++]->insert(reason); + columns[i++]->insert(thread_id); + columns[i++]->insert(query_id); + { + Map map; + map.reserve(detail.size()); + for (const auto & [k, v] : detail) + map.push_back(Tuple{k, v}); + columns[i++]->insert(map); + } +} + +} diff --git a/src/Interpreters/ContentAddressedLog.h b/src/Interpreters/ContentAddressedLog.h new file mode 100644 index 000000000000..84c7ae3c251c --- /dev/null +++ b/src/Interpreters/ContentAddressedLog.h @@ -0,0 +1,47 @@ +#pragma once +#include +#include +#include +#include +#include + +namespace DB +{ + +/// One row per content-addressed (CA) decision/event (B170). The decoupled Core POD `Cas::CasEvent` +/// is mapped to this element by `ContentAddressedMetadataStorage::makeCasEventSink` and forwarded to +/// the SystemLog. Optional (off by default); enabled for soak/CI. The set is exhaustive enough to +/// reconstruct an entity's whole lifetime; `reason`/`detail` carry each decision's rationale. +struct ContentAddressedLogElement +{ + time_t event_time = 0; + Decimal64 event_time_microseconds = 0; + + String event_type; /// Cas::CasEventType name (snake_case), LowCardinality in the table + String disk_name; + String namespace_; + String ref_name; + String object_kind; /// none/blob/manifest/root/snap + String object_hash; + String token; + UInt64 round = 0; + UInt64 gen = 0; + UInt64 at_version = 0; + String outcome; + String reason; + UInt64 thread_id = 0; + String query_id; + std::map detail; + + static std::string name() { return "ContentAddressedLog"; } + static ColumnsDescription getColumnsDescription(); + static NamesAndAliases getNamesAndAliases() { return {}; } + void appendToBlock(MutableColumns & columns) const; +}; + +class ContentAddressedLog : public SystemLog +{ + using SystemLog::SystemLog; +}; + +} diff --git a/src/Interpreters/Context.cpp b/src/Interpreters/Context.cpp index 1edce5d06b45..a55da287815b 100644 --- a/src/Interpreters/Context.cpp +++ b/src/Interpreters/Context.cpp @@ -6238,6 +6238,22 @@ std::shared_ptr Context::getPartLog() const return shared->system_logs->part_log; } +std::shared_ptr Context::getContentAddressedGarbageCollectionLog() const +{ + SharedLockGuard lock(shared->mutex); + if (!shared->system_logs) + return {}; + return shared->system_logs->cas_gc_log; +} + +std::shared_ptr Context::getContentAddressedLog() const +{ + SharedLockGuard lock(shared->mutex); + if (!shared->system_logs) + return {}; + return shared->system_logs->cas_log; +} + std::shared_ptr Context::getBackgroundSchedulePoolLog() const { SharedLockGuard lock(shared->mutex); diff --git a/src/Interpreters/Context.h b/src/Interpreters/Context.h index e939d24e0e86..2ee2713caa70 100644 --- a/src/Interpreters/Context.h +++ b/src/Interpreters/Context.h @@ -128,6 +128,8 @@ class QueryMetricLog; class QueryThreadLog; class QueryViewsLog; class PartLog; +class ContentAddressedGarbageCollectionLog; +class ContentAddressedLog; class BackgroundSchedulePoolLog; class TextLog; class TraceLog; @@ -1619,6 +1621,8 @@ class Context: public ContextData, public std::enable_shared_from_this /// Returns an object used to log operations with parts if it possible. /// Provide table name to make required checks. std::shared_ptr getPartLog() const; + std::shared_ptr getContentAddressedGarbageCollectionLog() const; + std::shared_ptr getContentAddressedLog() const; std::shared_ptr getBackgroundSchedulePoolLog() const; diff --git a/src/Interpreters/InterpreterSystemQuery.cpp b/src/Interpreters/InterpreterSystemQuery.cpp index 8220a1a82db6..9ad1bc8dc8c8 100644 --- a/src/Interpreters/InterpreterSystemQuery.cpp +++ b/src/Interpreters/InterpreterSystemQuery.cpp @@ -21,6 +21,14 @@ #include #include #include +#include +#include +/// Direct, though `ContentAddressedMetadataStorage.h` above would also pull it in: this TU renders +/// `FsckReport`'s hard findings into the SQL row, and `CasFsck.h`'s `kFsckHardFindings` tripwire is what +/// breaks THIS build when a finding is added. Depending on another header's include list for that would +/// make the coverage silently removable. +#include +#include #include #include #include @@ -71,6 +79,7 @@ #include #include #include +#include #include #include #include @@ -94,6 +103,7 @@ #include #include #include +#include #include "config.h" @@ -264,6 +274,18 @@ AccessType getRequiredAccessType(StorageActionBlockType action_type) constexpr std::string_view table_is_not_replicated = "Table {} is not replicated"; +/// A table in a database created with `lazy_load_tables = 1` stays wrapped in a `StorageTableProxy` +/// until its first access, so a `dynamic_cast` to the real engine (e.g. `StorageReplicatedMergeTree`) +/// fails and a `SYSTEM` verb that targets one specific named table misreports it as not replicated. +/// Materialize the proxy before such a cast; generic query paths already materialize on read by +/// design and must not go through this helper. +StoragePtr unwrapTableProxy(const StoragePtr & storage) +{ + if (const auto * proxy = dynamic_cast(storage.get())) + return proxy->getNested(); + return storage; +} + } /// Implements SYSTEM [START|STOP] @@ -996,6 +1018,100 @@ BlockIO InterpreterSystemQuery::execute() break; } + case Type::CAS_GC_RUN: + { + /// A manual GC RUN executes REGARDLESS of SYSTEM CAS GC STOP: STOP pauses only the + /// background PACER, not the GC engine, so an explicit operator round still runs (explicit intent + /// wins). A round that acquires the lease sets the disk's in-process is_leader=true, which its + /// introspection can surface transiently even while the background scheduler stays stopped — + /// until a peer mounter steals the lease or GC START resumes pacing. This is truthful (the round + /// DID lead) and harmless (no background thread acts on it while stopped). + getContext()->checkAccess(AccessType::SYSTEM_CAS_GC_RUN); + result = runContentAddressedGcRun(query.disk); + break; + } + case Type::CAS_GC_REBUILD: + { + getContext()->checkAccess(AccessType::SYSTEM_CAS_GC_REBUILD); + result = runContentAddressedGcRebuild(query.disk, query.cas_gc_rebuild_force); + break; + } + case Type::CAS_FSCK: + { + getContext()->checkAccess(AccessType::SYSTEM_CAS_FSCK); + result = runContentAddressedFsck(query.disk); + break; + } + case Type::CAS_FORGET: + { + getContext()->checkAccess(AccessType::SYSTEM_CAS_FORGET); + contentAddressedForget(query.disk); + break; + } + case Type::CAS_GC_STOP: + { + getContext()->checkAccess(AccessType::SYSTEM_CAS_GC_STOP); + contentAddressedGcStop(query.disk); + break; + } + case Type::CAS_GC_START: + { + getContext()->checkAccess(AccessType::SYSTEM_CAS_GC_START); + contentAddressedGcStart(query.disk); + break; + } + case Type::CAS_DROP_POOL_MEMBER: + { + getContext()->checkAccess(AccessType::SYSTEM_CAS_DROP_POOL_MEMBER); + + auto disk = getContext()->getDisk(query.disk); + auto * ca = ContentAddressedMetadataStorage::tryFromDisk(disk); + if (!ca) + throw Exception(ErrorCodes::BAD_ARGUMENTS, + "SYSTEM CAS DROP POOL MEMBER: disk '{}' is not a content-addressed disk", query.disk); + ca->checkNotReadOnly("SYSTEM CAS DROP POOL MEMBER"); + + const auto & host_store = ca->store(); + const auto report = Cas::decommissionPoolMember( + host_store->poolBackendPtr(), host_store->poolConfig(), query.replica, {}, + [ca] { ca->requestGcRoundSoon(); }); + + /// One-row summary result set (precedent: SYNC_FILESYSTEM_CACHE's MutableColumns/ + /// SourceFromSingleChunk construction above). + ColumnsDescription columns{NamesAndTypesList{ + {"server_root_id", std::make_shared()}, + {"namespaces_removed", std::make_shared()}, + {"namespaces_already_removed", std::make_shared()}, + {"committed_refs_removed", std::make_shared()}, + {"precommits_removed", std::make_shared()}, + {"manifest_debris_removed", std::make_shared()}, + {"staging_objects_removed", std::make_shared()}, + {"mountpoint_objects_removed", std::make_shared()}, + {"slot_removed", std::make_shared()}, + {"warnings", std::make_shared()}, + }}; + Block sample_block; + for (const auto & column : columns) + sample_block.insert({column.type->createColumn(), column.type, column.name}); + + MutableColumns res_columns = sample_block.cloneEmptyColumns(); + size_t i = 0; + res_columns[i++]->insert(report.srid); + res_columns[i++]->insert(report.namespaces_removed); + res_columns[i++]->insert(report.namespaces_already_removed); + res_columns[i++]->insert(report.committed_refs_removed); + res_columns[i++]->insert(report.precommits_removed); + res_columns[i++]->insert(report.manifest_debris_removed); + res_columns[i++]->insert(report.staging_objects_removed); + res_columns[i++]->insert(report.mountpoint_objects_removed); + res_columns[i++]->insert(static_cast(report.slot_removed)); + res_columns[i++]->insert(fmt::format("{}", fmt::join(report.warnings, "; "))); + + size_t num_rows = res_columns[0]->size(); + auto source = std::make_shared(std::make_shared(std::move(sample_block)), Chunk(std::move(res_columns), num_rows)); + result.pipeline = QueryPipeline(std::move(source)); + break; + } case Type::RESTART_DISK: { restartDisk(query.disk); @@ -1259,7 +1375,7 @@ void InterpreterSystemQuery::restoreReplica() { getContext()->checkAccess(AccessType::SYSTEM_RESTORE_REPLICA, table_id); - const StoragePtr table_ptr = DatabaseCatalog::instance().getTable(table_id, getContext()); + const StoragePtr table_ptr = unwrapTableProxy(DatabaseCatalog::instance().getTable(table_id, getContext())); auto * const table_replicated_ptr = dynamic_cast(table_ptr.get()); @@ -1334,7 +1450,9 @@ StoragePtr InterpreterSystemQuery::doRestartReplica(const StorageID & replica, C return nullptr; } - if (!dynamic_cast(table.get())) + /// Only the type check needs the materialized (unwrapped) storage; the possibly-still-proxied + /// `table` is what actually stays registered in `database` and is what gets locked/detached below. + if (!dynamic_cast(unwrapTableProxy(table).get())) { if (throw_on_error) throw Exception(ErrorCodes::BAD_ARGUMENTS, table_is_not_replicated.data(), replica.getNameForLogs()); @@ -1543,7 +1661,7 @@ void InterpreterSystemQuery::dropReplica(ASTSystemQuery & query) if (!table_id.empty()) { getContext()->checkAccess(AccessType::SYSTEM_DROP_REPLICA, table_id); - StoragePtr table = DatabaseCatalog::instance().getTable(table_id, getContext()); + StoragePtr table = unwrapTableProxy(DatabaseCatalog::instance().getTable(table_id, getContext())); if (!dropStorageReplica(query.replica, table)) throw Exception(ErrorCodes::BAD_ARGUMENTS, table_is_not_replicated.data(), table_id.getNameForLogs()); @@ -2021,7 +2139,7 @@ bool InterpreterSystemQuery::trySyncReplica(StoragePtr table, SyncReplicaMode sy break; } - if (auto * storage_replicated = dynamic_cast(table.get())) + if (auto * storage_replicated = dynamic_cast(unwrapTableProxy(table).get())) { auto log = getLogger("InterpreterSystemQuery"); LOG_TRACE(log, "Synchronizing entries in replica's queue with table's log and waiting for current last entry to be processed"); @@ -2067,7 +2185,7 @@ void InterpreterSystemQuery::syncReplica(ASTSystemQuery & query) void InterpreterSystemQuery::waitLoadingParts() { getContext()->checkAccess(AccessType::SYSTEM_WAIT_LOADING_PARTS, table_id); - StoragePtr table = DatabaseCatalog::instance().getTable(table_id, getContext()); + StoragePtr table = unwrapTableProxy(DatabaseCatalog::instance().getTable(table_id, getContext())); if (auto * merge_tree = dynamic_cast(table.get())) { @@ -2139,11 +2257,12 @@ namespace MergeTreeData & getMergeTreeWithManualSelector(const StoragePtr & table, const StorageID & table_id, const char * action) { - auto * merge_tree = dynamic_cast(table.get()); + const StoragePtr unwrapped = unwrapTableProxy(table); + auto * merge_tree = dynamic_cast(unwrapped.get()); if (!merge_tree) throw Exception(ErrorCodes::BAD_ARGUMENTS, "Command {} is supported only for MergeTree-family tables, but got: {}", - action, table->getName()); + action, unwrapped->getName()); const auto algorithm = (*merge_tree->getSettings())[MergeTreeSetting::merge_selector_algorithm].value; if (algorithm != MergeSelectorAlgorithm::MANUAL) @@ -2207,6 +2326,363 @@ void InterpreterSystemQuery::syncMerges() throw DB::Exception(DB::ErrorCodes::TIMEOUT_EXCEEDED, "SYNC MERGES {}: command timed out. See the 'max_execution_time' setting", table_id.getNameForLogs()); } +namespace +{ + +/// One-row-per-disk result-set builders for the CAS GC verbs, mirroring the SYSTEM CAS +/// DROP POOL MEMBER precedent (ColumnsDescription + MutableColumns + SourceFromSingleChunk; see also +/// SYNC_FILESYSTEM_CACHE above). +ColumnsDescription contentAddressedGcRoundColumns() +{ + return ColumnsDescription{NamesAndTypesList{ + {"disk", std::make_shared()}, + {"acquired_lease", std::make_shared()}, + {"deferred", std::make_shared()}, + {"round", std::make_shared()}, + {"candidates_marked", std::make_shared()}, + {"objects_deleted", std::make_shared()}, + {"objects_absent", std::make_shared()}, + {"objects_replaced", std::make_shared()}, + {"objects_spared", std::make_shared()}, + {"manifests_deleted", std::make_shared()}, + {"entries_condemned", std::make_shared()}, + {"entries_graduated", std::make_shared()}, + {"entries_redeleted", std::make_shared()}, + {"fence_outs", std::make_shared()}, + {"anomalies", std::make_shared()}, + /// Task 7: the retire pipeline's REMAINING (not this-round-delta) sizes, read from the gc/state + /// this round's single CAS just published -- see `Cas::RoundReport`'s field comments. Zero on a + /// non-authoritative row (!acquired_lease or deferred), same as every other counter above. + {"pending_candidates", std::make_shared()}, + {"pending_condemned", std::make_shared()}, + {"pending_retired", std::make_shared()}, + }}; +} + +void appendContentAddressedGcRoundRow(MutableColumns & res_columns, const String & disk_name, const Cas::RoundReport & rep) +{ + size_t i = 0; + res_columns[i++]->insert(disk_name); + res_columns[i++]->insert(static_cast(rep.acquired_lease)); + res_columns[i++]->insert(static_cast(rep.deferred)); + res_columns[i++]->insert(rep.round); + res_columns[i++]->insert(rep.candidates); + res_columns[i++]->insert(rep.deleted); + res_columns[i++]->insert(rep.absent); + res_columns[i++]->insert(rep.replaced); + res_columns[i++]->insert(rep.spared); + res_columns[i++]->insert(rep.manifests_deleted); + res_columns[i++]->insert(rep.condemned); + res_columns[i++]->insert(rep.graduated); + res_columns[i++]->insert(rep.redeleted); + res_columns[i++]->insert(rep.fence_outs); + res_columns[i++]->insert(rep.anomalies.size()); + res_columns[i++]->insert(rep.pending_candidates); + res_columns[i++]->insert(rep.pending_condemned); + res_columns[i++]->insert(rep.pending_retired); +} + +ColumnsDescription contentAddressedGcRebuildColumns() +{ + return ColumnsDescription{NamesAndTypesList{ + {"disk", std::make_shared()}, + {"performed", std::make_shared()}, + {"round", std::make_shared()}, + {"generation", std::make_shared()}, + {"namespaces", std::make_shared()}, + {"shards", std::make_shared()}, + {"committed_refs", std::make_shared()}, + {"live_precommits", std::make_shared()}, + {"unowned_alive_manifests", std::make_shared()}, + {"edges", std::make_shared()}, + {"clamped_shards", std::make_shared()}, + /// 1 => the rebuild found no fold seal at all and carried NO durable hold forward, having + /// concluded from enumeration alone that the pool never sealed a baseline. On a pool that has + /// ever completed a GC round this means the object listing lied. + {"virgin_by_enumeration", std::make_shared()}, + /// Which generation's fold seal the rebuild carried holds from; 0 when it carried none. + {"adopted_seal_generation", std::make_shared()}, + }}; +} + +void appendContentAddressedGcRebuildRow(MutableColumns & res_columns, const String & disk_name, const Cas::RebuildReport & rep) +{ + size_t i = 0; + res_columns[i++]->insert(disk_name); + res_columns[i++]->insert(static_cast(rep.performed)); + res_columns[i++]->insert(rep.round); + res_columns[i++]->insert(rep.generation); + res_columns[i++]->insert(rep.namespaces); + res_columns[i++]->insert(rep.shards); + res_columns[i++]->insert(rep.committed_refs); + res_columns[i++]->insert(rep.live_precommits); + res_columns[i++]->insert(rep.unowned_alive_manifests); + res_columns[i++]->insert(rep.edges); + res_columns[i++]->insert(rep.clamped_shards); + res_columns[i++]->insert(static_cast(rep.virgin_by_enumeration)); + res_columns[i++]->insert(rep.adopted_seal_generation); +} + +/// SYSTEM CAS FSCK's one-row-per-disk summary. Named UInt64 columns only, no DETAIL +/// keyword (YAGNI -- the offline `clickhouse-disks cas-fsck --detail` applet already covers per-object +/// listing). Field order/names mirror `Cas::FsckReport`; the row was a deliberate SUBSET of it until +/// 2026-07-29, and is no longer one where findings are concerned -- see the rule stated at +/// `stale_edge` below. `CommandFsck.cpp`'s `formatFsckSummary` line carries the same fields. +ColumnsDescription contentAddressedFsckColumns() +{ + return ColumnsDescription{NamesAndTypesList{ + {"disk", std::make_shared()}, + {"reachable", std::make_shared()}, + {"dangling", std::make_shared()}, + {"unreachable", std::make_shared()}, + {"pending_gc", std::make_shared()}, + {"awaiting_gc", std::make_shared()}, + {"unaccounted", std::make_shared()}, + /// EVERY TERM OF `FsckReport::clean` APPEARS HERE. This row is the only view of a report a SQL + /// consumer ever gets, so a hard finding the row omits is a finding no query can see — the same + /// shape that hid `corrupted_runs` from the text summary for months. The row was a deliberate + /// subset until 2026-07-29 and `stale_edge`/`corrupted_runs` were invisible from SQL while + /// `clickhouse-disks cas-fsck` surfaced them; then + /// `lifeless_keys` was added to `clean` in 2026-07-30 and missed here too. Every time, the rule + /// was written in prose, and every time the prose did not hold. + /// + /// So it no longer lives only in prose: `kFsckHardFindings` (`CasFsck.h`) is the list `clean` is + /// computed from, and the `static_assert` beside it breaks the build in THIS translation unit when + /// a term is added. Read that assert's message before bumping its count -- it names this site as + /// one of the three that owes an update, and says that two of the three have no test that can fail + /// on their behalf. WHICH two is in the comment above the assert, not in the message; this site is + /// one of them. + /// + /// `stale_edge` is nonzero only in `detail` mode and this row is built from a summary scan, so it + /// reads 0 here always — present because "absent" and "zero" are different facts to a consumer, + /// and a column that appears the day the scan gains detail is a schema change nobody asked for. + {"stale_edge", std::make_shared()}, + {"corrupted_runs", std::make_shared()}, + /// The ref-stream verdicts (spec §7). `chain_broken` is a HARD finding — it belongs on the row + /// for the same reason `dangling` does. `unchecked` is its honest companion: namespaces the audit + /// could not prove either way, so a zero here is what makes the other zeros mean something. + {"chain_broken", std::make_shared()}, + {"unchecked", std::make_shared()}, + /// A malformed/non-canonical namespace-tree key, or an ambiguous/unreadable catalog + /// incarnation — a term of `clean`. + {"lifeless_keys", std::make_shared()}, + /// A COMPLETE, canonical namespace-life key whose life is absent from a catalog cut taken after + /// the listing: janitor-pending debris, NOT a term of `clean` (see `FsckClass::JanitorPending`). + {"namespace_janitor_pending", std::make_shared()}, + {"namespace_janitor_pending_bytes", std::make_shared()}, + {"namespace_janitor_pending_lives", std::make_shared()}, + {"ref_records_walked", std::make_shared()}, + {"physical_bytes", std::make_shared()}, + {"referenced_logical_bytes", std::make_shared()}, + {"distinct_blobs", std::make_shared()}, + {"total_blob_refs", std::make_shared()}, + }}; +} + +void appendContentAddressedFsckRow(MutableColumns & res_columns, const String & disk_name, const Cas::FsckReport & rep) +{ + size_t i = 0; + res_columns[i++]->insert(disk_name); + res_columns[i++]->insert(rep.reachable); + res_columns[i++]->insert(rep.dangling); + res_columns[i++]->insert(rep.unreachable); + res_columns[i++]->insert(rep.pending_gc); + res_columns[i++]->insert(rep.awaiting_gc); + res_columns[i++]->insert(rep.unaccounted); + res_columns[i++]->insert(rep.stale_edge); + res_columns[i++]->insert(rep.corrupted_runs); + res_columns[i++]->insert(rep.chain_broken); + res_columns[i++]->insert(rep.unchecked); + res_columns[i++]->insert(rep.lifeless_keys); + res_columns[i++]->insert(rep.namespace_janitor_pending); + res_columns[i++]->insert(rep.namespace_janitor_pending_bytes); + res_columns[i++]->insert(rep.namespace_janitor_pending_lives); + res_columns[i++]->insert(rep.ref_records_walked); + res_columns[i++]->insert(rep.physical_bytes); + res_columns[i++]->insert(rep.referenced_logical_bytes); + res_columns[i++]->insert(rep.distinct_blobs); + res_columns[i++]->insert(rep.total_blob_refs); +} + +} + +BlockIO InterpreterSystemQuery::runContentAddressedGcRun(const String & disk_name) +{ + ColumnsDescription columns = contentAddressedGcRoundColumns(); + Block sample_block; + for (const auto & column : columns) + sample_block.insert({column.type->createColumn(), column.type, column.name}); + MutableColumns res_columns = sample_block.cloneEmptyColumns(); + + if (!disk_name.empty()) + { + auto disk = getContext()->getDisk(disk_name); + auto * ca = ContentAddressedMetadataStorage::tryFromDisk(disk); + if (!ca) + throw Exception(ErrorCodes::BAD_ARGUMENTS, "Disk '{}' is not a content-addressed disk", disk_name); + appendContentAddressedGcRoundRow(res_columns, disk_name, ca->runGarbageCollectionRoundNow()); /// synchronous, one round + } + else + { + size_t ran = 0; + for (const auto & [name, disk] : getContext()->getDisksMap()) + { + if (auto * ca = ContentAddressedMetadataStorage::tryFromDisk(disk)) + { + appendContentAddressedGcRoundRow(res_columns, name, ca->runGarbageCollectionRoundNow()); /// synchronous, one round + ++ran; + } + } + if (ran == 0) + throw Exception(ErrorCodes::BAD_ARGUMENTS, "No content-addressed disks are configured on this node"); + } + + size_t num_rows = res_columns[0]->size(); + auto source = std::make_shared(std::make_shared(std::move(sample_block)), Chunk(std::move(res_columns), num_rows)); + BlockIO result; + result.pipeline = QueryPipeline(std::move(source)); + return result; +} + +BlockIO InterpreterSystemQuery::runContentAddressedGcRebuild(const String & disk_name, bool force) +{ + /// REBUILD requires an EXPLICIT disk (E1): the destructive baseline rebuild must never fan out + /// across every content-addressed disk on the node. The parser enforces this syntactically; this is + /// the fail-closed backstop for a directly-constructed AST. + if (disk_name.empty()) + throw Exception(ErrorCodes::BAD_ARGUMENTS, + "SYSTEM CAS GC REBUILD requires an explicit disk name"); + + auto disk = getContext()->getDisk(disk_name); + auto * ca = ContentAddressedMetadataStorage::tryFromDisk(disk); + if (!ca) + throw Exception(ErrorCodes::BAD_ARGUMENTS, "Disk '{}' is not a content-addressed disk", disk_name); + + Cas::RebuildReport rep = ca->runGcRebuildNow(force); /// synchronous, one rebuild + if (!rep.performed) + throw Exception(ErrorCodes::BAD_ARGUMENTS, "CAS GC rebuild refused: {}", rep.refusal); + LOG_INFO(log, + "CAS GC rebuild on disk '{}' completed: round={} generation={} namespaces={} shards={} " + "committed_refs={} live_precommits={} unowned_alive_manifests={} edges={} clamped_shards={} " + "virgin_by_enumeration={} adopted_seal_generation={}", + disk_name, rep.round, rep.generation, rep.namespaces, rep.shards, rep.committed_refs, + rep.live_precommits, rep.unowned_alive_manifests, rep.edges, rep.clamped_shards, + rep.virgin_by_enumeration, rep.adopted_seal_generation); + + ColumnsDescription columns = contentAddressedGcRebuildColumns(); + Block sample_block; + for (const auto & column : columns) + sample_block.insert({column.type->createColumn(), column.type, column.name}); + MutableColumns res_columns = sample_block.cloneEmptyColumns(); + appendContentAddressedGcRebuildRow(res_columns, disk_name, rep); + + size_t num_rows = res_columns[0]->size(); + auto source = std::make_shared(std::make_shared(std::move(sample_block)), Chunk(std::move(res_columns), num_rows)); + BlockIO result; + result.pipeline = QueryPipeline(std::move(source)); + return result; +} + +BlockIO InterpreterSystemQuery::runContentAddressedFsck(const String & disk_name) +{ + /// FSCK runs on a RUNNING disk (rev.8): the scan is read-only and revalidates every ref-walk finding + /// against a fresh authoritative read, so it needs no quiesce. The disk is REQUIRED, enforced by the + /// parser -- no fan-out form. + if (disk_name.empty()) + throw Exception(ErrorCodes::BAD_ARGUMENTS, "SYSTEM CAS FSCK requires an explicit disk name"); + + auto disk = getContext()->getDisk(disk_name); + auto * ca = ContentAddressedMetadataStorage::tryFromDisk(disk); + if (!ca) + throw Exception(ErrorCodes::BAD_ARGUMENTS, "Disk '{}' is not a content-addressed disk", disk_name); + + const Cas::FsckReport rep = ca->runFsckNow(/* detail= */ false); /// summary only (no DETAIL keyword yet) + + ColumnsDescription columns = contentAddressedFsckColumns(); + Block sample_block; + for (const auto & column : columns) + sample_block.insert({column.type->createColumn(), column.type, column.name}); + MutableColumns res_columns = sample_block.cloneEmptyColumns(); + appendContentAddressedFsckRow(res_columns, disk_name, rep); + + size_t num_rows = res_columns[0]->size(); + auto source = std::make_shared(std::make_shared(std::move(sample_block)), Chunk(std::move(res_columns), num_rows)); + BlockIO result; + result.pipeline = QueryPipeline(std::move(source)); + return result; +} + +void InterpreterSystemQuery::contentAddressedForget(const String & disk_name) +{ + /// The operator "fire-marshal" verb (spec §5): a force-Vanish that decommissions a content-addressed + /// disk NODE-LOCALLY. Unlike the store()-class verbs, FORGET must work on a disk that is NOT live -- + /// that is its whole purpose (a stuck transient/IdentityLost pool, an operator-asserted decommission) -- + /// so it does NOT go through `checkOpAdmitted`/`store()` (which refuse a not-live disk). It is a + /// lifecycle verb like the Factory class: it reaches the pool directly and drives it to + /// `Vanished(forgotten)`. FORGET is an operator ASSERTION, not an erasure proof; the resulting [D5] + /// error message says so. + if (disk_name.empty()) + throw Exception(ErrorCodes::BAD_ARGUMENTS, "SYSTEM CAS FORGET requires an explicit disk name"); + + auto disk = getContext()->getDisk(disk_name); /// UNKNOWN_DISK on a bad name + auto * ca = ContentAddressedMetadataStorage::tryFromDisk(disk); + if (!ca) + throw Exception(ErrorCodes::BAD_ARGUMENTS, "Disk '{}' is not a content-addressed disk", disk_name); + + ca->forgetDisk(); + LOG_WARNING(log, + "SYSTEM CAS FORGET decommissioned content-addressed disk '{}' (node-local; erasure " + "NOT verified). The disk stays registered and answers store-class access with a typed error; a " + "server restart re-registers the name.", + disk_name); +} + +void InterpreterSystemQuery::contentAddressedGcStop(const String & disk_name) +{ + /// SYSTEM CAS GC STOP (spec §6): stop ONLY the background GC scheduler on this disk. The + /// disk stays fully usable -- reads and writes are unaffected; this is granular operator control of GC + /// alone (e.g. to pause reclamation during an incident), not a lifecycle transition. STOP-IN-PLACE: the + /// scheduler object is retained so a later GC START restarts the SAME instance (its gc_id and lease + /// observation history preserved). Idempotent; works even on a not-live/Vanished disk (stopping GC on a + /// sick disk is legitimate). The disk is REQUIRED -- there is no fan-out form. + if (disk_name.empty()) + throw Exception(ErrorCodes::BAD_ARGUMENTS, "SYSTEM CAS GC STOP requires an explicit disk name"); + + auto disk = getContext()->getDisk(disk_name); /// UNKNOWN_DISK on a bad name + auto * ca = ContentAddressedMetadataStorage::tryFromDisk(disk); + if (!ca) + throw Exception(ErrorCodes::BAD_ARGUMENTS, "Disk '{}' is not a content-addressed disk", disk_name); + + ca->gcStop(); + LOG_INFO(log, + "SYSTEM CAS GC STOP: stopped the background garbage-collection scheduler on " + "content-addressed disk '{}' (the disk stays fully usable; SYSTEM CAS GC START " + "resumes it).", + disk_name); +} + +void InterpreterSystemQuery::contentAddressedGcStart(const String & disk_name) +{ + /// SYSTEM CAS GC START (spec §6): restart the background GC scheduler stopped by GC STOP. + /// It re-enters the SAME scheduler instance; leadership is NOT auto-restored -- the scheduler re-acquires + /// the durable `gc/state` lease through the next round's normal acquisition. Idempotent (a no-op on a + /// running scheduler). Refuses on a decommissioned/uncertain pool (typed error) -- restarting GC there + /// would only spin failing rounds. The disk is REQUIRED -- there is no fan-out form. + if (disk_name.empty()) + throw Exception(ErrorCodes::BAD_ARGUMENTS, "SYSTEM CAS GC START requires an explicit disk name"); + + auto disk = getContext()->getDisk(disk_name); /// UNKNOWN_DISK on a bad name + auto * ca = ContentAddressedMetadataStorage::tryFromDisk(disk); + if (!ca) + throw Exception(ErrorCodes::BAD_ARGUMENTS, "Disk '{}' is not a content-addressed disk", disk_name); + + ca->gcStart(); + LOG_INFO(log, + "SYSTEM CAS GC START: resumed the background garbage-collection scheduler on " + "content-addressed disk '{}'.", + disk_name); +} + void InterpreterSystemQuery::loadPrimaryKeys() { loadOrUnloadPrimaryKeysImpl(true); @@ -2222,7 +2698,7 @@ void InterpreterSystemQuery::loadOrUnloadPrimaryKeysImpl(bool load) if (!table_id.empty()) { getContext()->checkAccess(load ? AccessType::SYSTEM_LOAD_PRIMARY_KEY : AccessType::SYSTEM_UNLOAD_PRIMARY_KEY, table_id.database_name, table_id.table_name); - StoragePtr table = DatabaseCatalog::instance().getTable(table_id, getContext()); + StoragePtr table = unwrapTableProxy(DatabaseCatalog::instance().getTable(table_id, getContext())); if (auto * merge_tree = dynamic_cast(table.get())) { @@ -2368,7 +2844,7 @@ void InterpreterSystemQuery::flushDistributed(ASTSystemQuery & query) if (query.query_settings) settings_changes = query.query_settings->as()->changes; - if (auto * storage_distributed = dynamic_cast(DatabaseCatalog::instance().getTable(table_id, getContext()).get())) + if (auto * storage_distributed = dynamic_cast(unwrapTableProxy(DatabaseCatalog::instance().getTable(table_id, getContext())).get())) storage_distributed->flushClusterNodesAllData(getContext(), settings_changes); else throw Exception(ErrorCodes::BAD_ARGUMENTS, "Table {} is not distributed", table_id.getNameForLogs()); @@ -2382,7 +2858,7 @@ void InterpreterSystemQuery::flushObjectStorageQueue(ASTSystemQuery & query) if (query.queue_path.empty()) throw Exception(ErrorCodes::BAD_ARGUMENTS, "PATH must be specified for SYSTEM FLUSH OBJECT STORAGE QUEUE"); - auto table = DatabaseCatalog::instance().getTable(table_id, context); + auto table = unwrapTableProxy(DatabaseCatalog::instance().getTable(table_id, context)); auto * queue = dynamic_cast(table.get()); if (!queue) throw Exception(ErrorCodes::BAD_ARGUMENTS, @@ -2409,7 +2885,7 @@ void InterpreterSystemQuery::prewarmMarkCache() getContext()->checkAccess(AccessType::SYSTEM_PREWARM_MARK_CACHE, table_id); - auto table_ptr = DatabaseCatalog::instance().getTable(table_id, getContext()); + auto table_ptr = unwrapTableProxy(DatabaseCatalog::instance().getTable(table_id, getContext())); auto * merge_tree = dynamic_cast(table_ptr.get()); if (!merge_tree) throw Exception(ErrorCodes::BAD_ARGUMENTS, "Command PREWARM MARK CACHE is supported only for MergeTree table, but got: {}", table_ptr->getName()); @@ -2433,7 +2909,7 @@ void InterpreterSystemQuery::prewarmPrimaryIndexCache() getContext()->checkAccess(AccessType::SYSTEM_PREWARM_PRIMARY_INDEX_CACHE, table_id); - auto table_ptr = DatabaseCatalog::instance().getTable(table_id, getContext()); + auto table_ptr = unwrapTableProxy(DatabaseCatalog::instance().getTable(table_id, getContext())); auto * merge_tree = dynamic_cast(table_ptr.get()); if (!merge_tree) throw Exception(ErrorCodes::BAD_ARGUMENTS, "Command PREWARM PRIMARY INDEX CACHE is supported only for MergeTree table, but got: {}", table_ptr->getName()); @@ -2765,6 +3241,41 @@ AccessRightsElements InterpreterSystemQuery::getRequiredAccessForDDLOnCluster() required_access.emplace_back(AccessType::SYSTEM_WAIT_BLOBS_CLEANUP); break; } + case Type::CAS_GC_RUN: + { + required_access.emplace_back(AccessType::SYSTEM_CAS_GC_RUN); + break; + } + case Type::CAS_GC_REBUILD: + { + required_access.emplace_back(AccessType::SYSTEM_CAS_GC_REBUILD); + break; + } + case Type::CAS_DROP_POOL_MEMBER: + { + required_access.emplace_back(AccessType::SYSTEM_CAS_DROP_POOL_MEMBER); + break; + } + case Type::CAS_FSCK: + { + required_access.emplace_back(AccessType::SYSTEM_CAS_FSCK); + break; + } + case Type::CAS_FORGET: + { + required_access.emplace_back(AccessType::SYSTEM_CAS_FORGET); + break; + } + case Type::CAS_GC_STOP: + { + required_access.emplace_back(AccessType::SYSTEM_CAS_GC_STOP); + break; + } + case Type::CAS_GC_START: + { + required_access.emplace_back(AccessType::SYSTEM_CAS_GC_START); + break; + } case Type::UNFREEZE: { required_access.emplace_back(AccessType::SYSTEM_UNFREEZE); diff --git a/src/Interpreters/InterpreterSystemQuery.h b/src/Interpreters/InterpreterSystemQuery.h index ab772cbd8801..f56625f43b37 100644 --- a/src/Interpreters/InterpreterSystemQuery.h +++ b/src/Interpreters/InterpreterSystemQuery.h @@ -74,6 +74,14 @@ class InterpreterSystemQuery : public IInterpreter, WithMutableContext void scheduleMerge(ASTSystemQuery & query); void syncMerges(); + BlockIO runContentAddressedGcRun(const String & disk_name); + BlockIO runContentAddressedGcRebuild(const String & disk_name, bool force); + + BlockIO runContentAddressedFsck(const String & disk_name); + void contentAddressedForget(const String & disk_name); + void contentAddressedGcStop(const String & disk_name); + void contentAddressedGcStart(const String & disk_name); + void loadPrimaryKeys(); void unloadPrimaryKeys(); void loadOrUnloadPrimaryKeysImpl(bool load); diff --git a/src/Interpreters/MergeTreeTransaction/VersionMetadataOnDisk.cpp b/src/Interpreters/MergeTreeTransaction/VersionMetadataOnDisk.cpp index eb7b9df9f056..ab7061854ad0 100644 --- a/src/Interpreters/MergeTreeTransaction/VersionMetadataOnDisk.cpp +++ b/src/Interpreters/MergeTreeTransaction/VersionMetadataOnDisk.cpp @@ -326,6 +326,18 @@ void VersionMetadataOnDisk::storeInfoToDataPartStorage( static constexpr auto filename = TXN_VERSION_METADATA_FILE_NAME; static constexpr auto tmp_filename = TMP_TXN_VERSION_METADATA_FILE_NAME; + if (data_part_storage.supportsAtomicFileWrites()) + { + /// Single atomic write: storages that publish file writes atomically do not need + /// the tmp+replace dance (which exists only for partial-local-write crash safety). + auto write_settings = storage.getContext()->getWriteSettings(); + auto buf = data_part_storage.writeFile(filename, 256, write_settings); + new_info.writeToBuffer(*buf, /*one_line=*/false); + buf->finalize(); + buf->sync(); + return; + } + try { { diff --git a/src/Interpreters/ServerAsynchronousMetrics.cpp b/src/Interpreters/ServerAsynchronousMetrics.cpp index 5f49f439e684..3447e9a70bea 100644 --- a/src/Interpreters/ServerAsynchronousMetrics.cpp +++ b/src/Interpreters/ServerAsynchronousMetrics.cpp @@ -11,6 +11,8 @@ #include +#include + #include #include #include @@ -366,6 +368,37 @@ void ServerAsynchronousMetrics::updateImpl(TimePoint update_time, TimePoint curr } } #endif + + /// Per-disk CAS GC health, for Prometheus scraping. `tryFromDisk` returns nullptr for a + /// disk whose metadata storage is not content-addressed (the common case); `gcHealth()` + /// returns nullopt for a content-addressed disk whose GC scheduler has not started yet + /// (still opening, read-only, or GC disabled by configuration) -- both are skipped + /// silently, same as the DiskUsed_/DiskTotal_ metrics above skip disks that don't report + /// space. This runs on every asynchronous-metrics tick for every configured disk and must + /// never throw. + try + { + if (auto * ca_storage = ContentAddressedMetadataStorage::tryFromDisk(disk)) + { + if (auto health = ca_storage->gcHealth()) + { + new_values[fmt::format("CASGCIsLeader_{}", name)] = { health->is_leader ? 1 : 0, + "Whether this server currently holds the content-addressed garbage-collection lease for the disk (1) or not (0, e.g. another replica is leading)." }; + new_values[fmt::format("CASGCPendingReclaim_{}", name)] = { health->pending_reclaim, + "Cumulative content-addressed objects condemned minus objects physically deleted by this process while it has held the GC lease on the disk. A persistently growing value indicates GC is not keeping up with reclaim." }; + new_values[fmt::format("CASGCLastSuccessAgeSeconds_{}", name)] = { health->last_success_age_seconds, + "Seconds since this process last completed a successful content-addressed GC round as leader on the disk (0 if it has never led one)." }; + new_values[fmt::format("CASGCWedgedNamespaces_{}", name)] = { health->wedged_namespace_count, + "Number of content-addressed namespaces on the disk currently stuck behind a wedged reference lane, unable to make GC progress." }; + } + } + } + catch (...) // NOLINT(bugprone-empty-catch) + { + /// Sampled on every server tick for every disk; a transient failure here (e.g. a + /// store health query hiccup) must never break the rest of asynchronous-metrics + /// collection. + } } } diff --git a/src/Interpreters/SystemLog.cpp b/src/Interpreters/SystemLog.cpp index 00c6f99595cd..a4ce6371abf6 100644 --- a/src/Interpreters/SystemLog.cpp +++ b/src/Interpreters/SystemLog.cpp @@ -31,6 +31,8 @@ #include #include #include +#include +#include #include #include #include diff --git a/src/Interpreters/SystemLog.h b/src/Interpreters/SystemLog.h index dcf52c250fe2..684c1f3f4525 100644 --- a/src/Interpreters/SystemLog.h +++ b/src/Interpreters/SystemLog.h @@ -17,6 +17,8 @@ M(QueryLog, query_log, "Contains information about executed queries, for example, start time, duration of processing, error messages.") \ M(QueryThreadLog, query_thread_log, "Contains information about threads that execute queries, for example, thread name, thread start time, duration of query processing.") \ M(PartLog, part_log, "This table contains information about events that occurred with data parts in the MergeTree family tables, such as adding or merging data.") \ + M(ContentAddressedGarbageCollectionLog, cas_gc_log, "Per-round records of the content-addressed (CA) MergeTree garbage collector: a Start and a Finish row per GC round, with counts of objects marked/deleted, duration, outcome, and per-round ProfileEvents.") \ + M(ContentAddressedLog, cas_log, "Per-event content-addressed (CA) MergeTree audit log: one row per blob/ref/GC decision (put, reuse, retire, delete, root add/remove, in-degree-zero, fence, lease, ...) plus errors (dangling access, fail-closed). Enabled by default while the CA disk feature is experimental (see config.xml); it is the primary forensic instrument for a CA issue and costs nothing when no CA disk is configured.") \ M(BackgroundSchedulePoolLog, background_schedule_pool_log, "Contains history of background schedule pool task executions.") \ M(TraceLog, trace_log, "Contains stack traces collected by the sampling query profiler.") \ M(CrashLog, crash_log, "Contains information about stack traces for fatal errors. The table does not exist in the database by default, it is created only when fatal errors occur.") \ diff --git a/src/Interpreters/ThreadStatusExt.cpp b/src/Interpreters/ThreadStatusExt.cpp index 813254ef842b..1ac5c8aa6050 100644 --- a/src/Interpreters/ThreadStatusExt.cpp +++ b/src/Interpreters/ThreadStatusExt.cpp @@ -129,6 +129,8 @@ ThreadGroup::ThreadGroup(ThreadGroupPtr parent) , global_context(parent->global_context) , fatal_error_callback(parent->fatal_error_callback) , os_threads_nice_value(parent->os_threads_nice_value) + /// Keep the parent group alive: this child parents its trackers at the parent's via raw pointers (B90). + , parent_thread_group(parent) , memory_spill_scheduler(parent->memory_spill_scheduler) , performance_counters(VariableContext::Process, &parent->performance_counters) , memory_tracker(&parent->memory_tracker, VariableContext::Process, /*log_peak_memory_usage_in_destructor*/ false) @@ -143,6 +145,8 @@ ThreadGroup::ThreadGroup(ContextPtr query_context_, ThreadGroupPtr parent) , global_context(query_context_->getGlobalContext()) , fatal_error_callback(parent->fatal_error_callback) , os_threads_nice_value(parent->os_threads_nice_value) + /// Keep the parent group alive: this child parents its trackers at the parent's via raw pointers (B90). + , parent_thread_group(parent) , memory_spill_scheduler(parent->memory_spill_scheduler) , performance_counters(VariableContext::Process, &parent->performance_counters) , memory_tracker(&parent->memory_tracker, VariableContext::Process, /*log_peak_memory_usage_in_destructor*/ false) diff --git a/src/Parsers/ASTSystemQuery.cpp b/src/Parsers/ASTSystemQuery.cpp index c4ee97348899..3b029948ee42 100644 --- a/src/Parsers/ASTSystemQuery.cpp +++ b/src/Parsers/ASTSystemQuery.cpp @@ -153,6 +153,9 @@ void ASTSystemQuery::formatImpl(WriteBuffer & ostr, const FormatSettings & setti Type::CLEAR_DISTRIBUTED_CACHE, Type::SYNC_FILESYSTEM_CACHE, Type::CLEAR_QUERY_CACHE, + /// The grammar parses ` FROM DISK ` before `ON CLUSTER` (ParserSystemQuery.cpp), + /// so the round-trip format must print it last too. + Type::CAS_DROP_POOL_MEMBER, }; if (!queries_with_on_cluster_at_end.contains(type) && !cluster.empty()) @@ -259,13 +262,50 @@ void ASTSystemQuery::formatImpl(WriteBuffer & ostr, const FormatSettings & setti break; } + case Type::CAS_GC_REBUILD: + { + /// FORCE precedes the required disk name: SYSTEM CAS GC REBUILD + /// [FORCE] . + if (cas_gc_rebuild_force) + print_keyword(" FORCE"); + if (!disk.empty()) + { + ostr << ' '; + print_identifier(disk); + } + break; + } + case Type::CAS_DROP_POOL_MEMBER: + { + /// SYSTEM CAS DROP POOL MEMBER FROM DISK -- both required, both + /// quoted string literals (unlike the sibling CAS_* commands' bare identifier + /// disk target: an srid is an opaque server-root path, not necessarily identifier-shaped). + ostr << ' ' << quoteString(replica); + print_keyword(" FROM DISK ") << quoteString(disk); + break; + } + case Type::CAS_FSCK: + case Type::CAS_FORGET: + case Type::CAS_GC_STOP: + case Type::CAS_GC_START: + { + /// SYSTEM CAS FSCK/FORGET/GC STOP/GC START -- the disk is REQUIRED + /// (unlike GC RUN's optional disk): each scan/decommission/scheduler-control verb targets + /// exactly one disk, never a fan-out. + ostr << ' '; + print_identifier(disk); + break; + } case Type::RELOAD_DICTIONARY: case Type::RELOAD_MODEL: case Type::RELOAD_FUNCTION: + case Type::CAS_GC_RUN: case Type::RESTART_DISK: case Type::WAIT_BLOBS_CLEANUP: case Type::CLEAR_DISK_METADATA_CACHE: { + /// RELOAD DICTIONARY prints its database/table target, RELOAD MODEL/FUNCTION their + /// identifier target; CAS GC RUN's disk is optional. if (table) { ostr << ' '; diff --git a/src/Parsers/ASTSystemQuery.h b/src/Parsers/ASTSystemQuery.h index adf76d898d42..365b7046fa5f 100644 --- a/src/Parsers/ASTSystemQuery.h +++ b/src/Parsers/ASTSystemQuery.h @@ -4,6 +4,7 @@ #include #include #include +#include #include "config.h" @@ -146,6 +147,13 @@ class ASTSystemQuery : public IAST, public ASTQueryWithOnCluster INSTRUMENT_ADD, INSTRUMENT_REMOVE, RESET_DDL_WORKER, + CAS_GC_RUN, + CAS_GC_REBUILD, + CAS_DROP_POOL_MEMBER, + CAS_FSCK, + CAS_FORGET, + CAS_GC_STOP, + CAS_GC_START, END }; @@ -176,6 +184,9 @@ class ASTSystemQuery : public IAST, public ASTQueryWithOnCluster String storage_policy; String volume; String disk; + /// SYSTEM CAS GC REBUILD FORCE [] — the raw baseline-rebuild disaster + /// recovery command's optional FORCE keyword (bypass the "healthy state" refusal). + bool cas_gc_rebuild_force = false; UInt64 seconds{}; UInt64 untracked_memory_size{}; @@ -265,3 +276,14 @@ class ASTSystemQuery : public IAST, public ASTQueryWithOnCluster } + +/// Type has grown past the default magic_enum range [-128, 127] (130+ entries and counting, one per +/// SYSTEM sub-command): both the type-detection loop in ParserSystemQuery.cpp (which iterates +/// magic_enum::enum_values() to try every command's auto-derived keyword phrase) and +/// ASTSystemQuery::typeToString's index table silently drop/OOB-index any value past the range without +/// this. Matches the GeometryColumnType / Coordination::OpNum precedent for a large enum. +template <> struct magic_enum::customize::enum_range +{ + static constexpr int min = 0; + static constexpr int max = 255; +}; diff --git a/src/Parsers/ParserSystemQuery.cpp b/src/Parsers/ParserSystemQuery.cpp index 07b79465ea04..4b15505fd9e4 100644 --- a/src/Parsers/ParserSystemQuery.cpp +++ b/src/Parsers/ParserSystemQuery.cpp @@ -457,6 +457,70 @@ bool ParserSystemQuery::parseImpl(IParser::Pos & pos, ASTPtr & node, Expected & return false; break; } + case Type::CAS_GC_RUN: + { + /// SYSTEM CAS GC RUN [] [ON CLUSTER cluster]. The disk is OPTIONAL. + /// When omitted, the empty disk means "all content-addressed disks on this node". + /// First try the full target form (which also handles ON CLUSTER); if no disk follows, + /// fall back to parsing just the optional ON CLUSTER clause and leave the disk empty. + auto saved_pos = pos; + Expected target_expected = expected; + if (!parseQueryWithOnClusterAndTarget(res, pos, target_expected, SystemQueryTargetType::Disk)) + { + pos = saved_pos; + res->disk.clear(); + if (!parseQueryWithOnCluster(res, pos, expected)) + return false; + } + break; + } + case Type::CAS_GC_REBUILD: + { + /// SYSTEM CAS GC REBUILD [FORCE] [ON CLUSTER cluster]. Unlike the + /// per-round GC RUN command, REBUILD requires an EXPLICIT disk: the destructive + /// baseline rebuild must never fan out across every content-addressed disk from a bare + /// command. parseQueryWithOnClusterAndTarget requires the target, so omitting the disk is a + /// syntax error. + res->cas_gc_rebuild_force = ParserKeyword{Keyword::FORCE}.ignore(pos, expected); + if (!parseQueryWithOnClusterAndTarget(res, pos, expected, SystemQueryTargetType::Disk)) + return false; + break; + } + case Type::CAS_FSCK: + case Type::CAS_FORGET: + case Type::CAS_GC_STOP: + case Type::CAS_GC_START: + { + /// SYSTEM CAS FSCK/FORGET/GC STOP/GC START [ON CLUSTER cluster]. + /// Unlike GC RUN, the disk is REQUIRED -- mirrors CAS_GC_REBUILD (minus the FORCE + /// keyword): parseQueryWithOnClusterAndTarget requires the target, so omitting the disk is a + /// syntax error rather than a silent fan-out across every content-addressed disk. + if (!parseQueryWithOnClusterAndTarget(res, pos, expected, SystemQueryTargetType::Disk)) + return false; + break; + } + case Type::CAS_DROP_POOL_MEMBER: + { + /// SYSTEM CAS DROP POOL MEMBER FROM DISK [ON CLUSTER cluster]. + /// Both the srid and the disk name are REQUIRED quoted string literals -- an srid is an + /// opaque server-root path (may contain '/'), not the bare identifier the sibling + /// CAS_* commands' disk TARGET accepts, so this does not go through + /// parseQueryWithOnClusterAndTarget. + ASTPtr ast; + if (!ParserStringLiteral{}.parse(pos, ast, expected)) + return false; + res->replica = ast->as().value.safeGet(); + if (!ParserKeyword{Keyword::FROM}.ignore(pos, expected)) + return false; + if (!ParserKeyword{Keyword::DISK}.ignore(pos, expected)) + return false; + if (!ParserStringLiteral{}.parse(pos, ast, expected)) + return false; + res->disk = ast->as().value.safeGet(); + if (!parseQueryWithOnCluster(res, pos, expected)) + return false; + break; + } /// FLUSH DISTRIBUTED requires table /// START/STOP DISTRIBUTED SENDS does not require table case Type::STOP_DISTRIBUTED_SENDS: diff --git a/src/Parsers/tests/gtest_Parser.cpp b/src/Parsers/tests/gtest_Parser.cpp index c62fd894e875..b7ec22da4420 100644 --- a/src/Parsers/tests/gtest_Parser.cpp +++ b/src/Parsers/tests/gtest_Parser.cpp @@ -9,6 +9,7 @@ #include #include #include +#include #include #include #include @@ -477,6 +478,49 @@ INSTANTIATE_TEST_SUITE_P(ParserRenameQuery, ParserTest, } }))); +// SYSTEM CAS DROP POOL MEMBER: srid and disk are both required quoted string literals +// (an srid is an opaque server-root path, not identifier-shaped); ON CLUSTER round-trips as a bare +// identifier (ASTQueryWithOnCluster::formatOnCluster uses backQuoteIfNeed, no quoting needed for a +// plain name), even though the parser also accepts a quoted string literal for it on input. +INSTANTIATE_TEST_SUITE_P(ParserSystemQuery, ParserTest, + ::testing::Combine( + ::testing::Values(std::make_shared()), + ::testing::ValuesIn(std::initializer_list{ + { + "SYSTEM CAS DROP POOL MEMBER 'srv1' FROM DISK 'disk1'", + "SYSTEM CAS DROP POOL MEMBER 'srv1' FROM DISK 'disk1'" + }, + { + "SYSTEM CAS DROP POOL MEMBER 'srv1' FROM DISK 'disk1' ON CLUSTER my_cluster", + "SYSTEM CAS DROP POOL MEMBER 'srv1' FROM DISK 'disk1' ON CLUSTER my_cluster" + }, + { + "SYSTEM CAS DROP POOL MEMBER 'srv1'", // missing FROM DISK + nullptr + }, + { + "SYSTEM CAS DROP POOL MEMBER FROM DISK 'disk1'", // missing srid + nullptr + }, + { + "SYSTEM CAS GC RUN", + "SYSTEM CAS GC RUN" + }, + { + "SYSTEM CAS GC RUN disk1", + "SYSTEM CAS GC RUN disk1" + }, + { + /// CAS_GC_RUN goes through the shared parseQueryWithOnClusterAndTarget + /// helper (like RESTART_DISK / WAIT_BLOBS_CLEANUP / CLEAR_DISK_METADATA_CACHE), whose + /// round-trip format always normalizes to "ON CLUSTER cluster target" -- unlike + /// CAS_DROP_POOL_MEMBER, which has its own dedicated grammar and prints + /// ON CLUSTER last. + "SYSTEM CAS GC RUN disk1 ON CLUSTER my_cluster", + "SYSTEM CAS GC RUN ON CLUSTER my_cluster disk1" + }, +}))); + static constexpr size_t kDummyMaxQuerySize = 256 * 1024; static constexpr size_t kDummyMaxParserDepth = 256; static constexpr size_t kDummyMaxParserBacktracks = 1000000; diff --git a/src/Storages/MergeTree/DataPartStorageOnDiskBase.cpp b/src/Storages/MergeTree/DataPartStorageOnDiskBase.cpp index def7f6395860..5ab4565675a4 100644 --- a/src/Storages/MergeTree/DataPartStorageOnDiskBase.cpp +++ b/src/Storages/MergeTree/DataPartStorageOnDiskBase.cpp @@ -2,6 +2,7 @@ #include #include #include +#include #include #include #include @@ -14,6 +15,7 @@ #include #include #include +#include #include #include #include @@ -38,6 +40,7 @@ namespace ErrorCodes extern const int LOGICAL_ERROR; extern const int FILE_DOESNT_EXIST; extern const int CORRUPTED_DATA; + extern const int SUPPORT_IS_DISABLED; } std::unique_ptr IDataPartStorage::readFile( @@ -270,6 +273,16 @@ bool DataPartStorageOnDiskBase::isStoredOnRemoteDisk() const return volume->getDisk()->isRemote(); } +bool DataPartStorageOnDiskBase::isContentAddressed() const +{ + return volume->getDisk()->isContentAddressed(); +} + +bool DataPartStorageOnDiskBase::supportsAtomicFileWrites() const +{ + return volume->getDisk()->supportsAtomicFileWrites(); +} + std::optional DataPartStorageOnDiskBase::getCacheName() const { if (volume->getDisk()->supportsCache()) @@ -401,6 +414,18 @@ void DataPartStorageOnDiskBase::backup( auto disk = volume->getDisk(); + /// B34: the temporary-hard-link BACKUP path (used for Ordinary, non-UUID databases) calls + /// disk->createHardLink with a non-part-shaped temp path, which on a CAS disk + /// would otherwise surface as a raw LOGICAL_ERROR. Fail closed with a clear message instead. + /// The pointer-holding path (make_temporary_hard_links=false, used by Atomic/UUID databases) + /// uses getStorageObjects and round-trips on a CAS disk, so it is left untouched. + if (make_temporary_hard_links && disk->isContentAddressed()) + throw Exception( + ErrorCodes::SUPPORT_IS_DISABLED, + "BACKUP via temporary hard links is not supported on a CAS disk yet (B16/B34); " + "use an Atomic database (which backs up via pointer-holding) instead; disk '{}'", + disk->getName()); + fs::path temp_part_dir; std::shared_ptr temp_dir_owner; if (make_temporary_hard_links) @@ -506,8 +531,20 @@ MutableDataPartStoragePtr DataPartStorageOnDiskBase::freeze( const ClonePartParams & params) const { auto disk = volume->getDisk(); - if (params.external_transaction) - params.external_transaction->createDirectories(to); + + /// A CAS disk models a part as one atomic unit (N files -> one manifest -> one ref). + /// The per-file createHardLink autocommit Backup uses with no enclosing transaction would publish a + /// one-file ref per file and overwrite the destination, leaving the clone with only its last file + /// (the B21 corruption mode — seen as system.detached_parts listing metadata_version.txt instead of + /// the detached part dir, B36). When the caller did not supply a transaction, run the whole clone + /// through ONE self-created disk transaction so all files land in a single content-addressed part. + DiskTransactionPtr owned_transaction; + if (!params.external_transaction && disk->isContentAddressed()) + owned_transaction = disk->createTransaction(); + const DiskTransactionPtr & clone_transaction = params.external_transaction ? params.external_transaction : owned_transaction; + + if (clone_transaction) + clone_transaction->createDirectories(to); else disk->createDirectories(to); @@ -522,17 +559,38 @@ MutableDataPartStoragePtr DataPartStorageOnDiskBase::freeze( /* max_level= */ {}, params.copy_instead_of_hardlink, params.files_to_copy_instead_of_hardlinks, - params.external_transaction); + clone_transaction); if (save_metadata_callback) save_metadata_callback(disk); - if (params.external_transaction) + if (clone_transaction) { - params.external_transaction->removeFileIfExists(fs::path(to) / dir_path / "delete-on-destroy.txt"); - params.external_transaction->removeFileIfExists(fs::path(to) / dir_path / VersionMetadata::TXN_VERSION_METADATA_FILE_NAME); + clone_transaction->removeFileIfExists(fs::path(to) / dir_path / "delete-on-destroy.txt"); + clone_transaction->removeFileIfExists(fs::path(to) / dir_path / VersionMetadata::TXN_VERSION_METADATA_FILE_NAME); if (!params.keep_metadata_version) - params.external_transaction->removeFileIfExists(fs::path(to) / dir_path / IMergeTreeDataPart::METADATA_VERSION_FILE_NAME); + clone_transaction->removeFileIfExists(fs::path(to) / dir_path / IMergeTreeDataPart::METADATA_VERSION_FILE_NAME); + + /// When the caller wants a fresh metadata version written into the clone (the Replicated queue + /// clone path — `executeReplaceRange`/`replacePartitionFrom`/`movePartitionToTable` set + /// `metadata_version_to_write`), write `metadata_version.txt` INSIDE the clone transaction so it + /// is part of the single whole-part commit. On a content-addressed disk the part is published + /// atomically at `commit`; a separate post-clone autocommit `writeFile` of this part file (what + /// `cloneAndLoadDataPart` does for non-CA disks) would hit the per-file-autocommit guard (B21). + /// `cloneAndLoadDataPart`'s own post-clone write now runs unconditionally (the freeze special + /// case was dropped with all-tree Task 10): identical bytes land as a byte-equal repoint + /// no-op, differing bytes as a legal repoint. + if (params.metadata_version_to_write.has_value()) + { + chassert(!params.keep_metadata_version); + auto out_metadata = clone_transaction->writeFile( + fs::path(to) / dir_path / IMergeTreeDataPart::METADATA_VERSION_FILE_NAME, + 4096, + WriteMode::Rewrite, + write_settings); + writeText(*params.metadata_version_to_write, *out_metadata); + out_metadata->finalize(); + } } else { @@ -542,6 +600,11 @@ MutableDataPartStoragePtr DataPartStorageOnDiskBase::freeze( disk->removeFileIfExists(fs::path(to) / dir_path / IMergeTreeDataPart::METADATA_VERSION_FILE_NAME); } + /// Commit the self-created transaction (the whole-part clone commit point for CA). An external + /// transaction is committed by its owner, as before. + if (owned_transaction) + owned_transaction->commit(); + auto single_disk_volume = std::make_shared(disk->getName(), disk, 0); /// Do not initialize storage in case of DETACH because part may be broken. @@ -605,6 +668,51 @@ MutableDataPartStoragePtr DataPartStorageOnDiskBase::freezeRemote( return create(single_disk_volume, to, dir_path, /*initialize=*/ !to_detached && !params.external_transaction); } +namespace +{ + +/// Recursively copy every file under `source_path` on `src_disk` into `destination_path` through +/// `dst_transaction`'s NON-autocommit `writeFile` (IDiskTransaction::writeFile, NOT +/// writeFileWithAutoCommit) -- the same primitive `freeze` already uses for a single file (the +/// metadata_version.txt write, DataPartStorageOnDiskBase::freeze). Cross-disk, so it cannot reuse +/// Backup()/BackupImpl: that helper's transactional branch calls transaction->copyFile, which is +/// SAME-disk only (DiskObjectStorageTransaction::copyFile throws NOT_IMPLEMENTED across disks on +/// CA), and its non-transactional branch always autocommits per file via IDisk::copyFile / +/// copyDirectoryContent. Sequential, not the parallel copyThroughBuffers thread pool: a +/// content-addressed transaction batches every file into ONE eventual manifest, and its staging +/// map is not mutex-guarded; MOVE is a background, latency-insensitive operation, so +/// parallelizing this is a deferred optimization, not a correctness requirement. +void copyDirectoryContentIntoTransaction( + IDisk & src_disk, + const String & source_path, + IDiskTransaction & dst_transaction, + const String & destination_path, + const ReadSettings & read_settings, + const WriteSettings & write_settings, + const std::function & cancellation_hook) +{ + dst_transaction.createDirectories(destination_path); + for (auto it = src_disk.iterateDirectory(source_path); it->isValid(); it->next()) + { + auto source = it->path(); + auto destination = fs::path(destination_path) / it->name(); + + if (src_disk.existsDirectory(source)) + { + copyDirectoryContentIntoTransaction( + src_disk, source, dst_transaction, destination, read_settings, write_settings, cancellation_hook); + continue; + } + + auto in = src_disk.readFile(source, read_settings); + auto out = dst_transaction.writeFile(destination, DBMS_DEFAULT_BUFFER_SIZE, WriteMode::Rewrite, write_settings); + copyData(*in, *out, cancellation_hook); + out->finalize(); + } +} + +} + MutableDataPartStoragePtr DataPartStorageOnDiskBase::clonePart( const std::string & to, const std::string & dir_path, @@ -624,18 +732,46 @@ MutableDataPartStoragePtr DataPartStorageOnDiskBase::clonePart( dir_path, getRelativePath(), path_to_clone, fullPath(dst_disk, path_to_clone)); } - try + if (dst_disk->isContentAddressed()) { - dst_disk->createDirectories(to); - src_disk->copyDirectoryContent(getRelativePath(), dst_disk, path_to_clone, read_settings, write_settings, cancellation_hook); + /// L2 (MOVE-to-CA fix): a content-addressed disk models a part as ONE atomic unit (N + /// files -> one manifest -> one ref). The generic per-file autocommit path below would + /// publish a separate one-file ref per file -- colliding on the shared "moving" ref + /// before L1, and throwing NOT_IMPLEMENTED on a non-first content file even after L1 + /// ("Autocommit writes are not supported for content part files"). Run the whole clone + /// through ONE self-created disk transaction instead, mirroring freeze's + /// owned_transaction shape -- but streaming cross-disk bytes, since freeze's Backup() is + /// same-disk hardlink/copyFile (throws NOT_IMPLEMENTED for CA cross-disk). + auto clone_transaction = dst_disk->createTransaction(); + try + { + copyDirectoryContentIntoTransaction( + *src_disk, getRelativePath(), *clone_transaction, path_to_clone, + read_settings, write_settings, cancellation_hook); + clone_transaction->commit(); + } + catch (...) + { + LOG_WARNING(log, "Rolling back transaction after failed attempt to move a data part to {}", path_to_clone); + clone_transaction->undo(); + throw; + } } - catch (...) + else { - /// It's safe to remove it recursively (even with zero-copy-replication) - /// because we've just did full copy through copyDirectoryContent - LOG_WARNING(log, "Removing directory {} after failed attempt to move a data part", path_to_clone); - dst_disk->removeRecursive(path_to_clone); - throw; + try + { + dst_disk->createDirectories(to); + src_disk->copyDirectoryContent(getRelativePath(), dst_disk, path_to_clone, read_settings, write_settings, cancellation_hook); + } + catch (...) + { + /// It's safe to remove it recursively (even with zero-copy-replication) + /// because we've just did full copy through copyDirectoryContent + LOG_WARNING(log, "Removing directory {} after failed attempt to move a data part", path_to_clone); + dst_disk->removeRecursive(path_to_clone); + throw; + } } auto single_disk_volume = std::make_shared(dst_disk->getName(), dst_disk, 0); diff --git a/src/Storages/MergeTree/DataPartStorageOnDiskBase.h b/src/Storages/MergeTree/DataPartStorageOnDiskBase.h index e1c55719954f..1bd66ff660a6 100644 --- a/src/Storages/MergeTree/DataPartStorageOnDiskBase.h +++ b/src/Storages/MergeTree/DataPartStorageOnDiskBase.h @@ -40,6 +40,8 @@ class DataPartStorageOnDiskBase : public IDataPartStorage std::string getDiskName() const override; std::string getDiskType() const override; bool isStoredOnRemoteDisk() const override; + bool isContentAddressed() const override; + bool supportsAtomicFileWrites() const override; std::optional getCacheName() const override; bool supportZeroCopyReplication() const override; bool supportParallelWrite() const override; diff --git a/src/Storages/MergeTree/DataPartStorageOnDiskFull.cpp b/src/Storages/MergeTree/DataPartStorageOnDiskFull.cpp index 5503d11c58d9..fd25e35153c4 100644 --- a/src/Storages/MergeTree/DataPartStorageOnDiskFull.cpp +++ b/src/Storages/MergeTree/DataPartStorageOnDiskFull.cpp @@ -2,6 +2,7 @@ #include #include +#include #include #include #include @@ -9,14 +10,24 @@ #include #include #include +#include #include +#include +#include + namespace DB { +namespace FailPoints +{ + extern const char part_storage_fail_commit_transaction[]; +} + namespace ErrorCodes { extern const int LOGICAL_ERROR; + extern const int FAULT_INJECTED; } DataPartStorageOnDiskFull::DataPartStorageOnDiskFull(VolumePtr volume_, std::string root_path_, std::string part_dir_) @@ -48,22 +59,39 @@ DataPartStoragePtr DataPartStorageOnDiskFull::getProjection(const std::string & bool DataPartStorageOnDiskFull::exists() const { - return volume->getDisk()->existsDirectory(fs::path(root_path) / part_dir); + auto path = fs::path(root_path) / part_dir; + /// CA read-your-writes: a part dir being assembled by this transaction (e.g. a carried-forward + /// projection dir staged into the open whole-part txn) is not on committed metadata yet. Mirrors + /// existsDirectory at directory granularity for the part's OWN directory. + if (transaction && transaction->hasInFlightDirectory(path)) + return true; + return volume->getDisk()->existsDirectory(path); } bool DataPartStorageOnDiskFull::existsFile(const std::string & name) const { + auto path = fs::path(root_path) / part_dir / name; + /// B59: a part still being assembled by this transaction can have staged-but-uncommitted files + /// (e.g. projection temp blocks on a content-addressed disk). Consult the held transaction first. + if (transaction && transaction->tryGetInFlightFileSize(path).has_value()) + return true; if (looksLikePackedSkipIndexFile(name)) { if (auto reader = getSkipIndicesPackedReader(); reader && reader->exists(name)) return true; } - return volume->getDisk()->existsFile(fs::path(root_path) / part_dir / name); + return volume->getDisk()->existsFile(path); } bool DataPartStorageOnDiskFull::existsDirectory(const std::string & name) const { - return volume->getDisk()->existsDirectory(fs::path(root_path) / part_dir / name); + auto path = fs::path(root_path) / part_dir / name; + /// CA read-your-writes: a part still being assembled by this transaction can have a staged-but-uncommitted + /// directory (e.g. a carried-forward projection hardlinked into the open whole-part txn) that committed + /// metadata cannot see yet. Mirrors existsFile (B59) at directory granularity. + if (transaction && transaction->hasInFlightDirectory(path)) + return true; + return volume->getDisk()->existsDirectory(path); } class DataPartStorageIteratorOnDisk final : public IDataPartStorageIterator @@ -85,11 +113,52 @@ class DataPartStorageIteratorOnDisk final : public IDataPartStorageIterator DirectoryIteratorPtr it; }; +/// CA read-your-writes directory enumeration: a merged view of the committed disk entries PLUS the +/// immediate children this transaction has STAGED under the part dir (deduplicated). Used so +/// loadProjections' withPartFormatFromDisk can iterate a staged-but-uncommitted projection directory and +/// find its mark file. Mirrors existsFile/existsDirectory (B59) at the enumeration level; the committed +/// entries dominate (a name present both on disk and staged appears once). +class DataPartStorageMergedIterator final : public IDataPartStorageIterator +{ +public: + DataPartStorageMergedIterator(DiskPtr disk_, std::string dir_path_, std::vector names_) + : disk(std::move(disk_)), dir_path(std::move(dir_path_)), names(std::move(names_)) + { + } + + void next() override { ++pos; } + bool isValid() const override { return pos < names.size(); } + std::string name() const override { return names[pos]; } + std::string path() const override { return fs::path(dir_path) / names[pos]; } + bool isFile() const override { return isValid() && disk->existsFile(path()); } + +private: + DiskPtr disk; + std::string dir_path; + std::vector names; + size_t pos = 0; +}; + DataPartStorageIteratorPtr DataPartStorageOnDiskFull::iterate() const { + auto dir_path = fs::path(root_path) / part_dir; + if (transaction) + { + if (auto staged = transaction->listInFlightDirectory(dir_path); !staged.empty()) + { + /// Union the committed entries with the staged children (set semantics, committed dominates). + std::set names(staged.begin(), staged.end()); + if (volume->getDisk()->existsDirectory(dir_path)) + for (auto it = volume->getDisk()->iterateDirectory(dir_path); it->isValid(); it->next()) + names.insert(it->name()); + return std::make_unique( + volume->getDisk(), dir_path, std::vector(names.begin(), names.end())); + } + } + return std::make_unique( volume->getDisk(), - volume->getDisk()->iterateDirectory(fs::path(root_path) / part_dir)); + volume->getDisk()->iterateDirectory(dir_path)); } Poco::Timestamp DataPartStorageOnDiskFull::getFileLastModified(const String & file_name) const @@ -99,12 +168,17 @@ Poco::Timestamp DataPartStorageOnDiskFull::getFileLastModified(const String & fi size_t DataPartStorageOnDiskFull::getFileSize(const String & file_name) const { + auto path = fs::path(root_path) / part_dir / file_name; + /// B59: see existsFile — the merge stats the staged temp files before reading them back. + if (transaction) + if (auto size = transaction->tryGetInFlightFileSize(path)) + return *size; if (looksLikePackedSkipIndexFile(file_name)) { if (auto reader = getSkipIndicesPackedReader(); reader && reader->exists(file_name)) return reader->getFileSize(file_name); } - return volume->getDisk()->getFileSize(fs::path(root_path) / part_dir / file_name); + return volume->getDisk()->getFileSize(path); } UInt32 DataPartStorageOnDiskFull::getRefCount(const String & file_name) const @@ -115,7 +189,17 @@ UInt32 DataPartStorageOnDiskFull::getRefCount(const String & file_name) const std::vector DataPartStorageOnDiskFull::getRemotePaths(const std::string & file_name) const { const std::string path = fs::path(root_path) / part_dir / file_name; - auto objects = volume->getDisk()->getStorageObjects(path); + + /// B59: a file staged by this transaction resolves to its already-uploaded blob object(s) before commit. + /// A mutable per-part file intentionally does NOT resolve here (tryGetInFlightStorageObjects returns + /// nullopt → falls through): it has no blob object and must be read via tryReadFileInFlight. The merge + /// reads projection column blocks (blob-backed) through this path, not mutable files. + StoredObjects objects; + if (transaction) + if (auto inflight = transaction->tryGetInFlightStorageObjects(path)) + objects = std::move(*inflight); + if (objects.empty()) + objects = volume->getDisk()->getStorageObjects(path); std::vector remote_paths; remote_paths.reserve(objects.size()); @@ -141,6 +225,42 @@ void DataPartStorageOnDiskFull::prepareRead( std::optional read_hint, ReadPipeline & pipeline) const { + auto path = fs::path(root_path) / part_dir / name; + + /// B59: read-your-writes for a part still being assembled by this transaction. A projection + /// spill-and-merge reads its own temp blocks back before the parent part's single commit; on a + /// content-addressed disk those files are staged in the transaction (blob uploaded, no ref yet), + /// so the committed metadata path can't see them. If the held transaction resolves the file + /// in-flight, serve it via a custom pipeline source that reads through the transaction. Gated on + /// `transaction != nullptr` so committed-part reads (no open transaction) are unchanged. + if (transaction) + { + StoredObjects inflight_objects; + if (auto objs = transaction->tryGetInFlightStorageObjects(path)) + inflight_objects = std::move(*objs); + else if (auto size = transaction->tryGetInFlightFileSize(path)) + /// Mutable per-part file staged inline (no blob object); synthesize a placeholder so the + /// single-object pipeline is satisfied — the custom creator below ignores it and reads the + /// inline bytes through the transaction. + inflight_objects = StoredObjects{StoredObject(path, path, *size)}; + + if (!inflight_objects.empty()) + { + /// Safe to capture the raw transaction pointer: no cache/gather/async stage is added on this + /// branch, so the custom source is consumed synchronously inside build() during this read and + /// the pointer is never retained past it. + auto * tx = transaction.get(); + pipeline.setSource( + [tx, path](const StoredObject &, const ReadSettings & read_settings, bool /*use_external_buffer*/, bool /*restrict_seek*/) + { + return tx->tryReadFileInFlight(path, read_settings, std::nullopt); + }, + std::move(inflight_objects), + settings); + return; + } + } + if (looksLikePackedSkipIndexFile(name)) { if (auto reader = getSkipIndicesPackedReader(); reader && reader->exists(name)) @@ -161,7 +281,8 @@ void DataPartStorageOnDiskFull::prepareRead( return; } } - volume->getDisk()->prepareRead(fs::path(root_path) / part_dir / name, settings, read_hint, pipeline); + + volume->getDisk()->prepareRead(path, settings, read_hint, pipeline); } std::unique_ptr DataPartStorageOnDiskFull::readFileIfExists( @@ -169,6 +290,13 @@ std::unique_ptr DataPartStorageOnDiskFull::readFileIfExi const ReadSettings & settings, std::optional read_hint) const { + auto path = fs::path(root_path) / part_dir / name; + /// B59: serve a file staged by this transaction (uploaded blob or inline mutable bytes) before commit. + /// This direct delegate bypasses prepareRead, so the in-flight guard must be repeated here; it is the + /// only path that reaches the inline-mutable case via a returned buffer. + if (transaction) + if (auto rb = transaction->tryReadFileInFlight(path, settings, read_hint)) + return rb; if (looksLikePackedSkipIndexFile(name)) { if (auto reader = getSkipIndicesPackedReader(); reader && reader->exists(name)) @@ -177,7 +305,7 @@ std::unique_ptr DataPartStorageOnDiskFull::readFileIfExi fs::path(root_path) / part_dir / String(SKIP_INDICES_PACKED_FILENAME), name, settings, read_hint); } - return volume->getDisk()->readFileIfExists(fs::path(root_path) / part_dir / name, settings, read_hint); + return volume->getDisk()->readFileIfExists(path, settings, read_hint); } std::unique_ptr DataPartStorageOnDiskFull::writeFile( @@ -266,20 +394,38 @@ void DataPartStorageOnDiskFull::createProjection(const std::string & name) void DataPartStorageOnDiskFull::beginTransaction() { + /// A borrowed projection sub-part shares the PARENT part's whole-part transaction (on a + /// content-addressed disk a part is one atomic unit: one manifest + one ref). It must not open its + /// own — riding the parent transaction is the point (B58) — so begin is a no-op here. This + /// centralizes the rule the 6 merge/mutate call sites used to duplicate as + /// `if (!isContentAddressed()) beginTransaction()`. + if (has_shared_transaction) + return; + if (transaction) - throw Exception(ErrorCodes::LOGICAL_ERROR, - "Uncommitted{}transaction already exists", has_shared_transaction ? " shared " : " "); + throw Exception(ErrorCodes::LOGICAL_ERROR, "Uncommitted transaction already exists"); transaction = volume->getDisk()->createTransaction(); } void DataPartStorageOnDiskFull::commitTransaction() { + /// The mirror of beginTransaction: a borrowed projection sub-part rides the parent's transaction and + /// is published by the parent's single commit. Committing here would be committing someone else's + /// transaction, so it is a no-op. + if (has_shared_transaction) + return; + if (!transaction) throw Exception(ErrorCodes::LOGICAL_ERROR, "There is no uncommitted transaction"); - if (has_shared_transaction) - throw Exception(ErrorCodes::LOGICAL_ERROR, "Cannot commit shared transaction"); + /// Regression gate for the part-durability-before-Keeper-commit invariant: lets a test fail the + /// close of the PART's deferred disk transaction specifically (autocommit one-shot disk ops are + /// not affected, unlike disk_object_storage_fail_commit_metadata_transaction). + fiu_do_on(FailPoints::part_storage_fail_commit_transaction, + { + throw Exception(ErrorCodes::FAULT_INJECTED, "part_storage_fail_commit_transaction"); + }); transaction->commit(); transaction.reset(); diff --git a/src/Storages/MergeTree/DataPartsExchange.cpp b/src/Storages/MergeTree/DataPartsExchange.cpp index 690b40b6662d..00f27191db7b 100644 --- a/src/Storages/MergeTree/DataPartsExchange.cpp +++ b/src/Storages/MergeTree/DataPartsExchange.cpp @@ -5,6 +5,9 @@ #include #include #include +#include +#include +#include #include #include #include @@ -44,6 +47,15 @@ namespace CurrentMetrics namespace DB { +namespace FailPoints +{ + /// CAS fetch-by-relink, receiver side. Both exist because the two exits they drive are properties of + /// the sender/receiver PAIR and of the interval between the receiver's publish and its confirm — and + /// neither is reachable from configuration, so an integration test cannot produce them any other way. + extern const char cas_relink_receiver_force_mechanism_failure[]; + extern const char cas_relink_receiver_pause_before_confirm[]; +} + namespace MergeTreeSetting { extern const MergeTreeSettingsBool allow_remote_fs_zero_copy_replication; @@ -60,6 +72,7 @@ namespace ErrorCodes extern const int CHECKSUM_DOESNT_MATCH; extern const int INSECURE_PATH; extern const int LOGICAL_ERROR; + extern const int NETWORK_ERROR; extern const int S3_ERROR; extern const int ZERO_COPY_REPLICATION_ERROR; } @@ -78,12 +91,78 @@ constexpr auto REPLICATION_PROTOCOL_VERSION_WITH_PARTS_ZERO_COPY = 6; constexpr auto REPLICATION_PROTOCOL_VERSION_WITH_PARTS_PROJECTION = 7; constexpr auto REPLICATION_PROTOCOL_VERSION_WITH_METADATA_VERSION = 8; constexpr auto REPLICATION_PROTOCOL_VERSION_WITH_COLUMNS_SUBSTREAMS = 9; +/// CAS replication 2b: fetch-by-relink. The receiver advertises its content-addressed pool identity +/// (`cas_pool_uuid`) and, if it matches the sender's own pool, the sender sends only the +/// part's content id (`part_id`) + the mutable header — no file bytes — and the receiver "fetches" by +/// publishing its own ref to the blobs already present in the shared pool (the CA analogue of the +/// zero-copy metadata-only fetch). Everything is gated behind a matching pool_uuid, so a non-CA fetch +/// is byte-for-byte unchanged. +/// Kept although nothing gates on it any more: the offer gate moved to `..._WITH_CA_CONFIRM` below, but +/// 10 is a version peers still advertise, and deleting the record of what it meant would leave the next +/// reader unable to tell what an incoming 10 promises (a relink it will NOT confirm). +[[maybe_unused]] constexpr auto REPLICATION_PROTOCOL_VERSION_WITH_CA_RELINK = 10; +/// CAS replication, publish-then-confirm (spec §wire-protocol). A relink offer is now accompanied by a +/// source token, and the endpoint answers a second, part-less request that asks whether that token is +/// still exactly what the sender's ref names. A server advertising this version serves the confirm +/// action; a receiver advertising it must confirm before it promotes. +constexpr auto REPLICATION_PROTOCOL_VERSION_WITH_CA_CONFIRM = 11; std::string getEndpointId(const std::string & node_id) { return "DataPartsExchange:" + node_id; } +/// CAS replication 2b. The receiver advertises its target pool's identity under this request param so +/// the sender can decide whether a fetch-by-relink (same pool) is possible. +constexpr auto CA_POOL_UUID_PARAM = "cas_pool_uuid"; +/// Set on the response when the sender chose the relink path; the receiver then reads the relink payload +/// (the opaque encoded PartManifest body — self-contained, see part_manifest_v2 below) instead of the +/// byte stream. +constexpr auto CA_RELINK_COOKIE = "cas_relink"; +/// All-tree task 7: the manifest is now self-contained (uuid.txt/metadata_version.txt are ordinary +/// manifest entries, task 6), so the wire payload dropped its trailing metadata_version field (the +/// manifest bytes are now the ONLY field). Bumped from `part_manifest_v1` so a mixed-build pair (old +/// sender, new receiver) does not try to parse the old two-field payload under the new one-field shape +/// — the receiver rejects a cookie value it does not recognize and falls back to a byte fetch instead +/// of desyncing on the wire format. +constexpr auto CA_RELINK_COOKIE_VALUE = "part_manifest_v2"; + +/// CAS fetch-by-relink, publish-then-confirm (spec §wire-protocol). Three names make up the second +/// request of the handshake. +/// +/// The request parameter both selects the confirm action and carries its only argument: the opaque +/// source token the sender minted for the offer. There is no separate action flag, because an action +/// without its token is not a question anyone can answer, and a token without the action would have to +/// be ignored — one name cannot be half-present. +constexpr auto CA_CONFIRM_ACTION_PARAM = "cas_confirm"; +/// Response cookie on the relink offer: the token, opaque to the receiver, echoed back verbatim. +constexpr auto CA_CONFIRM_TOKEN_COOKIE = "cas_source_token"; +/// Response cookie on the confirm: the answer. +constexpr auto CA_CONFIRM_ANSWER_COOKIE = "cas_confirm_answer"; +/// The ONLY value that authorizes the receiver to promote. +constexpr auto CA_CONFIRM_ANSWER_PROVEN = "yes"; +/// Everything else: the source did not prove the binding. The wire vocabulary is deliberately BINARY +/// even though `CasConfirmAnswer` has three values. `No` and `Unknown` are one outcome for every caller +/// (see `CasConfirmAnswer`): gate 1 evaluates the mount fence LAST, so a mount that has already lost +/// its fence — and can no longer speak for the namespace at all — still answers `No` for a token that +/// does not match its last-known row. Putting `no` on the wire as a distinct value would invite a +/// receiver to act on it as knowledge, and it is not knowledge. The distinction is diagnostic only, so +/// it is logged on the sender, where the gate that produced it can be named, and never transmitted. +/// An ABSENT cookie reads as unproven too, which is what makes an older peer and a failed request the +/// same safe outcome as a refusal. +constexpr auto CA_CONFIRM_ANSWER_UNPROVEN = "unproven"; + +/// Resolve a disk to the content-addressed exchange facade, or nullptr if the disk is not CA. The +/// cast targets the purpose-built INTERFACE (IContentAddressedExchange), never the concrete +/// metadata-storage class (M-W design section 4). Used by both the relink sender (the part's +/// disk) and the relink receiver (the target disk). +IContentAddressedExchange * tryGetContentAddressedExchange(const DiskPtr & disk) +{ + if (!disk || !disk->isContentAddressed()) + return nullptr; + return dynamic_cast(disk->getMetadataStorage().get()); +} + /// Simple functor for tracking fetch progress in system.replicated_fetches table. struct ReplicatedFetchReadCallback { @@ -130,8 +209,108 @@ std::string Service::getId(const std::string & node_id) const return getEndpointId(node_id); } +CasConfirmAnswer Service::resolveContentAddressedConfirm( + const String & pool_uuid, + const String & server_root_id, + const String & root_namespace, + const String & ref_name, + const String & part_name, + const String & manifest_ref_text) const +{ + /// CAS fetch-by-relink, publish-then-confirm (spec §confirm-primitive). The receiver's own `+1` is + /// already durable when this runs; a `Yes` is what authorizes it to promote a part whose blobs are + /// protected only by THIS server's committed binding of that exact manifest. Every field below comes + /// from a remote peer, so nothing here is trusted beyond being used as a lookup key. + if (pool_uuid.empty() || server_root_id.empty() || root_namespace.empty() || ref_name.empty() || part_name.empty()) + return CasConfirmAnswer::Unknown; + + /// Routing. A pool UUID identifies the shared pool, not the mount: every server root writing into it + /// reports the same one, so the namespace's owner decides which instance may answer. EXACTLY one + /// match is required — zero means this table has no such disk, several mean the question is + /// ambiguous, and both are `Unknown` rather than a guess. + const IContentAddressedExchange * matched = nullptr; + DiskPtr matched_disk; + for (const auto & disk : data.getDisks()) + { + const auto * ca_meta = tryGetContentAddressedExchange(disk); + if (!ca_meta || ca_meta->getPoolUUID() != pool_uuid || !ca_meta->ownsNamespace(server_root_id, root_namespace)) + continue; + if (matched) + return CasConfirmAnswer::Unknown; + matched = ca_meta; + matched_disk = disk; + } + if (!matched) + return CasConfirmAnswer::Unknown; + + /// Gate 0 — the part-anchored fast filter. It is an AVAILABILITY filter and never a proof (spec + /// §confirm-primitive, demoted in rev.5): `rollbackDeletingParts` puts a part back to `Outdated` + /// after a failed filesystem removal, and the in-memory part path is deliberately not updated by a + /// `delete_tmp_*` rename, so an `Active`/`Outdated` part object authorizes nothing. What it buys is + /// a cheap `No` that costs no ledger work; every `Yes` is earned by gate 1 alone. + /// + /// `Deleting` is excluded by the state filter, an unknown name yields no part at all, and a part of + /// this name living on ANOTHER disk is rejected explicitly — `MOVE ... TO DISK` leaves a same-name + /// `Active` part behind on the destination disk, and only the instance the token routed to may be + /// the one the confirm is about. The parts set is read under its own lock, which + /// `getPartIfExists` takes and releases, and the part reference is dropped before any ledger lock. + { + const auto part_info = MergeTreePartInfo::tryParsePartName(part_name, data.format_version); + if (!part_info) + return CasConfirmAnswer::Unknown; + const auto part = data.getPartIfExists( + *part_info, {MergeTreeDataPartState::Active, MergeTreeDataPartState::Outdated}); + if (!part || part->getDataPartStorage().getDiskName() != matched_disk->getName()) + return CasConfirmAnswer::No; + } + + /// Gate 1 — authoritative, and the only source of a `Yes`. + return matched->confirmExactRef(root_namespace, ref_name, manifest_ref_text); +} + +void Service::answerContentAddressedConfirm(const String & token_text, HTTPServerResponse & response) const +{ + /// The confirm action's whole handler. It reads no part parameter, sends no body, and touches no + /// send metric: the request asks a question about a binding, it does not transfer anything. + const auto token = decodeCasRelinkSourceToken(token_text); + if (!token) + { + /// The raw text is NOT logged: it is unvalidated peer bytes, and a decoded token is the only + /// form this server has established is free of the control characters that forge log lines. + LOG_DEBUG(log, "Relink confirm is unproven: the source token ({} bytes) is not one this server minted", + token_text.size()); + response.addCookie({CA_CONFIRM_ANSWER_COOKIE, CA_CONFIRM_ANSWER_UNPROVEN}); + return; + } + + const CasConfirmAnswer answer = resolveContentAddressedConfirm( + token->pool_uuid, token->server_root_id, token->root_namespace, + token->ref_name, token->part_name, token->manifest_ref_text); + + /// The `No`/`Unknown` distinction stays here, on the node that computed it and can name the binding + /// that produced it. It is triage information, not an authorization, and the wire carries only the + /// authorization (`CA_CONFIRM_ANSWER_UNPROVEN`). + if (answer != CasConfirmAnswer::Yes) + LOG_DEBUG(log, "Relink confirm is unproven ({}) for ref '{}' (part {}, manifest {}) in namespace '{}'", + answer == CasConfirmAnswer::No ? "no" : "unknown", + token->ref_name, token->part_name, token->manifest_ref_text, token->root_namespace); + + response.addCookie({CA_CONFIRM_ANSWER_COOKIE, + answer == CasConfirmAnswer::Yes ? CA_CONFIRM_ANSWER_PROVEN : CA_CONFIRM_ANSWER_UNPROVEN}); +} + void Service::processQuery(const HTMLForm & params, ReadBufferPtr body, WriteBuffer & out, HTTPServerResponse & response) { + /// CAS fetch-by-relink, publish-then-confirm (spec §wire-protocol): the second request of the + /// handshake, dispatched before `part` is required because a confirm carries none — the part name + /// is inside the token. Authentication parity with the fetch is inherent: the shared handler + /// authenticates before it dispatches to any endpoint. + if (const String confirm_token = params.get(CA_CONFIRM_ACTION_PARAM, ""); !confirm_token.empty()) + { + answerContentAddressedConfirm(confirm_token, response); + return; + } + // nothing to read from body body.reset(); @@ -145,7 +324,7 @@ void Service::processQuery(const HTMLForm & params, ReadBufferPtr body, WriteBuf MergeTreePartInfo::fromPartName(part_name, data.format_version); /// We pretend to work as older server version, to be sure that client will correctly process our version - response.addCookie({"server_protocol_version", toString(std::min(client_protocol_version, REPLICATION_PROTOCOL_VERSION_WITH_COLUMNS_SUBSTREAMS))}); + response.addCookie({"server_protocol_version", toString(std::min(client_protocol_version, REPLICATION_PROTOCOL_VERSION_WITH_CA_CONFIRM))}); LOG_TRACE(log, "Sending part {}", part_name); @@ -209,6 +388,51 @@ void Service::processQuery(const HTMLForm & params, ReadBufferPtr body, WriteBuf writeBinary(projections.size(), out); } + /// CAS replication 2b — fetch-by-relink (spec §4). If the part is on a content-addressed disk and + /// the receiver advertised a `cas_pool_uuid` equal to THIS server's own pool_uuid + /// (same shared pool), send only the part's content id + the mutable header — no file bytes — so + /// the receiver can "fetch" by publishing its own ref to the blobs already in the shared pool. + /// Strictly gated on a matching pool_uuid: a non-CA part, a CA part on a different pool, or a + /// receiver without the capability all fall through to the unchanged byte path below. + /// + /// The gate is `..._WITH_CA_CONFIRM`, not `..._WITH_CA_RELINK`: a receiver is offered a relink + /// only once it advertises that it will confirm the offer before promoting it. A receiver that + /// still advertises `..._WITH_CA_RELINK` gets the bytes — mixed versions degrade to bytes, never + /// to an unconfirmed relink. This gate and the version the client advertises + /// (`fetchSelectedPart`) are one change in two places; separated in either order they either + /// disable relink outright or hand an unconfirmed relink to a receiver that claimed it confirms. + if (client_protocol_version >= REPLICATION_PROTOCOL_VERSION_WITH_CA_CONFIRM + && part->getDataPartStorage().isContentAddressed()) + { + const String receiver_pool_uuid = parse(params.get(CA_POOL_UUID_PARAM, "")); + DiskPtr part_disk = data.getStoragePolicy()->tryGetDiskByName(part->getDataPartStorage().getDiskName()); + auto * ca_meta = tryGetContentAddressedExchange(part_disk); + if (ca_meta && !receiver_pool_uuid.empty() && receiver_pool_uuid == ca_meta->getPoolUUID()) + { + auto offer = ca_meta->getRelinkOffer(part->getDataPartStorage().getRelativePath()); + if (offer) + { + LOG_DEBUG(log, "Sending part {} by relink (content-addressed, shared pool {}), manifest payload {} bytes", + part_name, receiver_pool_uuid, offer->manifest_bytes.size()); + response.addCookie({CA_RELINK_COOKIE, CA_RELINK_COOKIE_VALUE}); + /// The source token for the confirm request the receiver makes before it promotes + /// (spec §wire-protocol). It always accompanies the offer, and its ABSENCE is what + /// tells a confirm-capable receiver that this sender predates the handshake. + response.addCookie({CA_CONFIRM_TOKEN_COOKIE, offer->confirm_token}); + /// The relink payload (B7 part_manifest_v2, all-tree task 7): the opaque encoded + /// PartManifest body (the receiver decodes it, ignores the sender identity, and + /// stages its OWN local manifest over the shared-pool blobs; the legacy part_id wire + /// field carries it). Self-contained: uuid.txt/metadata_version.txt are ordinary + /// manifest entries now (task 6), so no separate mutable-header field is sent. + writeStringBinary(offer->manifest_bytes, out); + data.addLastSentPart(part->info); + return; + } + /// No offer (no committed ref for this part here, or no mintable token) — fall through + /// to the byte path. + } + } + if ((*data_settings)[MergeTreeSetting::allow_remote_fs_zero_copy_replication] && client_protocol_version >= REPLICATION_PROTOCOL_VERSION_WITH_PARTS_ZERO_COPY) { @@ -421,7 +645,8 @@ std::pair Fetcher::fetchSelected const String & tmp_prefix_, std::optional * tagger_ptr, bool try_zero_copy, - DiskPtr disk) + DiskPtr disk, + bool allow_ca_relink) { if (blocker.isCancelled()) throw Exception(ErrorCodes::ABORTED, "Fetching of part was cancelled"); @@ -454,13 +679,51 @@ std::pair Fetcher::fetchSelected { {"endpoint", endpoint_id}, {"part", part_name}, - {"client_protocol_version", toString(REPLICATION_PROTOCOL_VERSION_WITH_COLUMNS_SUBSTREAMS)}, + /// Advertising `..._WITH_CA_CONFIRM` is a PROMISE, not a capability list: this receiver will + /// confirm a relink offer against its source before it promotes (`relinkPartToDisk`). It is the + /// pair of the sender-side offer gate on the same constant, and the two cannot be separated — + /// see the comment there. + {"client_protocol_version", toString(REPLICATION_PROTOCOL_VERSION_WITH_CA_CONFIRM)}, {"compress", "false"} }); if (disk) LOG_TRACE(log, "Will fetch to disk {} with type {}", disk->getName(), disk->getDataSourceDescription().toString()); + /// CAS replication 2b — fetch-by-relink (spec §4). Advertise this replica's target content-addressed + /// pool identity so a same-pool sender can relink instead of streaming bytes. The target disk is the + /// provided one if it is CA, else the first CA disk among the table's disks. A non-CA fetch adds + /// nothing here and is byte-for-byte unchanged. + /// Gated on `allow_ca_relink` alone (B66b). That flag is the RECURSION BRAKE and nothing else: not + /// advertising is what makes the sender stream bytes, so every same-sender byte re-request below + /// clears it, and a persistent relink-mechanism failure therefore costs exactly one relink attempt. + /// The gate used to be `try_zero_copy && !to_detached`, and BOTH halves were accidents of that same + /// brake — `try_zero_copy` because the fallback re-requests with it false, and `!to_detached` + /// because the relink path staged at the ACTIVE part path and ignored `to_detached`. `to_detached` + /// is now a parameter of `relinkPartToDisk` (it stages under the `detached/` parent), and + /// `try_zero_copy` goes back to meaning real zero-copy only. + String advertised_pool_uuid; + if (allow_ca_relink) + { + if (auto * ca_meta = tryGetContentAddressedExchange(disk)) + { + advertised_pool_uuid = ca_meta->getPoolUUID(); + uri.addQueryParameter(CA_POOL_UUID_PARAM, advertised_pool_uuid); + } + else if (!disk) + { + for (const auto & data_disk : data.getDisks()) + { + if (auto * ca_disk_meta = tryGetContentAddressedExchange(data_disk)) + { + advertised_pool_uuid = ca_disk_meta->getPoolUUID(); + uri.addQueryParameter(CA_POOL_UUID_PARAM, advertised_pool_uuid); + break; + } + } + } + } + Strings capability; if (try_zero_copy && (*data_settings)[MergeTreeSetting::allow_remote_fs_zero_copy_replication]) { @@ -615,6 +878,78 @@ std::pair Fetcher::fetchSelected if (server_protocol_version >= REPLICATION_PROTOCOL_VERSION_WITH_PARTS_PROJECTION) readBinary(projections, *in); + /// CAS replication 2b — fetch-by-relink (spec §4; B7 part_manifest_v2, all-tree task 7). The sender + /// chose to relink: it sent only the part's encoded PartManifest body, no file bytes. Build the part + /// by staging this server's OWN local manifest over the blobs already in the shared pool (adopt-by-hash + /// -> revalidate -> promote inside adoptPartFromManifest) — self-contained since task 6 routed + /// uuid.txt/metadata_version.txt through the content path, so there is no separate mutable header to + /// reconstruct. If the relink is not possible (blob missing/condemned — a transient or a + /// genuinely-different pool the cheap pre-filter let through, or a mixed-build pair offering an + /// unrecognized cookie value), fall back to a normal byte fetch by re-requesting WITHOUT relink. + String ca_relink = parse(in->getResponseCookie(CA_RELINK_COOKIE, "")); + if (!ca_relink.empty()) + { + /// Re-request without the relink capability: pass the SAME (CA) disk but disable zero-copy/relink + /// so the sender streams bytes; on CA the downloaded files content-address and dedup. + /// + /// THE RECURSION BRAKE (B66b). `allow_ca_relink=false` is what bounds this: the re-request does + /// not advertise the pool identity, so the sender cannot offer relink again, so this lambda + /// cannot be reached a second time for the same fetch. Before relink had its own capability the + /// brake was implicit in `try_zero_copy=false`; with the two decoupled it has to be spelled out, + /// and it must be spelled out at EVERY same-sender fallback — a relink failure that is a + /// property of the pair reproduces on every attempt, so without the brake the fallback re-offers + /// and recurses without bound. The failures it actually bounds are the ones that leave the CA + /// disk resolved and matching: a mixed build offering an unrecognized cookie value, a sender that + /// predates the confirm handshake, an undecodable manifest, a local ref conflict. (The + /// reservation-outside-the-pool exit below is bounded twice over — it re-requests with the + /// non-CA disk it resolved, which cannot advertise anything either way — so do not read that one + /// as evidence that the brake is redundant.) + auto fall_back_to_byte_fetch = [&] + { + temporary_directory_lock = {}; + return fetchSelectedPart( + metadata_snapshot, context, part_name, zookeeper_name, replica_path, host, port, timeouts, + user, password, interserver_scheme, throttler, to_detached, tmp_prefix, nullptr, false, disk, + /*allow_ca_relink=*/ false); + }; + + if (ca_relink != CA_RELINK_COOKIE_VALUE) + { + /// Mixed-build cluster (rolling upgrade): this receiver build does not recognize the sender's + /// relink wire format. Bail out before reading anything else off the stream rather than + /// misparsing an incompatible payload shape. + LOG_INFO(log, "Part {} was offered by relink with cookie '{}' (this build expects '{}'); " + "falling back to a byte fetch", part_name, ca_relink, CA_RELINK_COOKIE_VALUE); + return fall_back_to_byte_fetch(); + } + + auto * chosen_ca = tryGetContentAddressedExchange(disk); + if (!chosen_ca || chosen_ca->getPoolUUID() != advertised_pool_uuid) + { + LOG_INFO(log, "Part {} was offered by relink for content-addressed pool '{}', but reservation landed " + "outside the advertised pool on disk {} (chosen pool: '{}'); falling back to a byte fetch", + part_name, advertised_pool_uuid, disk->getName(), chosen_ca ? chosen_ca->getPoolUUID() : ""); + return fall_back_to_byte_fetch(); + } + + String sender_manifest_bytes; + readStringBinary(sender_manifest_bytes, *in); + assertEOF(*in); + + /// Publish-then-confirm (spec §core-idea) happens inside `relinkPartToDisk`, including the second + /// interserver request; the token cookie is the sender's offer identity and is opaque here. A + /// `nullptr` means the mechanism cannot work but the sender still has the part, so the byte + /// re-request below is sound; a THROW means the source did not prove the binding, and the whole + /// point of it being a throw is that this fallback must NOT run for it. + auto relinked = relinkPartToDisk(part_name, tmp_prefix, disk, to_detached, sender_manifest_bytes, + in->getResponseCookie(CA_CONFIRM_TOKEN_COOKIE, ""), uri, creds, timeouts, read_settings); + if (relinked) + return std::make_pair(std::move(relinked), std::move(temporary_directory_lock)); + + LOG_INFO(log, "Relink of part {} is not possible on this pair; falling back to a byte fetch", part_name); + return fall_back_to_byte_fetch(); + } + if (!remote_fs_metadata.empty()) { if (!try_zero_copy) @@ -656,7 +991,10 @@ std::pair Fetcher::fetchSelected temporary_directory_lock = {}; - /// Try again but without zero-copy + /// Try again but without zero-copy. `allow_ca_relink=false` for the same reason as the relink + /// branch's fallback above: this is a same-sender byte re-request, and it must not re-open a + /// capability the failed attempt is not evidence about. It also preserves the behaviour this + /// call had while relink rode on `try_zero_copy` — the flag it already passes as false. return fetchSelectedPart( metadata_snapshot, context, @@ -666,7 +1004,8 @@ std::pair Fetcher::fetchSelected host, port, timeouts, - user, password, interserver_scheme, throttler, to_detached, tmp_prefix, nullptr, false, disk); + user, password, interserver_scheme, throttler, to_detached, tmp_prefix, nullptr, false, disk, + /*allow_ca_relink=*/ false); } } @@ -949,6 +1288,312 @@ MergeTreeData::MutableDataPartPtr Fetcher::downloadPartToDisk( return new_data_part; } +/// The receiver's half of publish-then-confirm, and its complete failure taxonomy (spec +/// §failure-taxonomy). Every exit of `relinkPartToDisk` is one of these seven rows; the last two columns +/// are the questions a reviewer has to be able to answer without reading the control flow, because a +/// part-exchange path that gets them wrong either loses a part or commits it twice. +/// +/// 1. THE SOURCE SENT NO TOKEN (a peer that predates the handshake). +/// `+1`: never staged. Action: return `nullptr`, the caller byte-fetches from the same sender. +/// Lose a part? No -- the sender still has it and streams it. +/// Double-promote? No -- nothing was staged, so there is nothing to promote. +/// +/// 2. `prepareAdoptFromManifest` -> `MechanismFallbackAllowed` (manifest decode failure, or the +/// retryable staging class: body-absent precommit / precommit no longer the live owner / ref +/// conflict). +/// `+1`: NOTHING IS PUBLISHED, and that -- not "never staged" -- is what makes the byte fallback +/// sound here. A precommit whose ref-log append came back `Unresolved` may in fact be durable, so +/// `prepareEntries`' own `abandon` queues the exact removal for it (`PartWriteTxn::precommitAdd` +/// records the intent BEFORE the append precisely so that removal is never skipped) and leaves the +/// manifest body for GC rather than deleting it. A precommit is not a committed ref: a later byte +/// fetch publishes the same ref name over it without conflict, and a removal that could not be +/// appended at all leaks retained blobs -- it never double-publishes. +/// Action: return `nullptr`, the caller byte-fetches. Lose a part? No, as row 1. +/// Double-promote? No -- no handle exists, and nothing was committed. +/// +/// 3. THE CONFIRM DID NOT PROVE THE SOURCE: an `unproven` answer, an absent answer cookie, a transport +/// failure, a timeout. All one outcome, deliberately (`CasConfirmAnswer`: only `yes` authorizes). +/// `+1`: durable, then released by `abort`. Action: THROW a locally generated retry-later +/// `NETWORK_ERROR` naming the source and the part -- never `nullptr`, because a byte re-request goes +/// back to the very source whose state is in doubt. +/// Lose a part? No -- the queue stores the exception, backs off, and re-executes the entry, which +/// recomputes the source and the covering-part discovery. The fetch is postponed, not dropped. +/// Double-promote? No -- `abort` appends the exact precommit removal and no committed ref exists. +/// +/// 4. CONFIRM `yes`, `promote` -> `Committed`. +/// `+1`: committed. Action: return the relinked part; the usual `tmp-fetch_` re-key follows. +/// Lose a part? No. Double-promote? No -- `promote` is the handle's single terminal operation, the +/// handle is released immediately after it, and a second call is rejected rather than re-driving a +/// finished transaction. +/// +/// 5. CONFIRM `yes`, `promote` -> `MechanismFallbackAllowed` (a local ref conflict; the source proved +/// its side, this receiver could not commit its own). The promote was rejected BEFORE its ref-log +/// append, so "nothing was committed" is proven, not assumed -- see row 5b for the case where it is +/// not. +/// `+1`: released -- a failed `promote` abandons its build on the way out. +/// Action: return `nullptr`, the caller byte-fetches. Lose a part? No, as row 1. +/// Double-promote? No -- the byte fetch starts from a clean slate. +/// +/// 5b. CONFIRM `yes`, `promote` -> `Unresolved` (the promotion's ref-log append was attempted and came +/// back without a verdict; the receiver's ref MAY be committed). +/// `+1`: still owed -- the handle attempts its abandon, which is REJECTED by the state machine if +/// the promote in fact landed (a promoted binding is no longer a precommit), so no committed ref is +/// ever undone here. +/// Action: THROW the retry-later `NETWORK_ERROR`, as row 3 -- returning `nullptr` is the one thing +/// that must not happen, because a byte fetch would publish the part a SECOND time over a relink +/// that may already be committed. +/// Lose a part? No -- retry-later, as row 3. Double-promote? No -- nothing is published on this exit. +/// +/// 6. ANY OTHER EXCEPTION (an unclassified local error, or a `promote` failure outside the known +/// retryable class). +/// `+1`: durable if one was staged, then released by the scope guard. Action: propagate. +/// Lose a part? No -- retry-later, exactly as row 3. Double-promote? No -- the scope guard runs +/// `abort` before the exception leaves the function, and the handle's own destructor is the backstop +/// if that abort's append fails. +/// +/// The asymmetry between rows 2/5 and row 3 is the entire point of the typed boundary. A byte +/// re-request goes back to the SAME sender, so it is a sound recovery exactly when the doubt is about +/// the MECHANISM and the sender is known to still hold the part -- and never when the doubt is about +/// the source itself. `adoptPartFromManifest` used to collapse the two by catching every `Exception` +/// and returning `false`. +/// +/// B66b — WHAT CHANGES WHEN THE TARGET IS `detached/`. Every row above still holds, and the two columns +/// that matter are unchanged in every one of them, but two rows hold for a DIFFERENT reason and that +/// difference is worth stating rather than rediscovering: +/// +/// - Row 3 (and row 6, which recovers the same way) argues "no part is lost" from the replication queue: +/// it stores the exception, backs off, and re-executes the entry. Two of the three detached callers +/// have no queue entry -- `FETCH PARTITION`/`FETCH PART ... FROM` are user DDL -- so the retry-later +/// error surfaces to the user, who re-issues the statement. Nothing is lost either way, and for a +/// stronger reason than in the active case: a detached fetch is not replication, so no replicated +/// state was ever expecting the part. (The third, `executeClonePartFromShard`, IS a queue entry and +/// recovers exactly as the active path does.) +/// - Row 4's "no double-promote" is about the relink's own terminal operation and is unaffected. What +/// the CALLER then does with the part differs: `renameTo(detached/, true)` rather than +/// `renameTempPartAndReplace`. Both are ref repoints within one namespace on a content-addressed disk +/// (`detached/` is a ref-name prefix, not a namespace), and the detached one keeps its existing +/// collision behaviour -- an existing `detached/` is displaced. That is the pre-existing +/// semantic of a detached BYTE fetch, deliberately left alone: relink must not change what a fetch +/// into `detached/` means, only how the bytes get there. +/// +/// The staged ref itself is `detached/tmp-fetch_` rather than `tmp-fetch_`, which is what +/// keeps a failed detached relink from ever being visible as a live part: the abandoned precommit and +/// the abandoned staging directory both live in the detached ref space. +/// +/// What a `yes` does NOT prove: `CaRelinkConfirmCore.tla` config `_sab_holeylist` shows that with every +/// confirm rule intact and one incomplete listing page permitted, `ConfirmedRelinkNeverDangles` still +/// breaks (BACKLOG `{#list-as-journal-dataloss-2026-07-25}`). A confirmed relink is therefore NOT proven +/// dangle-free; a `yes` means only "the source still holds exactly this manifest right now", which is +/// what closes the codex-6 handoff window and nothing more. +MergeTreeData::MutableDataPartPtr Fetcher::relinkPartToDisk( + const String & part_name, + const String & tmp_prefix, + DiskPtr disk, + bool to_detached, + const String & sender_manifest_bytes, + const String & source_token, + const Poco::URI & fetch_uri, + const Poco::Net::HTTPBasicCredentials & credentials, + const ConnectionTimeouts & timeouts, + const ReadSettings & read_settings) +{ + auto * ca_meta = tryGetContentAddressedExchange(disk); + if (!ca_meta) + throw Exception(ErrorCodes::LOGICAL_ERROR, + "relinkPartToDisk called for a non-content-addressed disk {}", disk->getName()); + + if (tmp_prefix.empty() + || part_name.empty() + || std::string::npos != tmp_prefix.find_first_of("/.") + || std::string::npos != part_name.find_first_of("/.")) + throw Exception(ErrorCodes::LOGICAL_ERROR, "`tmp_prefix` and `part_name` cannot be empty or contain '.' or '/' characters."); + + /// Taxonomy row 1 — the capability gate, and it comes FIRST so a pre-confirm sender costs nothing. + /// An absent token is how such a sender identifies itself: it offers relink exactly as before and + /// simply has no token cookie to attach. There is no version number to consult here and that is + /// deliberate — the sender's advertised version says what it can serve, while the token's presence + /// says what it actually did for THIS offer, and only the latter can be confirmed. A relink that + /// cannot be confirmed is never promoted, so the bytes are fetched instead. + if (source_token.empty()) + { + LOG_INFO(log, "Part {} was offered by relink without a source token, so the offer cannot be confirmed " + "(the sender predates the publish-then-confirm handshake); falling back to a byte fetch", part_name); + return nullptr; + } + + /// Test-only. Forces the "mechanism failed, the sender still has the part" exit (the ACTION of + /// taxonomy rows 2 and 5) on EVERY attempt, which is precisely the shape the recursion brake has to + /// bound: a persistent property of this sender/receiver pair, so the byte re-request re-offers and + /// re-fails unless it clears `allow_ca_relink`. It fires AFTER the token gate and BEFORE + /// `prepareAdoptFromManifest`, so nothing is staged and no `+1` has to be released — the failpoint + /// injects the exit, never a half-finished transaction. + fiu_do_on(FailPoints::cas_relink_receiver_force_mechanism_failure, + { + LOG_INFO(log, "Failpoint cas_relink_receiver_force_mechanism_failure: abandoning the relink of part {} " + "before anything is staged", part_name); + return nullptr; + }); + + /// Stage under the tmp-fetch dir OF THE TARGET PARENT — the table dir, or `TABLE/detached` when + /// the caller asked for a detached fetch (B66b). The parent is composed exactly as + /// `downloadPartToDisk` composes it, so the two fetch paths put a part in the same place and the + /// caller's finalization is unchanged: `renameTempPartAndReplace`'s moveDirectory(tmp-fetch_ + /// -> ) for the active path, `renameTo(detached/)` for the detached one. Both are ref + /// repoints within one namespace on a content-addressed disk (`detached/` is a ref-name prefix, not + /// a namespace), so a relinked part re-keys exactly as a byte-fetched one does. + /// + /// The ref name is NOT built here: the disk-relative path is handed to the CA exchange whole and its + /// router folds `TABLE/detached/DIR` onto the `detached/DIR` ref, the same routing every other + /// read and write of a detached part goes through. This side has no business knowing that prefix, + /// and the sender's half of the offer (`getRelinkOffer`) is already addressed by path too. + const String part_dir = tmp_prefix + part_name; + const String part_relative_path + = data.getRelativeDataPath() + String(to_detached ? MergeTreeData::DETACHED_DIR_NAME : ""); + const String part_path = fs::path(part_relative_path) / part_dir; + + LOG_DEBUG(log, "Relinking part {} (staged as {}) onto content-addressed disk {} from a {}-byte transferred manifest.", + part_name, part_path, disk->getName(), sender_manifest_bytes.size()); + + /// T1 — PUBLISH. Adopt-from-manifest and precommit, stopping short of the promote (B7 + /// part_manifest_v2, all-tree task 7): the receiver decodes the transferred body and stages its OWN + /// local manifest over the shared-pool blobs (adopt-by-hash). Self-contained: + /// uuid.txt/metadata_version.txt are ordinary entries in the transferred manifest (task 6), so there + /// is no sidecar to reconstruct. Trust boundary is the interserver channel, as for a normal part + /// fetch — see `prepareAdoptFromManifest`. + /// + /// The order is the whole protocol. This `+1` must be DURABLE before the source is asked anything, + /// because the question "do you still hold it?" only excludes a later removal if the receiver's own + /// reference is already in the ref log when that removal is appended (spec §correctness). Asking + /// first and publishing after would prove nothing about the interval in between. What it does NOT + /// establish is that every subsequent GC fold OBSERVES that reference -- see "What a `yes` does NOT + /// prove" above; ordering is necessary here, not sufficient. + std::unique_ptr prepared; + if (ca_meta->prepareAdoptFromManifest(part_path, sender_manifest_bytes, prepared) + == CaRelinkPrepare::MechanismFallbackAllowed) + return nullptr; /// taxonomy row 2 + if (!prepared) + throw Exception(ErrorCodes::LOGICAL_ERROR, + "Relink of part {} reported a prepared write but produced no handle", part_name); + + /// Belt-and-braces over the handle's own destructor: the durable `+1` is released on EVERY exit that + /// is not a completed promote, including an exception. `abort` is the non-throwing form by contract — + /// this runs while the retry-later error of row 3 is already in flight. + SCOPE_EXIT({ + if (prepared) + prepared->abort(); + }); + + /// Test-only, and this is the ONE seam worth injecting on the whole path: it opens the window the + /// protocol exists to make safe. The receiver's `+1` is durable and its release is armed, and the + /// source has not been asked anything yet, so a test that holds the fetch here can do to the source + /// exactly what codex-6 described — merge the part away, run GC to fixpoint — and then observe both + /// halves of the contract: the source's blobs survive the round (this receiver's binding protects + /// them) and the confirm that follows refuses to authorize a promote (the binding it named is gone). + FailPointInjection::pauseFailPoint(FailPoints::cas_relink_receiver_pause_before_confirm); + + /// T2 — CONFIRM. One read-only interserver question, aimed at the endpoint copied out of the fetch + /// URI so it reaches exactly the table and replica that made the offer. Only the literal + /// `CA_CONFIRM_ANSWER_PROVEN` cookie authorizes a promote: an `unproven` answer, an absent cookie + /// (any peer that does not implement the action) and a failed request are ONE outcome, and it is not + /// knowledge about the source — see `CasConfirmAnswer` on why `no` is never put on the wire. + bool source_proved_the_binding = false; + try + { + Poco::URI confirm_uri; + confirm_uri.setScheme(fetch_uri.getScheme()); + confirm_uri.setHost(fetch_uri.getHost()); + confirm_uri.setPort(fetch_uri.getPort()); + Poco::URI::QueryParameters confirm_params; + for (const auto & fetch_param : fetch_uri.getQueryParameters()) + if (fetch_param.first == "endpoint") + confirm_params.push_back(fetch_param); + confirm_params.emplace_back(CA_CONFIRM_ACTION_PARAM, source_token); + confirm_params.emplace_back("compress", "false"); + confirm_uri.setQueryParameters(confirm_params); + + /// `read_settings` is the caller's, which already caps HTTP retries at one: the queue owns the + /// retry policy for a fetch, and a silently retried confirm would widen the window it measures. + auto confirm_in = BuilderRWBufferFromHTTP(confirm_uri) + .withConnectionGroup(HTTPConnectionGroupType::HTTP) + .withBypassProxy(true) + .withMethod(Poco::Net::HTTPRequest::HTTP_POST) + .withTimeouts(timeouts) + .withSettings(read_settings) + .withDelayInit(false) + .create(credentials); + /// The confirm answer is a cookie and the response body is empty by construction. Requiring EOF + /// before reading the answer means a response carrying anything at all — a misrouted reply, a + /// desynchronized peer — is unproven rather than half-parsed. + assertEOF(*confirm_in); + source_proved_the_binding + = confirm_in->getResponseCookie(CA_CONFIRM_ANSWER_COOKIE, "") == CA_CONFIRM_ANSWER_PROVEN; + } + catch (...) + { + /// Not a fallback: a confirm that could not be delivered is the same "not proven" as a refusal, + /// and it takes the same path out. Logged rather than propagated so the error the caller sees is + /// the one that names the relink — but logged in full, because the reason (refused, timed out, + /// 500) exists nowhere else. `information`, not `error`: a peer restarting mid-fetch is ordinary, + /// and the throw below is what makes the failure loud. + tryLogCurrentException(log, fmt::format("while confirming the relink offer for part {} with {}", + part_name, fetch_uri.getHost()), LogsLevel::information); + source_proved_the_binding = false; + } + + if (!source_proved_the_binding) + { + /// Taxonomy row 3. Locally generated on purpose — nothing here is the source's error to report — + /// and thrown rather than returned, because the one recovery that is NOT sound after this is a + /// byte re-request to the same source. `NETWORK_ERROR` puts it in the retry-later class, so the + /// queue stores it, backs off, and re-selects on re-execution. + throw Exception(ErrorCodes::NETWORK_ERROR, + "Source {} did not prove it still holds the manifest it offered for part {} by relink; " + "the relink is abandoned and the fetch will be retried later", + fetch_uri.getHost(), part_name); + } + + /// T3 — PROMOTE. Only now, and only because the source proved the binding at T2 > T1. + switch (prepared->promote()) + { + case CaRelinkPromote::Committed: + break; + case CaRelinkPromote::MechanismFallbackAllowed: + return nullptr; /// taxonomy row 5 + case CaRelinkPromote::Unresolved: + /// The promotion append may have landed, so this is the ONE promote outcome that is not row + /// 5: returning `nullptr` would send the caller to fetch the bytes and publish the part a + /// second time over a relink that may already be committed. Thrown in the retry-later class + /// instead, exactly as an unproven confirm is (row 3) -- the queue stores it, backs off, and + /// re-executes, by which time the ref lane has resolved the ambiguity one way or the other. + throw Exception(ErrorCodes::NETWORK_ERROR, + "Relink of part {} from {} could not be resolved: the promotion may or may not have " + "committed, so the bytes must NOT be fetched; the fetch will be retried later", + part_name, fetch_uri.getHost()); + } + /// The single terminal operation is done, so the handle owes nothing; releasing it here also disarms + /// the scope guard for the part-building code below. + prepared.reset(); + + auto volume = std::make_shared("volume_" + part_name, disk); + + MergeTreeData::MutableDataPartPtr new_data_part; + MergeTreeDataPartBuilder builder(data, part_name, volume, part_relative_path, part_dir, getReadSettings()); + /// Read the part format from the now-published manifest (type + storage type), exactly as the byte + /// fetch does — authoritative over the transferred `part_type` header (kept for protocol symmetry). + new_data_part = builder.withPartFormatFromDisk().build(); + + new_data_part->version->setAndStoreCreationTID(Tx::NonTransactionalTID, nullptr); + new_data_part->is_temp = true; + /// The blobs are shared in the pool; a discarded temporary relink part must NOT reclaim them (another + /// replica's ref keeps them alive). Same policy a zero-copy-fetched temporary part uses. + new_data_part->remove_tmp_policy = IMergeTreeDataPart::BlobsRemovalPolicyForTemporaryParts::PRESERVE_BLOBS; + new_data_part->modification_time = time(nullptr); + new_data_part->loadColumnsChecksumsIndexes(true, false); + + LOG_DEBUG(log, "Relink of part {} onto disk {} finished (no bytes transferred).", part_name, disk->getName()); + return new_data_part; +} + } } diff --git a/src/Storages/MergeTree/DataPartsExchange.h b/src/Storages/MergeTree/DataPartsExchange.h index 6e79d67d5714..5bec506eac21 100644 --- a/src/Storages/MergeTree/DataPartsExchange.h +++ b/src/Storages/MergeTree/DataPartsExchange.h @@ -18,11 +18,22 @@ namespace zkutil using ZooKeeperPtr = std::shared_ptr; } +/// Only the content-addressed relink's confirm request needs these, and only as parameter types, so +/// they are declared rather than included — this header is pulled in by the whole replication tree. +namespace Poco { class URI; } +namespace Poco::Net { class HTTPBasicCredentials; } + namespace DB { class StorageReplicatedMergeTree; class ReadWriteBufferFromHTTP; +struct ReadSettings; + +/// Declared by `ContentAddressedExchange.h` (the narrow content-addressed seam). Opaque-enum-declared +/// here so this header stays free of content-addressed includes; the definition must keep the same +/// underlying type. +enum class CasConfirmAnswer : uint8_t; namespace DataPartsExchange { @@ -41,6 +52,27 @@ class Service final : public InterserverIOEndpoint void processQuery(const HTMLForm & params, ReadBufferPtr body, WriteBuffer & out, HTTPServerResponse & response) override; private: + /// CAS fetch-by-relink, publish-then-confirm: answer one relink confirm token — "is `manifest_ref_text` + /// still exactly what `ref_name` names here?" — for a receiver that has already made its own `+1` + /// durable and may promote only on `Yes`. Everything content-addressed is behind + /// `IContentAddressedExchange`; what has to live here is what only the storage can see: which of this + /// table's disks is entitled to answer (`ownsNamespace` under a matching pool UUID, exactly one match + /// or `Unknown`), and gate 0, the part-anchored filter over this table's parts set. Never throws, and + /// `No` is not knowledge — see `CasConfirmAnswer`. + /// The confirm action's handler: decode the peer's token, resolve it, and set the answer cookie. + /// Exactly two answers cross the wire — proven, and not proven — because only `Yes` authorizes + /// anything and `No` is not knowledge (see `CasConfirmAnswer`). Never throws: an unparsable token + /// is one more unproven answer, not an error the receiver would have to classify. + void answerContentAddressedConfirm(const String & token_text, HTTPServerResponse & response) const; + + CasConfirmAnswer resolveContentAddressedConfirm( + const String & pool_uuid, + const String & server_root_id, + const String & root_namespace, + const String & ref_name, + const String & part_name, + const String & manifest_ref_text) const; + MergeTreeData::DataPartPtr findPart(const String & name); MergeTreeData::DataPart::Checksums sendPartFromDisk( @@ -81,7 +113,20 @@ class Fetcher final : private boost::noncopyable const String & tmp_prefix_ = "", std::optional * tagger_ptr = nullptr, bool try_zero_copy = true, - DiskPtr dest_disk = nullptr); + DiskPtr dest_disk = nullptr, + /// CAS fetch-by-relink (spec §B66b): may this request advertise its content-addressed pool + /// identity, i.e. may the sender answer with a relink offer instead of the part's bytes? + /// + /// It is a capability of its own rather than a rider on `try_zero_copy`, and it carries the + /// RECURSION BRAKE. Relink used to be gated on `try_zero_copy` purely because the byte-fetch + /// fallback re-requests with `try_zero_copy=false`, so the brake came for free; with the two + /// decoupled, every same-sender byte re-request must clear THIS flag explicitly or a + /// persistent relink-mechanism failure re-offers, re-fails and re-requests without bound. + /// + /// It defaults to `true`, and that default is what makes a manual `FETCH PARTITION`/`FETCH + /// PART` (which passes `try_fetch_shared=false`, so `try_zero_copy` is already false) relink, + /// into `detached/` as well as into the active part path. + bool allow_ca_relink = true); /// You need to stop the data transfer. ActionBlocker blocker; @@ -111,6 +156,36 @@ class Fetcher final : private boost::noncopyable ThrottlerPtr throttler, bool sync); + /// CAS replication 2b — fetch-by-relink (spec §4), publish-then-confirm (spec §core-idea). Build a + /// part WITHOUT downloading any bytes by publishing this server's own ref to the blobs already in the + /// shared content-addressed pool. Stages the ref under the tmp-fetch dir of the target parent — the + /// table dir, or `detached/` when `to_detached` (B66b) — so the caller's finalization re-keys it to + /// the final part name, exactly as for a byte-fetched part: `renameTempPartAndReplace` for the + /// active path, `renameTo(detached/)` for the detached one. Then it ASKS THE SOURCE whether it + /// still holds exactly the manifest it offered, and only then promotes and loads the part. + /// Self-contained (all-tree task 7): the transferred manifest alone is enough to rebuild the part — + /// no separate uuid/metadata_version wire fields to reconstruct as a sidecar. + /// + /// The whole failure taxonomy lives at the definition; the two outcomes a CALLER must distinguish: + /// `nullptr` means relink cannot work here and the source still has the part, so a byte re-request to + /// the SAME source is sound; a THROW means the source could not prove it still holds the manifest, + /// and the one recovery that is not sound is asking that same source for the bytes. + /// + /// `source_token`, `fetch_uri` and the connection parameters are what the confirm request is built + /// from: the token is the sender's opaque offer identity, and the request is aimed at the endpoint + /// COPIED out of the fetch URI so it cannot reach a different table or replica than the offer did. + MergeTreeData::MutableDataPartPtr relinkPartToDisk( + const String & part_name, + const String & tmp_prefix, + DiskPtr disk, + bool to_detached, + const String & sender_manifest_bytes, + const String & source_token, + const Poco::URI & fetch_uri, + const Poco::Net::HTTPBasicCredentials & credentials, + const ConnectionTimeouts & timeouts, + const ReadSettings & read_settings); + MergeTreeData::MutableDataPartPtr downloadPartToDiskRemoteMeta( const String & part_name, const String & replica_path, diff --git a/src/Storages/MergeTree/IDataPartStorage.h b/src/Storages/MergeTree/IDataPartStorage.h index 37f31ade32b9..37cc719215a8 100644 --- a/src/Storages/MergeTree/IDataPartStorage.h +++ b/src/Storages/MergeTree/IDataPartStorage.h @@ -189,6 +189,15 @@ class IDataPartStorage : public boost::noncopyable virtual std::string getDiskName() const = 0; virtual std::string getDiskType() const = 0; virtual bool isStoredOnRemoteDisk() const { return false; } + /// True when the underlying disk stores a part as one atomic content-addressed unit (one manifest + /// + one ref). On such disks a projection sub-part must be written through the PARENT part's + /// whole-part transaction rather than its own sub-transaction (otherwise the projection is lost + /// from the committed manifest — B58). + virtual bool isContentAddressed() const { return false; } + /// True when the underlying disk publishes a file write atomically in one shot (no partial + /// content ever becomes visible under the file's final name). Such disks do not need the + /// tmp-file + `replaceFile` crash-safety dance that plain local writes require. + virtual bool supportsAtomicFileWrites() const { return false; } virtual std::optional getCacheName() const { return std::nullopt; } virtual bool supportZeroCopyReplication() const { return false; } virtual bool supportParallelWrite() const = 0; diff --git a/src/Storages/MergeTree/IMergeTreeDataPart.cpp b/src/Storages/MergeTree/IMergeTreeDataPart.cpp index 5a29ab7a5f91..465f64c91a80 100644 --- a/src/Storages/MergeTree/IMergeTreeDataPart.cpp +++ b/src/Storages/MergeTree/IMergeTreeDataPart.cpp @@ -1356,7 +1356,13 @@ MergeTreeDataPartBuilder IMergeTreeDataPart::getProjectionPartBuilder( const String & projection_name, ProjectionDescriptionRawPtr projection, bool is_temp_projection) { const char * projection_extension = is_temp_projection ? ".tmp_proj" : ".proj"; - auto projection_storage = getDataPartStorage().getProjection(projection_name + projection_extension, !is_temp_projection); + /// On a content-addressed disk a part is one atomic unit, so a temp projection sub-part (written + /// during a merge/mutate rebuild under `.tmp_proj`) must share the PARENT part's whole-part + /// transaction — its files are re-keyed into the parent manifest when `.tmp_proj` is renamed to + /// `.proj` (B58). On a non-CA disk a temp projection keeps its own sub-transaction (the + /// historical behavior): `use_parent_transaction = !is_temp_projection`. + const bool use_parent_transaction = !is_temp_projection || getDataPartStorage().isContentAddressed(); + auto projection_storage = getDataPartStorage().getProjection(projection_name + projection_extension, use_parent_transaction); MergeTreeDataPartBuilder builder(storage, projection_name, projection_storage, getReadSettings()); return builder.withPartInfo(MergeListElement::FAKE_RESULT_PART_FOR_PROJECTION).withParentPart(this).withProjection(projection); } diff --git a/src/Storages/MergeTree/MergeProjectionPartsTask.cpp b/src/Storages/MergeTree/MergeProjectionPartsTask.cpp index 8956d382ef6b..cb8b7171ab7f 100644 --- a/src/Storages/MergeTree/MergeProjectionPartsTask.cpp +++ b/src/Storages/MergeTree/MergeProjectionPartsTask.cpp @@ -129,6 +129,10 @@ bool MergeProjectionPartsTask::executeStep() /// FIXME (alesapin) we should use some temporary storage for this, /// not commit each subprojection part + /// + /// A borrowed (CA) recursively-merged projection sub-part shares the parent part's whole-part + /// transaction (the nested MergeTask skipped its own begin), so it is committed by the parent's + /// single commit; the storage makes commitTransaction a no-op there, so this is unconditional (B58). next_level_parts.back()->getDataPartStorage().commitTransaction(); next_level_parts.back()->is_temp = true; next_level_parts.back()->temp_projection_block_number = block_num; diff --git a/src/Storages/MergeTree/MergeTask.cpp b/src/Storages/MergeTree/MergeTask.cpp index 5e7d3c71435b..7a631e06baaf 100644 --- a/src/Storages/MergeTree/MergeTask.cpp +++ b/src/Storages/MergeTree/MergeTask.cpp @@ -559,7 +559,14 @@ bool MergeTask::ExecuteAndFinalizeHorizontalPart::prepare() const std::optional builder; if (global_ctx->parent_part) { - auto data_part_storage = global_ctx->parent_part->getDataPartStorage().getProjection(local_tmp_part_basename, /* use parent transaction */ false); + /// On a content-addressed disk a part is one atomic unit (one manifest + one ref). The projection + /// sub-part must therefore be written through the PARENT part's whole-part transaction (mirroring + /// the INSERT path, `MergeTreeDataWriter::writeProjectionPartImpl` with `use_parent_transaction = + /// true`) so its files land in the parent manifest and survive a reload (B58). On a non-CA disk we + /// keep the historical behavior: the projection sub-part opens and commits its own sub-transaction. + global_ctx->projection_uses_parent_transaction = global_ctx->parent_part->getDataPartStorage().isContentAddressed(); + auto data_part_storage = global_ctx->parent_part->getDataPartStorage().getProjection( + local_tmp_part_basename, /* use_parent_transaction */ global_ctx->projection_uses_parent_transaction); builder.emplace(*global_ctx->data, global_ctx->future_part->name, data_part_storage, getReadSettings()); builder->withParentPart(global_ctx->parent_part); } @@ -579,6 +586,8 @@ bool MergeTask::ExecuteAndFinalizeHorizontalPart::prepare() const if (data_part_storage->exists()) throw Exception(ErrorCodes::DIRECTORY_ALREADY_EXISTS, "Directory {} already exists", data_part_storage->getFullPath()); + /// A borrowed projection sub-part shares the parent's already-open transaction; the storage makes + /// beginTransaction a no-op in that case, so this can be called unconditionally. data_part_storage->beginTransaction(); /// Background temp dirs cleaner will not touch tmp projection directory because @@ -1314,6 +1323,9 @@ void MergeTask::ExecuteAndFinalizeHorizontalPart::calculateProjectionForBlock( *global_ctx->data, ctx->log, result, projection, global_ctx->new_data_part.get(), ++ctx->projection_block_num, global_ctx->context); tmp_part->finalize(); + /// A borrowed (CA) temp projection sub-part rides the parent's whole-part transaction and is + /// committed by the parent's single commit; the storage makes commitTransaction a no-op there, + /// so this can be called unconditionally (B58). tmp_part->part->getDataPartStorage().commitTransaction(); ctx->projection_parts[projection.name].emplace_back(std::move(tmp_part->part)); } @@ -1356,6 +1368,8 @@ void MergeTask::ExecuteAndFinalizeHorizontalPart::finalizeProjections() const *global_ctx->data, ctx->log, result, projection, global_ctx->new_data_part.get(), ++ctx->projection_block_num, global_ctx->context); temp_part->finalize(); + /// See the matching note above: a borrowed (CA) temp projection sub-part rides the parent + /// transaction, so commitTransaction is a no-op and can be called unconditionally. temp_part->part->getDataPartStorage().commitTransaction(); ctx->projection_parts[projection.name].emplace_back(std::move(temp_part->part)); } diff --git a/src/Storages/MergeTree/MergeTask.h b/src/Storages/MergeTree/MergeTask.h index 21d027b5db20..a982d9ba58d6 100644 --- a/src/Storages/MergeTree/MergeTask.h +++ b/src/Storages/MergeTree/MergeTask.h @@ -219,6 +219,10 @@ class MergeTask ProjectionDescriptionRawPtr projection{nullptr}; /// This will be either nullptr or new_data_part, so raw pointer is ok. IMergeTreeDataPart * parent_part{nullptr}; + /// True only when this MergeTask builds a projection sub-part (`parent_part != nullptr`) whose + /// parent lives on a content-addressed disk: the sub-part then shares the parent's whole-part + /// transaction and must NOT begin/commit its own (B58). False for non-CA disks and top-level parts. + bool projection_uses_parent_transaction{false}; MergedPartOffsetsPtr merged_part_offsets; ContextPtr context{nullptr}; time_t time_of_merge{0}; diff --git a/src/Storages/MergeTree/MergeTreeData.cpp b/src/Storages/MergeTree/MergeTreeData.cpp index 0d46d17862db..0ffa9443770d 100644 --- a/src/Storages/MergeTree/MergeTreeData.cpp +++ b/src/Storages/MergeTree/MergeTreeData.cpp @@ -33,9 +33,11 @@ #include #include #include +#include #include #include #include +#include #include #include #include @@ -5915,6 +5917,27 @@ MergeTreeData::PartsToRemoveFromZooKeeper MergeTreeData::removePartsInRangeFromW MergeTreeData::Transaction transaction(*this, NO_TRANSACTION_RAW); renameTempPartAndAdd(new_data_part, transaction, lock, /*rename_in_transaction=*/ false); /// All covered parts must be already removed + /// On a content-addressed disk a part directory becomes durable only when its disk-storage + /// transaction is committed (the ref to its manifest is published at commit, not at rename). + /// The flow below rolls back the in-memory MergeTreeData transaction (to keep the empty part + /// Outdated, not Active), which never calls commitTransaction on the disk storage — so on a CA + /// disk the empty covering part would leave NO on-disk ref and vanish on restart/reattach, + /// defeating its sole purpose (it exists only to cover the dropped parts on disk so a restart + /// does not treat them as uncovered unexpected parts and trip TOO_MANY_UNEXPECTED_DATA_PARTS). + /// On a plain disk the rename in renameTempPartAndAdd is already durable, so this is a no-op + /// there. Commit the disk storage transaction here (CA only) so the ref is published before the + /// in-memory rollback; the part still ends up Outdated, exactly as on a plain disk. + /// + /// [TXN-ONE-PIPELINE] (`2026-07-16-cas-txn-one-pipeline-design.md`, Audit 7 / Tension 2): this + /// hand-placed `commitTransaction()` is NOT made redundant by moving publication into `commit` + /// — it is the direct consequence of that design. There is no `precommit` phase under the + /// one-pipeline model, and this rollback path (by construction, to keep the part Outdated) never + /// reaches `MergeTreeData::Transaction::commit`, the only other place a disk transaction is + /// committed. So this call remains the ONLY thing that publishes the empty cover's ref. Keep it. + if (new_data_part->getDataPartStorage().isContentAddressed() + && new_data_part->getDataPartStorage().hasActiveTransaction()) + new_data_part->getDataPartStorage().commitTransaction(); + /// It will add the empty part to the set of Outdated parts without making it Active (exactly what we need) transaction.rollback(&lock); new_data_part->remove_time.store(0, std::memory_order_relaxed); @@ -6725,6 +6748,54 @@ void MergeTreeData::checkAlterPartitionIsPossible( can_execute_alter_on_disk = std::ranges::contains(supported_commands, command.type); break; } + case MetadataStorageType::CAS: + { + /// On a CAS disk a part clone is cheap: identical content has the same + /// `part_id`, so cloning is publishing a ref (no byte copy). The clone path is now + /// transactional — `DataPartStorageOnDiskBase::freeze` runs the whole clone through ONE + /// CA transaction, and `moveDirectory` re-keys the detached-staging → active rename into + /// a complete active ref — so these are SUPPORTED and verified (read back identical data, + /// survive restart): `ATTACH PARTITION`/`ATTACH PART` (re-clone of the table's own + /// detached parts), `REPLACE PARTITION`/`ATTACH PARTITION ... FROM` (parses to + /// `REPLACE_PARTITION`), and `MOVE PARTITION ... TO TABLE`. The pointer-unlink commands + /// `DROP PARTITION` / `DETACH PARTITION` / `DROP DETACHED PARTITION` are also fine. + /// `FETCH PARTITION`/`FETCH PART` is also SUPPORTED — it is a `ReplicatedMergeTree` op + /// (now supported on CA), and a `to_detached` fetch takes the byte-fetch path: the + /// downloaded files content-address into the `detached/` namespace (relink-into-detached + /// is deferred, see backlog). `ALTER ... FETCH PART` parses to the same `FETCH_PARTITION` + /// command type (with `part=true`), so this entry covers both. + /// `FREEZE PARTITION`/`FREEZE ALL` and `UNFREEZE PARTITION`/`UNFREEZE ALL` are now SUPPORTED: + /// a freeze publishes each part as its own ref in the `shadow/` namespace (a GC root sharing + /// the live blobs zero-copy — no byte copy); UNFREEZE removes the backup's refs. + /// `FORGET PARTITION` is SUPPORTED on CA — it only manipulates ZooKeeper partition metadata + /// (removes block-number nodes from ZooKeeper) and does not write, clone, or touch any part + /// files on disk, so it is safe on a content-addressed disk. + /// NOTE: `MOVE_PARTITION` also admits cross-disk + /// `MOVE ... TO DISK/VOLUME` (this check cannot distinguish the destination); that uses + /// the byte-copy `clonePart` path (NOT the corrupting per-file hardlink), but only + /// same-disk `MOVE ... TO TABLE` is verified here — cross-disk is a follow-up to verify. + const static auto supported_commands = { + PartitionCommand::DROP_PARTITION, + PartitionCommand::DROP_DETACHED_PARTITION, + PartitionCommand::FORGET_PARTITION, + PartitionCommand::ATTACH_PARTITION, + PartitionCommand::REPLACE_PARTITION, + PartitionCommand::MOVE_PARTITION, + PartitionCommand::FETCH_PARTITION, + PartitionCommand::FREEZE_PARTITION, + PartitionCommand::FREEZE_ALL_PARTITIONS, + PartitionCommand::UNFREEZE_PARTITION, + PartitionCommand::UNFREEZE_ALL_PARTITIONS, + }; + + if (!std::ranges::contains(supported_commands, command.type)) + throw Exception( + ErrorCodes::SUPPORT_IS_DISABLED, + "Partition operation ALTER TABLE {} is not supported on a CAS disk yet " + "(it clones parts file-by-file with no transaction, which would corrupt the clone); disk '{}'", + command.typeToString(), disk->getName()); + break; + } case MetadataStorageType::StaticWeb: { can_execute_alter_on_disk = false; @@ -7465,6 +7536,14 @@ void MergeTreeData::restorePartFromBackup(std::shared_ptr r /// Copy files from the backup to the directory `tmp_part_dir`. disk->createDirectories(temp_part_dir); + /// A content-addressed disk publishes a part as ONE manifest (N files -> one ref) atomically, so the + /// per-file copyFileToDisk autocommit below is rejected for content part files. Route the restore + /// through one whole-part transaction (mirrors DataPartStorageOnDiskBase::freeze's owned_transaction): + /// all files land in a single content-addressed part at tmp_restore_, published by tx->commit(). + DiskTransactionPtr restore_tx; + if (disk->isContentAddressed()) + restore_tx = disk->createTransaction(); + for (const String & filename : filenames) { /// Needs to create subdirectories before copying the files. Subdirectories are used to represent projections. @@ -7485,10 +7564,24 @@ void MergeTreeData::restorePartFromBackup(std::shared_ptr r continue; } - size_t file_size = backup->copyFileToDisk(part_path_in_backup_fs / filename, disk, temp_part_dir / filename, WriteMode::Rewrite); - reservation->update(reservation->getSize() - file_size); + if (restore_tx) + { + auto in = backup->readFile(part_path_in_backup_fs / filename); + auto out = restore_tx->writeFile(temp_part_dir / filename, DBMS_DEFAULT_BUFFER_SIZE, WriteMode::Rewrite, getContext()->getWriteSettings()); + copyData(*in, *out); + out->finalize(); + reservation->update(reservation->getSize() - backup->getFileSize(part_path_in_backup_fs / filename)); + } + else + { + size_t file_size = backup->copyFileToDisk(part_path_in_backup_fs / filename, disk, temp_part_dir / filename, WriteMode::Rewrite); + reservation->update(reservation->getSize() - file_size); + } } + if (restore_tx) + restore_tx->commit(); + if (auto part = loadPartRestoredFromBackup(part_name, disk, temp_part_dir, detach_if_broken)) restored_parts_holder->addPart(part); else @@ -8901,12 +8994,31 @@ void MergeTreeData::Transaction::clear() void MergeTreeData::Transaction::renameParts() { + /// Materialize every part of this transaction: perform the deferred tmp->final renames, then + /// close each part's disk-storage transaction, making the parts DURABLE on their disks. + /// + /// Contract: after renameParts returns, every part of this transaction is durable at its + /// final name. commit only flips in-memory visibility (its commitTransaction loop remains as + /// a safety net for paths that do not come through here); rollback compensates with new + /// operations over committed disk state (removing a rolled-back part reclaims its disk data; + /// on a content-addressed disk that drops the published ref). + /// + /// Ordering is load-bearing: every call site invokes renameParts BEFORE its external Keeper + /// commit decision. A part must be durable before its block_id/part-znode is registered, + /// otherwise a fault between the Keeper commit and the disk commit leaves a phantom part whose + /// surviving block_id silently dedups a byte-identical client retry (acked data loss). This + /// also keeps the disk commit (network I/O on object storages) off the data_parts lock, which + /// Transaction::commit holds. for (const auto & part_need_rename : precommitted_parts_need_rename) { LOG_TEST(data.log, "Renaming part to {}", part_need_rename->name); part_need_rename->renameTo(part_need_rename->name, true); } precommitted_parts_need_rename.clear(); + + for (const auto & part : precommitted_parts) + if (part->getDataPartStorage().hasActiveTransaction()) + part->getDataPartStorage().commitTransaction(); } MergeTreeData::DataPartsVector MergeTreeData::Transaction::commit() diff --git a/src/Storages/MergeTree/MergeTreeData.h b/src/Storages/MergeTree/MergeTreeData.h index 826f4eead8f5..349341d8a81f 100644 --- a/src/Storages/MergeTree/MergeTreeData.h +++ b/src/Storages/MergeTree/MergeTreeData.h @@ -366,9 +366,17 @@ class MergeTreeData : public WithMutableContext, public IStorage, public IBackgr DataPartsVector commit(); DataPartsVector commit(DataPartsLock & lock); - /// Rename should be done explicitly, before calling commit(), to - /// guarantee that no lock held during rename (since rename is IO - /// bound, while data parts lock is the bottleneck) + /// Renames should be done explicitly, before calling commit, to + /// guarantee that no lock is held during the rename and the disk + /// commit (both are IO bound, while the data parts lock is the + /// bottleneck). Contract: after renameParts every part of this + /// transaction is durable on its disk at its final name; commit only + /// flips in-memory visibility, and rollback compensates via new disk + /// operations (part removal). Every caller runs this BEFORE its + /// external Keeper commit decision: a part must be durable before its + /// block_id/part-znode is registered in Keeper, otherwise a fault between the two commits + /// leaves a phantom part whose surviving block_id silently dedups a byte-identical client + /// retry (acked data loss). void renameParts(); void addPart(MutableDataPartPtr & part, bool need_rename); diff --git a/src/Storages/MergeTree/MergeTreeDataWriter.cpp b/src/Storages/MergeTree/MergeTreeDataWriter.cpp index 8e64543510c3..aaf7ddc527a9 100644 --- a/src/Storages/MergeTree/MergeTreeDataWriter.cpp +++ b/src/Storages/MergeTree/MergeTreeDataWriter.cpp @@ -1053,6 +1053,8 @@ MergeTreeTemporaryPartPtr MergeTreeDataWriter::writeProjectionPartImpl( auto projection_part_storage = new_data_part->getDataPartStoragePtr(); auto data_settings = data.getSettings(&projection.settings_changes); + /// A temp projection sub-part opens a transaction only if it owns one; a borrowed (CA) projection + /// storage makes beginTransaction a no-op, so the `isContentAddressed()` branch is no longer needed. if (is_temp) projection_part_storage->beginTransaction(); diff --git a/src/Storages/MergeTree/MergeTreeDeduplicationLog.cpp b/src/Storages/MergeTree/MergeTreeDeduplicationLog.cpp index 9987e466c53b..a4ff5691bf4a 100644 --- a/src/Storages/MergeTree/MergeTreeDeduplicationLog.cpp +++ b/src/Storages/MergeTree/MergeTreeDeduplicationLog.cpp @@ -20,6 +20,7 @@ namespace DB namespace ErrorCodes { extern const int ABORTED; + extern const int LOGICAL_ERROR; } namespace @@ -103,8 +104,14 @@ void MergeTreeDeduplicationLog::load() { if (auto * object_storage = dynamic_cast(disk.get())) { - // MetadataStorageType::Plain does not have directory concept. When checking `logs_dir` existence, it might return false. - if (object_storage->getMetadataStorage()->getType() != MetadataStorageType::Plain) + // Plain and ContentAddressed object storages do not materialize empty directories, so a + // missing logs_dir is normal for a fresh table: fall through so the current_writer is still + // created (an INSERT must have a writer, else addPart fails closed). For these types a + // missing dir is NOT evidence of nothing to do; iterateDirectory below finds any logs that + // already exist, and rotate() creates the writer when there are none. Any other object + // storage returns here: a missing dir means there is genuinely nothing and nowhere to write. + const auto type = object_storage->getMetadataStorage()->getType(); + if (type != MetadataStorageType::Plain && type != MetadataStorageType::CAS) return; } } @@ -268,7 +275,15 @@ std::vector MergeTreeDeduplicationLog: throw Exception(ErrorCodes::ABORTED, "Storage has been shutdown when we add this part."); } - chassert(current_writer != nullptr); + /// A disk that cannot host the append-mode log leaves current_writer null; the release-build + /// chassert above is a no-op, so dereferencing it would segfault. Fail closed with a clear + /// exception instead of crashing the server (B37). + if (!current_writer) + throw Exception( + ErrorCodes::LOGICAL_ERROR, + "MergeTree deduplication log has no writer (the disk does not support the on-disk " + "deduplication log); cannot add part {}", + part_info.getPartNameAndCheckFormat(format_version)); for (const auto & block_id : block_ids) { @@ -306,7 +321,13 @@ void MergeTreeDeduplicationLog::dropPart(const MergeTreePartInfo & drop_part_inf throw Exception(ErrorCodes::ABORTED, "Storage has been shutdown when we drop this part."); } - chassert(current_writer != nullptr); + /// As in addPart: a null writer must produce a clear exception, never a segfault (B37). + if (!current_writer) + throw Exception( + ErrorCodes::LOGICAL_ERROR, + "MergeTree deduplication log has no writer (the disk does not support the on-disk " + "deduplication log); cannot drop part {}", + drop_part_info.getPartNameAndCheckFormat(format_version)); for (auto itr = deduplication_map.begin(); itr != deduplication_map.end(); /* no increment here, we erasing from map */) { diff --git a/src/Storages/MergeTree/MutateTask.cpp b/src/Storages/MergeTree/MutateTask.cpp index 918ead21658d..0eb164c8e7a7 100644 --- a/src/Storages/MergeTree/MutateTask.cpp +++ b/src/Storages/MergeTree/MutateTask.cpp @@ -1871,6 +1871,9 @@ void PartMergerWriter::writeTempProjectionPart(size_t projection_idx, Chunk chun ctx->context); tmp_part->finalize(); + /// A borrowed (CA) temp projection sub-part shares the new (parent) part's whole-part transaction + /// (see `IMergeTreeDataPart::getProjectionPartBuilder`) and is committed by the parent's single + /// commit; the storage makes commitTransaction a no-op there, so this is called unconditionally (B58). tmp_part->part->getDataPartStorage().commitTransaction(); projection_parts[projection.name].emplace_back(std::move(tmp_part->part)); } diff --git a/src/Storages/MergeTree/tests/gtest_deduplication_log_null_writer.cpp b/src/Storages/MergeTree/tests/gtest_deduplication_log_null_writer.cpp new file mode 100644 index 000000000000..7a2f72313a19 --- /dev/null +++ b/src/Storages/MergeTree/tests/gtest_deduplication_log_null_writer.cpp @@ -0,0 +1,139 @@ +#include + +#include +#include +#include +#include +#include /// DEBUG_OR_SANITIZER_BUILD + +#include +#include +#include +#include + +using namespace DB; + +namespace DB::ErrorCodes +{ + extern const int LOGICAL_ERROR; +} + +namespace +{ +constexpr auto FORMAT_VERSION = MERGE_TREE_DATA_MIN_FORMAT_VERSION_WITH_CUSTOM_PARTITIONING; + +/// B37 regression: a `MergeTreeDeduplicationLog` whose `current_writer` is null (the disk could not +/// host the append-mode log -- see `MergeTreeDeduplicationLog::load()`'s early-return path for a +/// `DiskObjectStorage` whose metadata storage type is neither `Plain` nor `ContentAddressed`) used to +/// be dereferenced unconditionally by `addPart`/`dropPart`: a release-build `chassert` is a no-op, so +/// this was a null-pointer dereference (segfault) rather than a handled error. The fix makes both +/// throw a `LOGICAL_ERROR` `DB::Exception` instead. +/// +/// There is no way to drive this from a stateless SQL test: every disk type that reaches production +/// either materializes `logs_dir` (so `load()` takes the normal `rotate()` path and sets a writer) or +/// is one of the two types (`Plain`, `ContentAddressed`) `load()` explicitly special-cases to still get +/// a writer. So this test constructs the log directly and never calls `load()` -- `current_writer` +/// simply stays at its default-constructed null value, which is the exact precondition the guard in +/// `addPart`/`dropPart` exists for. +struct DeduplicationLogNullWriterFixture : public ::testing::Test +{ + std::filesystem::path base_path; + DiskPtr disk; + std::unique_ptr log; + + void SetUp() override + { + const auto unique = std::to_string(::getpid()) + "_" + std::to_string(reinterpret_cast(this)); + base_path = std::filesystem::temp_directory_path() / ("dedup_log_null_writer_gtest_" + unique); + std::filesystem::create_directories(base_path); + disk = std::make_shared("test_disk_" + unique, base_path.string()); + + /// deduplication_window != 0 so addPart/dropPart don't bail out on the "deduplication is off" + /// fast path before ever reaching the null-writer guard. `load()` is deliberately NOT called: + /// that is what leaves `current_writer` null. + log = std::make_unique("deduplication_logs", /*deduplication_window_=*/4, FORMAT_VERSION, disk); + } + + void TearDown() override + { + log.reset(); + std::error_code ec; + std::filesystem::remove_all(base_path, ec); + } +}; + +} + +#if defined(DEBUG_OR_SANITIZER_BUILD) +/// gtest runs *DeathTest suites before others; reuse the same fixture via an alias so the death arm +/// gets the same null-writer precondition. +using DeduplicationLogNullWriterDeathTest = DeduplicationLogNullWriterFixture; +#endif + +#ifndef DEBUG_OR_SANITIZER_BUILD +TEST_F(DeduplicationLogNullWriterFixture, AddPartThrowsLogicalErrorInsteadOfCrashing) +{ + /// LOGICAL_ERROR "no writer" is a broken-invariant guard (addPart on a null current_writer). Under + /// abort_on_logical_error it aborts at construction instead of being catchable -- the DeathTest + /// below proves the abort in those builds. + auto part_info = MergeTreePartInfo::fromPartName("all_0_0_0", FORMAT_VERSION); + + EXPECT_THROW( + { + try + { + log->addPart({"block-1"}, part_info); + } + catch (const Exception & e) + { + EXPECT_EQ(e.code(), ErrorCodes::LOGICAL_ERROR); + EXPECT_NE(e.message().find("no writer"), std::string::npos); + throw; + } + }, + Exception); + + /// The object stays alive and usable after the guard fires: it isn't left half-corrupted by the + /// failed call, and repeating the same call (still no writer) throws again, cleanly, rather than + /// crashing or behaving differently the second time. + EXPECT_THROW(log->addPart({"block-1"}, part_info), Exception); +} +#endif + +#if defined(DEBUG_OR_SANITIZER_BUILD) +TEST_F(DeduplicationLogNullWriterDeathTest, AddPartAborts) +{ + auto part_info = MergeTreePartInfo::fromPartName("all_0_0_0", FORMAT_VERSION); + EXPECT_DEATH({ log->addPart({"block-1"}, part_info); }, "no writer"); +} +#endif + +#ifndef DEBUG_OR_SANITIZER_BUILD +TEST_F(DeduplicationLogNullWriterFixture, DropPartThrowsLogicalErrorInsteadOfCrashing) +{ + auto part_info = MergeTreePartInfo::fromPartName("all_0_0_0", FORMAT_VERSION); + + EXPECT_THROW( + { + try + { + log->dropPart(part_info); + } + catch (const Exception & e) + { + EXPECT_EQ(e.code(), ErrorCodes::LOGICAL_ERROR); + EXPECT_NE(e.message().find("no writer"), std::string::npos); + throw; + } + }, + Exception); +} +#endif + +#if defined(DEBUG_OR_SANITIZER_BUILD) +TEST_F(DeduplicationLogNullWriterDeathTest, DropPartAborts) +{ + auto part_info = MergeTreePartInfo::fromPartName("all_0_0_0", FORMAT_VERSION); + EXPECT_DEATH({ log->dropPart(part_info); }, "no writer"); +} +#endif diff --git a/src/Storages/MergeTree/tests/gtest_projection_borrowed_transaction.cpp b/src/Storages/MergeTree/tests/gtest_projection_borrowed_transaction.cpp new file mode 100644 index 000000000000..ae079a61450f --- /dev/null +++ b/src/Storages/MergeTree/tests/gtest_projection_borrowed_transaction.cpp @@ -0,0 +1,86 @@ +#include + +#include +#include +#include + +#include +#include +#include +#include + +using namespace DB; + +namespace +{ + /// A DiskLocal-backed parent part storage. `DiskLocal::createTransaction` yields a real + /// transaction object, which is all `beginTransaction` needs to hand a NON-NULL transaction to a + /// borrowed projection sub-part (the `has_shared_transaction == true` case). + struct ParentStorageFixture + { + std::filesystem::path base_path; + DiskPtr disk; + VolumePtr volume; + MutableDataPartStoragePtr parent; + + ParentStorageFixture() + { + const auto unique = std::to_string(::getpid()) + "_" + + std::to_string(reinterpret_cast(this)); + base_path = std::filesystem::temp_directory_path() / ("proj_txn_gtest_" + unique); + std::filesystem::create_directories(base_path / "all_1_1_0"); + disk = std::make_shared("test_disk_" + unique, base_path.string()); + volume = std::make_shared("test_volume", disk); + parent = std::make_shared(volume, /*root_path=*/"", "all_1_1_0"); + } + + ~ParentStorageFixture() + { + std::error_code ec; + std::filesystem::remove_all(base_path, ec); + } + }; +} + +/// A projection sub-part that BORROWS the parent's whole-part transaction (the CA-disk shape: +/// getProjection(..., use_parent_transaction = true)) must let begin/commit be NO-OPS — it rides the +/// parent's single commit. Before the encapsulation this threw "Uncommitted shared transaction already +/// exists" / "Cannot commit shared transaction", forcing every caller to branch on isContentAddressed(). +TEST(ProjectionBorrowedTransaction, BorrowedStorageBeginCommitAreNoOps) +{ + ParentStorageFixture fx; + + /// Parent opens the whole-part transaction (as MergeTask/writer do for a CA part). + fx.parent->beginTransaction(); + ASSERT_TRUE(fx.parent->hasActiveTransaction()); + + /// Borrowed projection sub-part: shares the parent transaction (has_shared_transaction == true). + auto proj = fx.parent->getProjection("p.proj", /*use_parent_transaction=*/true); + EXPECT_TRUE(proj->hasActiveTransaction()); + + /// The encapsulated rule: begin/commit on the borrowed storage are silent no-ops (they must NOT + /// open a second transaction, nor commit the parent's). + EXPECT_NO_THROW(proj->beginTransaction()); + EXPECT_NO_THROW(proj->commitTransaction()); + + /// The parent's transaction is untouched by the projection's no-ops and still commits cleanly. + EXPECT_TRUE(fx.parent->hasActiveTransaction()); + EXPECT_NO_THROW(fx.parent->commitTransaction()); + EXPECT_FALSE(fx.parent->hasActiveTransaction()); +} + +/// The non-CA temp-projection shape (use_parent_transaction = false) is unchanged: the sub-part OWNS +/// its transaction, so begin creates it and commit commits it (has_shared_transaction == false, so the +/// no-op path never triggers). +TEST(ProjectionBorrowedTransaction, OwnedProjectionStorageStillBeginsAndCommits) +{ + ParentStorageFixture fx; + + auto proj = fx.parent->getProjection("q.proj", /*use_parent_transaction=*/false); + EXPECT_FALSE(proj->hasActiveTransaction()); + + EXPECT_NO_THROW(proj->beginTransaction()); + EXPECT_TRUE(proj->hasActiveTransaction()); + EXPECT_NO_THROW(proj->commitTransaction()); + EXPECT_FALSE(proj->hasActiveTransaction()); +} diff --git a/src/Storages/StorageMergeTree.cpp b/src/Storages/StorageMergeTree.cpp index b58b4e0dd1f6..1143c40938dd 100644 --- a/src/Storages/StorageMergeTree.cpp +++ b/src/Storages/StorageMergeTree.cpp @@ -13,6 +13,7 @@ #include #include #include +#include #include #include #include @@ -176,11 +177,15 @@ static bool supportTransaction(const Disks & disks, LoggerPtr log) { for (const auto & disk : disks) { - if (!supportWritingWithAppend(disk)) - { - LOG_DEBUG(log, "Disk {} does not support writing with append", disk->getName()); - return false; - } + if (supportWritingWithAppend(disk)) + continue; + /// A content-addressed disk does not support append, but persists the per-part mutable + /// transaction file (txn_version.txt) via its per-ref sidecar, which is all MVCC needs. + if (auto * obj = dynamic_cast(disk.get()); + obj && obj->getMetadataStorage()->supportsTransactionalMutableFiles()) + continue; + LOG_DEBUG(log, "Disk {} does not support transactions", disk->getName()); + return false; } return true; } diff --git a/src/Storages/StorageProxy.h b/src/Storages/StorageProxy.h index dc1c0570e9b6..fe5b91e3bfba 100644 --- a/src/Storages/StorageProxy.h +++ b/src/Storages/StorageProxy.h @@ -143,6 +143,15 @@ class StorageProxy : public IStorage void mutate(const MutationCommands & commands, ContextPtr context) override { getNested()->mutate(commands, context); } + /// Must forward alongside `mutate`: `IStorage`'s default throws NOT_IMPLEMENTED ("doesn't + /// support mutations"), so a non-forwarding proxy rejects every mutation on a wrapped table + /// even though the nested engine supports them (found via `ALTER TABLE ... MATERIALIZE TTL` + /// on a `lazy_load_tables = 1` table wrapped in `StorageTableProxy`). + void checkMutationIsPossible(const MutationCommands & commands, const Settings & settings) const override + { + getNested()->checkMutationIsPossible(commands, settings); + } + CancellationCode killMutation(const String & mutation_id) override { return getNested()->killMutation(mutation_id); } void startup() override { getNested()->startup(); } diff --git a/src/Storages/StorageReplicatedMergeTree.cpp b/src/Storages/StorageReplicatedMergeTree.cpp index ad52354eb849..017f742edd5d 100644 --- a/src/Storages/StorageReplicatedMergeTree.cpp +++ b/src/Storages/StorageReplicatedMergeTree.cpp @@ -464,6 +464,18 @@ StorageReplicatedMergeTree::StorageReplicatedMergeTree( { if (disk->getDataSourceDescription().metadata_type == MetadataStorageType::Keeper) throw Exception(ErrorCodes::BAD_ARGUMENTS, "ReplicatedMergeTree doesn't work with 's3_with_keeper' disk type"); + + /// B33 (lifted, CAS replication 2b + Phase 3.2): ReplicatedMergeTree on a content-addressed disk + /// is allowed. INSERT/SELECT/merge/mutation and fetch-by-relink (the CA analogue of zero-copy + /// replication) route through the working whole-part CA transaction / the relink path. The + /// replication-queue CLONE paths (queue-driven REPLACE/MOVE/ATTACH PARTITION FROM, the + /// cloneAndLoadDataPart-on-the-queue path) were audited in Phase 3.2: they reach the SAME + /// whole-part ContentAddressedTransaction the non-replicated stack uses (see + /// `MergeTreeData::checkAlterPartitionIsPossible`, reached here by dynamic dispatch — the + /// Phase 3.2 fail-closed override in this class was a pure delegation and was deleted by the + /// tail de-patch), NOT the per-file-autocommit B21 mode, so they are now permitted. The + /// zero-copy lockSharedData/unlockSharedData calls these reach are safe no-ops on CA (they + /// early-return on !supportZeroCopyReplication, which CA is). } initializeDirectoriesAndFormatVersion(relative_data_path_, LoadingStrictnessLevel::ATTACH <= mode, date_column_name); diff --git a/src/Storages/StorageTableProxy.h b/src/Storages/StorageTableProxy.h index fa998bab42ac..e935de70e221 100644 --- a/src/Storages/StorageTableProxy.h +++ b/src/Storages/StorageTableProxy.h @@ -54,6 +54,14 @@ class StorageTableProxy final : public StorageProxy StoragePolicyPtr getStoragePolicy() const override { return nullptr; } bool isView() const override { return false; } + /// NOTE: this proxy deliberately does NOT forward `checkTableCanBeRenamed` to the nested engine. + /// Doing so would materialize the lazy table (`getNested`) while `DatabaseAtomic` holds its + /// non-recursive database mutex, and a schema-inferred lazy `Buffer` resolves its destination via + /// `DatabaseCatalog::getTable` in its constructor -- re-entering the same database and self- + /// deadlocking. Bypassing the nested engine's rename restriction for a lazy (never-accessed) table + /// is a pre-existing gap tracked in docs/superpowers/cas/BACKLOG.md; the correct fix is to + /// materialize before the database mutex is taken, at the interpreter level. + /// /// Startup is deferred until first access via `getNested`. void startup() override { } diff --git a/src/Storages/System/StorageSystemContentAddressedMounts.cpp b/src/Storages/System/StorageSystemContentAddressedMounts.cpp new file mode 100644 index 000000000000..2a1b82a01ee7 --- /dev/null +++ b/src/Storages/System/StorageSystemContentAddressedMounts.cpp @@ -0,0 +1,273 @@ +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +namespace DB +{ + +namespace ErrorCodes +{ + extern const int INVALID_STATE; +} + +StorageSystemContentAddressedMounts::StorageSystemContentAddressedMounts(const StorageID & table_id_) + : StorageWithCommonVirtualColumns(table_id_) +{ + StorageInMemoryMetadata storage_metadata; + storage_metadata.setColumns(ColumnsDescription( + { + {"disk", std::make_shared(), "Name of the content-addressed disk."}, + {"server_root_id", std::make_shared(), "Server root id owning the mount slot."}, + {"server_uuid", std::make_shared(), "UUID of the server incarnation holding the lease."}, + {"hostname", std::make_shared(), "Hostname recorded in the lease body."}, + {"process_id", std::make_shared(), "Process id recorded in the lease body."}, + {"writer_epoch", std::make_shared(), "Fenced writer epoch of the incarnation."}, + {"renewal_sequence", std::make_shared(), "Lease renewal sequence number."}, + {"started_at", std::make_shared(3), "Time when the lease started."}, + {"expires_at", std::make_shared(3), "Time when the lease expires."}, + {"min_active_build_sequence", std::make_shared(), "Oldest in-flight build sequence (UINT64_MAX means the mount said farewell)."}, + {"gc_fenced", std::make_shared(), "1 if GC fenced this slot out (terminal)."}, + {"state", std::make_shared(), "Mount slot state: live, expired, terminated, fenced or corrupt."}, + {"is_leader", std::make_shared(std::make_shared()), "1 if this server's GC scheduler holds this disk's leadership lease. NULL on rows describing other servers' mounts."}, + {"pending_reclaim", std::make_shared(std::make_shared()), "Cumulative condemned-minus-deleted backlog observed by this process's GC on this disk. NULL on rows describing other servers' mounts."}, + {"last_success_age_seconds", std::make_shared(std::make_shared()), "Seconds since this disk's GC last led a round (0 if it never led). NULL on rows describing other servers' mounts."}, + {"wedged_namespace_count", std::make_shared(std::make_shared()), "Ref-append lanes currently wedged on this disk. NULL on rows describing other servers' mounts."}, + {"lifecycle", std::make_shared(), "This server's content-addressed pool lifecycle for the disk (non-gated snapshot, always populated so a not-live disk stays visible): live, not_live, identity_lost, vanished, constructing (never started) or shutdown (torn down)."}, + {"lifecycle_reason", std::make_shared(), "The enum-clean sub-state word for a vanished disk: replaced or forgotten. Empty for every other lifecycle (so lifecycle || '(' || lifecycle_reason || ')' reads e.g. vanished(forgotten))."}, + {"lifecycle_detail", std::make_shared(), "The full typed reason text naming the actual cause when not live: the vanish diagnosis (data root replaced by a foreign pool / decommissioned by SYSTEM CAS FORGET at
/detached/). Before the +-- fix, parsePartFilePath reported part_name="detached" + a non-empty file equal to the detached part +-- directory name, so getLastModified fell through to the part-file manifest lookup and threw +-- "ContentAddressed: file not in manifest". getLastModified now recognises the detached +-- part directory and reports the "detached" ref manifest object's mtime. + +DROP TABLE IF EXISTS t_cas_detach_mtime; + +CREATE TABLE t_cas_detach_mtime (a UInt64, b UInt64) +ENGINE = MergeTree ORDER BY a +SETTINGS disk = disk( + type = object_storage, + object_storage_type = local, + metadata_type = cas, + server_root_id = '04288', + name = '04288_cas_detach_mtime', + path = '04288_cas_detach_mtime_pool/'); + +INSERT INTO t_cas_detach_mtime SELECT number, number * 2 FROM numbers(50); + +SELECT 'count_before', count() FROM t_cas_detach_mtime; + +ALTER TABLE t_cas_detach_mtime DETACH PARTITION tuple(); + +SELECT 'count_after', count() FROM t_cas_detach_mtime; + +-- The modification_time read must succeed (be non-NULL) instead of throwing FILE_DOESNT_EXIST. +SELECT 'detached_mtime_readable', name, modification_time IS NOT NULL +FROM system.detached_parts +WHERE database = currentDatabase() AND table = 't_cas_detach_mtime' +ORDER BY name; + +DROP TABLE t_cas_detach_mtime; +SELECT 'dropped_ok'; diff --git a/tests/queries/0_stateless/04289_cas_multi_detach_drop.reference b/tests/queries/0_stateless/04289_cas_multi_detach_drop.reference new file mode 100644 index 000000000000..f87bae700d97 --- /dev/null +++ b/tests/queries/0_stateless/04289_cas_multi_detach_drop.reference @@ -0,0 +1,7 @@ +active_parts 3 +detached_after 3 +detached_names 1_1_1_0 +detached_names 2_2_2_0 +detached_names 3_3_3_0 +detached_after_drop 0 +dropped_ok diff --git a/tests/queries/0_stateless/04289_cas_multi_detach_drop.sql b/tests/queries/0_stateless/04289_cas_multi_detach_drop.sql new file mode 100644 index 000000000000..3de4dd0a1906 --- /dev/null +++ b/tests/queries/0_stateless/04289_cas_multi_detach_drop.sql @@ -0,0 +1,43 @@ +-- Tags: no-fasttest +-- ^ cas is an object-storage metadata type; keep it off the minimal fasttest image. + +-- B46/B47: multiple partitions detached on a cas disk must COEXIST under the one +-- shared "detached" ref, and DROP DETACHED PARTITION ALL must remove them. +-- B46: each DETACH PARTITION clones one part into detached// via a fresh CA commit; +-- the commit used to REWRITE the shared "detached" ref, so each detach overwrote the previous +-- one and only the last detached part was listed. commit now MERGES into the existing detached +-- ref's manifest + sidecar, so all detached parts coexist. +-- B47: DROP DETACHED PARTITION first renames the detached part to "deleting_" +-- (PartsTemporaryRename) then removes it; CA moveDirectory ignored a detached->detached rename +-- (the rename was a no-op, so removeRecursive on the renamed dir found nothing). moveDirectory +-- now re-keys the detached part dir within the shared detached ref, and removeRecursive handles a +-- detached part directory by removing only that part's keys from the shared ref. + +DROP TABLE IF EXISTS t_cas_multi_detach; + +CREATE TABLE t_cas_multi_detach (p UInt64, v UInt64) +ENGINE = MergeTree PARTITION BY p ORDER BY v +SETTINGS disk = disk( + type = object_storage, + object_storage_type = local, + metadata_type = cas, + server_root_id = '04289', + name = '04289_cas_multi_detach', + path = '04289_cas_multi_detach_pool/'); + +INSERT INTO t_cas_multi_detach VALUES (1, 1), (2, 2), (3, 3); +SELECT 'active_parts', count() FROM system.parts WHERE database = currentDatabase() AND table = 't_cas_multi_detach' AND active; + +ALTER TABLE t_cas_multi_detach DETACH PARTITION ALL; + +-- All three partitions must be listed as detached parts (not just the last one detached). +SELECT 'detached_after', count() FROM system.detached_parts WHERE database = currentDatabase() AND table = 't_cas_multi_detach'; +SELECT 'detached_names', name FROM system.detached_parts WHERE database = currentDatabase() AND table = 't_cas_multi_detach' ORDER BY name; + +ALTER TABLE t_cas_multi_detach DROP DETACHED PARTITION ALL SETTINGS allow_drop_detached = 1; + +-- DROP DETACHED PARTITION ALL must remove every detached part. +SELECT 'detached_after_drop', count() FROM system.detached_parts WHERE database = currentDatabase() AND table = 't_cas_multi_detach'; + +DROP TABLE t_cas_multi_detach; +SELECT 'dropped_ok'; diff --git a/tests/queries/0_stateless/04290_cas_no_leftovers.reference b/tests/queries/0_stateless/04290_cas_no_leftovers.reference new file mode 100644 index 000000000000..ea1449c10eba --- /dev/null +++ b/tests/queries/0_stateless/04290_cas_no_leftovers.reference @@ -0,0 +1,5 @@ +rows 600000 +grew_above_baseline 1 +fsck_unreachable 0 +fsck_dangling 0 +pool_meta_present 1 diff --git a/tests/queries/0_stateless/04290_cas_no_leftovers.sh b/tests/queries/0_stateless/04290_cas_no_leftovers.sh new file mode 100755 index 000000000000..eda5ff7d4fbf --- /dev/null +++ b/tests/queries/0_stateless/04290_cas_no_leftovers.sh @@ -0,0 +1,132 @@ +#!/usr/bin/env bash +# Tags: no-fasttest, no-parallel +# ^ cas is an object-storage metadata type (keep it off the minimal fasttest image); +# no-parallel because we inspect a known on-disk pool directory from the shell and must not race +# another test sharing the same path. + +# North-star "no S3 leftovers" oracle for the content-addressed pool, exercised over a `local` +# object_storage backend so the pool is a plain directory the test shell can inspect directly. +# +# We put the pool under CLICKHOUSE_USER_FILES_UNIQUE (an absolute path both the server and this +# shell can see on a local run) and enable the background reachability GC aggressively +# (gc_enabled=1, grace=2s, interval=1s). We then: +# (1) record the baseline blobs+parts object count (~0), +# (2) CREATE a MergeTree on the CA disk and INSERT several distinct batches to make many blobs, +# (3) assert the count rose above baseline, +# (4) DROP TABLE ... SYNC so the refs are unlinked and the blobs/footers become GC fodder, +# (5) drain the retire pipeline deterministically via `SYSTEM CAS GC RUN` (bounded +# loop on the `pending_*` gauges, NOT a fixed sleep), then run `FSCK` directly on the running +# disk (T13): a clean reachability audit reading back zero `unreachable`/`dangling` is a +# strictly stronger no-leftovers oracle than polling the pool directory ever was. +# `_pool_meta` (durable single-owner marker) and the `store/` metadata tree are expected to remain. +# Teardown is fail-closed (spec rev.8 §5/§9): `SYSTEM CAS FORGET` the disk (force-Vanish, +# node-local), verify it reads `vanished(forgotten)` in system.cas_mounts, and only then +# `rm -rf` — FORGET stopped and joined every CAS background thread for this disk. + +CUR_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd) +# shellcheck source=../shell_config.sh +. "$CUR_DIR"/../shell_config.sh + +POOL_DIR="${CLICKHOUSE_USER_FILES_UNIQUE}_04290_${RANDOM}" + +# Fresh pool dir for this run. +rm -rf "${POOL_DIR:?}" +mkdir -p "${POOL_DIR}" + +# Count regular files (objects) currently living under blobs/ and parts/ in the pool. +count_pool_objects() { + local n_blobs n_parts + n_blobs=$(find "${POOL_DIR}/ca/blobs" "${POOL_DIR}/ca/packs" -type f 2>/dev/null | wc -l) + n_parts=$(find "${POOL_DIR}/ca/trees" -type f 2>/dev/null | wc -l) + echo $(( n_blobs + n_parts )) +} + +DISK_NAME="ca_04290_${CLICKHOUSE_TEST_UNIQUE_NAME}_${RANDOM}" +DISK_DEF="disk( + type = object_storage, + object_storage_type = local, + metadata_type = cas, + server_root_id = '04290', + name = '${DISK_NAME}', + path = '${POOL_DIR}/', + gc_enabled = 1, + gc_interval_sec = 1)" + +$CLICKHOUSE_CLIENT --query "DROP TABLE IF EXISTS t_cas_leftovers SYNC" + +# (1) Baseline. +BASELINE=$(count_pool_objects) + +$CLICKHOUSE_CLIENT --query " +CREATE TABLE t_cas_leftovers (a UInt64, s String, d Date) +ENGINE = MergeTree ORDER BY a +SETTINGS disk = ${DISK_DEF}" + +# (2) Several distinct inserts -> several distinct parts/blobs (distinct data => no dedup-away). +for i in 0 1 2 3 4 5; do + $CLICKHOUSE_CLIENT --query " + INSERT INTO t_cas_leftovers + SELECT number + ${i} * 100000, toString(number + ${i} * 100000), toDate('2020-01-01') + (number % 1000) + FROM numbers(100000)" +done + +$CLICKHOUSE_CLIENT --query "SELECT 'rows', count() FROM t_cas_leftovers" + +# (3) Pool must have grown above baseline. +AFTER_INSERT=$(count_pool_objects) +if [ "$AFTER_INSERT" -gt "$BASELINE" ]; then + echo "grew_above_baseline 1" +else + echo "grew_above_baseline 0 (baseline=${BASELINE} after_insert=${AFTER_INSERT})" +fi + +# (4) Drop: refs unlinked synchronously, blobs/footers become unreferenced GC fodder. +$CLICKHOUSE_CLIENT --query "DROP TABLE t_cas_leftovers SYNC" + +# (5) Drain GC deterministically: loop `SYSTEM CAS GC RUN` rounds until the retire +# pipeline's `pending_*` gauges (Task 7) read back to empty. Bounded (~60 rounds, half-second +# spacing), not a fixed sleep; column values are looked up BY HEADER NAME (not position) so the +# loop keeps working if the result set gains columns. +PENDING=1 +for _ in $(seq 1 60); do + PENDING=$($CLICKHOUSE_CLIENT --query "SYSTEM CAS GC RUN '${DISK_NAME}'" --format TSVWithNames \ + | awk -F'\t' 'NR==1 { for (i = 1; i <= NF; i++) col[$i] = i; next } + { print $col["pending_condemned"] } # already candidates+retired per its doc in Gc/CasGc.h; summing all three double-counts') + [ "${PENDING}" = "0" ] && break + sleep 0.5 +done + +if [ "${PENDING}" != "0" ]; then + echo "FAIL: GC did not drain the retire pipeline within the bounded loop (pending=${PENDING})" >&2 + exit 1 +fi + +# (6) FSCK runs directly on the running disk (T13): a reachability audit that must read back zero +# unreachable/dangling objects. This is a strictly stronger no-leftovers oracle than the old +# dir-poll. +$CLICKHOUSE_CLIENT --query "SYSTEM CAS FSCK '${DISK_NAME}'" --format TSVWithNames \ + | awk -F'\t' 'NR==1 { for (i = 1; i <= NF; i++) col[$i] = i; next } + { print "fsck_unreachable", $col["unreachable"]; print "fsck_dangling", $col["dangling"] }' + +# _pool_meta must still be present (durable single-owner marker is never GC'd). +if [ -f "${POOL_DIR}/ca/_pool_meta" ]; then + echo "pool_meta_present 1" +else + echo "pool_meta_present 0" +fi + +# (7) Fail-closed teardown (spec rev.8 §5/§9): FORGET the disk (force-Vanish, node-local; the table is +# already dropped above), verify it reads exactly `vanished(forgotten)` in the mounts table, and +# only then rm. A failed FORGET or an unexpected lifecycle aborts with the pool dir left in place. +# FORGET logs an operator WARNING; the harness runs the client at --send_logs_level=warning, so that +# expected warning would stream to stderr and be flagged as a failure -- suppress it for this call. +$CLICKHOUSE_CLIENT --allow_repeated_settings --send_logs_level=fatal \ + --query "SYSTEM CAS FORGET '${DISK_NAME}'" || { + echo "FORGET failed — leaving pool dir in place (fail-closed)"; exit 1; } +LIFECYCLE=$($CLICKHOUSE_CLIENT --query " + SELECT lifecycle || '(' || lifecycle_reason || ')' FROM system.cas_mounts + WHERE disk = '${DISK_NAME}'") +[ "${LIFECYCLE}" = "vanished(forgotten)" ] || { + echo "unexpected lifecycle after FORGET: ${LIFECYCLE}"; exit 1; } + +rm -rf "${POOL_DIR:?}" # safe: FORGET stopped and joined every CAS thread for this disk diff --git a/tests/queries/0_stateless/04292_cas_mutations.reference b/tests/queries/0_stateless/04292_cas_mutations.reference new file mode 100644 index 000000000000..98e15e398c73 --- /dev/null +++ b/tests/queries/0_stateless/04292_cas_mutations.reference @@ -0,0 +1,7 @@ +after_update_v: match +after_delete: match +after_update_s: match +after_update_v_doubled: match +after_multi_update: match +final_rows_match 1 +final_data_match 1 diff --git a/tests/queries/0_stateless/04292_cas_mutations.sh b/tests/queries/0_stateless/04292_cas_mutations.sh new file mode 100755 index 000000000000..02cb60321a53 --- /dev/null +++ b/tests/queries/0_stateless/04292_cas_mutations.sh @@ -0,0 +1,114 @@ +#!/usr/bin/env bash +# Tags: no-fasttest +# ^ cas is an object-storage metadata type; keep it off the minimal fasttest image. + +# Correctness oracle for mutations on content-addressed disks (CAS M7). +# After supportsHardLinks() was flipped to true, mutations are enabled on +# content-addressed disks. A mutation builds the new part through a +# whole-part transaction: unchanged columns are carried forward by reference +# (same blob) and changed columns are written fresh. +# +# Strategy: both tables receive identical data and identical mutations; +# after each mutation we assert the full ordered contents are equal +# (CA vs plain MergeTree). Every assertion is a self-checking CA-vs-plain +# equality so the reference file is trivially correct (no hand-computed +# arithmetic needed). + +CUR_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd) +# shellcheck source=../shell_config.sh +. "$CUR_DIR"/../shell_config.sh + +DISK_CA="disk( + type = object_storage, + object_storage_type = local, + metadata_type = cas, + server_root_id = '04292', + name = '04292_cas_mut', + path = '04292_cas_mut_pool/')" + +$CLICKHOUSE_CLIENT --query "DROP TABLE IF EXISTS t_ca SYNC" +$CLICKHOUSE_CLIENT --query "DROP TABLE IF EXISTS t_plain SYNC" + +$CLICKHOUSE_CLIENT --query " +CREATE TABLE t_ca (id UInt64, v UInt64, s String) +ENGINE = MergeTree ORDER BY id +SETTINGS disk = ${DISK_CA}" + +$CLICKHOUSE_CLIENT --query " +CREATE TABLE t_plain (id UInt64, v UInt64, s String) +ENGINE = MergeTree ORDER BY id" + +# Seed both tables with identical deterministic data. +$CLICKHOUSE_CLIENT --query " +INSERT INTO t_ca SELECT number, number * 10, toString(number) FROM numbers(100)" +$CLICKHOUSE_CLIENT --query " +INSERT INTO t_plain SELECT number, number * 10, toString(number) FROM numbers(100)" + +# Helper: compare full ordered contents. +CMP_QUERY="SELECT if( + (SELECT groupArray((id, v, s)) FROM (SELECT id, v, s FROM t_ca ORDER BY id)) = + (SELECT groupArray((id, v, s)) FROM (SELECT id, v, s FROM t_plain ORDER BY id)), + 'match', 'DIFF')" + +# --- Mutation 1: UPDATE one column (id/s carry forward by reference on CA) --- +$CLICKHOUSE_CLIENT --query " +ALTER TABLE t_ca UPDATE v = v + 1 WHERE id % 3 = 0 SETTINGS mutations_sync = 2" +$CLICKHOUSE_CLIENT --query " +ALTER TABLE t_plain UPDATE v = v + 1 WHERE id % 3 = 0 SETTINGS mutations_sync = 2" + +echo -n 'after_update_v: ' +$CLICKHOUSE_CLIENT --query "$CMP_QUERY" + +# --- Mutation 2: DELETE --- +$CLICKHOUSE_CLIENT --query " +ALTER TABLE t_ca DELETE WHERE id % 7 = 0 SETTINGS mutations_sync = 2" +$CLICKHOUSE_CLIENT --query " +ALTER TABLE t_plain DELETE WHERE id % 7 = 0 SETTINGS mutations_sync = 2" + +echo -n 'after_delete: ' +$CLICKHOUSE_CLIENT --query "$CMP_QUERY" + +# --- Mutation 3: UPDATE string column for a range of rows --- +$CLICKHOUSE_CLIENT --query " +ALTER TABLE t_ca UPDATE s = concat(s, '_x') WHERE id > 50 SETTINGS mutations_sync = 2" +$CLICKHOUSE_CLIENT --query " +ALTER TABLE t_plain UPDATE s = concat(s, '_x') WHERE id > 50 SETTINGS mutations_sync = 2" + +echo -n 'after_update_s: ' +$CLICKHOUSE_CLIENT --query "$CMP_QUERY" + +# NOTE: a column-type change (`MODIFY COLUMN v Int64`) is deliberately NOT exercised here. It is a +# data-`ALTER` that runs `checkAlterIsPossible`, which on a table created with an inline +# `disk = disk(...)` setting trips a PRE-EXISTING, engine-agnostic bug: the `disk` value is stored as a +# `CustomType` in `settings_changes` and several ALTER sub-checks read it as a `String` (`BAD_GET`). +# That is orthogonal to content-addressing (it reproduces on any inline-disk table). `MODIFY COLUMN` on +# a content-addressed disk is covered through the storage-policy path by the CA-default suite run. + +# --- Mutation 4: UPDATE the numeric column again (compounding the carry-forward) --- +$CLICKHOUSE_CLIENT --query " +ALTER TABLE t_ca UPDATE v = v * 2 WHERE id % 2 = 0 SETTINGS mutations_sync = 2" +$CLICKHOUSE_CLIENT --query " +ALTER TABLE t_plain UPDATE v = v * 2 WHERE id % 2 = 0 SETTINGS mutations_sync = 2" + +echo -n 'after_update_v_doubled: ' +$CLICKHOUSE_CLIENT --query "$CMP_QUERY" + +# --- Mutation 5: multi-column UPDATE in one mutation (both data columns rewritten together) --- +$CLICKHOUSE_CLIENT --query " +ALTER TABLE t_ca UPDATE v = v + id, s = concat('p_', s) WHERE id < 40 SETTINGS mutations_sync = 2" +$CLICKHOUSE_CLIENT --query " +ALTER TABLE t_plain UPDATE v = v + id, s = concat('p_', s) WHERE id < 40 SETTINGS mutations_sync = 2" + +echo -n 'after_multi_update: ' +$CLICKHOUSE_CLIENT --query "$CMP_QUERY" + +# --- Final sanity: row count and data equality --- +$CLICKHOUSE_CLIENT --query " +SELECT 'final_rows_match', count() = (SELECT count() FROM t_plain) FROM t_ca" +$CLICKHOUSE_CLIENT --query " +SELECT 'final_data_match', + (SELECT groupArray((id, v, s)) FROM (SELECT id, v, s FROM t_ca ORDER BY id)) = + (SELECT groupArray((id, v, s)) FROM (SELECT id, v, s FROM t_plain ORDER BY id))" + +$CLICKHOUSE_CLIENT --query "DROP TABLE t_ca SYNC" +$CLICKHOUSE_CLIENT --query "DROP TABLE t_plain SYNC" diff --git a/tests/queries/0_stateless/04293_cas_lightweight_delete.reference b/tests/queries/0_stateless/04293_cas_lightweight_delete.reference new file mode 100644 index 000000000000..18c1efab4a4a --- /dev/null +++ b/tests/queries/0_stateless/04293_cas_lightweight_delete.reference @@ -0,0 +1,6 @@ +after_delete_mod5: match +after_delete_like: match +after_delete_range: match +after_optimize: match +final_rows_match 1 +final_data_match 1 diff --git a/tests/queries/0_stateless/04293_cas_lightweight_delete.sh b/tests/queries/0_stateless/04293_cas_lightweight_delete.sh new file mode 100755 index 000000000000..a034f9cc4f70 --- /dev/null +++ b/tests/queries/0_stateless/04293_cas_lightweight_delete.sh @@ -0,0 +1,101 @@ +#!/usr/bin/env bash +# Tags: no-fasttest +# ^ cas is an object-storage metadata type; keep it off the minimal fasttest image. + +# Correctness oracle for lightweight DELETE on content-addressed disks (CAS M7). +# After the supportsHardLinks() gate was lifted, lightweight DELETE is enabled on +# content-addressed disks. Unlike heavy mutations, lightweight DELETE uses row- +# existence bitmaps stored alongside each part and is applied physically during +# the next OPTIMIZE/merge. +# +# Strategy: both tables receive identical data and identical lightweight DELETEs; +# after each DELETE (and after OPTIMIZE FINAL) we assert the full ordered contents +# are equal (CA vs plain MergeTree). Every assertion is a self-checking CA-vs-plain +# equality so the reference file is trivially correct (no hand-computed arithmetic +# needed). + +CUR_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd) +# shellcheck source=../shell_config.sh +. "$CUR_DIR"/../shell_config.sh + +DISK_CA="disk( + type = object_storage, + object_storage_type = local, + metadata_type = cas, + server_root_id = '04293', + name = '04293_cas_lwd', + path = '04293_cas_lwd_pool/')" + +$CLICKHOUSE_CLIENT --query "DROP TABLE IF EXISTS t_ca SYNC" +$CLICKHOUSE_CLIENT --query "DROP TABLE IF EXISTS t_plain SYNC" + +$CLICKHOUSE_CLIENT --query " +CREATE TABLE t_ca (id UInt64, v UInt64, s String) +ENGINE = MergeTree ORDER BY id +SETTINGS disk = ${DISK_CA}" + +$CLICKHOUSE_CLIENT --query " +CREATE TABLE t_plain (id UInt64, v UInt64, s String) +ENGINE = MergeTree ORDER BY id" + +# Seed both tables with identical deterministic data spread across two parts +# (lightweight DELETEs across multiple parts are more meaningful than single-part). +$CLICKHOUSE_CLIENT --query " +INSERT INTO t_ca SELECT number, number * 10, toString(number) FROM numbers(100)" +$CLICKHOUSE_CLIENT --query " +INSERT INTO t_ca SELECT number, number * 10, toString(number) FROM numbers(100, 100)" +$CLICKHOUSE_CLIENT --query " +INSERT INTO t_plain SELECT number, number * 10, toString(number) FROM numbers(100)" +$CLICKHOUSE_CLIENT --query " +INSERT INTO t_plain SELECT number, number * 10, toString(number) FROM numbers(100, 100)" + +# Helper: compare full ordered contents of both tables. +CMP_QUERY="SELECT if( + (SELECT groupArray((id, v, s)) FROM (SELECT id, v, s FROM t_ca ORDER BY id)) = + (SELECT groupArray((id, v, s)) FROM (SELECT id, v, s FROM t_plain ORDER BY id)), + 'match', 'DIFF')" + +# --- Lightweight DELETE 1: every 5th row --- +$CLICKHOUSE_CLIENT --query " +DELETE FROM t_ca WHERE id % 5 = 0 SETTINGS lightweight_deletes_sync = 2" +$CLICKHOUSE_CLIENT --query " +DELETE FROM t_plain WHERE id % 5 = 0 SETTINGS lightweight_deletes_sync = 2" + +echo -n 'after_delete_mod5: ' +$CLICKHOUSE_CLIENT --query "$CMP_QUERY" + +# --- Lightweight DELETE 2: rows whose string starts with '1' (overlaps first delete) --- +$CLICKHOUSE_CLIENT --query " +DELETE FROM t_ca WHERE s LIKE '1%' SETTINGS lightweight_deletes_sync = 2" +$CLICKHOUSE_CLIENT --query " +DELETE FROM t_plain WHERE s LIKE '1%' SETTINGS lightweight_deletes_sync = 2" + +echo -n 'after_delete_like: ' +$CLICKHOUSE_CLIENT --query "$CMP_QUERY" + +# --- Lightweight DELETE 3: rows with v > 1500 --- +$CLICKHOUSE_CLIENT --query " +DELETE FROM t_ca WHERE v > 1500 SETTINGS lightweight_deletes_sync = 2" +$CLICKHOUSE_CLIENT --query " +DELETE FROM t_plain WHERE v > 1500 SETTINGS lightweight_deletes_sync = 2" + +echo -n 'after_delete_range: ' +$CLICKHOUSE_CLIENT --query "$CMP_QUERY" + +# --- OPTIMIZE FINAL: force merge so lightweight deletes are physically applied --- +$CLICKHOUSE_CLIENT --query "OPTIMIZE TABLE t_ca FINAL" +$CLICKHOUSE_CLIENT --query "OPTIMIZE TABLE t_plain FINAL" + +echo -n 'after_optimize: ' +$CLICKHOUSE_CLIENT --query "$CMP_QUERY" + +# --- Final sanity: row count and data equality --- +$CLICKHOUSE_CLIENT --query " +SELECT 'final_rows_match', count() = (SELECT count() FROM t_plain) FROM t_ca" +$CLICKHOUSE_CLIENT --query " +SELECT 'final_data_match', + (SELECT groupArray((id, v, s)) FROM (SELECT id, v, s FROM t_ca ORDER BY id)) = + (SELECT groupArray((id, v, s)) FROM (SELECT id, v, s FROM t_plain ORDER BY id))" + +$CLICKHOUSE_CLIENT --query "DROP TABLE t_ca SYNC" +$CLICKHOUSE_CLIENT --query "DROP TABLE t_plain SYNC" diff --git a/tests/queries/0_stateless/04294_cas_patch_parts.reference b/tests/queries/0_stateless/04294_cas_patch_parts.reference new file mode 100644 index 000000000000..0f54f244519c --- /dev/null +++ b/tests/queries/0_stateless/04294_cas_patch_parts.reference @@ -0,0 +1,6 @@ +after_patch_delete_1: match +after_patch_delete_2: match +ca_has_patch_part: 1 +after_optimize: match +final_rows_match 1 +final_data_match 1 diff --git a/tests/queries/0_stateless/04294_cas_patch_parts.sh b/tests/queries/0_stateless/04294_cas_patch_parts.sh new file mode 100755 index 000000000000..323ae0e46c9a --- /dev/null +++ b/tests/queries/0_stateless/04294_cas_patch_parts.sh @@ -0,0 +1,88 @@ +#!/usr/bin/env bash +# Tags: no-fasttest +# ^ cas is an object-storage metadata type; keep it off the minimal fasttest image. + +# Correctness oracle for PATCH PARTS (the native lightweight-update model, B5) on content-addressed +# disks (CAS M7). The default lightweight DELETE mode is `alter_update` (a heavy mutation); this test +# forces the lightweight-update path with `lightweight_delete_mode = 'lightweight_update_force'`, which +# produces a PATCH PART (an `UPDATE _row_exists = 0`). `_force` THROWS if the table cannot do a +# lightweight update, so a successful run is itself proof the patch-part path was exercised — on a +# content-addressed disk the patch part is written through the same whole-part transaction as any part. +# +# Lightweight updates require materialized `_block_number` / `_block_offset` columns +# (enable_block_number_column / enable_block_offset_column) and a non-UNIQUE-KEY custom-partitioned +# table. Both tables get identical settings, data, and operations; every assertion is a self-checking +# CA-vs-plain equality so the reference file is trivially correct. + +CUR_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd) +# shellcheck source=../shell_config.sh +. "$CUR_DIR"/../shell_config.sh + +DISK_CA="disk( + type = object_storage, + object_storage_type = local, + metadata_type = cas, + server_root_id = '04294', + name = '04294_cas_patch', + path = '04294_cas_patch_pool/')" + +$CLICKHOUSE_CLIENT --query "DROP TABLE IF EXISTS t_ca SYNC" +$CLICKHOUSE_CLIENT --query "DROP TABLE IF EXISTS t_plain SYNC" + +$CLICKHOUSE_CLIENT --query " +CREATE TABLE t_ca (id UInt64, v UInt64, s String) +ENGINE = MergeTree ORDER BY id +SETTINGS disk = ${DISK_CA}, enable_block_number_column = 1, enable_block_offset_column = 1" + +$CLICKHOUSE_CLIENT --query " +CREATE TABLE t_plain (id UInt64, v UInt64, s String) +ENGINE = MergeTree ORDER BY id +SETTINGS enable_block_number_column = 1, enable_block_offset_column = 1" + +# Seed both identically across two parts. +$CLICKHOUSE_CLIENT --query "INSERT INTO t_ca SELECT number, number * 10, toString(number) FROM numbers(100)" +$CLICKHOUSE_CLIENT --query "INSERT INTO t_ca SELECT number, number * 10, toString(number) FROM numbers(100, 100)" +$CLICKHOUSE_CLIENT --query "INSERT INTO t_plain SELECT number, number * 10, toString(number) FROM numbers(100)" +$CLICKHOUSE_CLIENT --query "INSERT INTO t_plain SELECT number, number * 10, toString(number) FROM numbers(100, 100)" + +CMP_QUERY="SELECT if( + (SELECT groupArray((id, v, s)) FROM (SELECT id, v, s FROM t_ca ORDER BY id)) = + (SELECT groupArray((id, v, s)) FROM (SELECT id, v, s FROM t_plain ORDER BY id)), + 'match', 'DIFF')" + +# Force the patch-part (lightweight-update) path. `_force` throws if unsupported, so success == patch path. +LWU_SETTINGS="SETTINGS enable_lightweight_update = 1, lightweight_delete_mode = 'lightweight_update_force', lightweight_deletes_sync = 2" + +# --- Patch-part DELETE 1 --- +$CLICKHOUSE_CLIENT --query "DELETE FROM t_ca WHERE id % 5 = 0 ${LWU_SETTINGS}" +$CLICKHOUSE_CLIENT --query "DELETE FROM t_plain WHERE id % 5 = 0 ${LWU_SETTINGS}" +echo -n 'after_patch_delete_1: ' +$CLICKHOUSE_CLIENT --query "$CMP_QUERY" + +# --- Patch-part DELETE 2 (overlaps the first) --- +$CLICKHOUSE_CLIENT --query "DELETE FROM t_ca WHERE v > 1500 ${LWU_SETTINGS}" +$CLICKHOUSE_CLIENT --query "DELETE FROM t_plain WHERE v > 1500 ${LWU_SETTINGS}" +echo -n 'after_patch_delete_2: ' +$CLICKHOUSE_CLIENT --query "$CMP_QUERY" + +# Prove a patch part really exists on the content-addressed table before it is merged away. +echo -n 'ca_has_patch_part: ' +$CLICKHOUSE_CLIENT --query " +SELECT count() > 0 FROM system.parts +WHERE database = currentDatabase() AND table = 't_ca' AND active AND startsWith(name, 'patch')" + +# --- OPTIMIZE FINAL applies the patch parts during merge --- +$CLICKHOUSE_CLIENT --query "OPTIMIZE TABLE t_ca FINAL" +$CLICKHOUSE_CLIENT --query "OPTIMIZE TABLE t_plain FINAL" +echo -n 'after_optimize: ' +$CLICKHOUSE_CLIENT --query "$CMP_QUERY" + +# --- Final equality --- +$CLICKHOUSE_CLIENT --query "SELECT 'final_rows_match', count() = (SELECT count() FROM t_plain) FROM t_ca" +$CLICKHOUSE_CLIENT --query " +SELECT 'final_data_match', + (SELECT groupArray((id, v, s)) FROM (SELECT id, v, s FROM t_ca ORDER BY id)) = + (SELECT groupArray((id, v, s)) FROM (SELECT id, v, s FROM t_plain ORDER BY id))" + +$CLICKHOUSE_CLIENT --query "DROP TABLE t_ca SYNC" +$CLICKHOUSE_CLIENT --query "DROP TABLE t_plain SYNC" diff --git a/tests/queries/0_stateless/04295_cas_mutation_no_leftovers.reference b/tests/queries/0_stateless/04295_cas_mutation_no_leftovers.reference new file mode 100644 index 000000000000..52b50e1048c9 --- /dev/null +++ b/tests/queries/0_stateless/04295_cas_mutation_no_leftovers.reference @@ -0,0 +1,5 @@ +grew_above_baseline 1 +rows_after_mutations_correct 1 +fsck_unreachable 0 +fsck_dangling 0 +pool_meta_present 1 diff --git a/tests/queries/0_stateless/04295_cas_mutation_no_leftovers.sh b/tests/queries/0_stateless/04295_cas_mutation_no_leftovers.sh new file mode 100755 index 000000000000..e33c824c1023 --- /dev/null +++ b/tests/queries/0_stateless/04295_cas_mutation_no_leftovers.sh @@ -0,0 +1,131 @@ +#!/usr/bin/env bash +# Tags: no-fasttest, no-parallel +# ^ cas is an object-storage metadata type (keep it off the minimal fasttest image); +# no-parallel because we inspect a known on-disk pool directory from the shell and must not race +# another test sharing the same path. + +# No-leftovers oracle for MUTATIONS + lightweight DELETE (patch parts) on the content-addressed pool +# (CAS M7), exercised over a `local` object_storage backend so the pool is a plain directory the test +# shell can inspect directly. Mirrors 04290 but adds heavy mutations and a patch-part lightweight +# DELETE before the drop: a mutation supersedes the source part (its uniquely-owned blobs become +# unreachable) and writes a new part; carried-forward columns stay referenced. We assert that after +# DROP, draining the retire pipeline via `SYSTEM CAS GC RUN` then running `FSCK` on the +# running disk (T13) reads back zero `unreachable`/`dangling` objects (no mutated-away or patch-part +# blobs left behind), and that `_pool_meta` survives. Teardown is fail-closed (spec rev.8 §5/§9): FORGET +# the disk, verify `vanished(forgotten)`, then rm. + +CUR_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd) +# shellcheck source=../shell_config.sh +. "$CUR_DIR"/../shell_config.sh + +POOL_DIR="${CLICKHOUSE_USER_FILES_UNIQUE}_04295_${RANDOM}" + +rm -rf "${POOL_DIR:?}" +mkdir -p "${POOL_DIR}" + +count_pool_objects() { + local n_blobs n_parts + n_blobs=$(find "${POOL_DIR}/ca/blobs" "${POOL_DIR}/ca/packs" -type f 2>/dev/null | wc -l) + n_parts=$(find "${POOL_DIR}/ca/trees" -type f 2>/dev/null | wc -l) + echo $(( n_blobs + n_parts )) +} + +DISK_NAME="ca_04295_${CLICKHOUSE_TEST_UNIQUE_NAME}_${RANDOM}" +DISK_DEF="disk( + type = object_storage, + object_storage_type = local, + metadata_type = cas, + server_root_id = '04295', + name = '${DISK_NAME}', + path = '${POOL_DIR}/', + gc_enabled = 1, + gc_interval_sec = 1)" + +$CLICKHOUSE_CLIENT --query "DROP TABLE IF EXISTS t_cas_mut_leftovers SYNC" + +BASELINE=$(count_pool_objects) + +$CLICKHOUSE_CLIENT --query " +CREATE TABLE t_cas_mut_leftovers (a UInt64, v UInt64, s String) +ENGINE = MergeTree ORDER BY a +SETTINGS disk = ${DISK_DEF}, enable_block_number_column = 1, enable_block_offset_column = 1" + +# Several distinct inserts -> several distinct parts/blobs. +for i in 0 1 2 3; do + $CLICKHOUSE_CLIENT --query " + INSERT INTO t_cas_mut_leftovers + SELECT number + ${i} * 100000, (number + ${i} * 100000) * 10, toString(number + ${i} * 100000) + FROM numbers(100000)" +done + +AFTER_INSERT=$(count_pool_objects) +if [ "$AFTER_INSERT" -gt "$BASELINE" ]; then + echo "grew_above_baseline 1" +else + echo "grew_above_baseline 0 (baseline=${BASELINE} after_insert=${AFTER_INSERT})" +fi + +# Heavy mutation (rewrites the v column; a/s carry forward by reference -> shared blobs). +$CLICKHOUSE_CLIENT --query "ALTER TABLE t_cas_mut_leftovers UPDATE v = v + 1 WHERE a % 2 = 0 SETTINGS mutations_sync = 2" +# Heavy mutation: delete part of the data. +$CLICKHOUSE_CLIENT --query "ALTER TABLE t_cas_mut_leftovers DELETE WHERE a % 5 = 0 SETTINGS mutations_sync = 2" +# Patch part: forced lightweight-update DELETE (throws if unsupported, so success == patch path). +$CLICKHOUSE_CLIENT --query " + DELETE FROM t_cas_mut_leftovers WHERE a % 7 = 0 + SETTINGS enable_lightweight_update = 1, lightweight_delete_mode = 'lightweight_update_force', lightweight_deletes_sync = 2" + +# Self-checking row count: a ranges over [0, 400000); the two deletes drop a%5=0 and a%7=0 +# (the UPDATE does not change the row count), so the survivors are exactly a%5!=0 AND a%7!=0. +$CLICKHOUSE_CLIENT --query " +SELECT 'rows_after_mutations_correct', + count() = (SELECT count() FROM numbers(400000) WHERE number % 5 != 0 AND number % 7 != 0) +FROM t_cas_mut_leftovers" + +# Drop: every ref (original, mutated, and patch parts) is unlinked; all blobs/footers become GC fodder. +$CLICKHOUSE_CLIENT --query "DROP TABLE t_cas_mut_leftovers SYNC" + +# Drain GC deterministically: loop `SYSTEM CAS GC RUN` rounds until the retire +# pipeline's `pending_*` gauges (Task 7) read back to empty. Bounded (~60 rounds, half-second +# spacing), not a fixed sleep; column values are looked up BY HEADER NAME (not position) so the +# loop keeps working if the result set gains columns. +PENDING=1 +for _ in $(seq 1 60); do + PENDING=$($CLICKHOUSE_CLIENT --query "SYSTEM CAS GC RUN '${DISK_NAME}'" --format TSVWithNames \ + | awk -F'\t' 'NR==1 { for (i = 1; i <= NF; i++) col[$i] = i; next } + { print $col["pending_condemned"] } # already candidates+retired per its doc in Gc/CasGc.h; summing all three double-counts') + [ "${PENDING}" = "0" ] && break + sleep 0.5 +done + +if [ "${PENDING}" != "0" ]; then + echo "FAIL: GC did not drain the retire pipeline within the bounded loop (pending=${PENDING})" >&2 + exit 1 +fi + +# FSCK runs directly on the running disk (T13): a reachability audit that must read back zero +# unreachable/dangling objects. This is a strictly stronger no-leftovers oracle than the old dir-poll. +$CLICKHOUSE_CLIENT --query "SYSTEM CAS FSCK '${DISK_NAME}'" --format TSVWithNames \ + | awk -F'\t' 'NR==1 { for (i = 1; i <= NF; i++) col[$i] = i; next } + { print "fsck_unreachable", $col["unreachable"]; print "fsck_dangling", $col["dangling"] }' + +if [ -f "${POOL_DIR}/ca/_pool_meta" ]; then + echo "pool_meta_present 1" +else + echo "pool_meta_present 0" +fi + +# Fail-closed teardown (spec rev.8 §5/§9): FORGET the disk (force-Vanish, node-local; the table is +# already dropped above), verify it reads exactly `vanished(forgotten)` in the mounts table, and only +# then rm. A failed FORGET or an unexpected lifecycle aborts with the pool dir left in place. FORGET logs +# an operator WARNING; the harness runs the client at --send_logs_level=warning, so that expected warning +# would stream to stderr and be flagged as a failure -- suppress it for this call. +$CLICKHOUSE_CLIENT --allow_repeated_settings --send_logs_level=fatal \ + --query "SYSTEM CAS FORGET '${DISK_NAME}'" || { + echo "FORGET failed — leaving pool dir in place (fail-closed)"; exit 1; } +LIFECYCLE=$($CLICKHOUSE_CLIENT --query " + SELECT lifecycle || '(' || lifecycle_reason || ')' FROM system.cas_mounts + WHERE disk = '${DISK_NAME}'") +[ "${LIFECYCLE}" = "vanished(forgotten)" ] || { + echo "unexpected lifecycle after FORGET: ${LIFECYCLE}"; exit 1; } + +rm -rf "${POOL_DIR:?}" # safe: FORGET stopped and joined every CAS thread for this disk diff --git a/tests/queries/0_stateless/04299_cas_projection_inline_disk.reference b/tests/queries/0_stateless/04299_cas_projection_inline_disk.reference new file mode 100644 index 000000000000..f45aa2ce0497 --- /dev/null +++ b/tests/queries/0_stateless/04299_cas_projection_inline_disk.reference @@ -0,0 +1,60 @@ +count 2000 +sum_b 9000 +by_b 0 200 +by_b 1 200 +by_b 2 200 +by_b 3 200 +by_b 4 200 +by_b 5 200 +by_b 6 200 +by_b 7 200 +by_b 8 200 +by_b 9 200 +after_merge_count 2000 +after_merge_by_b 0 200 +after_merge_by_b 1 200 +after_merge_by_b 2 200 +after_merge_by_b 3 200 +after_merge_by_b 4 200 +after_merge_by_b 5 200 +after_merge_by_b 6 200 +after_merge_by_b 7 200 +after_merge_by_b 8 200 +after_merge_by_b 9 200 +has_projection 1 +uses_projection 1 +after_merge_reload_projection 1 +after_merge_reload_uses_projection 1 +after_add_projection_count 2000 +projections_after_add p_by_b 1 +projections_after_add p_sum 1 +uses_p_sum 1 +projections_after_materialize_reload p_by_b 1 +projections_after_materialize_reload p_sum 1 +after_materialize_reload_uses_p_sum 1 +projections_after_update_reload p_by_b 1 +projections_after_update_reload p_sum 1 +after_update_reload_uses_p_sum 1 +after_drop_projection_count 2000 +projections_after_drop p_sum 1 +after_reload_by_b 0 200 +after_reload_by_b 1 200 +after_reload_by_b 2 200 +after_reload_by_b 3 200 +after_reload_by_b 4 200 +after_reload_by_b 5 200 +after_reload_by_b 6 200 +after_reload_by_b 7 200 +after_reload_by_b 8 200 +after_reload_by_b 9 200 +after_reload_sum_b 0 199000 +after_reload_sum_b 1 199200 +after_reload_sum_b 2 199400 +after_reload_sum_b 3 199600 +after_reload_sum_b 4 199800 +after_reload_sum_b 5 200000 +after_reload_sum_b 6 200200 +after_reload_sum_b 7 200400 +after_reload_sum_b 8 200600 +after_reload_sum_b 9 200800 +dropped_ok diff --git a/tests/queries/0_stateless/04299_cas_projection_inline_disk.sql b/tests/queries/0_stateless/04299_cas_projection_inline_disk.sql new file mode 100644 index 000000000000..ed32d61ff357 --- /dev/null +++ b/tests/queries/0_stateless/04299_cas_projection_inline_disk.sql @@ -0,0 +1,125 @@ +-- Tags: no-fasttest +-- ^ cas is an object-storage metadata type; keep it off the minimal fasttest image. + +-- Projections on a cas disk: the projection's files are stored as nested keys +-- (.proj/) in the parent part's manifest. Verify INSERT writes a projection, a +-- projection-optimized SELECT returns correct results, and a merge (OPTIMIZE FINAL) rebuilds it. + +DROP TABLE IF EXISTS t_proj_cas; + +CREATE TABLE t_proj_cas (a UInt64, b UInt64, PROJECTION p_by_b (SELECT a, b ORDER BY b)) +ENGINE = MergeTree ORDER BY a +SETTINGS disk = disk( + type = object_storage, + object_storage_type = local, + metadata_type = cas, + server_root_id = '04299', + name = '04299_cas_projection', + path = '04299_cas_projection_pool/'); + +INSERT INTO t_proj_cas SELECT number, number % 10 FROM numbers(1000); +INSERT INTO t_proj_cas SELECT number, number % 10 FROM numbers(1000, 1000); + +SELECT 'count', count() FROM t_proj_cas; +SELECT 'sum_b', sum(b) FROM t_proj_cas; +SELECT 'by_b', b, count() FROM t_proj_cas GROUP BY b ORDER BY b; + +OPTIMIZE TABLE t_proj_cas FINAL; +SELECT 'after_merge_count', count() FROM t_proj_cas; +SELECT 'after_merge_by_b', b, count() FROM t_proj_cas GROUP BY b ORDER BY b; + +SELECT 'has_projection', countDistinct(name) FROM system.projection_parts +WHERE database = currentDatabase() AND table = 't_proj_cas' AND active; + +-- Prove the projection is actually selected by the optimizer (not a silent base-table fallback). +SET optimize_use_projections = 1, force_optimize_projection = 1; +SELECT 'uses_projection', countIf(explain LIKE '%p_by_b%') > 0 +FROM (EXPLAIN actions = 1 SELECT b, count() FROM t_proj_cas GROUP BY b); + +DROP TABLE t_proj_cas; + +-- ALTER ADD/DROP/MATERIALIZE PROJECTION + DETACH/ATTACH durability on the cas disk. We use +-- the server's default cas storage policy here rather than an inline `disk = disk(...)` +-- definition: an ALTER runs `checkColumnFilenamesForCollision`, which re-applies the table's raw +-- `settings_changes` AST through the generic settings path, and the inline `disk(...)` function value +-- is a CustomType that cannot be assigned to the String `disk` setting there (BAD_GET). That is a +-- pre-existing, metadata-type-independent inline-disk-vs-ALTER issue, unrelated to content addressing; +-- the projection ALTER mechanics on the CA disk are identical with the default-disk table. On the +-- cas-default test job this plain table lands on a CA disk; on the normal job it lands on +-- the local disk. The expected values below are the same on both (the oracle) — that equivalence is the +-- whole point of B58: a merge/mutate-rebuilt projection must survive a reload on CA exactly as on a +-- normal disk. +DROP TABLE IF EXISTS t_proj_cas_alter; + +CREATE TABLE t_proj_cas_alter (a UInt64, b UInt64, PROJECTION p_by_b (SELECT a, b ORDER BY b)) +ENGINE = MergeTree ORDER BY a; + +INSERT INTO t_proj_cas_alter SELECT number, number % 10 FROM numbers(1000); +INSERT INTO t_proj_cas_alter SELECT number, number % 10 FROM numbers(1000, 1000); +OPTIMIZE TABLE t_proj_cas_alter FINAL; + +-- B58 DURABILITY (merge): the merge-rebuilt projection must survive a DETACH/ATTACH — it must live in the +-- committed manifest, not only in memory. Reload and assert the projection is still active and usable. +DETACH TABLE t_proj_cas_alter; +ATTACH TABLE t_proj_cas_alter; +SELECT 'after_merge_reload_projection', countDistinct(name) FROM system.projection_parts +WHERE database = currentDatabase() AND table = 't_proj_cas_alter' AND active; +SET optimize_use_projections = 1, force_optimize_projection = 1; +SELECT 'after_merge_reload_uses_projection', countIf(explain LIKE '%p_by_b%') > 0 +FROM (EXPLAIN actions = 1 SELECT b, count() FROM t_proj_cas_alter GROUP BY b); +SET force_optimize_projection = 0; + +-- ALTER ADD PROJECTION on an existing table, then MATERIALIZE it on existing parts (rebuild path). +-- This exercises the temp-projection (.tmp_proj -> .proj) flow inside the mutated part on +-- the CA disk. +ALTER TABLE t_proj_cas_alter ADD PROJECTION p_sum (SELECT b, sum(a) GROUP BY b); +ALTER TABLE t_proj_cas_alter MATERIALIZE PROJECTION p_sum SETTINGS mutations_sync = 2; +SELECT 'after_add_projection_count', count() FROM t_proj_cas_alter; +-- After MATERIALIZE both the pre-existing p_by_b and the freshly built p_sum must be active. B58: the +-- mutation must carry p_by_b forward and persist p_sum into the manifest of the rebuilt part. +SELECT 'projections_after_add', name, count() FROM system.projection_parts +WHERE database = currentDatabase() AND table = 't_proj_cas_alter' AND active GROUP BY name ORDER BY name; + +-- The newly materialized projection must actually be selected by the optimizer. +SELECT 'uses_p_sum', countIf(explain LIKE '%p_sum%') > 0 +FROM (EXPLAIN actions = 1 SELECT b, sum(a) FROM t_proj_cas_alter GROUP BY b); + +-- B58 DURABILITY (materialize): both projections must survive a DETACH/ATTACH after MATERIALIZE. +DETACH TABLE t_proj_cas_alter; +ATTACH TABLE t_proj_cas_alter; +SELECT 'projections_after_materialize_reload', name, count() FROM system.projection_parts +WHERE database = currentDatabase() AND table = 't_proj_cas_alter' AND active GROUP BY name ORDER BY name; +SET optimize_use_projections = 1, force_optimize_projection = 1; +SELECT 'after_materialize_reload_uses_p_sum', countIf(explain LIKE '%p_sum%') > 0 +FROM (EXPLAIN actions = 1 SELECT b, sum(a) FROM t_proj_cas_alter GROUP BY b); +SET force_optimize_projection = 0; + +-- B58 DURABILITY (data mutation): a mutation rebuilds the part; the surviving projections must be carried +-- into the mutated part's manifest and stay usable after a reload. We use a DELETE that matches no rows so +-- the row data (and therefore every expected value below) is unchanged across CA and non-CA — the part is +-- still fully rewritten, exercising the mutation projection path. +ALTER TABLE t_proj_cas_alter DELETE WHERE b = 999 SETTINGS mutations_sync = 2; +DETACH TABLE t_proj_cas_alter; +ATTACH TABLE t_proj_cas_alter; +SELECT 'projections_after_update_reload', name, count() FROM system.projection_parts +WHERE database = currentDatabase() AND table = 't_proj_cas_alter' AND active GROUP BY name ORDER BY name; +SET optimize_use_projections = 1, force_optimize_projection = 1; +SELECT 'after_update_reload_uses_p_sum', countIf(explain LIKE '%p_sum%') > 0 +FROM (EXPLAIN actions = 1 SELECT b, sum(a) FROM t_proj_cas_alter GROUP BY b); +SET force_optimize_projection = 0; + +-- DROP a projection: results unchanged, the projection's nested keys leave the new part version. +ALTER TABLE t_proj_cas_alter DROP PROJECTION p_by_b SETTINGS mutations_sync = 2; +SELECT 'after_drop_projection_count', count() FROM t_proj_cas_alter; +SELECT 'projections_after_drop', name, count() FROM system.projection_parts +WHERE database = currentDatabase() AND table = 't_proj_cas_alter' AND active GROUP BY name ORDER BY name; + +-- Persistence: reload from the disk and re-read. `p_by_b` is gone, so the count() query falls back to the +-- base table; the surviving `p_sum` still serves the sum(a) aggregation after the reload. +DETACH TABLE t_proj_cas_alter; +ATTACH TABLE t_proj_cas_alter; +SELECT 'after_reload_by_b', b, count() FROM t_proj_cas_alter GROUP BY b ORDER BY b; +SELECT 'after_reload_sum_b', b, sum(a) FROM t_proj_cas_alter GROUP BY b ORDER BY b; + +DROP TABLE t_proj_cas_alter; +SELECT 'dropped_ok'; diff --git a/tests/queries/0_stateless/04300_cas_projection_multiblock.reference b/tests/queries/0_stateless/04300_cas_projection_multiblock.reference new file mode 100644 index 000000000000..c16767ba74c9 --- /dev/null +++ b/tests/queries/0_stateless/04300_cas_projection_multiblock.reference @@ -0,0 +1,14 @@ +count 2600000 +by_b_top 1299999 3899998 +by_b_top 1299998 3899996 +by_b_top 1299997 3899994 +after_merge_by_b_top 1299999 3899998 +after_merge_by_b_top 1299998 3899996 +after_merge_by_b_top 1299997 3899994 +after_materialize_count 2600000 +projection_active 1 +uses_projection 1 +after_reload_by_b_top 1299999 3899998 +after_reload_by_b_top 1299998 3899996 +after_reload_by_b_top 1299997 3899994 +dropped_ok diff --git a/tests/queries/0_stateless/04300_cas_projection_multiblock.sql b/tests/queries/0_stateless/04300_cas_projection_multiblock.sql new file mode 100644 index 000000000000..e8dcb65642f0 --- /dev/null +++ b/tests/queries/0_stateless/04300_cas_projection_multiblock.sql @@ -0,0 +1,48 @@ +-- Tags: no-fasttest +-- ^ cas is an object-storage metadata type; keep it off the minimal fasttest image. + +-- A projection built across MULTIPLE temp projection blocks (spill-and-merge) must read its own staged +-- temp blocks back on a content-addressed disk (B59). MergeProjectionPartsTask only EXERCISES the +-- read-back path when it has >1 temp projection part to merge (selected_parts.size() > 1); with a single +-- temp part it just renames it. The temp-part flush threshold is min_insert_block_size_rows, and the +-- background merge/mutation runs in the server's background context (NOT the client query settings), so +-- the threshold is the server default (DEFAULT_INSERT_BLOCK_SIZE = 1048449). We therefore make the +-- projection emit MORE rows than that: a high-cardinality GROUP BY key (1.3M distinct groups) forces >=2 +-- temp projection parts for BOTH an OPTIMIZE merge and an ALTER ... MATERIALIZE PROJECTION rebuild. + +DROP TABLE IF EXISTS t_pmb; +CREATE TABLE t_pmb (a UInt64, b UInt64, PROJECTION p_by_b (SELECT b, sum(a) GROUP BY b)) +ENGINE = MergeTree ORDER BY a +SETTINGS disk = disk(type = object_storage, object_storage_type = local, metadata_type = cas, + name = '04300_pmb', server_root_id = '04300', path = '04300_pmb_pool/'); + +-- 1.3M distinct b values, each appearing twice: a = number and a = number + 1300000, so for group b the +-- two rows are b and b + 1300000 -> sum(a) = 2*b + 1300000. The projection emits 1.3M rows > 1048449 -> +-- >= 2 temp projection parts on rebuild. +INSERT INTO t_pmb SELECT number, number FROM numbers(1300000); +INSERT INTO t_pmb SELECT number + 1300000, number FROM numbers(1300000); + +SELECT 'count', count() FROM t_pmb; +SELECT 'by_b_top', b, sum(a) AS s FROM t_pmb GROUP BY b ORDER BY s DESC, b LIMIT 3; + +-- MERGE the parts: the projection rebuild merges >1 temp projection part (multi-block read-back). +OPTIMIZE TABLE t_pmb FINAL; +SELECT 'after_merge_by_b_top', b, sum(a) AS s FROM t_pmb GROUP BY b ORDER BY s DESC, b LIMIT 3; + +-- MUTATION that rebuilds the projection across >1 temp projection block: +ALTER TABLE t_pmb MATERIALIZE PROJECTION p_by_b SETTINGS mutations_sync = 2; +SELECT 'after_materialize_count', count() FROM t_pmb; +SELECT 'projection_active', countDistinct(name) FROM system.projection_parts WHERE database = currentDatabase() AND table = 't_pmb' AND active; + +-- Prove the projection is actually selected by the optimizer (not a silent base-table fallback). +SET optimize_use_projections = 1, force_optimize_projection = 1; +SELECT 'uses_projection', countIf(explain LIKE '%p_by_b%') > 0 +FROM (EXPLAIN actions = 1 SELECT b, sum(a) FROM t_pmb GROUP BY b); +SET force_optimize_projection = 0; + +-- survives reload: +DETACH TABLE t_pmb; ATTACH TABLE t_pmb; +SELECT 'after_reload_by_b_top', b, sum(a) AS s FROM t_pmb GROUP BY b ORDER BY s DESC, b LIMIT 3; + +DROP TABLE t_pmb; +SELECT 'dropped_ok'; diff --git a/tests/queries/0_stateless/04316_reader_executor_basic.sql b/tests/queries/0_stateless/04316_reader_executor_basic.sql index 950db07f0003..f063513d9199 100644 --- a/tests/queries/0_stateless/04316_reader_executor_basic.sql +++ b/tests/queries/0_stateless/04316_reader_executor_basic.sql @@ -1,9 +1,12 @@ --- Tags: no-distributed-cache, no-encrypted-storage +-- Tags: no-distributed-cache, no-encrypted-storage, no-cas-storage -- The executor does not implement the distributed cache or decryption, so it -- falls back on those storage configs and the activation check below would not -- hold. Those stages can't be turned off from the test (unlike async prefetch -- and the filesystem cache), so skip them; the test still runs on local disk and --- plain object storage where the executor engages. +-- plain object storage where the executor engages. Content-addressed storage +-- always adds a `file_view` stage (the payload is a byte window inside a shared +-- blob), which the executor falls back on the same way -- see +-- `ReadPipeline::tryBuildReaderExecutor` -- so it never engages there either. -- -- Smoke test for the experimental ReaderExecutor read path. Reads a MergeTree -- table with `use_reader_executor = 1`, checks the data comes back correct (full diff --git a/tests/queries/0_stateless/04327_reader_executor_metrics.sql b/tests/queries/0_stateless/04327_reader_executor_metrics.sql index 9ec8766021d8..fead6d70ab72 100644 --- a/tests/queries/0_stateless/04327_reader_executor_metrics.sql +++ b/tests/queries/0_stateless/04327_reader_executor_metrics.sql @@ -1,8 +1,10 @@ --- Tags: no-distributed-cache, no-encrypted-storage +-- Tags: no-distributed-cache, no-encrypted-storage, no-cas-storage -- Like 04316, the executor falls back on the distributed cache and decryption -- (which can't be disabled from the test), so its metrics would not be emitted on -- those storage configs. Skip them; the test still runs on local disk and plain --- object storage where the executor engages. +-- object storage where the executor engages. Content-addressed storage always +-- adds a `file_view` stage (byte window inside a shared blob), which the +-- executor falls back on the same way -- see `ReadPipeline::tryBuildReaderExecutor`. -- -- Checks that the experimental ReaderExecutor emits its observability metrics. -- Reads a MergeTree table with `use_reader_executor = 1` and verifies, via the diff --git a/tests/queries/0_stateless/04328_reader_executor_kpi_async_metric.sql b/tests/queries/0_stateless/04328_reader_executor_kpi_async_metric.sql index 32988b1ea311..71063e563c4d 100644 --- a/tests/queries/0_stateless/04328_reader_executor_kpi_async_metric.sql +++ b/tests/queries/0_stateless/04328_reader_executor_kpi_async_metric.sql @@ -1,7 +1,9 @@ --- Tags: no-distributed-cache, no-encrypted-storage +-- Tags: no-distributed-cache, no-encrypted-storage, no-cas-storage -- The executor falls back on the distributed cache and decryption (which can't be -- disabled from the test), so its metrics would not be emitted there; skip those --- configs (as in 04316 / 04327). +-- configs (as in 04316 / 04327). Content-addressed storage always adds a +-- `file_view` stage (byte window inside a shared blob), which the executor +-- falls back on the same way -- see `ReadPipeline::tryBuildReaderExecutor`. -- -- End-to-end check that the modeled-cost KPI asynchronous metric -- `ReaderExecutorModeledCostMsPerRequestedMiB` moves when the executor does work. diff --git a/tests/queries/0_stateless/05000_cas_projection_carry_forward.reference b/tests/queries/0_stateless/05000_cas_projection_carry_forward.reference new file mode 100644 index 000000000000..42430b9414c0 --- /dev/null +++ b/tests/queries/0_stateless/05000_cas_projection_carry_forward.reference @@ -0,0 +1,10 @@ +count 100000 +no_projection 1 0 0 1249950000 +no_projection 1 0 2 1250000000 +no_projection 1 1 1 1249975000 +no_projection 1 1 3 1250025000 +with_projection 1 0 0 1249950000 +with_projection 1 0 2 1250000000 +with_projection 1 1 1 1249975000 +with_projection 1 1 3 1250025000 +projection_parts 2 4 diff --git a/tests/queries/0_stateless/05000_cas_projection_carry_forward.sql b/tests/queries/0_stateless/05000_cas_projection_carry_forward.sql new file mode 100644 index 000000000000..b36959270616 --- /dev/null +++ b/tests/queries/0_stateless/05000_cas_projection_carry_forward.sql @@ -0,0 +1,45 @@ +-- Tags: no-random-settings, no-random-merge-tree-settings + +-- B63: MATERIALIZE PROJECTION over a table with HETEROGENEOUS projection coverage. The first part +-- predates ADD PROJECTION (it must BUILD the projection); a later part already has it (the mutation +-- CARRIES IT FORWARD). On a content-addressed disk the carried-forward projection part was registered +-- in-memory without its rows_count / index granularity (the hardlinked files are not yet committed, so +-- it cannot reload them from disk), so a projection-served SELECT read back NOTHING from that part and +-- silently dropped its rows from the aggregate. The fix copies the source projection part's already-loaded +-- read-time state. This oracle compares the projection-served aggregate against the non-projection one in +-- the same run, so it is correct on both a plain and a content-addressed default disk. + +DROP TABLE IF EXISTS t_proj_cf; + +CREATE TABLE t_proj_cf (k1 UInt32, k2 UInt32, k3 UInt32, value UInt32) +ENGINE = MergeTree ORDER BY tuple(); + +-- First part: NO projection yet. +INSERT INTO t_proj_cf SELECT 1, number % 2, number % 4, number FROM numbers(50000); + +SYSTEM STOP MERGES t_proj_cf; + +ALTER TABLE t_proj_cf ADD PROJECTION aaaa (SELECT k1, k2, k3, sum(value) GROUP BY k1, k2, k3); + +-- Second part: built WITH the projection (INSERT after ADD PROJECTION). +INSERT INTO t_proj_cf SELECT 1, number % 2, number % 4, number FROM numbers(100000) LIMIT 50000, 100000; + +SYSTEM START MERGES t_proj_cf; + +ALTER TABLE t_proj_cf MATERIALIZE PROJECTION aaaa SETTINGS mutations_sync = 2; + +SELECT 'count', count() FROM t_proj_cf; + +SELECT 'no_projection', k1, k2, k3, sum(value) v +FROM t_proj_cf GROUP BY k1, k2, k3 ORDER BY k1, k2, k3 +SETTINGS optimize_use_projections = 0; + +SELECT 'with_projection', k1, k2, k3, sum(value) v +FROM t_proj_cf GROUP BY k1, k2, k3 ORDER BY k1, k2, k3; + +-- Every active part must carry a non-empty projection part after MATERIALIZE. +SELECT 'projection_parts', countDistinct(parent_name), min(rows) +FROM system.projection_parts +WHERE database = currentDatabase() AND table = 't_proj_cf' AND active; + +DROP TABLE t_proj_cf; diff --git a/tests/queries/0_stateless/05001_cas_attach_partition_projection.reference b/tests/queries/0_stateless/05001_cas_attach_partition_projection.reference new file mode 100644 index 000000000000..1d143c7cd0d1 --- /dev/null +++ b/tests/queries/0_stateless/05001_cas_attach_partition_projection.reference @@ -0,0 +1,11 @@ +before_attach_rows 7 +after_attach_rows 7 +data 7 21 21 +projection_served 0 0 +projection_served 1 1 +projection_served 2 2 +projection_served 3 3 +projection_served 4 4 +projection_served 5 5 +projection_served 6 6 +1 diff --git a/tests/queries/0_stateless/05001_cas_attach_partition_projection.sql b/tests/queries/0_stateless/05001_cas_attach_partition_projection.sql new file mode 100644 index 000000000000..5808b8d05efb --- /dev/null +++ b/tests/queries/0_stateless/05001_cas_attach_partition_projection.sql @@ -0,0 +1,46 @@ +-- Tags: no-random-settings, no-random-merge-tree-settings + +-- B64: DETACH PARTITION + ATTACH PARTITION of a part that has a projection. On a content-addressed +-- disk the part is re-attached from its detached STAGING directory (detached/attaching_/), so +-- the projection sub-directory is read as the NESTED path detached/attaching_/.proj. The +-- CA metadata storage recognized a projection directory only as a DIRECT child of a part +-- (/.proj), so the nested staging shape was missed: existsDirectory(".proj") returned +-- false during the attach-time load, and IMergeTreeDataPart::loadProjections registered the surviving +-- projection part with EMPTY columns and rows_count == 0 — making it unusable (PROJECTION_NOT_USED) and +-- causing CHECK TABLE to throw BROKEN_PROJECTION (in-memory columns empty vs on-disk columns), even +-- though the on-disk projection data was intact. Same projection-on-CA family as B58/B63, on the +-- ATTACH-clone path. This oracle exercises DETACH+ATTACH PARTITION (no projection drop) and asserts the +-- surviving projection re-attaches with the correct rows, is usable, and CHECK TABLE passes. It is +-- correct on both a plain and a content-addressed default disk. + +DROP TABLE IF EXISTS t_attach_proj; + +CREATE TABLE t_attach_proj (x Int32, y Int32, PROJECTION p (SELECT x, y ORDER BY x)) +ENGINE = MergeTree() PARTITION BY intDiv(y, 100) ORDER BY y; + +INSERT INTO t_attach_proj SELECT number, number FROM numbers(7); + +SELECT 'before_attach_rows', min(rows) +FROM system.projection_parts +WHERE database = currentDatabase() AND table = 't_attach_proj' AND active; + +ALTER TABLE t_attach_proj DETACH PARTITION 0; +ALTER TABLE t_attach_proj ATTACH PARTITION 0; + +-- The surviving projection must re-attach with the correct row count (rows > 0), not empty. +SELECT 'after_attach_rows', min(rows) +FROM system.projection_parts +WHERE database = currentDatabase() AND table = 't_attach_proj' AND active; + +-- Base data must be intact. +SELECT 'data', count(), sum(x), sum(y) FROM t_attach_proj; + +-- The projection must be usable: force_optimize_projection requires a projection to serve the query, +-- so this throws if the projection is broken/empty. +SELECT 'projection_served', x, y FROM t_attach_proj ORDER BY x +SETTINGS optimize_use_projections = 1, force_optimize_projection = 1; + +-- CHECK TABLE must pass (the projection's in-memory columns must match the on-disk columns). +CHECK TABLE t_attach_proj SETTINGS check_query_single_value_result = 1; + +DROP TABLE t_attach_proj; diff --git a/tests/queries/0_stateless/05002_cas_fetch_partition.reference b/tests/queries/0_stateless/05002_cas_fetch_partition.reference new file mode 100644 index 000000000000..b883895433c7 --- /dev/null +++ b/tests/queries/0_stateless/05002_cas_fetch_partition.reference @@ -0,0 +1,8 @@ +src_parts 1 +detached_parts 1 +detached_after_attach 0 +attached_rows 3 +data_readback 0 a +data_readback 2 b +data_readback 4 c +dropped_ok diff --git a/tests/queries/0_stateless/05002_cas_fetch_partition.sql b/tests/queries/0_stateless/05002_cas_fetch_partition.sql new file mode 100644 index 000000000000..cf81e488ca7a --- /dev/null +++ b/tests/queries/0_stateless/05002_cas_fetch_partition.sql @@ -0,0 +1,58 @@ +-- Tags: no-fasttest, no-shared-merge-tree, no-replicated-database +-- ^ no-fasttest: cas is an object-storage metadata type; keep it off the minimal image. +-- no-shared-merge-tree: this exercises open-source ReplicatedMergeTree on a cas disk. +-- no-replicated-database: the source replica_path is hard-coded per the database, not per the replica. + +-- ALTER TABLE ... FETCH PARTITION ... FROM '' on a cas disk: the gate is lifted +-- and a to_detached fetch takes the byte-fetch path (the downloaded files content-address into the +-- detached/ namespace; relink-into-detached is deferred). The fetched part must land usably in the CA +-- detached/ namespace: system.detached_parts lists it, ATTACH publishes an active part out of it, and a +-- SELECT reads back the exact source data. Both tables share one inline CA pool (a single server fetches +-- from its own zk path, as 03350 does), so this also exercises the cross-table detached landing. + +DROP TABLE IF EXISTS t_cas_fetch_src; +DROP TABLE IF EXISTS t_cas_fetch_dst; + +CREATE TABLE t_cas_fetch_src (key Int, s String) +ENGINE = ReplicatedMergeTree('/clickhouse/tables/{database}/t_cas_fetch_src', 'r1') +PARTITION BY (key % 2) ORDER BY key +SETTINGS disk = disk( + type = object_storage, + object_storage_type = local, + metadata_type = cas, + server_root_id = '05002', + name = '05002_cas_fetch', + path = '05002_cas_fetch_pool/'); + +CREATE TABLE t_cas_fetch_dst (key Int, s String) +ENGINE = ReplicatedMergeTree('/clickhouse/tables/{database}/t_cas_fetch_dst', 'r1') +PARTITION BY (key % 2) ORDER BY key +SETTINGS disk = disk( + type = object_storage, + object_storage_type = local, + metadata_type = cas, + server_root_id = '05002', + name = '05002_cas_fetch', + path = '05002_cas_fetch_pool/'); + +INSERT INTO t_cas_fetch_src VALUES (0, 'a'), (2, 'b'), (4, 'c'); +SELECT 'src_parts', count() FROM system.parts WHERE database = currentDatabase() AND table = 't_cas_fetch_src' AND active AND partition = '0'; + +-- Fetch the single part of partition 0 into the destination's detached/ namespace. +ALTER TABLE t_cas_fetch_dst FETCH PARTITION 0 FROM '/clickhouse/tables/{database}/t_cas_fetch_src' + SETTINGS insert_keeper_fault_injection_probability = 0; + +-- The fetched part must be present as a detached part. +SELECT 'detached_parts', count() FROM system.detached_parts WHERE database = currentDatabase() AND table = 't_cas_fetch_dst'; + +-- ATTACH publishes an active part out of the detached landing; SELECT must read back the exact data. +ALTER TABLE t_cas_fetch_dst ATTACH PARTITION 0 + SETTINGS insert_keeper_fault_injection_probability = 0; + +SELECT 'detached_after_attach', count() FROM system.detached_parts WHERE database = currentDatabase() AND table = 't_cas_fetch_dst'; +SELECT 'attached_rows', count() FROM t_cas_fetch_dst; +SELECT 'data_readback', key, s FROM t_cas_fetch_dst ORDER BY key; + +DROP TABLE t_cas_fetch_src; +DROP TABLE t_cas_fetch_dst; +SELECT 'dropped_ok'; diff --git a/tests/queries/0_stateless/05003_cas_freeze.reference b/tests/queries/0_stateless/05003_cas_freeze.reference new file mode 100644 index 000000000000..1740ea879482 --- /dev/null +++ b/tests/queries/0_stateless/05003_cas_freeze.reference @@ -0,0 +1,7 @@ +live_before_freeze 1 3 +live_before_freeze 2 2 +is_frozen 1 +live_after_drop 2 2 +command_type partition_id part_name backup_name +SYSTEM UNFREEZE 1 1_1_1_0 backup_05003 +dropped_ok diff --git a/tests/queries/0_stateless/05003_cas_freeze.sh b/tests/queries/0_stateless/05003_cas_freeze.sh new file mode 100755 index 000000000000..6468a1339c7b --- /dev/null +++ b/tests/queries/0_stateless/05003_cas_freeze.sh @@ -0,0 +1,62 @@ +#!/usr/bin/env bash +# Tags: no-fasttest +# ^ cas is an object-storage metadata type; keep it off the minimal fasttest image. + +# CA durability oracle: a FREEZE PARTITION snapshot is an independent GC root — it survives +# ALTER TABLE ... DROP PARTITION on the same partition and remains independently recoverable. +# This property is CA-specific: on a plain disk FREEZE makes a hard-link snapshot in shadow/ which +# is independent by construction; on a CA disk the frozen part must be written as a separate shadow +# ref (not merely an alias of the live part ref) so DROP PARTITION cannot destroy it. + +CURDIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd) +# shellcheck source=../shell_config.sh +. "$CURDIR"/../shell_config.sh + +UNFREEZE_STRUCTURE='command_type String, partition_id String, part_name String, backup_name String, backup_path String, part_backup_path String' + +${CLICKHOUSE_CLIENT} --query "DROP TABLE IF EXISTS t_cas_freeze;" + +${CLICKHOUSE_CLIENT} --query " +CREATE TABLE t_cas_freeze (k UInt32, v String) +ENGINE = MergeTree ORDER BY k PARTITION BY k +SETTINGS disk = disk( + type = object_storage, + object_storage_type = local, + metadata_type = cas, + server_root_id = '05003', + name = '05003_cas_freeze', + path = '05003_cas_freeze_pool/');" + +# Two partitions: k=1 (will be frozen then dropped) and k=2 (must survive untouched). +${CLICKHOUSE_CLIENT} --query "SYSTEM STOP MERGES t_cas_freeze;" +${CLICKHOUSE_CLIENT} --query "INSERT INTO t_cas_freeze VALUES (1, 'a'), (1, 'b'), (1, 'c');" +${CLICKHOUSE_CLIENT} --query "INSERT INTO t_cas_freeze VALUES (2, 'x'), (2, 'y');" +${CLICKHOUSE_CLIENT} --query "SYSTEM START MERGES t_cas_freeze;" + +${CLICKHOUSE_CLIENT} --query "SELECT 'live_before_freeze', k, count() FROM t_cas_freeze GROUP BY k ORDER BY k;" + +# Freeze only partition 1. The shadow ref becomes an independent GC root on the CA disk. +${CLICKHOUSE_CLIENT} --query "ALTER TABLE t_cas_freeze FREEZE PARTITION 1 WITH NAME 'backup_05003';" + +${CLICKHOUSE_CLIENT} --query " +SELECT 'is_frozen', count() FROM system.parts +WHERE database = currentDatabase() AND table = 't_cas_freeze' + AND partition_id = '1' AND is_frozen AND active;" + +# Drop the live partition 1. On a CA disk this must NOT remove the shadow ref. +${CLICKHOUSE_CLIENT} --query "ALTER TABLE t_cas_freeze DROP PARTITION 1;" + +# Live k=1 is gone; k=2 is untouched. +${CLICKHOUSE_CLIENT} --query "SELECT 'live_after_drop', k, count() FROM t_cas_freeze GROUP BY k ORDER BY k;" + +# THE KEY ASSERTION: SYSTEM UNFREEZE finds and removes the frozen snapshot of partition 1, +# proving it survived the DROP PARTITION as an independent shadow ref. +# SYSTEM UNFREEZE does not accept a FORMAT clause; default output is TSV, piped through +# clickhouse-local to filter to deterministic columns (backup_path/part_backup_path are +# absolute paths; command_type/partition_id/part_name/backup_name are stable). +${CLICKHOUSE_CLIENT} --query "SYSTEM UNFREEZE WITH NAME 'backup_05003';" \ + | ${CLICKHOUSE_LOCAL} --structure "$UNFREEZE_STRUCTURE" \ + --query "SELECT command_type, partition_id, part_name, backup_name FROM table ORDER BY partition_id FORMAT TSVWithNames" + +${CLICKHOUSE_CLIENT} --query "DROP TABLE t_cas_freeze;" +${CLICKHOUSE_CLIENT} --query "SELECT 'dropped_ok';" diff --git a/tests/queries/0_stateless/05004_cas_transactions.reference b/tests/queries/0_stateless/05004_cas_transactions.reference new file mode 100644 index 000000000000..5c0d3efe9fd2 --- /dev/null +++ b/tests/queries/0_stateless/05004_cas_transactions.reference @@ -0,0 +1,7 @@ +base 1 +in_txn 2 +after_commit 2 +in_txn2 3 +after_rollback 2 +rolled_back_absent 0 +done diff --git a/tests/queries/0_stateless/05004_cas_transactions.sh b/tests/queries/0_stateless/05004_cas_transactions.sh new file mode 100755 index 000000000000..5c0e9e27493b --- /dev/null +++ b/tests/queries/0_stateless/05004_cas_transactions.sh @@ -0,0 +1,62 @@ +#!/usr/bin/env bash +# Tags: no-fasttest, no-ordinary-database +# no-fasttest: cas is an object-storage metadata type; not available on the minimal +# fasttest image. +# no-ordinary-database: transactions require DatabaseAtomic (or similar); they are not supported +# on DatabaseOrdinary. + +# CA transactions oracle: proves that transactional INSERT/COMMIT/ROLLBACK works correctly on a +# content-addressed (CA) disk. Three scenarios are verified: +# 1. A committed transaction's rows become visible after COMMIT. +# 2. A rolled-back transaction's rows are absent after ROLLBACK; prior data is intact. +# 3. Counts are deterministic: base=1, after commit=2, after rollback=2, rolled-back row absent. +# +# MERGES ARE STOPPED immediately after CREATE to prevent any background merge from firing on +# transactional parts during the test (transactional multi-part merges are not yet implemented on +# CA disks — B53 in the backlog). + +CUR_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd) +# shellcheck source=../shell_config.sh +. "$CUR_DIR"/../shell_config.sh + +${CLICKHOUSE_CLIENT} --query "DROP TABLE IF EXISTS t_cas_txn;" + +${CLICKHOUSE_CLIENT} --query " +CREATE TABLE t_cas_txn (k UInt32, v String) +ENGINE = MergeTree ORDER BY k +SETTINGS disk = disk( + type = object_storage, + object_storage_type = local, + metadata_type = cas, + server_root_id = '05004', + name = '05004_cas_transactions', + path = '05004_cas_transactions_pool/');" + +${CLICKHOUSE_CLIENT} --query "SYSTEM STOP MERGES t_cas_txn;" + +# ── Step 1: base row (outside any transaction) ────────────────────────────── +${CLICKHOUSE_CLIENT} --query "INSERT INTO t_cas_txn VALUES (1, 'a');" +${CLICKHOUSE_CLIENT} --query "SELECT 'base', count() FROM t_cas_txn;" + +# ── Step 2: committed transaction ─────────────────────────────────────────── +# BEGIN … COMMIT must share one client connection (one --query / multiquery block). +${CLICKHOUSE_CLIENT} --query " +BEGIN TRANSACTION; +INSERT INTO t_cas_txn VALUES (2, 'b'); +SELECT 'in_txn', count() FROM t_cas_txn; +COMMIT;" + +${CLICKHOUSE_CLIENT} --query "SELECT 'after_commit', count() FROM t_cas_txn;" + +# ── Step 3: rolled-back transaction ───────────────────────────────────────── +${CLICKHOUSE_CLIENT} --query " +BEGIN TRANSACTION; +INSERT INTO t_cas_txn VALUES (3, 'c'); +SELECT 'in_txn2', count() FROM t_cas_txn; +ROLLBACK;" + +${CLICKHOUSE_CLIENT} --query "SELECT 'after_rollback', count() FROM t_cas_txn;" +${CLICKHOUSE_CLIENT} --query "SELECT 'rolled_back_absent', count() FROM t_cas_txn WHERE k = 3;" + +${CLICKHOUSE_CLIENT} --query "DROP TABLE t_cas_txn;" +${CLICKHOUSE_CLIENT} --query "SELECT 'done';" diff --git a/tests/queries/0_stateless/05005_cas_backup_restore.reference b/tests/queries/0_stateless/05005_cas_backup_restore.reference new file mode 100644 index 000000000000..36da2c7b5808 --- /dev/null +++ b/tests/queries/0_stateless/05005_cas_backup_restore.reference @@ -0,0 +1,6 @@ +source 5 9 ['a','b','c','d','e'] +restored 5 9 ['a','b','c','d','e'] +projection 1 2 +projection 2 2 +projection 3 1 +done diff --git a/tests/queries/0_stateless/05005_cas_backup_restore.sh b/tests/queries/0_stateless/05005_cas_backup_restore.sh new file mode 100755 index 000000000000..5d3d51c2c88f --- /dev/null +++ b/tests/queries/0_stateless/05005_cas_backup_restore.sh @@ -0,0 +1,50 @@ +#!/usr/bin/env bash +# Tags: no-fasttest +# no-fasttest: cas is an object-storage metadata type; not available on the minimal +# fasttest image. + +# CA BACKUP/RESTORE round-trip oracle: proves a table on a content-addressed (CA) disk survives a +# full BACKUP -> DROP -> RESTORE cycle with byte-for-byte data equality, including a PROJECTION. +# RESTORE materializes each part through one whole-part ContentAddressedTransaction +# (restorePartFromBackup, commit d384298602b); BACKUP-read already worked. This is the inline-CA +# oracle for B16/B34. + +CUR_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd) +# shellcheck source=../shell_config.sh +. "$CUR_DIR"/../shell_config.sh + +backup_name="Disk('backups', '${CLICKHOUSE_TEST_UNIQUE_NAME}')" + +${CLICKHOUSE_CLIENT} --query "DROP TABLE IF EXISTS t_cas_br;" +${CLICKHOUSE_CLIENT} --query "DROP TABLE IF EXISTS t_cas_br_restored;" + +${CLICKHOUSE_CLIENT} --query " +CREATE TABLE t_cas_br (k UInt32, v String, PROJECTION p (SELECT k, count() GROUP BY k)) +ENGINE = MergeTree ORDER BY k +SETTINGS disk = disk( + type = object_storage, + object_storage_type = local, + metadata_type = cas, + server_root_id = '05005', + name = '05005_cas_backup_restore', + path = '05005_cas_backup_restore_pool/');" + +# Two inserts -> two parts; deterministic rows. +${CLICKHOUSE_CLIENT} --query "INSERT INTO t_cas_br VALUES (1, 'a'), (2, 'b'), (1, 'c');" +${CLICKHOUSE_CLIENT} --query "INSERT INTO t_cas_br VALUES (3, 'd'), (2, 'e');" + +${CLICKHOUSE_CLIENT} --query "SELECT 'source', count(), sum(k), arraySort(groupArray(v)) FROM t_cas_br;" + +${CLICKHOUSE_CLIENT} --query "BACKUP TABLE t_cas_br TO ${backup_name} FORMAT Null;" + +${CLICKHOUSE_CLIENT} --query "RESTORE TABLE t_cas_br AS t_cas_br_restored FROM ${backup_name} FORMAT Null;" + +# Round-trip data equality on the restored table. +${CLICKHOUSE_CLIENT} --query "SELECT 'restored', count(), sum(k), arraySort(groupArray(v)) FROM t_cas_br_restored;" + +# Projection-served query on the restored table (proves the projection round-tripped). +${CLICKHOUSE_CLIENT} --query "SELECT 'projection', k, count() FROM t_cas_br_restored GROUP BY k ORDER BY k SETTINGS force_optimize_projection = 1;" + +${CLICKHOUSE_CLIENT} --query "DROP TABLE t_cas_br;" +${CLICKHOUSE_CLIENT} --query "DROP TABLE t_cas_br_restored;" +${CLICKHOUSE_CLIENT} --query "SELECT 'done';" diff --git a/tests/queries/0_stateless/05006_cas_deduplication_blob_insert.reference b/tests/queries/0_stateless/05006_cas_deduplication_blob_insert.reference new file mode 100644 index 000000000000..78bf4c7a8ea9 --- /dev/null +++ b/tests/queries/0_stateless/05006_cas_deduplication_blob_insert.reference @@ -0,0 +1 @@ +1000000 499999500000 499999500000 1000000 diff --git a/tests/queries/0_stateless/05006_cas_deduplication_blob_insert.sql b/tests/queries/0_stateless/05006_cas_deduplication_blob_insert.sql new file mode 100644 index 000000000000..e7e82ea3bf40 --- /dev/null +++ b/tests/queries/0_stateless/05006_cas_deduplication_blob_insert.sql @@ -0,0 +1,18 @@ +-- Tags: long +-- On a content-addressed S3 disk (the cas_s3 test lane), byte-identical column +-- blobs deduplicate to a single object, so the second column's conditional PUT (If-None-Match: *) loses +-- its precondition. Before the `Expect: 100-continue` fix the rejected large body triggered a +-- 500/broken-pipe retry storm in the S3 client and this INSERT hung for tens of minutes (B118). +-- Regression: the INSERT must complete and the data must round-trip. On non-CA storage this is a +-- trivial fast insert. + +DROP TABLE IF EXISTS t_cas_deduplicated_blob; + +CREATE TABLE t_cas_deduplicated_blob (x UInt64, y UInt64) ENGINE = MergeTree ORDER BY x; + +-- x and y are byte-identical -> same content hash -> the second blob's conditional PUT 412s. +INSERT INTO t_cas_deduplicated_blob SELECT number, number FROM numbers(1000000); + +SELECT count(), sum(x), sum(y), sum(x = y) FROM t_cas_deduplicated_blob; + +DROP TABLE t_cas_deduplicated_blob; diff --git a/tests/queries/0_stateless/05007_cas_gc_introspection.reference b/tests/queries/0_stateless/05007_cas_gc_introspection.reference new file mode 100644 index 000000000000..486914465921 --- /dev/null +++ b/tests/queries/0_stateless/05007_cas_gc_introspection.reference @@ -0,0 +1,6 @@ +1 1 1 +1 +1 1 1 +1 1 1 +1 +ok diff --git a/tests/queries/0_stateless/05007_cas_gc_introspection.sh b/tests/queries/0_stateless/05007_cas_gc_introspection.sh new file mode 100755 index 000000000000..0832a5b83a84 --- /dev/null +++ b/tests/queries/0_stateless/05007_cas_gc_introspection.sh @@ -0,0 +1,109 @@ +#!/usr/bin/env bash +# Tags: no-fasttest +# ^ cas is an object-storage metadata type; keep it off the minimal fasttest image. + +# Introspection coverage for the content-addressed (CA) garbage collector: the +# `SYSTEM CAS GC RUN ` command runs one GC round synchronously and +# the round is recorded in `system.cas_gc_log` (a Start + Finish row +# per round, like `part_log`). We build a CA disk inline (named, so the SYSTEM command can target it), +# create garbage by inserting then truncating, run the round a few times, flush the log, and assert +# the rows are there with the right shape — including a non-empty per-round `ProfileEvents` delta +# (the Manual round runs on the query thread, which always has an attached ThreadStatus that captures +# ProfileEvents). +# +# This is a .sh test (not .sql) because `SYSTEM CAS GC RUN` now returns a +# one-row-per-disk result set (UX pass); the three synchronous rounds below only care about their +# side effects on the log, so their own output is redirected to /dev/null. + +CUR_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd) +# shellcheck source=../shell_config.sh +. "$CUR_DIR"/../shell_config.sh + +${CLICKHOUSE_CLIENT} --multiline -q """ +DROP TABLE IF EXISTS t_cas_gc_introspection; + +-- A named inline CA disk: the \`name\` is what \`SYSTEM CAS GC RUN \` +-- targets and what lands in the log's \`disk_name\` column. +CREATE TABLE t_cas_gc_introspection (a UInt64, s String) +ENGINE = MergeTree ORDER BY a +SETTINGS disk = disk( + type = object_storage, + object_storage_type = local, + metadata_type = cas, + server_root_id = '05007', + name = '05007_cas_gc_introspection', + path = '05007_cas_gc_introspection_pool/', + gc_enabled = 1, + gc_interval_sec = 1), + old_parts_lifetime = 1; + +-- Two distinct inserts => distinct blobs (not deduped away), then TRUNCATE drops every ref so the +-- blobs/trees become unreferenced GC fodder. +INSERT INTO t_cas_gc_introspection SELECT number, toString(number) FROM numbers(1000); +INSERT INTO t_cas_gc_introspection SELECT number, toString(number) FROM numbers(1000, 1000); +TRUNCATE TABLE t_cas_gc_introspection; +""" + +# Run several synchronous rounds: the first rounds mark the retired candidates, later rounds delete +# them once the durable watermark floor advances past the builds (the background renewer does this). +${CLICKHOUSE_CLIENT} -q "SYSTEM CAS GC RUN '05007_cas_gc_introspection'" > /dev/null +${CLICKHOUSE_CLIENT} -q "SYSTEM CAS GC RUN '05007_cas_gc_introspection'" > /dev/null +${CLICKHOUSE_CLIENT} -q "SYSTEM CAS GC RUN '05007_cas_gc_introspection'" > /dev/null + +${CLICKHOUSE_CLIENT} --multiline -q """ +SYSTEM FLUSH LOGS cas_gc_log; + +-- A Start, a Finish, and a Manual-triggered row were all recorded for this disk. +SELECT + countIf(event_type = 'Start') > 0, + countIf(event_type = 'Finish') > 0, + countIf(trigger = 'Manual') > 0 +FROM system.cas_gc_log +WHERE disk_name LIKE '%05007_cas_gc_introspection%'; + +-- A synchronous Manual Finish captured a non-empty per-round ProfileEvents delta (the round touches +-- the object storage, so Cas*/Disk*/S3* counters are non-zero). The query thread is always attached, +-- so capture is active for the Manual path. +SELECT any(length(ProfileEvents)) > 0 +FROM system.cas_gc_log +WHERE disk_name LIKE '%05007_cas_gc_introspection%' + AND event_type = 'Finish' + AND trigger = 'Manual'; + +-- Per-phase rows: a folding round emits one Phase row per phase it reached (19 of them), so a run +-- that folded at least once must show most of the phase vocabulary, including the fold's own +-- ref-prefix enumeration and the round-commit CAS. +SELECT countDistinct(phase) >= 10, + countIf(phase = 'fold_ref_group') > 0, + countIf(phase = 'round_commit') > 0 +FROM system.cas_gc_log +WHERE disk_name LIKE '%05007_cas_gc_introspection%' + AND event_type = 'Phase'; + +-- The correlator: every row of the most recent round of this disk -- its Start, each Phase, and its +-- Finish -- shares one \`round_id\`, and that round emitted at least one Phase row between them. +SELECT countIf(event_type = 'Start') = 1, + countIf(event_type = 'Finish') = 1, + countIf(event_type = 'Phase') > 0 +FROM system.cas_gc_log +WHERE round_id = ( + SELECT round_id FROM system.cas_gc_log + WHERE disk_name LIKE '%05007_cas_gc_introspection%' AND event_type = 'Finish' + ORDER BY event_time_microseconds DESC LIMIT 1); + +-- A Phase row's \`ProfileEvents\` is that phase's own delta, not the whole round's: the phase that +-- enumerates the ref prefix must show fewer events than the round summary it is a part of. That +-- enumeration lives in \`defer_decision\`, which owns the \`cas/refs/\` LIST. It is deliberately NOT +-- \`fold_ref_group\`: that phase is I/O-free by construction -- the keys are already in hand -- so its +-- own delta is empty and this assertion would answer 0 there no matter how healthy the round was. +SELECT max(length(ProfileEvents)) > 0 +FROM system.cas_gc_log +WHERE disk_name LIKE '%05007_cas_gc_introspection%' + AND event_type = 'Phase' AND phase = 'defer_decision'; + +-- The error path: a non-CA disk (the always-present local \`default\`) is rejected. +SYSTEM CAS GC RUN 'default'; -- { serverError BAD_ARGUMENTS } + +DROP TABLE t_cas_gc_introspection; +SELECT 'ok'; +""" diff --git a/tests/queries/0_stateless/05008_cas_gc_snapshot_prune.reference b/tests/queries/0_stateless/05008_cas_gc_snapshot_prune.reference new file mode 100644 index 000000000000..9972842f9827 --- /dev/null +++ b/tests/queries/0_stateless/05008_cas_gc_snapshot_prune.reference @@ -0,0 +1 @@ +1 1 diff --git a/tests/queries/0_stateless/05008_cas_gc_snapshot_prune.sh b/tests/queries/0_stateless/05008_cas_gc_snapshot_prune.sh new file mode 100755 index 000000000000..9478b4bc72c5 --- /dev/null +++ b/tests/queries/0_stateless/05008_cas_gc_snapshot_prune.sh @@ -0,0 +1,77 @@ +#!/usr/bin/env bash +# Tags: no-fasttest +# ^ cas is an object-storage metadata type; keep it off the minimal fasttest image. + +# End-to-end: a content-addressed (CA) GC round physically deletes unreferenced objects ONLY through +# the ack-floor retired-cursor pipeline — a blob is condemned (stage 1), floor-passed / republished as +# `delete_pending` (stage 2), then exact-token deleted (stage 3), surfaced as `entries_redeleted` / +# `objects_deleted` in `system.cas_gc_log`. We build a named inline CA +# disk, create garbage (INSERT then TRUNCATE), then run synchronous GC rounds in a retry loop until a +# round reports a physical delete — graduation (and so the physical delete) is round-paced, gated on +# `condemn_round < current_round` in `settleEntry` (`renewWatermarkOnce` exists but does not gate it), +# so we poll for a round boundary to pass rather than assume a fixed round count. Once a delete is +# observed we assert every physical delete went through stage 3 (`entries_redeleted >= objects_deleted`): +# the redelete loop is the SOLE content-delete site and counts one redelete per attempt +# (Deleted/Absent/Replaced), so this inequality is a structural identity of the pipeline and an ad-hoc +# delete that bypassed the graduate->redelete path would break it. This proves deletion fires +# end-to-end through the real SystemLog path. + +CUR_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd) +# shellcheck source=../shell_config.sh +. "$CUR_DIR"/../shell_config.sh + +DISK="05008_cas_gc_snapshot_prune" + +# CA-over-LOCAL object storage emits a one-time about emulated conditional operations on +# mount; the .sh harness fails on ANY client stderr, so send only error+ logs to the client (real +# errors still surface and fail the test; the expected mount warning does not). +CLIENT="$CLICKHOUSE_CLIENT --send_logs_level=error" + +$CLIENT -q "DROP TABLE IF EXISTS t_ca_p9" + +$CLIENT -q " +CREATE TABLE t_ca_p9 (a UInt64, s String) +ENGINE = MergeTree ORDER BY a +SETTINGS disk = disk( + type = object_storage, + object_storage_type = local, + metadata_type = cas, + server_root_id = '${DISK}', + name = '${DISK}', + path = '${DISK}_pool/', + gc_enabled = 1, + gc_interval_sec = 1), + old_parts_lifetime = 1" + +# Two distinct inserts => distinct blobs (not deduplicated away); TRUNCATE drops every ref so the +# blobs/trees become unreferenced GC fodder. +$CLIENT -q "INSERT INTO t_ca_p9 SELECT number, toString(number) FROM numbers(1000)" +$CLIENT -q "INSERT INTO t_ca_p9 SELECT number, toString(number) FROM numbers(1000, 1000)" +$CLIENT -q "TRUNCATE TABLE t_ca_p9" + +# Run synchronous rounds until a physical delete is observed (bounded retries; graduation is +# round-paced, so we poll for a round boundary to pass rather than assume a fixed round count). +deleted=0 +for _ in $(seq 1 40); do + $CLIENT -q "SYSTEM CAS GC RUN '${DISK}'" > /dev/null + $CLIENT -q "SYSTEM FLUSH LOGS cas_gc_log" + deleted=$($CLIENT -q " + SELECT sum(objects_deleted) + FROM system.cas_gc_log + WHERE disk_name LIKE '%${DISK}%' AND event_type = 'Finish'") + if [ "${deleted:-0}" -gt 0 ]; then break; fi + sleep 0.5 +done + +# A physical delete happened, and every physical delete went through stage 3 of the retired-cursor +# pipeline (`entries_redeleted >= objects_deleted`, a structural identity: the redelete loop is the sole +# content-delete site and increments `redeleted` once per attempt). Expect: "deleted>0 redeleted>=deleted" +# => 1 1. +$CLIENT -q " +SELECT + sum(objects_deleted) > 0, + sum(entries_redeleted) >= sum(objects_deleted) +FROM system.cas_gc_log +WHERE disk_name LIKE '%${DISK}%' AND event_type = 'Finish'" + +$CLIENT -q "DROP TABLE t_ca_p9" diff --git a/tests/queries/0_stateless/05009_cas_event_log.reference b/tests/queries/0_stateless/05009_cas_event_log.reference new file mode 100644 index 000000000000..1ee92adcf718 --- /dev/null +++ b/tests/queries/0_stateless/05009_cas_event_log.reference @@ -0,0 +1,4 @@ +rows 2000 +1 +has_blob_put 1 +ok diff --git a/tests/queries/0_stateless/05009_cas_event_log.sql b/tests/queries/0_stateless/05009_cas_event_log.sql new file mode 100644 index 000000000000..e70ad7c95bc4 --- /dev/null +++ b/tests/queries/0_stateless/05009_cas_event_log.sql @@ -0,0 +1,44 @@ +-- Tags: no-fasttest +-- ^ cas is an object-storage metadata type; keep it off the minimal fasttest image. + +-- Default-ON contract for `system.cas_log`: the per-event content-addressed audit log is +-- enabled by default. `programs/server/config.xml` ships a `` section because the +-- CAS disk feature is experimental and this audit log is its primary forensic instrument (it costs +-- nothing when no CAS disk is configured — events are emitted only by content-addressed disks). After we +-- exercise a content-addressed disk end-to-end (INSERT, OPTIMIZE), the table exists and carries this +-- disk's write-path events. + +DROP TABLE IF EXISTS t_cas_event_log; + +CREATE TABLE t_cas_event_log (a UInt64, s String) +ENGINE = MergeTree ORDER BY a +SETTINGS disk = disk( + type = object_storage, + object_storage_type = local, + metadata_type = cas, + server_root_id = '05009', + name = '05009_cas_event_log', + path = '05009_cas_event_log_pool/'); + +-- Exercise the content-addressed write/merge path: this is exactly the work that emits put/ref events. +INSERT INTO t_cas_event_log SELECT number, toString(number % 7) FROM numbers(1000); +INSERT INTO t_cas_event_log SELECT number, toString(number % 7) FROM numbers(1000, 1000); +OPTIMIZE TABLE t_cas_event_log FINAL; + +SELECT 'rows', count() FROM t_cas_event_log; + +-- Make the buffered events durable before we read them back. +SYSTEM FLUSH LOGS cas_log; + +-- Default-on assertion #1: the table exists (the config ships the section). +EXISTS TABLE system.cas_log; + +-- Default-on assertion #2: our disk's write path emitted at least one `blob_put` event. Filter by +-- disk_name so parallel tests sharing this system table (e.g. the lane's own cas_s3 disk) +-- cannot perturb the result. +SELECT 'has_blob_put', count() > 0 +FROM system.cas_log +WHERE disk_name = '05009_cas_event_log' AND event_type = 'blob_put'; + +DROP TABLE t_cas_event_log; +SELECT 'ok'; diff --git a/tests/queries/0_stateless/05010_cas_mounts_gc_health.reference b/tests/queries/0_stateless/05010_cas_mounts_gc_health.reference new file mode 100644 index 000000000000..92f6b78e6c78 --- /dev/null +++ b/tests/queries/0_stateless/05010_cas_mounts_gc_health.reference @@ -0,0 +1,3 @@ +1 0 +1 1 +ok diff --git a/tests/queries/0_stateless/05010_cas_mounts_gc_health.sh b/tests/queries/0_stateless/05010_cas_mounts_gc_health.sh new file mode 100755 index 000000000000..f5a0a3a8be41 --- /dev/null +++ b/tests/queries/0_stateless/05010_cas_mounts_gc_health.sh @@ -0,0 +1,52 @@ +#!/usr/bin/env bash +# Tags: no-fasttest +# ^ cas is an object-storage metadata type; keep it off the minimal fasttest image. + +# B3: system.cas_mounts exposes per-disk GC health (is_leader / pending_reclaim / +# last_success_age_seconds / wedged_namespace_count), replacing the retired process-global +# CasGcIsLeader / CasGcPendingReclaimEntries CurrentMetrics gauges (clobbered with >= 2 CAS disks). +# Build one named inline CA disk, run a synchronous GC round so this process has led at least once, +# then assert the column shapes on the healthy single-disk fixture. +# +# This is a .sh test (not .sql) because `SYSTEM CAS GC RUN` now returns a +# one-row-per-disk result set (UX pass); the round below only cares about its side effect (leading +# once), so its own output is redirected to /dev/null. + +CUR_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd) +# shellcheck source=../shell_config.sh +. "$CUR_DIR"/../shell_config.sh + +${CLICKHOUSE_CLIENT} --multiline -q """ +DROP TABLE IF EXISTS t_cas_mounts_gc_health; + +CREATE TABLE t_cas_mounts_gc_health (a UInt64, s String) +ENGINE = MergeTree ORDER BY a +SETTINGS disk = disk( + type = object_storage, + object_storage_type = local, + metadata_type = cas, + server_root_id = '05010', + name = '05010_cas_mounts_gc_health', + path = '05010_cas_mounts_gc_health_pool/', + gc_enabled = 1, + gc_interval_sec = 1), + old_parts_lifetime = 1; + +INSERT INTO t_cas_mounts_gc_health SELECT number, toString(number) FROM numbers(100); +TRUNCATE TABLE t_cas_mounts_gc_health; +""" + +${CLICKHOUSE_CLIENT} -q "SYSTEM CAS GC RUN '05010_cas_mounts_gc_health'" > /dev/null + +${CLICKHOUSE_CLIENT} --multiline -q """ +SELECT is_leader, wedged_namespace_count +FROM system.cas_mounts +WHERE disk LIKE '%05010_cas_mounts_gc_health%'; + +SELECT pending_reclaim >= 0, last_success_age_seconds < 60 +FROM system.cas_mounts +WHERE disk LIKE '%05010_cas_mounts_gc_health%'; + +DROP TABLE t_cas_mounts_gc_health; +SELECT 'ok'; +""" diff --git a/tests/queries/0_stateless/05011_cas_gc_rebuild_access.reference b/tests/queries/0_stateless/05011_cas_gc_rebuild_access.reference new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/tests/queries/0_stateless/05011_cas_gc_rebuild_access.sh b/tests/queries/0_stateless/05011_cas_gc_rebuild_access.sh new file mode 100755 index 000000000000..1c530430bed3 --- /dev/null +++ b/tests/queries/0_stateless/05011_cas_gc_rebuild_access.sh @@ -0,0 +1,66 @@ +#!/usr/bin/env bash +# Tags: no-parallel + +CUR_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd) +# shellcheck source=../shell_config.sh +. "$CUR_DIR"/../shell_config.sh + +# Intent (E1): +# 1) A role granted only "SYSTEM CAS GC RUN" is REFUSED (ACCESS_DENIED) +# when it runs "SYSTEM CAS GC REBUILD ", but ALLOWED to run the per-round +# "SYSTEM CAS GC RUN". Granting the new +# "SYSTEM CAS GC REBUILD" right then permits REBUILD. +# 2) "SYSTEM CAS GC REBUILD" with NO disk is a SYNTAX_ERROR (required disk); +# naming a non-content-addressed disk yields BAD_ARGUMENTS (not a silent all-disks fan-out). +# 3) A user with ZERO grants gets ACCESS_DENIED on the plain +# "SYSTEM CAS GC RUN 'no_such_disk'" -- the privilege check runs +# before disk resolution, so denial fires even though the named disk does not exist (it would +# otherwise be UNKNOWN_DISK). +# (No CA disk needs to exist: the privilege check and the grammar/required-disk check both fire +# before any disk I/O; assert on the specific error codes. The `default` disk always exists and is +# never content-addressed, so it deterministically yields BAD_ARGUMENTS once a check is passed.) + +${CLICKHOUSE_CLIENT} --multiline -q """ +DROP USER IF EXISTS user_test_05011; +CREATE USER user_test_05011 IDENTIFIED WITH plaintext_password BY 'user_test_05011'; +REVOKE ALL ON *.* FROM user_test_05011; +GRANT SYSTEM CAS GC RUN ON *.* TO user_test_05011; +""" + +# GC-only role: REBUILD is refused; the per-round GC is allowed (fails later, on the disk-type check). +${CLICKHOUSE_CLIENT} --multiline --user user_test_05011 --password user_test_05011 -q """ +SYSTEM CAS GC REBUILD default; -- { serverError ACCESS_DENIED } +SYSTEM CAS GC RUN default; -- { serverError BAD_ARGUMENTS } +""" + +${CLICKHOUSE_CLIENT} --multiline -q """ +GRANT SYSTEM CAS GC REBUILD ON *.* TO user_test_05011; +""" + +# Granting the new right permits REBUILD (fails later, on the disk-type check). +${CLICKHOUSE_CLIENT} --multiline --user user_test_05011 --password user_test_05011 -q """ +SYSTEM CAS GC REBUILD default; -- { serverError BAD_ARGUMENTS } +""" + +# REBUILD requires an explicit disk (syntax error), and never silently fans out across all disks. +${CLICKHOUSE_CLIENT} --multiline -q """ +SYSTEM CAS GC REBUILD; -- { clientError SYNTAX_ERROR } +SYSTEM CAS GC REBUILD default; -- { serverError BAD_ARGUMENTS } +""" + +# A zero-grant user is denied before the disk is even resolved: naming a disk that does not exist +# still yields ACCESS_DENIED, not UNKNOWN_DISK. +${CLICKHOUSE_CLIENT} --multiline -q """ +DROP USER IF EXISTS user_test_05011_zero_grants; +CREATE USER user_test_05011_zero_grants IDENTIFIED WITH plaintext_password BY 'user_test_05011_zero_grants'; +REVOKE ALL ON *.* FROM user_test_05011_zero_grants; +""" + +${CLICKHOUSE_CLIENT} --multiline --user user_test_05011_zero_grants --password user_test_05011_zero_grants -q """ +SYSTEM CAS GC RUN 'no_such_disk'; -- { serverError ACCESS_DENIED } +""" + +${CLICKHOUSE_CLIENT} --multiline -q """ +DROP USER IF EXISTS user_test_05011; +DROP USER IF EXISTS user_test_05011_zero_grants; +""" diff --git a/tests/queries/0_stateless/05012_cas_mounts_typed_columns.reference b/tests/queries/0_stateless/05012_cas_mounts_typed_columns.reference new file mode 100644 index 000000000000..87a5f014d9e5 --- /dev/null +++ b/tests/queries/0_stateless/05012_cas_mounts_typed_columns.reference @@ -0,0 +1,3 @@ +DateTime64(3) +UUID +DateTime64(3) diff --git a/tests/queries/0_stateless/05012_cas_mounts_typed_columns.sql b/tests/queries/0_stateless/05012_cas_mounts_typed_columns.sql new file mode 100644 index 000000000000..5b050154c846 --- /dev/null +++ b/tests/queries/0_stateless/05012_cas_mounts_typed_columns.sql @@ -0,0 +1,8 @@ +-- E3: system.cas_mounts exposes typed columns for the lease identity/timing fields +-- (server_uuid as UUID, started_at/expires_at as DateTime64(3)) instead of raw String/UInt64. +-- No CA disk needs to be mounted -- the table's ColumnsDescription is static. + +SELECT type FROM system.columns +WHERE database = 'system' AND table = 'cas_mounts' + AND name IN ('server_uuid', 'started_at', 'expires_at') +ORDER BY name; diff --git a/tests/queries/0_stateless/05013_system_cas_drop_pool_member.reference b/tests/queries/0_stateless/05013_system_cas_drop_pool_member.reference new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/tests/queries/0_stateless/05013_system_cas_drop_pool_member.sql b/tests/queries/0_stateless/05013_system_cas_drop_pool_member.sql new file mode 100644 index 000000000000..e4732c06a050 --- /dev/null +++ b/tests/queries/0_stateless/05013_system_cas_drop_pool_member.sql @@ -0,0 +1,2 @@ +-- Grammar + dispatch only: execution needs a CA disk (covered by the integration test). +SYSTEM CAS DROP POOL MEMBER 'srv1' FROM DISK 'no_such_disk'; -- { serverError UNKNOWN_DISK } diff --git a/tests/queries/0_stateless/05014_insert_dedup_disk_commit_failpoint.reference b/tests/queries/0_stateless/05014_insert_dedup_disk_commit_failpoint.reference new file mode 100644 index 000000000000..d00491fd7e5b --- /dev/null +++ b/tests/queries/0_stateless/05014_insert_dedup_disk_commit_failpoint.reference @@ -0,0 +1 @@ +1 diff --git a/tests/queries/0_stateless/05014_insert_dedup_disk_commit_failpoint.sql b/tests/queries/0_stateless/05014_insert_dedup_disk_commit_failpoint.sql new file mode 100644 index 000000000000..8188ac59c42a --- /dev/null +++ b/tests/queries/0_stateless/05014_insert_dedup_disk_commit_failpoint.sql @@ -0,0 +1,28 @@ +-- Tags: zookeeper, no-fasttest, no-parallel +-- no-fasttest: needs an object-storage disk (storage_policy 's3_cache'). +-- no-parallel: enables a server-global failpoint on the part disk-transaction commit. + +DROP TABLE IF EXISTS t_dedup_disk_commit SYNC; + +CREATE TABLE t_dedup_disk_commit (k UInt64, v String) +ENGINE = ReplicatedMergeTree('/clickhouse/tables/{database}/t_dedup_disk_commit', 'r1') +ORDER BY k +SETTINGS storage_policy = 's3_cache'; + +SYSTEM ENABLE FAILPOINT part_storage_fail_commit_transaction; + +-- The disk-storage commit of the inserted part fails. The part must NOT be registered in Keeper: +-- before the fix the disk commit ran only in MergeTreeData::Transaction::commit, AFTER the Keeper +-- multi had durably created the block_id dedup znode, so this failure left a phantom dedup token. +INSERT INTO t_dedup_disk_commit SETTINGS insert_deduplicate = 1, insert_keeper_fault_injection_probability = 0 VALUES (1, 'x'); -- { serverError FAULT_INJECTED } + +SYSTEM DISABLE FAILPOINT part_storage_fail_commit_transaction; + +-- Byte-identical retry of the failed INSERT: it must really insert. Before the fix it silently +-- deduplicated against the phantom block_id ("already exists ... ignoring it") and was acked with +-- zero rows written — the acked-then-lost data loss. +INSERT INTO t_dedup_disk_commit SETTINGS insert_deduplicate = 1, insert_keeper_fault_injection_probability = 0 VALUES (1, 'x'); + +SELECT count() FROM t_dedup_disk_commit; + +DROP TABLE t_dedup_disk_commit SYNC; diff --git a/tests/queries/0_stateless/05015_cas_reject_fake_transaction.reference b/tests/queries/0_stateless/05015_cas_reject_fake_transaction.reference new file mode 100644 index 000000000000..b261da18d51a --- /dev/null +++ b/tests/queries/0_stateless/05015_cas_reject_fake_transaction.reference @@ -0,0 +1,2 @@ +1 +0 diff --git a/tests/queries/0_stateless/05015_cas_reject_fake_transaction.sh b/tests/queries/0_stateless/05015_cas_reject_fake_transaction.sh new file mode 100755 index 000000000000..6f8af853ef2a --- /dev/null +++ b/tests/queries/0_stateless/05015_cas_reject_fake_transaction.sh @@ -0,0 +1,28 @@ +#!/usr/bin/env bash +# Tags: no-fasttest +# ^ cas is an object-storage metadata type; keep it off the minimal fasttest image. + +# An explicit `use_fake_transaction=1` on a `cas` disk would silently break the +# atomic manifest/ref publish (per-file autocommit, no commit point for the transaction). The disk +# factory must reject it at CREATE TABLE time with BAD_ARGUMENTS instead of silently corrupting +# writes later -- mirrors the existing missing-`server_root_id` fail-close handling. + +CUR_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd) +# shellcheck source=../shell_config.sh +. "$CUR_DIR"/../shell_config.sh + +${CLICKHOUSE_CLIENT} -q " +DROP TABLE IF EXISTS t_cas_reject_fake_transaction; +CREATE TABLE t_cas_reject_fake_transaction (a UInt64, s String) +ENGINE = MergeTree ORDER BY a +SETTINGS disk = disk( + type = object_storage, + object_storage_type = local, + metadata_type = cas, + server_root_id = '05015', + name = '05015_cas_reject_fake_transaction', + path = '05015_cas_reject_fake_transaction_pool/', + use_fake_transaction = 1); +" 2>&1 | grep -cm1 "use_fake_transaction. cannot be enabled for metadata type" + +${CLICKHOUSE_CLIENT} -q "SELECT count() FROM system.tables WHERE name = 't_cas_reject_fake_transaction'" diff --git a/tests/queries/0_stateless/05016_cas_drop_pool_member_access.reference b/tests/queries/0_stateless/05016_cas_drop_pool_member_access.reference new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/tests/queries/0_stateless/05016_cas_drop_pool_member_access.sh b/tests/queries/0_stateless/05016_cas_drop_pool_member_access.sh new file mode 100755 index 000000000000..d5855c7dba48 --- /dev/null +++ b/tests/queries/0_stateless/05016_cas_drop_pool_member_access.sh @@ -0,0 +1,37 @@ +#!/usr/bin/env bash +# Tags: no-parallel + +CUR_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd) +# shellcheck source=../shell_config.sh +. "$CUR_DIR"/../shell_config.sh + +# Intent: `SYSTEM CAS DROP POOL MEMBER` checks access BEFORE resolving the disk (same +# pattern as the GC/GC REBUILD verbs covered by 05011_cas_gc_rebuild_access.sh), so this needs no +# CA disk at all: +# 1) A user with ZERO grants is refused with ACCESS_DENIED, even though the named disk does not +# exist (it would otherwise be UNKNOWN_DISK once past the access check). +# 2) After granting "SYSTEM CAS DROP POOL MEMBER", the same query passes the access +# check and fails later with UNKNOWN_DISK -- proving that grant, and only that grant, is what +# unlocks the verb. + +${CLICKHOUSE_CLIENT} --multiline -q """ +DROP USER IF EXISTS user_test_05016; +CREATE USER user_test_05016 IDENTIFIED WITH plaintext_password BY 'user_test_05016'; +REVOKE ALL ON *.* FROM user_test_05016; +""" + +${CLICKHOUSE_CLIENT} --multiline --user user_test_05016 --password user_test_05016 -q """ +SYSTEM CAS DROP POOL MEMBER 'x' FROM DISK 'y'; -- { serverError ACCESS_DENIED } +""" + +${CLICKHOUSE_CLIENT} --multiline -q """ +GRANT SYSTEM CAS DROP POOL MEMBER ON *.* TO user_test_05016; +""" + +${CLICKHOUSE_CLIENT} --multiline --user user_test_05016 --password user_test_05016 -q """ +SYSTEM CAS DROP POOL MEMBER 'x' FROM DISK 'y'; -- { serverError UNKNOWN_DISK } +""" + +${CLICKHOUSE_CLIENT} --multiline -q """ +DROP USER IF EXISTS user_test_05016; +""" diff --git a/tests/queries/0_stateless/05017_lazy_load_tables_sync_replica.reference b/tests/queries/0_stateless/05017_lazy_load_tables_sync_replica.reference new file mode 100644 index 000000000000..29b63ba2b7be --- /dev/null +++ b/tests/queries/0_stateless/05017_lazy_load_tables_sync_replica.reference @@ -0,0 +1,2 @@ +TableProxy +1 diff --git a/tests/queries/0_stateless/05017_lazy_load_tables_sync_replica.sh b/tests/queries/0_stateless/05017_lazy_load_tables_sync_replica.sh new file mode 100755 index 000000000000..e60e86d28f41 --- /dev/null +++ b/tests/queries/0_stateless/05017_lazy_load_tables_sync_replica.sh @@ -0,0 +1,40 @@ +#!/usr/bin/env bash +# Tags: zookeeper, no-replicated-database +# no-replicated-database: the test creates its own Atomic database with `lazy_load_tables = 1` +# and an explicit ReplicatedMergeTree ZooKeeper path, which would conflict with the DDL +# replication mechanism of DatabaseReplicated. + +CUR_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd) +# shellcheck source=../shell_config.sh +. "$CUR_DIR"/../shell_config.sh + +# Regression test: a table in a database created with `lazy_load_tables = 1` stays wrapped in a +# `StorageTableProxy` until first access. Before this fix, `SYSTEM SYNC REPLICA` on such a table +# failed with `BAD_ARGUMENTS: Table ... is not replicated`, because the interpreter cast the proxy +# directly to `StorageReplicatedMergeTree` instead of materializing it first. + +LAZY_DB="${CLICKHOUSE_DATABASE}_lazy" + +${CLICKHOUSE_CLIENT} -q "DROP DATABASE IF EXISTS ${LAZY_DB}" +${CLICKHOUSE_CLIENT} -q "CREATE DATABASE ${LAZY_DB} ENGINE = Atomic SETTINGS lazy_load_tables = 1" + +${CLICKHOUSE_CLIENT} -q " + CREATE TABLE ${LAZY_DB}.t (a UInt64) + ENGINE = ReplicatedMergeTree('/clickhouse/tables/${CLICKHOUSE_TEST_ZOOKEEPER_PREFIX}/lazy_sync_replica', 'r1') + ORDER BY a +" +${CLICKHOUSE_CLIENT} -q "INSERT INTO ${LAZY_DB}.t VALUES (1)" + +${CLICKHOUSE_CLIENT} -q "DETACH DATABASE ${LAZY_DB}" +${CLICKHOUSE_CLIENT} -q "ATTACH DATABASE ${LAZY_DB}" + +# Confirm the table is still an unmaterialized proxy at this point. +${CLICKHOUSE_CLIENT} -q "SELECT engine FROM system.tables WHERE database = '${LAZY_DB}' AND name = 't'" + +# This must succeed without first touching the table, i.e. without materializing the proxy +# through any other path. +${CLICKHOUSE_CLIENT} -q "SYSTEM SYNC REPLICA ${LAZY_DB}.t" + +${CLICKHOUSE_CLIENT} -q "SELECT count() FROM ${LAZY_DB}.t" + +${CLICKHOUSE_CLIENT} -q "DROP DATABASE ${LAZY_DB}" diff --git a/tests/queries/0_stateless/05019_cas_fsck_access.reference b/tests/queries/0_stateless/05019_cas_fsck_access.reference new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/tests/queries/0_stateless/05019_cas_fsck_access.sh b/tests/queries/0_stateless/05019_cas_fsck_access.sh new file mode 100755 index 000000000000..1f3a08a69dcb --- /dev/null +++ b/tests/queries/0_stateless/05019_cas_fsck_access.sh @@ -0,0 +1,44 @@ +#!/usr/bin/env bash +# Tags: no-parallel + +CUR_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd) +# shellcheck source=../shell_config.sh +. "$CUR_DIR"/../shell_config.sh + +# Access control for `SYSTEM CAS FSCK` (mirrors 05011_cas_gc_rebuild_access.sh): +# 1) A zero-grant user is denied before the disk is even resolved -- naming a disk that does not +# exist still yields ACCESS_DENIED, not UNKNOWN_DISK. +# 2) Granting "SYSTEM CAS FSCK" permits the verb; it then fails later, on the +# disk-type check (the `default` disk always exists and is never content-addressed, so the +# query deterministically fails with BAD_ARGUMENTS instead). +# (The UNMOUNT/MOUNT siblings this file once covered were removed with the Dormant lifecycle, +# spec rev.8 §9; FORGET / GC STOP / GC START access coverage is tracked for the acceptance task.) + +${CLICKHOUSE_CLIENT} --multiline -q """ +DROP USER IF EXISTS user_test_05019; +CREATE USER user_test_05019 IDENTIFIED WITH plaintext_password BY 'user_test_05019'; +REVOKE ALL ON *.* FROM user_test_05019; +""" + +# Zero grants: denied before the disk is resolved. +${CLICKHOUSE_CLIENT} --multiline --user user_test_05019 --password user_test_05019 -q """ +SYSTEM CAS FSCK 'no_such_disk'; -- { serverError ACCESS_DENIED } +""" + +${CLICKHOUSE_CLIENT} --multiline -q """ +GRANT SYSTEM CAS FSCK ON *.* TO user_test_05019; +""" + +# Granting the FSCK right permits it (fails later, on the disk-type check). +${CLICKHOUSE_CLIENT} --multiline --user user_test_05019 --password user_test_05019 -q """ +SYSTEM CAS FSCK default; -- { serverError BAD_ARGUMENTS } +""" + +# The verb requires an explicit disk (syntax error). +${CLICKHOUSE_CLIENT} --multiline -q """ +SYSTEM CAS FSCK; -- { clientError SYNTAX_ERROR } +""" + +${CLICKHOUSE_CLIENT} --multiline -q """ +DROP USER IF EXISTS user_test_05019; +""" diff --git a/tests/queries/0_stateless/05020_cas_fsck.reference b/tests/queries/0_stateless/05020_cas_fsck.reference new file mode 100644 index 000000000000..2f34f4455804 --- /dev/null +++ b/tests/queries/0_stateless/05020_cas_fsck.reference @@ -0,0 +1,7 @@ +3 +0 0 0 +disk reachable dangling unreachable pending_gc awaiting_gc unaccounted stale_edge corrupted_runs chain_broken unchecked lifeless_keys namespace_janitor_pending namespace_janitor_pending_bytes namespace_janitor_pending_lives ref_records_walked physical_bytes referenced_logical_bytes distinct_blobs total_blob_refs + 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 +fsck_non_ca_disk_rejected: 1 +fsck_requires_disk: 1 +second_forget_idempotent: vanished(forgotten) diff --git a/tests/queries/0_stateless/05020_cas_fsck.sh b/tests/queries/0_stateless/05020_cas_fsck.sh new file mode 100755 index 000000000000..ea799db64469 --- /dev/null +++ b/tests/queries/0_stateless/05020_cas_fsck.sh @@ -0,0 +1,85 @@ +#!/usr/bin/env bash +# Tags: no-fasttest +# ^ cas is an object-storage metadata type; keep it off the minimal fasttest image. + +# `SYSTEM CAS FSCK ` (runs on a RUNNING disk, T13) + GC RUN's `pending_*` drain +# columns + the fail-closed FORGET teardown (spec rev.8 §5/§9). FSCK is a read-only reachability audit +# that now runs directly on the mounted, live disk and prints a clean one-row summary. The GC RUN result +# set carries the retire pipeline's REMAINING (not this-round-delta) `pending_*` columns; on a disk with +# nothing outstanding to reclaim they read 0. Teardown is fail-closed: DROP the table, `SYSTEM CONTENT +# ADDRESSED FORGET` the disk (force-Vanish, node-local), verify via system.cas_mounts that +# it reads exactly `vanished(forgotten)`, and only THEN `rm -rf` the pool dir (FORGET stopped and joined +# every CAS background thread for this disk). A failed FORGET or an unexpected lifecycle aborts the test +# with the pool dir left in place (the scripts have no `set -e`, so the checks are explicit). + +CUR_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd) +# shellcheck source=../shell_config.sh +. "$CUR_DIR"/../shell_config.sh + +DISK_NAME="ca_fsck_${CLICKHOUSE_TEST_UNIQUE_NAME}_${RANDOM}" +POOL_DIR="${CLICKHOUSE_USER_FILES_UNIQUE}_fsck_${RANDOM}" +rm -rf "${POOL_DIR:?}" +mkdir -p "${POOL_DIR}" +DISK_CA="disk( + type = object_storage, + object_storage_type = local, + metadata_type = cas, + server_root_id = '05020', + name = '${DISK_NAME}', + path = '${POOL_DIR}/')" + +${CLICKHOUSE_CLIENT} --query "DROP TABLE IF EXISTS t_fsck SYNC" + +${CLICKHOUSE_CLIENT} --query " +CREATE TABLE t_fsck (id UInt64) ENGINE = MergeTree ORDER BY id +SETTINGS disk = ${DISK_CA}" + +# --- GC RUN's result set carries the new pending_* columns while the disk is mounted, and they read 0 +# on this fresh pool (nothing was ever written, so nothing was ever condemned) --- +${CLICKHOUSE_CLIENT} --format TSVWithNames --query "SYSTEM CAS GC RUN '${DISK_NAME}'" \ + | tr '\t' '\n' | grep -c "pending_candidates\|pending_condemned\|pending_retired" +${CLICKHOUSE_CLIENT} --format TSV --query "SYSTEM CAS GC RUN '${DISK_NAME}'" \ + | awk -F'\t' '{print $(NF-2), $(NF-1), $NF}' + +# --- FSCK on the RUNNING, healthy pool (T13: FSCK runs on a mounted disk): a clean one-row summary, +# no dangling/unreachable --- +${CLICKHOUSE_CLIENT} --format TSVWithNames --query "SYSTEM CAS FSCK '${DISK_NAME}'" \ + | sed "s/${DISK_NAME}//" + +# --- A non-CA disk is rejected (the always-present local \`default\`) --- +echo -n 'fsck_non_ca_disk_rejected: ' +${CLICKHOUSE_CLIENT} --query "SYSTEM CAS FSCK default" 2>&1 \ + | grep -cm1 "is not a content-addressed disk" + +# --- FSCK requires an explicit disk (syntax error) --- +echo -n 'fsck_requires_disk: ' +${CLICKHOUSE_CLIENT} --query "SYSTEM CAS FSCK" 2>&1 \ + | grep -cm1 "Syntax error" + +# --- Fail-closed teardown (spec rev.8 §5/§9): DROP the table, FORGET the disk (force-Vanish, node-local), +# verify it reads exactly `vanished(forgotten)`, and only then rm. A failed FORGET or an unexpected +# lifecycle aborts here, leaving the pool dir in place. --- +${CLICKHOUSE_CLIENT} --query "DROP TABLE t_fsck SYNC" +# FORGET logs an operator WARNING (the decommission is deliberately prominent in the server log); the +# clickhouse-test harness runs the client at --send_logs_level=warning, which would stream that expected +# warning to stderr and be flagged as a failure. Suppress it on the client for the FORGET call only. +${CLICKHOUSE_CLIENT} --allow_repeated_settings --send_logs_level=fatal \ + --query "SYSTEM CAS FORGET '${DISK_NAME}'" || { + echo "FORGET failed — leaving pool dir in place (fail-closed)"; exit 1; } +LIFECYCLE=$(${CLICKHOUSE_CLIENT} --query " + SELECT lifecycle || '(' || lifecycle_reason || ')' FROM system.cas_mounts + WHERE disk = '${DISK_NAME}'") +[ "${LIFECYCLE}" = "vanished(forgotten)" ] || { + echo "unexpected lifecycle after FORGET: ${LIFECYCLE}"; exit 1; } + +# --- A second FORGET is idempotent: it succeeds and the disk stays `vanished(forgotten)` (an already +# terminal Vanished pool is the terminal truth — nothing to force, nothing to double-retire). --- +${CLICKHOUSE_CLIENT} --allow_repeated_settings --send_logs_level=fatal \ + --query "SYSTEM CAS FORGET '${DISK_NAME}'" || { + echo "second FORGET failed — leaving pool dir in place (fail-closed)"; exit 1; } +LIFECYCLE_AGAIN=$(${CLICKHOUSE_CLIENT} --query " + SELECT lifecycle || '(' || lifecycle_reason || ')' FROM system.cas_mounts + WHERE disk = '${DISK_NAME}'") +echo "second_forget_idempotent: ${LIFECYCLE_AGAIN}" + +rm -rf "${POOL_DIR:?}" # safe: FORGET stopped and joined every CAS thread for this disk diff --git a/tests/queries/0_stateless/05021_lazy_load_tables_mutations.reference b/tests/queries/0_stateless/05021_lazy_load_tables_mutations.reference new file mode 100644 index 000000000000..8482a9714e8f --- /dev/null +++ b/tests/queries/0_stateless/05021_lazy_load_tables_mutations.reference @@ -0,0 +1,2 @@ +1 11 +2 20 diff --git a/tests/queries/0_stateless/05021_lazy_load_tables_mutations.sh b/tests/queries/0_stateless/05021_lazy_load_tables_mutations.sh new file mode 100755 index 000000000000..c43684319c66 --- /dev/null +++ b/tests/queries/0_stateless/05021_lazy_load_tables_mutations.sh @@ -0,0 +1,41 @@ +#!/usr/bin/env bash +# Tags: zookeeper, no-replicated-database +# no-replicated-database: the test creates its own Atomic database with `lazy_load_tables = 1` +# and an explicit ReplicatedMergeTree ZooKeeper path, which would conflict with the DDL +# replication mechanism of DatabaseReplicated. + +CUR_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd) +# shellcheck source=../shell_config.sh +. "$CUR_DIR"/../shell_config.sh + +# Regression test: a table in a database created with `lazy_load_tables = 1` stays wrapped in a +# `StorageTableProxy` until first access. Before this fix, any mutation (`ALTER ... UPDATE`, +# `MATERIALIZE TTL`, ...) on such a table failed with NOT_IMPLEMENTED "Table engine +# ReplicatedMergeTree doesn't support mutations": `StorageProxy` forwarded `mutate` but not +# `checkMutationIsPossible`, so `IStorage`'s throwing default fired with the nested engine's name. + +LAZY_DB="${CLICKHOUSE_DATABASE}_lazy" + +${CLICKHOUSE_CLIENT} -q "DROP DATABASE IF EXISTS ${LAZY_DB}" +${CLICKHOUSE_CLIENT} -q "CREATE DATABASE ${LAZY_DB} ENGINE = Atomic SETTINGS lazy_load_tables = 1" + +${CLICKHOUSE_CLIENT} -q " + CREATE TABLE ${LAZY_DB}.t (a UInt64, b UInt64) + ENGINE = ReplicatedMergeTree('/clickhouse/tables/${CLICKHOUSE_TEST_ZOOKEEPER_PREFIX}/lazy_mutations', 'r1') + ORDER BY a +" +${CLICKHOUSE_CLIENT} -q "INSERT INTO ${LAZY_DB}.t (a, b) VALUES (1, 10), (2, 20)" + +# DETACH + ATTACH the database so the table goes back to an unmaterialized proxy: the INSERT above +# has already materialized it once, and the point is to mutate through the fresh proxy. +${CLICKHOUSE_CLIENT} -q "DETACH DATABASE ${LAZY_DB}" +${CLICKHOUSE_CLIENT} -q "ATTACH DATABASE ${LAZY_DB}" + +${CLICKHOUSE_CLIENT} -q "ALTER TABLE ${LAZY_DB}.t UPDATE b = b + 1 WHERE a = 1 SETTINGS mutations_sync = 2" +# NOTE: MATERIALIZE TTL through a lazy proxy is still broken differently (the proxy's cached +# in-memory metadata carries columns only, no TTL -- see the StorageProxy forwarding audit report); +# this test deliberately pins only what the checkMutationIsPossible forward fixes. + +${CLICKHOUSE_CLIENT} -q "SELECT a, b FROM ${LAZY_DB}.t ORDER BY a" + +${CLICKHOUSE_CLIENT} -q "DROP DATABASE ${LAZY_DB}" diff --git a/tests/queries/0_stateless/05022_cas_verb_access.reference b/tests/queries/0_stateless/05022_cas_verb_access.reference new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/tests/queries/0_stateless/05022_cas_verb_access.sh b/tests/queries/0_stateless/05022_cas_verb_access.sh new file mode 100755 index 000000000000..a1a750c724fe --- /dev/null +++ b/tests/queries/0_stateless/05022_cas_verb_access.sh @@ -0,0 +1,69 @@ +#!/usr/bin/env bash + +CUR_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd) +# shellcheck source=../shell_config.sh +. "$CUR_DIR"/../shell_config.sh + +# Access control for the three lifecycle verbs added in the rev.8 disk-lifecycle round -- SYSTEM CONTENT +# ADDRESSED FORGET / GC STOP / GC START -- mirroring 05019_cas_fsck_access.sh. For each verb: +# 1) A zero-grant user is denied BEFORE the disk is resolved -- naming a disk that does not exist still +# yields ACCESS_DENIED (the access check runs ahead of getDisk), not UNKNOWN_DISK. +# 2) Granting the matching right permits the verb; it then fails later on the disk-type check (the always +# -present `default` disk exists and is never content-addressed, so it deterministically fails with +# BAD_ARGUMENTS without any lifecycle side effect). +# 3) The verb requires an explicit disk (all three route through the target-required parser like FSCK, so +# omitting the disk is a client-side SYNTAX_ERROR, not a silent fan-out). +# A unique user name keeps this parallel-safe (no global fixed-name object), and every verb run targets only +# `no_such_disk`/`default`, so nothing is ever actually decommissioned or reconfigured. + +USER="user_test_${CLICKHOUSE_TEST_UNIQUE_NAME}" + +${CLICKHOUSE_CLIENT} --multiline -q """ +DROP USER IF EXISTS ${USER}; +CREATE USER ${USER} IDENTIFIED WITH plaintext_password BY 'pw'; +REVOKE ALL ON *.* FROM ${USER}; +""" + +# (1) Zero grants: each verb is denied before the disk is resolved. +${CLICKHOUSE_CLIENT} --multiline --user "${USER}" --password pw -q """ +SYSTEM CAS FORGET 'no_such_disk'; -- { serverError ACCESS_DENIED } +""" +${CLICKHOUSE_CLIENT} --multiline --user "${USER}" --password pw -q """ +SYSTEM CAS GC STOP 'no_such_disk'; -- { serverError ACCESS_DENIED } +""" +${CLICKHOUSE_CLIENT} --multiline --user "${USER}" --password pw -q """ +SYSTEM CAS GC START 'no_such_disk'; -- { serverError ACCESS_DENIED } +""" + +# Grant each verb its matching right. +${CLICKHOUSE_CLIENT} --multiline -q """ +GRANT SYSTEM CAS FORGET ON *.* TO ${USER}; +GRANT SYSTEM CAS GC STOP ON *.* TO ${USER}; +GRANT SYSTEM CAS GC START ON *.* TO ${USER}; +""" + +# (2) Granted: the verb is permitted, then fails later on the disk-type check against `default`. +${CLICKHOUSE_CLIENT} --multiline --user "${USER}" --password pw -q """ +SYSTEM CAS FORGET default; -- { serverError BAD_ARGUMENTS } +""" +${CLICKHOUSE_CLIENT} --multiline --user "${USER}" --password pw -q """ +SYSTEM CAS GC STOP default; -- { serverError BAD_ARGUMENTS } +""" +${CLICKHOUSE_CLIENT} --multiline --user "${USER}" --password pw -q """ +SYSTEM CAS GC START default; -- { serverError BAD_ARGUMENTS } +""" + +# (3) Each verb requires an explicit disk (syntax error). +${CLICKHOUSE_CLIENT} --multiline -q """ +SYSTEM CAS FORGET; -- { clientError SYNTAX_ERROR } +""" +${CLICKHOUSE_CLIENT} --multiline -q """ +SYSTEM CAS GC STOP; -- { clientError SYNTAX_ERROR } +""" +${CLICKHOUSE_CLIENT} --multiline -q """ +SYSTEM CAS GC START; -- { clientError SYNTAX_ERROR } +""" + +${CLICKHOUSE_CLIENT} --multiline -q """ +DROP USER IF EXISTS ${USER}; +""" diff --git a/tests/queries/0_stateless/05023_cas_dropns_leaked_namespace.reference b/tests/queries/0_stateless/05023_cas_dropns_leaked_namespace.reference new file mode 100644 index 000000000000..acfbe0e078ed --- /dev/null +++ b/tests/queries/0_stateless/05023_cas_dropns_leaked_namespace.reference @@ -0,0 +1,14 @@ +empty_table_state_before_drop live +empty_table_has_format_version 1 +empty_table_has_no_ref_stream_before_drop 1 +empty_table_state_after_sync_drop removing +empty_table_terminal_stream_record_exists 1 +one_part_table_state_before_drop live +one_part_table_state_after_sync_drop removing +negative_control_state_after_truncate live +negative_control_state_after_reinsert live +negative_control_rows 1 +cycle_leaks_after_sync_drop 0 +captured_rows_absent_after_gc_fixpoint 1 +fsck_unreachable 0 +fsck_dangling 0 diff --git a/tests/queries/0_stateless/05023_cas_dropns_leaked_namespace.sh b/tests/queries/0_stateless/05023_cas_dropns_leaked_namespace.sh new file mode 100755 index 000000000000..dcedd337a239 --- /dev/null +++ b/tests/queries/0_stateless/05023_cas_dropns_leaked_namespace.sh @@ -0,0 +1,196 @@ +#!/usr/bin/env bash +# Tags: no-fasttest +# ^ cas is an object-storage metadata type (keep it off the minimal fasttest image); this test uses its +# own unique local-object-storage pool and a per-run CAS disk name, so unlike 04290_cas_no_leftovers +# it does not need no-parallel. + +# FINDING #2 regression test: `DROP TABLE ... SYNC` on a content-addressed MergeTree used to leave the +# table's CAS ref-catalog row `live` forever whenever `DirShape::TableDir`'s `existsDirectory` observed +# zero committed refs -- an empty table, or one whose last part was just removed. `dropAllData`'s own +# `existsDirectory` precheck skipped `removeRecursive`/`dropNamespace` entirely in that shape, so the +# SQL-level drop completed normally while the CAS catalog row leaked, one per create/drop cycle. +# +# The primary oracle is the pool's OWN plain-text `cas/ref_catalog` object, read directly off disk: the +# exact `st` (lifecycle) field recorded for the table's logical namespace. `SYSTEM CAS FSCK`'s +# unreachable/dangling counts are a secondary check only -- fsck correctly regards a `live` leak as +# CONSISTENT (nothing is unreachable; the row simply never dies), so it cannot detect this defect on its +# own; `04290_cas_no_leftovers.sh`'s fsck-only oracle is exactly why FINDING #2 shipped unnoticed. + +CUR_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd) +# shellcheck source=../shell_config.sh +. "$CUR_DIR"/../shell_config.sh + +POOL_DIR="${CLICKHOUSE_USER_FILES_UNIQUE}_05023_${RANDOM}" +DISK_NAME="ca_05023_${CLICKHOUSE_TEST_UNIQUE_NAME}_${RANDOM}" +SERVER_ROOT_ID="dropns05023" + +rm -rf "${POOL_DIR:?}" +mkdir -p "${POOL_DIR}" + +CATALOG_FILE="${POOL_DIR}/ca/cas/ref_catalog" + +# The pool's own plain-text catalog line for namespace $1, or empty if the namespace has no row at all. +catalog_line() { + grep -F "\"ns\":\"$1\"" "${CATALOG_FILE}" 2>/dev/null || true +} + +# The `st` (lifecycle) word recorded for namespace $1: "live"/"creating"/"removing", or "absent" if the +# namespace has no catalog row (matches `04290`'s field-by-name discipline: never assume a position). +catalog_state() { + local line + line=$(catalog_line "$1") + if [ -z "${line}" ]; then + echo "absent" + return + fi + echo "${line}" | grep -o '"st":"[a-z]*"' | head -1 | sed -E 's/"st":"([a-z]*)"/\1/' +} + +# ClickHouse's own store// fanout with the CAS archive boundary marker, exactly as +# `Cas::mirroredArchiveNamespace` builds it -- see PartPathParser.cpp. `$1` is the table's UUID. +namespace_of() { + local uuid="$1" + echo "${SERVER_ROOT_ID}/store/${uuid:0:3}/${uuid}@cas@" +} + +DISK_DEF="disk( + type = object_storage, + object_storage_type = local, + metadata_type = cas, + server_root_id = '${SERVER_ROOT_ID}', + name = '${DISK_NAME}', + path = '${POOL_DIR}/', + gc_enabled = 1, + gc_interval_sec = 100000)" +# ^ gc_enabled=1 so `SYSTEM CAS GC RUN` is available; the interval is long enough that no background +# round can fire during the test's own window, so the post-drop catalog state read directly below is +# stable -- only the manual `GC RUN` loop at the end may advance it. + +$CLICKHOUSE_CLIENT --query "DROP TABLE IF EXISTS t_dropns_empty SYNC" +$CLICKHOUSE_CLIENT --query "DROP TABLE IF EXISTS t_dropns_one_part SYNC" +$CLICKHOUSE_CLIENT --query "DROP TABLE IF EXISTS t_dropns_negative_control SYNC" +$CLICKHOUSE_CLIENT --query "DROP TABLE IF EXISTS t_dropns_cycle SYNC" + +# ---- (1) an EMPTY table: zero parts, zero namespace files beyond format_version.txt ---- +$CLICKHOUSE_CLIENT --query " +CREATE TABLE t_dropns_empty (a UInt64) +ENGINE = MergeTree ORDER BY a +SETTINGS disk = ${DISK_DEF}" + +EMPTY_UUID=$($CLICKHOUSE_CLIENT --query "SELECT uuid FROM system.tables WHERE database = currentDatabase() AND name = 't_dropns_empty'") +EMPTY_NS=$(namespace_of "${EMPTY_UUID}") + +echo "empty_table_state_before_drop $(catalog_state "${EMPTY_NS}")" + +# Its only payload is the namespace-level format_version.txt: no ref stream object anywhere yet (a +# files-only life never touched by a ref op has no `_log`/`_snap` at all). +FORMAT_VERSION_HITS=$(find "${POOL_DIR}/ca/cas/ns/state" -path '*_files/format_version.txt' 2>/dev/null | wc -l) +STREAM_HITS_BEFORE=$(find "${POOL_DIR}/ca/cas/ns/stream" -type f 2>/dev/null | wc -l) +echo "empty_table_has_format_version $([ "${FORMAT_VERSION_HITS}" -ge 1 ] && echo 1 || echo 0)" +echo "empty_table_has_no_ref_stream_before_drop $([ "${STREAM_HITS_BEFORE}" -eq 0 ] && echo 1 || echo 0)" + +$CLICKHOUSE_CLIENT --query "DROP TABLE t_dropns_empty SYNC" + +# The current branch fails here by leaving st:"live"; the fix must show "removing" (a terminal stream +# record now exists but the catalog row itself is not deleted until GC folds and reclaims it). +echo "empty_table_state_after_sync_drop $(catalog_state "${EMPTY_NS}")" +STREAM_HITS_AFTER=$(find "${POOL_DIR}/ca/cas/ns/stream" -type f 2>/dev/null | wc -l) +echo "empty_table_terminal_stream_record_exists $([ "${STREAM_HITS_AFTER}" -ge 1 ] && echo 1 || echo 0)" + +# ---- (2) same shape, but with one committed part: "parts removed first, files remain" is a separate +# path from the zero-part path above; pin it too. ---- +$CLICKHOUSE_CLIENT --query " +CREATE TABLE t_dropns_one_part (a UInt64) +ENGINE = MergeTree ORDER BY a +SETTINGS disk = ${DISK_DEF}" +$CLICKHOUSE_CLIENT --query "INSERT INTO t_dropns_one_part VALUES (1)" + +ONE_PART_UUID=$($CLICKHOUSE_CLIENT --query "SELECT uuid FROM system.tables WHERE database = currentDatabase() AND name = 't_dropns_one_part'") +ONE_PART_NS=$(namespace_of "${ONE_PART_UUID}") +echo "one_part_table_state_before_drop $(catalog_state "${ONE_PART_NS}")" + +$CLICKHOUSE_CLIENT --query "DROP TABLE t_dropns_one_part SYNC" +echo "one_part_table_state_after_sync_drop $(catalog_state "${ONE_PART_NS}")" + +# ---- (3) negative control: removing the ONLY part while keeping the table must never admit removal. ---- +$CLICKHOUSE_CLIENT --query " +CREATE TABLE t_dropns_negative_control (a UInt64) +ENGINE = MergeTree ORDER BY a +SETTINGS disk = ${DISK_DEF}" +$CLICKHOUSE_CLIENT --query "INSERT INTO t_dropns_negative_control VALUES (1)" + +NEGATIVE_UUID=$($CLICKHOUSE_CLIENT --query "SELECT uuid FROM system.tables WHERE database = currentDatabase() AND name = 't_dropns_negative_control'") +NEGATIVE_NS=$(namespace_of "${NEGATIVE_UUID}") + +$CLICKHOUSE_CLIENT --query "TRUNCATE TABLE t_dropns_negative_control" +echo "negative_control_state_after_truncate $(catalog_state "${NEGATIVE_NS}")" +$CLICKHOUSE_CLIENT --query "INSERT INTO t_dropns_negative_control VALUES (2)" +echo "negative_control_state_after_reinsert $(catalog_state "${NEGATIVE_NS}")" +echo "negative_control_rows $($CLICKHOUSE_CLIENT --query "SELECT count() FROM t_dropns_negative_control")" + +$CLICKHOUSE_CLIENT --query "DROP TABLE t_dropns_negative_control SYNC" + +# ---- (4) several same-SQL-name create/drop cycles: every fresh Atomic UUID gets its OWN namespace, so +# a stale predecessor cannot mask a fresh leak, and a same-name CREATE must never wait on the old +# UUID's GC. ---- +CYCLE_NS_LIST=() +CYCLE_LEAK_COUNT=0 +for i in 1 2 3; do + $CLICKHOUSE_CLIENT --query " + CREATE TABLE t_dropns_cycle (a UInt64) + ENGINE = MergeTree ORDER BY a + SETTINGS disk = ${DISK_DEF}" + CYCLE_UUID=$($CLICKHOUSE_CLIENT --query "SELECT uuid FROM system.tables WHERE database = currentDatabase() AND name = 't_dropns_cycle'") + CYCLE_NS=$(namespace_of "${CYCLE_UUID}") + CYCLE_NS_LIST+=("${CYCLE_NS}") + + $CLICKHOUSE_CLIENT --query "DROP TABLE t_dropns_cycle SYNC" + if [ "$(catalog_state "${CYCLE_NS}")" = "live" ]; then + CYCLE_LEAK_COUNT=$((CYCLE_LEAK_COUNT + 1)) + fi +done +echo "cycle_leaks_after_sync_drop ${CYCLE_LEAK_COUNT}" + +# ---- (5) drive manual GC to a bounded fixpoint (Task 7 `pending_*` gauges, not a fixed sleep), then +# assert every captured namespace's catalog row is gone. ---- +ALL_NS=("${EMPTY_NS}" "${ONE_PART_NS}" "${CYCLE_NS_LIST[@]}") + +PENDING=1 +for _ in $(seq 1 60); do + PENDING=$($CLICKHOUSE_CLIENT --query "SYSTEM CAS GC RUN '${DISK_NAME}'" --format TSVWithNames \ + | awk -F'\t' 'NR==1 { for (i = 1; i <= NF; i++) col[$i] = i; next } + { print $col["pending_condemned"] }') + [ "${PENDING}" = "0" ] && break + sleep 0.5 +done +if [ "${PENDING}" != "0" ]; then + echo "FAIL: GC did not drain within the bounded loop (pending=${PENDING})" >&2 + exit 1 +fi + +ROWS_STILL_PRESENT=0 +for ns in "${ALL_NS[@]}"; do + if [ "$(catalog_state "${ns}")" != "absent" ]; then + ROWS_STILL_PRESENT=$((ROWS_STILL_PRESENT + 1)) + fi +done +echo "captured_rows_absent_after_gc_fixpoint $([ "${ROWS_STILL_PRESENT}" -eq 0 ] && echo 1 || echo 0)" + +# ---- (6) SYSTEM CAS FSCK: secondary check only -- a `live` leak alone would read as CONSISTENT here, +# which is exactly why the fsck-only oracle in 04290_cas_no_leftovers.sh did not catch this defect. ---- +$CLICKHOUSE_CLIENT --query "SYSTEM CAS FSCK '${DISK_NAME}'" --format TSVWithNames \ + | awk -F'\t' 'NR==1 { for (i = 1; i <= NF; i++) col[$i] = i; next } + { print "fsck_unreachable", $col["unreachable"]; print "fsck_dangling", $col["dangling"] }' + +# ---- (7) fail-closed teardown (spec rev.8 §5/§9): FORGET the disk (all tables already dropped above), +# verify it, only then rm. ---- +$CLICKHOUSE_CLIENT --allow_repeated_settings --send_logs_level=fatal \ + --query "SYSTEM CAS FORGET '${DISK_NAME}'" || { + echo "FORGET failed — leaving pool dir in place (fail-closed)"; exit 1; } +LIFECYCLE=$($CLICKHOUSE_CLIENT --query " + SELECT lifecycle || '(' || lifecycle_reason || ')' FROM system.cas_mounts + WHERE disk = '${DISK_NAME}'") +[ "${LIFECYCLE}" = "vanished(forgotten)" ] || { + echo "unexpected lifecycle after FORGET: ${LIFECYCLE}"; exit 1; } + +rm -rf "${POOL_DIR:?}" # safe: FORGET stopped and joined every CAS thread for this disk