diff --git a/.github/actionlint.yml b/.github/actionlint.yml new file mode 100644 index 0000000000..fddd2f980d --- /dev/null +++ b/.github/actionlint.yml @@ -0,0 +1,8 @@ +self-hosted-runner: + labels: + - gen3 + - large + - small + - us-east-2 +config-variables: + - SLACK_UPCOMING_RELEASE_CHANNEL_ID diff --git a/.github/workflows/actionlint.yml b/.github/workflows/actionlint.yml new file mode 100644 index 0000000000..584828c1d0 --- /dev/null +++ b/.github/workflows/actionlint.yml @@ -0,0 +1,31 @@ +name: Lint GitHub Workflows + +on: + push: + branches: + - main + - release + paths: + - '.github/workflows/*.ya?ml' + pull_request: + paths: + - '.github/workflows/*.ya?ml' + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + +jobs: + actionlint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: reviewdog/action-actionlint@v1 + env: + # SC2046 - Quote this to prevent word splitting. - https://www.shellcheck.net/wiki/SC2046 + # SC2086 - Double quote to prevent globbing and word splitting. - https://www.shellcheck.net/wiki/SC2086 + SHELLCHECK_OPTS: --exclude=SC2046,SC2086 + with: + fail_on_error: true + filter_mode: nofilter + level: error diff --git a/.github/workflows/approved-for-ci-run.yml b/.github/workflows/approved-for-ci-run.yml index ac9e908c09..e1c491b67c 100644 --- a/.github/workflows/approved-for-ci-run.yml +++ b/.github/workflows/approved-for-ci-run.yml @@ -2,7 +2,9 @@ name: Handle `approved-for-ci-run` label # This workflow helps to run CI pipeline for PRs made by external contributors (from forks). on: - pull_request: + pull_request_target: + branches: + - main types: # Default types that triggers a workflow ([1]): # - [1] https://docs.github.com/en/actions/using-workflows/events-that-trigger-workflows#pull_request @@ -18,38 +20,84 @@ env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} PR_NUMBER: ${{ github.event.pull_request.number }} +permissions: write-all + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number }} + jobs: remove-label: # Remove `approved-for-ci-run` label if the workflow is triggered by changes in a PR. # The PR should be reviewed and labelled manually again. - runs-on: [ ubuntu-latest ] - if: | contains(fromJSON('["opened", "synchronize", "reopened", "closed"]'), github.event.action) && contains(github.event.pull_request.labels.*.name, 'approved-for-ci-run') + runs-on: ubuntu-latest + steps: - run: gh pr --repo "${GITHUB_REPOSITORY}" edit "${PR_NUMBER}" --remove-label "approved-for-ci-run" - create-branch: - # Create a local branch for an `approved-for-ci-run` labelled PR to run CI pipeline in it. - - runs-on: [ ubuntu-latest ] + create-or-update-pr-for-ci-run: + # Create local PR for an `approved-for-ci-run` labelled PR to run CI pipeline in it. if: | github.event.action == 'labeled' && contains(github.event.pull_request.labels.*.name, 'approved-for-ci-run') + runs-on: ubuntu-latest + steps: - run: gh pr --repo "${GITHUB_REPOSITORY}" edit "${PR_NUMBER}" --remove-label "approved-for-ci-run" - uses: actions/checkout@v3 with: ref: main + token: ${{ secrets.CI_ACCESS_TOKEN }} - run: gh pr checkout "${PR_NUMBER}" - run: git checkout -b "ci-run/pr-${PR_NUMBER}" - run: git push --force origin "ci-run/pr-${PR_NUMBER}" + + - name: Create a Pull Request for CI run (if required) + env: + GH_TOKEN: ${{ secrets.CI_ACCESS_TOKEN }} + run: | + HEAD="ci-run/pr-${PR_NUMBER}" + cat << EOF > body.md + This Pull Request is created automatically to run the CI pipeline for #${PR_NUMBER} + + Please do not alter or merge/close it. + + Feel free to review/comment/discuss the original PR #${PR_NUMBER}. + EOF + + ALREADY_CREATED="$(gh pr --repo ${GITHUB_REPOSITORY} list --head ${HEAD} --base main --json number --jq '.[].number')" + if [ -z "${ALREADY_CREATED}" ]; then + gh pr --repo "${GITHUB_REPOSITORY}" create --title "CI run for PR #${PR_NUMBER}" \ + --body-file "body.md" \ + --head "${HEAD}" \ + --base "main" \ + --draft + fi + + cleanup: + # Close PRs and delete branchs if the original PR is closed. + + if: | + github.event.action == 'closed' && + github.event.pull_request.head.repo.full_name != github.repository + + runs-on: ubuntu-latest + + steps: + - run: | + HEAD="ci-run/pr-${PR_NUMBER}" + + CLOSED="$(gh pr --repo ${GITHUB_REPOSITORY} list --head ${HEAD} --json 'closed' --jq '.[].closed')" + if [ "${CLOSED}" != "false" ]; then + gh pr --repo "${GITHUB_REPOSITORY}" close "ci-run/pr-${{ github.event.pull_request.number }}" --delete-branch + fi diff --git a/.github/workflows/benchmarking.yml b/.github/workflows/benchmarking.yml index 172b904331..e2f15d96db 100644 --- a/.github/workflows/benchmarking.yml +++ b/.github/workflows/benchmarking.yml @@ -117,6 +117,7 @@ jobs: outputs: pgbench-compare-matrix: ${{ steps.pgbench-compare-matrix.outputs.matrix }} olap-compare-matrix: ${{ steps.olap-compare-matrix.outputs.matrix }} + tpch-compare-matrix: ${{ steps.tpch-compare-matrix.outputs.matrix }} steps: - name: Generate matrix for pgbench benchmark @@ -136,11 +137,11 @@ jobs: }' if [ "$(date +%A)" = "Saturday" ]; then - matrix=$(echo $matrix | jq '.include += [{ "platform": "rds-postgres", "db_size": "10gb"}, + matrix=$(echo "$matrix" | jq '.include += [{ "platform": "rds-postgres", "db_size": "10gb"}, { "platform": "rds-aurora", "db_size": "50gb"}]') fi - echo "matrix=$(echo $matrix | jq --compact-output '.')" >> $GITHUB_OUTPUT + echo "matrix=$(echo "$matrix" | jq --compact-output '.')" >> $GITHUB_OUTPUT - name: Generate matrix for OLAP benchmarks id: olap-compare-matrix @@ -152,11 +153,30 @@ jobs: }' if [ "$(date +%A)" = "Saturday" ]; then - matrix=$(echo $matrix | jq '.include += [{ "platform": "rds-postgres" }, + matrix=$(echo "$matrix" | jq '.include += [{ "platform": "rds-postgres" }, { "platform": "rds-aurora" }]') fi - echo "matrix=$(echo $matrix | jq --compact-output '.')" >> $GITHUB_OUTPUT + echo "matrix=$(echo "$matrix" | jq --compact-output '.')" >> $GITHUB_OUTPUT + + - name: Generate matrix for TPC-H benchmarks + id: tpch-compare-matrix + run: | + matrix='{ + "platform": [ + "neon-captest-reuse" + ], + "scale": [ + "10" + ] + }' + + if [ "$(date +%A)" = "Saturday" ]; then + matrix=$(echo "$matrix" | jq '.include += [{ "platform": "rds-postgres", "scale": "10" }, + { "platform": "rds-aurora", "scale": "10" }]') + fi + + echo "matrix=$(echo "$matrix" | jq --compact-output '.')" >> $GITHUB_OUTPUT pgbench-compare: needs: [ generate-matrices ] @@ -233,7 +253,11 @@ jobs: echo "connstr=${CONNSTR}" >> $GITHUB_OUTPUT - psql ${CONNSTR} -c "SELECT version();" + QUERY="SELECT version();" + if [[ "${PLATFORM}" = "neon"* ]]; then + QUERY="${QUERY} SHOW neon.tenant_id; SHOW neon.timeline_id;" + fi + psql ${CONNSTR} -c "${QUERY}" - name: Benchmark init uses: ./.github/actions/run-python-test-set @@ -358,7 +382,11 @@ jobs: echo "connstr=${CONNSTR}" >> $GITHUB_OUTPUT - psql ${CONNSTR} -c "SELECT version();" + QUERY="SELECT version();" + if [[ "${PLATFORM}" = "neon"* ]]; then + QUERY="${QUERY} SHOW neon.tenant_id; SHOW neon.timeline_id;" + fi + psql ${CONNSTR} -c "${QUERY}" - name: ClickBench benchmark uses: ./.github/actions/run-python-test-set @@ -372,6 +400,7 @@ jobs: VIP_VAP_ACCESS_TOKEN: "${{ secrets.VIP_VAP_ACCESS_TOKEN }}" PERF_TEST_RESULT_CONNSTR: "${{ secrets.PERF_TEST_RESULT_CONNSTR }}" BENCHMARK_CONNSTR: ${{ steps.set-up-connstr.outputs.connstr }} + TEST_OLAP_SCALE: 10 - name: Create Allure report if: ${{ !cancelled() }} @@ -398,7 +427,7 @@ jobs: strategy: fail-fast: false - matrix: ${{ fromJson(needs.generate-matrices.outputs.olap-compare-matrix) }} + matrix: ${{ fromJson(needs.generate-matrices.outputs.tpch-compare-matrix) }} env: POSTGRES_DISTRIB_DIR: /tmp/neon/pg_install @@ -407,6 +436,7 @@ jobs: BUILD_TYPE: remote SAVE_PERF_REPORT: ${{ github.event.inputs.save_perf_report || ( github.ref_name == 'main' ) }} PLATFORM: ${{ matrix.platform }} + TEST_OLAP_SCALE: ${{ matrix.scale }} runs-on: [ self-hosted, us-east-2, x64 ] container: @@ -428,18 +458,17 @@ jobs: ${POSTGRES_DISTRIB_DIR}/v${DEFAULT_PG_VERSION}/bin/pgbench --version echo "${POSTGRES_DISTRIB_DIR}/v${DEFAULT_PG_VERSION}/bin" >> $GITHUB_PATH - - name: Set up Connection String - id: set-up-connstr + - name: Get Connstring Secret Name run: | case "${PLATFORM}" in neon-captest-reuse) - CONNSTR=${{ secrets.BENCHMARK_CAPTEST_TPCH_S10_CONNSTR }} + ENV_PLATFORM=CAPTEST_TPCH ;; rds-aurora) - CONNSTR=${{ secrets.BENCHMARK_RDS_AURORA_TPCH_S10_CONNSTR }} + ENV_PLATFORM=RDS_AURORA_TPCH ;; rds-postgres) - CONNSTR=${{ secrets.BENCHMARK_RDS_POSTGRES_TPCH_S10_CONNSTR }} + ENV_PLATFORM=RDS_AURORA_TPCH ;; *) echo >&2 "Unknown PLATFORM=${PLATFORM}. Allowed only 'neon-captest-reuse', 'rds-aurora', or 'rds-postgres'" @@ -447,9 +476,21 @@ jobs: ;; esac + CONNSTR_SECRET_NAME="BENCHMARK_${ENV_PLATFORM}_S${TEST_OLAP_SCALE}_CONNSTR" + echo "CONNSTR_SECRET_NAME=${CONNSTR_SECRET_NAME}" >> $GITHUB_ENV + + - name: Set up Connection String + id: set-up-connstr + run: | + CONNSTR=${{ secrets[env.CONNSTR_SECRET_NAME] }} + echo "connstr=${CONNSTR}" >> $GITHUB_OUTPUT - psql ${CONNSTR} -c "SELECT version();" + QUERY="SELECT version();" + if [[ "${PLATFORM}" = "neon"* ]]; then + QUERY="${QUERY} SHOW neon.tenant_id; SHOW neon.timeline_id;" + fi + psql ${CONNSTR} -c "${QUERY}" - name: Run TPC-H benchmark uses: ./.github/actions/run-python-test-set @@ -463,6 +504,7 @@ jobs: VIP_VAP_ACCESS_TOKEN: "${{ secrets.VIP_VAP_ACCESS_TOKEN }}" PERF_TEST_RESULT_CONNSTR: "${{ secrets.PERF_TEST_RESULT_CONNSTR }}" BENCHMARK_CONNSTR: ${{ steps.set-up-connstr.outputs.connstr }} + TEST_OLAP_SCALE: ${{ matrix.scale }} - name: Create Allure report if: ${{ !cancelled() }} @@ -534,7 +576,11 @@ jobs: echo "connstr=${CONNSTR}" >> $GITHUB_OUTPUT - psql ${CONNSTR} -c "SELECT version();" + QUERY="SELECT version();" + if [[ "${PLATFORM}" = "neon"* ]]; then + QUERY="${QUERY} SHOW neon.tenant_id; SHOW neon.timeline_id;" + fi + psql ${CONNSTR} -c "${QUERY}" - name: Run user examples uses: ./.github/actions/run-python-test-set diff --git a/.github/workflows/build_and_test.yml b/.github/workflows/build_and_test.yml index 144a96910e..898d19e7d4 100644 --- a/.github/workflows/build_and_test.yml +++ b/.github/workflows/build_and_test.yml @@ -5,7 +5,6 @@ on: branches: - main - release - - ci-run/pr-* pull_request: defaults: @@ -24,7 +23,30 @@ env: AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_KEY_DEV }} jobs: + check-permissions: + runs-on: ubuntu-latest + + steps: + - name: Disallow PRs from forks + if: | + github.event_name == 'pull_request' && + github.event.pull_request.head.repo.full_name != github.repository + + run: | + if [ "${{ contains(fromJSON('["OWNER", "MEMBER", "COLLABORATOR"]'), github.event.pull_request.author_association) }}" = "true" ]; then + MESSAGE="Please create a PR from a branch of ${GITHUB_REPOSITORY} instead of a fork" + else + MESSAGE="The PR should be reviewed and labelled with 'approved-for-ci-run' to trigger a CI run" + fi + + echo >&2 "We don't run CI for PRs from forks" + echo >&2 "${MESSAGE}" + + exit 1 + + tag: + needs: [ check-permissions ] runs-on: [ self-hosted, gen3, small ] container: 369495373322.dkr.ecr.eu-central-1.amazonaws.com/base:pinned outputs: @@ -53,6 +75,7 @@ jobs: id: build-tag check-codestyle-python: + needs: [ check-permissions ] runs-on: [ self-hosted, gen3, small ] container: image: 369495373322.dkr.ecr.eu-central-1.amazonaws.com/rust:pinned @@ -85,6 +108,7 @@ jobs: run: poetry run mypy . check-codestyle-rust: + needs: [ check-permissions ] runs-on: [ self-hosted, gen3, large ] container: image: 369495373322.dkr.ecr.eu-central-1.amazonaws.com/rust:pinned @@ -151,6 +175,7 @@ jobs: run: cargo deny check build-neon: + needs: [ check-permissions ] runs-on: [ self-hosted, gen3, large ] container: image: 369495373322.dkr.ecr.eu-central-1.amazonaws.com/rust:pinned @@ -229,10 +254,12 @@ jobs: cov_prefix="" CARGO_FLAGS="--locked --release" fi - echo "cov_prefix=${cov_prefix}" >> $GITHUB_ENV - echo "CARGO_FEATURES=${CARGO_FEATURES}" >> $GITHUB_ENV - echo "CARGO_FLAGS=${CARGO_FLAGS}" >> $GITHUB_ENV - echo "CARGO_HOME=${GITHUB_WORKSPACE}/.cargo" >> $GITHUB_ENV + { + echo "cov_prefix=${cov_prefix}" + echo "CARGO_FEATURES=${CARGO_FEATURES}" + echo "CARGO_FLAGS=${CARGO_FLAGS}" + echo "CARGO_HOME=${GITHUB_WORKSPACE}/.cargo" + } >> $GITHUB_ENV # Disabled for now # Don't include the ~/.cargo/registry/src directory. It contains just @@ -348,12 +375,12 @@ jobs: uses: ./.github/actions/save-coverage-data regress-tests: + needs: [ check-permissions, build-neon ] runs-on: [ self-hosted, gen3, large ] container: image: 369495373322.dkr.ecr.eu-central-1.amazonaws.com/rust:pinned # Default shared memory is 64mb options: --init --shm-size=512mb - needs: [ build-neon ] strategy: fail-fast: false matrix: @@ -386,12 +413,12 @@ jobs: uses: ./.github/actions/save-coverage-data benchmarks: + needs: [ check-permissions, build-neon ] runs-on: [ self-hosted, gen3, small ] container: image: 369495373322.dkr.ecr.eu-central-1.amazonaws.com/rust:pinned # Default shared memory is 64mb options: --init --shm-size=512mb - needs: [ build-neon ] if: github.ref_name == 'main' || contains(github.event.pull_request.labels.*.name, 'run-benchmarks') strategy: fail-fast: false @@ -418,12 +445,13 @@ jobs: # while coverage is currently collected for the debug ones create-test-report: + needs: [ check-permissions, regress-tests, coverage-report, benchmarks ] + if: ${{ !cancelled() && contains(fromJSON('["skipped", "success"]'), needs.check-permissions.result) }} + runs-on: [ self-hosted, gen3, small ] container: image: 369495373322.dkr.ecr.eu-central-1.amazonaws.com/rust:pinned options: --init - needs: [ regress-tests, benchmarks ] - if: ${{ !cancelled() }} steps: - uses: actions/checkout@v3 @@ -449,42 +477,40 @@ jobs: reportJsonUrl: "${{ steps.create-allure-report.outputs.report-json-url }}", } + const coverage = { + coverageUrl: "${{ needs.coverage-report.outputs.coverage-html }}", + summaryJsonUrl: "${{ needs.coverage-report.outputs.coverage-json }}", + } + const script = require("./scripts/comment-test-report.js") await script({ github, context, fetch, report, + coverage, }) coverage-report: + needs: [ check-permissions, regress-tests ] + runs-on: [ self-hosted, gen3, small ] container: image: 369495373322.dkr.ecr.eu-central-1.amazonaws.com/rust:pinned options: --init - needs: [ regress-tests ] strategy: fail-fast: false matrix: build_type: [ debug ] + outputs: + coverage-html: ${{ steps.upload-coverage-report-new.outputs.report-url }} + coverage-json: ${{ steps.upload-coverage-report-new.outputs.summary-json }} steps: - name: Checkout uses: actions/checkout@v3 with: submodules: true - fetch-depth: 1 - -# Disabled for now -# - name: Restore cargo deps cache -# id: cache_cargo -# uses: actions/cache@v3 -# with: -# path: | -# ~/.cargo/registry/ -# !~/.cargo/registry/src -# ~/.cargo/git/ -# target/ -# key: v1-${{ runner.os }}-${{ matrix.build_type }}-cargo-${{ hashFiles('rust-toolchain.toml') }}-${{ hashFiles('Cargo.lock') }} + fetch-depth: 0 - name: Get Neon artifact uses: ./.github/actions/download @@ -527,13 +553,45 @@ jobs: REPORT_URL=https://${BUCKET}.s3.amazonaws.com/code-coverage/${COMMIT_SHA}/index.html echo "report-url=${REPORT_URL}" >> $GITHUB_OUTPUT + - name: Build coverage report NEW + id: upload-coverage-report-new + env: + BUCKET: neon-github-public-dev + COMMIT_SHA: ${{ github.event.pull_request.head.sha || github.sha }} + run: | + BASELINE="$(git merge-base HEAD origin/main)" + CURRENT="${COMMIT_SHA}" + + cp /tmp/coverage/report/lcov.info ./${CURRENT}.info + + GENHTML_ARGS="--ignore-errors path,unmapped,empty --synthesize-missing --demangle-cpp rustfilt --output-directory lcov-html ${CURRENT}.info" + + # Use differential coverage if the baseline coverage exists. + # It can be missing if the coverage repoer wasn't uploaded yet or tests has failed on BASELINE commit. + if aws s3 cp --only-show-errors s3://${BUCKET}/code-coverage/${BASELINE}/lcov.info ./${BASELINE}.info; then + git diff ${BASELINE} ${CURRENT} -- '*.rs' > baseline-current.diff + + GENHTML_ARGS="--baseline-file ${BASELINE}.info --diff-file baseline-current.diff ${GENHTML_ARGS}" + fi + + genhtml ${GENHTML_ARGS} + + aws s3 cp --only-show-errors --recursive ./lcov-html/ s3://${BUCKET}/code-coverage/${COMMIT_SHA}/lcov + + REPORT_URL=https://${BUCKET}.s3.amazonaws.com/code-coverage/${COMMIT_SHA}/lcov/index.html + echo "report-url=${REPORT_URL}" >> $GITHUB_OUTPUT + + REPORT_URL=https://${BUCKET}.s3.amazonaws.com/code-coverage/${COMMIT_SHA}/lcov/summary.json + echo "summary-json=${REPORT_URL}" >> $GITHUB_OUTPUT + - uses: actions/github-script@v6 env: REPORT_URL: ${{ steps.upload-coverage-report.outputs.report-url }} + REPORT_URL_NEW: ${{ steps.upload-coverage-report-new.outputs.report-url }} COMMIT_SHA: ${{ github.event.pull_request.head.sha || github.sha }} with: script: | - const { REPORT_URL, COMMIT_SHA } = process.env + const { REPORT_URL, REPORT_URL_NEW, COMMIT_SHA } = process.env await github.rest.repos.createCommitStatus({ owner: context.repo.owner, @@ -544,12 +602,21 @@ jobs: context: 'Code coverage report', }) + await github.rest.repos.createCommitStatus({ + owner: context.repo.owner, + repo: context.repo.repo, + sha: `${COMMIT_SHA}`, + state: 'success', + target_url: `${REPORT_URL_NEW}`, + context: 'Code coverage report NEW', + }) + trigger-e2e-tests: + needs: [ check-permissions, promote-images, tag ] runs-on: [ self-hosted, gen3, small ] container: image: 369495373322.dkr.ecr.eu-central-1.amazonaws.com/base:pinned options: --init - needs: [ promote-images, tag ] steps: - name: Set PR's status to pending and request a remote CI test run: | @@ -590,8 +657,8 @@ jobs: }" neon-image: + needs: [ check-permissions, tag ] runs-on: [ self-hosted, gen3, large ] - needs: [ tag ] container: gcr.io/kaniko-project/executor:v1.9.2-debug defaults: run: @@ -638,7 +705,7 @@ jobs: compute-tools-image: runs-on: [ self-hosted, gen3, large ] - needs: [ tag ] + needs: [ check-permissions, tag ] container: gcr.io/kaniko-project/executor:v1.9.2-debug defaults: run: @@ -683,13 +750,13 @@ jobs: run: rm -rf ~/.ecr compute-node-image: + needs: [ check-permissions, tag ] runs-on: [ self-hosted, gen3, large ] container: image: gcr.io/kaniko-project/executor:v1.9.2-debug # Workaround for "Resolving download.osgeo.org (download.osgeo.org)... failed: Temporary failure in name resolution."" # Should be prevented by https://github.com/neondatabase/neon/issues/4281 options: --add-host=download.osgeo.org:140.211.15.30 - needs: [ tag ] strategy: fail-fast: false matrix: @@ -742,8 +809,8 @@ jobs: run: rm -rf ~/.ecr vm-compute-node-image: + needs: [ check-permissions, tag, compute-node-image ] runs-on: [ self-hosted, gen3, large ] - needs: [ tag, compute-node-image ] strategy: fail-fast: false matrix: @@ -784,7 +851,7 @@ jobs: docker push 369495373322.dkr.ecr.eu-central-1.amazonaws.com/vm-compute-node-${{ matrix.version }}:${{needs.tag.outputs.build-tag}} test-images: - needs: [ tag, neon-image, compute-node-image, compute-tools-image ] + needs: [ check-permissions, tag, neon-image, compute-node-image, compute-tools-image ] runs-on: [ self-hosted, gen3, small ] steps: @@ -827,8 +894,8 @@ jobs: docker compose -f ./docker-compose/docker-compose.yml down promote-images: + needs: [ check-permissions, tag, test-images, vm-compute-node-image ] runs-on: [ self-hosted, gen3, small ] - needs: [ tag, test-images, vm-compute-node-image ] container: golang:1.19-bullseye # Don't add if-condition here. # The job should always be run because we have dependant other jobs that shouldn't be skipped @@ -899,12 +966,9 @@ jobs: - name: Cleanup ECR folder run: rm -rf ~/.ecr - trigger-custom-extensions-build: - runs-on: [ self-hosted, gen3, small ] - container: - image: 369495373322.dkr.ecr.eu-central-1.amazonaws.com/base:pinned - options: --init - needs: [ tag ] + trigger-custom-extensions-build-and-wait: + needs: [ check-permissions, tag ] + runs-on: ubuntu-latest steps: - name: Set PR's status to pending and request a remote CI test run: | @@ -938,21 +1002,16 @@ jobs: } }" - wait-for-extensions-build: - runs-on: ubuntu-latest - needs: [ trigger-custom-extensions-build ] - - steps: - name: Wait for extension build to finish env: GH_TOKEN: ${{ secrets.CI_ACCESS_TOKEN }} run: | - TIMEOUT=600 # 10 minutes, currently it takes ~2-3 minutes + TIMEOUT=1800 # 30 minutes, usually it takes ~2-3 minutes, but if runners are busy, it might take longer INTERVAL=15 # try each N seconds last_status="" # a variable to carry the last status of the "build-and-upload-extensions" context - for ((i=0; i <= $TIMEOUT; i+=$INTERVAL)); do + for ((i=0; i <= TIMEOUT; i+=INTERVAL)); do sleep $INTERVAL # Get statuses for the latest commit in the PR / branch @@ -982,10 +1041,11 @@ jobs: exit 1 deploy: + needs: [ check-permissions, promote-images, tag, regress-tests, trigger-custom-extensions-build-and-wait ] + if: ( github.ref_name == 'main' || github.ref_name == 'release' ) && github.event_name != 'workflow_dispatch' + runs-on: [ self-hosted, gen3, small ] container: 369495373322.dkr.ecr.eu-central-1.amazonaws.com/ansible:latest - needs: [ promote-images, tag, regress-tests, wait-for-extensions-build ] - if: ( github.ref_name == 'main' || github.ref_name == 'release' ) && github.event_name != 'workflow_dispatch' steps: - name: Fix git ownership run: | @@ -1023,20 +1083,35 @@ jobs: # Retry script for 5XX server errors: https://github.com/actions/github-script#retries retries: 5 script: | - github.rest.git.createRef({ + await github.rest.git.createRef({ owner: context.repo.owner, repo: context.repo.repo, ref: "refs/tags/${{ needs.tag.outputs.build-tag }}", sha: context.sha, }) + - name: Create GitHub release + if: github.ref_name == 'release' + uses: actions/github-script@v6 + with: + # Retry script for 5XX server errors: https://github.com/actions/github-script#retries + retries: 5 + script: | + await github.rest.repos.createRelease({ + owner: context.repo.owner, + repo: context.repo.repo, + tag_name: "${{ needs.tag.outputs.build-tag }}", + generate_release_notes: true, + }) + promote-compatibility-data: + needs: [ check-permissions, promote-images, tag, regress-tests ] + if: github.ref_name == 'release' + runs-on: [ self-hosted, gen3, small ] container: image: 369495373322.dkr.ecr.eu-central-1.amazonaws.com/base:pinned options: --init - needs: [ promote-images, tag, regress-tests ] - if: github.ref_name == 'release' && github.event_name != 'workflow_dispatch' steps: - name: Promote compatibility snapshot for the release env: diff --git a/.github/workflows/neon_extra_builds.yml b/.github/workflows/neon_extra_builds.yml index a21ddb0414..f4e9e27e2b 100644 --- a/.github/workflows/neon_extra_builds.yml +++ b/.github/workflows/neon_extra_builds.yml @@ -4,7 +4,6 @@ on: push: branches: - main - - ci-run/pr-* pull_request: defaults: diff --git a/.github/workflows/release-notify.yml b/.github/workflows/release-notify.yml new file mode 100644 index 0000000000..ba396dba74 --- /dev/null +++ b/.github/workflows/release-notify.yml @@ -0,0 +1,29 @@ +name: Notify Slack channel about upcoming release + +concurrency: + group: ${{ github.workflow }}-${{ github.event.number }} + cancel-in-progress: true + +on: + pull_request: + branches: + - release + types: + # Default types that triggers a workflow: + # - https://docs.github.com/en/actions/using-workflows/events-that-trigger-workflows#pull_request + - opened + - synchronize + - reopened + # Additional types that we want to handle: + - closed + +jobs: + notify: + runs-on: [ ubuntu-latest ] + + steps: + - uses: neondatabase/dev-actions/release-pr-notify@main + with: + slack-token: ${{ secrets.SLACK_BOT_TOKEN }} + slack-channel-id: ${{ vars.SLACK_UPCOMING_RELEASE_CHANNEL_ID || 'C05QQ9J1BRC' }} # if not set, then `#test-release-notifications` + github-token: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 595ee05514..17a43fe8c1 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -2,7 +2,7 @@ name: Create Release Branch on: schedule: - - cron: '0 10 * * 2' + - cron: '0 7 * * 2' workflow_dispatch: jobs: diff --git a/Cargo.lock b/Cargo.lock index 98cf23c620..e811a06fac 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -213,17 +213,6 @@ dependencies = [ "critical-section", ] -[[package]] -name = "atty" -version = "0.2.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d9b39be18770d11421cdb1b9947a45dd3f37e93092cbf377614828a319d5fee8" -dependencies = [ - "hermit-abi 0.1.19", - "libc", - "winapi", -] - [[package]] name = "autocfg" version = "1.1.0" @@ -1068,6 +1057,8 @@ dependencies = [ "comfy-table", "compute_api", "git-version", + "hex", + "hyper", "nix 0.26.2", "once_cell", "pageserver_api", @@ -1083,6 +1074,7 @@ dependencies = [ "storage_broker", "tar", "thiserror", + "tokio", "toml", "tracing", "url", @@ -1786,15 +1778,6 @@ version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8" -[[package]] -name = "hermit-abi" -version = "0.1.19" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "62b467343b94ba476dcb2500d242dadbb39557df889310ac77c5d99100aaac33" -dependencies = [ - "libc", -] - [[package]] name = "hermit-abi" version = "0.2.6" @@ -1825,6 +1808,16 @@ version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6fe2267d4ed49bc07b63801559be28c718ea06c4738b7a03c94df7386d2cde46" +[[package]] +name = "histogram" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e673d137229619d5c2c8903b6ed5852b43636c0017ff2e66b1aafb8ccf04b80b" +dependencies = [ + "serde", + "thiserror", +] + [[package]] name = "hmac" version = "0.12.1" @@ -3738,7 +3731,7 @@ name = "s3_scrubber" version = "0.1.0" dependencies = [ "anyhow", - "atty", + "async-stream", "aws-config", "aws-sdk-s3", "aws-smithy-http", @@ -3749,7 +3742,10 @@ dependencies = [ "clap", "crc32c", "either", + "futures-util", "hex", + "histogram", + "itertools", "pageserver", "rand", "reqwest", @@ -3759,6 +3755,7 @@ dependencies = [ "thiserror", "tokio", "tokio-rustls", + "tokio-stream", "tracing", "tracing-appender", "tracing-subscriber", diff --git a/Cargo.toml b/Cargo.toml index 9e2a32ba52..847865cfe8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,4 +1,5 @@ [workspace] +resolver = "2" members = [ "compute_tools", "control_plane", diff --git a/Dockerfile.compute-node b/Dockerfile.compute-node index 0416a98450..79bce8c244 100644 --- a/Dockerfile.compute-node +++ b/Dockerfile.compute-node @@ -74,8 +74,8 @@ RUN wget https://gitlab.com/Oslandia/SFCGAL/-/archive/v1.3.10/SFCGAL-v1.3.10.tar ENV PATH "/usr/local/pgsql/bin:$PATH" -RUN wget https://download.osgeo.org/postgis/source/postgis-3.3.2.tar.gz -O postgis.tar.gz && \ - echo "9a2a219da005a1730a39d1959a1c7cec619b1efb009b65be80ffc25bad299068 postgis.tar.gz" | sha256sum --check && \ +RUN wget https://download.osgeo.org/postgis/source/postgis-3.3.3.tar.gz -O postgis.tar.gz && \ + echo "74eb356e3f85f14233791013360881b6748f78081cc688ff9d6f0f673a762d13 postgis.tar.gz" | sha256sum --check && \ mkdir postgis-src && cd postgis-src && tar xvzf ../postgis.tar.gz --strip-components=1 -C . && \ find /usr/local/pgsql -type f | sed 's|^/usr/local/pgsql/||' > /before.txt &&\ ./autogen.sh && \ @@ -124,8 +124,8 @@ COPY --from=pg-build /usr/local/pgsql/ /usr/local/pgsql/ RUN apt update && \ apt install -y ninja-build python3-dev libncurses5 binutils clang -RUN wget https://github.com/plv8/plv8/archive/refs/tags/v3.1.5.tar.gz -O plv8.tar.gz && \ - echo "1e108d5df639e4c189e1c5bdfa2432a521c126ca89e7e5a969d46899ca7bf106 plv8.tar.gz" | sha256sum --check && \ +RUN wget https://github.com/plv8/plv8/archive/refs/tags/v3.1.8.tar.gz -O plv8.tar.gz && \ + echo "92b10c7db39afdae97ff748c9ec54713826af222c459084ad002571b79eb3f49 plv8.tar.gz" | sha256sum --check && \ mkdir plv8-src && cd plv8-src && tar xvzf ../plv8.tar.gz --strip-components=1 -C . && \ export PATH="/usr/local/pgsql/bin:$PATH" && \ make DOCKER=1 -j $(getconf _NPROCESSORS_ONLN) install && \ @@ -172,8 +172,8 @@ RUN wget https://github.com/uber/h3/archive/refs/tags/v4.1.0.tar.gz -O h3.tar.gz cp -R /h3/usr / && \ rm -rf build -RUN wget https://github.com/zachasme/h3-pg/archive/refs/tags/v4.1.2.tar.gz -O h3-pg.tar.gz && \ - echo "c135aa45999b2ad1326d2537c1cadef96d52660838e4ca371706c08fdea1a956 h3-pg.tar.gz" | sha256sum --check && \ +RUN wget https://github.com/zachasme/h3-pg/archive/refs/tags/v4.1.3.tar.gz -O h3-pg.tar.gz && \ + echo "5c17f09a820859ffe949f847bebf1be98511fb8f1bd86f94932512c00479e324 h3-pg.tar.gz" | sha256sum --check && \ mkdir h3-pg-src && cd h3-pg-src && tar xvzf ../h3-pg.tar.gz --strip-components=1 -C . && \ export PATH="/usr/local/pgsql/bin:$PATH" && \ make -j $(getconf _NPROCESSORS_ONLN) && \ @@ -243,8 +243,8 @@ RUN wget https://github.com/michelp/pgjwt/archive/9742dab1b2f297ad3811120db7b214 FROM build-deps AS hypopg-pg-build COPY --from=pg-build /usr/local/pgsql/ /usr/local/pgsql/ -RUN wget https://github.com/HypoPG/hypopg/archive/refs/tags/1.3.1.tar.gz -O hypopg.tar.gz && \ - echo "e7f01ee0259dc1713f318a108f987663d60f3041948c2ada57a94b469565ca8e hypopg.tar.gz" | sha256sum --check && \ +RUN wget https://github.com/HypoPG/hypopg/archive/refs/tags/1.4.0.tar.gz -O hypopg.tar.gz && \ + echo "0821011743083226fc9b813c1f2ef5897a91901b57b6bea85a78e466187c6819 hypopg.tar.gz" | sha256sum --check && \ mkdir hypopg-src && cd hypopg-src && tar xvzf ../hypopg.tar.gz --strip-components=1 -C . && \ make -j $(getconf _NPROCESSORS_ONLN) PG_CONFIG=/usr/local/pgsql/bin/pg_config && \ make -j $(getconf _NPROCESSORS_ONLN) install PG_CONFIG=/usr/local/pgsql/bin/pg_config && \ @@ -307,8 +307,8 @@ RUN wget https://github.com/theory/pgtap/archive/refs/tags/v1.2.0.tar.gz -O pgta FROM build-deps AS ip4r-pg-build COPY --from=pg-build /usr/local/pgsql/ /usr/local/pgsql/ -RUN wget https://github.com/RhodiumToad/ip4r/archive/refs/tags/2.4.1.tar.gz -O ip4r.tar.gz && \ - echo "78b9f0c1ae45c22182768fe892a32d533c82281035e10914111400bf6301c726 ip4r.tar.gz" | sha256sum --check && \ +RUN wget https://github.com/RhodiumToad/ip4r/archive/refs/tags/2.4.2.tar.gz -O ip4r.tar.gz && \ + echo "0f7b1f159974f49a47842a8ab6751aecca1ed1142b6d5e38d81b064b2ead1b4b ip4r.tar.gz" | sha256sum --check && \ mkdir ip4r-src && cd ip4r-src && tar xvzf ../ip4r.tar.gz --strip-components=1 -C . && \ make -j $(getconf _NPROCESSORS_ONLN) PG_CONFIG=/usr/local/pgsql/bin/pg_config && \ make -j $(getconf _NPROCESSORS_ONLN) install PG_CONFIG=/usr/local/pgsql/bin/pg_config && \ @@ -323,8 +323,8 @@ RUN wget https://github.com/RhodiumToad/ip4r/archive/refs/tags/2.4.1.tar.gz -O i FROM build-deps AS prefix-pg-build COPY --from=pg-build /usr/local/pgsql/ /usr/local/pgsql/ -RUN wget https://github.com/dimitri/prefix/archive/refs/tags/v1.2.9.tar.gz -O prefix.tar.gz && \ - echo "38d30a08d0241a8bbb8e1eb8f0152b385051665a8e621c8899e7c5068f8b511e prefix.tar.gz" | sha256sum --check && \ +RUN wget https://github.com/dimitri/prefix/archive/refs/tags/v1.2.10.tar.gz -O prefix.tar.gz && \ + echo "4342f251432a5f6fb05b8597139d3ccde8dcf87e8ca1498e7ee931ca057a8575 prefix.tar.gz" | sha256sum --check && \ mkdir prefix-src && cd prefix-src && tar xvzf ../prefix.tar.gz --strip-components=1 -C . && \ make -j $(getconf _NPROCESSORS_ONLN) PG_CONFIG=/usr/local/pgsql/bin/pg_config && \ make -j $(getconf _NPROCESSORS_ONLN) install PG_CONFIG=/usr/local/pgsql/bin/pg_config && \ @@ -339,8 +339,8 @@ RUN wget https://github.com/dimitri/prefix/archive/refs/tags/v1.2.9.tar.gz -O pr FROM build-deps AS hll-pg-build COPY --from=pg-build /usr/local/pgsql/ /usr/local/pgsql/ -RUN wget https://github.com/citusdata/postgresql-hll/archive/refs/tags/v2.17.tar.gz -O hll.tar.gz && \ - echo "9a18288e884f197196b0d29b9f178ba595b0dfc21fbf7a8699380e77fa04c1e9 hll.tar.gz" | sha256sum --check && \ +RUN wget https://github.com/citusdata/postgresql-hll/archive/refs/tags/v2.18.tar.gz -O hll.tar.gz && \ + echo "e2f55a6f4c4ab95ee4f1b4a2b73280258c5136b161fe9d059559556079694f0e hll.tar.gz" | sha256sum --check && \ mkdir hll-src && cd hll-src && tar xvzf ../hll.tar.gz --strip-components=1 -C . && \ make -j $(getconf _NPROCESSORS_ONLN) PG_CONFIG=/usr/local/pgsql/bin/pg_config && \ make -j $(getconf _NPROCESSORS_ONLN) install PG_CONFIG=/usr/local/pgsql/bin/pg_config && \ @@ -355,8 +355,8 @@ RUN wget https://github.com/citusdata/postgresql-hll/archive/refs/tags/v2.17.tar FROM build-deps AS plpgsql-check-pg-build COPY --from=pg-build /usr/local/pgsql/ /usr/local/pgsql/ -RUN wget https://github.com/okbob/plpgsql_check/archive/refs/tags/v2.3.2.tar.gz -O plpgsql_check.tar.gz && \ - echo "9d81167c4bbeb74eebf7d60147b21961506161addc2aee537f95ad8efeae427b plpgsql_check.tar.gz" | sha256sum --check && \ +RUN wget https://github.com/okbob/plpgsql_check/archive/refs/tags/v2.4.0.tar.gz -O plpgsql_check.tar.gz && \ + echo "9ba58387a279b35a3bfa39ee611e5684e6cddb2ba046ddb2c5190b3bd2ca254a plpgsql_check.tar.gz" | sha256sum --check && \ mkdir plpgsql_check-src && cd plpgsql_check-src && tar xvzf ../plpgsql_check.tar.gz --strip-components=1 -C . && \ make -j $(getconf _NPROCESSORS_ONLN) PG_CONFIG=/usr/local/pgsql/bin/pg_config USE_PGXS=1 && \ make -j $(getconf _NPROCESSORS_ONLN) install PG_CONFIG=/usr/local/pgsql/bin/pg_config USE_PGXS=1 && \ @@ -452,8 +452,8 @@ FROM build-deps AS pg-cron-pg-build COPY --from=pg-build /usr/local/pgsql/ /usr/local/pgsql/ ENV PATH "/usr/local/pgsql/bin/:$PATH" -RUN wget https://github.com/citusdata/pg_cron/archive/refs/tags/v1.5.2.tar.gz -O pg_cron.tar.gz && \ - echo "6f7f0980c03f1e2a6a747060e67bf4a303ca2a50e941e2c19daeed2b44dec744 pg_cron.tar.gz" | sha256sum --check && \ +RUN wget https://github.com/citusdata/pg_cron/archive/refs/tags/v1.6.0.tar.gz -O pg_cron.tar.gz && \ + echo "383a627867d730222c272bfd25cd5e151c578d73f696d32910c7db8c665cc7db pg_cron.tar.gz" | sha256sum --check && \ mkdir pg_cron-src && cd pg_cron-src && tar xvzf ../pg_cron.tar.gz --strip-components=1 -C . && \ make -j $(getconf _NPROCESSORS_ONLN) && \ make -j $(getconf _NPROCESSORS_ONLN) install && \ @@ -479,8 +479,8 @@ RUN apt-get update && \ libfreetype6-dev ENV PATH "/usr/local/pgsql/bin/:/usr/local/pgsql/:$PATH" -RUN wget https://github.com/rdkit/rdkit/archive/refs/tags/Release_2023_03_1.tar.gz -O rdkit.tar.gz && \ - echo "db346afbd0ba52c843926a2a62f8a38c7b774ffab37eaf382d789a824f21996c rdkit.tar.gz" | sha256sum --check && \ +RUN wget https://github.com/rdkit/rdkit/archive/refs/tags/Release_2023_03_3.tar.gz -O rdkit.tar.gz && \ + echo "bdbf9a2e6988526bfeb8c56ce3cdfe2998d60ac289078e2215374288185e8c8d rdkit.tar.gz" | sha256sum --check && \ mkdir rdkit-src && cd rdkit-src && tar xvzf ../rdkit.tar.gz --strip-components=1 -C . && \ cmake \ -D RDK_BUILD_CAIRO_SUPPORT=OFF \ diff --git a/README.md b/README.md index 5780608949..d948a92062 100644 --- a/README.md +++ b/README.md @@ -29,18 +29,18 @@ See developer documentation in [SUMMARY.md](/docs/SUMMARY.md) for more informati ```bash apt install build-essential libtool libreadline-dev zlib1g-dev flex bison libseccomp-dev \ libssl-dev clang pkg-config libpq-dev cmake postgresql-client protobuf-compiler \ -libcurl4-openssl-dev openssl python-poetry +libcurl4-openssl-dev openssl python-poetry lsof ``` * On Fedora, these packages are needed: ```bash dnf install flex bison readline-devel zlib-devel openssl-devel \ libseccomp-devel perl clang cmake postgresql postgresql-contrib protobuf-compiler \ - protobuf-devel libcurl-devel openssl poetry + protobuf-devel libcurl-devel openssl poetry lsof ``` * On Arch based systems, these packages are needed: ```bash pacman -S base-devel readline zlib libseccomp openssl clang \ -postgresql-libs cmake postgresql protobuf curl +postgresql-libs cmake postgresql protobuf curl lsof ``` Building Neon requires 3.15+ version of `protoc` (protobuf-compiler). If your distribution provides an older version, you can install a newer version from [here](https://github.com/protocolbuffers/protobuf/releases). diff --git a/clippy.toml b/clippy.toml new file mode 100644 index 0000000000..d788afc84d --- /dev/null +++ b/clippy.toml @@ -0,0 +1,5 @@ +disallowed-methods = [ + "tokio::task::block_in_place", + # Allow this for now, to deny it later once we stop using Handle::block_on completely + # "tokio::runtime::Handle::block_on", +] diff --git a/compute_tools/src/checker.rs b/compute_tools/src/checker.rs index 6f52004fa8..d76eaad0a0 100644 --- a/compute_tools/src/checker.rs +++ b/compute_tools/src/checker.rs @@ -1,7 +1,7 @@ use anyhow::{anyhow, Ok, Result}; use postgres::Client; use tokio_postgres::NoTls; -use tracing::{error, instrument}; +use tracing::{error, instrument, warn}; use crate::compute::ComputeNode; @@ -55,13 +55,24 @@ pub async fn check_writability(compute: &ComputeNode) -> Result<()> { ON CONFLICT (id) DO UPDATE SET updated_at = now();"; - let result = client.simple_query(query).await?; - - if result.len() != 1 { - return Err(anyhow::format_err!( - "expected 1 query result, but got {}", - result.len() - )); + match client.simple_query(query).await { + Result::Ok(result) => { + if result.len() != 1 { + return Err(anyhow::anyhow!( + "expected 1 query results, but got {}", + result.len() + )); + } + } + Err(err) => { + if let Some(state) = err.code() { + if state == &tokio_postgres::error::SqlState::DISK_FULL { + warn!("Tenant disk is full"); + return Ok(()); + } + } + return Err(err.into()); + } } Ok(()) diff --git a/compute_tools/src/config.rs b/compute_tools/src/config.rs index 2da671a149..bc48a2110d 100644 --- a/compute_tools/src/config.rs +++ b/compute_tools/src/config.rs @@ -46,8 +46,6 @@ pub fn write_postgres_conf( writeln!(file, "{}", conf)?; } - write!(file, "{}", &spec.cluster.settings.as_pg_settings())?; - // Add options for connecting to storage writeln!(file, "# Neon storage settings")?; if let Some(s) = &spec.pageserver_connstring { diff --git a/compute_tools/src/extension_server.rs b/compute_tools/src/extension_server.rs index 54c22026e7..bf32f4a517 100644 --- a/compute_tools/src/extension_server.rs +++ b/compute_tools/src/extension_server.rs @@ -74,6 +74,7 @@ More specifically, here is an example ext_index.json use anyhow::Context; use anyhow::{self, Result}; use compute_api::spec::RemoteExtSpec; +use regex::Regex; use remote_storage::*; use serde_json; use std::io::Read; @@ -108,14 +109,58 @@ pub fn get_pg_version(pgbin: &str) -> String { // pg_config --version returns a (platform specific) human readable string // such as "PostgreSQL 15.4". We parse this to v14/v15 let human_version = get_pg_config("--version", pgbin); - if human_version.contains("15") { - return "v15".to_string(); - } else if human_version.contains("14") { - return "v14".to_string(); + return parse_pg_version(&human_version).to_string(); +} + +fn parse_pg_version(human_version: &str) -> &str { + match Regex::new(r"(?\d+)\.(?\d+)") + .unwrap() + .captures(human_version) + { + Some(captures) if captures.len() == 3 => match &captures["major"] { + "14" => return "v14", + "15" => return "v15", + _ => {} + }, + _ => {} } panic!("Unsuported postgres version {human_version}"); } +#[cfg(test)] +mod tests { + use super::parse_pg_version; + + #[test] + fn test_parse_pg_version() { + assert_eq!(parse_pg_version("PostgreSQL 15.4"), "v15"); + assert_eq!(parse_pg_version("PostgreSQL 15.14"), "v15"); + assert_eq!( + parse_pg_version("PostgreSQL 15.4 (Ubuntu 15.4-0ubuntu0.23.04.1)"), + "v15" + ); + + assert_eq!(parse_pg_version("PostgreSQL 14.15"), "v14"); + assert_eq!(parse_pg_version("PostgreSQL 14.0"), "v14"); + assert_eq!( + parse_pg_version("PostgreSQL 14.9 (Debian 14.9-1.pgdg120+1"), + "v14" + ); + } + + #[test] + #[should_panic] + fn test_parse_pg_unsupported_version() { + parse_pg_version("PostgreSQL 13.14"); + } + + #[test] + #[should_panic] + fn test_parse_pg_incorrect_version_format() { + parse_pg_version("PostgreSQL 14"); + } +} + // download the archive for a given extension, // unzip it, and place files in the appropriate locations (share/lib) pub async fn download_extension( diff --git a/control_plane/Cargo.toml b/control_plane/Cargo.toml index d2c99c5f36..ec685915f9 100644 --- a/control_plane/Cargo.toml +++ b/control_plane/Cargo.toml @@ -12,6 +12,8 @@ git-version.workspace = true nix.workspace = true once_cell.workspace = true postgres.workspace = true +hex.workspace = true +hyper.workspace = true regex.workspace = true reqwest = { workspace = true, features = ["blocking", "json"] } serde.workspace = true @@ -20,6 +22,7 @@ serde_with.workspace = true tar.workspace = true thiserror.workspace = true toml.workspace = true +tokio.workspace = true url.workspace = true # Note: Do not directly depend on pageserver or safekeeper; use pageserver_api or safekeeper_api # instead, so that recompile times are better. diff --git a/control_plane/simple.conf b/control_plane/simple.conf index 243e13f3d3..0ad90a4618 100644 --- a/control_plane/simple.conf +++ b/control_plane/simple.conf @@ -1,6 +1,7 @@ # Minimal neon environment with one safekeeper. This is equivalent to the built-in # defaults that you get with no --config -[pageserver] +[[pageservers]] +id=1 listen_pg_addr = '127.0.0.1:64000' listen_http_addr = '127.0.0.1:9898' pg_auth_type = 'Trust' diff --git a/control_plane/src/attachment_service.rs b/control_plane/src/attachment_service.rs new file mode 100644 index 0000000000..f0e649cfa8 --- /dev/null +++ b/control_plane/src/attachment_service.rs @@ -0,0 +1,105 @@ +use crate::{background_process, local_env::LocalEnv}; +use anyhow::anyhow; +use serde::{Deserialize, Serialize}; +use serde_with::{serde_as, DisplayFromStr}; +use std::{path::PathBuf, process::Child}; +use utils::id::{NodeId, TenantId}; + +pub struct AttachmentService { + env: LocalEnv, + listen: String, + path: PathBuf, +} + +const COMMAND: &str = "attachment_service"; + +#[serde_as] +#[derive(Serialize, Deserialize)] +pub struct AttachHookRequest { + #[serde_as(as = "DisplayFromStr")] + pub tenant_id: TenantId, + pub pageserver_id: Option, +} + +#[derive(Serialize, Deserialize)] +pub struct AttachHookResponse { + pub gen: Option, +} + +impl AttachmentService { + pub fn from_env(env: &LocalEnv) -> Self { + let path = env.base_data_dir.join("attachments.json"); + + // Makes no sense to construct this if pageservers aren't going to use it: assume + // pageservers have control plane API set + let listen_url = env.control_plane_api.clone().unwrap(); + + let listen = format!( + "{}:{}", + listen_url.host_str().unwrap(), + listen_url.port().unwrap() + ); + + Self { + env: env.clone(), + path, + listen, + } + } + + fn pid_file(&self) -> PathBuf { + self.env.base_data_dir.join("attachment_service.pid") + } + + pub fn start(&self) -> anyhow::Result { + let path_str = self.path.to_string_lossy(); + + background_process::start_process( + COMMAND, + &self.env.base_data_dir, + &self.env.attachment_service_bin(), + ["-l", &self.listen, "-p", &path_str], + [], + background_process::InitialPidFile::Create(&self.pid_file()), + // TODO: a real status check + || Ok(true), + ) + } + + pub fn stop(&self, immediate: bool) -> anyhow::Result<()> { + background_process::stop_process(immediate, COMMAND, &self.pid_file()) + } + + /// Call into the attach_hook API, for use before handing out attachments to pageservers + pub fn attach_hook( + &self, + tenant_id: TenantId, + pageserver_id: NodeId, + ) -> anyhow::Result> { + use hyper::StatusCode; + + let url = self + .env + .control_plane_api + .clone() + .unwrap() + .join("attach_hook") + .unwrap(); + let client = reqwest::blocking::ClientBuilder::new() + .build() + .expect("Failed to construct http client"); + + let request = AttachHookRequest { + tenant_id, + pageserver_id: Some(pageserver_id), + }; + + let response = client.post(url).json(&request).send()?; + if response.status() != StatusCode::OK { + return Err(anyhow!("Unexpected status {}", response.status())); + } + + let response = response.json::()?; + Ok(response.gen) + } +} diff --git a/control_plane/src/bin/attachment_service.rs b/control_plane/src/bin/attachment_service.rs new file mode 100644 index 0000000000..e879646b63 --- /dev/null +++ b/control_plane/src/bin/attachment_service.rs @@ -0,0 +1,273 @@ +/// The attachment service mimics the aspects of the control plane API +/// that are required for a pageserver to operate. +/// +/// This enables running & testing pageservers without a full-blown +/// deployment of the Neon cloud platform. +/// +use anyhow::anyhow; +use clap::Parser; +use hex::FromHex; +use hyper::StatusCode; +use hyper::{Body, Request, Response}; +use serde::{Deserialize, Serialize}; +use std::path::{Path, PathBuf}; +use std::{collections::HashMap, sync::Arc}; +use utils::logging::{self, LogFormat}; + +use utils::{ + http::{ + endpoint::{self}, + error::ApiError, + json::{json_request, json_response}, + RequestExt, RouterBuilder, + }, + id::{NodeId, TenantId}, + tcp_listener, +}; + +use pageserver_api::control_api::{ + ReAttachRequest, ReAttachResponse, ReAttachResponseTenant, ValidateRequest, ValidateResponse, + ValidateResponseTenant, +}; + +use control_plane::attachment_service::{AttachHookRequest, AttachHookResponse}; + +#[derive(Parser)] +#[command(author, version, about, long_about = None)] +#[command(arg_required_else_help(true))] +struct Cli { + /// Host and port to listen on, like `127.0.0.1:1234` + #[arg(short, long)] + listen: std::net::SocketAddr, + + /// Path to the .json file to store state (will be created if it doesn't exist) + #[arg(short, long)] + path: PathBuf, +} + +// The persistent state of each Tenant +#[derive(Serialize, Deserialize, Clone)] +struct TenantState { + // Currently attached pageserver + pageserver: Option, + + // Latest generation number: next time we attach, increment this + // and use the incremented number when attaching + generation: u32, +} + +fn to_hex_map(input: &HashMap, serializer: S) -> Result +where + S: serde::Serializer, + V: Clone + Serialize, +{ + let transformed = input.iter().map(|(k, v)| (hex::encode(k), v.clone())); + + transformed + .collect::>() + .serialize(serializer) +} + +fn from_hex_map<'de, D, V>(deserializer: D) -> Result, D::Error> +where + D: serde::de::Deserializer<'de>, + V: Deserialize<'de>, +{ + let hex_map = HashMap::::deserialize(deserializer)?; + hex_map + .into_iter() + .map(|(k, v)| { + TenantId::from_hex(k) + .map(|k| (k, v)) + .map_err(serde::de::Error::custom) + }) + .collect() +} + +// Top level state available to all HTTP handlers +#[derive(Serialize, Deserialize)] +struct PersistentState { + #[serde(serialize_with = "to_hex_map", deserialize_with = "from_hex_map")] + tenants: HashMap, + + #[serde(skip)] + path: PathBuf, +} + +impl PersistentState { + async fn save(&self) -> anyhow::Result<()> { + let bytes = serde_json::to_vec(self)?; + tokio::fs::write(&self.path, &bytes).await?; + + Ok(()) + } + + async fn load(path: &Path) -> anyhow::Result { + let bytes = tokio::fs::read(path).await?; + let mut decoded = serde_json::from_slice::(&bytes)?; + decoded.path = path.to_owned(); + Ok(decoded) + } + + async fn load_or_new(path: &Path) -> Self { + match Self::load(path).await { + Ok(s) => { + tracing::info!("Loaded state file at {}", path.display()); + s + } + Err(e) + if e.downcast_ref::() + .map(|e| e.kind() == std::io::ErrorKind::NotFound) + .unwrap_or(false) => + { + tracing::info!("Will create state file at {}", path.display()); + Self { + tenants: HashMap::new(), + path: path.to_owned(), + } + } + Err(e) => { + panic!("Failed to load state from '{}': {e:#} (maybe your .neon/ dir was written by an older version?)", path.display()) + } + } + } +} + +/// State available to HTTP request handlers +#[derive(Clone)] +struct State { + inner: Arc>, +} + +impl State { + fn new(persistent_state: PersistentState) -> State { + Self { + inner: Arc::new(tokio::sync::RwLock::new(persistent_state)), + } + } +} + +#[inline(always)] +fn get_state(request: &Request) -> &State { + request + .data::>() + .expect("unknown state type") + .as_ref() +} + +/// Pageserver calls into this on startup, to learn which tenants it should attach +async fn handle_re_attach(mut req: Request) -> Result, ApiError> { + let reattach_req = json_request::(&mut req).await?; + + let state = get_state(&req).inner.clone(); + let mut locked = state.write().await; + + let mut response = ReAttachResponse { + tenants: Vec::new(), + }; + for (t, state) in &mut locked.tenants { + if state.pageserver == Some(reattach_req.node_id) { + state.generation += 1; + response.tenants.push(ReAttachResponseTenant { + id: *t, + generation: state.generation, + }); + } + } + + locked.save().await.map_err(ApiError::InternalServerError)?; + + json_response(StatusCode::OK, response) +} + +/// Pageserver calls into this before doing deletions, to confirm that it still +/// holds the latest generation for the tenants with deletions enqueued +async fn handle_validate(mut req: Request) -> Result, ApiError> { + let validate_req = json_request::(&mut req).await?; + + let locked = get_state(&req).inner.read().await; + + let mut response = ValidateResponse { + tenants: Vec::new(), + }; + + for req_tenant in validate_req.tenants { + if let Some(tenant_state) = locked.tenants.get(&req_tenant.id) { + let valid = tenant_state.generation == req_tenant.gen; + response.tenants.push(ValidateResponseTenant { + id: req_tenant.id, + valid, + }); + } + } + + json_response(StatusCode::OK, response) +} +/// Call into this before attaching a tenant to a pageserver, to acquire a generation number +/// (in the real control plane this is unnecessary, because the same program is managing +/// generation numbers and doing attachments). +async fn handle_attach_hook(mut req: Request) -> Result, ApiError> { + let attach_req = json_request::(&mut req).await?; + + let state = get_state(&req).inner.clone(); + let mut locked = state.write().await; + + let tenant_state = locked + .tenants + .entry(attach_req.tenant_id) + .or_insert_with(|| TenantState { + pageserver: attach_req.pageserver_id, + generation: 0, + }); + + if attach_req.pageserver_id.is_some() { + tenant_state.generation += 1; + } + let generation = tenant_state.generation; + + locked.save().await.map_err(ApiError::InternalServerError)?; + + json_response( + StatusCode::OK, + AttachHookResponse { + gen: attach_req.pageserver_id.map(|_| generation), + }, + ) +} + +fn make_router(persistent_state: PersistentState) -> RouterBuilder { + endpoint::make_router() + .data(Arc::new(State::new(persistent_state))) + .post("/re-attach", handle_re_attach) + .post("/validate", handle_validate) + .post("/attach_hook", handle_attach_hook) +} + +#[tokio::main] +async fn main() -> anyhow::Result<()> { + logging::init( + LogFormat::Plain, + logging::TracingErrorLayerEnablement::Disabled, + )?; + + let args = Cli::parse(); + tracing::info!( + "Starting, state at {}, listening on {}", + args.path.to_string_lossy(), + args.listen + ); + + let persistent_state = PersistentState::load_or_new(&args.path).await; + + let http_listener = tcp_listener::bind(args.listen)?; + let router = make_router(persistent_state) + .build() + .map_err(|err| anyhow!(err))?; + let service = utils::http::RouterService::new(router).unwrap(); + let server = hyper::Server::from_tcp(http_listener)?.serve(service); + + tracing::info!("Serving on {0}", args.listen); + server.await?; + + Ok(()) +} diff --git a/control_plane/src/bin/neon_local.rs b/control_plane/src/bin/neon_local.rs index ef308cb2d2..4cdb91bfd2 100644 --- a/control_plane/src/bin/neon_local.rs +++ b/control_plane/src/bin/neon_local.rs @@ -8,6 +8,7 @@ use anyhow::{anyhow, bail, Context, Result}; use clap::{value_parser, Arg, ArgAction, ArgMatches, Command}; use compute_api::spec::ComputeMode; +use control_plane::attachment_service::AttachmentService; use control_plane::endpoint::ComputeControlPlane; use control_plane::local_env::LocalEnv; use control_plane::pageserver::PageServerNode; @@ -43,14 +44,18 @@ project_git_version!(GIT_VERSION); const DEFAULT_PG_VERSION: &str = "15"; +const DEFAULT_PAGESERVER_CONTROL_PLANE_API: &str = "http://127.0.0.1:1234/"; + fn default_conf() -> String { format!( r#" # Default built-in configuration, defined in main.rs +control_plane_api = '{DEFAULT_PAGESERVER_CONTROL_PLANE_API}' + [broker] listen_addr = '{DEFAULT_BROKER_ADDR}' -[pageserver] +[[pageservers]] id = {DEFAULT_PAGESERVER_ID} listen_pg_addr = '{DEFAULT_PAGESERVER_PG_ADDR}' listen_http_addr = '{DEFAULT_PAGESERVER_HTTP_ADDR}' @@ -61,6 +66,7 @@ http_auth_type = '{trust_auth}' id = {DEFAULT_SAFEKEEPER_ID} pg_port = {DEFAULT_SAFEKEEPER_PG_PORT} http_port = {DEFAULT_SAFEKEEPER_HTTP_PORT} + "#, trust_auth = AuthType::Trust, ) @@ -107,6 +113,7 @@ fn main() -> Result<()> { "start" => handle_start_all(sub_args, &env), "stop" => handle_stop_all(sub_args, &env), "pageserver" => handle_pageserver(sub_args, &env), + "attachment_service" => handle_attachment_service(sub_args, &env), "safekeeper" => handle_safekeeper(sub_args, &env), "endpoint" => handle_endpoint(sub_args, &env), "pg" => bail!("'pg' subcommand has been renamed to 'endpoint'"), @@ -252,7 +259,7 @@ fn get_timeline_infos( env: &local_env::LocalEnv, tenant_id: &TenantId, ) -> Result> { - Ok(PageServerNode::from_env(env) + Ok(get_default_pageserver(env) .timeline_list(tenant_id)? .into_iter() .map(|timeline_info| (timeline_info.timeline_id, timeline_info)) @@ -313,17 +320,30 @@ fn handle_init(init_match: &ArgMatches) -> anyhow::Result { .context("Failed to initialize neon repository")?; // Initialize pageserver, create initial tenant and timeline. - let pageserver = PageServerNode::from_env(&env); - pageserver - .initialize(&pageserver_config_overrides(init_match)) - .unwrap_or_else(|e| { - eprintln!("pageserver init failed: {e:?}"); - exit(1); - }); + for ps_conf in &env.pageservers { + PageServerNode::from_env(&env, ps_conf) + .initialize(&pageserver_config_overrides(init_match)) + .unwrap_or_else(|e| { + eprintln!("pageserver init failed: {e:?}"); + exit(1); + }); + } Ok(env) } +/// The default pageserver is the one where CLI tenant/timeline operations are sent by default. +/// For typical interactive use, one would just run with a single pageserver. Scenarios with +/// tenant/timeline placement across multiple pageservers are managed by python test code rather +/// than this CLI. +fn get_default_pageserver(env: &local_env::LocalEnv) -> PageServerNode { + let ps_conf = env + .pageservers + .first() + .expect("Config is validated to contain at least one pageserver"); + PageServerNode::from_env(env, ps_conf) +} + fn pageserver_config_overrides(init_match: &ArgMatches) -> Vec<&str> { init_match .get_many::("pageserver-config-override") @@ -334,7 +354,7 @@ fn pageserver_config_overrides(init_match: &ArgMatches) -> Vec<&str> { } fn handle_tenant(tenant_match: &ArgMatches, env: &mut local_env::LocalEnv) -> anyhow::Result<()> { - let pageserver = PageServerNode::from_env(env); + let pageserver = get_default_pageserver(env); match tenant_match.subcommand() { Some(("list", _)) => { for t in pageserver.tenant_list()? { @@ -342,13 +362,25 @@ fn handle_tenant(tenant_match: &ArgMatches, env: &mut local_env::LocalEnv) -> an } } Some(("create", create_match)) => { - let initial_tenant_id = parse_tenant_id(create_match)?; let tenant_conf: HashMap<_, _> = create_match .get_many::("config") .map(|vals| vals.flat_map(|c| c.split_once(':')).collect()) .unwrap_or_default(); - let new_tenant_id = pageserver.tenant_create(initial_tenant_id, tenant_conf)?; - println!("tenant {new_tenant_id} successfully created on the pageserver"); + + // If tenant ID was not specified, generate one + let tenant_id = parse_tenant_id(create_match)?.unwrap_or_else(TenantId::generate); + + let generation = if env.control_plane_api.is_some() { + // We must register the tenant with the attachment service, so + // that when the pageserver restarts, it will be re-attached. + let attachment_service = AttachmentService::from_env(env); + attachment_service.attach_hook(tenant_id, pageserver.conf.id)? + } else { + None + }; + + pageserver.tenant_create(tenant_id, generation, tenant_conf)?; + println!("tenant {tenant_id} successfully created on the pageserver"); // Create an initial timeline for the new tenant let new_timeline_id = parse_timeline_id(create_match)?; @@ -358,7 +390,7 @@ fn handle_tenant(tenant_match: &ArgMatches, env: &mut local_env::LocalEnv) -> an .context("Failed to parse postgres version from the argument string")?; let timeline_info = pageserver.timeline_create( - new_tenant_id, + tenant_id, new_timeline_id, None, None, @@ -369,17 +401,17 @@ fn handle_tenant(tenant_match: &ArgMatches, env: &mut local_env::LocalEnv) -> an env.register_branch_mapping( DEFAULT_BRANCH_NAME.to_string(), - new_tenant_id, + tenant_id, new_timeline_id, )?; println!( - "Created an initial timeline '{new_timeline_id}' at Lsn {last_record_lsn} for tenant: {new_tenant_id}", + "Created an initial timeline '{new_timeline_id}' at Lsn {last_record_lsn} for tenant: {tenant_id}", ); if create_match.get_flag("set-default") { - println!("Setting tenant {new_tenant_id} as a default one"); - env.default_tenant_id = Some(new_tenant_id); + println!("Setting tenant {tenant_id} as a default one"); + env.default_tenant_id = Some(tenant_id); } } Some(("set-default", set_default_match)) => { @@ -407,7 +439,7 @@ fn handle_tenant(tenant_match: &ArgMatches, env: &mut local_env::LocalEnv) -> an } fn handle_timeline(timeline_match: &ArgMatches, env: &mut local_env::LocalEnv) -> Result<()> { - let pageserver = PageServerNode::from_env(env); + let pageserver = get_default_pageserver(env); match timeline_match.subcommand() { Some(("list", list_match)) => { @@ -484,6 +516,7 @@ fn handle_timeline(timeline_match: &ArgMatches, env: &mut local_env::LocalEnv) - None, pg_version, ComputeMode::Primary, + DEFAULT_PAGESERVER_ID, )?; println!("Done"); } @@ -537,7 +570,6 @@ fn handle_endpoint(ep_match: &ArgMatches, env: &local_env::LocalEnv) -> Result<( Some(ep_subcommand_data) => ep_subcommand_data, None => bail!("no endpoint subcommand provided"), }; - let mut cplane = ComputeControlPlane::load(env.clone())?; // All subcommands take an optional --tenant-id option @@ -634,6 +666,13 @@ fn handle_endpoint(ep_match: &ArgMatches, env: &local_env::LocalEnv) -> Result<( .copied() .unwrap_or(false); + let pageserver_id = + if let Some(id_str) = sub_args.get_one::("endpoint-pageserver-id") { + NodeId(id_str.parse().context("while parsing pageserver id")?) + } else { + DEFAULT_PAGESERVER_ID + }; + let mode = match (lsn, hot_standby) { (Some(lsn), false) => ComputeMode::Static(lsn), (None, true) => ComputeMode::Replica, @@ -649,6 +688,7 @@ fn handle_endpoint(ep_match: &ArgMatches, env: &local_env::LocalEnv) -> Result<( http_port, pg_version, mode, + pageserver_id, )?; } "start" => { @@ -658,6 +698,13 @@ fn handle_endpoint(ep_match: &ArgMatches, env: &local_env::LocalEnv) -> Result<( .get_one::("endpoint_id") .ok_or_else(|| anyhow!("No endpoint ID was provided to start"))?; + let pageserver_id = + if let Some(id_str) = sub_args.get_one::("endpoint-pageserver-id") { + NodeId(id_str.parse().context("while parsing pageserver id")?) + } else { + DEFAULT_PAGESERVER_ID + }; + let remote_ext_config = sub_args.get_one::("remote-ext-config"); // If --safekeepers argument is given, use only the listed safekeeper nodes. @@ -677,7 +724,8 @@ fn handle_endpoint(ep_match: &ArgMatches, env: &local_env::LocalEnv) -> Result<( let endpoint = cplane.endpoints.get(endpoint_id.as_str()); - let auth_token = if matches!(env.pageserver.pg_auth_type, AuthType::NeonJWT) { + let ps_conf = env.get_pageserver_conf(pageserver_id)?; + let auth_token = if matches!(ps_conf.pg_auth_type, AuthType::NeonJWT) { let claims = Claims::new(Some(tenant_id), Scope::Tenant); Some(env.generate_auth_token(&claims)?) @@ -744,6 +792,7 @@ fn handle_endpoint(ep_match: &ArgMatches, env: &local_env::LocalEnv) -> Result<( http_port, pg_version, mode, + pageserver_id, )?; ep.start(&auth_token, safekeepers, remote_ext_config)?; } @@ -768,51 +817,94 @@ fn handle_endpoint(ep_match: &ArgMatches, env: &local_env::LocalEnv) -> Result<( } fn handle_pageserver(sub_match: &ArgMatches, env: &local_env::LocalEnv) -> Result<()> { - let pageserver = PageServerNode::from_env(env); + fn get_pageserver(env: &local_env::LocalEnv, args: &ArgMatches) -> Result { + let node_id = if let Some(id_str) = args.get_one::("pageserver-id") { + NodeId(id_str.parse().context("while parsing pageserver id")?) + } else { + DEFAULT_PAGESERVER_ID + }; + + Ok(PageServerNode::from_env( + env, + env.get_pageserver_conf(node_id)?, + )) + } match sub_match.subcommand() { - Some(("start", start_match)) => { - if let Err(e) = pageserver.start(&pageserver_config_overrides(start_match)) { + Some(("start", subcommand_args)) => { + if let Err(e) = get_pageserver(env, subcommand_args)? + .start(&pageserver_config_overrides(subcommand_args)) + { eprintln!("pageserver start failed: {e}"); exit(1); } } + Some(("stop", subcommand_args)) => { + let immediate = subcommand_args + .get_one::("stop-mode") + .map(|s| s.as_str()) + == Some("immediate"); + + if let Err(e) = get_pageserver(env, subcommand_args)?.stop(immediate) { + eprintln!("pageserver stop failed: {}", e); + exit(1); + } + } + + Some(("restart", subcommand_args)) => { + let pageserver = get_pageserver(env, subcommand_args)?; + //TODO what shutdown strategy should we use here? + if let Err(e) = pageserver.stop(false) { + eprintln!("pageserver stop failed: {}", e); + exit(1); + } + + if let Err(e) = pageserver.start(&pageserver_config_overrides(subcommand_args)) { + eprintln!("pageserver start failed: {e}"); + exit(1); + } + } + + Some(("status", subcommand_args)) => { + match get_pageserver(env, subcommand_args)?.check_status() { + Ok(_) => println!("Page server is up and running"), + Err(err) => { + eprintln!("Page server is not available: {}", err); + exit(1); + } + } + } + + Some((sub_name, _)) => bail!("Unexpected pageserver subcommand '{}'", sub_name), + None => bail!("no pageserver subcommand provided"), + } + Ok(()) +} + +fn handle_attachment_service(sub_match: &ArgMatches, env: &local_env::LocalEnv) -> Result<()> { + let svc = AttachmentService::from_env(env); + match sub_match.subcommand() { + Some(("start", _start_match)) => { + if let Err(e) = svc.start() { + eprintln!("start failed: {e}"); + exit(1); + } + } + Some(("stop", stop_match)) => { let immediate = stop_match .get_one::("stop-mode") .map(|s| s.as_str()) == Some("immediate"); - if let Err(e) = pageserver.stop(immediate) { - eprintln!("pageserver stop failed: {}", e); + if let Err(e) = svc.stop(immediate) { + eprintln!("stop failed: {}", e); exit(1); } } - - Some(("restart", restart_match)) => { - //TODO what shutdown strategy should we use here? - if let Err(e) = pageserver.stop(false) { - eprintln!("pageserver stop failed: {}", e); - exit(1); - } - - if let Err(e) = pageserver.start(&pageserver_config_overrides(restart_match)) { - eprintln!("pageserver start failed: {e}"); - exit(1); - } - } - - Some(("status", _)) => match PageServerNode::from_env(env).check_status() { - Ok(_) => println!("Page server is up and running"), - Err(err) => { - eprintln!("Page server is not available: {}", err); - exit(1); - } - }, - - Some((sub_name, _)) => bail!("Unexpected pageserver subcommand '{}'", sub_name), - None => bail!("no pageserver subcommand provided"), + Some((sub_name, _)) => bail!("Unexpected attachment_service subcommand '{}'", sub_name), + None => bail!("no attachment_service subcommand provided"), } Ok(()) } @@ -897,11 +989,23 @@ fn handle_start_all(sub_match: &ArgMatches, env: &local_env::LocalEnv) -> anyhow broker::start_broker_process(env)?; - let pageserver = PageServerNode::from_env(env); - if let Err(e) = pageserver.start(&pageserver_config_overrides(sub_match)) { - eprintln!("pageserver {} start failed: {:#}", env.pageserver.id, e); - try_stop_all(env, true); - exit(1); + // Only start the attachment service if the pageserver is configured to need it + if env.control_plane_api.is_some() { + let attachment_service = AttachmentService::from_env(env); + if let Err(e) = attachment_service.start() { + eprintln!("attachment_service start failed: {:#}", e); + try_stop_all(env, true); + exit(1); + } + } + + for ps_conf in &env.pageservers { + let pageserver = PageServerNode::from_env(env, ps_conf); + if let Err(e) = pageserver.start(&pageserver_config_overrides(sub_match)) { + eprintln!("pageserver {} start failed: {:#}", ps_conf.id, e); + try_stop_all(env, true); + exit(1); + } } for node in env.safekeepers.iter() { @@ -925,8 +1029,6 @@ fn handle_stop_all(sub_match: &ArgMatches, env: &local_env::LocalEnv) -> Result< } fn try_stop_all(env: &local_env::LocalEnv, immediate: bool) { - let pageserver = PageServerNode::from_env(env); - // Stop all endpoints match ComputeControlPlane::load(env.clone()) { Ok(cplane) => { @@ -941,8 +1043,11 @@ fn try_stop_all(env: &local_env::LocalEnv, immediate: bool) { } } - if let Err(e) = pageserver.stop(immediate) { - eprintln!("pageserver {} stop failed: {:#}", env.pageserver.id, e); + for ps_conf in &env.pageservers { + let pageserver = PageServerNode::from_env(env, ps_conf); + if let Err(e) = pageserver.stop(immediate) { + eprintln!("pageserver {} stop failed: {:#}", ps_conf.id, e); + } } for node in env.safekeepers.iter() { @@ -955,6 +1060,13 @@ fn try_stop_all(env: &local_env::LocalEnv, immediate: bool) { if let Err(e) = broker::stop_broker_process(env) { eprintln!("neon broker stop failed: {e:#}"); } + + if env.control_plane_api.is_some() { + let attachment_service = AttachmentService::from_env(env); + if let Err(e) = attachment_service.stop(immediate) { + eprintln!("attachment service stop failed: {e:#}"); + } + } } fn cli() -> Command { @@ -969,6 +1081,16 @@ fn cli() -> Command { let safekeeper_id_arg = Arg::new("id").help("safekeeper id").required(false); + // --id, when using a pageserver command + let pageserver_id_arg = Arg::new("pageserver-id") + .long("id") + .help("pageserver id") + .required(false); + // --pageserver-id when using a non-pageserver command + let endpoint_pageserver_id_arg = Arg::new("endpoint-pageserver-id") + .long("pageserver-id") + .required(false); + let safekeeper_extra_opt_arg = Arg::new("safekeeper-extra-opt") .short('e') .long("safekeeper-extra-opt") @@ -1133,10 +1255,24 @@ fn cli() -> Command { .arg_required_else_help(true) .about("Manage pageserver") .subcommand(Command::new("status")) + .arg(pageserver_id_arg.clone()) + .subcommand(Command::new("start").about("Start local pageserver") + .arg(pageserver_id_arg.clone()) + .arg(pageserver_config_args.clone())) + .subcommand(Command::new("stop").about("Stop local pageserver") + .arg(pageserver_id_arg.clone()) + .arg(stop_mode_arg.clone())) + .subcommand(Command::new("restart").about("Restart local pageserver") + .arg(pageserver_id_arg.clone()) + .arg(pageserver_config_args.clone())) + ) + .subcommand( + Command::new("attachment_service") + .arg_required_else_help(true) + .about("Manage attachment_service") .subcommand(Command::new("start").about("Start local pageserver").arg(pageserver_config_args.clone())) .subcommand(Command::new("stop").about("Stop local pageserver") .arg(stop_mode_arg.clone())) - .subcommand(Command::new("restart").about("Restart local pageserver").arg(pageserver_config_args.clone())) ) .subcommand( Command::new("safekeeper") @@ -1172,6 +1308,7 @@ fn cli() -> Command { .arg(lsn_arg.clone()) .arg(pg_port_arg.clone()) .arg(http_port_arg.clone()) + .arg(endpoint_pageserver_id_arg.clone()) .arg( Arg::new("config-only") .help("Don't do basebackup, create endpoint directory with only config files") @@ -1189,6 +1326,7 @@ fn cli() -> Command { .arg(lsn_arg) .arg(pg_port_arg) .arg(http_port_arg) + .arg(endpoint_pageserver_id_arg.clone()) .arg(pg_version_arg) .arg(hot_standby_arg) .arg(safekeepers_arg) diff --git a/control_plane/src/endpoint.rs b/control_plane/src/endpoint.rs index 4ed03c8771..cba364c049 100644 --- a/control_plane/src/endpoint.rs +++ b/control_plane/src/endpoint.rs @@ -70,6 +70,7 @@ pub struct EndpointConf { http_port: u16, pg_version: u32, skip_pg_catalog_updates: bool, + pageserver_id: NodeId, } // @@ -82,19 +83,16 @@ pub struct ComputeControlPlane { pub endpoints: BTreeMap>, env: LocalEnv, - pageserver: Arc, } impl ComputeControlPlane { // Load current endpoints from the endpoints/ subdirectories pub fn load(env: LocalEnv) -> Result { - let pageserver = Arc::new(PageServerNode::from_env(&env)); - let mut endpoints = BTreeMap::default(); for endpoint_dir in std::fs::read_dir(env.endpoints_path()) .with_context(|| format!("failed to list {}", env.endpoints_path().display()))? { - let ep = Endpoint::from_dir_entry(endpoint_dir?, &env, &pageserver)?; + let ep = Endpoint::from_dir_entry(endpoint_dir?, &env)?; endpoints.insert(ep.endpoint_id.clone(), Arc::new(ep)); } @@ -102,7 +100,6 @@ impl ComputeControlPlane { base_port: 55431, endpoints, env, - pageserver, }) } @@ -125,15 +122,18 @@ impl ComputeControlPlane { http_port: Option, pg_version: u32, mode: ComputeMode, + pageserver_id: NodeId, ) -> Result> { let pg_port = pg_port.unwrap_or_else(|| self.get_port()); let http_port = http_port.unwrap_or_else(|| self.get_port() + 1); + let pageserver = + PageServerNode::from_env(&self.env, self.env.get_pageserver_conf(pageserver_id)?); let ep = Arc::new(Endpoint { endpoint_id: endpoint_id.to_owned(), pg_address: SocketAddr::new("127.0.0.1".parse().unwrap(), pg_port), http_address: SocketAddr::new("127.0.0.1".parse().unwrap(), http_port), env: self.env.clone(), - pageserver: Arc::clone(&self.pageserver), + pageserver, timeline_id, mode, tenant_id, @@ -159,6 +159,7 @@ impl ComputeControlPlane { pg_port, pg_version, skip_pg_catalog_updates: true, + pageserver_id, })?, )?; std::fs::write( @@ -193,18 +194,14 @@ pub struct Endpoint { // These are not part of the endpoint as such, but the environment // the endpoint runs in. pub env: LocalEnv, - pageserver: Arc, + pageserver: PageServerNode, // Optimizations skip_pg_catalog_updates: bool, } impl Endpoint { - fn from_dir_entry( - entry: std::fs::DirEntry, - env: &LocalEnv, - pageserver: &Arc, - ) -> Result { + fn from_dir_entry(entry: std::fs::DirEntry, env: &LocalEnv) -> Result { if !entry.file_type()?.is_dir() { anyhow::bail!( "Endpoint::from_dir_entry failed: '{}' is not a directory", @@ -220,12 +217,15 @@ impl Endpoint { let conf: EndpointConf = serde_json::from_slice(&std::fs::read(entry.path().join("endpoint.json"))?)?; + let pageserver = + PageServerNode::from_env(env, env.get_pageserver_conf(conf.pageserver_id)?); + Ok(Endpoint { pg_address: SocketAddr::new("127.0.0.1".parse().unwrap(), conf.pg_port), http_address: SocketAddr::new("127.0.0.1".parse().unwrap(), conf.http_port), endpoint_id, env: env.clone(), - pageserver: Arc::clone(pageserver), + pageserver, timeline_id: conf.timeline_id, mode: conf.mode, tenant_id: conf.tenant_id, diff --git a/control_plane/src/lib.rs b/control_plane/src/lib.rs index a773b8dcc3..7592880402 100644 --- a/control_plane/src/lib.rs +++ b/control_plane/src/lib.rs @@ -7,6 +7,7 @@ // local installations. // +pub mod attachment_service; mod background_process; pub mod broker; pub mod endpoint; diff --git a/control_plane/src/local_env.rs b/control_plane/src/local_env.rs index 9e42c2e333..b4d09b01ab 100644 --- a/control_plane/src/local_env.rs +++ b/control_plane/src/local_env.rs @@ -68,11 +68,17 @@ pub struct LocalEnv { pub broker: NeonBroker, - pub pageserver: PageServerConf, + /// This Vec must always contain at least one pageserver + pub pageservers: Vec, #[serde(default)] pub safekeepers: Vec, + // Control plane location: if None, we will not run attachment_service. If set, this will + // be propagated into each pageserver's configuration. + #[serde(default)] + pub control_plane_api: Option, + /// Keep human-readable aliases in memory (and persist them to config), to hide ZId hex strings from the user. #[serde(default)] // A `HashMap>` would be more appropriate here, @@ -202,6 +208,10 @@ impl LocalEnv { self.neon_distrib_dir.join("pageserver") } + pub fn attachment_service_bin(&self) -> PathBuf { + self.neon_distrib_dir.join("attachment_service") + } + pub fn safekeeper_bin(&self) -> PathBuf { self.neon_distrib_dir.join("safekeeper") } @@ -214,15 +224,23 @@ impl LocalEnv { self.base_data_dir.join("endpoints") } - // TODO: move pageserver files into ./pageserver - pub fn pageserver_data_dir(&self) -> PathBuf { - self.base_data_dir.clone() + pub fn pageserver_data_dir(&self, pageserver_id: NodeId) -> PathBuf { + self.base_data_dir + .join(format!("pageserver_{pageserver_id}")) } pub fn safekeeper_data_dir(&self, data_dir_name: &str) -> PathBuf { self.base_data_dir.join("safekeepers").join(data_dir_name) } + pub fn get_pageserver_conf(&self, id: NodeId) -> anyhow::Result<&PageServerConf> { + if let Some(conf) = self.pageservers.iter().find(|node| node.id == id) { + Ok(conf) + } else { + bail!("could not find pageserver {id}") + } + } + pub fn register_branch_mapping( &mut self, branch_name: String, @@ -299,6 +317,10 @@ impl LocalEnv { env.neon_distrib_dir = env::current_exe()?.parent().unwrap().to_owned(); } + if env.pageservers.is_empty() { + anyhow::bail!("Configuration must contain at least one pageserver"); + } + env.base_data_dir = base_path(); Ok(env) @@ -331,7 +353,7 @@ impl LocalEnv { // We read that in, in `create_config`, and fill any missing defaults. Then it's saved // to .neon/config. TODO: We lose any formatting and comments along the way, which is // a bit sad. - let mut conf_content = r#"# This file describes a locale deployment of the page server + let mut conf_content = r#"# This file describes a local deployment of the page server # and safekeeeper node. It is read by the 'neon_local' command-line # utility. "# @@ -461,9 +483,9 @@ impl LocalEnv { } fn auth_keys_needed(&self) -> bool { - self.pageserver.pg_auth_type == AuthType::NeonJWT - || self.pageserver.http_auth_type == AuthType::NeonJWT - || self.safekeepers.iter().any(|sk| sk.auth_enabled) + self.pageservers.iter().any(|ps| { + ps.pg_auth_type == AuthType::NeonJWT || ps.http_auth_type == AuthType::NeonJWT + }) || self.safekeepers.iter().any(|sk| sk.auth_enabled) } } diff --git a/control_plane/src/pageserver.rs b/control_plane/src/pageserver.rs index 2ff09021e5..a6b675fdb5 100644 --- a/control_plane/src/pageserver.rs +++ b/control_plane/src/pageserver.rs @@ -27,6 +27,7 @@ use utils::{ lsn::Lsn, }; +use crate::local_env::PageServerConf; use crate::{background_process, local_env::LocalEnv}; #[derive(Error, Debug)] @@ -76,43 +77,40 @@ impl ResponseErrorMessageExt for Response { #[derive(Debug)] pub struct PageServerNode { pub pg_connection_config: PgConnectionConfig, + pub conf: PageServerConf, pub env: LocalEnv, pub http_client: Client, pub http_base_url: String, } impl PageServerNode { - pub fn from_env(env: &LocalEnv) -> PageServerNode { - let (host, port) = parse_host_port(&env.pageserver.listen_pg_addr) - .expect("Unable to parse listen_pg_addr"); + pub fn from_env(env: &LocalEnv, conf: &PageServerConf) -> PageServerNode { + let (host, port) = + parse_host_port(&conf.listen_pg_addr).expect("Unable to parse listen_pg_addr"); let port = port.unwrap_or(5432); Self { pg_connection_config: PgConnectionConfig::new_host_port(host, port), + conf: conf.clone(), env: env.clone(), http_client: Client::new(), - http_base_url: format!("http://{}/v1", env.pageserver.listen_http_addr), + http_base_url: format!("http://{}/v1", conf.listen_http_addr), } } // pageserver conf overrides defined by neon_local configuration. fn neon_local_overrides(&self) -> Vec { - let id = format!("id={}", self.env.pageserver.id); + let id = format!("id={}", self.conf.id); // FIXME: the paths should be shell-escaped to handle paths with spaces, quotas etc. let pg_distrib_dir_param = format!( "pg_distrib_dir='{}'", self.env.pg_distrib_dir_raw().display() ); - let http_auth_type_param = - format!("http_auth_type='{}'", self.env.pageserver.http_auth_type); - let listen_http_addr_param = format!( - "listen_http_addr='{}'", - self.env.pageserver.listen_http_addr - ); + let http_auth_type_param = format!("http_auth_type='{}'", self.conf.http_auth_type); + let listen_http_addr_param = format!("listen_http_addr='{}'", self.conf.listen_http_addr); - let pg_auth_type_param = format!("pg_auth_type='{}'", self.env.pageserver.pg_auth_type); - let listen_pg_addr_param = - format!("listen_pg_addr='{}'", self.env.pageserver.listen_pg_addr); + let pg_auth_type_param = format!("pg_auth_type='{}'", self.conf.pg_auth_type); + let listen_pg_addr_param = format!("listen_pg_addr='{}'", self.conf.listen_pg_addr); let broker_endpoint_param = format!("broker_endpoint='{}'", self.env.broker.client_url()); @@ -126,10 +124,18 @@ impl PageServerNode { broker_endpoint_param, ]; - if self.env.pageserver.http_auth_type != AuthType::Trust - || self.env.pageserver.pg_auth_type != AuthType::Trust + if let Some(control_plane_api) = &self.env.control_plane_api { + overrides.push(format!( + "control_plane_api='{}'", + control_plane_api.as_str() + )); + } + + if self.conf.http_auth_type != AuthType::Trust || self.conf.pg_auth_type != AuthType::Trust { - overrides.push("auth_validation_public_key_path='auth_public_key.pem'".to_owned()); + // Keys are generated in the toplevel repo dir, pageservers' workdirs + // are one level below that, so refer to keys with ../ + overrides.push("auth_validation_public_key_path='../auth_public_key.pem'".to_owned()); } overrides } @@ -137,16 +143,12 @@ impl PageServerNode { /// Initializes a pageserver node by creating its config with the overrides provided. pub fn initialize(&self, config_overrides: &[&str]) -> anyhow::Result<()> { // First, run `pageserver --init` and wait for it to write a config into FS and exit. - self.pageserver_init(config_overrides).with_context(|| { - format!( - "Failed to run init for pageserver node {}", - self.env.pageserver.id, - ) - }) + self.pageserver_init(config_overrides) + .with_context(|| format!("Failed to run init for pageserver node {}", self.conf.id,)) } pub fn repo_path(&self) -> PathBuf { - self.env.pageserver_data_dir() + self.env.pageserver_data_dir(self.conf.id) } /// The pid file is created by the pageserver process, with its pid stored inside. @@ -162,7 +164,7 @@ impl PageServerNode { fn pageserver_init(&self, config_overrides: &[&str]) -> anyhow::Result<()> { let datadir = self.repo_path(); - let node_id = self.env.pageserver.id; + let node_id = self.conf.id; println!( "Initializing pageserver node {} at '{}' in {:?}", node_id, @@ -171,6 +173,10 @@ impl PageServerNode { ); io::stdout().flush()?; + if !datadir.exists() { + std::fs::create_dir(&datadir)?; + } + let datadir_path_str = datadir.to_str().with_context(|| { format!("Cannot start pageserver node {node_id} in path that has no string representation: {datadir:?}") })?; @@ -201,7 +207,7 @@ impl PageServerNode { let datadir = self.repo_path(); print!( "Starting pageserver node {} at '{}' in {:?}", - self.env.pageserver.id, + self.conf.id, self.pg_connection_config.raw_address(), datadir ); @@ -210,7 +216,7 @@ impl PageServerNode { let datadir_path_str = datadir.to_str().with_context(|| { format!( "Cannot start pageserver node {} in path that has no string representation: {:?}", - self.env.pageserver.id, datadir, + self.conf.id, datadir, ) })?; let mut args = self.pageserver_basic_args(config_overrides, datadir_path_str); @@ -254,7 +260,7 @@ impl PageServerNode { // FIXME: why is this tied to pageserver's auth type? Whether or not the safekeeper // needs a token, and how to generate that token, seems independent to whether // the pageserver requires a token in incoming requests. - Ok(if self.env.pageserver.http_auth_type != AuthType::Trust { + Ok(if self.conf.http_auth_type != AuthType::Trust { // Generate a token to connect from the pageserver to a safekeeper let token = self .env @@ -279,7 +285,7 @@ impl PageServerNode { pub fn page_server_psql_client(&self) -> anyhow::Result { let mut config = self.pg_connection_config.clone(); - if self.env.pageserver.pg_auth_type == AuthType::NeonJWT { + if self.conf.pg_auth_type == AuthType::NeonJWT { let token = self .env .generate_auth_token(&Claims::new(None, Scope::PageServerApi))?; @@ -290,7 +296,7 @@ impl PageServerNode { fn http_request(&self, method: Method, url: U) -> anyhow::Result { let mut builder = self.http_client.request(method, url); - if self.env.pageserver.http_auth_type == AuthType::NeonJWT { + if self.conf.http_auth_type == AuthType::NeonJWT { let token = self .env .generate_auth_token(&Claims::new(None, Scope::PageServerApi))?; @@ -316,7 +322,8 @@ impl PageServerNode { pub fn tenant_create( &self, - new_tenant_id: Option, + new_tenant_id: TenantId, + generation: Option, settings: HashMap<&str, &str>, ) -> anyhow::Result { let mut settings = settings.clone(); @@ -382,11 +389,9 @@ impl PageServerNode { .context("Failed to parse 'gc_feedback' as bool")?, }; - // If tenant ID was not specified, generate one - let new_tenant_id = new_tenant_id.unwrap_or(TenantId::generate()); - let request = models::TenantCreateRequest { new_tenant_id, + generation, config, }; if !settings.is_empty() { diff --git a/libs/pageserver_api/src/control_api.rs b/libs/pageserver_api/src/control_api.rs new file mode 100644 index 0000000000..a54fee47a5 --- /dev/null +++ b/libs/pageserver_api/src/control_api.rs @@ -0,0 +1,52 @@ +//! Types in this file are for pageserver's upward-facing API calls to the control plane, +//! required for acquiring and validating tenant generation numbers. +//! +//! See docs/rfcs/025-generation-numbers.md + +use serde::{Deserialize, Serialize}; +use serde_with::{serde_as, DisplayFromStr}; +use utils::id::{NodeId, TenantId}; + +#[derive(Serialize, Deserialize)] +pub struct ReAttachRequest { + pub node_id: NodeId, +} + +#[serde_as] +#[derive(Serialize, Deserialize)] +pub struct ReAttachResponseTenant { + #[serde_as(as = "DisplayFromStr")] + pub id: TenantId, + pub generation: u32, +} + +#[derive(Serialize, Deserialize)] +pub struct ReAttachResponse { + pub tenants: Vec, +} + +#[serde_as] +#[derive(Serialize, Deserialize)] +pub struct ValidateRequestTenant { + #[serde_as(as = "DisplayFromStr")] + pub id: TenantId, + pub gen: u32, +} + +#[derive(Serialize, Deserialize)] +pub struct ValidateRequest { + pub tenants: Vec, +} + +#[derive(Serialize, Deserialize)] +pub struct ValidateResponse { + pub tenants: Vec, +} + +#[serde_as] +#[derive(Serialize, Deserialize)] +pub struct ValidateResponseTenant { + #[serde_as(as = "DisplayFromStr")] + pub id: TenantId, + pub valid: bool, +} diff --git a/libs/pageserver_api/src/lib.rs b/libs/pageserver_api/src/lib.rs index 4890d54f36..d844021785 100644 --- a/libs/pageserver_api/src/lib.rs +++ b/libs/pageserver_api/src/lib.rs @@ -1,6 +1,7 @@ use const_format::formatcp; /// Public API types +pub mod control_api; pub mod models; pub mod reltag; diff --git a/libs/pageserver_api/src/models.rs b/libs/pageserver_api/src/models.rs index 2f4c21326e..f354296be2 100644 --- a/libs/pageserver_api/src/models.rs +++ b/libs/pageserver_api/src/models.rs @@ -194,10 +194,22 @@ pub struct TimelineCreateRequest { pub struct TenantCreateRequest { #[serde_as(as = "DisplayFromStr")] pub new_tenant_id: TenantId, + #[serde(default)] + #[serde(skip_serializing_if = "Option::is_none")] + pub generation: Option, #[serde(flatten)] pub config: TenantConfig, // as we have a flattened field, we should reject all unknown fields in it } +#[serde_as] +#[derive(Deserialize, Debug)] +#[serde(deny_unknown_fields)] +pub struct TenantLoadRequest { + #[serde(default)] + #[serde(skip_serializing_if = "Option::is_none")] + pub generation: Option, +} + impl std::ops::Deref for TenantCreateRequest { type Target = TenantConfig; @@ -241,15 +253,6 @@ pub struct StatusResponse { pub id: NodeId, } -impl TenantCreateRequest { - pub fn new(new_tenant_id: TenantId) -> TenantCreateRequest { - TenantCreateRequest { - new_tenant_id, - config: TenantConfig::default(), - } - } -} - #[serde_as] #[derive(Serialize, Deserialize, Debug)] #[serde(deny_unknown_fields)] @@ -293,9 +296,11 @@ impl TenantConfigRequest { } } -#[derive(Debug, Serialize, Deserialize)] +#[derive(Debug, Deserialize)] pub struct TenantAttachRequest { pub config: TenantAttachConfig, + #[serde(default)] + pub generation: Option, } /// Newtype to enforce deny_unknown_fields on TenantConfig for @@ -376,6 +381,8 @@ pub struct TimelineInfo { pub pg_version: u32, pub state: TimelineState, + + pub walreceiver_status: String, } #[derive(Debug, Clone, Serialize)] diff --git a/libs/pq_proto/src/lib.rs b/libs/pq_proto/src/lib.rs index 809fa5fffd..47faad363f 100644 --- a/libs/pq_proto/src/lib.rs +++ b/libs/pq_proto/src/lib.rs @@ -959,7 +959,7 @@ mod tests { let make_params = |options| StartupMessageParams::new([("options", options)]); let params = StartupMessageParams::new([]); - assert!(matches!(params.options_escaped(), None)); + assert!(params.options_escaped().is_none()); let params = make_params(""); assert!(split_options(¶ms).is_empty()); diff --git a/libs/remote_storage/src/local_fs.rs b/libs/remote_storage/src/local_fs.rs index f1095ad8b8..5040183045 100644 --- a/libs/remote_storage/src/local_fs.rs +++ b/libs/remote_storage/src/local_fs.rs @@ -148,21 +148,55 @@ impl RemoteStorage for LocalFs { Some(folder) => folder.with_base(&self.storage_root), None => self.storage_root.clone(), }; - let mut files = vec![]; - let mut directory_queue = vec![full_path.clone()]; + // If we were given a directory, we may use it as our starting point. + // Otherwise, we must go up to the parent directory. This is because + // S3 object list prefixes can be arbitrary strings, but when reading + // the local filesystem we need a directory to start calling read_dir on. + let mut initial_dir = full_path.clone(); + match fs::metadata(full_path.clone()).await { + Ok(meta) => { + if !meta.is_dir() { + // It's not a directory: strip back to the parent + initial_dir.pop(); + } + } + Err(e) if e.kind() == ErrorKind::NotFound => { + // It's not a file that exists: strip the prefix back to the parent directory + initial_dir.pop(); + } + Err(e) => { + // Unexpected I/O error + anyhow::bail!(e) + } + } + + // Note that PathBuf starts_with only considers full path segments, but + // object prefixes are arbitrary strings, so we need the strings for doing + // starts_with later. + let prefix = full_path.to_string_lossy(); + + let mut files = vec![]; + let mut directory_queue = vec![initial_dir.clone()]; while let Some(cur_folder) = directory_queue.pop() { let mut entries = fs::read_dir(cur_folder.clone()).await?; while let Some(entry) = entries.next_entry().await? { let file_name: PathBuf = entry.file_name().into(); let full_file_name = cur_folder.clone().join(&file_name); - let file_remote_path = self.local_file_to_relative_path(full_file_name.clone()); - files.push(file_remote_path.clone()); - if full_file_name.is_dir() { - directory_queue.push(full_file_name); + if full_file_name + .to_str() + .map(|s| s.starts_with(prefix.as_ref())) + .unwrap_or(false) + { + let file_remote_path = self.local_file_to_relative_path(full_file_name.clone()); + files.push(file_remote_path.clone()); + if full_file_name.is_dir() { + directory_queue.push(full_file_name); + } } } } + Ok(files) } diff --git a/libs/remote_storage/src/s3_bucket.rs b/libs/remote_storage/src/s3_bucket.rs index 9d05fa32b3..9262f1e88f 100644 --- a/libs/remote_storage/src/s3_bucket.rs +++ b/libs/remote_storage/src/s3_bucket.rs @@ -573,7 +573,7 @@ mod tests { #[test] fn relative_path() { - let all_paths = vec!["", "some/path", "some/path/"]; + let all_paths = ["", "some/path", "some/path/"]; let all_paths: Vec = all_paths .iter() .map(|x| RemotePath::new(Path::new(x)).expect("bad path")) diff --git a/libs/utils/src/generation.rs b/libs/utils/src/generation.rs index 87c6361255..163c8c0467 100644 --- a/libs/utils/src/generation.rs +++ b/libs/utils/src/generation.rs @@ -53,6 +53,7 @@ impl Generation { matches!(self, Self::None) } + #[track_caller] pub fn get_suffix(&self) -> String { match self { Self::Valid(v) => { @@ -64,6 +65,30 @@ impl Generation { } } } + + /// `suffix` is the part after "-" in a key + /// + /// Returns None if parsing was unsuccessful + pub fn parse_suffix(suffix: &str) -> Option { + u32::from_str_radix(suffix, 16).map(Generation::new).ok() + } + + #[track_caller] + pub fn previous(&self) -> Generation { + match self { + Self::Valid(n) => { + if *n == 0 { + // Since a tenant may be upgraded from a pre-generations state, interpret the "previous" generation + // to 0 as being "no generation". + Self::None + } else { + Self::Valid(n - 1) + } + } + Self::None => Self::None, + Self::Broken => panic!("Attempted to use a broken generation"), + } + } } impl Serialize for Generation { diff --git a/libs/vm_monitor/src/lib.rs b/libs/vm_monitor/src/lib.rs index 2eee31c965..1cbfdc6ba6 100644 --- a/libs/vm_monitor/src/lib.rs +++ b/libs/vm_monitor/src/lib.rs @@ -178,14 +178,17 @@ pub async fn ws_handler( /// Starts the monitor. If startup fails or the monitor exits, an error will /// be logged and our internal state will be reset to allow for new connections. -#[tracing::instrument(skip_all, fields(?args))] +#[tracing::instrument(skip_all)] async fn start_monitor( ws: WebSocket, args: &Args, kill: broadcast::Receiver<()>, token: CancellationToken, ) { - info!("accepted new websocket connection -> starting monitor"); + info!( + ?args, + "accepted new websocket connection -> starting monitor" + ); let timeout = Duration::from_secs(4); let monitor = tokio::time::timeout( timeout, diff --git a/libs/vm_monitor/src/runner.rs b/libs/vm_monitor/src/runner.rs index 82055fda2e..8f904b879d 100644 --- a/libs/vm_monitor/src/runner.rs +++ b/libs/vm_monitor/src/runner.rs @@ -5,6 +5,7 @@ //! all functionality. use std::sync::Arc; +use std::time::{Duration, Instant}; use std::{fmt::Debug, mem}; use anyhow::{bail, Context}; @@ -36,6 +37,8 @@ pub struct Runner { /// by us vs the autoscaler-agent. counter: usize, + last_upscale_request_at: Option, + /// A signal to kill the main thread produced by `self.run()`. This is triggered /// when the server receives a new connection. When the thread receives the /// signal off this channel, it will gracefully shutdown. @@ -99,6 +102,7 @@ impl Runner { cgroup: None, dispatcher, counter: 1, // NB: must be odd, see the comment about the field for more. + last_upscale_request_at: None, kill, }; @@ -397,6 +401,20 @@ impl Runner { if request.is_none() { bail!("failed to listen for upscale event from cgroup") } + + // If it's been less than 1 second since the last time we requested upscaling, + // ignore the event, to avoid spamming the agent (otherwise, this can happen + // ~1k times per second). + if let Some(t) = self.last_upscale_request_at { + let elapsed = t.elapsed(); + if elapsed < Duration::from_secs(1) { + info!(elapsed_millis = elapsed.as_millis(), "cgroup asked for upscale but too soon to forward the request, ignoring"); + continue; + } + } + + self.last_upscale_request_at = Some(Instant::now()); + info!("cgroup asking for upscale; forwarding request"); self.counter += 2; // Increment, preserving parity (i.e. keep the // counter odd). See the field comment for more. diff --git a/pageserver/ctl/src/layer_map_analyzer.rs b/pageserver/ctl/src/layer_map_analyzer.rs index 29bd6ce598..495dae87e3 100644 --- a/pageserver/ctl/src/layer_map_analyzer.rs +++ b/pageserver/ctl/src/layer_map_analyzer.rs @@ -3,6 +3,7 @@ //! Currently it only analyzes holes, which are regions within the layer range that the layer contains no updates for. In the future it might do more analysis (maybe key quantiles?) but it should never return sensitive data. use anyhow::Result; +use pageserver::tenant::{TENANTS_SEGMENT_NAME, TIMELINES_SEGMENT_NAME}; use std::cmp::Ordering; use std::collections::BinaryHeap; use std::ops::Range; @@ -96,7 +97,7 @@ pub(crate) fn parse_filename(name: &str) -> Option { // Finds the max_holes largest holes, ignoring any that are smaller than MIN_HOLE_LENGTH" async fn get_holes(path: &Path, max_holes: usize) -> Result> { - let file = FileBlockReader::new(VirtualFile::open(path)?); + let file = FileBlockReader::new(VirtualFile::open(path).await?); let summary_blk = file.read_blk(0).await?; let actual_summary = Summary::des_prefix(summary_blk.as_ref())?; let tree_reader = DiskBtreeReader::<_, DELTA_KEY_SIZE>::new( @@ -142,12 +143,12 @@ pub(crate) async fn main(cmd: &AnalyzeLayerMapCmd) -> Result<()> { let mut total_delta_layers = 0usize; let mut total_image_layers = 0usize; let mut total_excess_layers = 0usize; - for tenant in fs::read_dir(storage_path.join("tenants"))? { + for tenant in fs::read_dir(storage_path.join(TENANTS_SEGMENT_NAME))? { let tenant = tenant?; if !tenant.file_type()?.is_dir() { continue; } - for timeline in fs::read_dir(tenant.path().join("timelines"))? { + for timeline in fs::read_dir(tenant.path().join(TIMELINES_SEGMENT_NAME))? { let timeline = timeline?; if !timeline.file_type()?.is_dir() { continue; diff --git a/pageserver/ctl/src/layers.rs b/pageserver/ctl/src/layers.rs index 608b3cecd6..33a6f197cf 100644 --- a/pageserver/ctl/src/layers.rs +++ b/pageserver/ctl/src/layers.rs @@ -5,6 +5,7 @@ use clap::Subcommand; use pageserver::tenant::block_io::BlockCursor; use pageserver::tenant::disk_btree::DiskBtreeReader; use pageserver::tenant::storage_layer::delta_layer::{BlobRef, Summary}; +use pageserver::tenant::{TENANTS_SEGMENT_NAME, TIMELINES_SEGMENT_NAME}; use pageserver::{page_cache, virtual_file}; use pageserver::{ repository::{Key, KEY_SIZE}, @@ -47,7 +48,7 @@ async fn read_delta_file(path: impl AsRef) -> Result<()> { let path = path.as_ref(); virtual_file::init(10); page_cache::init(100); - let file = FileBlockReader::new(VirtualFile::open(path)?); + let file = FileBlockReader::new(VirtualFile::open(path).await?); let summary_blk = file.read_blk(0).await?; let actual_summary = Summary::des_prefix(summary_blk.as_ref())?; let tree_reader = DiskBtreeReader::<_, DELTA_KEY_SIZE>::new( @@ -80,13 +81,13 @@ async fn read_delta_file(path: impl AsRef) -> Result<()> { pub(crate) async fn main(cmd: &LayerCmd) -> Result<()> { match cmd { LayerCmd::List { path } => { - for tenant in fs::read_dir(path.join("tenants"))? { + for tenant in fs::read_dir(path.join(TENANTS_SEGMENT_NAME))? { let tenant = tenant?; if !tenant.file_type()?.is_dir() { continue; } println!("tenant {}", tenant.file_name().to_string_lossy()); - for timeline in fs::read_dir(tenant.path().join("timelines"))? { + for timeline in fs::read_dir(tenant.path().join(TIMELINES_SEGMENT_NAME))? { let timeline = timeline?; if !timeline.file_type()?.is_dir() { continue; @@ -101,9 +102,9 @@ pub(crate) async fn main(cmd: &LayerCmd) -> Result<()> { timeline, } => { let timeline_path = path - .join("tenants") + .join(TENANTS_SEGMENT_NAME) .join(tenant) - .join("timelines") + .join(TIMELINES_SEGMENT_NAME) .join(timeline); let mut idx = 0; for layer in fs::read_dir(timeline_path)? { diff --git a/pageserver/src/bin/pageserver.rs b/pageserver/src/bin/pageserver.rs index 71e3a0ff3f..6103f4c885 100644 --- a/pageserver/src/bin/pageserver.rs +++ b/pageserver/src/bin/pageserver.rs @@ -388,6 +388,7 @@ fn start_pageserver( remote_storage: remote_storage.clone(), }, order, + shutdown_pageserver.clone(), ))?; BACKGROUND_RUNTIME.spawn({ @@ -476,16 +477,19 @@ fn start_pageserver( { let _rt_guard = MGMT_REQUEST_RUNTIME.enter(); - let router = http::make_router( - conf, - launch_ts, - http_auth, - broker_client.clone(), - remote_storage, - disk_usage_eviction_state, - )? - .build() - .map_err(|err| anyhow!(err))?; + let router_state = Arc::new( + http::routes::State::new( + conf, + http_auth.clone(), + remote_storage, + broker_client.clone(), + disk_usage_eviction_state, + ) + .context("Failed to initialize router state")?, + ); + let router = http::make_router(router_state, launch_ts, http_auth.clone())? + .build() + .map_err(|err| anyhow!(err))?; let service = utils::http::RouterService::new(router).unwrap(); let server = hyper::Server::from_tcp(http_listener)? .serve(service) diff --git a/pageserver/src/config.rs b/pageserver/src/config.rs index 5394f17398..89ba3b6310 100644 --- a/pageserver/src/config.rs +++ b/pageserver/src/config.rs @@ -32,7 +32,8 @@ use crate::disk_usage_eviction_task::DiskUsageEvictionTaskConfig; use crate::tenant::config::TenantConf; use crate::tenant::config::TenantConfOpt; use crate::tenant::{ - TENANT_ATTACHING_MARKER_FILENAME, TENANT_DELETED_MARKER_FILE_NAME, TIMELINES_SEGMENT_NAME, + TENANTS_SEGMENT_NAME, TENANT_ATTACHING_MARKER_FILENAME, TENANT_DELETED_MARKER_FILE_NAME, + TIMELINES_SEGMENT_NAME, }; use crate::{ IGNORED_TENANT_FILE_NAME, METADATA_FILE_NAME, TENANT_CONFIG_NAME, TIMELINE_DELETE_MARK_SUFFIX, @@ -72,7 +73,7 @@ pub mod defaults { /// Default built-in configuration file. /// pub const DEFAULT_CONFIG_FILE: &str = formatcp!( - r###" + r#" # Initial configuration file created by 'pageserver --init' #listen_pg_addr = '{DEFAULT_PG_LISTEN_ADDR}' #listen_http_addr = '{DEFAULT_HTTP_LISTEN_ADDR}' @@ -117,7 +118,7 @@ pub mod defaults { [remote_storage] -"### +"# ); } @@ -204,6 +205,8 @@ pub struct PageServerConf { /// has it's initial logical size calculated. Not running background tasks for some seconds is /// not terrible. pub background_task_maximum_delay: Duration, + + pub control_plane_api: Option, } /// We do not want to store this in a PageServerConf because the latter may be logged @@ -278,6 +281,8 @@ struct PageServerConfigBuilder { ondemand_download_behavior_treat_error_as_warn: BuilderValue, background_task_maximum_delay: BuilderValue, + + control_plane_api: BuilderValue>, } impl Default for PageServerConfigBuilder { @@ -340,6 +345,8 @@ impl Default for PageServerConfigBuilder { DEFAULT_BACKGROUND_TASK_MAXIMUM_DELAY, ) .unwrap()), + + control_plane_api: Set(None), } } } @@ -468,6 +475,10 @@ impl PageServerConfigBuilder { self.background_task_maximum_delay = BuilderValue::Set(delay); } + pub fn control_plane_api(&mut self, api: Url) { + self.control_plane_api = BuilderValue::Set(Some(api)) + } + pub fn build(self) -> anyhow::Result { let concurrent_tenant_size_logical_size_queries = self .concurrent_tenant_size_logical_size_queries @@ -553,6 +564,9 @@ impl PageServerConfigBuilder { background_task_maximum_delay: self .background_task_maximum_delay .ok_or(anyhow!("missing background_task_maximum_delay"))?, + control_plane_api: self + .control_plane_api + .ok_or(anyhow!("missing control_plane_api"))?, }) } } @@ -563,7 +577,7 @@ impl PageServerConf { // pub fn tenants_path(&self) -> PathBuf { - self.workdir.join("tenants") + self.workdir.join(TENANTS_SEGMENT_NAME) } pub fn tenant_path(&self, tenant_id: &TenantId) -> PathBuf { @@ -741,6 +755,7 @@ impl PageServerConf { }, "ondemand_download_behavior_treat_error_as_warn" => builder.ondemand_download_behavior_treat_error_as_warn(parse_toml_bool(key, item)?), "background_task_maximum_delay" => builder.background_task_maximum_delay(parse_toml_duration(key, item)?), + "control_plane_api" => builder.control_plane_api(parse_toml_string(key, item)?.parse().context("failed to parse control plane URL")?), _ => bail!("unrecognized pageserver option '{key}'"), } } @@ -909,6 +924,7 @@ impl PageServerConf { test_remote_failures: 0, ondemand_download_behavior_treat_error_as_warn: false, background_task_maximum_delay: Duration::ZERO, + control_plane_api: None, } } } @@ -1132,6 +1148,7 @@ background_task_maximum_delay = '334 s' background_task_maximum_delay: humantime::parse_duration( defaults::DEFAULT_BACKGROUND_TASK_MAXIMUM_DELAY )?, + control_plane_api: None }, "Correct defaults should be used when no config values are provided" ); @@ -1187,6 +1204,7 @@ background_task_maximum_delay = '334 s' test_remote_failures: 0, ondemand_download_behavior_treat_error_as_warn: false, background_task_maximum_delay: Duration::from_secs(334), + control_plane_api: None }, "Should be able to parse all basic config values correctly" ); diff --git a/pageserver/src/control_plane_client.rs b/pageserver/src/control_plane_client.rs new file mode 100644 index 0000000000..192eb16789 --- /dev/null +++ b/pageserver/src/control_plane_client.rs @@ -0,0 +1,119 @@ +use std::collections::HashMap; + +use hyper::StatusCode; +use pageserver_api::control_api::{ReAttachRequest, ReAttachResponse}; +use tokio_util::sync::CancellationToken; +use url::Url; +use utils::{ + backoff, + generation::Generation, + id::{NodeId, TenantId}, +}; + +use crate::config::PageServerConf; + +// Backoffs when control plane requests do not succeed: compromise between reducing load +// on control plane, and retrying frequently when we are blocked on a control plane +// response to make progress. +const BACKOFF_INCREMENT: f64 = 0.1; +const BACKOFF_MAX: f64 = 10.0; + +/// The Pageserver's client for using the control plane API: this is a small subset +/// of the overall control plane API, for dealing with generations (see docs/rfcs/025-generation-numbers.md) +pub(crate) struct ControlPlaneClient { + http_client: reqwest::Client, + base_url: Url, + node_id: NodeId, + cancel: CancellationToken, +} + +impl ControlPlaneClient { + /// A None return value indicates that the input `conf` object does not have control + /// plane API enabled. + pub(crate) fn new(conf: &'static PageServerConf, cancel: &CancellationToken) -> Option { + let mut url = match conf.control_plane_api.as_ref() { + Some(u) => u.clone(), + None => return None, + }; + + if let Ok(mut segs) = url.path_segments_mut() { + // This ensures that `url` ends with a slash if it doesn't already. + // That way, we can subsequently use join() to safely attach extra path elements. + segs.pop_if_empty().push(""); + } + + let client = reqwest::ClientBuilder::new() + .build() + .expect("Failed to construct http client"); + + Some(Self { + http_client: client, + base_url: url, + node_id: conf.id, + cancel: cancel.clone(), + }) + } + + async fn try_re_attach( + &self, + url: Url, + request: &ReAttachRequest, + ) -> anyhow::Result { + match self.http_client.post(url).json(request).send().await { + Err(e) => Err(anyhow::Error::from(e)), + Ok(r) => { + if r.status() == StatusCode::OK { + r.json::() + .await + .map_err(anyhow::Error::from) + } else { + Err(anyhow::anyhow!("Unexpected status {}", r.status())) + } + } + } + } + + /// Block until we get a successful response + pub(crate) async fn re_attach(&self) -> anyhow::Result> { + let re_attach_path = self + .base_url + .join("re-attach") + .expect("Failed to build re-attach path"); + let request = ReAttachRequest { + node_id: self.node_id, + }; + + let mut attempt = 0; + loop { + let result = self.try_re_attach(re_attach_path.clone(), &request).await; + match result { + Ok(res) => { + tracing::info!( + "Received re-attach response with {} tenants", + res.tenants.len() + ); + + return Ok(res + .tenants + .into_iter() + .map(|t| (t.id, Generation::new(t.generation))) + .collect::>()); + } + Err(e) => { + tracing::error!("Error re-attaching tenants, retrying: {e:#}"); + backoff::exponential_backoff( + attempt, + BACKOFF_INCREMENT, + BACKOFF_MAX, + &self.cancel, + ) + .await; + if self.cancel.is_cancelled() { + return Err(anyhow::anyhow!("Shutting down")); + } + attempt += 1; + } + } + } + } +} diff --git a/pageserver/src/http/openapi_spec.yml b/pageserver/src/http/openapi_spec.yml index 38e07f172d..4988641d6a 100644 --- a/pageserver/src/http/openapi_spec.yml +++ b/pageserver/src/http/openapi_spec.yml @@ -383,7 +383,6 @@ paths: schema: type: string format: hex - post: description: | Schedules attach operation to happen in the background for the given tenant. @@ -1020,6 +1019,9 @@ components: properties: config: $ref: '#/components/schemas/TenantConfig' + generation: + type: integer + description: Attachment generation number. TenantConfigRequest: allOf: - $ref: '#/components/schemas/TenantConfig' diff --git a/pageserver/src/http/routes.rs b/pageserver/src/http/routes.rs index f86657fa77..a8e914ba08 100644 --- a/pageserver/src/http/routes.rs +++ b/pageserver/src/http/routes.rs @@ -8,9 +8,10 @@ use anyhow::{anyhow, Context, Result}; use hyper::StatusCode; use hyper::{Body, Request, Response, Uri}; use metrics::launch_timestamp::LaunchTimestamp; -use pageserver_api::models::{DownloadRemoteLayersTaskSpawnRequest, TenantAttachRequest}; +use pageserver_api::models::{ + DownloadRemoteLayersTaskSpawnRequest, TenantAttachRequest, TenantLoadRequest, +}; use remote_storage::GenericRemoteStorage; -use storage_broker::BrokerClientChannel; use tenant_size_model::{SizeResult, StorageModel}; use tokio_util::sync::CancellationToken; use tracing::*; @@ -32,11 +33,13 @@ use crate::tenant::mgr::{ }; use crate::tenant::size::ModelInputs; use crate::tenant::storage_layer::LayerAccessStatsReset; -use crate::tenant::{LogicalSizeCalculationCause, PageReconstructError, Timeline}; +use crate::tenant::timeline::Timeline; +use crate::tenant::{LogicalSizeCalculationCause, PageReconstructError}; use crate::{config::PageServerConf, tenant::mgr}; use crate::{disk_usage_eviction_task, tenant}; use utils::{ auth::JwtAuth, + generation::Generation, http::{ endpoint::{self, attach_openapi_ui, auth_middleware, check_permission_with}, error::{ApiError, HttpErrorBody}, @@ -51,7 +54,7 @@ use utils::{ // Imports only used for testing APIs use super::models::ConfigureFailpointsRequest; -struct State { +pub struct State { conf: &'static PageServerConf, auth: Option>, allowlist_routes: Vec, @@ -61,7 +64,7 @@ struct State { } impl State { - fn new( + pub fn new( conf: &'static PageServerConf, auth: Option>, remote_storage: Option, @@ -282,6 +285,8 @@ async fn build_timeline_info_common( let state = timeline.current_state(); let remote_consistent_lsn = timeline.get_remote_consistent_lsn().unwrap_or(Lsn(0)); + let walreceiver_status = timeline.walreceiver_status(); + let info = TimelineInfo { tenant_id: timeline.tenant_id, timeline_id: timeline.timeline_id, @@ -302,6 +307,8 @@ async fn build_timeline_info_common( pg_version: timeline.pg_version, state, + + walreceiver_status, }; Ok(info) } @@ -472,7 +479,7 @@ async fn tenant_attach_handler( check_permission(&request, Some(tenant_id))?; let maybe_body: Option = json_request_or_empty_body(&mut request).await?; - let tenant_conf = match maybe_body { + let tenant_conf = match &maybe_body { Some(request) => TenantConfOpt::try_from(&*request.config).map_err(ApiError::BadRequest)?, None => TenantConfOpt::default(), }; @@ -483,10 +490,13 @@ async fn tenant_attach_handler( let state = get_state(&request); + let generation = get_request_generation(state, maybe_body.as_ref().and_then(|r| r.generation))?; + if let Some(remote_storage) = &state.remote_storage { mgr::attach_tenant( state.conf, tenant_id, + generation, tenant_conf, state.broker_client.clone(), remote_storage.clone(), @@ -538,7 +548,7 @@ async fn tenant_detach_handler( } async fn tenant_load_handler( - request: Request, + mut request: Request, _cancel: CancellationToken, ) -> Result, ApiError> { let tenant_id: TenantId = parse_request_param(&request, "tenant_id")?; @@ -546,10 +556,18 @@ async fn tenant_load_handler( let ctx = RequestContext::new(TaskKind::MgmtRequest, DownloadBehavior::Warn); + let maybe_body: Option = json_request_or_empty_body(&mut request).await?; + let state = get_state(&request); + + // The /load request is only usable when control_plane_api is not set. Once it is set, callers + // should always use /attach instead. + let generation = get_request_generation(state, maybe_body.as_ref().and_then(|r| r.generation))?; + mgr::load_tenant( state.conf, tenant_id, + generation, state.broker_client.clone(), state.remote_storage.clone(), &ctx, @@ -851,6 +869,21 @@ pub fn html_response(status: StatusCode, data: String) -> Result, Ok(response) } +/// Helper for requests that may take a generation, which is mandatory +/// when control_plane_api is set, but otherwise defaults to Generation::none() +fn get_request_generation(state: &State, req_gen: Option) -> Result { + if state.conf.control_plane_api.is_some() { + req_gen + .map(Generation::new) + .ok_or(ApiError::BadRequest(anyhow!( + "generation attribute missing" + ))) + } else { + // Legacy mode: all tenants operate with no generation + Ok(Generation::none()) + } +} + async fn tenant_create_handler( mut request: Request, _cancel: CancellationToken, @@ -867,14 +900,17 @@ async fn tenant_create_handler( let tenant_conf = TenantConfOpt::try_from(&request_data.config).map_err(ApiError::BadRequest)?; - let ctx = RequestContext::new(TaskKind::MgmtRequest, DownloadBehavior::Warn); - let state = get_state(&request); + let generation = get_request_generation(state, request_data.generation)?; + + let ctx = RequestContext::new(TaskKind::MgmtRequest, DownloadBehavior::Warn); + let new_tenant = mgr::create_tenant( state.conf, tenant_conf, target_tenant_id, + generation, state.broker_client.clone(), state.remote_storage.clone(), &ctx, @@ -1321,12 +1357,9 @@ where } pub fn make_router( - conf: &'static PageServerConf, + state: Arc, launch_ts: &'static LaunchTimestamp, auth: Option>, - broker_client: BrokerClientChannel, - remote_storage: Option, - disk_usage_eviction_state: Arc, ) -> anyhow::Result> { let spec = include_bytes!("openapi_spec.yml"); let mut router = attach_openapi_ui(endpoint::make_router(), spec, "/swagger.yml", "/v1/doc"); @@ -1350,16 +1383,7 @@ pub fn make_router( ); Ok(router - .data(Arc::new( - State::new( - conf, - auth, - remote_storage, - broker_client, - disk_usage_eviction_state, - ) - .context("Failed to initialize router state")?, - )) + .data(state) .get("/v1/status", |r| api_handler(r, status_handler)) .put("/v1/failpoints", |r| { testing_api_handler("manage failpoints", r, failpoints_handler) diff --git a/pageserver/src/lib.rs b/pageserver/src/lib.rs index cb20caba1f..3049ad6a4e 100644 --- a/pageserver/src/lib.rs +++ b/pageserver/src/lib.rs @@ -3,6 +3,7 @@ pub mod basebackup; pub mod config; pub mod consumption_metrics; pub mod context; +mod control_plane_client; pub mod disk_usage_eviction_task; pub mod http; pub mod import_datadir; diff --git a/pageserver/src/metrics.rs b/pageserver/src/metrics.rs index 1dc039056b..691ccafae1 100644 --- a/pageserver/src/metrics.rs +++ b/pageserver/src/metrics.rs @@ -537,7 +537,7 @@ const STORAGE_IO_TIME_BUCKETS: &[f64] = &[ 30.000, // 30000 ms ]; -/// Tracks time taken by fs operations near VirtualFile. +/// VirtualFile fs operation variants. /// /// Operations: /// - open ([`std::fs::OpenOptions::open`]) @@ -548,15 +548,66 @@ const STORAGE_IO_TIME_BUCKETS: &[f64] = &[ /// - seek (modify internal position or file length query) /// - fsync ([`std::fs::File::sync_all`]) /// - metadata ([`std::fs::File::metadata`]) -pub(crate) static STORAGE_IO_TIME: Lazy = Lazy::new(|| { - register_histogram_vec!( - "pageserver_io_operations_seconds", - "Time spent in IO operations", - &["operation"], - STORAGE_IO_TIME_BUCKETS.into() - ) - .expect("failed to define a metric") -}); +#[derive( + Debug, Clone, Copy, strum_macros::EnumCount, strum_macros::EnumIter, strum_macros::FromRepr, +)] +pub(crate) enum StorageIoOperation { + Open, + Close, + CloseByReplace, + Read, + Write, + Seek, + Fsync, + Metadata, +} + +impl StorageIoOperation { + pub fn as_str(&self) -> &'static str { + match self { + StorageIoOperation::Open => "open", + StorageIoOperation::Close => "close", + StorageIoOperation::CloseByReplace => "close-by-replace", + StorageIoOperation::Read => "read", + StorageIoOperation::Write => "write", + StorageIoOperation::Seek => "seek", + StorageIoOperation::Fsync => "fsync", + StorageIoOperation::Metadata => "metadata", + } + } +} + +/// Tracks time taken by fs operations near VirtualFile. +#[derive(Debug)] +pub(crate) struct StorageIoTime { + metrics: [Histogram; StorageIoOperation::COUNT], +} + +impl StorageIoTime { + fn new() -> Self { + let storage_io_histogram_vec = register_histogram_vec!( + "pageserver_io_operations_seconds", + "Time spent in IO operations", + &["operation"], + STORAGE_IO_TIME_BUCKETS.into() + ) + .expect("failed to define a metric"); + let metrics = std::array::from_fn(|i| { + let op = StorageIoOperation::from_repr(i).unwrap(); + let metric = storage_io_histogram_vec + .get_metric_with_label_values(&[op.as_str()]) + .unwrap(); + metric + }); + Self { metrics } + } + + pub(crate) fn get(&self, op: StorageIoOperation) -> &Histogram { + &self.metrics[op as usize] + } +} + +pub(crate) static STORAGE_IO_TIME_METRIC: Lazy = Lazy::new(StorageIoTime::new); const STORAGE_IO_SIZE_OPERATIONS: &[&str] = &["read", "write"]; @@ -1165,6 +1216,12 @@ impl TimelineMetrics { ), } } + + pub fn record_new_file_metrics(&self, sz: u64) { + self.resident_physical_size_gauge.add(sz); + self.num_persistent_files_created.inc_by(1); + self.persistent_bytes_written.inc_by(sz); + } } impl Drop for TimelineMetrics { diff --git a/pageserver/src/page_cache.rs b/pageserver/src/page_cache.rs index fb1c5fc485..9cc1bf35dc 100644 --- a/pageserver/src/page_cache.rs +++ b/pageserver/src/page_cache.rs @@ -799,8 +799,9 @@ impl PageCache { fn new(num_pages: usize) -> Self { assert!(num_pages > 0, "page cache size must be > 0"); - // We use Box::leak here and into_boxed_slice to avoid leaking uninitialized - // memory that Vec's might contain. + // We could use Vec::leak here, but that potentially also leaks + // uninitialized reserved capacity. With into_boxed_slice and Box::leak + // this is avoided. let page_buffer = Box::leak(vec![0u8; num_pages * PAGE_SZ].into_boxed_slice()); let size_metrics = &crate::metrics::PAGE_CACHE_SIZE; diff --git a/pageserver/src/tenant.rs b/pageserver/src/tenant.rs index 3256a00182..f0639844bd 100644 --- a/pageserver/src/tenant.rs +++ b/pageserver/src/tenant.rs @@ -141,6 +141,9 @@ pub use crate::tenant::metadata::save_metadata; // re-export for use in walreceiver pub use crate::tenant::timeline::WalReceiverInfo; +/// The "tenants" part of `tenants//timelines...` +pub const TENANTS_SEGMENT_NAME: &str = "tenants"; + /// Parts of the `.neon/tenants//timelines/` directory prefix. pub const TIMELINES_SEGMENT_NAME: &str = "timelines"; @@ -3378,7 +3381,7 @@ pub mod harness { pub tenant_conf: TenantConf, pub tenant_id: TenantId, pub generation: Generation, - remote_storage: GenericRemoteStorage, + pub remote_storage: GenericRemoteStorage, pub remote_fs_dir: PathBuf, } diff --git a/pageserver/src/tenant/blob_io.rs b/pageserver/src/tenant/blob_io.rs index e4dede2c30..4fad1f3c14 100644 --- a/pageserver/src/tenant/blob_io.rs +++ b/pageserver/src/tenant/blob_io.rs @@ -13,6 +13,7 @@ //! use crate::page_cache::PAGE_SZ; use crate::tenant::block_io::BlockCursor; +use crate::virtual_file::VirtualFile; use std::cmp::min; use std::io::{Error, ErrorKind}; @@ -83,29 +84,24 @@ impl<'a> BlockCursor<'a> { } } +/// A wrapper of `VirtualFile` that allows users to write blobs. /// -/// Abstract trait for a data sink that you can write blobs to. -/// -pub trait BlobWriter { - /// Write a blob of data. Returns the offset that it was written to, - /// which can be used to retrieve the data later. - fn write_blob(&mut self, srcbuf: &[u8]) -> Result; -} - -/// -/// An implementation of BlobWriter to write blobs to anything that -/// implements std::io::Write. -/// -pub struct WriteBlobWriter { - inner: W, +/// If a `BlobWriter` is dropped, the internal buffer will be +/// discarded. You need to call [`flush_buffer`](Self::flush_buffer) +/// manually before dropping. +pub struct BlobWriter { + inner: VirtualFile, offset: u64, + /// A buffer to save on write calls, only used if BUFFERED=true + buf: Vec, } -impl WriteBlobWriter { - pub fn new(inner: W, start_offset: u64) -> Self { - WriteBlobWriter { +impl BlobWriter { + pub fn new(inner: VirtualFile, start_offset: u64) -> Self { + Self { inner, offset: start_offset, + buf: Vec::with_capacity(Self::CAPACITY), } } @@ -113,28 +109,79 @@ impl WriteBlobWriter { self.offset } - /// Access the underlying Write object. - /// - /// NOTE: WriteBlobWriter keeps track of the current write offset. If - /// you write something directly to the inner Write object, it makes the - /// internally tracked 'offset' to go out of sync. So don't do that. - pub fn into_inner(self) -> W { - self.inner - } -} + const CAPACITY: usize = if BUFFERED { PAGE_SZ } else { 0 }; -impl BlobWriter for WriteBlobWriter -where - W: std::io::Write, -{ - fn write_blob(&mut self, srcbuf: &[u8]) -> Result { + #[inline(always)] + /// Writes the given buffer directly to the underlying `VirtualFile`. + /// You need to make sure that the internal buffer is empty, otherwise + /// data will be written in wrong order. + async fn write_all_unbuffered(&mut self, src_buf: &[u8]) -> Result<(), Error> { + self.inner.write_all(src_buf).await?; + self.offset += src_buf.len() as u64; + Ok(()) + } + + #[inline(always)] + /// Flushes the internal buffer to the underlying `VirtualFile`. + pub async fn flush_buffer(&mut self) -> Result<(), Error> { + self.inner.write_all(&self.buf).await?; + self.buf.clear(); + Ok(()) + } + + #[inline(always)] + /// Writes as much of `src_buf` into the internal buffer as it fits + fn write_into_buffer(&mut self, src_buf: &[u8]) -> usize { + let remaining = Self::CAPACITY - self.buf.len(); + let to_copy = src_buf.len().min(remaining); + self.buf.extend_from_slice(&src_buf[..to_copy]); + self.offset += to_copy as u64; + to_copy + } + + /// Internal, possibly buffered, write function + async fn write_all(&mut self, mut src_buf: &[u8]) -> Result<(), Error> { + if !BUFFERED { + assert!(self.buf.is_empty()); + self.write_all_unbuffered(src_buf).await?; + return Ok(()); + } + let remaining = Self::CAPACITY - self.buf.len(); + // First try to copy as much as we can into the buffer + if remaining > 0 { + let copied = self.write_into_buffer(src_buf); + src_buf = &src_buf[copied..]; + } + // Then, if the buffer is full, flush it out + if self.buf.len() == Self::CAPACITY { + self.flush_buffer().await?; + } + // Finally, write the tail of src_buf: + // If it wholly fits into the buffer without + // completely filling it, then put it there. + // If not, write it out directly. + if !src_buf.is_empty() { + assert_eq!(self.buf.len(), 0); + if src_buf.len() < Self::CAPACITY { + let copied = self.write_into_buffer(src_buf); + // We just verified above that src_buf fits into our internal buffer. + assert_eq!(copied, src_buf.len()); + } else { + self.write_all_unbuffered(src_buf).await?; + } + } + Ok(()) + } + + /// Write a blob of data. Returns the offset that it was written to, + /// which can be used to retrieve the data later. + pub async fn write_blob(&mut self, srcbuf: &[u8]) -> Result { let offset = self.offset; if srcbuf.len() < 128 { // Short blob. Write a 1-byte length header let len_buf = srcbuf.len() as u8; - self.inner.write_all(&[len_buf])?; - self.offset += 1; + self.write_all(&[len_buf]).await?; } else { // Write a 4-byte length header if srcbuf.len() > 0x7fff_ffff { @@ -145,11 +192,153 @@ where } let mut len_buf = ((srcbuf.len()) as u32).to_be_bytes(); len_buf[0] |= 0x80; - self.inner.write_all(&len_buf)?; - self.offset += 4; + self.write_all(&len_buf).await?; } - self.inner.write_all(srcbuf)?; - self.offset += srcbuf.len() as u64; + self.write_all(srcbuf).await?; Ok(offset) } } + +impl BlobWriter { + /// Access the underlying `VirtualFile`. + /// + /// This function flushes the internal buffer before giving access + /// to the underlying `VirtualFile`. + pub async fn into_inner(mut self) -> Result { + self.flush_buffer().await?; + Ok(self.inner) + } + + /// Access the underlying `VirtualFile`. + /// + /// Unlike [`into_inner`](Self::into_inner), this doesn't flush + /// the internal buffer before giving access. + pub fn into_inner_no_flush(self) -> VirtualFile { + self.inner + } +} + +impl BlobWriter { + /// Access the underlying `VirtualFile`. + pub fn into_inner(self) -> VirtualFile { + self.inner + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::tenant::block_io::BlockReaderRef; + use rand::{Rng, SeedableRng}; + + async fn round_trip_test(blobs: &[Vec]) -> Result<(), Error> { + let temp_dir = tempfile::tempdir()?; + let path = temp_dir.path().join("file"); + + // Write part (in block to drop the file) + let mut offsets = Vec::new(); + { + let file = VirtualFile::create(&path).await?; + let mut wtr = BlobWriter::::new(file, 0); + for blob in blobs.iter() { + let offs = wtr.write_blob(blob).await?; + offsets.push(offs); + } + // Write out one page worth of zeros so that we can + // read again with read_blk + let offs = wtr.write_blob(&vec![0; PAGE_SZ]).await?; + println!("Writing final blob at offs={offs}"); + wtr.flush_buffer().await?; + } + + let file = VirtualFile::open(&path).await?; + let rdr = BlockReaderRef::VirtualFile(&file); + let rdr = BlockCursor::new(rdr); + for (idx, (blob, offset)) in blobs.iter().zip(offsets.iter()).enumerate() { + let blob_read = rdr.read_blob(*offset).await?; + assert_eq!( + blob, &blob_read, + "mismatch for idx={idx} at offset={offset}" + ); + } + Ok(()) + } + + fn random_array(len: usize) -> Vec { + let mut rng = rand::thread_rng(); + (0..len).map(|_| rng.gen()).collect::<_>() + } + + #[tokio::test] + async fn test_one() -> Result<(), Error> { + let blobs = &[vec![12, 21, 22]]; + round_trip_test::(blobs).await?; + round_trip_test::(blobs).await?; + Ok(()) + } + + #[tokio::test] + async fn test_hello_simple() -> Result<(), Error> { + let blobs = &[ + vec![0, 1, 2, 3], + b"Hello, World!".to_vec(), + Vec::new(), + b"foobar".to_vec(), + ]; + round_trip_test::(blobs).await?; + round_trip_test::(blobs).await?; + Ok(()) + } + + #[tokio::test] + async fn test_really_big_array() -> Result<(), Error> { + let blobs = &[ + b"test".to_vec(), + random_array(10 * PAGE_SZ), + b"foobar".to_vec(), + ]; + round_trip_test::(blobs).await?; + round_trip_test::(blobs).await?; + Ok(()) + } + + #[tokio::test] + async fn test_arrays_inc() -> Result<(), Error> { + let blobs = (0..PAGE_SZ / 8) + .map(|v| random_array(v * 16)) + .collect::>(); + round_trip_test::(&blobs).await?; + round_trip_test::(&blobs).await?; + Ok(()) + } + + #[tokio::test] + async fn test_arrays_random_size() -> Result<(), Error> { + let mut rng = rand::rngs::StdRng::seed_from_u64(42); + let blobs = (0..1024) + .map(|_| { + let mut sz: u16 = rng.gen(); + // Make 50% of the arrays small + if rng.gen() { + sz |= 63; + } + random_array(sz.into()) + }) + .collect::>(); + round_trip_test::(&blobs).await?; + round_trip_test::(&blobs).await?; + Ok(()) + } + + #[tokio::test] + async fn test_arrays_page_boundary() -> Result<(), Error> { + let blobs = &[ + random_array(PAGE_SZ - 4), + random_array(PAGE_SZ - 4), + random_array(PAGE_SZ - 4), + ]; + round_trip_test::(blobs).await?; + round_trip_test::(blobs).await?; + Ok(()) + } +} diff --git a/pageserver/src/tenant/block_io.rs b/pageserver/src/tenant/block_io.rs index 645ec81036..ab5b5e9fca 100644 --- a/pageserver/src/tenant/block_io.rs +++ b/pageserver/src/tenant/block_io.rs @@ -71,11 +71,13 @@ impl<'a> Deref for BlockLease<'a> { /// /// Unlike traits, we also support the read function to be async though. pub(crate) enum BlockReaderRef<'a> { - FileBlockReaderVirtual(&'a FileBlockReader), + FileBlockReader(&'a FileBlockReader), EphemeralFile(&'a EphemeralFile), Adapter(Adapter<&'a DeltaLayerInner>), #[cfg(test)] TestDisk(&'a super::disk_btree::tests::TestDisk), + #[cfg(test)] + VirtualFile(&'a VirtualFile), } impl<'a> BlockReaderRef<'a> { @@ -83,11 +85,13 @@ impl<'a> BlockReaderRef<'a> { async fn read_blk(&self, blknum: u32) -> Result { use BlockReaderRef::*; match self { - FileBlockReaderVirtual(r) => r.read_blk(blknum).await, + FileBlockReader(r) => r.read_blk(blknum).await, EphemeralFile(r) => r.read_blk(blknum).await, Adapter(r) => r.read_blk(blknum).await, #[cfg(test)] TestDisk(r) => r.read_blk(blknum), + #[cfg(test)] + VirtualFile(r) => r.read_blk(blknum).await, } } } @@ -120,7 +124,7 @@ impl<'a> BlockCursor<'a> { // Needed by cli pub fn new_fileblockreader(reader: &'a FileBlockReader) -> Self { BlockCursor { - reader: BlockReaderRef::FileBlockReaderVirtual(reader), + reader: BlockReaderRef::FileBlockReader(reader), } } @@ -193,7 +197,7 @@ impl FileBlockReader { impl BlockReader for FileBlockReader { fn block_cursor(&self) -> BlockCursor<'_> { - BlockCursor::new(BlockReaderRef::FileBlockReaderVirtual(self)) + BlockCursor::new(BlockReaderRef::FileBlockReader(self)) } } diff --git a/pageserver/src/tenant/ephemeral_file.rs b/pageserver/src/tenant/ephemeral_file.rs index 4c5fe424f3..887834cd9b 100644 --- a/pageserver/src/tenant/ephemeral_file.rs +++ b/pageserver/src/tenant/ephemeral_file.rs @@ -28,7 +28,7 @@ pub struct EphemeralFile { } impl EphemeralFile { - pub fn create( + pub async fn create( conf: &PageServerConf, tenant_id: TenantId, timeline_id: TimelineId, @@ -44,7 +44,8 @@ impl EphemeralFile { let file = VirtualFile::open_with_options( &filename, OpenOptions::new().read(true).write(true).create(true), - )?; + ) + .await?; Ok(EphemeralFile { page_cache_file_id: page_cache::next_file_id(), @@ -286,7 +287,7 @@ mod tests { async fn test_ephemeral_blobs() -> Result<(), io::Error> { let (conf, tenant_id, timeline_id) = harness("ephemeral_blobs")?; - let mut file = EphemeralFile::create(conf, tenant_id, timeline_id)?; + let mut file = EphemeralFile::create(conf, tenant_id, timeline_id).await?; let pos_foo = file.write_blob(b"foo").await?; assert_eq!( diff --git a/pageserver/src/tenant/metadata.rs b/pageserver/src/tenant/metadata.rs index 7b05704e4f..75ffe09696 100644 --- a/pageserver/src/tenant/metadata.rs +++ b/pageserver/src/tenant/metadata.rs @@ -230,6 +230,23 @@ impl TimelineMetadata { pub fn pg_version(&self) -> u32 { self.body.pg_version } + + // Checksums make it awkward to build a valid instance by hand. This helper + // provides a TimelineMetadata with a valid checksum in its header. + #[cfg(test)] + pub fn example() -> Self { + let instance = Self::new( + "0/16960E8".parse::().unwrap(), + None, + None, + Lsn::from_hex("00000000").unwrap(), + Lsn::from_hex("00000000").unwrap(), + Lsn::from_hex("00000000").unwrap(), + 0, + ); + let bytes = instance.to_bytes().unwrap(); + Self::from_bytes(&bytes).unwrap() + } } impl<'de> Deserialize<'de> for TimelineMetadata { diff --git a/pageserver/src/tenant/mgr.rs b/pageserver/src/tenant/mgr.rs index 72d150e0eb..74faee1115 100644 --- a/pageserver/src/tenant/mgr.rs +++ b/pageserver/src/tenant/mgr.rs @@ -1,9 +1,10 @@ //! This module acts as a switchboard to access different repositories managed by this //! page server. +use rand::{distributions::Alphanumeric, Rng}; use std::collections::{hash_map, HashMap}; use std::ffi::OsStr; -use std::path::Path; +use std::path::{Path, PathBuf}; use std::sync::Arc; use tokio::fs; @@ -11,6 +12,7 @@ use anyhow::Context; use once_cell::sync::Lazy; use tokio::sync::RwLock; use tokio::task::JoinSet; +use tokio_util::sync::CancellationToken; use tracing::*; use remote_storage::GenericRemoteStorage; @@ -18,6 +20,7 @@ use utils::crashsafe; use crate::config::PageServerConf; use crate::context::{DownloadBehavior, RequestContext}; +use crate::control_plane_client::ControlPlaneClient; use crate::task_mgr::{self, TaskKind}; use crate::tenant::config::TenantConfOpt; use crate::tenant::delete::DeleteTenantFlow; @@ -68,6 +71,11 @@ impl TenantsMap { /// This is pageserver-specific, as it relies on future processes after a crash to check /// for TEMP_FILE_SUFFIX when loading things. async fn safe_remove_tenant_dir_all(path: impl AsRef) -> std::io::Result<()> { + let tmp_path = safe_rename_tenant_dir(path).await?; + fs::remove_dir_all(tmp_path).await +} + +async fn safe_rename_tenant_dir(path: impl AsRef) -> std::io::Result { let parent = path .as_ref() .parent() @@ -77,11 +85,16 @@ async fn safe_remove_tenant_dir_all(path: impl AsRef) -> std::io::Result<( std::io::ErrorKind::InvalidInput, "Path must be absolute", ))?; - - let tmp_path = path_with_suffix_extension(&path, TEMP_FILE_SUFFIX); + let rand_suffix = rand::thread_rng() + .sample_iter(&Alphanumeric) + .take(8) + .map(char::from) + .collect::() + + TEMP_FILE_SUFFIX; + let tmp_path = path_with_suffix_extension(&path, &rand_suffix); fs::rename(&path, &tmp_path).await?; fs::File::open(parent).await?.sync_all().await?; - fs::remove_dir_all(tmp_path).await + Ok(tmp_path) } static TENANTS: Lazy> = Lazy::new(|| RwLock::new(TenantsMap::Initializing)); @@ -94,12 +107,21 @@ pub async fn init_tenant_mgr( conf: &'static PageServerConf, resources: TenantSharedResources, init_order: InitializationOrder, + cancel: CancellationToken, ) -> anyhow::Result<()> { // Scan local filesystem for attached tenants let tenants_dir = conf.tenants_path(); let mut tenants = HashMap::new(); + // If we are configured to use the control plane API, then it is the source of truth for what tenants to load. + let tenant_generations = if let Some(client) = ControlPlaneClient::new(conf, &cancel) { + Some(client.re_attach().await?) + } else { + info!("Control plane API not configured, tenant generations are disabled"); + None + }; + let mut dir_entries = fs::read_dir(&tenants_dir) .await .with_context(|| format!("Failed to list tenants dir {tenants_dir:?}"))?; @@ -149,9 +171,53 @@ pub async fn init_tenant_mgr( continue; } + let tenant_id = match tenant_dir_path + .file_name() + .and_then(OsStr::to_str) + .unwrap_or_default() + .parse::() + { + Ok(id) => id, + Err(_) => { + warn!( + "Invalid tenant path (garbage in our repo directory?): {}", + tenant_dir_path.display() + ); + continue; + } + }; + + let generation = if let Some(generations) = &tenant_generations { + // We have a generation map: treat it as the authority for whether + // this tenant is really attached. + if let Some(gen) = generations.get(&tenant_id) { + *gen + } else { + info!("Detaching tenant {tenant_id}, control plane omitted it in re-attach response"); + if let Err(e) = safe_remove_tenant_dir_all(&tenant_dir_path).await { + error!( + "Failed to remove detached tenant directory '{}': {:?}", + tenant_dir_path.display(), + e + ); + } + continue; + } + } else { + // Legacy mode: no generation information, any tenant present + // on local disk may activate + info!( + "Starting tenant {} in legacy mode, no generation", + tenant_dir_path.display() + ); + Generation::none() + }; + match schedule_local_tenant_processing( conf, + tenant_id, &tenant_dir_path, + generation, resources.clone(), Some(init_order.clone()), &TENANTS, @@ -185,9 +251,12 @@ pub async fn init_tenant_mgr( Ok(()) } +#[allow(clippy::too_many_arguments)] pub(crate) fn schedule_local_tenant_processing( conf: &'static PageServerConf, + tenant_id: TenantId, tenant_path: &Path, + generation: Generation, resources: TenantSharedResources, init_order: Option, tenants: &'static tokio::sync::RwLock, @@ -208,15 +277,6 @@ pub(crate) fn schedule_local_tenant_processing( "Cannot load tenant from empty directory {tenant_path:?}" ); - let tenant_id = tenant_path - .file_name() - .and_then(OsStr::to_str) - .unwrap_or_default() - .parse::() - .with_context(|| { - format!("Could not parse tenant id out of the tenant dir name in path {tenant_path:?}") - })?; - let tenant_ignore_mark = conf.tenant_ignore_mark_file_path(&tenant_id); anyhow::ensure!( !conf.tenant_ignore_mark_file_path(&tenant_id).exists(), @@ -229,7 +289,7 @@ pub(crate) fn schedule_local_tenant_processing( match Tenant::spawn_attach( conf, tenant_id, - Generation::none(), + generation, resources.broker_client, tenants, remote_storage, @@ -253,13 +313,7 @@ pub(crate) fn schedule_local_tenant_processing( info!("tenant {tenant_id} is assumed to be loadable, starting load operation"); // Start loading the tenant into memory. It will initially be in Loading state. Tenant::spawn_load( - conf, - tenant_id, - Generation::none(), - resources, - init_order, - tenants, - ctx, + conf, tenant_id, generation, resources, init_order, tenants, ctx, ) }; Ok(tenant) @@ -383,6 +437,7 @@ pub async fn create_tenant( conf: &'static PageServerConf, tenant_conf: TenantConfOpt, tenant_id: TenantId, + generation: Generation, broker_client: storage_broker::BrokerClientChannel, remote_storage: Option, ctx: &RequestContext, @@ -400,7 +455,8 @@ pub async fn create_tenant( remote_storage, }; let created_tenant = - schedule_local_tenant_processing(conf, &tenant_directory, tenant_resources, None, &TENANTS, ctx)?; + schedule_local_tenant_processing(conf, tenant_id, &tenant_directory, + generation, tenant_resources, None, &TENANTS, ctx)?; // TODO: tenant object & its background loops remain, untracked in tenant map, if we fail here. // See https://github.com/neondatabase/neon/issues/4233 @@ -447,6 +503,8 @@ pub enum GetTenantError { /// Gets the tenant from the in-memory data, erroring if it's absent or is not fitting to the query. /// `active_only = true` allows to query only tenants that are ready for operations, erroring on other kinds of tenants. +/// +/// This method is cancel-safe. pub async fn get_tenant( tenant_id: TenantId, active_only: bool, @@ -506,7 +564,24 @@ pub async fn detach_tenant( tenant_id: TenantId, detach_ignored: bool, ) -> Result<(), TenantStateError> { - detach_tenant0(conf, &TENANTS, tenant_id, detach_ignored).await + let tmp_path = detach_tenant0(conf, &TENANTS, tenant_id, detach_ignored).await?; + // Although we are cleaning up the tenant, this task is not meant to be bound by the lifetime of the tenant in memory. + // After a tenant is detached, there are no more task_mgr tasks for that tenant_id. + let task_tenant_id = None; + task_mgr::spawn( + task_mgr::BACKGROUND_RUNTIME.handle(), + TaskKind::MgmtRequest, + task_tenant_id, + None, + "tenant_files_delete", + false, + async move { + fs::remove_dir_all(tmp_path.as_path()) + .await + .with_context(|| format!("tenant directory {:?} deletion", tmp_path)) + }, + ); + Ok(()) } async fn detach_tenant0( @@ -514,20 +589,16 @@ async fn detach_tenant0( tenants: &tokio::sync::RwLock, tenant_id: TenantId, detach_ignored: bool, -) -> Result<(), TenantStateError> { - let local_files_cleanup_operation = |tenant_id_to_clean| async move { +) -> Result { + let tenant_dir_rename_operation = |tenant_id_to_clean| async move { let local_tenant_directory = conf.tenant_path(&tenant_id_to_clean); - safe_remove_tenant_dir_all(&local_tenant_directory) + safe_rename_tenant_dir(&local_tenant_directory) .await - .with_context(|| { - format!("local tenant directory {local_tenant_directory:?} removal") - })?; - Ok(()) + .with_context(|| format!("local tenant directory {local_tenant_directory:?} rename")) }; let removal_result = - remove_tenant_from_memory(tenants, tenant_id, local_files_cleanup_operation(tenant_id)) - .await; + remove_tenant_from_memory(tenants, tenant_id, tenant_dir_rename_operation(tenant_id)).await; // Ignored tenants are not present in memory and will bail the removal from memory operation. // Before returning the error, check for ignored tenant removal case — we only need to clean its local files then. @@ -535,10 +606,10 @@ async fn detach_tenant0( let tenant_ignore_mark = conf.tenant_ignore_mark_file_path(&tenant_id); if tenant_ignore_mark.exists() { info!("Detaching an ignored tenant"); - local_files_cleanup_operation(tenant_id) + let tmp_path = tenant_dir_rename_operation(tenant_id) .await - .with_context(|| format!("Ignored tenant {tenant_id} local files cleanup"))?; - return Ok(()); + .with_context(|| format!("Ignored tenant {tenant_id} local directory rename"))?; + return Ok(tmp_path); } } @@ -548,6 +619,7 @@ async fn detach_tenant0( pub async fn load_tenant( conf: &'static PageServerConf, tenant_id: TenantId, + generation: Generation, broker_client: storage_broker::BrokerClientChannel, remote_storage: Option, ctx: &RequestContext, @@ -564,7 +636,7 @@ pub async fn load_tenant( broker_client, remote_storage, }; - let new_tenant = schedule_local_tenant_processing(conf, &tenant_path, resources, None, &TENANTS, ctx) + let new_tenant = schedule_local_tenant_processing(conf, tenant_id, &tenant_path, generation, resources, None, &TENANTS, ctx) .with_context(|| { format!("Failed to schedule tenant processing in path {tenant_path:?}") })?; @@ -628,6 +700,7 @@ pub async fn list_tenants() -> Result, TenantMapLis pub async fn attach_tenant( conf: &'static PageServerConf, tenant_id: TenantId, + generation: Generation, tenant_conf: TenantConfOpt, broker_client: storage_broker::BrokerClientChannel, remote_storage: GenericRemoteStorage, @@ -649,7 +722,7 @@ pub async fn attach_tenant( broker_client, remote_storage: Some(remote_storage), }; - let attached_tenant = schedule_local_tenant_processing(conf, &tenant_dir, resources, None, &TENANTS, ctx)?; + let attached_tenant = schedule_local_tenant_processing(conf, tenant_id, &tenant_dir, generation, resources, None, &TENANTS, ctx)?; // TODO: tenant object & its background loops remain, untracked in tenant map, if we fail here. // See https://github.com/neondatabase/neon/issues/4233 diff --git a/pageserver/src/tenant/par_fsync.rs b/pageserver/src/tenant/par_fsync.rs index 3cbcfe8774..705b42aff7 100644 --- a/pageserver/src/tenant/par_fsync.rs +++ b/pageserver/src/tenant/par_fsync.rs @@ -4,10 +4,9 @@ use std::{ sync::atomic::{AtomicUsize, Ordering}, }; -use crate::virtual_file::VirtualFile; - fn fsync_path(path: &Path) -> io::Result<()> { - let file = VirtualFile::open(path)?; + // TODO use VirtualFile::fsync_all once we fully go async. + let file = std::fs::File::open(path)?; file.sync_all() } diff --git a/pageserver/src/tenant/remote_timeline_client.rs b/pageserver/src/tenant/remote_timeline_client.rs index 13f3fac41c..6f42b54ac2 100644 --- a/pageserver/src/tenant/remote_timeline_client.rs +++ b/pageserver/src/tenant/remote_timeline_client.rs @@ -1430,6 +1430,30 @@ pub fn remote_index_path( .expect("Failed to construct path") } +/// Given the key of an index, parse out the generation part of the name +pub(crate) fn parse_remote_index_path(path: RemotePath) -> Option { + let file_name = match path.get_path().file_name() { + Some(f) => f, + None => { + // Unexpected: we should be seeing index_part.json paths only + tracing::warn!("Malformed index key {}", path); + return None; + } + }; + + let file_name_str = match file_name.to_str() { + Some(s) => s, + None => { + tracing::warn!("Malformed index key {:?}", path); + return None; + } + }; + match file_name_str.split_once('-') { + Some((_, gen_suffix)) => Generation::parse_suffix(gen_suffix), + None => None, + } +} + /// Files on the remote storage are stored with paths, relative to the workdir. /// That path includes in itself both tenant and timeline ids, allowing to have a unique remote storage path. /// @@ -1546,6 +1570,33 @@ mod tests { tenant_ctx: ctx, }) } + + /// Construct a RemoteTimelineClient in an arbitrary generation + fn build_client(&self, generation: Generation) -> Arc { + Arc::new(RemoteTimelineClient { + conf: self.harness.conf, + runtime: tokio::runtime::Handle::current(), + tenant_id: self.harness.tenant_id, + timeline_id: TIMELINE_ID, + generation, + storage_impl: self.harness.remote_storage.clone(), + upload_queue: Mutex::new(UploadQueue::Uninitialized), + metrics: Arc::new(RemoteTimelineClientMetrics::new( + &self.harness.tenant_id, + &TIMELINE_ID, + )), + }) + } + + /// A tracing::Span that satisfies remote_timeline_client methods that assert tenant_id + /// and timeline_id are present. + fn span(&self) -> tracing::Span { + tracing::info_span!( + "test", + tenant_id = %self.harness.tenant_id, + timeline_id = %TIMELINE_ID + ) + } } // Test scheduling @@ -1565,12 +1616,16 @@ mod tests { // Schedule another deletion. Check that it's launched immediately. // Schedule index upload. Check that it's queued + let test_setup = TestSetup::new("upload_scheduling").await.unwrap(); + let span = test_setup.span(); + let _guard = span.enter(); + let TestSetup { harness, tenant: _tenant, timeline, tenant_ctx: _tenant_ctx, - } = TestSetup::new("upload_scheduling").await.unwrap(); + } = test_setup; let client = timeline.remote_client.as_ref().unwrap(); @@ -1818,4 +1873,106 @@ mod tests { }; assert_eq!(actual_c, expected_c); } + + async fn inject_index_part(test_state: &TestSetup, generation: Generation) -> IndexPart { + // An empty IndexPart, just sufficient to ensure deserialization will succeed + let example_metadata = TimelineMetadata::example(); + let example_index_part = IndexPart::new( + HashMap::new(), + example_metadata.disk_consistent_lsn(), + example_metadata, + ); + + let index_part_bytes = serde_json::to_vec(&example_index_part).unwrap(); + + let timeline_path = test_state.harness.timeline_path(&TIMELINE_ID); + let remote_timeline_dir = test_state.harness.remote_fs_dir.join( + timeline_path + .strip_prefix(&test_state.harness.conf.workdir) + .unwrap(), + ); + + std::fs::create_dir_all(remote_timeline_dir).expect("creating test dir should work"); + + let index_path = test_state.harness.remote_fs_dir.join( + remote_index_path(&test_state.harness.tenant_id, &TIMELINE_ID, generation).get_path(), + ); + eprintln!("Writing {}", index_path.display()); + std::fs::write(&index_path, index_part_bytes).unwrap(); + example_index_part + } + + /// Assert that when a RemoteTimelineclient in generation `get_generation` fetches its + /// index, the IndexPart returned is equal to `expected` + async fn assert_got_index_part( + test_state: &TestSetup, + get_generation: Generation, + expected: &IndexPart, + ) { + let client = test_state.build_client(get_generation); + + let download_r = client + .download_index_file() + .await + .expect("download should always succeed"); + assert!(matches!(download_r, MaybeDeletedIndexPart::IndexPart(_))); + match download_r { + MaybeDeletedIndexPart::IndexPart(index_part) => { + assert_eq!(&index_part, expected); + } + MaybeDeletedIndexPart::Deleted(_index_part) => panic!("Test doesn't set deleted_at"), + } + } + + #[tokio::test] + async fn index_part_download_simple() -> anyhow::Result<()> { + let test_state = TestSetup::new("index_part_download_simple").await.unwrap(); + let span = test_state.span(); + let _guard = span.enter(); + + // Simple case: we are in generation N, load the index from generation N - 1 + let generation_n = 5; + let injected = inject_index_part(&test_state, Generation::new(generation_n - 1)).await; + + assert_got_index_part(&test_state, Generation::new(generation_n), &injected).await; + + Ok(()) + } + + #[tokio::test] + async fn index_part_download_ordering() -> anyhow::Result<()> { + let test_state = TestSetup::new("index_part_download_ordering") + .await + .unwrap(); + + let span = test_state.span(); + let _guard = span.enter(); + + // A generation-less IndexPart exists in the bucket, we should find it + let generation_n = 5; + let injected_none = inject_index_part(&test_state, Generation::none()).await; + assert_got_index_part(&test_state, Generation::new(generation_n), &injected_none).await; + + // If a more recent-than-none generation exists, we should prefer to load that + let injected_1 = inject_index_part(&test_state, Generation::new(1)).await; + assert_got_index_part(&test_state, Generation::new(generation_n), &injected_1).await; + + // If a more-recent-than-me generation exists, we should ignore it. + let _injected_10 = inject_index_part(&test_state, Generation::new(10)).await; + assert_got_index_part(&test_state, Generation::new(generation_n), &injected_1).await; + + // If a directly previous generation exists, _and_ an index exists in my own + // generation, I should prefer my own generation. + let _injected_prev = + inject_index_part(&test_state, Generation::new(generation_n - 1)).await; + let injected_current = inject_index_part(&test_state, Generation::new(generation_n)).await; + assert_got_index_part( + &test_state, + Generation::new(generation_n), + &injected_current, + ) + .await; + + Ok(()) + } } diff --git a/pageserver/src/tenant/remote_timeline_client/download.rs b/pageserver/src/tenant/remote_timeline_client/download.rs index dc8d87b9e1..9863215529 100644 --- a/pageserver/src/tenant/remote_timeline_client/download.rs +++ b/pageserver/src/tenant/remote_timeline_client/download.rs @@ -24,7 +24,10 @@ use utils::crashsafe::path_with_suffix_extension; use utils::id::{TenantId, TimelineId}; use super::index::{IndexPart, LayerFileMetadata}; -use super::{remote_index_path, FAILED_DOWNLOAD_WARN_THRESHOLD, FAILED_REMOTE_OP_RETRIES}; +use super::{ + parse_remote_index_path, remote_index_path, FAILED_DOWNLOAD_WARN_THRESHOLD, + FAILED_REMOTE_OP_RETRIES, +}; static MAX_DOWNLOAD_DURATION: Duration = Duration::from_secs(120); @@ -219,13 +222,13 @@ pub async fn list_remote_timelines( Ok(timeline_ids) } -pub(super) async fn download_index_part( +async fn do_download_index_part( storage: &GenericRemoteStorage, tenant_id: &TenantId, timeline_id: &TimelineId, - generation: Generation, + index_generation: Generation, ) -> Result { - let remote_path = remote_index_path(tenant_id, timeline_id, generation); + let remote_path = remote_index_path(tenant_id, timeline_id, index_generation); let index_part_bytes = download_retry( || async { @@ -252,6 +255,105 @@ pub(super) async fn download_index_part( Ok(index_part) } +/// index_part.json objects are suffixed with a generation number, so we cannot +/// directly GET the latest index part without doing some probing. +/// +/// In this function we probe for the most recent index in a generation <= our current generation. +/// See "Finding the remote indices for timelines" in docs/rfcs/025-generation-numbers.md +#[tracing::instrument(skip_all, fields(generation=?my_generation))] +pub(super) async fn download_index_part( + storage: &GenericRemoteStorage, + tenant_id: &TenantId, + timeline_id: &TimelineId, + my_generation: Generation, +) -> Result { + debug_assert_current_span_has_tenant_and_timeline_id(); + + if my_generation.is_none() { + // Operating without generations: just fetch the generation-less path + return do_download_index_part(storage, tenant_id, timeline_id, my_generation).await; + } + + // Stale case: If we were intentionally attached in a stale generation, there may already be a remote + // index in our generation. + // + // This is an optimization to avoid doing the listing for the general case below. + let res = do_download_index_part(storage, tenant_id, timeline_id, my_generation).await; + match res { + Ok(index_part) => { + tracing::debug!( + "Found index_part from current generation (this is a stale attachment)" + ); + return Ok(index_part); + } + Err(DownloadError::NotFound) => {} + Err(e) => return Err(e), + }; + + // Typical case: the previous generation of this tenant was running healthily, and had uploaded + // and index part. We may safely start from this index without doing a listing, because: + // - We checked for current generation case above + // - generations > my_generation are to be ignored + // - any other indices that exist would have an older generation than `previous_gen`, and + // we want to find the most recent index from a previous generation. + // + // This is an optimization to avoid doing the listing for the general case below. + let res = + do_download_index_part(storage, tenant_id, timeline_id, my_generation.previous()).await; + match res { + Ok(index_part) => { + tracing::debug!("Found index_part from previous generation"); + return Ok(index_part); + } + Err(DownloadError::NotFound) => { + tracing::debug!( + "No index_part found from previous generation, falling back to listing" + ); + } + Err(e) => { + return Err(e); + } + } + + // General case/fallback: if there is no index at my_generation or prev_generation, then list all index_part.json + // objects, and select the highest one with a generation <= my_generation. + let index_prefix = remote_index_path(tenant_id, timeline_id, Generation::none()); + let indices = backoff::retry( + || async { storage.list_files(Some(&index_prefix)).await }, + |_| false, + FAILED_DOWNLOAD_WARN_THRESHOLD, + FAILED_REMOTE_OP_RETRIES, + "listing index_part files", + // TODO: use a cancellation token (https://github.com/neondatabase/neon/issues/5066) + backoff::Cancel::new(CancellationToken::new(), || -> anyhow::Error { + unreachable!() + }), + ) + .await + .map_err(DownloadError::Other)?; + + // General case logic for which index to use: the latest index whose generation + // is <= our own. See "Finding the remote indices for timelines" in docs/rfcs/025-generation-numbers.md + let max_previous_generation = indices + .into_iter() + .filter_map(parse_remote_index_path) + .filter(|g| g <= &my_generation) + .max(); + + match max_previous_generation { + Some(g) => { + tracing::debug!("Found index_part in generation {g:?}"); + do_download_index_part(storage, tenant_id, timeline_id, g).await + } + None => { + // Migration from legacy pre-generation state: we have a generation but no prior + // attached pageservers did. Try to load from a no-generation path. + tracing::info!("No index_part.json* found"); + do_download_index_part(storage, tenant_id, timeline_id, Generation::none()).await + } + } +} + /// Helper function to handle retries for a download operation. /// /// Remote operations can fail due to rate limits (IAM, S3), spurious network diff --git a/pageserver/src/tenant/storage_layer/delta_layer.rs b/pageserver/src/tenant/storage_layer/delta_layer.rs index 60427a22e4..6925cb59cd 100644 --- a/pageserver/src/tenant/storage_layer/delta_layer.rs +++ b/pageserver/src/tenant/storage_layer/delta_layer.rs @@ -31,7 +31,7 @@ use crate::config::PageServerConf; use crate::context::RequestContext; use crate::page_cache::PAGE_SZ; use crate::repository::{Key, Value, KEY_SIZE}; -use crate::tenant::blob_io::{BlobWriter, WriteBlobWriter}; +use crate::tenant::blob_io::BlobWriter; use crate::tenant::block_io::{BlockBuf, BlockCursor, BlockLease, BlockReader, FileBlockReader}; use crate::tenant::disk_btree::{DiskBtreeBuilder, DiskBtreeReader, VisitDirection}; use crate::tenant::storage_layer::{ @@ -46,7 +46,6 @@ use rand::{distributions::Alphanumeric, Rng}; use serde::{Deserialize, Serialize}; use std::fs::{self, File}; use std::io::SeekFrom; -use std::io::{BufWriter, Write}; use std::ops::Range; use std::os::unix::fs::FileExt; use std::path::{Path, PathBuf}; @@ -583,14 +582,14 @@ struct DeltaLayerWriterInner { tree: DiskBtreeBuilder, - blob_writer: WriteBlobWriter>, + blob_writer: BlobWriter, } impl DeltaLayerWriterInner { /// /// Start building a new delta layer. /// - fn new( + async fn new( conf: &'static PageServerConf, timeline_id: TimelineId, tenant_id: TenantId, @@ -605,11 +604,10 @@ impl DeltaLayerWriterInner { // FIXME: throw an error instead? let path = DeltaLayer::temp_path_for(conf, &tenant_id, &timeline_id, key_start, &lsn_range); - let mut file = VirtualFile::create(&path)?; + let mut file = VirtualFile::create(&path).await?; // make room for the header block - file.seek(SeekFrom::Start(PAGE_SZ as u64))?; - let buf_writer = BufWriter::new(file); - let blob_writer = WriteBlobWriter::new(buf_writer, PAGE_SZ as u64); + file.seek(SeekFrom::Start(PAGE_SZ as u64)).await?; + let blob_writer = BlobWriter::new(file, PAGE_SZ as u64); // Initialize the b-tree index builder let block_buf = BlockBuf::new(); @@ -632,11 +630,12 @@ impl DeltaLayerWriterInner { /// /// The values must be appended in key, lsn order. /// - fn put_value(&mut self, key: Key, lsn: Lsn, val: Value) -> anyhow::Result<()> { + async fn put_value(&mut self, key: Key, lsn: Lsn, val: Value) -> anyhow::Result<()> { self.put_value_bytes(key, lsn, &Value::ser(&val)?, val.will_init()) + .await } - fn put_value_bytes( + async fn put_value_bytes( &mut self, key: Key, lsn: Lsn, @@ -645,7 +644,7 @@ impl DeltaLayerWriterInner { ) -> anyhow::Result<()> { assert!(self.lsn_range.start <= lsn); - let off = self.blob_writer.write_blob(val)?; + let off = self.blob_writer.write_blob(val).await?; let blob_ref = BlobRef::new(off, will_init); @@ -662,18 +661,18 @@ impl DeltaLayerWriterInner { /// /// Finish writing the delta layer. /// - fn finish(self, key_end: Key) -> anyhow::Result { + async fn finish(self, key_end: Key) -> anyhow::Result { let index_start_blk = ((self.blob_writer.size() + PAGE_SZ as u64 - 1) / PAGE_SZ as u64) as u32; - let buf_writer = self.blob_writer.into_inner(); - let mut file = buf_writer.into_inner()?; + let mut file = self.blob_writer.into_inner().await?; // Write out the index let (index_root_blk, block_buf) = self.tree.finish()?; - file.seek(SeekFrom::Start(index_start_blk as u64 * PAGE_SZ as u64))?; + file.seek(SeekFrom::Start(index_start_blk as u64 * PAGE_SZ as u64)) + .await?; for buf in block_buf.blocks { - file.write_all(buf.as_ref())?; + file.write_all(buf.as_ref()).await?; } assert!(self.lsn_range.start < self.lsn_range.end); // Fill in the summary on blk 0 @@ -687,11 +686,22 @@ impl DeltaLayerWriterInner { index_start_blk, index_root_blk, }; - file.seek(SeekFrom::Start(0))?; - Summary::ser_into(&summary, &mut file)?; + + let mut buf = smallvec::SmallVec::<[u8; PAGE_SZ]>::new(); + Summary::ser_into(&summary, &mut buf)?; + if buf.spilled() { + // This is bad as we only have one free block for the summary + warn!( + "Used more than one page size for summary buffer: {}", + buf.len() + ); + } + file.seek(SeekFrom::Start(0)).await?; + file.write_all(&buf).await?; let metadata = file .metadata() + .await .context("get file metadata to determine size")?; // 5GB limit for objects without multipart upload (which we don't want to use) @@ -722,7 +732,7 @@ impl DeltaLayerWriterInner { }; // fsync the file - file.sync_all()?; + file.sync_all().await?; // Rename the file to its final name // // Note: This overwrites any existing file. There shouldn't be any. @@ -774,7 +784,7 @@ impl DeltaLayerWriter { /// /// Start building a new delta layer. /// - pub fn new( + pub async fn new( conf: &'static PageServerConf, timeline_id: TimelineId, tenant_id: TenantId, @@ -782,13 +792,10 @@ impl DeltaLayerWriter { lsn_range: Range, ) -> anyhow::Result { Ok(Self { - inner: Some(DeltaLayerWriterInner::new( - conf, - timeline_id, - tenant_id, - key_start, - lsn_range, - )?), + inner: Some( + DeltaLayerWriterInner::new(conf, timeline_id, tenant_id, key_start, lsn_range) + .await?, + ), }) } @@ -797,11 +804,11 @@ impl DeltaLayerWriter { /// /// The values must be appended in key, lsn order. /// - pub fn put_value(&mut self, key: Key, lsn: Lsn, val: Value) -> anyhow::Result<()> { - self.inner.as_mut().unwrap().put_value(key, lsn, val) + pub async fn put_value(&mut self, key: Key, lsn: Lsn, val: Value) -> anyhow::Result<()> { + self.inner.as_mut().unwrap().put_value(key, lsn, val).await } - pub fn put_value_bytes( + pub async fn put_value_bytes( &mut self, key: Key, lsn: Lsn, @@ -812,6 +819,7 @@ impl DeltaLayerWriter { .as_mut() .unwrap() .put_value_bytes(key, lsn, val, will_init) + .await } pub fn size(&self) -> u64 { @@ -821,21 +829,18 @@ impl DeltaLayerWriter { /// /// Finish writing the delta layer. /// - pub fn finish(mut self, key_end: Key) -> anyhow::Result { - self.inner.take().unwrap().finish(key_end) + pub async fn finish(mut self, key_end: Key) -> anyhow::Result { + self.inner.take().unwrap().finish(key_end).await } } impl Drop for DeltaLayerWriter { fn drop(&mut self) { if let Some(inner) = self.inner.take() { - match inner.blob_writer.into_inner().into_inner() { - Ok(vfile) => vfile.remove(), - Err(err) => warn!( - "error while flushing buffer of image layer temporary file: {}", - err - ), - } + // We want to remove the virtual file here, so it's fine to not + // having completely flushed unwritten data. + let vfile = inner.blob_writer.into_inner_no_flush(); + vfile.remove(); } } } @@ -846,6 +851,7 @@ impl DeltaLayerInner { summary: Option, ) -> anyhow::Result { let file = VirtualFile::open(path) + .await .with_context(|| format!("Failed to open file '{}'", path.display()))?; let file = FileBlockReader::new(file); diff --git a/pageserver/src/tenant/storage_layer/image_layer.rs b/pageserver/src/tenant/storage_layer/image_layer.rs index f329041fb1..2a6cabcc97 100644 --- a/pageserver/src/tenant/storage_layer/image_layer.rs +++ b/pageserver/src/tenant/storage_layer/image_layer.rs @@ -27,7 +27,7 @@ use crate::config::PageServerConf; use crate::context::RequestContext; use crate::page_cache::PAGE_SZ; use crate::repository::{Key, KEY_SIZE}; -use crate::tenant::blob_io::{BlobWriter, WriteBlobWriter}; +use crate::tenant::blob_io::BlobWriter; use crate::tenant::block_io::{BlockBuf, BlockReader, FileBlockReader}; use crate::tenant::disk_btree::{DiskBtreeBuilder, DiskBtreeReader, VisitDirection}; use crate::tenant::storage_layer::{ @@ -43,7 +43,6 @@ use rand::{distributions::Alphanumeric, Rng}; use serde::{Deserialize, Serialize}; use std::fs::{self, File}; use std::io::SeekFrom; -use std::io::Write; use std::ops::Range; use std::os::unix::prelude::FileExt; use std::path::{Path, PathBuf}; @@ -439,6 +438,7 @@ impl ImageLayerInner { summary: Option, ) -> anyhow::Result { let file = VirtualFile::open(path) + .await .with_context(|| format!("Failed to open file '{}'", path.display()))?; let file = FileBlockReader::new(file); let summary_blk = file.read_blk(0).await?; @@ -511,7 +511,7 @@ struct ImageLayerWriterInner { key_range: Range, lsn: Lsn, - blob_writer: WriteBlobWriter, + blob_writer: BlobWriter, tree: DiskBtreeBuilder, } @@ -519,7 +519,7 @@ impl ImageLayerWriterInner { /// /// Start building a new image layer. /// - fn new( + async fn new( conf: &'static PageServerConf, timeline_id: TimelineId, tenant_id: TenantId, @@ -541,10 +541,11 @@ impl ImageLayerWriterInner { let mut file = VirtualFile::open_with_options( &path, std::fs::OpenOptions::new().write(true).create_new(true), - )?; + ) + .await?; // make room for the header block - file.seek(SeekFrom::Start(PAGE_SZ as u64))?; - let blob_writer = WriteBlobWriter::new(file, PAGE_SZ as u64); + file.seek(SeekFrom::Start(PAGE_SZ as u64)).await?; + let blob_writer = BlobWriter::new(file, PAGE_SZ as u64); // Initialize the b-tree index builder let block_buf = BlockBuf::new(); @@ -569,9 +570,9 @@ impl ImageLayerWriterInner { /// /// The page versions must be appended in blknum order. /// - fn put_image(&mut self, key: Key, img: &[u8]) -> anyhow::Result<()> { + async fn put_image(&mut self, key: Key, img: &[u8]) -> anyhow::Result<()> { ensure!(self.key_range.contains(&key)); - let off = self.blob_writer.write_blob(img)?; + let off = self.blob_writer.write_blob(img).await?; let mut keybuf: [u8; KEY_SIZE] = [0u8; KEY_SIZE]; key.write_to_byte_slice(&mut keybuf); @@ -583,17 +584,18 @@ impl ImageLayerWriterInner { /// /// Finish writing the image layer. /// - fn finish(self) -> anyhow::Result { + async fn finish(self) -> anyhow::Result { let index_start_blk = ((self.blob_writer.size() + PAGE_SZ as u64 - 1) / PAGE_SZ as u64) as u32; let mut file = self.blob_writer.into_inner(); // Write out the index - file.seek(SeekFrom::Start(index_start_blk as u64 * PAGE_SZ as u64))?; + file.seek(SeekFrom::Start(index_start_blk as u64 * PAGE_SZ as u64)) + .await?; let (index_root_blk, block_buf) = self.tree.finish()?; for buf in block_buf.blocks { - file.write_all(buf.as_ref())?; + file.write_all(buf.as_ref()).await?; } // Fill in the summary on blk 0 @@ -607,11 +609,22 @@ impl ImageLayerWriterInner { index_start_blk, index_root_blk, }; - file.seek(SeekFrom::Start(0))?; - Summary::ser_into(&summary, &mut file)?; + + let mut buf = smallvec::SmallVec::<[u8; PAGE_SZ]>::new(); + Summary::ser_into(&summary, &mut buf)?; + if buf.spilled() { + // This is bad as we only have one free block for the summary + warn!( + "Used more than one page size for summary buffer: {}", + buf.len() + ); + } + file.seek(SeekFrom::Start(0)).await?; + file.write_all(&buf).await?; let metadata = file .metadata() + .await .context("get metadata to determine file size")?; let desc = PersistentLayerDesc::new_img( @@ -634,7 +647,7 @@ impl ImageLayerWriterInner { }; // fsync the file - file.sync_all()?; + file.sync_all().await?; // Rename the file to its final name // @@ -687,7 +700,7 @@ impl ImageLayerWriter { /// /// Start building a new image layer. /// - pub fn new( + pub async fn new( conf: &'static PageServerConf, timeline_id: TimelineId, tenant_id: TenantId, @@ -695,13 +708,9 @@ impl ImageLayerWriter { lsn: Lsn, ) -> anyhow::Result { Ok(Self { - inner: Some(ImageLayerWriterInner::new( - conf, - timeline_id, - tenant_id, - key_range, - lsn, - )?), + inner: Some( + ImageLayerWriterInner::new(conf, timeline_id, tenant_id, key_range, lsn).await?, + ), }) } @@ -710,15 +719,15 @@ impl ImageLayerWriter { /// /// The page versions must be appended in blknum order. /// - pub fn put_image(&mut self, key: Key, img: &[u8]) -> anyhow::Result<()> { - self.inner.as_mut().unwrap().put_image(key, img) + pub async fn put_image(&mut self, key: Key, img: &[u8]) -> anyhow::Result<()> { + self.inner.as_mut().unwrap().put_image(key, img).await } /// /// Finish writing the image layer. /// - pub fn finish(mut self) -> anyhow::Result { - self.inner.take().unwrap().finish() + pub async fn finish(mut self) -> anyhow::Result { + self.inner.take().unwrap().finish().await } } diff --git a/pageserver/src/tenant/storage_layer/inmemory_layer.rs b/pageserver/src/tenant/storage_layer/inmemory_layer.rs index 35a77a7331..3ff1c6bb18 100644 --- a/pageserver/src/tenant/storage_layer/inmemory_layer.rs +++ b/pageserver/src/tenant/storage_layer/inmemory_layer.rs @@ -236,7 +236,7 @@ impl InMemoryLayer { /// /// Create a new, empty, in-memory layer /// - pub fn create( + pub async fn create( conf: &'static PageServerConf, timeline_id: TimelineId, tenant_id: TenantId, @@ -244,7 +244,7 @@ impl InMemoryLayer { ) -> Result { trace!("initializing new empty InMemoryLayer for writing on timeline {timeline_id} at {start_lsn}"); - let file = EphemeralFile::create(conf, tenant_id, timeline_id)?; + let file = EphemeralFile::create(conf, tenant_id, timeline_id).await?; Ok(InMemoryLayer { conf, @@ -333,7 +333,8 @@ impl InMemoryLayer { self.tenant_id, Key::MIN, self.start_lsn..end_lsn, - )?; + ) + .await?; let mut buf = Vec::new(); @@ -348,11 +349,13 @@ impl InMemoryLayer { for (lsn, pos) in vec_map.as_slice() { cursor.read_blob_into_buf(*pos, &mut buf).await?; let will_init = Value::des(&buf)?.will_init(); - delta_layer_writer.put_value_bytes(key, *lsn, &buf, will_init)?; + delta_layer_writer + .put_value_bytes(key, *lsn, &buf, will_init) + .await?; } } - let delta_layer = delta_layer_writer.finish(Key::MAX)?; + let delta_layer = delta_layer_writer.finish(Key::MAX).await?; Ok(delta_layer) } } diff --git a/pageserver/src/tenant/storage_layer/remote_layer.rs b/pageserver/src/tenant/storage_layer/remote_layer.rs index 3f8d700863..3968c16c31 100644 --- a/pageserver/src/tenant/storage_layer/remote_layer.rs +++ b/pageserver/src/tenant/storage_layer/remote_layer.rs @@ -74,7 +74,7 @@ impl Layer for RemoteLayer { _reconstruct_state: &mut ValueReconstructState, _ctx: &RequestContext, ) -> Result { - bail!("layer {self} needs to be downloaded"); + Err(anyhow::anyhow!("layer {self} needs to be downloaded")) } } diff --git a/pageserver/src/tenant/tasks.rs b/pageserver/src/tenant/tasks.rs index c067a84471..df3ffd08d3 100644 --- a/pageserver/src/tenant/tasks.rs +++ b/pageserver/src/tenant/tasks.rs @@ -102,6 +102,7 @@ async fn compaction_loop(tenant: Arc, cancel: CancellationToken) { let started_at = Instant::now(); let sleep_duration = if period == Duration::ZERO { + #[cfg(not(feature = "testing"))] info!("automatic compaction is disabled"); // check again in 10 seconds, in case it's been enabled again. Duration::from_secs(10) @@ -166,6 +167,7 @@ async fn gc_loop(tenant: Arc, cancel: CancellationToken) { let gc_horizon = tenant.get_gc_horizon(); let sleep_duration = if period == Duration::ZERO || gc_horizon == 0 { + #[cfg(not(feature = "testing"))] info!("automatic GC is disabled"); // check again in 10 seconds, in case it's been enabled again. Duration::from_secs(10) diff --git a/pageserver/src/tenant/timeline.rs b/pageserver/src/tenant/timeline.rs index 816af214a5..fa3b487589 100644 --- a/pageserver/src/tenant/timeline.rs +++ b/pageserver/src/tenant/timeline.rs @@ -585,15 +585,7 @@ impl Timeline { Err(e) => { // don't count the time spent waiting for lock below, and also in walreceiver.status(), towards the wait_lsn_time_histo drop(_timer); - let walreceiver_status = { - match &*self.walreceiver.lock().unwrap() { - None => "stopping or stopped".to_string(), - Some(walreceiver) => match walreceiver.status() { - Some(status) => status.to_human_readable_string(), - None => "Not active".to_string(), - }, - } - }; + let walreceiver_status = self.walreceiver_status(); Err(anyhow::Error::new(e).context({ format!( "Timed out while waiting for WAL record at LSN {} to arrive, last_record_lsn {} disk consistent LSN={}, WalReceiver status: {}", @@ -607,6 +599,16 @@ impl Timeline { } } + pub(crate) fn walreceiver_status(&self) -> String { + match &*self.walreceiver.lock().unwrap() { + None => "stopping or stopped".to_string(), + Some(walreceiver) => match walreceiver.status() { + Some(status) => status.to_human_readable_string(), + None => "Not active".to_string(), + }, + } + } + /// Check that it is valid to request operations with that lsn. pub fn check_lsn_is_in_scope( &self, @@ -1724,11 +1726,18 @@ impl Timeline { for (name, decision) in decided { let decision = match decision { Ok(UseRemote { local, remote }) => { - path.push(name.file_name()); - init::cleanup_local_file_for_remote(&path, &local, &remote)?; - path.pop(); - - UseRemote { local, remote } + // Remote is authoritative, but we may still choose to retain + // the local file if the contents appear to match + if local.file_size() == remote.file_size() { + // Use the local file, but take the remote metadata so that we pick up + // the correct generation. + UseLocal(remote) + } else { + path.push(name.file_name()); + init::cleanup_local_file_for_remote(&path, &local, &remote)?; + path.pop(); + UseRemote { local, remote } + } } Ok(decision) => decision, Err(FutureLayer { local }) => { @@ -2537,13 +2546,15 @@ impl Timeline { /// async fn get_layer_for_write(&self, lsn: Lsn) -> anyhow::Result> { let mut guard = self.layers.write().await; - let layer = guard.get_layer_for_write( - lsn, - self.get_last_record_lsn(), - self.conf, - self.timeline_id, - self.tenant_id, - )?; + let layer = guard + .get_layer_for_write( + lsn, + self.get_last_record_lsn(), + self.conf, + self.timeline_id, + self.tenant_id, + ) + .await?; Ok(layer) } @@ -2747,9 +2758,7 @@ impl Timeline { // update metrics let sz = l.layer_desc().file_size; - self.metrics.resident_physical_size_gauge.add(sz); - self.metrics.num_persistent_files_created.inc_by(1); - self.metrics.persistent_bytes_written.inc_by(sz); + self.metrics.record_new_file_metrics(sz); } guard.finish_flush_l0_layer(delta_layer_to_add, &frozen_layer); @@ -3033,7 +3042,8 @@ impl Timeline { self.tenant_id, &img_range, lsn, - )?; + ) + .await?; fail_point!("image-layer-writer-fail-before-finish", |_| { Err(PageReconstructError::Other(anyhow::anyhow!( @@ -3069,11 +3079,11 @@ impl Timeline { } } }; - image_layer_writer.put_image(key, &img)?; + image_layer_writer.put_image(key, &img).await?; key = key.next(); } } - let image_layer = image_layer_writer.finish()?; + let image_layer = image_layer_writer.finish().await?; image_layers.push(image_layer); } } @@ -3123,9 +3133,8 @@ impl Timeline { LayerFileMetadata::new(metadata.len(), self.generation), ); - self.metrics - .resident_physical_size_gauge - .add(metadata.len()); + // update metrics + self.metrics.record_new_file_metrics(metadata.len()); let l = Arc::new(l); l.access_stats().record_residence_event( LayerResidenceStatus::Resident, @@ -3618,7 +3627,11 @@ impl Timeline { { // ... if so, flush previous layer and prepare to write new one new_layers.push(Arc::new( - writer.take().unwrap().finish(prev_key.unwrap().next())?, + writer + .take() + .unwrap() + .finish(prev_key.unwrap().next()) + .await?, )); writer = None; @@ -3633,20 +3646,23 @@ impl Timeline { } if writer.is_none() { // Create writer if not initiaized yet - writer = Some(DeltaLayerWriter::new( - self.conf, - self.timeline_id, - self.tenant_id, - key, - if dup_end_lsn.is_valid() { - // this is a layer containing slice of values of the same key - debug!("Create new dup layer {}..{}", dup_start_lsn, dup_end_lsn); - dup_start_lsn..dup_end_lsn - } else { - debug!("Create new layer {}..{}", lsn_range.start, lsn_range.end); - lsn_range.clone() - }, - )?); + writer = Some( + DeltaLayerWriter::new( + self.conf, + self.timeline_id, + self.tenant_id, + key, + if dup_end_lsn.is_valid() { + // this is a layer containing slice of values of the same key + debug!("Create new dup layer {}..{}", dup_start_lsn, dup_end_lsn); + dup_start_lsn..dup_end_lsn + } else { + debug!("Create new layer {}..{}", lsn_range.start, lsn_range.end); + lsn_range.clone() + }, + ) + .await?, + ); } fail_point!("delta-layer-writer-fail-before-finish", |_| { @@ -3655,11 +3671,11 @@ impl Timeline { ))) }); - writer.as_mut().unwrap().put_value(key, lsn, value)?; + writer.as_mut().unwrap().put_value(key, lsn, value).await?; prev_key = Some(key); } if let Some(writer) = writer { - new_layers.push(Arc::new(writer.finish(prev_key.unwrap().next())?)); + new_layers.push(Arc::new(writer.finish(prev_key.unwrap().next()).await?)); } // Sync layers @@ -3800,10 +3816,8 @@ impl Timeline { )?; } - // update the timeline's physical size - self.metrics - .resident_physical_size_gauge - .add(metadata.len()); + // update metrics, including the timeline's physical size + self.metrics.record_new_file_metrics(metadata.len()); new_layer_paths.insert( new_delta_path, diff --git a/pageserver/src/tenant/timeline/eviction_task.rs b/pageserver/src/tenant/timeline/eviction_task.rs index 3e407dda57..39f0d03a01 100644 --- a/pageserver/src/tenant/timeline/eviction_task.rs +++ b/pageserver/src/tenant/timeline/eviction_task.rs @@ -328,9 +328,24 @@ impl Timeline { // Make one of the tenant's timelines draw the short straw and run the calculation. // The others wait until the calculation is done so that they take into account the // imitated accesses that the winner made. - let Ok(tenant) = crate::tenant::mgr::get_tenant(self.tenant_id, true).await else { - // likely, we're shutting down - return ControlFlow::Break(()); + // + // It is critical we are responsive to cancellation here. Otherwise, we deadlock with + // tenant deletion (holds TENANTS in read mode) any other task that attempts to + // acquire TENANTS in write mode before we here call get_tenant. + // See https://github.com/neondatabase/neon/issues/5284. + let res = tokio::select! { + _ = cancel.cancelled() => { + return ControlFlow::Break(()); + } + res = crate::tenant::mgr::get_tenant(self.tenant_id, true) => { + res + } + }; + let tenant = match res { + Ok(t) => t, + Err(_) => { + return ControlFlow::Break(()); + } }; let mut state = tenant.eviction_task_tenant_state.lock().await; match state.last_layer_access_imitation { diff --git a/pageserver/src/tenant/timeline/init.rs b/pageserver/src/tenant/timeline/init.rs index 33effb4318..22976a514d 100644 --- a/pageserver/src/tenant/timeline/init.rs +++ b/pageserver/src/tenant/timeline/init.rs @@ -147,11 +147,7 @@ pub(super) fn reconcile( Err(FutureLayer { local }) } else { Ok(match (local, remote) { - (Some(local), Some(remote)) if local != remote => { - assert_eq!(local.generation, remote.generation); - - UseRemote { local, remote } - } + (Some(local), Some(remote)) if local != remote => UseRemote { local, remote }, (Some(x), Some(_)) => UseLocal(x), (None, Some(x)) => Evicted(x), (Some(x), None) => NeedsUpload(x), diff --git a/pageserver/src/tenant/timeline/layer_manager.rs b/pageserver/src/tenant/timeline/layer_manager.rs index 5522ea1788..3c88d31f24 100644 --- a/pageserver/src/tenant/timeline/layer_manager.rs +++ b/pageserver/src/tenant/timeline/layer_manager.rs @@ -87,7 +87,7 @@ impl LayerManager { /// Open a new writable layer to append data if there is no open layer, otherwise return the current open layer, /// called within `get_layer_for_write`. - pub(crate) fn get_layer_for_write( + pub(crate) async fn get_layer_for_write( &mut self, lsn: Lsn, last_record_lsn: Lsn, @@ -129,7 +129,7 @@ impl LayerManager { lsn ); - let new_layer = InMemoryLayer::create(conf, timeline_id, tenant_id, start_lsn)?; + let new_layer = InMemoryLayer::create(conf, timeline_id, tenant_id, start_lsn).await?; let layer = Arc::new(new_layer); self.layer_map.open_layer = Some(layer.clone()); diff --git a/pageserver/src/tenant/timeline/walreceiver.rs b/pageserver/src/tenant/timeline/walreceiver.rs index ccff735c3c..842bc3675c 100644 --- a/pageserver/src/tenant/timeline/walreceiver.rs +++ b/pageserver/src/tenant/timeline/walreceiver.rs @@ -135,7 +135,7 @@ impl WalReceiver { .await; } - pub(super) fn status(&self) -> Option { + pub(crate) fn status(&self) -> Option { self.manager_status.read().unwrap().clone() } } diff --git a/pageserver/src/virtual_file.rs b/pageserver/src/virtual_file.rs index 41c5eb96cf..e437ffb2d7 100644 --- a/pageserver/src/virtual_file.rs +++ b/pageserver/src/virtual_file.rs @@ -10,14 +10,16 @@ //! This is similar to PostgreSQL's virtual file descriptor facility in //! src/backend/storage/file/fd.c //! -use crate::metrics::{STORAGE_IO_SIZE, STORAGE_IO_TIME}; +use crate::metrics::{StorageIoOperation, STORAGE_IO_SIZE, STORAGE_IO_TIME_METRIC}; +use crate::tenant::TENANTS_SEGMENT_NAME; use once_cell::sync::OnceCell; use std::fs::{self, File, OpenOptions}; -use std::io::{Error, ErrorKind, Seek, SeekFrom, Write}; +use std::io::{Error, ErrorKind, Seek, SeekFrom}; use std::os::unix::fs::FileExt; use std::path::{Path, PathBuf}; use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; -use std::sync::{RwLock, RwLockWriteGuard}; +use tokio::sync::{RwLock, RwLockReadGuard, RwLockWriteGuard}; +use tokio::time::Instant; /// /// A virtual file descriptor. You can use this just like std::fs::File, but internally @@ -109,7 +111,7 @@ impl OpenFiles { /// /// On return, we hold a lock on the slot, and its 'tag' has been updated /// recently_used has been set. It's all ready for reuse. - fn find_victim_slot(&self) -> (SlotHandle, RwLockWriteGuard) { + async fn find_victim_slot(&self) -> (SlotHandle, RwLockWriteGuard) { // // Run the clock algorithm to find a slot to replace. // @@ -141,7 +143,7 @@ impl OpenFiles { } retries += 1; } else { - slot_guard = slot.inner.write().unwrap(); + slot_guard = slot.inner.write().await; index = next; break; } @@ -152,10 +154,10 @@ impl OpenFiles { // old file. // if let Some(old_file) = slot_guard.file.take() { - // the normal path of dropping VirtualFile uses "close", use "close-by-replace" here to + // the normal path of dropping VirtualFile uses `Close`, use `CloseByReplace` here to // distinguish the two. - STORAGE_IO_TIME - .with_label_values(&["close-by-replace"]) + STORAGE_IO_TIME_METRIC + .get(StorageIoOperation::CloseByReplace) .observe_closure_duration(|| drop(old_file)); } @@ -207,19 +209,43 @@ impl CrashsafeOverwriteError { } } +/// Observe duration for the given storage I/O operation +/// +/// Unlike `observe_closure_duration`, this supports async, +/// where "support" means that we measure wall clock time. +macro_rules! observe_duration { + ($op:expr, $($body:tt)*) => {{ + let instant = Instant::now(); + let result = $($body)*; + let elapsed = instant.elapsed().as_secs_f64(); + STORAGE_IO_TIME_METRIC + .get($op) + .observe(elapsed); + result + }} +} + +macro_rules! with_file { + ($this:expr, $op:expr, | $ident:ident | $($body:tt)*) => {{ + let $ident = $this.lock_file().await?; + observe_duration!($op, $($body)*) + }}; +} + impl VirtualFile { /// Open a file in read-only mode. Like File::open. - pub fn open(path: &Path) -> Result { - Self::open_with_options(path, OpenOptions::new().read(true)) + pub async fn open(path: &Path) -> Result { + Self::open_with_options(path, OpenOptions::new().read(true)).await } /// Create a new file for writing. If the file exists, it will be truncated. /// Like File::create. - pub fn create(path: &Path) -> Result { + pub async fn create(path: &Path) -> Result { Self::open_with_options( path, OpenOptions::new().write(true).create(true).truncate(true), ) + .await } /// Open a file with given options. @@ -227,7 +253,7 @@ impl VirtualFile { /// Note: If any custom flags were set in 'open_options' through OpenOptionsExt, /// they will be applied also when the file is subsequently re-opened, not only /// on the first time. Make sure that's sane! - pub fn open_with_options( + pub async fn open_with_options( path: &Path, open_options: &OpenOptions, ) -> Result { @@ -235,17 +261,16 @@ impl VirtualFile { let parts = path_str.split('/').collect::>(); let tenant_id; let timeline_id; - if parts.len() > 5 && parts[parts.len() - 5] == "tenants" { + if parts.len() > 5 && parts[parts.len() - 5] == TENANTS_SEGMENT_NAME { tenant_id = parts[parts.len() - 4].to_string(); timeline_id = parts[parts.len() - 2].to_string(); } else { tenant_id = "*".to_string(); timeline_id = "*".to_string(); } - let (handle, mut slot_guard) = get_open_files().find_victim_slot(); - let file = STORAGE_IO_TIME - .with_label_values(&["open"]) - .observe_closure_duration(|| open_options.open(path))?; + let (handle, mut slot_guard) = get_open_files().find_victim_slot().await; + + let file = observe_duration!(StorageIoOperation::Open, open_options.open(path))?; // Strip all options other than read and write. // @@ -298,10 +323,13 @@ impl VirtualFile { // we bail out instead of causing damage. .create_new(true), ) + .await .map_err(CrashsafeOverwriteError::CreateTempfile)?; file.write_all(content) + .await .map_err(CrashsafeOverwriteError::WriteContents)?; file.sync_all() + .await .map_err(CrashsafeOverwriteError::SyncTempfile)?; drop(file); // before the rename, that's important! // renames are atomic @@ -314,29 +342,35 @@ impl VirtualFile { // try_lock. let final_parent_dirfd = Self::open_with_options(final_path_parent, OpenOptions::new().read(true)) + .await .map_err(CrashsafeOverwriteError::OpenFinalPathParentDir)?; final_parent_dirfd .sync_all() + .await .map_err(CrashsafeOverwriteError::SyncFinalPathParentDir)?; Ok(()) } /// Call File::sync_all() on the underlying File. - pub fn sync_all(&self) -> Result<(), Error> { - self.with_file("fsync", |file| file.sync_all())? + pub async fn sync_all(&self) -> Result<(), Error> { + with_file!(self, StorageIoOperation::Fsync, |file| file + .as_ref() + .sync_all()) } - pub fn metadata(&self) -> Result { - self.with_file("metadata", |file| file.metadata())? + pub async fn metadata(&self) -> Result { + with_file!(self, StorageIoOperation::Metadata, |file| file + .as_ref() + .metadata()) } - /// Helper function that looks up the underlying File for this VirtualFile, - /// opening it and evicting some other File if necessary. It calls 'func' - /// with the physical File. - fn with_file(&self, op: &str, mut func: F) -> Result - where - F: FnMut(&File) -> R, - { + /// Helper function internal to `VirtualFile` that looks up the underlying File, + /// opens it and evicts some other File if necessary. The passed parameter is + /// assumed to be a function available for the physical `File`. + /// + /// We are doing it via a macro as Rust doesn't support async closures that + /// take on parameters with lifetimes. + async fn lock_file(&self) -> Result, Error> { let open_files = get_open_files(); let mut handle_guard = { @@ -346,27 +380,23 @@ impl VirtualFile { // We only need to hold the handle lock while we read the current handle. If // another thread closes the file and recycles the slot for a different file, // we will notice that the handle we read is no longer valid and retry. - let mut handle = *self.handle.read().unwrap(); + let mut handle = *self.handle.read().await; loop { // Check if the slot contains our File { let slot = &open_files.slots[handle.index]; - let slot_guard = slot.inner.read().unwrap(); - if slot_guard.tag == handle.tag { - if let Some(file) = &slot_guard.file { - // Found a cached file descriptor. - slot.recently_used.store(true, Ordering::Relaxed); - return Ok(STORAGE_IO_TIME - .with_label_values(&[op]) - .observe_closure_duration(|| func(file))); - } + let slot_guard = slot.inner.read().await; + if slot_guard.tag == handle.tag && slot_guard.file.is_some() { + // Found a cached file descriptor. + slot.recently_used.store(true, Ordering::Relaxed); + return Ok(FileGuard { slot_guard }); } } // The slot didn't contain our File. We will have to open it ourselves, // but before that, grab a write lock on handle in the VirtualFile, so // that no other thread will try to concurrently open the same file. - let handle_guard = self.handle.write().unwrap(); + let handle_guard = self.handle.write().await; // If another thread changed the handle while we were not holding the lock, // then the handle might now be valid again. Loop back to retry. @@ -380,17 +410,10 @@ impl VirtualFile { // We need to open the file ourselves. The handle in the VirtualFile is // now locked in write-mode. Find a free slot to put it in. - let (handle, mut slot_guard) = open_files.find_victim_slot(); + let (handle, mut slot_guard) = open_files.find_victim_slot().await; // Open the physical file - let file = STORAGE_IO_TIME - .with_label_values(&["open"]) - .observe_closure_duration(|| self.open_options.open(&self.path))?; - - // Perform the requested operation on it - let result = STORAGE_IO_TIME - .with_label_values(&[op]) - .observe_closure_duration(|| func(&file)); + let file = observe_duration!(StorageIoOperation::Open, self.open_options.open(&self.path))?; // Store the File in the slot and update the handle in the VirtualFile // to point to it. @@ -398,7 +421,9 @@ impl VirtualFile { *handle_guard = handle; - Ok(result) + return Ok(FileGuard { + slot_guard: slot_guard.downgrade(), + }); } pub fn remove(self) { @@ -407,13 +432,15 @@ impl VirtualFile { std::fs::remove_file(path).expect("failed to remove the virtual file"); } - pub fn seek(&mut self, pos: SeekFrom) -> Result { + pub async fn seek(&mut self, pos: SeekFrom) -> Result { match pos { SeekFrom::Start(offset) => { self.pos = offset; } SeekFrom::End(offset) => { - self.pos = self.with_file("seek", |mut file| file.seek(SeekFrom::End(offset)))?? + self.pos = with_file!(self, StorageIoOperation::Seek, |file| file + .as_ref() + .seek(SeekFrom::End(offset)))? } SeekFrom::Current(offset) => { let pos = self.pos as i128 + offset as i128; @@ -432,22 +459,6 @@ impl VirtualFile { Ok(self.pos) } - #[cfg(test)] - async fn read_to_end(&mut self, buf: &mut Vec) -> Result<(), Error> { - loop { - let mut tmp = [0; 128]; - match self.read_at(&mut tmp, self.pos).await { - Ok(0) => return Ok(()), - Ok(n) => { - self.pos += n as u64; - buf.extend_from_slice(&tmp[..n]); - } - Err(ref e) if e.kind() == std::io::ErrorKind::Interrupted => {} - Err(e) => return Err(e), - } - } - } - // Copied from https://doc.rust-lang.org/1.72.0/src/std/os/unix/fs.rs.html#117-135 pub async fn read_exact_at(&self, mut buf: &mut [u8], mut offset: u64) -> Result<(), Error> { while !buf.is_empty() { @@ -472,7 +483,7 @@ impl VirtualFile { // Copied from https://doc.rust-lang.org/1.72.0/src/std/os/unix/fs.rs.html#219-235 pub async fn write_all_at(&self, mut buf: &[u8], mut offset: u64) -> Result<(), Error> { while !buf.is_empty() { - match self.write_at(buf, offset) { + match self.write_at(buf, offset).await { Ok(0) => { return Err(Error::new( std::io::ErrorKind::WriteZero, @@ -490,8 +501,36 @@ impl VirtualFile { Ok(()) } + pub async fn write_all(&mut self, mut buf: &[u8]) -> Result<(), Error> { + while !buf.is_empty() { + match self.write(buf).await { + Ok(0) => { + return Err(Error::new( + std::io::ErrorKind::WriteZero, + "failed to write whole buffer", + )); + } + Ok(n) => { + buf = &buf[n..]; + } + Err(ref e) if e.kind() == std::io::ErrorKind::Interrupted => {} + Err(e) => return Err(e), + } + } + Ok(()) + } + + async fn write(&mut self, buf: &[u8]) -> Result { + let pos = self.pos; + let n = self.write_at(buf, pos).await?; + self.pos += n as u64; + Ok(n) + } + pub async fn read_at(&self, buf: &mut [u8], offset: u64) -> Result { - let result = self.with_file("read", |file| file.read_at(buf, offset))?; + let result = with_file!(self, StorageIoOperation::Read, |file| file + .as_ref() + .read_at(buf, offset)); if let Ok(size) = result { STORAGE_IO_SIZE .with_label_values(&["read", &self.tenant_id, &self.timeline_id]) @@ -500,8 +539,10 @@ impl VirtualFile { result } - pub fn write_at(&self, buf: &[u8], offset: u64) -> Result { - let result = self.with_file("write", |file| file.write_at(buf, offset))?; + async fn write_at(&self, buf: &[u8], offset: u64) -> Result { + let result = with_file!(self, StorageIoOperation::Write, |file| file + .as_ref() + .write_at(buf, offset)); if let Ok(size) = result { STORAGE_IO_SIZE .with_label_values(&["write", &self.tenant_id, &self.timeline_id]) @@ -511,38 +552,83 @@ impl VirtualFile { } } -impl Drop for VirtualFile { - /// If a VirtualFile is dropped, close the underlying file if it was open. - fn drop(&mut self) { - let handle = self.handle.get_mut().unwrap(); +struct FileGuard<'a> { + slot_guard: RwLockReadGuard<'a, SlotInner>, +} - // We could check with a read-lock first, to avoid waiting on an - // unrelated I/O. - let slot = &get_open_files().slots[handle.index]; - let mut slot_guard = slot.inner.write().unwrap(); - if slot_guard.tag == handle.tag { - slot.recently_used.store(false, Ordering::Relaxed); - // there is also operation "close-by-replace" for closes done on eviction for - // comparison. - STORAGE_IO_TIME - .with_label_values(&["close"]) - .observe_closure_duration(|| drop(slot_guard.file.take())); +impl<'a> AsRef for FileGuard<'a> { + fn as_ref(&self) -> &File { + // This unwrap is safe because we only create `FileGuard`s + // if we know that the file is Some. + self.slot_guard.file.as_ref().unwrap() + } +} + +#[cfg(test)] +impl VirtualFile { + pub(crate) async fn read_blk( + &self, + blknum: u32, + ) -> Result, std::io::Error> { + use crate::page_cache::PAGE_SZ; + let mut buf = [0; PAGE_SZ]; + self.read_exact_at(&mut buf, blknum as u64 * (PAGE_SZ as u64)) + .await?; + Ok(std::sync::Arc::new(buf).into()) + } + + async fn read_to_end(&mut self, buf: &mut Vec) -> Result<(), Error> { + loop { + let mut tmp = [0; 128]; + match self.read_at(&mut tmp, self.pos).await { + Ok(0) => return Ok(()), + Ok(n) => { + self.pos += n as u64; + buf.extend_from_slice(&tmp[..n]); + } + Err(ref e) if e.kind() == std::io::ErrorKind::Interrupted => {} + Err(e) => return Err(e), + } } } } -impl Write for VirtualFile { - fn write(&mut self, buf: &[u8]) -> Result { - let pos = self.pos; - let n = self.write_at(buf, pos)?; - self.pos += n as u64; - Ok(n) - } +impl Drop for VirtualFile { + /// If a VirtualFile is dropped, close the underlying file if it was open. + fn drop(&mut self) { + let handle = self.handle.get_mut(); - fn flush(&mut self) -> Result<(), std::io::Error> { - // flush is no-op for File (at least on unix), so we don't need to do - // anything here either. - Ok(()) + fn clean_slot(slot: &Slot, mut slot_guard: RwLockWriteGuard<'_, SlotInner>, tag: u64) { + if slot_guard.tag == tag { + slot.recently_used.store(false, Ordering::Relaxed); + // there is also the `CloseByReplace` operation for closes done on eviction for + // comparison. + STORAGE_IO_TIME_METRIC + .get(StorageIoOperation::Close) + .observe_closure_duration(|| drop(slot_guard.file.take())); + } + } + + // We don't have async drop so we cannot directly await the lock here. + // Instead, first do a best-effort attempt at closing the underlying + // file descriptor by using `try_write`, and if that fails, spawn + // a tokio task to do it asynchronously: we just want it to be + // cleaned up eventually. + // Most of the time, the `try_lock` should succeed though, + // as we have `&mut self` access. In other words, if the slot + // is still occupied by our file, there should be no access from + // other I/O operations; the only other possible place to lock + // the slot is the lock algorithm looking for free slots. + let slot = &get_open_files().slots[handle.index]; + if let Ok(slot_guard) = slot.inner.try_write() { + clean_slot(slot, slot_guard, handle.tag); + } else { + let tag = handle.tag; + tokio::spawn(async move { + let slot_guard = slot.inner.write().await; + clean_slot(slot, slot_guard, tag); + }); + }; } } @@ -599,6 +685,8 @@ mod tests { use rand::seq::SliceRandom; use rand::thread_rng; use rand::Rng; + use std::future::Future; + use std::io::Write; use std::sync::Arc; enum MaybeVirtualFile { @@ -619,15 +707,15 @@ mod tests { MaybeVirtualFile::File(file) => file.write_all_at(buf, offset), } } - fn seek(&mut self, pos: SeekFrom) -> Result { + async fn seek(&mut self, pos: SeekFrom) -> Result { match self { - MaybeVirtualFile::VirtualFile(file) => file.seek(pos), + MaybeVirtualFile::VirtualFile(file) => file.seek(pos).await, MaybeVirtualFile::File(file) => file.seek(pos), } } async fn write_all(&mut self, buf: &[u8]) -> Result<(), Error> { match self { - MaybeVirtualFile::VirtualFile(file) => file.write_all(buf), + MaybeVirtualFile::VirtualFile(file) => file.write_all(buf).await, MaybeVirtualFile::File(file) => file.write_all(buf), } } @@ -667,8 +755,8 @@ mod tests { // results with VirtualFiles as with native Files. (Except that with // native files, you will run out of file descriptors if the ulimit // is low enough.) - test_files("virtual_files", |path, open_options| { - let vf = VirtualFile::open_with_options(path, open_options)?; + test_files("virtual_files", |path, open_options| async move { + let vf = VirtualFile::open_with_options(&path, &open_options).await?; Ok(MaybeVirtualFile::VirtualFile(vf)) }) .await @@ -676,31 +764,37 @@ mod tests { #[tokio::test] async fn test_physical_files() -> Result<(), Error> { - test_files("physical_files", |path, open_options| { + test_files("physical_files", |path, open_options| async move { Ok(MaybeVirtualFile::File(open_options.open(path)?)) }) .await } - async fn test_files(testname: &str, openfunc: OF) -> Result<(), Error> + async fn test_files(testname: &str, openfunc: OF) -> Result<(), Error> where - OF: Fn(&Path, &OpenOptions) -> Result, + OF: Fn(PathBuf, OpenOptions) -> FT, + FT: Future>, { let testdir = crate::config::PageServerConf::test_repo_dir(testname); std::fs::create_dir_all(&testdir)?; let path_a = testdir.join("file_a"); let mut file_a = openfunc( - &path_a, - OpenOptions::new().write(true).create(true).truncate(true), - )?; + path_a.clone(), + OpenOptions::new() + .write(true) + .create(true) + .truncate(true) + .to_owned(), + ) + .await?; file_a.write_all(b"foobar").await?; // cannot read from a file opened in write-only mode let _ = file_a.read_string().await.unwrap_err(); // Close the file and re-open for reading - let mut file_a = openfunc(&path_a, OpenOptions::new().read(true))?; + let mut file_a = openfunc(path_a, OpenOptions::new().read(true).to_owned()).await?; // cannot write to a file opened in read-only mode let _ = file_a.write_all(b"bar").await.unwrap_err(); @@ -712,23 +806,23 @@ mod tests { assert_eq!("", file_a.read_string().await?); // Test seeks. - assert_eq!(file_a.seek(SeekFrom::Start(1))?, 1); + assert_eq!(file_a.seek(SeekFrom::Start(1)).await?, 1); assert_eq!("oobar", file_a.read_string().await?); - assert_eq!(file_a.seek(SeekFrom::End(-2))?, 4); + assert_eq!(file_a.seek(SeekFrom::End(-2)).await?, 4); assert_eq!("ar", file_a.read_string().await?); - assert_eq!(file_a.seek(SeekFrom::Start(1))?, 1); - assert_eq!(file_a.seek(SeekFrom::Current(2))?, 3); + assert_eq!(file_a.seek(SeekFrom::Start(1)).await?, 1); + assert_eq!(file_a.seek(SeekFrom::Current(2)).await?, 3); assert_eq!("bar", file_a.read_string().await?); - assert_eq!(file_a.seek(SeekFrom::Current(-5))?, 1); + assert_eq!(file_a.seek(SeekFrom::Current(-5)).await?, 1); assert_eq!("oobar", file_a.read_string().await?); // Test erroneous seeks to before byte 0 - assert!(file_a.seek(SeekFrom::End(-7)).is_err()); - assert_eq!(file_a.seek(SeekFrom::Start(1))?, 1); - assert!(file_a.seek(SeekFrom::Current(-2)).is_err()); + file_a.seek(SeekFrom::End(-7)).await.unwrap_err(); + assert_eq!(file_a.seek(SeekFrom::Start(1)).await?, 1); + file_a.seek(SeekFrom::Current(-2)).await.unwrap_err(); // the erroneous seek should have left the position unchanged assert_eq!("oobar", file_a.read_string().await?); @@ -736,13 +830,15 @@ mod tests { // Create another test file, and try FileExt functions on it. let path_b = testdir.join("file_b"); let mut file_b = openfunc( - &path_b, + path_b.clone(), OpenOptions::new() .read(true) .write(true) .create(true) - .truncate(true), - )?; + .truncate(true) + .to_owned(), + ) + .await?; file_b.write_all_at(b"BAR", 3).await?; file_b.write_all_at(b"FOO", 0).await?; @@ -752,11 +848,12 @@ mod tests { // open the same file many times. The effect is the same.) // // leave file_a positioned at offset 1 before we start - assert_eq!(file_a.seek(SeekFrom::Start(1))?, 1); + assert_eq!(file_a.seek(SeekFrom::Start(1)).await?, 1); let mut vfiles = Vec::new(); for _ in 0..100 { - let mut vfile = openfunc(&path_b, OpenOptions::new().read(true))?; + let mut vfile = + openfunc(path_b.clone(), OpenOptions::new().read(true).to_owned()).await?; assert_eq!("FOOBAR", vfile.read_string().await?); vfiles.push(vfile); } @@ -781,8 +878,8 @@ mod tests { /// Test using VirtualFiles from many threads concurrently. This tests both using /// a lot of VirtualFiles concurrently, causing evictions, and also using the same /// VirtualFile from multiple threads concurrently. - #[test] - fn test_vfile_concurrency() -> Result<(), Error> { + #[tokio::test] + async fn test_vfile_concurrency() -> Result<(), Error> { const SIZE: usize = 8 * 1024; const VIRTUAL_FILES: usize = 100; const THREADS: usize = 100; @@ -801,7 +898,8 @@ mod tests { // Open the file many times. let mut files = Vec::new(); for _ in 0..VIRTUAL_FILES { - let f = VirtualFile::open_with_options(&test_file_path, OpenOptions::new().read(true))?; + let f = VirtualFile::open_with_options(&test_file_path, OpenOptions::new().read(true)) + .await?; files.push(f); } let files = Arc::new(files); @@ -812,9 +910,10 @@ mod tests { .thread_name("test_vfile_concurrency thread") .build() .unwrap(); + let mut hdls = Vec::new(); for _threadno in 0..THREADS { let files = files.clone(); - rt.spawn(async move { + let hdl = rt.spawn(async move { let mut buf = [0u8; SIZE]; let mut rng = rand::rngs::OsRng; for _ in 1..1000 { @@ -823,7 +922,12 @@ mod tests { assert!(buf == SAMPLE); } }); + hdls.push(hdl); } + for hdl in hdls { + hdl.await?; + } + std::mem::forget(rt); Ok(()) } diff --git a/pgxn/neon/file_cache.c b/pgxn/neon/file_cache.c index 262814c818..300c80e276 100644 --- a/pgxn/neon/file_cache.c +++ b/pgxn/neon/file_cache.c @@ -77,13 +77,14 @@ typedef struct FileCacheEntry typedef struct FileCacheControl { + uint64 generation; /* generation is needed to handle correct hash reenabling */ uint32 size; /* size of cache file in chunks */ uint32 used; /* number of used chunks */ dlist_head lru; /* double linked list for LRU replacement algorithm */ } FileCacheControl; static HTAB* lfc_hash; -static int lfc_desc; +static int lfc_desc = 0; static LWLockId lfc_lock; static int lfc_max_size; static int lfc_size_limit; @@ -98,6 +99,60 @@ static int lfc_shrinking_factor; /* power of two by which local cache size wil void FileCacheMonitorMain(Datum main_arg); +/* + * Local file cache is mandatory and Neon can work without it. + * In case of any any errors with this cache, we should disable it but to not throw error. + * Also we should allow re-enable it if source of failure (lack of disk space, permissions,...) is fixed. + * All cache content should be invalidated to avoid reading of stale or corrupted data + */ +static void +lfc_disable(char const* op) +{ + HASH_SEQ_STATUS status; + FileCacheEntry* entry; + + elog(WARNING, "Failed to %s local file cache at %s: %m, disabling local file cache", op, lfc_path); + + if (lfc_desc > 0) + close(lfc_desc); + + lfc_desc = -1; + lfc_size_limit = 0; + + /* Invalidate hash */ + LWLockAcquire(lfc_lock, LW_EXCLUSIVE); + + hash_seq_init(&status, lfc_hash); + while ((entry = hash_seq_search(&status)) != NULL) + { + hash_search(lfc_hash, &entry->key, HASH_REMOVE, NULL); + memset(entry->bitmap, 0, sizeof entry->bitmap); + } + hash_seq_term(&status); + lfc_ctl->generation += 1; + lfc_ctl->size = 0; + lfc_ctl->used = 0; + dlist_init(&lfc_ctl->lru); + + LWLockRelease(lfc_lock); +} + +static bool +lfc_ensure_opened(void) +{ + /* Open cache file if not done yet */ + if (lfc_desc <= 0) + { + lfc_desc = BasicOpenFile(lfc_path, O_RDWR|O_CREAT); + + if (lfc_desc < 0) { + lfc_disable("open"); + return false; + } + } + return false; +} + static void lfc_shmem_startup(void) { @@ -123,6 +178,7 @@ lfc_shmem_startup(void) lfc_size+1, lfc_size+1, &info, HASH_ELEM | HASH_BLOBS); + lfc_ctl->generation = 0; lfc_ctl->size = 0; lfc_ctl->used = 0; dlist_init(&lfc_ctl->lru); @@ -168,7 +224,7 @@ lfc_change_limit_hook(int newval, void *extra) return; /* Open cache file if not done yet */ - if (lfc_desc == 0) + if (lfc_desc <= 0) { lfc_desc = BasicOpenFile(lfc_path, O_RDWR|O_CREAT); if (lfc_desc < 0) { @@ -454,10 +510,15 @@ lfc_read(RelFileNode rnode, ForkNumber forkNum, BlockNumber blkno, int chunk_offs = blkno & (BLOCKS_PER_CHUNK-1); bool result = true; uint32 hash; + uint64 generation; + uint32 entry_offset; if (lfc_size_limit == 0) /* fast exit if file cache is disabled */ return false; + if (!lfc_ensure_opened()) + return false; + tag.rnode = rnode; tag.forkNum = forkNum; tag.blockNum = blkno & ~(BLOCKS_PER_CHUNK-1); @@ -474,35 +535,29 @@ lfc_read(RelFileNode rnode, ForkNumber forkNum, BlockNumber blkno, /* Unlink entry from LRU list to pin it for the duration of IO operation */ if (entry->access_count++ == 0) dlist_delete(&entry->lru_node); + generation = lfc_ctl->generation; + entry_offset = entry->offset; + LWLockRelease(lfc_lock); - /* Open cache file if not done yet */ - if (lfc_desc == 0) + rc = pread(lfc_desc, buffer, BLCKSZ, ((off_t)entry_offset*BLOCKS_PER_CHUNK + chunk_offs)*BLCKSZ); + if (rc != BLCKSZ) { - lfc_desc = BasicOpenFile(lfc_path, O_RDWR|O_CREAT); - if (lfc_desc < 0) { - elog(LOG, "Failed to open file cache %s: %m", lfc_path); - lfc_size_limit = 0; /* disable file cache */ - result = false; - } - } - - if (lfc_desc > 0) - { - rc = pread(lfc_desc, buffer, BLCKSZ, ((off_t)entry->offset*BLOCKS_PER_CHUNK + chunk_offs)*BLCKSZ); - if (rc != BLCKSZ) - { - elog(INFO, "Failed to read file cache: %m"); - lfc_size_limit = 0; /* disable file cache */ - result = false; - } + lfc_disable("read"); + return false; } /* Place entry to the head of LRU list */ LWLockAcquire(lfc_lock, LW_EXCLUSIVE); - Assert(entry->access_count > 0); - if (--entry->access_count == 0) - dlist_push_tail(&lfc_ctl->lru, &entry->lru_node); + if (lfc_ctl->generation == generation) + { + Assert(entry->access_count > 0); + if (--entry->access_count == 0) + dlist_push_tail(&lfc_ctl->lru, &entry->lru_node); + } + else + result = false; + LWLockRelease(lfc_lock); return result; @@ -526,6 +581,9 @@ lfc_write(RelFileNode rnode, ForkNumber forkNum, BlockNumber blkno, if (lfc_size_limit == 0) /* fast exit if file cache is disabled */ return; + if (!lfc_ensure_opened()) + return; + tag.rnode = rnode; tag.forkNum = forkNum; tag.blockNum = blkno & ~(BLOCKS_PER_CHUNK-1); @@ -567,33 +625,22 @@ lfc_write(RelFileNode rnode, ForkNumber forkNum, BlockNumber blkno, entry->access_count = 1; memset(entry->bitmap, 0, sizeof entry->bitmap); } - LWLockRelease(lfc_lock); - /* Open cache file if not done yet */ - if (lfc_desc == 0) + rc = pwrite(lfc_desc, buffer, BLCKSZ, ((off_t)entry->offset*BLOCKS_PER_CHUNK + chunk_offs)*BLCKSZ); + if (rc != BLCKSZ) { - lfc_desc = BasicOpenFile(lfc_path, O_RDWR|O_CREAT); - if (lfc_desc < 0) { - elog(WARNING, "Failed to open file cache %s: %m, disabling file cache", lfc_path); - lfc_size_limit = 0; /* disable file cache */ - } + lfc_disable("write"); } - if (lfc_desc > 0) + else { - rc = pwrite(lfc_desc, buffer, BLCKSZ, ((off_t)entry->offset*BLOCKS_PER_CHUNK + chunk_offs)*BLCKSZ); - if (rc != BLCKSZ) - { - elog(WARNING, "Failed to write file cache: %m, disabling file cache"); - lfc_size_limit = 0; /* disable file cache */ - } - } - /* Place entry to the head of LRU list */ - LWLockAcquire(lfc_lock, LW_EXCLUSIVE); - Assert(entry->access_count > 0); - if (--entry->access_count == 0) - dlist_push_tail(&lfc_ctl->lru, &entry->lru_node); - if (lfc_size_limit != 0) + /* Place entry to the head of LRU list */ + Assert(entry->access_count > 0); + if (--entry->access_count == 0) + dlist_push_tail(&lfc_ctl->lru, &entry->lru_node); + entry->bitmap[chunk_offs >> 5] |= (1 << (chunk_offs & 31)); + } + LWLockRelease(lfc_lock); } @@ -694,6 +741,7 @@ local_cache_pages(PG_FUNCTION_ARGS) for (int i = 0; i < BLOCKS_PER_CHUNK; i++) n_pages += (entry->bitmap[i >> 5] & (1 << (i & 31))) != 0; } + hash_seq_term(&status); fctx->record = (LocalCachePagesRec *) MemoryContextAllocHuge(CurrentMemoryContext, sizeof(LocalCachePagesRec) * n_pages); @@ -732,6 +780,7 @@ local_cache_pages(PG_FUNCTION_ARGS) } } } + hash_seq_term(&status); Assert(n_pages == funcctx->max_calls); LWLockRelease(lfc_lock); } diff --git a/pgxn/neon/walproposer.c b/pgxn/neon/walproposer.c index d9999ef2b1..7ca335945d 100644 --- a/pgxn/neon/walproposer.c +++ b/pgxn/neon/walproposer.c @@ -2539,8 +2539,15 @@ backpressure_throttling_impl(void) ? PrevProcessInterruptsCallback() : false; - /* Don't throttle read only transactions and wal sender. */ - if (am_walsender || !TransactionIdIsValid(GetCurrentTransactionIdIfAny())) + /* + * Don't throttle read only transactions or wal sender. + * Do throttle CREATE INDEX CONCURRENTLY, however. It performs some + * stages outside a transaction, even though it writes a lot of WAL. + * Check PROC_IN_SAFE_IC flag to cover that case. + */ + if (am_walsender + || (!(MyProc->statusFlags & PROC_IN_SAFE_IC) + && !TransactionIdIsValid(GetCurrentTransactionIdIfAny()))) return retry; /* Calculate replicas lag */ diff --git a/rust-toolchain.toml b/rust-toolchain.toml index 0ce368ff9d..a21a293218 100644 --- a/rust-toolchain.toml +++ b/rust-toolchain.toml @@ -1,5 +1,5 @@ [toolchain] -channel = "1.71.0" +channel = "1.72.0" profile = "default" # The default profile includes rustc, rust-std, cargo, rust-docs, rustfmt and clippy. # https://rust-lang.github.io/rustup/concepts/profiles.html diff --git a/s3_scrubber/Cargo.toml b/s3_scrubber/Cargo.toml index 47668eb4aa..82cd105d4f 100644 --- a/s3_scrubber/Cargo.toml +++ b/s3_scrubber/Cargo.toml @@ -22,6 +22,10 @@ serde_json.workspace = true serde_with.workspace = true workspace_hack.workspace = true utils.workspace = true +async-stream.workspace = true +tokio-stream.workspace = true +futures-util.workspace = true +itertools.workspace = true tokio = { workspace = true, features = ["macros", "rt-multi-thread"] } chrono = { workspace = true, default-features = false, features = ["clock", "serde"] } @@ -30,10 +34,8 @@ aws-config = { workspace = true, default-features = false, features = ["rustls", pageserver = {path="../pageserver"} - tracing.workspace = true tracing-subscriber.workspace = true clap.workspace = true - -atty = "0.2" -tracing-appender = "0.2" \ No newline at end of file +tracing-appender = "0.2" +histogram = "0.7" \ No newline at end of file diff --git a/s3_scrubber/src/checks.rs b/s3_scrubber/src/checks.rs index c52a40ee94..914681d707 100644 --- a/s3_scrubber/src/checks.rs +++ b/s3_scrubber/src/checks.rs @@ -4,13 +4,12 @@ use std::time::Duration; use anyhow::Context; use aws_sdk_s3::Client; -use tokio::io::AsyncReadExt; use tokio::task::JoinSet; use tracing::{error, info, info_span, warn, Instrument}; use crate::cloud_admin_api::{BranchData, CloudAdminApiClient, ProjectId}; use crate::delete_batch_producer::DeleteProducerStats; -use crate::{list_objects_with_retries, RootTarget, MAX_RETRIES}; +use crate::{download_object_with_retries, list_objects_with_retries, RootTarget, MAX_RETRIES}; use pageserver::tenant::storage_layer::LayerFileName; use pageserver::tenant::IndexPart; use utils::id::TenantTimelineId; @@ -47,7 +46,10 @@ pub async fn validate_pageserver_active_tenant_and_timelines( .push(active_branch); let Some(active_project) = s3_active_projects.get(&active_project_id) else { - error!("Branch {:?} for project {:?} has no such project in the active projects", active_branch_id, active_project_id); + error!( + "Branch {:?} for project {:?} has no such project in the active projects", + active_branch_id, active_project_id + ); continue; }; @@ -92,9 +94,9 @@ pub async fn validate_pageserver_active_tenant_and_timelines( branch_checks.spawn( async move { let check_errors = branch_cleanup_and_check_errors( - id, + &id, &s3_root, - &s3_active_branch, + Some(&s3_active_branch), console_branch, s3_data, ) @@ -107,13 +109,13 @@ pub async fn validate_pageserver_active_tenant_and_timelines( } let mut total_stats = BranchCheckStats::default(); - while let Some((id, branch_check_errors)) = branch_checks + while let Some((id, analysis)) = branch_checks .join_next() .await .transpose() .context("branch check task join")? { - total_stats.add(id, branch_check_errors); + total_stats.add(id, analysis.errors); } Ok(total_stats) } @@ -160,35 +162,59 @@ impl BranchCheckStats { } } -async fn branch_cleanup_and_check_errors( - id: TenantTimelineId, +pub struct TimelineAnalysis { + /// Anomalies detected + pub errors: Vec, + + /// Healthy-but-noteworthy, like old-versioned structures that are readable but + /// worth reporting for awareness that we must not remove that old version decoding + /// yet. + pub warnings: Vec, + + /// Keys not referenced in metadata: candidates for removal + pub garbage_keys: Vec, +} + +impl TimelineAnalysis { + fn new() -> Self { + Self { + errors: Vec::new(), + warnings: Vec::new(), + garbage_keys: Vec::new(), + } + } +} + +pub async fn branch_cleanup_and_check_errors( + id: &TenantTimelineId, s3_root: &RootTarget, - s3_active_branch: &BranchData, + s3_active_branch: Option<&BranchData>, console_branch: Option, s3_data: Option, -) -> Vec { - info!( - "Checking timeline for branch branch {:?}/{:?}", - s3_active_branch.project_id, s3_active_branch.id - ); - let mut branch_check_errors = Vec::new(); +) -> TimelineAnalysis { + let mut result = TimelineAnalysis::new(); - match console_branch { - Some(console_active_branch) => { - if console_active_branch.deleted { - branch_check_errors.push(format!("Timeline has deleted branch data in the console (id = {:?}, project_id = {:?}), recheck whether if it got removed during the check", - s3_active_branch.id, s3_active_branch.project_id)) - } - }, - None => branch_check_errors.push(format!("Timeline has no branch data in the console (id = {:?}, project_id = {:?}), recheck whether if it got removed during the check", + info!("Checking timeline {id}"); + + if let Some(s3_active_branch) = s3_active_branch { + info!( + "Checking console status for timeline for branch {:?}/{:?}", + s3_active_branch.project_id, s3_active_branch.id + ); + match console_branch { + Some(_) => {result.errors.push(format!("Timeline has deleted branch data in the console (id = {:?}, project_id = {:?}), recheck whether it got removed during the check", + s3_active_branch.id, s3_active_branch.project_id)) + }, + None => { + result.errors.push(format!("Timeline has no branch data in the console (id = {:?}, project_id = {:?}), recheck whether it got removed during the check", s3_active_branch.id, s3_active_branch.project_id)) + } + }; } - let mut keys_to_remove = Vec::new(); - match s3_data { Some(s3_data) => { - keys_to_remove.extend(s3_data.keys_to_remove); + result.garbage_keys.extend(s3_data.keys_to_remove); match s3_data.blob_data { BlobDataParseResult::Parsed { @@ -196,16 +222,23 @@ async fn branch_cleanup_and_check_errors( mut s3_layers, } => { if !IndexPart::KNOWN_VERSIONS.contains(&index_part.get_version()) { - branch_check_errors.push(format!( + result.errors.push(format!( "index_part.json version: {}", index_part.get_version() )) } + if &index_part.get_version() != IndexPart::KNOWN_VERSIONS.last().unwrap() { + result.warnings.push(format!( + "index_part.json version is not latest: {}", + index_part.get_version() + )) + } + if index_part.metadata.disk_consistent_lsn() != index_part.get_disk_consistent_lsn() { - branch_check_errors.push(format!( + result.errors.push(format!( "Mismatching disk_consistent_lsn in TimelineMetadata ({}) and in the index_part ({})", index_part.metadata.disk_consistent_lsn(), index_part.get_disk_consistent_lsn(), @@ -220,13 +253,13 @@ async fn branch_cleanup_and_check_errors( for (layer, metadata) in index_part.layer_metadata { if metadata.file_size == 0 { - branch_check_errors.push(format!( + result.errors.push(format!( "index_part.json contains a layer {} that has 0 size in its layer metadata", layer.file_name(), )) } if !s3_layers.remove(&layer) { - branch_check_errors.push(format!( + result.errors.push(format!( "index_part.json contains a layer {} that is not present in S3", layer.file_name(), )) @@ -234,55 +267,66 @@ async fn branch_cleanup_and_check_errors( } if !s3_layers.is_empty() { - branch_check_errors.push(format!( + result.errors.push(format!( "index_part.json does not contain layers from S3: {:?}", s3_layers .iter() .map(|layer_name| layer_name.file_name()) .collect::>(), )); - keys_to_remove.extend(s3_layers.iter().map(|layer_name| { - let mut key = s3_root.timeline_root(id).prefix_in_bucket; - let delimiter = s3_root.delimiter(); - if !key.ends_with(delimiter) { - key.push_str(delimiter); - } - key.push_str(&layer_name.file_name()); - key - })); + result + .garbage_keys + .extend(s3_layers.iter().map(|layer_name| { + let mut key = s3_root.timeline_root(id).prefix_in_bucket; + let delimiter = s3_root.delimiter(); + if !key.ends_with(delimiter) { + key.push_str(delimiter); + } + key.push_str(&layer_name.file_name()); + key + })); } } - BlobDataParseResult::Incorrect(parse_errors) => branch_check_errors.extend( + BlobDataParseResult::Incorrect(parse_errors) => result.errors.extend( parse_errors .into_iter() .map(|error| format!("parse error: {error}")), ), } } - None => branch_check_errors.push("Timeline has no data on S3 at all".to_string()), + None => result + .errors + .push("Timeline has no data on S3 at all".to_string()), } - if branch_check_errors.is_empty() { + if result.errors.is_empty() { info!("No check errors found"); } else { - warn!("Found check errors: {branch_check_errors:?}"); + warn!("Timeline metadata errors: {0:?}", result.errors); } - if !keys_to_remove.is_empty() { - error!("The following keys should be removed from S3: {keys_to_remove:?}") + if !result.warnings.is_empty() { + warn!("Timeline metadata warnings: {0:?}", result.warnings); } - branch_check_errors + if !result.garbage_keys.is_empty() { + error!( + "The following keys should be removed from S3: {0:?}", + result.garbage_keys + ) + } + + result } #[derive(Debug)] -struct S3TimelineBlobData { - blob_data: BlobDataParseResult, - keys_to_remove: Vec, +pub struct S3TimelineBlobData { + pub blob_data: BlobDataParseResult, + pub keys_to_remove: Vec, } #[derive(Debug)] -enum BlobDataParseResult { +pub enum BlobDataParseResult { Parsed { index_part: IndexPart, s3_layers: HashSet, @@ -290,7 +334,7 @@ enum BlobDataParseResult { Incorrect(Vec), } -async fn list_timeline_blobs( +pub async fn list_timeline_blobs( s3_client: &Client, id: TenantTimelineId, s3_root: &RootTarget, @@ -298,7 +342,7 @@ async fn list_timeline_blobs( let mut s3_layers = HashSet::new(); let mut index_part_object = None; - let timeline_dir_target = s3_root.timeline_root(id); + let timeline_dir_target = s3_root.timeline_root(&id); let mut continuation_token = None; let mut errors = Vec::new(); @@ -394,45 +438,3 @@ async fn list_timeline_blobs( keys_to_remove, }) } - -async fn download_object_with_retries( - s3_client: &Client, - bucket_name: &str, - key: &str, -) -> anyhow::Result> { - for _ in 0..MAX_RETRIES { - let mut body_buf = Vec::new(); - let response_stream = match s3_client - .get_object() - .bucket(bucket_name) - .key(key) - .send() - .await - { - Ok(response) => response, - Err(e) => { - error!("Failed to download object for key {key}: {e}"); - tokio::time::sleep(Duration::from_secs(1)).await; - continue; - } - }; - - match response_stream - .body - .into_async_read() - .read_to_end(&mut body_buf) - .await - { - Ok(bytes_read) => { - info!("Downloaded {bytes_read} bytes for object object with key {key}"); - return Ok(body_buf); - } - Err(e) => { - error!("Failed to stream object body for key {key}: {e}"); - tokio::time::sleep(Duration::from_secs(1)).await; - } - } - } - - anyhow::bail!("Failed to download objects with key {key} {MAX_RETRIES} times") -} diff --git a/s3_scrubber/src/delete_batch_producer/timeline_batch.rs b/s3_scrubber/src/delete_batch_producer/timeline_batch.rs index 0a0b31ae87..2ad522d3fb 100644 --- a/s3_scrubber/src/delete_batch_producer/timeline_batch.rs +++ b/s3_scrubber/src/delete_batch_producer/timeline_batch.rs @@ -41,7 +41,7 @@ pub async fn schedule_cleanup_deleted_timelines( let new_stats = async move { let tenant_id_to_check = project_to_check.tenant; - let check_target = check_root.timelines_root(tenant_id_to_check); + let check_target = check_root.timelines_root(&tenant_id_to_check); let stats = super::process_s3_target_recursively( &check_s3_client, &check_target, diff --git a/s3_scrubber/src/lib.rs b/s3_scrubber/src/lib.rs index ea1338cf11..f19a55efac 100644 --- a/s3_scrubber/src/lib.rs +++ b/s3_scrubber/src/lib.rs @@ -1,12 +1,15 @@ pub mod checks; pub mod cloud_admin_api; pub mod delete_batch_producer; +pub mod metadata_stream; mod s3_deletion; +pub mod scan_metadata; use std::env; use std::fmt::Display; use std::time::Duration; +use anyhow::Context; use aws_config::environment::EnvironmentVariableCredentialsProvider; use aws_config::imds::credentials::ImdsCredentialsProvider; use aws_config::meta::credentials::CredentialsProviderChain; @@ -14,7 +17,9 @@ use aws_config::sso::SsoCredentialsProvider; use aws_sdk_s3::config::Region; use aws_sdk_s3::{Client, Config}; +use reqwest::Url; pub use s3_deletion::S3Deleter; +use tokio::io::AsyncReadExt; use tracing::error; use tracing_appender::non_blocking::WorkerGuard; use tracing_subscriber::{fmt, prelude::*, EnvFilter}; @@ -23,6 +28,8 @@ use utils::id::{TenantId, TenantTimelineId}; const MAX_RETRIES: usize = 20; const CLOUD_ADMIN_API_TOKEN_ENV_VAR: &str = "CLOUD_ADMIN_API_TOKEN"; +pub const CLI_NAME: &str = "s3-scrubber"; + #[derive(Debug, Clone)] pub struct S3Target { pub bucket_name: String, @@ -69,19 +76,19 @@ impl RootTarget { } } - pub fn tenant_root(&self, tenant_id: TenantId) -> S3Target { + pub fn tenant_root(&self, tenant_id: &TenantId) -> S3Target { self.tenants_root().with_sub_segment(&tenant_id.to_string()) } - pub fn timelines_root(&self, tenant_id: TenantId) -> S3Target { + pub fn timelines_root(&self, tenant_id: &TenantId) -> S3Target { match self { Self::Pageserver(_) => self.tenant_root(tenant_id).with_sub_segment("timelines"), Self::Safekeeper(_) => self.tenant_root(tenant_id), } } - pub fn timeline_root(&self, id: TenantTimelineId) -> S3Target { - self.timelines_root(id.tenant_id) + pub fn timeline_root(&self, id: &TenantTimelineId) -> S3Target { + self.timelines_root(&id.tenant_id) .with_sub_segment(&id.timeline_id.to_string()) } @@ -100,6 +107,55 @@ impl RootTarget { } } +pub struct BucketConfig { + pub region: String, + pub bucket: String, + + /// Use SSO if this is set, else rely on AWS_* environment vars + pub sso_account_id: Option, +} + +impl Display for BucketConfig { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!( + f, + "{}/{}/{}", + self.sso_account_id.as_deref().unwrap_or(""), + self.region, + self.bucket + ) + } +} + +impl BucketConfig { + pub fn from_env() -> anyhow::Result { + let sso_account_id = env::var("SSO_ACCOUNT_ID").ok(); + let region = env::var("REGION").context("'REGION' param retrieval")?; + let bucket = env::var("BUCKET").context("'BUCKET' param retrieval")?; + + Ok(Self { + region, + bucket, + sso_account_id, + }) + } +} + +pub struct ConsoleConfig { + pub admin_api_url: Url, +} + +impl ConsoleConfig { + pub fn from_env() -> anyhow::Result { + let admin_api_url: Url = env::var("CLOUD_ADMIN_API_URL") + .context("'CLOUD_ADMIN_API_URL' param retrieval")? + .parse() + .context("'CLOUD_ADMIN_API_URL' param parsing")?; + + Ok(Self { admin_api_url }) + } +} + pub fn get_cloud_admin_api_token_or_exit() -> String { match env::var(CLOUD_ADMIN_API_TOKEN_ENV_VAR) { Ok(token) => token, @@ -114,23 +170,7 @@ pub fn get_cloud_admin_api_token_or_exit() -> String { } } -pub fn init_logging(binary_name: &str, dry_run: bool, node_kind: &str) -> WorkerGuard { - let file_name = if dry_run { - format!( - "{}_{}_{}__dry.log", - binary_name, - node_kind, - chrono::Utc::now().format("%Y_%m_%d__%H_%M_%S") - ) - } else { - format!( - "{}_{}_{}.log", - binary_name, - node_kind, - chrono::Utc::now().format("%Y_%m_%d__%H_%M_%S") - ) - }; - +pub fn init_logging(file_name: &str) -> WorkerGuard { let (file_writer, guard) = tracing_appender::non_blocking(tracing_appender::rolling::never("./logs/", file_name)); @@ -140,7 +180,6 @@ pub fn init_logging(binary_name: &str, dry_run: bool, node_kind: &str) -> Worker .with_writer(file_writer); let stdout_logs = fmt::Layer::new() .with_target(false) - .with_ansi(atty::is(atty::Stream::Stdout)) .with_writer(std::io::stdout); tracing_subscriber::registry() .with(EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info"))) @@ -151,30 +190,43 @@ pub fn init_logging(binary_name: &str, dry_run: bool, node_kind: &str) -> Worker guard } -pub fn init_s3_client(account_id: String, bucket_region: Region) -> Client { +pub fn init_s3_client(account_id: Option, bucket_region: Region) -> Client { let credentials_provider = { // uses "AWS_ACCESS_KEY_ID", "AWS_SECRET_ACCESS_KEY" - CredentialsProviderChain::first_try("env", EnvironmentVariableCredentialsProvider::new()) - // uses sso - .or_else( + let chain = CredentialsProviderChain::first_try( + "env", + EnvironmentVariableCredentialsProvider::new(), + ); + + // Use SSO if we were given an account ID + match account_id { + Some(sso_account) => chain.or_else( "sso", SsoCredentialsProvider::builder() - .account_id(account_id) + .account_id(sso_account) .role_name("PowerUserAccess") .start_url("https://neondb.awsapps.com/start") .region(Region::from_static("eu-central-1")) .build(), - ) - // uses imds v2 - .or_else("imds", ImdsCredentialsProvider::builder().build()) + ), + None => chain, + } + .or_else( + // Finally try IMDS + "imds", + ImdsCredentialsProvider::builder().build(), + ) }; - let config = Config::builder() + let mut builder = Config::builder() .region(bucket_region) - .credentials_provider(credentials_provider) - .build(); + .credentials_provider(credentials_provider); - Client::from_conf(config) + if let Ok(endpoint) = env::var("AWS_ENDPOINT_URL") { + builder = builder.endpoint_url(endpoint) + } + + Client::from_conf(builder.build()) } async fn list_objects_with_retries( @@ -202,3 +254,45 @@ async fn list_objects_with_retries( anyhow::bail!("Failed to list objects {MAX_RETRIES} times") } + +async fn download_object_with_retries( + s3_client: &Client, + bucket_name: &str, + key: &str, +) -> anyhow::Result> { + for _ in 0..MAX_RETRIES { + let mut body_buf = Vec::new(); + let response_stream = match s3_client + .get_object() + .bucket(bucket_name) + .key(key) + .send() + .await + { + Ok(response) => response, + Err(e) => { + error!("Failed to download object for key {key}: {e}"); + tokio::time::sleep(Duration::from_secs(1)).await; + continue; + } + }; + + match response_stream + .body + .into_async_read() + .read_to_end(&mut body_buf) + .await + { + Ok(bytes_read) => { + tracing::info!("Downloaded {bytes_read} bytes for object object with key {key}"); + return Ok(body_buf); + } + Err(e) => { + error!("Failed to stream object body for key {key}: {e}"); + tokio::time::sleep(Duration::from_secs(1)).await; + } + } + } + + anyhow::bail!("Failed to download objects with key {key} {MAX_RETRIES} times") +} diff --git a/s3_scrubber/src/main.rs b/s3_scrubber/src/main.rs index 7004bcad51..3c60723f88 100644 --- a/s3_scrubber/src/main.rs +++ b/s3_scrubber/src/main.rs @@ -1,19 +1,18 @@ use std::collections::HashMap; -use std::env; use std::fmt::Display; use std::num::NonZeroUsize; use std::sync::Arc; use anyhow::Context; use aws_sdk_s3::config::Region; -use reqwest::Url; use s3_scrubber::cloud_admin_api::CloudAdminApiClient; use s3_scrubber::delete_batch_producer::DeleteBatchProducer; +use s3_scrubber::scan_metadata::scan_metadata; use s3_scrubber::{ - checks, get_cloud_admin_api_token_or_exit, init_logging, init_s3_client, RootTarget, S3Deleter, - S3Target, TraversingDepth, + checks, get_cloud_admin_api_token_or_exit, init_logging, init_s3_client, BucketConfig, + ConsoleConfig, RootTarget, S3Deleter, S3Target, TraversingDepth, CLI_NAME, }; -use tracing::{info, info_span, warn}; +use tracing::{info, warn}; use clap::{Parser, Subcommand, ValueEnum}; @@ -59,48 +58,7 @@ enum Command { #[arg(short, long, default_value_t = false)] skip_validation: bool, }, -} - -struct BucketConfig { - region: String, - bucket: String, - sso_account_id: String, -} - -impl Display for BucketConfig { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!(f, "{}/{}/{}", self.sso_account_id, self.region, self.bucket) - } -} - -impl BucketConfig { - fn from_env() -> anyhow::Result { - let sso_account_id = - env::var("SSO_ACCOUNT_ID").context("'SSO_ACCOUNT_ID' param retrieval")?; - let region = env::var("REGION").context("'REGION' param retrieval")?; - let bucket = env::var("BUCKET").context("'BUCKET' param retrieval")?; - - Ok(Self { - region, - bucket, - sso_account_id, - }) - } -} - -struct ConsoleConfig { - admin_api_url: Url, -} - -impl ConsoleConfig { - fn from_env() -> anyhow::Result { - let admin_api_url: Url = env::var("CLOUD_ADMIN_API_URL") - .context("'CLOUD_ADMIN_API_URL' param retrieval")? - .parse() - .context("'CLOUD_ADMIN_API_URL' param parsing")?; - - Ok(Self { admin_api_url }) - } + ScanMetadata {}, } async fn tidy( @@ -111,13 +69,24 @@ async fn tidy( depth: TraversingDepth, skip_validation: bool, ) -> anyhow::Result<()> { - let binary_name = env::args() - .next() - .context("binary name in not the first argument")?; - let dry_run = !cli.delete; - let _guard = init_logging(&binary_name, dry_run, node_kind.as_str()); - let _main_span = info_span!("tidy", binary = %binary_name, %dry_run).entered(); + let file_name = if dry_run { + format!( + "{}_{}_{}__dry.log", + CLI_NAME, + node_kind, + chrono::Utc::now().format("%Y_%m_%d__%H_%M_%S") + ) + } else { + format!( + "{}_{}_{}.log", + CLI_NAME, + node_kind, + chrono::Utc::now().format("%Y_%m_%d__%H_%M_%S") + ) + }; + + let _guard = init_logging(&file_name); if dry_run { info!("Dry run, not removing items for real"); @@ -247,7 +216,7 @@ async fn main() -> anyhow::Result<()> { let bucket_config = BucketConfig::from_env()?; - match &cli.command { + match cli.command { Command::Tidy { node_kind, depth, @@ -258,11 +227,25 @@ async fn main() -> anyhow::Result<()> { &cli, bucket_config, console_config, - *node_kind, - *depth, - *skip_validation, + node_kind, + depth, + skip_validation, ) .await } + Command::ScanMetadata {} => match scan_metadata(bucket_config).await { + Err(e) => { + tracing::error!("Failed: {e}"); + Err(e) + } + Ok(summary) => { + println!("{}", summary.summary_string()); + if summary.is_fatal() { + Err(anyhow::anyhow!("Fatal scrub errors detected")) + } else { + Ok(()) + } + } + }, } } diff --git a/s3_scrubber/src/metadata_stream.rs b/s3_scrubber/src/metadata_stream.rs new file mode 100644 index 0000000000..4e500a96cf --- /dev/null +++ b/s3_scrubber/src/metadata_stream.rs @@ -0,0 +1,106 @@ +use anyhow::Context; +use async_stream::{stream, try_stream}; +use aws_sdk_s3::Client; +use tokio_stream::Stream; + +use crate::{list_objects_with_retries, RootTarget, TenantId}; +use utils::id::{TenantTimelineId, TimelineId}; + +/// Given an S3 bucket, output a stream of TenantIds discovered via ListObjectsv2 +pub fn stream_tenants<'a>( + s3_client: &'a Client, + target: &'a RootTarget, +) -> impl Stream> + 'a { + try_stream! { + let mut continuation_token = None; + loop { + let tenants_target = target.tenants_root(); + let fetch_response = + list_objects_with_retries(s3_client, tenants_target, continuation_token.clone()).await?; + + let new_entry_ids = fetch_response + .common_prefixes() + .unwrap_or_default() + .iter() + .filter_map(|prefix| prefix.prefix()) + .filter_map(|prefix| -> Option<&str> { + prefix + .strip_prefix(&tenants_target.prefix_in_bucket)? + .strip_suffix('/') + }).map(|entry_id_str| { + entry_id_str + .parse() + .with_context(|| format!("Incorrect entry id str: {entry_id_str}")) + }); + + for i in new_entry_ids { + yield i?; + } + + match fetch_response.next_continuation_token { + Some(new_token) => continuation_token = Some(new_token), + None => break, + } + } + } +} + +/// Given a TenantId, output a stream of the timelines within that tenant, discovered +/// using ListObjectsv2. The listing is done before the stream is built, so that this +/// function can be used to generate concurrency on a stream using buffer_unordered. +pub async fn stream_tenant_timelines<'a>( + s3_client: &'a Client, + target: &'a RootTarget, + tenant: TenantId, +) -> anyhow::Result> + 'a> { + let mut timeline_ids: Vec> = Vec::new(); + let mut continuation_token = None; + let timelines_target = target.timelines_root(&tenant); + + loop { + tracing::info!("Listing in {}", tenant); + let fetch_response = + list_objects_with_retries(s3_client, &timelines_target, continuation_token.clone()) + .await; + let fetch_response = match fetch_response { + Err(e) => { + timeline_ids.push(Err(e)); + break; + } + Ok(r) => r, + }; + + let new_entry_ids = fetch_response + .common_prefixes() + .unwrap_or_default() + .iter() + .filter_map(|prefix| prefix.prefix()) + .filter_map(|prefix| -> Option<&str> { + prefix + .strip_prefix(&timelines_target.prefix_in_bucket)? + .strip_suffix('/') + }) + .map(|entry_id_str| { + entry_id_str + .parse::() + .with_context(|| format!("Incorrect entry id str: {entry_id_str}")) + }); + + for i in new_entry_ids { + timeline_ids.push(i); + } + + match fetch_response.next_continuation_token { + Some(new_token) => continuation_token = Some(new_token), + None => break, + } + } + + tracing::info!("Yielding for {}", tenant); + Ok(stream! { + for i in timeline_ids { + let id = i?; + yield Ok(TenantTimelineId::new(tenant, id)); + } + }) +} diff --git a/s3_scrubber/src/s3_deletion.rs b/s3_scrubber/src/s3_deletion.rs index 716443790b..a03cc65c89 100644 --- a/s3_scrubber/src/s3_deletion.rs +++ b/s3_scrubber/src/s3_deletion.rs @@ -164,7 +164,7 @@ async fn delete_tenants_batch( s3_target, s3_client, dry_run, - |root_target, tenant_to_delete| root_target.tenant_root(tenant_to_delete), + |root_target, tenant_to_delete| root_target.tenant_root(&tenant_to_delete), ) .await?; @@ -215,7 +215,7 @@ async fn delete_timelines_batch( s3_target, s3_client, dry_run, - |root_target, timeline_to_delete| root_target.timeline_root(timeline_to_delete), + |root_target, timeline_to_delete| root_target.timeline_root(&timeline_to_delete), ) .await?; @@ -386,7 +386,7 @@ async fn ensure_tenant_batch_deleted( for &tenant_id in batch { let fetch_response = - list_objects_with_retries(s3_client, &s3_target.tenant_root(tenant_id), None).await?; + list_objects_with_retries(s3_client, &s3_target.tenant_root(&tenant_id), None).await?; if fetch_response.is_truncated() || fetch_response.contents().is_some() @@ -415,7 +415,7 @@ async fn ensure_timeline_batch_deleted( for &id in batch { let fetch_response = - list_objects_with_retries(s3_client, &s3_target.timeline_root(id), None).await?; + list_objects_with_retries(s3_client, &s3_target.timeline_root(&id), None).await?; if fetch_response.is_truncated() || fetch_response.contents().is_some() diff --git a/s3_scrubber/src/scan_metadata.rs b/s3_scrubber/src/scan_metadata.rs new file mode 100644 index 0000000000..f75d7645a8 --- /dev/null +++ b/s3_scrubber/src/scan_metadata.rs @@ -0,0 +1,234 @@ +use std::collections::{HashMap, HashSet}; +use std::sync::Arc; + +use crate::checks::{ + branch_cleanup_and_check_errors, list_timeline_blobs, BlobDataParseResult, S3TimelineBlobData, + TimelineAnalysis, +}; +use crate::metadata_stream::{stream_tenant_timelines, stream_tenants}; +use crate::{init_logging, init_s3_client, BucketConfig, RootTarget, S3Target, CLI_NAME}; +use aws_sdk_s3::Client; +use aws_types::region::Region; +use futures_util::{pin_mut, StreamExt, TryStreamExt}; +use histogram::Histogram; +use pageserver::tenant::{IndexPart, TENANTS_SEGMENT_NAME}; +use utils::id::TenantTimelineId; + +pub struct MetadataSummary { + count: usize, + with_errors: HashSet, + with_warnings: HashSet, + with_garbage: HashSet, + indices_by_version: HashMap, + + layer_count: MinMaxHisto, + timeline_size_bytes: MinMaxHisto, + layer_size_bytes: MinMaxHisto, +} + +/// A histogram plus minimum and maximum tracking +struct MinMaxHisto { + histo: Histogram, + min: u64, + max: u64, +} + +impl MinMaxHisto { + fn new() -> Self { + Self { + histo: histogram::Histogram::builder() + .build() + .expect("Bad histogram params"), + min: u64::MAX, + max: 0, + } + } + + fn sample(&mut self, v: u64) -> Result<(), histogram::Error> { + self.min = std::cmp::min(self.min, v); + self.max = std::cmp::max(self.max, v); + let r = self.histo.increment(v, 1); + + if r.is_err() { + tracing::warn!("Bad histogram sample: {v}"); + } + + r + } + + fn oneline(&self) -> String { + let percentiles = match self.histo.percentiles(&[1.0, 10.0, 50.0, 90.0, 99.0]) { + Ok(p) => p, + Err(e) => return format!("No data: {}", e), + }; + + let percentiles: Vec = percentiles + .iter() + .map(|p| p.bucket().low() + p.bucket().high() / 2) + .collect(); + + format!( + "min {}, 1% {}, 10% {}, 50% {}, 90% {}, 99% {}, max {}", + self.min, + percentiles[0], + percentiles[1], + percentiles[2], + percentiles[3], + percentiles[4], + self.max, + ) + } +} + +impl MetadataSummary { + fn new() -> Self { + Self { + count: 0, + with_errors: HashSet::new(), + with_warnings: HashSet::new(), + with_garbage: HashSet::new(), + indices_by_version: HashMap::new(), + layer_count: MinMaxHisto::new(), + timeline_size_bytes: MinMaxHisto::new(), + layer_size_bytes: MinMaxHisto::new(), + } + } + + fn update_histograms(&mut self, index_part: &IndexPart) -> Result<(), histogram::Error> { + self.layer_count + .sample(index_part.layer_metadata.len() as u64)?; + let mut total_size: u64 = 0; + for meta in index_part.layer_metadata.values() { + total_size += meta.file_size; + self.layer_size_bytes.sample(meta.file_size)?; + } + self.timeline_size_bytes.sample(total_size)?; + + Ok(()) + } + + fn update_data(&mut self, data: &S3TimelineBlobData) { + self.count += 1; + if let BlobDataParseResult::Parsed { + index_part, + s3_layers: _, + } = &data.blob_data + { + *self + .indices_by_version + .entry(index_part.get_version()) + .or_insert(0) += 1; + + if let Err(e) = self.update_histograms(index_part) { + // Value out of range? Warn that the results are untrustworthy + tracing::warn!( + "Error updating histograms, summary stats may be wrong: {}", + e + ); + } + } + } + + fn update_analysis(&mut self, id: &TenantTimelineId, analysis: &TimelineAnalysis) { + if !analysis.errors.is_empty() { + self.with_errors.insert(*id); + } + + if !analysis.warnings.is_empty() { + self.with_warnings.insert(*id); + } + } + + /// Long-form output for printing at end of a scan + pub fn summary_string(&self) -> String { + let version_summary: String = itertools::join( + self.indices_by_version + .iter() + .map(|(k, v)| format!("{k}: {v}")), + ", ", + ); + + format!( + "Timelines: {0} +With errors: {1} +With warnings: {2} +With garbage: {3} +Index versions: {version_summary} +Timeline size bytes: {4} +Layer size bytes: {5} +Timeline layer count: {6} +", + self.count, + self.with_errors.len(), + self.with_warnings.len(), + self.with_garbage.len(), + self.timeline_size_bytes.oneline(), + self.layer_size_bytes.oneline(), + self.layer_count.oneline(), + ) + } + + pub fn is_fatal(&self) -> bool { + !self.with_errors.is_empty() + } +} + +/// Scan the pageserver metadata in an S3 bucket, reporting errors and statistics. +pub async fn scan_metadata(bucket_config: BucketConfig) -> anyhow::Result { + let file_name = format!( + "{}_scan_metadata_{}_{}.log", + CLI_NAME, + bucket_config.bucket, + chrono::Utc::now().format("%Y_%m_%d__%H_%M_%S") + ); + + let _guard = init_logging(&file_name); + + let s3_client = Arc::new(init_s3_client( + bucket_config.sso_account_id, + Region::new(bucket_config.region), + )); + let delimiter = "/"; + let target = RootTarget::Pageserver(S3Target { + bucket_name: bucket_config.bucket.to_string(), + prefix_in_bucket: ["pageserver", "v1", TENANTS_SEGMENT_NAME, ""].join(delimiter), + delimiter: delimiter.to_string(), + }); + + let tenants = stream_tenants(&s3_client, &target); + + // How many tenants to process in parallel. We need to be mindful of pageservers + // accessing the same per tenant prefixes, so use a lower setting than pageservers. + const CONCURRENCY: usize = 32; + + // Generate a stream of TenantTimelineId + let timelines = tenants.map_ok(|t| stream_tenant_timelines(&s3_client, &target, t)); + let timelines = timelines.try_buffer_unordered(CONCURRENCY); + let timelines = timelines.try_flatten(); + + // Generate a stream of S3TimelineBlobData + async fn report_on_timeline( + s3_client: &Client, + target: &RootTarget, + ttid: TenantTimelineId, + ) -> anyhow::Result<(TenantTimelineId, S3TimelineBlobData)> { + let data = list_timeline_blobs(s3_client, ttid, target).await?; + Ok((ttid, data)) + } + let timelines = timelines.map_ok(|ttid| report_on_timeline(&s3_client, &target, ttid)); + let timelines = timelines.try_buffer_unordered(CONCURRENCY); + + let mut summary = MetadataSummary::new(); + pin_mut!(timelines); + while let Some(i) = timelines.next().await { + let (ttid, data) = i?; + summary.update_data(&data); + + let analysis = + branch_cleanup_and_check_errors(&ttid, &target, None, None, Some(data)).await; + + summary.update_analysis(&ttid, &analysis); + } + + Ok(summary) +} diff --git a/scripts/comment-test-report.js b/scripts/comment-test-report.js index 1410b8a0ca..89befda71f 100755 --- a/scripts/comment-test-report.js +++ b/scripts/comment-test-report.js @@ -18,6 +18,10 @@ // reportUrl: "...", // reportJsonUrl: "...", // }, +// coverage: { +// coverageUrl: "...", +// summaryJsonUrl: "...", +// } // }) // @@ -135,7 +139,7 @@ const reportSummary = async (params) => { // Print test resuls from the newest to the oldest Postgres version for release and debug builds. for (const pgVersion of Array.from(pgVersions).sort().reverse()) { if (Object.keys(failedTests[pgVersion]).length > 0) { - summary += `#### Failures on Posgres ${pgVersion}\n\n` + summary += `#### Failures on Postgres ${pgVersion}\n\n` for (const [testName, tests] of Object.entries(failedTests[pgVersion])) { const links = [] for (const test of tests) { @@ -183,11 +187,40 @@ const reportSummary = async (params) => { return summary } -module.exports = async ({ github, context, fetch, report }) => { - // Marker to find the comment in the subsequent runs - const startMarker = `` +const parseCoverageSummary = async ({ summaryJsonUrl, coverageUrl, fetch }) => { + let summary = `\n### Code coverage ([full report](${coverageUrl}))\n` + + const coverage = await (await fetch(summaryJsonUrl)).json() + for (const covType of Object.keys(coverage).sort()) { + if (!coverage.hasOwnProperty(covType)) { + continue + } + + summary += `- \`${covType}s\`: \`${coverage[covType]["_summary"]}\`\n` + } + + summary += `\n___\n` + + return summary +} + +module.exports = async ({ github, context, fetch, report, coverage }) => { // If we run the script in the PR or in the branch (main/release/...) const isPullRequest = !!context.payload.pull_request + // Which PR to comment (for ci-run/pr-* it will comment the parent PR, not the ci-run/pr-* PR) + let prToComment + if (isPullRequest) { + const branchName = context.payload.pull_request.head.ref.replace(/^refs\/heads\//, "") + const match = branchName.match(/ci-run\/pr-(?\d+)/)?.groups + if (match) { + ({ prNumber } = match) + prToComment = parseInt(prNumber, 10) + } else { + prToComment = context.payload.number + } + } + // Marker to find the comment in the subsequent runs + const startMarker = `` // Latest commit in PR or in the branch const commitSha = isPullRequest ? context.payload.pull_request.head.sha : context.sha // Let users know that the comment is updated automatically @@ -204,7 +237,6 @@ module.exports = async ({ github, context, fetch, report }) => { } const {reportUrl, reportJsonUrl} = report - if (reportUrl && reportJsonUrl) { try { const parsed = await parseReportJson({ reportJsonUrl, fetch }) @@ -223,6 +255,22 @@ module.exports = async ({ github, context, fetch, report }) => { } else { commentBody += `#### No tests were run or test report is not available\n` } + + const { coverageUrl, summaryJsonUrl } = coverage + if (coverageUrl && summaryJsonUrl) { + try { + commentBody += await parseCoverageSummary({ summaryJsonUrl, coverageUrl, fetch }) + } catch (error) { + commentBody += `### [full report](${coverageUrl})\n___\n` + commentBody += `#### Failed to create a coverage summary for the test run: \n` + commentBody += "```\n" + commentBody += `${error.stack}\n` + commentBody += "```\n" + } + } else { + commentBody += `\n#### Test coverage report is not available\n` + } + commentBody += autoupdateNotice let createCommentFn, listCommentsFn, updateCommentFn, issueNumberOrSha @@ -231,7 +279,7 @@ module.exports = async ({ github, context, fetch, report }) => { listCommentsFn = github.rest.issues.listComments updateCommentFn = github.rest.issues.updateComment issueNumberOrSha = { - issue_number: context.payload.number, + issue_number: prToComment, } } else { updateCommentFn = github.rest.repos.updateCommitComment diff --git a/test_runner/fixtures/compare_fixtures.py b/test_runner/fixtures/compare_fixtures.py index a10ef70aa2..1254c4e779 100644 --- a/test_runner/fixtures/compare_fixtures.py +++ b/test_runner/fixtures/compare_fixtures.py @@ -14,6 +14,7 @@ from fixtures.neon_fixtures import ( PgProtocol, RemotePostgres, VanillaPostgres, + wait_for_last_flush_lsn, ) from fixtures.pg_stats import PgStatTable @@ -129,6 +130,7 @@ class NeonCompare(PgCompare): return self._pg_bin def flush(self): + wait_for_last_flush_lsn(self.env, self._pg, self.tenant, self.timeline) self.pageserver_http_client.timeline_checkpoint(self.tenant, self.timeline) self.pageserver_http_client.timeline_gc(self.tenant, self.timeline, 0) diff --git a/test_runner/fixtures/neon_fixtures.py b/test_runner/fixtures/neon_fixtures.py index 3936736e56..272c32fdcd 100644 --- a/test_runner/fixtures/neon_fixtures.py +++ b/test_runner/fixtures/neon_fixtures.py @@ -24,7 +24,6 @@ from urllib.parse import urlparse import asyncpg import backoff -import boto3 import jwt import psycopg2 import pytest @@ -32,7 +31,6 @@ import requests from _pytest.config import Config from _pytest.config.argparsing import Parser from _pytest.fixtures import FixtureRequest -from mypy_boto3_s3 import S3Client # Type-related stuff from psycopg2.extensions import connection as PgConnection @@ -47,11 +45,10 @@ from fixtures.pageserver.utils import wait_for_last_record_lsn, wait_for_upload from fixtures.pg_version import PgVersion from fixtures.port_distributor import PortDistributor from fixtures.remote_storage import ( - LocalFsStorage, MockS3Server, RemoteStorage, RemoteStorageKind, - RemoteStorageUsers, + RemoteStorageUser, S3Storage, remote_storage_to_toml_inline_table, ) @@ -415,10 +412,11 @@ class NeonEnvBuilder: pg_distrib_dir: Path, pg_version: PgVersion, test_name: str, - remote_storage: Optional[RemoteStorage] = None, - remote_storage_users: RemoteStorageUsers = RemoteStorageUsers.PAGESERVER, + test_output_dir: Path, + pageserver_remote_storage: Optional[RemoteStorage] = None, pageserver_config_override: Optional[str] = None, num_safekeepers: int = 1, + num_pageservers: int = 1, # Use non-standard SK ids to check for various parsing bugs safekeepers_id_start: int = 0, # fsync is disabled by default to make the tests go faster @@ -433,21 +431,25 @@ class NeonEnvBuilder: self.repo_dir = repo_dir self.rust_log_override = rust_log_override self.port_distributor = port_distributor - self.remote_storage = remote_storage + + # Pageserver remote storage + self.pageserver_remote_storage = pageserver_remote_storage + # Extensions remote storage self.ext_remote_storage: Optional[S3Storage] = None - self.remote_storage_client: Optional[S3Client] = None - self.remote_storage_users = remote_storage_users + # Safekeepers remote storage + self.sk_remote_storage: Optional[RemoteStorage] = None + self.broker = broker self.run_id = run_id self.mock_s3_server: MockS3Server = mock_s3_server self.pageserver_config_override = pageserver_config_override self.num_safekeepers = num_safekeepers + self.num_pageservers = num_pageservers self.safekeepers_id_start = safekeepers_id_start self.safekeepers_enable_fsync = safekeepers_enable_fsync self.auth_enabled = auth_enabled self.default_branch_name = default_branch_name self.env: Optional[NeonEnv] = None - self.remote_storage_prefix: Optional[str] = None self.keep_remote_storage_contents: bool = True self.neon_binpath = neon_binpath self.pg_distrib_dir = pg_distrib_dir @@ -455,6 +457,9 @@ class NeonEnvBuilder: self.preserve_database_files = preserve_database_files self.initial_tenant = initial_tenant or TenantId.generate() self.initial_timeline = initial_timeline or TimelineId.generate() + self.enable_generations = False + self.scrub_on_exit = False + self.test_output_dir = test_output_dir assert test_name.startswith( "test_" @@ -489,141 +494,83 @@ class NeonEnvBuilder: return env - def enable_remote_storage( + def enable_scrub_on_exit(self): + """ + Call this if you would like the fixture to automatically run + s3_scrubber at the end of the test, as a bidirectional test + that the scrubber is working properly, and that the code within + the test didn't produce any invalid remote state. + """ + + if not isinstance(self.pageserver_remote_storage, S3Storage): + # The scrubber can't talk to e.g. LocalFS -- it needs + # an HTTP endpoint (mock is fine) to connect to. + raise RuntimeError( + "Cannot scrub with remote_storage={self.pageserver_remote_storage}, require an S3 endpoint" + ) + + self.scrub_on_exit = True + + def enable_pageserver_remote_storage( self, remote_storage_kind: RemoteStorageKind, - force_enable: bool = True, - enable_remote_extensions: bool = False, ): - bucket_name = re.sub(r"[_\[\]]", "-", self.test_name)[:63] + assert self.pageserver_remote_storage is None, "remote storage is enabled already" + ret = self._configure_and_create_remote_storage( + remote_storage_kind, RemoteStorageUser.PAGESERVER + ) + self.pageserver_remote_storage = ret - if remote_storage_kind == RemoteStorageKind.NOOP: - return - elif remote_storage_kind == RemoteStorageKind.LOCAL_FS: - self.enable_local_fs_remote_storage(force_enable=force_enable) - elif remote_storage_kind == RemoteStorageKind.MOCK_S3: - self.enable_mock_s3_remote_storage( - bucket_name=bucket_name, - force_enable=force_enable, - enable_remote_extensions=enable_remote_extensions, - ) - elif remote_storage_kind == RemoteStorageKind.REAL_S3: - self.enable_real_s3_remote_storage( - test_name=bucket_name, - force_enable=force_enable, - enable_remote_extensions=enable_remote_extensions, - ) - else: - raise RuntimeError(f"Unknown storage type: {remote_storage_kind}") + def enable_extensions_remote_storage(self, kind: RemoteStorageKind): + assert self.ext_remote_storage is None, "already configured extensions remote storage" - self.remote_storage_kind = remote_storage_kind + # there is an assumption that REAL_S3 for extensions is never + # cleaned up these are also special in that they have a hardcoded + # bucket and region, which is most likely the same as our normal + ext = self._configure_and_create_remote_storage( + kind, + RemoteStorageUser.EXTENSIONS, + bucket_name="neon-dev-extensions-eu-central-1", + bucket_region="eu-central-1", + ) + assert isinstance( + ext, S3Storage + ), "unsure why, but only MOCK_S3 and REAL_S3 are currently supported for extensions" + ext.cleanup = False + self.ext_remote_storage = ext - def enable_local_fs_remote_storage(self, force_enable: bool = True): - """ - Sets up the pageserver to use the local fs at the `test_dir/local_fs_remote_storage` path. - Errors, if the pageserver has some remote storage configuration already, unless `force_enable` is not set to `True`. - """ - assert force_enable or self.remote_storage is None, "remote storage is enabled already" - self.remote_storage = LocalFsStorage(Path(self.repo_dir / "local_fs_remote_storage")) + def enable_safekeeper_remote_storage(self, kind: RemoteStorageKind): + assert self.sk_remote_storage is None, "sk_remote_storage already configured" - def enable_mock_s3_remote_storage( + self.sk_remote_storage = self._configure_and_create_remote_storage( + kind, RemoteStorageUser.SAFEKEEPER + ) + + def _configure_and_create_remote_storage( self, - bucket_name: str, - force_enable: bool = True, - enable_remote_extensions: bool = False, - ): - """ - Sets up the pageserver to use the S3 mock server, creates the bucket, if it's not present already. - Starts up the mock server, if that does not run yet. - Errors, if the pageserver has some remote storage configuration already, unless `force_enable` is not set to `True`. - - Also creates the bucket for extensions, self.ext_remote_storage bucket - """ - assert force_enable or self.remote_storage is None, "remote storage is enabled already" - mock_endpoint = self.mock_s3_server.endpoint() - mock_region = self.mock_s3_server.region() - - self.remote_storage_client = boto3.client( - "s3", - endpoint_url=mock_endpoint, - region_name=mock_region, - aws_access_key_id=self.mock_s3_server.access_key(), - aws_secret_access_key=self.mock_s3_server.secret_key(), - ) - self.remote_storage_client.create_bucket(Bucket=bucket_name) - - self.remote_storage = S3Storage( + kind: RemoteStorageKind, + user: RemoteStorageUser, + bucket_name: Optional[str] = None, + bucket_region: Optional[str] = None, + ) -> Optional[RemoteStorage]: + ret = kind.configure( + self.repo_dir, + self.mock_s3_server, + str(self.run_id), + self.test_name, + user, bucket_name=bucket_name, - endpoint=mock_endpoint, - bucket_region=mock_region, - access_key=self.mock_s3_server.access_key(), - secret_key=self.mock_s3_server.secret_key(), - prefix_in_bucket="pageserver", + bucket_region=bucket_region, ) - if enable_remote_extensions: - self.ext_remote_storage = S3Storage( - bucket_name=bucket_name, - endpoint=mock_endpoint, - bucket_region=mock_region, - access_key=self.mock_s3_server.access_key(), - secret_key=self.mock_s3_server.secret_key(), - prefix_in_bucket="ext", - ) + if kind == RemoteStorageKind.MOCK_S3: + assert isinstance(ret, S3Storage) + ret.client.create_bucket(Bucket=ret.bucket_name) + elif kind == RemoteStorageKind.REAL_S3: + assert isinstance(ret, S3Storage) + assert ret.cleanup, "we should not leave files in REAL_S3" - def enable_real_s3_remote_storage( - self, - test_name: str, - force_enable: bool = True, - enable_remote_extensions: bool = False, - ): - """ - Sets up configuration to use real s3 endpoint without mock server - """ - assert force_enable or self.remote_storage is None, "remote storage is enabled already" - - access_key = os.getenv("AWS_ACCESS_KEY_ID") - assert access_key, "no aws access key provided" - secret_key = os.getenv("AWS_SECRET_ACCESS_KEY") - assert secret_key, "no aws access key provided" - - # session token is needed for local runs with sso auth - session_token = os.getenv("AWS_SESSION_TOKEN") - - bucket_name = os.getenv("REMOTE_STORAGE_S3_BUCKET") - assert bucket_name, "no remote storage bucket name provided" - region = os.getenv("REMOTE_STORAGE_S3_REGION") - assert region, "no remote storage region provided" - - # do not leave data in real s3 - self.keep_remote_storage_contents = False - - # construct a prefix inside bucket for the particular test case and test run - self.remote_storage_prefix = f"{self.run_id}/{test_name}" - - self.remote_storage_client = boto3.client( - "s3", - region_name=region, - aws_access_key_id=access_key, - aws_secret_access_key=secret_key, - aws_session_token=session_token, - ) - self.remote_storage = S3Storage( - bucket_name=bucket_name, - bucket_region=region, - access_key=access_key, - secret_key=secret_key, - prefix_in_bucket=self.remote_storage_prefix, - ) - - if enable_remote_extensions: - self.ext_remote_storage = S3Storage( - bucket_name="neon-dev-extensions-eu-central-1", - bucket_region="eu-central-1", - access_key=access_key, - secret_key=secret_key, - prefix_in_bucket=None, - ) + return ret def cleanup_local_storage(self): if self.preserve_database_files: @@ -648,60 +595,10 @@ class NeonEnvBuilder: directory_to_clean.rmdir() def cleanup_remote_storage(self): - # here wee check for true remote storage, no the local one - # local cleanup is not needed after test because in ci all env will be destroyed anyway - if self.remote_storage_prefix is None: - log.info("no remote storage was set up, skipping cleanup") - return - - # Making mypy happy with allowing only `S3Storage` further. - # `self.remote_storage_prefix` is coupled with `S3Storage` storage type, - # so this line effectively a no-op - assert isinstance(self.remote_storage, S3Storage) - assert self.remote_storage_client is not None - - if self.keep_remote_storage_contents: - log.info("keep_remote_storage_contents skipping remote storage cleanup") - return - - log.info( - "removing data from test s3 bucket %s by prefix %s", - self.remote_storage.bucket_name, - self.remote_storage_prefix, - ) - paginator = self.remote_storage_client.get_paginator("list_objects_v2") - pages = paginator.paginate( - Bucket=self.remote_storage.bucket_name, - Prefix=self.remote_storage_prefix, - ) - - # Using Any because DeleteTypeDef (from boto3-stubs) doesn't fit our case - objects_to_delete: Any = {"Objects": []} - cnt = 0 - for item in pages.search("Contents"): - # weirdly when nothing is found it returns [None] - if item is None: - break - - objects_to_delete["Objects"].append({"Key": item["Key"]}) - - # flush once aws limit reached - if len(objects_to_delete["Objects"]) >= 1000: - self.remote_storage_client.delete_objects( - Bucket=self.remote_storage.bucket_name, - Delete=objects_to_delete, - ) - objects_to_delete = {"Objects": []} - cnt += 1 - - # flush rest - if len(objects_to_delete["Objects"]): - self.remote_storage_client.delete_objects( - Bucket=self.remote_storage.bucket_name, - Delete=objects_to_delete, - ) - - log.info(f"deleted {cnt} objects from remote storage") + # extensions are currently not cleaned up, disabled when creating + for x in [self.pageserver_remote_storage, self.ext_remote_storage, self.sk_remote_storage]: + if isinstance(x, S3Storage): + x.do_cleanup() def __enter__(self) -> "NeonEnvBuilder": return self @@ -718,14 +615,28 @@ class NeonEnvBuilder: self.env.endpoints.stop_all() for sk in self.env.safekeepers: sk.stop(immediate=True) - self.env.pageserver.stop(immediate=True) + + for pageserver in self.env.pageservers: + pageserver.stop(immediate=True) + + if self.env.attachment_service is not None: + self.env.attachment_service.stop(immediate=True) cleanup_error = None + + if self.scrub_on_exit: + try: + S3Scrubber(self.test_output_dir, self).scan_metadata() + except Exception as e: + log.error(f"Error during remote storage scrub: {e}") + cleanup_error = e + try: self.cleanup_remote_storage() except Exception as e: log.error(f"Error during remote storage cleanup: {e}") - cleanup_error = e + if cleanup_error is not None: + cleanup_error = e try: self.cleanup_local_storage() @@ -737,7 +648,8 @@ class NeonEnvBuilder: if cleanup_error is not None: raise cleanup_error - self.env.pageserver.assert_no_errors() + for pageserver in self.env.pageservers: + pageserver.assert_no_errors() class NeonEnv: @@ -757,8 +669,7 @@ class NeonEnv: postgres - A factory object for creating postgres compute nodes. - pageserver - An object that contains functions for manipulating and - connecting to the pageserver + pageservers - An array containing objects representing the pageservers safekeepers - An array containing objects representing the safekeepers @@ -773,6 +684,8 @@ class NeonEnv: the tenant id """ + BASE_PAGESERVER_ID = 1 + def __init__(self, config: NeonEnvBuilder): self.repo_dir = config.repo_dir self.rust_log_override = config.rust_log_override @@ -781,21 +694,34 @@ class NeonEnv: self.neon_cli = NeonCli(env=self) self.endpoints = EndpointFactory(self) self.safekeepers: List[Safekeeper] = [] + self.pageservers: List[NeonPageserver] = [] self.broker = config.broker - self.remote_storage = config.remote_storage - self.remote_storage_users = config.remote_storage_users + self.pageserver_remote_storage = config.pageserver_remote_storage + self.ext_remote_storage = config.ext_remote_storage + self.safekeepers_remote_storage = config.sk_remote_storage self.pg_version = config.pg_version + # Binary path for pageserver, safekeeper, etc self.neon_binpath = config.neon_binpath + # Binary path for neon_local test-specific binaries: may be overridden + # after construction for compat testing + self.neon_local_binpath = config.neon_binpath self.pg_distrib_dir = config.pg_distrib_dir self.endpoint_counter = 0 - self.remote_storage_client = config.remote_storage_client - self.ext_remote_storage = config.ext_remote_storage + self.pageserver_config_override = config.pageserver_config_override # generate initial tenant ID here instead of letting 'neon init' generate it, # so that we don't need to dig it out of the config file afterwards. self.initial_tenant = config.initial_tenant self.initial_timeline = config.initial_timeline + if config.enable_generations: + attachment_service_port = self.port_distributor.get_port() + self.control_plane_api: Optional[str] = f"http://127.0.0.1:{attachment_service_port}" + self.attachment_service: Optional[NeonAttachmentService] = NeonAttachmentService(self) + else: + self.control_plane_api = None + self.attachment_service = None + # Create a config file corresponding to the options toml = textwrap.dedent( f""" @@ -803,6 +729,13 @@ class NeonEnv: """ ) + if self.control_plane_api is not None: + toml += textwrap.dedent( + f""" + control_plane_api = '{self.control_plane_api}' + """ + ) + toml += textwrap.dedent( f""" [broker] @@ -811,29 +744,36 @@ class NeonEnv: ) # Create config for pageserver - pageserver_port = PageserverPort( - pg=self.port_distributor.get_port(), - http=self.port_distributor.get_port(), - ) http_auth_type = "NeonJWT" if config.auth_enabled else "Trust" pg_auth_type = "NeonJWT" if config.auth_enabled else "Trust" + for ps_id in range( + self.BASE_PAGESERVER_ID, self.BASE_PAGESERVER_ID + config.num_pageservers + ): + pageserver_port = PageserverPort( + pg=self.port_distributor.get_port(), + http=self.port_distributor.get_port(), + ) - toml += textwrap.dedent( - f""" - [pageserver] - id=1 - listen_pg_addr = 'localhost:{pageserver_port.pg}' - listen_http_addr = 'localhost:{pageserver_port.http}' - pg_auth_type = '{pg_auth_type}' - http_auth_type = '{http_auth_type}' - """ - ) - - # Create a corresponding NeonPageserver object - self.pageserver = NeonPageserver( - self, port=pageserver_port, config_override=config.pageserver_config_override - ) + toml += textwrap.dedent( + f""" + [[pageservers]] + id={ps_id} + listen_pg_addr = 'localhost:{pageserver_port.pg}' + listen_http_addr = 'localhost:{pageserver_port.http}' + pg_auth_type = '{pg_auth_type}' + http_auth_type = '{http_auth_type}' + """ + ) + # Create a corresponding NeonPageserver object + self.pageservers.append( + NeonPageserver( + self, + ps_id, + port=pageserver_port, + config_override=config.pageserver_config_override, + ) + ) # Create config and a Safekeeper object for each safekeeper for i in range(1, config.num_safekeepers + 1): port = SafekeeperPort( @@ -857,13 +797,10 @@ class NeonEnv: auth_enabled = true """ ) - if ( - bool(self.remote_storage_users & RemoteStorageUsers.SAFEKEEPER) - and self.remote_storage is not None - ): + if config.sk_remote_storage is not None: toml += textwrap.dedent( f""" - remote_storage = "{remote_storage_to_toml_inline_table(self.remote_storage)}" + remote_storage = "{remote_storage_to_toml_inline_table(config.sk_remote_storage)}" """ ) safekeeper = Safekeeper(env=self, id=id, port=port) @@ -875,25 +812,58 @@ class NeonEnv: def start(self): # Start up broker, pageserver and all safekeepers self.broker.try_start() - self.pageserver.start() + + if self.attachment_service is not None: + self.attachment_service.start() + + for pageserver in self.pageservers: + pageserver.start() for safekeeper in self.safekeepers: safekeeper.start() + @property + def pageserver(self) -> NeonPageserver: + """ + For tests that are naive to multiple pageservers: give them the 1st in the list, and + assert that there is only one. Tests with multiple pageservers should always use + get_pageserver with an explicit ID. + """ + assert len(self.pageservers) == 1 + return self.pageservers[0] + + def get_pageserver(self, id: Optional[int]) -> NeonPageserver: + """ + Look up a pageserver by its node ID. + + As a convenience for tests that do not use multiple pageservers, passing None + will yield the same default pageserver as `self.pageserver`. + """ + + if id is None: + return self.pageserver + + for ps in self.pageservers: + if ps.id == id: + return ps + + raise RuntimeError(f"Pageserver with ID {id} not found") + def get_safekeeper_connstrs(self) -> str: """Get list of safekeeper endpoints suitable for safekeepers GUC""" return ",".join(f"localhost:{wa.port.pg}" for wa in self.safekeepers) - def timeline_dir(self, tenant_id: TenantId, timeline_id: TimelineId) -> Path: - """Get a timeline directory's path based on the repo directory of the test environment""" - return self.tenant_dir(tenant_id) / "timelines" / str(timeline_id) - - def tenant_dir( - self, - tenant_id: TenantId, + def timeline_dir( + self, tenant_id: TenantId, timeline_id: TimelineId, pageserver_id: Optional[int] = None ) -> Path: + """Get a timeline directory's path based on the repo directory of the test environment""" + return ( + self.tenant_dir(tenant_id, pageserver_id=pageserver_id) / "timelines" / str(timeline_id) + ) + + def tenant_dir(self, tenant_id: TenantId, pageserver_id: Optional[int] = None) -> Path: """Get a tenant directory's path based on the repo directory of the test environment""" - return self.repo_dir / "tenants" / str(tenant_id) + return self.get_pageserver(pageserver_id).workdir / "tenants" / str(tenant_id) def get_pageserver_version(self) -> str: bin_pageserver = str(self.neon_binpath / "pageserver") @@ -929,6 +899,7 @@ def _shared_simple_env( default_broker: NeonBroker, run_id: uuid.UUID, top_output_dir: Path, + test_output_dir: Path, neon_binpath: Path, pg_distrib_dir: Path, pg_version: PgVersion, @@ -957,6 +928,7 @@ def _shared_simple_env( run_id=run_id, preserve_database_files=pytestconfig.getoption("--preserve-database-files"), test_name=request.node.name, + test_output_dir=test_output_dir, ) as builder: env = builder.init_start() @@ -984,7 +956,7 @@ def neon_simple_env(_shared_simple_env: NeonEnv) -> Iterator[NeonEnv]: @pytest.fixture(scope="function") def neon_env_builder( pytestconfig: Config, - test_output_dir: str, + test_output_dir: Path, port_distributor: PortDistributor, mock_s3_server: MockS3Server, neon_binpath: Path, @@ -1022,6 +994,7 @@ def neon_env_builder( run_id=run_id, preserve_database_files=pytestconfig.getoption("--preserve-database-files"), test_name=request.node.name, + test_output_dir=test_output_dir, ) as builder: yield builder @@ -1058,6 +1031,7 @@ class AbstractNeonCli(abc.ABC): extra_env_vars: Optional[Dict[str, str]] = None, check_return_code=True, timeout=None, + local_binpath=False, ) -> "subprocess.CompletedProcess[str]": """ Run the command with the specified arguments. @@ -1071,12 +1045,19 @@ class AbstractNeonCli(abc.ABC): >>> log.info(result.stdout) If `check_return_code`, on non-zero exit code logs failure and raises. + + If `local_binpath` is true, then we are invoking a test utility """ assert type(arguments) == list assert type(self.COMMAND) == str - bin_neon = str(self.env.neon_binpath / self.COMMAND) + if local_binpath: + # Test utility + bin_neon = str(self.env.neon_local_binpath / self.COMMAND) + else: + # Normal binary + bin_neon = str(self.env.neon_binpath / self.COMMAND) args = [bin_neon] + arguments log.info('Running command "{}"'.format(" ".join(args))) @@ -1130,6 +1111,10 @@ class NeonCli(AbstractNeonCli): COMMAND = "neon_local" + def raw_cli(self, *args, **kwargs) -> subprocess.CompletedProcess[str]: + kwargs["local_binpath"] = True + return super().raw_cli(*args, **kwargs) + def create_tenant( self, tenant_id: Optional[TenantId] = None, @@ -1283,43 +1268,53 @@ class NeonCli(AbstractNeonCli): cmd = ["init", f"--config={tmp.name}", "--pg-version", self.env.pg_version] + storage = self.env.pageserver_remote_storage + append_pageserver_param_overrides( params_to_update=cmd, - remote_storage=self.env.remote_storage, - remote_storage_users=self.env.remote_storage_users, - pageserver_config_override=self.env.pageserver.config_override, + remote_storage=storage, + pageserver_config_override=self.env.pageserver_config_override, ) s3_env_vars = None - if self.env.remote_storage is not None and isinstance( - self.env.remote_storage, S3Storage - ): - s3_env_vars = self.env.remote_storage.access_env_vars() + if isinstance(storage, S3Storage): + s3_env_vars = storage.access_env_vars() res = self.raw_cli(cmd, extra_env_vars=s3_env_vars) res.check_returncode() return res + def attachment_service_start(self): + cmd = ["attachment_service", "start"] + return self.raw_cli(cmd) + + def attachment_service_stop(self, immediate: bool): + cmd = ["attachment_service", "stop"] + if immediate: + cmd.extend(["-m", "immediate"]) + return self.raw_cli(cmd) + def pageserver_start( self, + id: int, overrides: Tuple[str, ...] = (), extra_env_vars: Optional[Dict[str, str]] = None, ) -> "subprocess.CompletedProcess[str]": - start_args = ["pageserver", "start", *overrides] + start_args = ["pageserver", "start", f"--id={id}", *overrides] + storage = self.env.pageserver_remote_storage append_pageserver_param_overrides( params_to_update=start_args, - remote_storage=self.env.remote_storage, - remote_storage_users=self.env.remote_storage_users, - pageserver_config_override=self.env.pageserver.config_override, + remote_storage=storage, + pageserver_config_override=self.env.pageserver_config_override, ) - if self.env.remote_storage is not None and isinstance(self.env.remote_storage, S3Storage): - s3_env_vars = self.env.remote_storage.access_env_vars() + if isinstance(storage, S3Storage): + s3_env_vars = storage.access_env_vars() extra_env_vars = (extra_env_vars or {}) | s3_env_vars return self.raw_cli(start_args, extra_env_vars=extra_env_vars) - def pageserver_stop(self, immediate=False) -> "subprocess.CompletedProcess[str]": - cmd = ["pageserver", "stop"] + def pageserver_stop(self, id: int, immediate=False) -> "subprocess.CompletedProcess[str]": + cmd = ["pageserver", "stop", f"--id={id}"] if immediate: cmd.extend(["-m", "immediate"]) @@ -1330,8 +1325,8 @@ class NeonCli(AbstractNeonCli): self, id: int, extra_opts: Optional[List[str]] = None ) -> "subprocess.CompletedProcess[str]": s3_env_vars = None - if self.env.remote_storage is not None and isinstance(self.env.remote_storage, S3Storage): - s3_env_vars = self.env.remote_storage.access_env_vars() + if isinstance(self.env.safekeepers_remote_storage, S3Storage): + s3_env_vars = self.env.safekeepers_remote_storage.access_env_vars() if extra_opts is not None: extra_opts = [f"-e={opt}" for opt in extra_opts] @@ -1360,6 +1355,7 @@ class NeonCli(AbstractNeonCli): tenant_id: Optional[TenantId] = None, hot_standby: bool = False, lsn: Optional[Lsn] = None, + pageserver_id: Optional[int] = None, ) -> "subprocess.CompletedProcess[str]": args = [ "endpoint", @@ -1381,6 +1377,8 @@ class NeonCli(AbstractNeonCli): args.append(endpoint_id) if hot_standby: args.extend(["--hot-standby", "true"]) + if pageserver_id is not None: + args.extend(["--pageserver-id", str(pageserver_id)]) res = self.raw_cli(args) res.check_returncode() @@ -1396,6 +1394,7 @@ class NeonCli(AbstractNeonCli): lsn: Optional[Lsn] = None, branch_name: Optional[str] = None, remote_ext_config: Optional[str] = None, + pageserver_id: Optional[int] = None, ) -> "subprocess.CompletedProcess[str]": args = [ "endpoint", @@ -1418,10 +1417,13 @@ class NeonCli(AbstractNeonCli): args.extend(["--branch-name", branch_name]) if endpoint_id is not None: args.append(endpoint_id) + if pageserver_id is not None: + args.extend(["--pageserver-id", str(pageserver_id)]) + storage = self.env.ext_remote_storage s3_env_vars = None - if self.env.remote_storage is not None and isinstance(self.env.remote_storage, S3Storage): - s3_env_vars = self.env.remote_storage.access_env_vars() + if isinstance(storage, S3Storage): + s3_env_vars = storage.access_env_vars() res = self.raw_cli(args, extra_env_vars=s3_env_vars) res.check_returncode() @@ -1480,6 +1482,35 @@ class ComputeCtl(AbstractNeonCli): COMMAND = "compute_ctl" +class NeonAttachmentService: + def __init__(self, env: NeonEnv): + self.env = env + self.running = False + + def start(self): + assert not self.running + self.env.neon_cli.attachment_service_start() + self.running = True + return self + + def stop(self, immediate: bool = False) -> "NeonAttachmentService": + if self.running: + self.env.neon_cli.attachment_service_stop(immediate) + self.running = False + return self + + def __enter__(self) -> "NeonAttachmentService": + return self + + def __exit__( + self, + exc_type: Optional[Type[BaseException]], + exc: Optional[BaseException], + tb: Optional[TracebackType], + ): + self.stop(immediate=True) + + class NeonPageserver(PgProtocol): """ An object representing a running pageserver. @@ -1487,9 +1518,12 @@ class NeonPageserver(PgProtocol): TEMP_FILE_SUFFIX = "___temp" - def __init__(self, env: NeonEnv, port: PageserverPort, config_override: Optional[str] = None): + def __init__( + self, env: NeonEnv, id: int, port: PageserverPort, config_override: Optional[str] = None + ): super().__init__(host="localhost", port=port.pg, user="cloud_admin") self.env = env + self.id = id self.running = False self.service_port = port self.config_override = config_override @@ -1563,7 +1597,9 @@ class NeonPageserver(PgProtocol): """ assert self.running is False - self.env.neon_cli.pageserver_start(overrides=overrides, extra_env_vars=extra_env_vars) + self.env.neon_cli.pageserver_start( + self.id, overrides=overrides, extra_env_vars=extra_env_vars + ) self.running = True return self @@ -1573,7 +1609,7 @@ class NeonPageserver(PgProtocol): Returns self. """ if self.running: - self.env.neon_cli.pageserver_stop(immediate) + self.env.neon_cli.pageserver_stop(self.id, immediate) self.running = False return self @@ -1599,8 +1635,12 @@ class NeonPageserver(PgProtocol): is_testing_enabled_or_skip=self.is_testing_enabled_or_skip, ) + @property + def workdir(self) -> Path: + return Path(os.path.join(self.env.repo_dir, f"pageserver_{self.id}")) + def assert_no_errors(self): - logfile = open(os.path.join(self.env.repo_dir, "pageserver.log"), "r") + logfile = open(os.path.join(self.workdir, "pageserver.log"), "r") error_or_warn = re.compile(r"\s(ERROR|WARN)") errors = [] while True: @@ -1623,7 +1663,7 @@ class NeonPageserver(PgProtocol): def log_contains(self, pattern: str) -> Optional[str]: """Check that the pageserver log contains a line that matches the given regex""" - logfile = open(os.path.join(self.env.repo_dir, "pageserver.log"), "r") + logfile = open(os.path.join(self.workdir, "pageserver.log"), "r") contains_re = re.compile(pattern) @@ -1643,14 +1683,33 @@ class NeonPageserver(PgProtocol): return None + def tenant_attach( + self, tenant_id: TenantId, config: None | Dict[str, Any] = None, config_null: bool = False + ): + """ + Tenant attachment passes through here to acquire a generation number before proceeding + to call into the pageserver HTTP client. + """ + if self.env.attachment_service is not None: + response = requests.post( + f"{self.env.control_plane_api}/attach_hook", + json={"tenant_id": str(tenant_id), "pageserver_id": self.id}, + ) + response.raise_for_status() + generation = response.json()["gen"] + else: + generation = None + + client = self.http_client() + return client.tenant_attach(tenant_id, config, config_null, generation=generation) + def append_pageserver_param_overrides( params_to_update: List[str], remote_storage: Optional[RemoteStorage], - remote_storage_users: RemoteStorageUsers, pageserver_config_override: Optional[str] = None, ): - if bool(remote_storage_users & RemoteStorageUsers.PAGESERVER) and remote_storage is not None: + if remote_storage is not None: remote_storage_toml_table = remote_storage_to_toml_inline_table(remote_storage) params_to_update.append( @@ -1728,7 +1787,10 @@ class PgBin: self._fixpath(command) log.info(f"Running command '{' '.join(command)}'") env = self._build_env(env) - return subprocess_capture(self.log_dir, command, env=env, cwd=cwd, check=True, **kwargs) + base_path, _, _ = subprocess_capture( + self.log_dir, command, env=env, cwd=cwd, check=True, **kwargs + ) + return base_path @pytest.fixture(scope="function") @@ -2249,6 +2311,7 @@ class Endpoint(PgProtocol): hot_standby: bool = False, lsn: Optional[Lsn] = None, config_lines: Optional[List[str]] = None, + pageserver_id: Optional[int] = None, ) -> "Endpoint": """ Create a new Postgres endpoint. @@ -2270,6 +2333,7 @@ class Endpoint(PgProtocol): hot_standby=hot_standby, pg_port=self.pg_port, http_port=self.http_port, + pageserver_id=pageserver_id, ) path = Path("endpoints") / self.endpoint_id / "pgdata" self.pgdata_dir = os.path.join(self.env.repo_dir, path) @@ -2283,7 +2347,9 @@ class Endpoint(PgProtocol): return self - def start(self, remote_ext_config: Optional[str] = None) -> "Endpoint": + def start( + self, remote_ext_config: Optional[str] = None, pageserver_id: Optional[int] = None + ) -> "Endpoint": """ Start the Postgres instance. Returns self. @@ -2300,6 +2366,7 @@ class Endpoint(PgProtocol): tenant_id=self.tenant_id, safekeepers=self.active_safekeepers, remote_ext_config=remote_ext_config, + pageserver_id=pageserver_id, ) self.running = True @@ -2390,6 +2457,7 @@ class Endpoint(PgProtocol): lsn: Optional[Lsn] = None, config_lines: Optional[List[str]] = None, remote_ext_config: Optional[str] = None, + pageserver_id: Optional[int] = None, ) -> "Endpoint": """ Create an endpoint, apply config, and start Postgres. @@ -2404,6 +2472,7 @@ class Endpoint(PgProtocol): config_lines=config_lines, hot_standby=hot_standby, lsn=lsn, + pageserver_id=pageserver_id, ).start(remote_ext_config=remote_ext_config) log.info(f"Postgres startup took {time.time() - started_at} seconds") @@ -2439,6 +2508,7 @@ class EndpointFactory: hot_standby: bool = False, config_lines: Optional[List[str]] = None, remote_ext_config: Optional[str] = None, + pageserver_id: Optional[int] = None, ) -> Endpoint: ep = Endpoint( self.env, @@ -2456,6 +2526,7 @@ class EndpointFactory: config_lines=config_lines, lsn=lsn, remote_ext_config=remote_ext_config, + pageserver_id=pageserver_id, ) def create( @@ -2734,6 +2805,41 @@ class SafekeeperHttpClient(requests.Session): return metrics +class S3Scrubber: + def __init__(self, log_dir: Path, env: NeonEnvBuilder): + self.env = env + self.log_dir = log_dir + + def scrubber_cli(self, args, timeout): + assert isinstance(self.env.pageserver_remote_storage, S3Storage) + s3_storage = self.env.pageserver_remote_storage + + env = { + "REGION": s3_storage.bucket_region, + "BUCKET": s3_storage.bucket_name, + } + env.update(s3_storage.access_env_vars()) + + if s3_storage.endpoint is not None: + env.update({"AWS_ENDPOINT_URL": s3_storage.endpoint}) + + base_args = [self.env.neon_binpath / "s3_scrubber"] + args = base_args + args + + (output_path, _, status_code) = subprocess_capture( + self.log_dir, args, echo_stderr=True, echo_stdout=True, env=env, check=False + ) + if status_code: + log.warning(f"Scrub command {args} failed") + log.warning(f"Scrub environment: {env}") + log.warning(f"Output at: {output_path}") + + raise RuntimeError("Remote storage scrub failed") + + def scan_metadata(self): + self.scrubber_cli(["scan-metadata"], timeout=30) + + def get_test_output_dir(request: FixtureRequest, top_output_dir: Path) -> Path: """Compute the working directory for an individual test.""" test_name = request.node.name @@ -2855,9 +2961,7 @@ def list_files_to_compare(pgdata_dir: Path) -> List[str]: # pg is the existing and running compute node, that we want to compare with a basebackup def check_restored_datadir_content( - test_output_dir: Path, - env: NeonEnv, - endpoint: Endpoint, + test_output_dir: Path, env: NeonEnv, endpoint: Endpoint, pageserver_id: Optional[int] = None ): # Get the timeline ID. We need it for the 'basebackup' command timeline_id = TimelineId(endpoint.safe_psql("SHOW neon.timeline_id")[0][0]) @@ -2882,7 +2986,7 @@ def check_restored_datadir_content( cmd = rf""" {psql_path} \ --no-psqlrc \ - postgres://localhost:{env.pageserver.service_port.pg} \ + postgres://localhost:{env.get_pageserver(pageserver_id).service_port.pg} \ -c 'basebackup {endpoint.tenant_id} {timeline_id}' \ | tar -x -C {restored_dir_path} """ @@ -2932,19 +3036,32 @@ def check_restored_datadir_content( def wait_for_last_flush_lsn( - env: NeonEnv, endpoint: Endpoint, tenant: TenantId, timeline: TimelineId + env: NeonEnv, + endpoint: Endpoint, + tenant: TenantId, + timeline: TimelineId, + pageserver_id: Optional[int] = None, ) -> Lsn: """Wait for pageserver to catch up the latest flush LSN, returns the last observed lsn.""" + last_flush_lsn = Lsn(endpoint.safe_psql("SELECT pg_current_wal_flush_lsn()")[0][0]) - return wait_for_last_record_lsn(env.pageserver.http_client(), tenant, timeline, last_flush_lsn) + return wait_for_last_record_lsn( + env.get_pageserver(pageserver_id).http_client(), tenant, timeline, last_flush_lsn + ) def wait_for_wal_insert_lsn( - env: NeonEnv, endpoint: Endpoint, tenant: TenantId, timeline: TimelineId + env: NeonEnv, + endpoint: Endpoint, + tenant: TenantId, + timeline: TimelineId, + pageserver_id: Optional[int] = None, ) -> Lsn: """Wait for pageserver to catch up the latest flush LSN, returns the last observed lsn.""" last_flush_lsn = Lsn(endpoint.safe_psql("SELECT pg_current_wal_insert_lsn()")[0][0]) - return wait_for_last_record_lsn(env.pageserver.http_client(), tenant, timeline, last_flush_lsn) + return wait_for_last_record_lsn( + env.get_pageserver(pageserver_id).http_client(), tenant, timeline, last_flush_lsn + ) def fork_at_current_lsn( @@ -2964,15 +3081,21 @@ def fork_at_current_lsn( def last_flush_lsn_upload( - env: NeonEnv, endpoint: Endpoint, tenant_id: TenantId, timeline_id: TimelineId + env: NeonEnv, + endpoint: Endpoint, + tenant_id: TenantId, + timeline_id: TimelineId, + pageserver_id: Optional[int] = None, ) -> Lsn: """ Wait for pageserver to catch to the latest flush LSN of given endpoint, checkpoint pageserver, and wait for it to be uploaded (remote_consistent_lsn reaching flush LSN). """ - last_flush_lsn = wait_for_last_flush_lsn(env, endpoint, tenant_id, timeline_id) - ps_http = env.pageserver.http_client() + last_flush_lsn = wait_for_last_flush_lsn( + env, endpoint, tenant_id, timeline_id, pageserver_id=pageserver_id + ) + ps_http = env.get_pageserver(pageserver_id).http_client() wait_for_last_record_lsn(ps_http, tenant_id, timeline_id, last_flush_lsn) # force a checkpoint to trigger upload ps_http.timeline_checkpoint(tenant_id, timeline_id) diff --git a/test_runner/fixtures/pageserver/http.py b/test_runner/fixtures/pageserver/http.py index a179ebdd09..9373073abf 100644 --- a/test_runner/fixtures/pageserver/http.py +++ b/test_runner/fixtures/pageserver/http.py @@ -186,18 +186,25 @@ class PageserverHttpClient(requests.Session): return TenantId(new_tenant_id) def tenant_attach( - self, tenant_id: TenantId, config: None | Dict[str, Any] = None, config_null: bool = False + self, + tenant_id: TenantId, + config: None | Dict[str, Any] = None, + config_null: bool = False, + generation: Optional[int] = None, ): if config_null: assert config is None - body = "null" + body: Any = None else: # null-config is prohibited by the API config = config or {} - body = json.dumps({"config": config}) + body = {"config": config} + if generation is not None: + body.update({"generation": generation}) + res = self.post( f"http://localhost:{self.port}/v1/tenant/{tenant_id}/attach", - data=body, + data=json.dumps(body), headers={"Content-Type": "application/json"}, ) self.verbose_error(res) diff --git a/test_runner/fixtures/pageserver/utils.py b/test_runner/fixtures/pageserver/utils.py index 708e90d9b2..3197017bd1 100644 --- a/test_runner/fixtures/pageserver/utils.py +++ b/test_runner/fixtures/pageserver/utils.py @@ -154,16 +154,17 @@ def wait_for_last_record_lsn( lsn: Lsn, ) -> Lsn: """waits for pageserver to catch up to a certain lsn, returns the last observed lsn.""" - for i in range(10): + for i in range(100): current_lsn = last_record_lsn(pageserver_http, tenant, timeline) if current_lsn >= lsn: return current_lsn - log.info( - "waiting for last_record_lsn to reach {}, now {}, iteration {}".format( - lsn, current_lsn, i + 1 + if i % 10 == 0: + log.info( + "waiting for last_record_lsn to reach {}, now {}, iteration {}".format( + lsn, current_lsn, i + 1 + ) ) - ) - time.sleep(1) + time.sleep(0.1) raise Exception( "timed out while waiting for last_record_lsn to reach {}, was {}".format(lsn, current_lsn) ) @@ -260,15 +261,11 @@ def list_prefix( Note that this function takes into account prefix_in_bucket. """ # For local_fs we need to properly handle empty directories, which we currently dont, so for simplicity stick to s3 api. - assert neon_env_builder.remote_storage_kind in ( - RemoteStorageKind.MOCK_S3, - RemoteStorageKind.REAL_S3, - ) - # For mypy - assert isinstance(neon_env_builder.remote_storage, S3Storage) - assert neon_env_builder.remote_storage_client is not None + remote = neon_env_builder.pageserver_remote_storage + assert isinstance(remote, S3Storage), "localfs is currently not supported" + assert remote.client is not None - prefix_in_bucket = neon_env_builder.remote_storage.prefix_in_bucket or "" + prefix_in_bucket = remote.prefix_in_bucket or "" if not prefix: prefix = prefix_in_bucket else: @@ -277,9 +274,9 @@ def list_prefix( prefix = "/".join((prefix_in_bucket, prefix)) # Note that this doesnt use pagination, so list is not guaranteed to be exhaustive. - response = neon_env_builder.remote_storage_client.list_objects_v2( + response = remote.client.list_objects_v2( Delimiter="/", - Bucket=neon_env_builder.remote_storage.bucket_name, + Bucket=remote.bucket_name, Prefix=prefix, ) return response diff --git a/test_runner/fixtures/remote_storage.py b/test_runner/fixtures/remote_storage.py index 320e658639..ca7ad93f83 100644 --- a/test_runner/fixtures/remote_storage.py +++ b/test_runner/fixtures/remote_storage.py @@ -1,10 +1,15 @@ import enum +import hashlib import json import os +import re import subprocess from dataclasses import dataclass from pathlib import Path -from typing import Dict, List, Optional, Union +from typing import Any, Dict, List, Optional, Union + +import boto3 +from mypy_boto3_s3 import S3Client from fixtures.log_helper import log from fixtures.types import TenantId, TimelineId @@ -12,6 +17,20 @@ from fixtures.types import TenantId, TimelineId TIMELINE_INDEX_PART_FILE_NAME = "index_part.json" +@enum.unique +class RemoteStorageUser(str, enum.Enum): + """ + Instead of using strings for the users, use a more strict enum. + """ + + PAGESERVER = "pageserver" + EXTENSIONS = "ext" + SAFEKEEPER = "safekeeper" + + def __str__(self) -> str: + return self.value + + class MockS3Server: """ Starts a mock S3 server for testing on a port given, errors if the server fails to start or exits prematurely. @@ -58,6 +77,124 @@ class MockS3Server: self.subprocess.kill() +@dataclass +class LocalFsStorage: + root: Path + + def tenant_path(self, tenant_id: TenantId) -> Path: + return self.root / "tenants" / str(tenant_id) + + def timeline_path(self, tenant_id: TenantId, timeline_id: TimelineId) -> Path: + return self.tenant_path(tenant_id) / "timelines" / str(timeline_id) + + def index_path(self, tenant_id: TenantId, timeline_id: TimelineId) -> Path: + return self.timeline_path(tenant_id, timeline_id) / TIMELINE_INDEX_PART_FILE_NAME + + def index_content(self, tenant_id: TenantId, timeline_id: TimelineId): + with self.index_path(tenant_id, timeline_id).open("r") as f: + return json.load(f) + + def to_toml_inline_table(self) -> str: + return f"local_path='{self.root}'" + + def cleanup(self): + # no cleanup is done here, because there's NeonEnvBuilder.cleanup_local_storage which will remove everything, including localfs files + pass + + @staticmethod + def component_path(repo_dir: Path, user: RemoteStorageUser) -> Path: + return repo_dir / "local_fs_remote_storage" / str(user) + + +@dataclass +class S3Storage: + bucket_name: str + bucket_region: str + access_key: str + secret_key: str + prefix_in_bucket: str + client: S3Client + cleanup: bool + endpoint: Optional[str] = None + + def access_env_vars(self) -> Dict[str, str]: + return { + "AWS_ACCESS_KEY_ID": self.access_key, + "AWS_SECRET_ACCESS_KEY": self.secret_key, + } + + def to_string(self) -> str: + return json.dumps( + { + "bucket": self.bucket_name, + "region": self.bucket_region, + "endpoint": self.endpoint, + "prefix": self.prefix_in_bucket, + } + ) + + def to_toml_inline_table(self) -> str: + s = [ + f"bucket_name='{self.bucket_name}'", + f"bucket_region='{self.bucket_region}'", + ] + + if self.prefix_in_bucket is not None: + s.append(f"prefix_in_bucket='{self.prefix_in_bucket}'") + + if self.endpoint is not None: + s.append(f"endpoint='{self.endpoint}'") + + return ",".join(s) + + def do_cleanup(self): + if not self.cleanup: + # handles previous keep_remote_storage_contents + return + + log.info( + "removing data from test s3 bucket %s by prefix %s", + self.bucket_name, + self.prefix_in_bucket, + ) + paginator = self.client.get_paginator("list_objects_v2") + pages = paginator.paginate( + Bucket=self.bucket_name, + Prefix=self.prefix_in_bucket, + ) + + # Using Any because DeleteTypeDef (from boto3-stubs) doesn't fit our case + objects_to_delete: Any = {"Objects": []} + cnt = 0 + for item in pages.search("Contents"): + # weirdly when nothing is found it returns [None] + if item is None: + break + + objects_to_delete["Objects"].append({"Key": item["Key"]}) + + # flush once aws limit reached + if len(objects_to_delete["Objects"]) >= 1000: + self.client.delete_objects( + Bucket=self.bucket_name, + Delete=objects_to_delete, + ) + objects_to_delete = {"Objects": []} + cnt += 1 + + # flush rest + if len(objects_to_delete["Objects"]): + self.client.delete_objects( + Bucket=self.bucket_name, + Delete=objects_to_delete, + ) + + log.info(f"deleted {cnt} objects from remote storage") + + +RemoteStorage = Union[LocalFsStorage, S3Storage] + + @enum.unique class RemoteStorageKind(str, enum.Enum): LOCAL_FS = "local_fs" @@ -67,6 +204,104 @@ class RemoteStorageKind(str, enum.Enum): # to ensure the test pass with or without the remote storage NOOP = "noop" + def configure( + self, + repo_dir: Path, + mock_s3_server, + run_id: str, + test_name: str, + user: RemoteStorageUser, + bucket_name: Optional[str] = None, + bucket_region: Optional[str] = None, + ) -> Optional[RemoteStorage]: + if self == RemoteStorageKind.NOOP: + return None + + if self == RemoteStorageKind.LOCAL_FS: + return LocalFsStorage(LocalFsStorage.component_path(repo_dir, user)) + + # real_s3 uses this as part of prefix, mock_s3 uses this as part of + # bucket name, giving all users unique buckets because we have to + # create them + test_name = re.sub(r"[_\[\]]", "-", test_name) + + def to_bucket_name(user: str, test_name: str) -> str: + s = f"{user}-{test_name}" + + if len(s) > 63: + prefix = s[:30] + suffix = hashlib.sha256(test_name.encode()).hexdigest()[:32] + s = f"{prefix}-{suffix}" + assert len(s) == 63 + + return s + + if self == RemoteStorageKind.MOCK_S3: + # there's a single mock_s3 server for each process running the tests + mock_endpoint = mock_s3_server.endpoint() + mock_region = mock_s3_server.region() + + access_key, secret_key = mock_s3_server.access_key(), mock_s3_server.secret_key() + + client = boto3.client( + "s3", + endpoint_url=mock_endpoint, + region_name=mock_region, + aws_access_key_id=access_key, + aws_secret_access_key=secret_key, + ) + + bucket_name = to_bucket_name(user, test_name) + log.info( + f"using mock_s3 bucket name {bucket_name} for user={user}, test_name={test_name}" + ) + + return S3Storage( + bucket_name=bucket_name, + endpoint=mock_endpoint, + bucket_region=mock_region, + access_key=access_key, + secret_key=secret_key, + prefix_in_bucket="", + client=client, + cleanup=False, + ) + + assert self == RemoteStorageKind.REAL_S3 + + env_access_key = os.getenv("AWS_ACCESS_KEY_ID") + assert env_access_key, "no aws access key provided" + env_secret_key = os.getenv("AWS_SECRET_ACCESS_KEY") + assert env_secret_key, "no aws access key provided" + + # session token is needed for local runs with sso auth + session_token = os.getenv("AWS_SESSION_TOKEN") + + bucket_name = bucket_name or os.getenv("REMOTE_STORAGE_S3_BUCKET") + assert bucket_name is not None, "no remote storage bucket name provided" + bucket_region = bucket_region or os.getenv("REMOTE_STORAGE_S3_REGION") + assert bucket_region is not None, "no remote storage region provided" + + prefix_in_bucket = f"{run_id}/{test_name}/{user}" + + client = boto3.client( + "s3", + region_name=bucket_region, + aws_access_key_id=env_access_key, + aws_secret_access_key=env_secret_key, + aws_session_token=session_token, + ) + + return S3Storage( + bucket_name=bucket_name, + bucket_region=bucket_region, + access_key=env_access_key, + secret_key=env_secret_key, + prefix_in_bucket=prefix_in_bucket, + client=client, + cleanup=True, + ) + def available_remote_storages() -> List[RemoteStorageKind]: remote_storages = [RemoteStorageKind.LOCAL_FS, RemoteStorageKind.MOCK_S3] @@ -88,72 +323,22 @@ def available_s3_storages() -> List[RemoteStorageKind]: return remote_storages -@dataclass -class LocalFsStorage: - root: Path +def s3_storage() -> RemoteStorageKind: + """ + For tests that require a remote storage impl that exposes an S3 + endpoint, but don't want to parametrize over multiple storage types. - def tenant_path(self, tenant_id: TenantId) -> Path: - return self.root / "tenants" / str(tenant_id) - - def timeline_path(self, tenant_id: TenantId, timeline_id: TimelineId) -> Path: - return self.tenant_path(tenant_id) / "timelines" / str(timeline_id) - - def index_path(self, tenant_id: TenantId, timeline_id: TimelineId) -> Path: - return self.timeline_path(tenant_id, timeline_id) / TIMELINE_INDEX_PART_FILE_NAME - - def index_content(self, tenant_id: TenantId, timeline_id: TimelineId): - with self.index_path(tenant_id, timeline_id).open("r") as f: - return json.load(f) - - -@dataclass -class S3Storage: - bucket_name: str - bucket_region: str - access_key: str - secret_key: str - endpoint: Optional[str] = None - prefix_in_bucket: Optional[str] = "" - - def access_env_vars(self) -> Dict[str, str]: - return { - "AWS_ACCESS_KEY_ID": self.access_key, - "AWS_SECRET_ACCESS_KEY": self.secret_key, - } - - def to_string(self) -> str: - return json.dumps( - { - "bucket": self.bucket_name, - "region": self.bucket_region, - "endpoint": self.endpoint, - "prefix": self.prefix_in_bucket, - } - ) - - -RemoteStorage = Union[LocalFsStorage, S3Storage] + Use real S3 if available, else use MockS3 + """ + if os.getenv("ENABLE_REAL_S3_REMOTE_STORAGE") is not None: + return RemoteStorageKind.REAL_S3 + else: + return RemoteStorageKind.MOCK_S3 # serialize as toml inline table def remote_storage_to_toml_inline_table(remote_storage: RemoteStorage) -> str: - if isinstance(remote_storage, LocalFsStorage): - remote_storage_config = f"local_path='{remote_storage.root}'" - elif isinstance(remote_storage, S3Storage): - remote_storage_config = f"bucket_name='{remote_storage.bucket_name}',\ - bucket_region='{remote_storage.bucket_region}'" - - if remote_storage.prefix_in_bucket is not None: - remote_storage_config += f",prefix_in_bucket='{remote_storage.prefix_in_bucket}'" - - if remote_storage.endpoint is not None: - remote_storage_config += f",endpoint='{remote_storage.endpoint}'" - else: + if not isinstance(remote_storage, (LocalFsStorage, S3Storage)): raise Exception("invalid remote storage type") - return f"{{{remote_storage_config}}}" - - -class RemoteStorageUsers(enum.Flag): - PAGESERVER = enum.auto() - SAFEKEEPER = enum.auto() + return f"{{{remote_storage.to_toml_inline_table()}}}" diff --git a/test_runner/fixtures/utils.py b/test_runner/fixtures/utils.py index d03d2e7595..46ab446f99 100644 --- a/test_runner/fixtures/utils.py +++ b/test_runner/fixtures/utils.py @@ -4,9 +4,10 @@ import os import re import subprocess import tarfile +import threading import time from pathlib import Path -from typing import TYPE_CHECKING, Any, Callable, Dict, List, Tuple, TypeVar +from typing import TYPE_CHECKING, Any, Callable, Dict, List, Optional, Tuple, TypeVar from urllib.parse import urlencode import allure @@ -26,34 +27,100 @@ def get_self_dir() -> Path: return Path(__file__).resolve().parent -def subprocess_capture(capture_dir: Path, cmd: List[str], **kwargs: Any) -> str: - """Run a process and capture its output +def subprocess_capture( + capture_dir: Path, + cmd: List[str], + *, + check=False, + echo_stderr=False, + echo_stdout=False, + capture_stdout=False, + **kwargs: Any, +) -> Tuple[str, Optional[str], int]: + """Run a process and bifurcate its output to files and the `log` logger - Output will go to files named "cmd_NNN.stdout" and "cmd_NNN.stderr" + stderr and stdout are always captured in files. They are also optionally + echoed to the log (echo_stderr, echo_stdout), and/or captured and returned + (capture_stdout). + + File output will go to files named "cmd_NNN.stdout" and "cmd_NNN.stderr" where "cmd" is the name of the program and NNN is an incrementing counter. If those files already exist, we will overwrite them. - Returns basepath for files with captured output. + + Returns 3-tuple of: + - The base path for output files + - Captured stdout, or None + - The exit status of the process """ assert isinstance(cmd, list) - base = f"{os.path.basename(cmd[0])}_{global_counter()}" + base_cmd = os.path.basename(cmd[0]) + base = f"{base_cmd}_{global_counter()}" basepath = os.path.join(capture_dir, base) stdout_filename = f"{basepath}.stdout" stderr_filename = f"{basepath}.stderr" + # Since we will stream stdout and stderr concurrently, need to do it in a thread. + class OutputHandler(threading.Thread): + def __init__(self, in_file, out_file, echo: bool, capture: bool): + super().__init__() + self.in_file = in_file + self.out_file = out_file + self.echo = echo + self.capture = capture + self.captured = "" + + def run(self): + for line in self.in_file: + # Only bother decoding if we are going to do something more than stream to a file + if self.echo or self.capture: + string = line.decode(encoding="utf-8", errors="replace") + + if self.echo: + log.info(string) + + if self.capture: + self.captured += string + + self.out_file.write(line) + + captured = None try: - with open(stdout_filename, "w") as stdout_f: - with open(stderr_filename, "w") as stderr_f: + with open(stdout_filename, "wb") as stdout_f: + with open(stderr_filename, "wb") as stderr_f: log.info(f'Capturing stdout to "{base}.stdout" and stderr to "{base}.stderr"') - subprocess.run(cmd, **kwargs, stdout=stdout_f, stderr=stderr_f) + + p = subprocess.Popen( + cmd, + **kwargs, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + ) + stdout_handler = OutputHandler( + p.stdout, stdout_f, echo=echo_stdout, capture=capture_stdout + ) + stdout_handler.start() + stderr_handler = OutputHandler(p.stderr, stderr_f, echo=echo_stderr, capture=False) + stderr_handler.start() + + r = p.wait() + + stdout_handler.join() + stderr_handler.join() + + if check and r != 0: + raise subprocess.CalledProcessError(r, " ".join(cmd)) + + if capture_stdout: + captured = stdout_handler.captured finally: # Remove empty files if there is no output for filename in (stdout_filename, stderr_filename): if os.stat(filename).st_size == 0: os.remove(filename) - return basepath + return (basepath, captured, r) _global_counter = 0 diff --git a/test_runner/performance/test_bulk_insert.py b/test_runner/performance/test_bulk_insert.py index d6e67aa361..92da2dae96 100644 --- a/test_runner/performance/test_bulk_insert.py +++ b/test_runner/performance/test_bulk_insert.py @@ -1,6 +1,8 @@ +import shutil from contextlib import closing -from fixtures.compare_fixtures import PgCompare +from fixtures.compare_fixtures import NeonCompare, PgCompare +from fixtures.pg_version import PgVersion # @@ -28,3 +30,30 @@ def test_bulk_insert(neon_with_baseline: PgCompare): env.report_peak_memory_use() env.report_size() + + # When testing neon, also check how long it takes the pageserver to reingest the + # wal from safekeepers. If this number is close to total runtime, then the pageserver + # is the bottleneck. + if isinstance(env, NeonCompare): + measure_recovery_time(env) + + +def measure_recovery_time(env: NeonCompare): + client = env.env.pageserver.http_client() + pg_version = PgVersion(client.timeline_detail(env.tenant, env.timeline)["pg_version"]) + + # Stop pageserver and remove tenant data + env.env.pageserver.stop() + timeline_dir = env.env.timeline_dir(env.tenant, env.timeline) + shutil.rmtree(timeline_dir) + + # Start pageserver + env.env.pageserver.start() + + # Measure recovery time + with env.record_duration("wal_recovery"): + # Create the tenant, which will start walingest + client.timeline_create(pg_version, env.tenant, env.timeline) + + # Flush, which will also wait for lsn to catch up + env.flush() diff --git a/test_runner/performance/test_perf_olap.py b/test_runner/performance/test_perf_olap.py index 39aafa80df..0f7615f7ed 100644 --- a/test_runner/performance/test_perf_olap.py +++ b/test_runner/performance/test_perf_olap.py @@ -1,5 +1,6 @@ +import os from dataclasses import dataclass -from typing import Dict, Tuple +from typing import Dict, List, Tuple import pytest from _pytest.mark import ParameterSet @@ -78,6 +79,15 @@ QUERIES: Tuple[LabelledQuery, ...] = ( ) +def get_scale() -> List[str]: + # We parametrize each tpc-h and clickbench test with scale + # to distinguish them from each other, but don't really use it inside. + # Databases are pre-created and passed through BENCHMARK_CONNSTR env variable. + + scale = os.getenv("TEST_OLAP_SCALE", "noscale") + return [scale] + + def run_psql(env: RemoteCompare, labelled_query: LabelledQuery, times: int) -> None: # prepare connstr: # - cut out password from connstr to pass it via env @@ -100,9 +110,10 @@ def run_psql(env: RemoteCompare, labelled_query: LabelledQuery, times: int) -> N env.pg_bin.run_capture(["psql", connstr, "-c", query], env=environ) +@pytest.mark.parametrize("scale", get_scale()) @pytest.mark.parametrize("query", QUERIES) @pytest.mark.remote_cluster -def test_clickbench(query: LabelledQuery, remote_compare: RemoteCompare): +def test_clickbench(query: LabelledQuery, remote_compare: RemoteCompare, scale: str): """ An OLAP-style ClickHouse benchmark @@ -128,9 +139,10 @@ def tpch_queuies() -> Tuple[ParameterSet, ...]: ) +@pytest.mark.parametrize("scale", get_scale()) @pytest.mark.parametrize("query", tpch_queuies()) @pytest.mark.remote_cluster -def test_tpch(query: LabelledQuery, remote_compare: RemoteCompare): +def test_tpch(query: LabelledQuery, remote_compare: RemoteCompare, scale: str): """ TCP-H Benchmark diff --git a/test_runner/performance/test_startup.py b/test_runner/performance/test_startup.py index fade78504a..366b9b0e68 100644 --- a/test_runner/performance/test_startup.py +++ b/test_runner/performance/test_startup.py @@ -84,7 +84,7 @@ def test_startup_simple(neon_env_builder: NeonEnvBuilder, zenbenchmark: NeonBenc # This test sometimes runs for longer than the global 5 minute timeout. -@pytest.mark.timeout(600) +@pytest.mark.timeout(900) def test_startup(neon_env_builder: NeonEnvBuilder, zenbenchmark: NeonBenchmarker): neon_env_builder.num_safekeepers = 3 env = neon_env_builder.init_start() diff --git a/test_runner/pg_clients/test_pg_clients.py b/test_runner/pg_clients/test_pg_clients.py index 7c20bac399..8381eac946 100644 --- a/test_runner/pg_clients/test_pg_clients.py +++ b/test_runner/pg_clients/test_pg_clients.py @@ -48,6 +48,6 @@ def test_pg_clients(test_output_dir: Path, remote_pg: RemotePostgres, client: st subprocess_capture(test_output_dir, build_cmd, check=True) run_cmd = [docker_bin, "run", "--rm", "--env-file", env_file, image_tag] - basepath = subprocess_capture(test_output_dir, run_cmd, check=True) + basepath, _, _ = subprocess_capture(test_output_dir, run_cmd, check=True) assert Path(f"{basepath}.stdout").read_text().strip() == "1" diff --git a/test_runner/regress/test_attach_tenant_config.py b/test_runner/regress/test_attach_tenant_config.py index 1acf429c52..892e40b19b 100644 --- a/test_runner/regress/test_attach_tenant_config.py +++ b/test_runner/regress/test_attach_tenant_config.py @@ -14,16 +14,14 @@ from fixtures.utils import wait_until @pytest.fixture def positive_env(neon_env_builder: NeonEnvBuilder) -> NeonEnv: - neon_env_builder.enable_remote_storage( - remote_storage_kind=RemoteStorageKind.LOCAL_FS, - ) + neon_env_builder.enable_pageserver_remote_storage(RemoteStorageKind.LOCAL_FS) env = neon_env_builder.init_start() # eviction might be the first one after an attach to access the layers env.pageserver.allowed_errors.append( ".*unexpectedly on-demand downloading remote layer remote.* for task kind Eviction" ) - assert isinstance(env.remote_storage, LocalFsStorage) + assert isinstance(env.pageserver_remote_storage, LocalFsStorage) return env @@ -36,11 +34,9 @@ class NegativeTests: @pytest.fixture def negative_env(neon_env_builder: NeonEnvBuilder) -> Generator[NegativeTests, None, None]: - neon_env_builder.enable_remote_storage( - remote_storage_kind=RemoteStorageKind.LOCAL_FS, - ) + neon_env_builder.enable_pageserver_remote_storage(RemoteStorageKind.LOCAL_FS) env = neon_env_builder.init_start() - assert isinstance(env.remote_storage, LocalFsStorage) + assert isinstance(env.pageserver_remote_storage, LocalFsStorage) ps_http = env.pageserver.http_client() (tenant_id, _) = env.neon_cli.create_tenant() diff --git a/test_runner/regress/test_broken_timeline.py b/test_runner/regress/test_broken_timeline.py index 57e9413aa3..c446f865cb 100644 --- a/test_runner/regress/test_broken_timeline.py +++ b/test_runner/regress/test_broken_timeline.py @@ -44,14 +44,14 @@ def test_broken_timeline(neon_env_builder: NeonEnvBuilder): log.info(f"Timeline {tenant0}/{timeline0} is left intact") (tenant1, timeline1, pg1) = tenant_timelines[1] - metadata_path = f"{env.repo_dir}/tenants/{tenant1}/timelines/{timeline1}/metadata" + metadata_path = f"{env.pageserver.workdir}/tenants/{tenant1}/timelines/{timeline1}/metadata" f = open(metadata_path, "w") f.write("overwritten with garbage!") f.close() log.info(f"Timeline {tenant1}/{timeline1} got its metadata spoiled") (tenant2, timeline2, pg2) = tenant_timelines[2] - timeline_path = f"{env.repo_dir}/tenants/{tenant2}/timelines/{timeline2}/" + timeline_path = f"{env.pageserver.workdir}/tenants/{tenant2}/timelines/{timeline2}/" for filename in os.listdir(timeline_path): if filename.startswith("00000"): # Looks like a layer file. Remove it @@ -61,7 +61,7 @@ def test_broken_timeline(neon_env_builder: NeonEnvBuilder): ) (tenant3, timeline3, pg3) = tenant_timelines[3] - timeline_path = f"{env.repo_dir}/tenants/{tenant3}/timelines/{timeline3}/" + timeline_path = f"{env.pageserver.workdir}/tenants/{tenant3}/timelines/{timeline3}/" for filename in os.listdir(timeline_path): if filename.startswith("00000"): # Looks like a layer file. Corrupt it @@ -122,8 +122,8 @@ def test_create_multiple_timelines_parallel(neon_simple_env: NeonEnv): future.result() -def test_timeline_init_break_before_checkpoint(neon_simple_env: NeonEnv): - env = neon_simple_env +def test_timeline_init_break_before_checkpoint(neon_env_builder: NeonEnvBuilder): + env = neon_env_builder.init_start() pageserver_http = env.pageserver.http_client() env.pageserver.allowed_errors.extend( @@ -133,9 +133,9 @@ def test_timeline_init_break_before_checkpoint(neon_simple_env: NeonEnv): ] ) - tenant_id, _ = env.neon_cli.create_tenant() + tenant_id = env.initial_tenant - timelines_dir = env.repo_dir / "tenants" / str(tenant_id) / "timelines" + timelines_dir = env.pageserver.workdir / "tenants" / str(tenant_id) / "timelines" old_tenant_timelines = env.neon_cli.list_timelines(tenant_id) initial_timeline_dirs = [d for d in timelines_dir.iterdir()] @@ -145,8 +145,8 @@ def test_timeline_init_break_before_checkpoint(neon_simple_env: NeonEnv): _ = env.neon_cli.create_timeline("test_timeline_init_break_before_checkpoint", tenant_id) # Restart the page server - env.neon_cli.pageserver_stop(immediate=True) - env.neon_cli.pageserver_start() + env.pageserver.stop(immediate=True) + env.pageserver.start() # Creating the timeline didn't finish. The other timelines on tenant should still be present and work normally. new_tenant_timelines = env.neon_cli.list_timelines(tenant_id) @@ -160,13 +160,13 @@ def test_timeline_init_break_before_checkpoint(neon_simple_env: NeonEnv): ), "pageserver should clean its temp timeline files on timeline creation failure" -def test_timeline_create_break_after_uninit_mark(neon_simple_env: NeonEnv): - env = neon_simple_env +def test_timeline_create_break_after_uninit_mark(neon_env_builder: NeonEnvBuilder): + env = neon_env_builder.init_start() pageserver_http = env.pageserver.http_client() - tenant_id, _ = env.neon_cli.create_tenant() + tenant_id = env.initial_tenant - timelines_dir = env.repo_dir / "tenants" / str(tenant_id) / "timelines" + timelines_dir = env.pageserver.workdir / "tenants" / str(tenant_id) / "timelines" old_tenant_timelines = env.neon_cli.list_timelines(tenant_id) initial_timeline_dirs = [d for d in timelines_dir.iterdir()] diff --git a/test_runner/regress/test_close_fds.py b/test_runner/regress/test_close_fds.py index 7059f3360e..ce9ecb3dc4 100644 --- a/test_runner/regress/test_close_fds.py +++ b/test_runner/regress/test_close_fds.py @@ -33,7 +33,7 @@ def test_lsof_pageserver_pid(neon_simple_env: NeonEnv): workload_thread = threading.Thread(target=start_workload, args=(), daemon=True) workload_thread.start() - path = os.path.join(env.repo_dir, "pageserver.pid") + path = os.path.join(env.pageserver.workdir, "pageserver.pid") lsof = lsof_path() while workload_thread.is_alive(): res = subprocess.run( diff --git a/test_runner/regress/test_compatibility.py b/test_runner/regress/test_compatibility.py index 6979b99708..161662bc99 100644 --- a/test_runner/regress/test_compatibility.py +++ b/test_runner/regress/test_compatibility.py @@ -22,6 +22,7 @@ from fixtures.pageserver.utils import ( ) from fixtures.pg_version import PgVersion from fixtures.port_distributor import PortDistributor +from fixtures.remote_storage import LocalFsStorage, RemoteStorageKind, RemoteStorageUser from fixtures.types import Lsn from pytest import FixtureRequest @@ -39,6 +40,54 @@ from pytest import FixtureRequest # - prepare_snapshot copies the snapshot, cleans it up and makes it ready for the current version of Neon (replaces paths and ports in config files). # - check_neon_works performs the test itself, feel free to add more checks there. # +# +# How to run `test_backward_compatibility` locally: +# +# export DEFAULT_PG_VERSION=15 +# export BUILD_TYPE=release +# export CHECK_ONDISK_DATA_COMPATIBILITY=true +# +# # Build previous version of binaries and create a data snapshot: +# rm -rf pg_install target +# git checkout +# CARGO_BUILD_FLAGS="--features=testing" make -s -j`nproc` +# ./scripts/pytest -k test_create_snapshot +# +# # Build current version of binaries +# rm -rf pg_install target +# git checkout +# CARGO_BUILD_FLAGS="--features=testing" make -s -j`nproc` +# +# # Run backward compatibility test +# COMPATIBILITY_SNAPSHOT_DIR=test_output/compatibility_snapshot_pgv${DEFAULT_PG_VERSION} \ +# ./scripts/pytest -k test_backward_compatibility +# +# +# How to run `test_forward_compatibility` locally: +# +# export DEFAULT_PG_VERSION=15 +# export BUILD_TYPE=release +# export CHECK_ONDISK_DATA_COMPATIBILITY=true +# +# # Build previous version of binaries and store them somewhere: +# rm -rf pg_install target +# git checkout +# CARGO_BUILD_FLAGS="--features=testing" make -s -j`nproc` +# mkdir -p neon_previous/target +# cp -a target/${BUILD_TYPE} ./neon_previous/target/${BUILD_TYPE} +# cp -a pg_install ./neon_previous/pg_install +# +# # Build current version of binaries and create a data snapshot: +# rm -rf pg_install target +# git checkout +# CARGO_BUILD_FLAGS="--features=testing" make -s -j`nproc` +# ./scripts/pytest -k test_create_snapshot +# +# # Run forward compatibility test +# COMPATIBILITY_NEON_BIN=neon_previous/target/${BUILD_TYPE} \ +# COMPATIBILITY_POSTGRES_DISTRIB_DIR=neon_previous/pg_install \ +# ./scripts/pytest -k test_forward_compatibility +# check_ondisk_data_compatibility_if_enabled = pytest.mark.skipif( os.environ.get("CHECK_ONDISK_DATA_COMPATIBILITY") is None, @@ -61,7 +110,7 @@ def test_create_snapshot( # There's no cleanup here, it allows to adjust the data in `test_backward_compatibility` itself without re-collecting it. neon_env_builder.pg_version = pg_version neon_env_builder.num_safekeepers = 3 - neon_env_builder.enable_local_fs_remote_storage() + neon_env_builder.enable_pageserver_remote_storage(RemoteStorageKind.LOCAL_FS) env = neon_env_builder.init_start() endpoint = env.endpoints.create_start("main") @@ -132,7 +181,6 @@ def test_backward_compatibility( prepare_snapshot( from_dir=compatibility_snapshot_dir, to_dir=test_output_dir / "compatibility_snapshot", - neon_binpath=neon_binpath, port_distributor=port_distributor, ) @@ -201,7 +249,6 @@ def test_forward_compatibility( from_dir=compatibility_snapshot_dir, to_dir=test_output_dir / "compatibility_snapshot", port_distributor=port_distributor, - neon_binpath=compatibility_neon_bin, pg_distrib_dir=compatibility_postgres_distrib_dir, ) @@ -233,7 +280,6 @@ def prepare_snapshot( from_dir: Path, to_dir: Path, port_distributor: PortDistributor, - neon_binpath: Path, pg_distrib_dir: Optional[Path] = None, ): assert from_dir.exists(), f"Snapshot '{from_dir}' doesn't exist" @@ -245,6 +291,9 @@ def prepare_snapshot( repo_dir = to_dir / "repo" + snapshot_config_toml = repo_dir / "config" + snapshot_config = toml.load(snapshot_config_toml) + # Remove old logs to avoid confusion in test artifacts for logfile in repo_dir.glob("**/*.log"): logfile.unlink() @@ -258,29 +307,53 @@ def prepare_snapshot( os.mkdir(repo_dir / "endpoints") # Update paths and ports in config files - pageserver_toml = repo_dir / "pageserver.toml" - pageserver_config = toml.load(pageserver_toml) - pageserver_config["remote_storage"]["local_path"] = str(repo_dir / "local_fs_remote_storage") - for param in ("listen_http_addr", "listen_pg_addr", "broker_endpoint"): - pageserver_config[param] = port_distributor.replace_with_new_port(pageserver_config[param]) + legacy_pageserver_toml = repo_dir / "pageserver.toml" + legacy_bundle = os.path.exists(legacy_pageserver_toml) - # We don't use authentication in compatibility tests - # so just remove authentication related settings. - pageserver_config.pop("pg_auth_type", None) - pageserver_config.pop("http_auth_type", None) - - if pg_distrib_dir: - pageserver_config["pg_distrib_dir"] = str(pg_distrib_dir) - - with pageserver_toml.open("w") as f: - toml.dump(pageserver_config, f) - - snapshot_config_toml = repo_dir / "config" - snapshot_config = toml.load(snapshot_config_toml) - for param in ("listen_http_addr", "listen_pg_addr"): - snapshot_config["pageserver"][param] = port_distributor.replace_with_new_port( - snapshot_config["pageserver"][param] + path_to_config: dict[Path, dict[Any, Any]] = {} + if legacy_bundle: + os.mkdir(repo_dir / "pageserver_1") + path_to_config[repo_dir / "pageserver_1" / "pageserver.toml"] = toml.load( + legacy_pageserver_toml ) + os.remove(legacy_pageserver_toml) + os.rename(repo_dir / "tenants", repo_dir / "pageserver_1" / "tenants") + else: + for ps_conf in snapshot_config["pageservers"]: + config_path = repo_dir / f"pageserver_{ps_conf['id']}" / "pageserver.toml" + path_to_config[config_path] = toml.load(config_path) + + # For each pageserver config, edit it and rewrite + for config_path, pageserver_config in path_to_config.items(): + pageserver_config["remote_storage"]["local_path"] = str( + LocalFsStorage.component_path(repo_dir, RemoteStorageUser.PAGESERVER) + ) + + for param in ("listen_http_addr", "listen_pg_addr", "broker_endpoint"): + pageserver_config[param] = port_distributor.replace_with_new_port( + pageserver_config[param] + ) + + # We don't use authentication in compatibility tests + # so just remove authentication related settings. + pageserver_config.pop("pg_auth_type", None) + pageserver_config.pop("http_auth_type", None) + + if pg_distrib_dir: + pageserver_config["pg_distrib_dir"] = str(pg_distrib_dir) + + with config_path.open("w") as f: + toml.dump(pageserver_config, f) + + # neon_local config doesn't have to be backward compatible. If we're using a dump from before + # it supported multiple pageservers, fix it up. + if "pageservers" not in snapshot_config: + snapshot_config["pageservers"] = [snapshot_config["pageserver"]] + del snapshot_config["pageserver"] + + for param in ("listen_http_addr", "listen_pg_addr"): + for pageserver in snapshot_config["pageservers"]: + pageserver[param] = port_distributor.replace_with_new_port(pageserver[param]) snapshot_config["broker"]["listen_addr"] = port_distributor.replace_with_new_port( snapshot_config["broker"]["listen_addr"] ) @@ -338,10 +411,15 @@ def check_neon_works( config.initial_tenant = snapshot_config["default_tenant_id"] config.pg_distrib_dir = pg_distrib_dir config.remote_storage = None + config.ext_remote_storage = None + config.sk_remote_storage = None # Use the "target" binaries to launch the storage nodes config_target = config config_target.neon_binpath = neon_target_binpath + # We are using maybe-old binaries for neon services, but want to use current + # binaries for test utilities like neon_local + config_target.neon_local_binpath = neon_current_binpath cli_target = NeonCli(config_target) # And the current binaries to launch computes @@ -374,7 +452,7 @@ def check_neon_works( # loosely based on https://github.com/neondatabase/cloud/wiki/Recovery-from-WAL tenant_id = snapshot_config["default_tenant_id"] timeline_id = dict(snapshot_config["branch_name_mappings"]["main"])[tenant_id] - pageserver_port = snapshot_config["pageserver"]["listen_http_addr"].split(":")[-1] + pageserver_port = snapshot_config["pageservers"][0]["listen_http_addr"].split(":")[-1] pageserver_http = PageserverHttpClient( port=pageserver_port, is_testing_enabled_or_skip=lambda: True, # TODO: check if testing really enabled diff --git a/test_runner/regress/test_disk_usage_eviction.py b/test_runner/regress/test_disk_usage_eviction.py index cdbd02de03..32eb910188 100644 --- a/test_runner/regress/test_disk_usage_eviction.py +++ b/test_runner/regress/test_disk_usage_eviction.py @@ -135,7 +135,7 @@ def eviction_env(request, neon_env_builder: NeonEnvBuilder, pg_bin: PgBin) -> Ev log.info(f"setting up eviction_env for test {request.node.name}") - neon_env_builder.enable_remote_storage(RemoteStorageKind.LOCAL_FS) + neon_env_builder.enable_pageserver_remote_storage(RemoteStorageKind.LOCAL_FS) # initial tenant will not be present on this pageserver env = neon_env_builder.init_configs() diff --git a/test_runner/regress/test_download_extensions.py b/test_runner/regress/test_download_extensions.py index 54f51414bb..5f5c733b76 100644 --- a/test_runner/regress/test_download_extensions.py +++ b/test_runner/regress/test_download_extensions.py @@ -10,7 +10,11 @@ from fixtures.neon_fixtures import ( NeonEnvBuilder, ) from fixtures.pg_version import PgVersion -from fixtures.remote_storage import RemoteStorageKind, available_s3_storages +from fixtures.remote_storage import ( + RemoteStorageKind, + S3Storage, + available_s3_storages, +) # Cleaning up downloaded files is important for local tests @@ -72,7 +76,8 @@ def upload_files(env): with open(full_path, "rb") as f: log.info(f"UPLOAD {full_path} to ext/{full_path}") - env.remote_storage_client.upload_fileobj( + assert isinstance(env.pageserver_remote_storage, S3Storage) + env.pageserver_remote_storage.client.upload_fileobj( f, env.ext_remote_storage.bucket_name, f"ext/{full_path}", @@ -88,16 +93,12 @@ def test_remote_extensions( remote_storage_kind: RemoteStorageKind, pg_version: PgVersion, ): - neon_env_builder.enable_remote_storage( - remote_storage_kind=remote_storage_kind, - enable_remote_extensions=True, - ) + neon_env_builder.enable_extensions_remote_storage(remote_storage_kind) env = neon_env_builder.init_start() tenant_id, _ = env.neon_cli.create_tenant() env.neon_cli.create_timeline("test_remote_extensions", tenant_id=tenant_id) assert env.ext_remote_storage is not None # satisfy mypy - assert env.remote_storage_client is not None # satisfy mypy # For MOCK_S3 we upload test files. # For REAL_S3 we use the files already in the bucket @@ -154,16 +155,12 @@ def test_remote_library( remote_storage_kind: RemoteStorageKind, pg_version: PgVersion, ): - neon_env_builder.enable_remote_storage( - remote_storage_kind=remote_storage_kind, - enable_remote_extensions=True, - ) + neon_env_builder.enable_extensions_remote_storage(remote_storage_kind) env = neon_env_builder.init_start() tenant_id, _ = env.neon_cli.create_tenant() env.neon_cli.create_timeline("test_remote_library", tenant_id=tenant_id) assert env.ext_remote_storage is not None # satisfy mypy - assert env.remote_storage_client is not None # satisfy mypy # For MOCK_S3 we upload test files. # For REAL_S3 we use the files already in the bucket @@ -214,16 +211,12 @@ def test_multiple_extensions_one_archive( neon_env_builder: NeonEnvBuilder, pg_version: PgVersion, ): - neon_env_builder.enable_remote_storage( - remote_storage_kind=RemoteStorageKind.REAL_S3, - enable_remote_extensions=True, - ) + neon_env_builder.enable_extensions_remote_storage(RemoteStorageKind.REAL_S3) env = neon_env_builder.init_start() tenant_id, _ = env.neon_cli.create_tenant() env.neon_cli.create_timeline("test_multiple_extensions_one_archive", tenant_id=tenant_id) assert env.ext_remote_storage is not None # satisfy mypy - assert env.remote_storage_client is not None # satisfy mypy endpoint = env.endpoints.create_start( "test_multiple_extensions_one_archive", @@ -261,16 +254,12 @@ def test_extension_download_after_restart( if "15" in pg_version: # SKIP v15 for now because test set only has extension built for v14 return None - neon_env_builder.enable_remote_storage( - remote_storage_kind=RemoteStorageKind.MOCK_S3, - enable_remote_extensions=True, - ) + neon_env_builder.enable_extensions_remote_storage(RemoteStorageKind.MOCK_S3) env = neon_env_builder.init_start() tenant_id, _ = env.neon_cli.create_tenant() env.neon_cli.create_timeline("test_extension_download_after_restart", tenant_id=tenant_id) assert env.ext_remote_storage is not None # satisfy mypy - assert env.remote_storage_client is not None # satisfy mypy # For MOCK_S3 we upload test files. upload_files(env) diff --git a/test_runner/regress/test_gc_aggressive.py b/test_runner/regress/test_gc_aggressive.py index 8d7a42a805..db0020a8a4 100644 --- a/test_runner/regress/test_gc_aggressive.py +++ b/test_runner/regress/test_gc_aggressive.py @@ -100,9 +100,7 @@ def test_gc_index_upload(neon_env_builder: NeonEnvBuilder, remote_storage_kind: # Disable time-based pitr, we will use LSN-based thresholds in the manual GC calls neon_env_builder.pageserver_config_override = "tenant_config={pitr_interval = '0 sec'}" - neon_env_builder.enable_remote_storage( - remote_storage_kind=remote_storage_kind, - ) + neon_env_builder.enable_pageserver_remote_storage(remote_storage_kind) env = neon_env_builder.init_start() tenant_id = env.initial_tenant diff --git a/test_runner/regress/test_import.py b/test_runner/regress/test_import.py index 68086d3f84..1722d6bfe7 100644 --- a/test_runner/regress/test_import.py +++ b/test_runner/regress/test_import.py @@ -19,6 +19,7 @@ from fixtures.pageserver.utils import ( wait_for_last_record_lsn, wait_for_upload, ) +from fixtures.remote_storage import RemoteStorageKind from fixtures.types import Lsn, TenantId, TimelineId from fixtures.utils import subprocess_capture @@ -80,7 +81,7 @@ def test_import_from_vanilla(test_output_dir, pg_bin, vanilla_pg, neon_env_build timeline = TimelineId.generate() # Set up pageserver for import - neon_env_builder.enable_local_fs_remote_storage() + neon_env_builder.enable_pageserver_remote_storage(RemoteStorageKind.LOCAL_FS) env = neon_env_builder.init_start() client = env.pageserver.http_client() @@ -163,7 +164,7 @@ def test_import_from_vanilla(test_output_dir, pg_bin, vanilla_pg, neon_env_build def test_import_from_pageserver_small(pg_bin: PgBin, neon_env_builder: NeonEnvBuilder): - neon_env_builder.enable_local_fs_remote_storage() + neon_env_builder.enable_pageserver_remote_storage(RemoteStorageKind.LOCAL_FS) env = neon_env_builder.init_start() # FIXME: Is this expected? @@ -185,7 +186,7 @@ def test_import_from_pageserver_small(pg_bin: PgBin, neon_env_builder: NeonEnvBu # @pytest.mark.skipif(os.environ.get('BUILD_TYPE') == "debug", reason="only run with release build") @pytest.mark.skip("See https://github.com/neondatabase/neon/issues/2255") def test_import_from_pageserver_multisegment(pg_bin: PgBin, neon_env_builder: NeonEnvBuilder): - neon_env_builder.enable_local_fs_remote_storage() + neon_env_builder.enable_pageserver_remote_storage(RemoteStorageKind.LOCAL_FS) env = neon_env_builder.init_start() timeline = env.neon_cli.create_branch("test_import_from_pageserver_multisegment") @@ -270,7 +271,7 @@ def _import( env.endpoints.stop_all() env.pageserver.stop() - dir_to_clear = Path(env.repo_dir) / "tenants" + dir_to_clear = Path(env.pageserver.workdir) / "tenants" shutil.rmtree(dir_to_clear) os.mkdir(dir_to_clear) diff --git a/test_runner/regress/test_large_schema.py b/test_runner/regress/test_large_schema.py index 72bf32fcd3..b6ac1aa41f 100644 --- a/test_runner/regress/test_large_schema.py +++ b/test_runner/regress/test_large_schema.py @@ -75,7 +75,7 @@ def test_large_schema(neon_env_builder: NeonEnvBuilder): # Check layer file sizes timeline_path = "{}/tenants/{}/timelines/{}/".format( - env.repo_dir, env.initial_tenant, env.initial_timeline + env.pageserver.workdir, env.initial_tenant, env.initial_timeline ) for filename in os.listdir(timeline_path): if filename.startswith("00000"): diff --git a/test_runner/regress/test_layer_eviction.py b/test_runner/regress/test_layer_eviction.py index c939ace803..5bb953ffb0 100644 --- a/test_runner/regress/test_layer_eviction.py +++ b/test_runner/regress/test_layer_eviction.py @@ -19,9 +19,7 @@ def test_basic_eviction( neon_env_builder: NeonEnvBuilder, remote_storage_kind: RemoteStorageKind, ): - neon_env_builder.enable_remote_storage( - remote_storage_kind=remote_storage_kind, - ) + neon_env_builder.enable_pageserver_remote_storage(remote_storage_kind) env = neon_env_builder.init_start( initial_tenant_conf={ @@ -154,9 +152,7 @@ def test_basic_eviction( def test_gc_of_remote_layers(neon_env_builder: NeonEnvBuilder): - neon_env_builder.enable_remote_storage( - remote_storage_kind=RemoteStorageKind.LOCAL_FS, - ) + neon_env_builder.enable_pageserver_remote_storage(RemoteStorageKind.LOCAL_FS) env = neon_env_builder.init_start() diff --git a/test_runner/regress/test_metric_collection.py b/test_runner/regress/test_metric_collection.py index e4641cff05..9ec649ee24 100644 --- a/test_runner/regress/test_metric_collection.py +++ b/test_runner/regress/test_metric_collection.py @@ -94,9 +94,7 @@ def test_metric_collection( + "tenant_config={pitr_interval = '0 sec'}" ) - neon_env_builder.enable_remote_storage( - remote_storage_kind=remote_storage_kind, - ) + neon_env_builder.enable_pageserver_remote_storage(remote_storage_kind) log.info(f"test_metric_collection endpoint is {metric_collection_endpoint}") diff --git a/test_runner/regress/test_neon_cli.py b/test_runner/regress/test_neon_cli.py index 9d24594cb6..1b3984583a 100644 --- a/test_runner/regress/test_neon_cli.py +++ b/test_runner/regress/test_neon_cli.py @@ -124,10 +124,14 @@ def test_cli_ipv4_listeners(neon_env_builder: NeonEnvBuilder): def test_cli_start_stop(neon_env_builder: NeonEnvBuilder): + """ + Basic start/stop with default single-instance config for + safekeeper and pageserver + """ env = neon_env_builder.init_start() # Stop default ps/sk - env.neon_cli.pageserver_stop() + env.neon_cli.pageserver_stop(env.pageserver.id) env.neon_cli.safekeeper_stop() # Default start @@ -139,6 +143,38 @@ def test_cli_start_stop(neon_env_builder: NeonEnvBuilder): res.check_returncode() +def test_cli_start_stop_multi(neon_env_builder: NeonEnvBuilder): + """ + Basic start/stop with explicitly configured counts of pageserver + and safekeeper + """ + neon_env_builder.num_pageservers = 2 + neon_env_builder.num_safekeepers = 2 + env = neon_env_builder.init_start() + + env.neon_cli.pageserver_stop(env.BASE_PAGESERVER_ID) + env.neon_cli.pageserver_stop(env.BASE_PAGESERVER_ID + 1) + + # Addressing a nonexistent ID throws + with pytest.raises(RuntimeError): + env.neon_cli.pageserver_stop(env.BASE_PAGESERVER_ID + 100) + + # Using the single-pageserver shortcut property throws when there are multiple pageservers + with pytest.raises(AssertionError): + _drop = env.pageserver + + env.neon_cli.safekeeper_stop(neon_env_builder.safekeepers_id_start + 1) + env.neon_cli.safekeeper_stop(neon_env_builder.safekeepers_id_start + 2) + + # Default start + res = env.neon_cli.raw_cli(["start"]) + res.check_returncode() + + # Default stop + res = env.neon_cli.raw_cli(["stop"]) + res.check_returncode() + + @skip_on_postgres(PgVersion.V14, reason="does not use postgres") @pytest.mark.skipif( os.environ.get("BUILD_TYPE") == "debug", reason="unit test for test support, either build works" diff --git a/test_runner/regress/test_ondemand_download.py b/test_runner/regress/test_ondemand_download.py index 0ca6a7a595..fd9f9d631d 100644 --- a/test_runner/regress/test_ondemand_download.py +++ b/test_runner/regress/test_ondemand_download.py @@ -52,9 +52,7 @@ def test_ondemand_download_large_rel( neon_env_builder: NeonEnvBuilder, remote_storage_kind: RemoteStorageKind, ): - neon_env_builder.enable_remote_storage( - remote_storage_kind=remote_storage_kind, - ) + neon_env_builder.enable_pageserver_remote_storage(remote_storage_kind) # thinking about using a shared environment? the test assumes that global # metrics are for single tenant. @@ -117,7 +115,7 @@ def test_ondemand_download_large_rel( env.pageserver.stop() # remove all the layer files - for layer in (Path(env.repo_dir) / "tenants").glob("*/timelines/*/*-*_*"): + for layer in (Path(env.pageserver.workdir) / "tenants").glob("*/timelines/*/*-*_*"): log.info(f"unlinking layer {layer}") layer.unlink() @@ -154,9 +152,7 @@ def test_ondemand_download_timetravel( neon_env_builder: NeonEnvBuilder, remote_storage_kind: RemoteStorageKind, ): - neon_env_builder.enable_remote_storage( - remote_storage_kind=remote_storage_kind, - ) + neon_env_builder.enable_pageserver_remote_storage(remote_storage_kind) # thinking about using a shared environment? the test assumes that global # metrics are for single tenant. @@ -241,7 +237,7 @@ def test_ondemand_download_timetravel( env.pageserver.stop() # remove all the layer files - for layer in (Path(env.repo_dir) / "tenants").glob("*/timelines/*/*-*_*"): + for layer in (Path(env.pageserver.workdir) / "tenants").glob("*/timelines/*/*-*_*"): log.info(f"unlinking layer {layer}") layer.unlink() @@ -315,9 +311,7 @@ def test_download_remote_layers_api( neon_env_builder: NeonEnvBuilder, remote_storage_kind: RemoteStorageKind, ): - neon_env_builder.enable_remote_storage( - remote_storage_kind=remote_storage_kind, - ) + neon_env_builder.enable_pageserver_remote_storage(remote_storage_kind) ##### First start, insert data and upload it to the remote storage env = neon_env_builder.init_start( @@ -373,7 +367,7 @@ def test_download_remote_layers_api( # remove all the layer files # XXX only delete some of the layer files, to show that it really just downloads all the layers - for layer in (Path(env.repo_dir) / "tenants").glob("*/timelines/*/*-*_*"): + for layer in (Path(env.pageserver.workdir) / "tenants").glob("*/timelines/*/*-*_*"): log.info(f"unlinking layer {layer.name}") layer.unlink() @@ -396,9 +390,19 @@ def test_download_remote_layers_api( wait_until(10, 0.2, lambda: assert_tenant_state(client, tenant_id, "Active")) ###### Phase 1: exercise download error code path + + # comparison here is requiring the size to be at least the previous size, because it's possible received WAL after last_flush_lsn_upload + # witnessed for example difference of 29827072 (filled_current_physical) to 29868032 (here) is no good reason to fail a test. + this_time = get_api_current_physical_size() assert ( - filled_current_physical == get_api_current_physical_size() + filled_current_physical <= this_time ), "current_physical_size is sum of loaded layer sizes, independent of whether local or remote" + if filled_current_physical != this_time: + log.info( + f"fixing up filled_current_physical from {filled_current_physical} to {this_time} ({this_time - filled_current_physical})" + ) + filled_current_physical = this_time + post_unlink_size = get_resident_physical_size() log.info(f"post_unlink_size: {post_unlink_size}") assert ( @@ -476,9 +480,7 @@ def test_compaction_downloads_on_demand_without_image_creation( """ Create a few layers, then evict, then make sure compaction runs successfully. """ - neon_env_builder.enable_remote_storage( - remote_storage_kind=remote_storage_kind, - ) + neon_env_builder.enable_pageserver_remote_storage(remote_storage_kind) conf = { # Disable background GC & compaction @@ -563,9 +565,7 @@ def test_compaction_downloads_on_demand_with_image_creation( Due to current implementation, this will make image creation on-demand download layers, but we cannot really directly test for it. """ - neon_env_builder.enable_remote_storage( - remote_storage_kind=remote_storage_kind, - ) + neon_env_builder.enable_pageserver_remote_storage(remote_storage_kind) conf = { # Disable background GC & compaction @@ -663,9 +663,7 @@ def test_ondemand_download_failure_to_replace( See: https://github.com/neondatabase/neon/issues/3533 """ - neon_env_builder.enable_remote_storage( - remote_storage_kind=remote_storage_kind, - ) + neon_env_builder.enable_pageserver_remote_storage(remote_storage_kind) # disable gc and compaction via default tenant config because config is lost while detaching # so that compaction will not be the one to download the layer but the http handler is diff --git a/test_runner/regress/test_pageserver_api.py b/test_runner/regress/test_pageserver_api.py index 28732872df..f5bcfd52f9 100644 --- a/test_runner/regress/test_pageserver_api.py +++ b/test_runner/regress/test_pageserver_api.py @@ -17,13 +17,13 @@ from fixtures.utils import wait_until def test_pageserver_init_node_id( neon_simple_env: NeonEnv, neon_binpath: Path, pg_distrib_dir: Path ): - repo_dir = neon_simple_env.repo_dir - pageserver_config = repo_dir / "pageserver.toml" + workdir = neon_simple_env.pageserver.workdir + pageserver_config = workdir / "pageserver.toml" pageserver_bin = neon_binpath / "pageserver" def run_pageserver(args): return subprocess.run( - [str(pageserver_bin), "-D", str(repo_dir), *args], + [str(pageserver_bin), "-D", str(workdir), *args], check=False, universal_newlines=True, stdout=subprocess.PIPE, diff --git a/test_runner/regress/test_pageserver_restart.py b/test_runner/regress/test_pageserver_restart.py index 1e41ebd15b..2965a354bd 100644 --- a/test_runner/regress/test_pageserver_restart.py +++ b/test_runner/regress/test_pageserver_restart.py @@ -3,11 +3,17 @@ from contextlib import closing import pytest from fixtures.log_helper import log from fixtures.neon_fixtures import NeonEnvBuilder +from fixtures.remote_storage import s3_storage # Test restarting page server, while safekeeper and compute node keep # running. -def test_pageserver_restart(neon_env_builder: NeonEnvBuilder): +@pytest.mark.parametrize("generations", [True, False]) +def test_pageserver_restart(neon_env_builder: NeonEnvBuilder, generations: bool): + neon_env_builder.enable_generations = generations + neon_env_builder.enable_pageserver_remote_storage(s3_storage()) + neon_env_builder.enable_scrub_on_exit() + env = neon_env_builder.init_start() env.neon_cli.create_branch("test_pageserver_restart") @@ -109,6 +115,9 @@ def test_pageserver_restart(neon_env_builder: NeonEnvBuilder): # safekeeper and compute node keep running. @pytest.mark.timeout(540) def test_pageserver_chaos(neon_env_builder: NeonEnvBuilder): + neon_env_builder.enable_pageserver_remote_storage(s3_storage()) + neon_env_builder.enable_scrub_on_exit() + env = neon_env_builder.init_start() # Use a tiny checkpoint distance, to create a lot of layers quickly. diff --git a/test_runner/regress/test_read_trace.py b/test_runner/regress/test_read_trace.py index cae8ca3919..e6b3ccd7ec 100644 --- a/test_runner/regress/test_read_trace.py +++ b/test_runner/regress/test_read_trace.py @@ -35,5 +35,5 @@ def test_read_request_tracing(neon_env_builder: NeonEnvBuilder): # Stop postgres so we drop the connection and flush the traces endpoint.stop() - trace_path = env.repo_dir / "traces" / str(tenant_id) / str(timeline_id) + trace_path = env.pageserver.workdir / "traces" / str(tenant_id) / str(timeline_id) assert trace_path.exists() diff --git a/test_runner/regress/test_remote_storage.py b/test_runner/regress/test_remote_storage.py index 8b75d35200..d0bb52336d 100644 --- a/test_runner/regress/test_remote_storage.py +++ b/test_runner/regress/test_remote_storage.py @@ -52,17 +52,17 @@ from requests import ReadTimeout # # The tests are done for all types of remote storage pageserver supports. @pytest.mark.parametrize("remote_storage_kind", available_remote_storages()) +@pytest.mark.parametrize("generations", [True, False]) def test_remote_storage_backup_and_restore( - neon_env_builder: NeonEnvBuilder, - remote_storage_kind: RemoteStorageKind, + neon_env_builder: NeonEnvBuilder, remote_storage_kind: RemoteStorageKind, generations: bool ): # Use this test to check more realistic SK ids: some etcd key parsing bugs were related, # and this test needs SK to write data to pageserver, so it will be visible neon_env_builder.safekeepers_id_start = 12 - neon_env_builder.enable_remote_storage( - remote_storage_kind=remote_storage_kind, - ) + neon_env_builder.enable_pageserver_remote_storage(remote_storage_kind) + + neon_env_builder.enable_generations = generations # Exercise retry code path by making all uploads and downloads fail for the # first time. The retries print INFO-messages to the log; we will check @@ -137,7 +137,7 @@ def test_remote_storage_backup_and_restore( env.endpoints.stop_all() env.pageserver.stop() - dir_to_clear = Path(env.repo_dir) / "tenants" + dir_to_clear = Path(env.pageserver.workdir) / "tenants" shutil.rmtree(dir_to_clear) os.mkdir(dir_to_clear) @@ -154,7 +154,7 @@ def test_remote_storage_backup_and_restore( # background task to load the tenant. In that background task, # listing the remote timelines will fail because of the failpoint, # and the tenant will be marked as Broken. - client.tenant_attach(tenant_id) + env.pageserver.tenant_attach(tenant_id) tenant_info = wait_until_tenant_state(pageserver_http, tenant_id, "Broken", 15) assert tenant_info["attachment_status"] == { @@ -164,7 +164,7 @@ def test_remote_storage_backup_and_restore( # Ensure that even though the tenant is broken, we can't attach it again. with pytest.raises(Exception, match=f"tenant {tenant_id} already exists, state: Broken"): - client.tenant_attach(tenant_id) + env.pageserver.tenant_attach(tenant_id) # Restart again, this implicitly clears the failpoint. # test_remote_failures=1 remains active, though, as it's in the pageserver config. @@ -182,7 +182,7 @@ def test_remote_storage_backup_and_restore( # Ensure that the pageserver remembers that the tenant was attaching, by # trying to attach it again. It should fail. with pytest.raises(Exception, match=f"tenant {tenant_id} already exists, state:"): - client.tenant_attach(tenant_id) + env.pageserver.tenant_attach(tenant_id) log.info("waiting for tenant to become active. this should be quick with on-demand download") wait_until_tenant_active( @@ -222,9 +222,7 @@ def test_remote_storage_upload_queue_retries( neon_env_builder: NeonEnvBuilder, remote_storage_kind: RemoteStorageKind, ): - neon_env_builder.enable_remote_storage( - remote_storage_kind=remote_storage_kind, - ) + neon_env_builder.enable_pageserver_remote_storage(remote_storage_kind) env = neon_env_builder.init_start() @@ -355,14 +353,14 @@ def test_remote_storage_upload_queue_retries( env.pageserver.stop(immediate=True) env.endpoints.stop_all() - dir_to_clear = Path(env.repo_dir) / "tenants" + dir_to_clear = Path(env.pageserver.workdir) / "tenants" shutil.rmtree(dir_to_clear) os.mkdir(dir_to_clear) env.pageserver.start() client = env.pageserver.http_client() - client.tenant_attach(tenant_id) + env.pageserver.tenant_attach(tenant_id) wait_until_tenant_active(client, tenant_id) @@ -377,9 +375,7 @@ def test_remote_timeline_client_calls_started_metric( neon_env_builder: NeonEnvBuilder, remote_storage_kind: RemoteStorageKind, ): - neon_env_builder.enable_remote_storage( - remote_storage_kind=remote_storage_kind, - ) + neon_env_builder.enable_pageserver_remote_storage(remote_storage_kind) # thinking about using a shared environment? the test assumes that global # metrics are for single tenant. @@ -492,14 +488,14 @@ def test_remote_timeline_client_calls_started_metric( env.pageserver.stop(immediate=True) env.endpoints.stop_all() - dir_to_clear = Path(env.repo_dir) / "tenants" + dir_to_clear = Path(env.pageserver.workdir) / "tenants" shutil.rmtree(dir_to_clear) os.mkdir(dir_to_clear) env.pageserver.start() client = env.pageserver.http_client() - client.tenant_attach(tenant_id) + env.pageserver.tenant_attach(tenant_id) wait_until_tenant_active(client, tenant_id) @@ -519,9 +515,7 @@ def test_timeline_deletion_with_files_stuck_in_upload_queue( neon_env_builder: NeonEnvBuilder, remote_storage_kind: RemoteStorageKind, ): - neon_env_builder.enable_remote_storage( - remote_storage_kind=remote_storage_kind, - ) + neon_env_builder.enable_pageserver_remote_storage(remote_storage_kind) env = neon_env_builder.init_start( initial_tenant_conf={ @@ -610,8 +604,8 @@ def test_timeline_deletion_with_files_stuck_in_upload_queue( assert not timeline_path.exists() # to please mypy - assert isinstance(env.remote_storage, LocalFsStorage) - remote_timeline_path = env.remote_storage.timeline_path(tenant_id, timeline_id) + assert isinstance(env.pageserver_remote_storage, LocalFsStorage) + remote_timeline_path = env.pageserver_remote_storage.timeline_path(tenant_id, timeline_id) assert not list(remote_timeline_path.iterdir()) @@ -636,9 +630,7 @@ def test_empty_branch_remote_storage_upload( neon_env_builder: NeonEnvBuilder, remote_storage_kind: RemoteStorageKind, ): - neon_env_builder.enable_remote_storage( - remote_storage_kind=remote_storage_kind, - ) + neon_env_builder.enable_pageserver_remote_storage(remote_storage_kind) env = neon_env_builder.init_start() client = env.pageserver.http_client() @@ -687,9 +679,7 @@ def test_empty_branch_remote_storage_upload_on_restart( — the upload should be scheduled by load, and create_timeline should await for it even though it gets 409 Conflict. """ - neon_env_builder.enable_remote_storage( - remote_storage_kind=remote_storage_kind, - ) + neon_env_builder.enable_pageserver_remote_storage(remote_storage_kind) env = neon_env_builder.init_start() client = env.pageserver.http_client() @@ -717,9 +707,9 @@ def test_empty_branch_remote_storage_upload_on_restart( local_metadata = env.timeline_dir(env.initial_tenant, new_branch_timeline_id) / "metadata" assert local_metadata.is_file() - assert isinstance(env.remote_storage, LocalFsStorage) + assert isinstance(env.pageserver_remote_storage, LocalFsStorage) - new_branch_on_remote_storage = env.remote_storage.timeline_path( + new_branch_on_remote_storage = env.pageserver_remote_storage.timeline_path( env.initial_tenant, new_branch_timeline_id ) assert ( @@ -784,9 +774,7 @@ def test_compaction_delete_before_upload( neon_env_builder: NeonEnvBuilder, remote_storage_kind: RemoteStorageKind, ): - neon_env_builder.enable_remote_storage( - remote_storage_kind=remote_storage_kind, - ) + neon_env_builder.enable_pageserver_remote_storage(remote_storage_kind) env = neon_env_builder.init_start( initial_tenant_conf={ diff --git a/test_runner/regress/test_tenant_conf.py b/test_runner/regress/test_tenant_conf.py index 93ba5477a6..d8a9e0390a 100644 --- a/test_runner/regress/test_tenant_conf.py +++ b/test_runner/regress/test_tenant_conf.py @@ -292,16 +292,14 @@ eviction_policy = { "kind" = "LayerAccessThreshold", period = "20s", threshold = def test_creating_tenant_conf_after_attach(neon_env_builder: NeonEnvBuilder): - neon_env_builder.enable_remote_storage( - remote_storage_kind=RemoteStorageKind.LOCAL_FS, - ) + neon_env_builder.enable_pageserver_remote_storage(RemoteStorageKind.LOCAL_FS) env = neon_env_builder.init_start() - assert isinstance(env.remote_storage, LocalFsStorage) + assert isinstance(env.pageserver_remote_storage, LocalFsStorage) # tenant is created with defaults, as in without config file (tenant_id, timeline_id) = env.neon_cli.create_tenant() - config_path = env.repo_dir / "tenants" / str(tenant_id) / "config" + config_path = env.pageserver.workdir / "tenants" / str(tenant_id) / "config" assert config_path.exists(), "config file is always initially created" http_client = env.pageserver.http_client() @@ -336,12 +334,10 @@ def test_creating_tenant_conf_after_attach(neon_env_builder: NeonEnvBuilder): def test_live_reconfig_get_evictions_low_residence_duration_metric_threshold( neon_env_builder: NeonEnvBuilder, ): - neon_env_builder.enable_remote_storage( - remote_storage_kind=RemoteStorageKind.LOCAL_FS, - ) + neon_env_builder.enable_pageserver_remote_storage(RemoteStorageKind.LOCAL_FS) env = neon_env_builder.init_start() - assert isinstance(env.remote_storage, LocalFsStorage) + assert isinstance(env.pageserver_remote_storage, LocalFsStorage) (tenant_id, timeline_id) = env.neon_cli.create_tenant() ps_http = env.pageserver.http_client() diff --git a/test_runner/regress/test_tenant_delete.py b/test_runner/regress/test_tenant_delete.py index c7324cac83..e60b996a16 100644 --- a/test_runner/regress/test_tenant_delete.py +++ b/test_runner/regress/test_tenant_delete.py @@ -1,7 +1,6 @@ import enum import os import shutil -from pathlib import Path import pytest from fixtures.log_helper import log @@ -41,9 +40,7 @@ def test_tenant_delete_smoke( ): neon_env_builder.pageserver_config_override = "test_remote_failures=1" - neon_env_builder.enable_remote_storage( - remote_storage_kind=remote_storage_kind, - ) + neon_env_builder.enable_pageserver_remote_storage(remote_storage_kind) env = neon_env_builder.init_start() @@ -176,7 +173,7 @@ def test_delete_tenant_exercise_crash_safety_failpoints( if simulate_failures: neon_env_builder.pageserver_config_override = "test_remote_failures=1" - neon_env_builder.enable_remote_storage(remote_storage_kind) + neon_env_builder.enable_pageserver_remote_storage(remote_storage_kind) env = neon_env_builder.init_start(initial_tenant_conf=MANY_SMALL_LAYERS_TENANT_CONFIG) @@ -295,9 +292,7 @@ def test_tenant_delete_is_resumed_on_attach( remote_storage_kind: RemoteStorageKind, pg_bin: PgBin, ): - neon_env_builder.enable_remote_storage( - remote_storage_kind=remote_storage_kind, - ) + neon_env_builder.enable_pageserver_remote_storage(remote_storage_kind) env = neon_env_builder.init_start(initial_tenant_conf=MANY_SMALL_LAYERS_TENANT_CONFIG) @@ -371,7 +366,7 @@ def test_tenant_delete_is_resumed_on_attach( env.endpoints.stop_all() env.pageserver.stop() - dir_to_clear = Path(env.repo_dir) / "tenants" + dir_to_clear = env.pageserver.workdir / "tenants" shutil.rmtree(dir_to_clear) os.mkdir(dir_to_clear) diff --git a/test_runner/regress/test_tenant_detach.py b/test_runner/regress/test_tenant_detach.py index 20526cd0a9..cdf72edc4d 100644 --- a/test_runner/regress/test_tenant_detach.py +++ b/test_runner/regress/test_tenant_detach.py @@ -44,9 +44,7 @@ def test_tenant_reattach( neon_env_builder: NeonEnvBuilder, remote_storage_kind: RemoteStorageKind, ): - neon_env_builder.enable_remote_storage( - remote_storage_kind=remote_storage_kind, - ) + neon_env_builder.enable_pageserver_remote_storage(remote_storage_kind) # Exercise retry code path by making all uploads and downloads fail for the # first time. The retries print INFO-messages to the log; we will check @@ -228,9 +226,7 @@ def test_tenant_reattach_while_busy( neon_env_builder: NeonEnvBuilder, remote_storage_kind: RemoteStorageKind, ): - neon_env_builder.enable_remote_storage( - remote_storage_kind=remote_storage_kind, - ) + neon_env_builder.enable_pageserver_remote_storage(remote_storage_kind) env = neon_env_builder.init_start() pageserver_http = env.pageserver.http_client() @@ -293,7 +289,7 @@ def test_tenant_detach_smoke(neon_env_builder: NeonEnvBuilder): ) # assert tenant exists on disk - assert (env.repo_dir / "tenants" / str(tenant_id)).exists() + assert (env.pageserver.workdir / "tenants" / str(tenant_id)).exists() endpoint = env.endpoints.create_start("main", tenant_id=tenant_id) # we rely upon autocommit after each statement @@ -336,7 +332,7 @@ def test_tenant_detach_smoke(neon_env_builder: NeonEnvBuilder): log.info("gc thread returned") # check that nothing is left on disk for deleted tenant - assert not (env.repo_dir / "tenants" / str(tenant_id)).exists() + assert not (env.pageserver.workdir / "tenants" / str(tenant_id)).exists() with pytest.raises( expected_exception=PageserverApiException, match=f"NotFound: tenant {tenant_id}" @@ -361,7 +357,7 @@ def test_tenant_detach_ignored_tenant(neon_simple_env: NeonEnv): ) # assert tenant exists on disk - assert (env.repo_dir / "tenants" / str(tenant_id)).exists() + assert (env.pageserver.workdir / "tenants" / str(tenant_id)).exists() endpoint = env.endpoints.create_start("main", tenant_id=tenant_id) # we rely upon autocommit after each statement @@ -390,7 +386,7 @@ def test_tenant_detach_ignored_tenant(neon_simple_env: NeonEnv): log.info("ignored tenant detached without error") # check that nothing is left on disk for deleted tenant - assert not (env.repo_dir / "tenants" / str(tenant_id)).exists() + assert not (env.pageserver.workdir / "tenants" / str(tenant_id)).exists() # assert the tenant does not exists in the Pageserver tenants_after_detach = [tenant["id"] for tenant in client.tenant_list()] @@ -417,7 +413,7 @@ def test_tenant_detach_regular_tenant(neon_simple_env: NeonEnv): ) # assert tenant exists on disk - assert (env.repo_dir / "tenants" / str(tenant_id)).exists() + assert (env.pageserver.workdir / "tenants" / str(tenant_id)).exists() endpoint = env.endpoints.create_start("main", tenant_id=tenant_id) # we rely upon autocommit after each statement @@ -434,7 +430,7 @@ def test_tenant_detach_regular_tenant(neon_simple_env: NeonEnv): log.info("regular tenant detached without error") # check that nothing is left on disk for deleted tenant - assert not (env.repo_dir / "tenants" / str(tenant_id)).exists() + assert not (env.pageserver.workdir / "tenants" / str(tenant_id)).exists() # assert the tenant does not exists in the Pageserver tenants_after_detach = [tenant["id"] for tenant in client.tenant_list()] @@ -449,9 +445,7 @@ def test_detach_while_attaching( neon_env_builder: NeonEnvBuilder, remote_storage_kind: RemoteStorageKind, ): - neon_env_builder.enable_remote_storage( - remote_storage_kind=remote_storage_kind, - ) + neon_env_builder.enable_pageserver_remote_storage(remote_storage_kind) ##### First start, insert secret data and upload it to the remote storage env = neon_env_builder.init_start() @@ -532,14 +526,12 @@ def test_detach_while_attaching( def test_ignored_tenant_reattach( neon_env_builder: NeonEnvBuilder, remote_storage_kind: RemoteStorageKind ): - neon_env_builder.enable_remote_storage( - remote_storage_kind=remote_storage_kind, - ) + neon_env_builder.enable_pageserver_remote_storage(remote_storage_kind) env = neon_env_builder.init_start() pageserver_http = env.pageserver.http_client() ignored_tenant_id, _ = env.neon_cli.create_tenant() - tenant_dir = env.repo_dir / "tenants" / str(ignored_tenant_id) + tenant_dir = env.pageserver.workdir / "tenants" / str(ignored_tenant_id) tenants_before_ignore = [tenant["id"] for tenant in pageserver_http.tenant_list()] tenants_before_ignore.sort() timelines_before_ignore = [ @@ -603,9 +595,7 @@ def test_ignored_tenant_reattach( def test_ignored_tenant_download_missing_layers( neon_env_builder: NeonEnvBuilder, remote_storage_kind: RemoteStorageKind ): - neon_env_builder.enable_remote_storage( - remote_storage_kind=remote_storage_kind, - ) + neon_env_builder.enable_pageserver_remote_storage(remote_storage_kind) env = neon_env_builder.init_start() pageserver_http = env.pageserver.http_client() endpoint = env.endpoints.create_start("main") @@ -668,9 +658,7 @@ def test_ignored_tenant_download_missing_layers( def test_ignored_tenant_stays_broken_without_metadata( neon_env_builder: NeonEnvBuilder, remote_storage_kind: RemoteStorageKind ): - neon_env_builder.enable_remote_storage( - remote_storage_kind=remote_storage_kind, - ) + neon_env_builder.enable_pageserver_remote_storage(remote_storage_kind) env = neon_env_builder.init_start() pageserver_http = env.pageserver.http_client() env.endpoints.create_start("main") @@ -711,9 +699,7 @@ def test_ignored_tenant_stays_broken_without_metadata( def test_load_attach_negatives( neon_env_builder: NeonEnvBuilder, remote_storage_kind: RemoteStorageKind ): - neon_env_builder.enable_remote_storage( - remote_storage_kind=remote_storage_kind, - ) + neon_env_builder.enable_pageserver_remote_storage(remote_storage_kind) env = neon_env_builder.init_start() pageserver_http = env.pageserver.http_client() env.endpoints.create_start("main") @@ -755,9 +741,7 @@ def test_ignore_while_attaching( neon_env_builder: NeonEnvBuilder, remote_storage_kind: RemoteStorageKind, ): - neon_env_builder.enable_remote_storage( - remote_storage_kind=remote_storage_kind, - ) + neon_env_builder.enable_pageserver_remote_storage(remote_storage_kind) env = neon_env_builder.init_start() pageserver_http = env.pageserver.http_client() @@ -858,9 +842,7 @@ def test_metrics_while_ignoring_broken_tenant_and_reloading( neon_env_builder: NeonEnvBuilder, remote_storage_kind: RemoteStorageKind, ): - neon_env_builder.enable_remote_storage( - remote_storage_kind=remote_storage_kind, - ) + neon_env_builder.enable_pageserver_remote_storage(remote_storage_kind) env = neon_env_builder.init_start() diff --git a/test_runner/regress/test_tenant_relocation.py b/test_runner/regress/test_tenant_relocation.py index 6a81ff498e..a2b922bb84 100644 --- a/test_runner/regress/test_tenant_relocation.py +++ b/test_runner/regress/test_tenant_relocation.py @@ -22,7 +22,11 @@ from fixtures.pageserver.utils import ( wait_tenant_status_404, ) from fixtures.port_distributor import PortDistributor -from fixtures.remote_storage import RemoteStorageKind, available_remote_storages +from fixtures.remote_storage import ( + LocalFsStorage, + RemoteStorageKind, + available_remote_storages, +) from fixtures.types import Lsn, TenantId, TimelineId from fixtures.utils import ( query_scalar, @@ -264,7 +268,7 @@ def test_tenant_relocation( method: str, with_load: str, ): - neon_env_builder.enable_local_fs_remote_storage() + neon_env_builder.enable_pageserver_remote_storage(RemoteStorageKind.LOCAL_FS) env = neon_env_builder.init_start() @@ -278,8 +282,8 @@ def test_tenant_relocation( # Needed for detach polling. env.pageserver.allowed_errors.append(f".*NotFound: tenant {tenant_id}.*") - # create folder for remote storage mock - remote_storage_mock_path = env.repo_dir / "local_fs_remote_storage" + assert isinstance(env.pageserver_remote_storage, LocalFsStorage) + remote_storage_mock_path = env.pageserver_remote_storage.root # we use two branches to check that they are both relocated # first branch is used for load, compute for second one is used to @@ -524,9 +528,7 @@ def test_emergency_relocate_with_branches_slow_replay( neon_env_builder: NeonEnvBuilder, remote_storage_kind: RemoteStorageKind, ): - neon_env_builder.enable_remote_storage( - remote_storage_kind=remote_storage_kind, - ) + neon_env_builder.enable_pageserver_remote_storage(remote_storage_kind) env = neon_env_builder.init_start() env.pageserver.is_testing_enabled_or_skip() @@ -559,7 +561,7 @@ def test_emergency_relocate_with_branches_slow_replay( # simpler than initializing a new one from scratch, but the effect on the single tenant # is the same. env.pageserver.stop(immediate=True) - shutil.rmtree(Path(env.repo_dir) / "tenants" / str(tenant_id)) + shutil.rmtree(env.pageserver.workdir / "tenants" / str(tenant_id)) env.pageserver.start() # This fail point will pause the WAL ingestion on the main branch, after the @@ -680,9 +682,7 @@ def test_emergency_relocate_with_branches_createdb( neon_env_builder: NeonEnvBuilder, remote_storage_kind: RemoteStorageKind, ): - neon_env_builder.enable_remote_storage( - remote_storage_kind=remote_storage_kind, - ) + neon_env_builder.enable_pageserver_remote_storage(remote_storage_kind) env = neon_env_builder.init_start() pageserver_http = env.pageserver.http_client() @@ -709,7 +709,7 @@ def test_emergency_relocate_with_branches_createdb( # Kill the pageserver, remove the tenant directory, and restart env.pageserver.stop(immediate=True) - shutil.rmtree(Path(env.repo_dir) / "tenants" / str(tenant_id)) + shutil.rmtree(env.pageserver.workdir / "tenants" / str(tenant_id)) env.pageserver.start() # Wait before ingesting the WAL for CREATE DATABASE on the main branch. The original diff --git a/test_runner/regress/test_tenant_size.py b/test_runner/regress/test_tenant_size.py index 25c6634108..49a6ca5a53 100644 --- a/test_runner/regress/test_tenant_size.py +++ b/test_runner/regress/test_tenant_size.py @@ -11,7 +11,10 @@ from fixtures.neon_fixtures import ( wait_for_wal_insert_lsn, ) from fixtures.pageserver.http import PageserverHttpClient -from fixtures.pageserver.utils import timeline_delete_wait_completed +from fixtures.pageserver.utils import ( + timeline_delete_wait_completed, + wait_until_tenant_active, +) from fixtures.pg_version import PgVersion, xfail_on_postgres from fixtures.types import Lsn, TenantId, TimelineId @@ -517,6 +520,8 @@ def test_single_branch_get_tenant_size_grows( env.pageserver.stop() env.pageserver.start() + wait_until_tenant_active(http_client, tenant_id) + size_after = http_client.tenant_size(tenant_id) size_debug = http_client.tenant_size_debug(tenant_id) size_debug_file.write(size_debug) @@ -624,6 +629,8 @@ def test_get_tenant_size_with_multiple_branches( env.pageserver.stop() env.pageserver.start() + wait_until_tenant_active(http_client, tenant_id) + # chance of compaction and gc on startup might have an effect on the # tenant_size but so far this has been reliable, even though at least gc # and tenant_size race for the same locks diff --git a/test_runner/regress/test_tenants.py b/test_runner/regress/test_tenants.py index 985bd63b24..28f0e658a1 100644 --- a/test_runner/regress/test_tenants.py +++ b/test_runner/regress/test_tenants.py @@ -27,7 +27,7 @@ from prometheus_client.samples import Sample def test_tenant_creation_fails(neon_simple_env: NeonEnv): - tenants_dir = Path(neon_simple_env.repo_dir) / "tenants" + tenants_dir = Path(neon_simple_env.pageserver.workdir) / "tenants" initial_tenants = sorted( map(lambda t: t.split()[0], neon_simple_env.neon_cli.list_tenants().stdout.splitlines()) ) @@ -242,9 +242,7 @@ def test_pageserver_metrics_removed_after_detach( ): """Tests that when a tenant is detached, the tenant specific metrics are not left behind""" - neon_env_builder.enable_remote_storage( - remote_storage_kind=remote_storage_kind, - ) + neon_env_builder.enable_pageserver_remote_storage(remote_storage_kind) neon_env_builder.num_safekeepers = 3 @@ -302,9 +300,7 @@ def test_pageserver_metrics_removed_after_detach( def test_pageserver_with_empty_tenants( neon_env_builder: NeonEnvBuilder, remote_storage_kind: RemoteStorageKind ): - neon_env_builder.enable_remote_storage( - remote_storage_kind=remote_storage_kind, - ) + neon_env_builder.enable_pageserver_remote_storage(remote_storage_kind) env = neon_env_builder.init_start() @@ -326,7 +322,10 @@ def test_pageserver_with_empty_tenants( files_in_timelines_dir = sum( 1 for _p in Path.iterdir( - Path(env.repo_dir) / "tenants" / str(tenant_with_empty_timelines) / "timelines" + Path(env.pageserver.workdir) + / "tenants" + / str(tenant_with_empty_timelines) + / "timelines" ) ) assert ( @@ -338,7 +337,9 @@ def test_pageserver_with_empty_tenants( env.pageserver.stop() tenant_without_timelines_dir = env.initial_tenant - shutil.rmtree(Path(env.repo_dir) / "tenants" / str(tenant_without_timelines_dir) / "timelines") + shutil.rmtree( + Path(env.pageserver.workdir) / "tenants" / str(tenant_without_timelines_dir) / "timelines" + ) env.pageserver.start() diff --git a/test_runner/regress/test_tenants_with_remote_storage.py b/test_runner/regress/test_tenants_with_remote_storage.py index 6a541c8a37..4aec4c6113 100644 --- a/test_runner/regress/test_tenants_with_remote_storage.py +++ b/test_runner/regress/test_tenants_with_remote_storage.py @@ -62,9 +62,7 @@ async def all_tenants_workload(env: NeonEnv, tenants_endpoints): @pytest.mark.parametrize("remote_storage_kind", available_remote_storages()) def test_tenants_many(neon_env_builder: NeonEnvBuilder, remote_storage_kind: RemoteStorageKind): - neon_env_builder.enable_remote_storage( - remote_storage_kind=remote_storage_kind, - ) + neon_env_builder.enable_pageserver_remote_storage(remote_storage_kind) env = neon_env_builder.init_start() @@ -114,9 +112,7 @@ def test_tenants_many(neon_env_builder: NeonEnvBuilder, remote_storage_kind: Rem def test_tenants_attached_after_download( neon_env_builder: NeonEnvBuilder, remote_storage_kind: RemoteStorageKind ): - neon_env_builder.enable_remote_storage( - remote_storage_kind=remote_storage_kind, - ) + neon_env_builder.enable_pageserver_remote_storage(remote_storage_kind) data_id = 1 data_secret = "very secret secret" @@ -183,7 +179,9 @@ def test_tenants_attached_after_download( env.pageserver.stop() - timeline_dir = Path(env.repo_dir) / "tenants" / str(tenant_id) / "timelines" / str(timeline_id) + timeline_dir = ( + Path(env.pageserver.workdir) / "tenants" / str(tenant_id) / "timelines" / str(timeline_id) + ) local_layer_deleted = False for path in Path.iterdir(timeline_dir): if path.name.startswith("00000"): @@ -228,13 +226,11 @@ def test_tenant_redownloads_truncated_file_on_startup( remote_storage_kind = RemoteStorageKind.LOCAL_FS # since we now store the layer file length metadata, we notice on startup that a layer file is of wrong size, and proceed to redownload it. - neon_env_builder.enable_remote_storage( - remote_storage_kind=remote_storage_kind, - ) + neon_env_builder.enable_pageserver_remote_storage(remote_storage_kind) env = neon_env_builder.init_start() - assert isinstance(env.remote_storage, LocalFsStorage) + assert isinstance(env.pageserver_remote_storage, LocalFsStorage) env.pageserver.allowed_errors.append( ".*removing local file .* because it has unexpected length.*" @@ -278,7 +274,7 @@ def test_tenant_redownloads_truncated_file_on_startup( (path, expected_size) = local_layer_truncated # ensure the same size is found from the index_part.json - index_part = env.remote_storage.index_content(tenant_id, timeline_id) + index_part = env.pageserver_remote_storage.index_content(tenant_id, timeline_id) assert index_part["layer_metadata"][path.name]["file_size"] == expected_size ## Start the pageserver. It will notice that the file size doesn't match, and @@ -308,7 +304,9 @@ def test_tenant_redownloads_truncated_file_on_startup( assert os.stat(path).st_size == expected_size, "truncated layer should had been re-downloaded" # the remote side of local_layer_truncated - remote_layer_path = env.remote_storage.timeline_path(tenant_id, timeline_id) / path.name + remote_layer_path = ( + env.pageserver_remote_storage.timeline_path(tenant_id, timeline_id) / path.name + ) # if the upload ever was ongoing, this check would be racy, but at least one # extra http request has been made in between so assume it's enough delay diff --git a/test_runner/regress/test_threshold_based_eviction.py b/test_runner/regress/test_threshold_based_eviction.py index 739a9a5b74..6f5e3e2518 100644 --- a/test_runner/regress/test_threshold_based_eviction.py +++ b/test_runner/regress/test_threshold_based_eviction.py @@ -21,7 +21,7 @@ def test_threshold_based_eviction( pg_bin: PgBin, neon_env_builder: NeonEnvBuilder, ): - neon_env_builder.enable_remote_storage(RemoteStorageKind.LOCAL_FS) + neon_env_builder.enable_pageserver_remote_storage(RemoteStorageKind.LOCAL_FS) # Start with metrics collection enabled, so that the eviction task # imitates its accesses. We'll use a non-existent endpoint to make it fail. diff --git a/test_runner/regress/test_timeline_delete.py b/test_runner/regress/test_timeline_delete.py index 0ce714d185..0b79383442 100644 --- a/test_runner/regress/test_timeline_delete.py +++ b/test_runner/regress/test_timeline_delete.py @@ -73,7 +73,11 @@ def test_timeline_delete(neon_simple_env: NeonEnv): ) timeline_path = ( - env.repo_dir / "tenants" / str(env.initial_tenant) / "timelines" / str(parent_timeline_id) + env.pageserver.workdir + / "tenants" + / str(env.initial_tenant) + / "timelines" + / str(parent_timeline_id) ) with pytest.raises( @@ -86,7 +90,11 @@ def test_timeline_delete(neon_simple_env: NeonEnv): assert exc.value.status_code == 412 timeline_path = ( - env.repo_dir / "tenants" / str(env.initial_tenant) / "timelines" / str(leaf_timeline_id) + env.pageserver.workdir + / "tenants" + / str(env.initial_tenant) + / "timelines" + / str(leaf_timeline_id) ) assert timeline_path.exists() @@ -120,6 +128,8 @@ def test_timeline_delete(neon_simple_env: NeonEnv): env.pageserver.stop(immediate=True) env.pageserver.start() + wait_until_tenant_active(ps_http, env.initial_tenant) + with pytest.raises( PageserverApiException, match=f"Timeline {env.initial_tenant}/{leaf_timeline_id} was not found", @@ -191,7 +201,7 @@ def test_delete_timeline_exercise_crash_safety_failpoints( 8. Retry or restart without the failpoint and check the result. """ - neon_env_builder.enable_remote_storage(remote_storage_kind) + neon_env_builder.enable_pageserver_remote_storage(remote_storage_kind) env = neon_env_builder.init_start( initial_tenant_conf={ @@ -346,9 +356,7 @@ def test_timeline_resurrection_on_attach( Original issue: https://github.com/neondatabase/neon/issues/3560 """ - neon_env_builder.enable_remote_storage( - remote_storage_kind=remote_storage_kind, - ) + neon_env_builder.enable_pageserver_remote_storage(remote_storage_kind) ##### First start, insert data and upload it to the remote storage env = neon_env_builder.init_start() @@ -408,7 +416,7 @@ def test_timeline_resurrection_on_attach( env.endpoints.stop_all() env.pageserver.stop() - dir_to_clear = Path(env.repo_dir) / "tenants" + dir_to_clear = Path(env.pageserver.workdir) / "tenants" shutil.rmtree(dir_to_clear) os.mkdir(dir_to_clear) @@ -433,9 +441,7 @@ def test_timeline_delete_fail_before_local_delete(neon_env_builder: NeonEnvBuild the deletion of the local state. """ - neon_env_builder.enable_remote_storage( - remote_storage_kind=RemoteStorageKind.MOCK_S3, - ) + neon_env_builder.enable_pageserver_remote_storage(RemoteStorageKind.MOCK_S3) env = neon_env_builder.init_start() @@ -462,7 +468,11 @@ def test_timeline_delete_fail_before_local_delete(neon_env_builder: NeonEnvBuild ) leaf_timeline_path = ( - env.repo_dir / "tenants" / str(env.initial_tenant) / "timelines" / str(leaf_timeline_id) + env.pageserver.workdir + / "tenants" + / str(env.initial_tenant) + / "timelines" + / str(leaf_timeline_id) ) ps_http.timeline_delete(env.initial_tenant, leaf_timeline_id) @@ -552,9 +562,7 @@ def test_concurrent_timeline_delete_stuck_on( signalling to console that it should retry later. """ - neon_env_builder.enable_remote_storage( - remote_storage_kind=RemoteStorageKind.MOCK_S3, - ) + neon_env_builder.enable_pageserver_remote_storage(RemoteStorageKind.MOCK_S3) env = neon_env_builder.init_start() @@ -629,9 +637,7 @@ def test_delete_timeline_client_hangup(neon_env_builder: NeonEnvBuilder): This tests cancel safety up to the given failpoint. """ - neon_env_builder.enable_remote_storage( - remote_storage_kind=RemoteStorageKind.MOCK_S3, - ) + neon_env_builder.enable_pageserver_remote_storage(RemoteStorageKind.MOCK_S3) env = neon_env_builder.init_start() @@ -698,9 +704,7 @@ def test_timeline_delete_works_for_remote_smoke( neon_env_builder: NeonEnvBuilder, remote_storage_kind: RemoteStorageKind, ): - neon_env_builder.enable_remote_storage( - remote_storage_kind=remote_storage_kind, - ) + neon_env_builder.enable_pageserver_remote_storage(remote_storage_kind) env = neon_env_builder.init_start() @@ -773,7 +777,7 @@ def test_delete_orphaned_objects( pg_bin: PgBin, ): remote_storage_kind = RemoteStorageKind.LOCAL_FS - neon_env_builder.enable_remote_storage(remote_storage_kind) + neon_env_builder.enable_pageserver_remote_storage(remote_storage_kind) env = neon_env_builder.init_start( initial_tenant_conf={ @@ -784,7 +788,7 @@ def test_delete_orphaned_objects( } ) - assert isinstance(env.remote_storage, LocalFsStorage) + assert isinstance(env.pageserver_remote_storage, LocalFsStorage) ps_http = env.pageserver.http_client() @@ -795,7 +799,9 @@ def test_delete_orphaned_objects( last_flush_lsn_upload(env, endpoint, env.initial_tenant, timeline_id) # write orphaned file that is missing from the index - remote_timeline_path = env.remote_storage.timeline_path(env.initial_tenant, timeline_id) + remote_timeline_path = env.pageserver_remote_storage.timeline_path( + env.initial_tenant, timeline_id + ) orphans = [remote_timeline_path / f"orphan_{i}" for i in range(3)] for orphan in orphans: orphan.write_text("I shouldnt be there") @@ -826,7 +832,7 @@ def test_delete_orphaned_objects( f"deleting a file not referenced from index_part.json name={orphan.stem}" ) - assert env.remote_storage.index_path(env.initial_tenant, timeline_id).exists() + assert env.pageserver_remote_storage.index_path(env.initial_tenant, timeline_id).exists() @pytest.mark.parametrize("remote_storage_kind", available_remote_storages()) @@ -835,9 +841,7 @@ def test_timeline_delete_resumed_on_attach( remote_storage_kind: RemoteStorageKind, pg_bin: PgBin, ): - neon_env_builder.enable_remote_storage( - remote_storage_kind=remote_storage_kind, - ) + neon_env_builder.enable_pageserver_remote_storage(remote_storage_kind) env = neon_env_builder.init_start(initial_tenant_conf=MANY_SMALL_LAYERS_TENANT_CONFIG) @@ -917,7 +921,7 @@ def test_timeline_delete_resumed_on_attach( env.endpoints.stop_all() env.pageserver.stop() - dir_to_clear = Path(env.repo_dir) / "tenants" + dir_to_clear = Path(env.pageserver.workdir) / "tenants" shutil.rmtree(dir_to_clear) os.mkdir(dir_to_clear) diff --git a/test_runner/regress/test_timeline_size.py b/test_runner/regress/test_timeline_size.py index d754ce0aa0..3f379a173c 100644 --- a/test_runner/regress/test_timeline_size.py +++ b/test_runner/regress/test_timeline_size.py @@ -306,7 +306,7 @@ def test_timeline_physical_size_init( neon_env_builder: NeonEnvBuilder, remote_storage_kind: Optional[RemoteStorageKind] ): if remote_storage_kind is not None: - neon_env_builder.enable_remote_storage(remote_storage_kind) + neon_env_builder.enable_pageserver_remote_storage(remote_storage_kind) env = neon_env_builder.init_start() @@ -347,7 +347,7 @@ def test_timeline_physical_size_post_checkpoint( neon_env_builder: NeonEnvBuilder, remote_storage_kind: Optional[RemoteStorageKind] ): if remote_storage_kind is not None: - neon_env_builder.enable_remote_storage(remote_storage_kind) + neon_env_builder.enable_pageserver_remote_storage(remote_storage_kind) env = neon_env_builder.init_start() @@ -367,10 +367,13 @@ def test_timeline_physical_size_post_checkpoint( wait_for_last_flush_lsn(env, endpoint, env.initial_tenant, new_timeline_id) pageserver_http.timeline_checkpoint(env.initial_tenant, new_timeline_id) - assert_physical_size_invariants( - get_physical_size_values(env, env.initial_tenant, new_timeline_id, remote_storage_kind), - remote_storage_kind, - ) + def check(): + assert_physical_size_invariants( + get_physical_size_values(env, env.initial_tenant, new_timeline_id, remote_storage_kind), + remote_storage_kind, + ) + + wait_until(10, 1, check) @pytest.mark.parametrize("remote_storage_kind", [None, RemoteStorageKind.LOCAL_FS]) @@ -378,7 +381,7 @@ def test_timeline_physical_size_post_compaction( neon_env_builder: NeonEnvBuilder, remote_storage_kind: Optional[RemoteStorageKind] ): if remote_storage_kind is not None: - neon_env_builder.enable_remote_storage(remote_storage_kind) + neon_env_builder.enable_pageserver_remote_storage(remote_storage_kind) # Disable background compaction as we don't want it to happen after `get_physical_size` request # and before checking the expected size on disk, which makes the assertion failed @@ -431,7 +434,7 @@ def test_timeline_physical_size_post_gc( neon_env_builder: NeonEnvBuilder, remote_storage_kind: Optional[RemoteStorageKind] ): if remote_storage_kind is not None: - neon_env_builder.enable_remote_storage(remote_storage_kind) + neon_env_builder.enable_pageserver_remote_storage(remote_storage_kind) # Disable background compaction and GC as we don't want it to happen after `get_physical_size` request # and before checking the expected size on disk, which makes the assertion failed @@ -564,7 +567,7 @@ def test_tenant_physical_size( random.seed(100) if remote_storage_kind is not None: - neon_env_builder.enable_remote_storage(remote_storage_kind) + neon_env_builder.enable_pageserver_remote_storage(remote_storage_kind) env = neon_env_builder.init_start() diff --git a/test_runner/regress/test_wal_acceptor.py b/test_runner/regress/test_wal_acceptor.py index 119a597d43..b080caca33 100644 --- a/test_runner/regress/test_wal_acceptor.py +++ b/test_runner/regress/test_wal_acceptor.py @@ -37,7 +37,6 @@ from fixtures.pg_version import PgVersion from fixtures.port_distributor import PortDistributor from fixtures.remote_storage import ( RemoteStorageKind, - RemoteStorageUsers, available_remote_storages, ) from fixtures.types import Lsn, TenantId, TimelineId @@ -267,7 +266,7 @@ def test_restarts(neon_env_builder: NeonEnvBuilder): # Test that safekeepers push their info to the broker and learn peer status from it def test_broker(neon_env_builder: NeonEnvBuilder): neon_env_builder.num_safekeepers = 3 - neon_env_builder.enable_local_fs_remote_storage() + neon_env_builder.enable_pageserver_remote_storage(RemoteStorageKind.LOCAL_FS) env = neon_env_builder.init_start() tenant_id = env.initial_tenant @@ -313,7 +312,7 @@ def test_broker(neon_env_builder: NeonEnvBuilder): def test_wal_removal(neon_env_builder: NeonEnvBuilder, auth_enabled: bool): neon_env_builder.num_safekeepers = 2 # to advance remote_consistent_lsn - neon_env_builder.enable_local_fs_remote_storage() + neon_env_builder.enable_pageserver_remote_storage(RemoteStorageKind.LOCAL_FS) neon_env_builder.auth_enabled = auth_enabled env = neon_env_builder.init_start() @@ -436,12 +435,7 @@ def is_wal_trimmed(sk: Safekeeper, tenant_id: TenantId, timeline_id: TimelineId, @pytest.mark.parametrize("remote_storage_kind", available_remote_storages()) def test_wal_backup(neon_env_builder: NeonEnvBuilder, remote_storage_kind: RemoteStorageKind): neon_env_builder.num_safekeepers = 3 - - neon_env_builder.enable_remote_storage( - remote_storage_kind=remote_storage_kind, - ) - - neon_env_builder.remote_storage_users = RemoteStorageUsers.SAFEKEEPER + neon_env_builder.enable_safekeeper_remote_storage(remote_storage_kind) env = neon_env_builder.init_start() @@ -488,11 +482,7 @@ def test_wal_backup(neon_env_builder: NeonEnvBuilder, remote_storage_kind: Remot def test_s3_wal_replay(neon_env_builder: NeonEnvBuilder, remote_storage_kind: RemoteStorageKind): neon_env_builder.num_safekeepers = 3 - neon_env_builder.enable_remote_storage( - remote_storage_kind=remote_storage_kind, - ) - - neon_env_builder.remote_storage_users = RemoteStorageUsers.SAFEKEEPER + neon_env_builder.enable_safekeeper_remote_storage(remote_storage_kind) env = neon_env_builder.init_start() tenant_id = env.initial_tenant diff --git a/test_runner/regress/test_wal_restore.py b/test_runner/regress/test_wal_restore.py index c97c69db23..a4f03be7a0 100644 --- a/test_runner/regress/test_wal_restore.py +++ b/test_runner/regress/test_wal_restore.py @@ -29,7 +29,7 @@ def test_wal_restore( endpoint.safe_psql("create table t as select generate_series(1,300000)") tenant_id = TenantId(endpoint.safe_psql("show neon.tenant_id")[0][0]) timeline_id = TimelineId(endpoint.safe_psql("show neon.timeline_id")[0][0]) - env.neon_cli.pageserver_stop() + env.pageserver.stop() port = port_distributor.get_port() data_dir = test_output_dir / "pgsql.restored" with VanillaPostgres( diff --git a/test_runner/regress/test_walredo_not_left_behind_on_detach.py b/test_runner/regress/test_walredo_not_left_behind_on_detach.py index 4a47898935..155242ed15 100644 --- a/test_runner/regress/test_walredo_not_left_behind_on_detach.py +++ b/test_runner/regress/test_walredo_not_left_behind_on_detach.py @@ -27,7 +27,7 @@ def test_walredo_not_left_behind_on_detach(neon_env_builder: NeonEnvBuilder): env.pageserver.allowed_errors.append(".*NotFound: tenant.*") pageserver_http = env.pageserver.http_client() - pagserver_pid = int((env.repo_dir / "pageserver.pid").read_text()) + pagserver_pid = int((env.pageserver.workdir / "pageserver.pid").read_text()) assert_child_processes(pagserver_pid, wal_redo_present=False, defunct_present=False) @@ -43,7 +43,7 @@ def test_walredo_not_left_behind_on_detach(neon_env_builder: NeonEnvBuilder): tenant_id, _ = env.neon_cli.create_tenant() # assert tenant exists on disk - assert (env.repo_dir / "tenants" / str(tenant_id)).exists() + assert (env.pageserver.workdir / "tenants" / str(tenant_id)).exists() endpoint = env.endpoints.create_start("main", tenant_id=tenant_id) @@ -101,7 +101,7 @@ def test_walredo_not_left_behind_on_detach(neon_env_builder: NeonEnvBuilder): pytest.fail(f"could not detach tenant: {last_error}") # check that nothing is left on disk for deleted tenant - assert not (env.repo_dir / "tenants" / str(tenant_id)).exists() + assert not (env.pageserver.workdir / "tenants" / str(tenant_id)).exists() # Pageserver schedules kill+wait of the WAL redo process to the background runtime, # asynchronously to tenant detach. Cut it some slack to complete kill+wait before