diff --git a/.circleci/config.yml b/.circleci/config.yml index 61f551cd03..f64ba94cb4 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -100,10 +100,8 @@ jobs: name: Rust build << parameters.build_type >> command: | if [[ $BUILD_TYPE == "debug" ]]; then - cov_prefix=(scripts/coverage "--profraw-prefix=$CIRCLE_JOB" --dir=/tmp/zenith/coverage run) CARGO_FLAGS= elif [[ $BUILD_TYPE == "release" ]]; then - cov_prefix=() CARGO_FLAGS="--release --features profiling" fi @@ -112,7 +110,7 @@ jobs: export RUSTC_WRAPPER=cachepot export AWS_ACCESS_KEY_ID="${CACHEPOT_AWS_ACCESS_KEY_ID}" export AWS_SECRET_ACCESS_KEY="${CACHEPOT_AWS_SECRET_ACCESS_KEY}" - "${cov_prefix[@]}" mold -run cargo build $CARGO_FLAGS --features failpoints --bins --tests + mold -run cargo build $CARGO_FLAGS --features failpoints --bins --tests cachepot -s - save_cache: @@ -128,32 +126,24 @@ jobs: name: cargo test command: | if [[ $BUILD_TYPE == "debug" ]]; then - cov_prefix=(scripts/coverage "--profraw-prefix=$CIRCLE_JOB" --dir=/tmp/zenith/coverage run) CARGO_FLAGS= elif [[ $BUILD_TYPE == "release" ]]; then - cov_prefix=() CARGO_FLAGS=--release fi - "${cov_prefix[@]}" cargo test $CARGO_FLAGS + cargo test $CARGO_FLAGS # Install the rust binaries, for use by test jobs - run: name: Install rust binaries command: | - if [[ $BUILD_TYPE == "debug" ]]; then - cov_prefix=(scripts/coverage "--profraw-prefix=$CIRCLE_JOB" --dir=/tmp/zenith/coverage run) - elif [[ $BUILD_TYPE == "release" ]]; then - cov_prefix=() - fi - binaries=$( - "${cov_prefix[@]}" cargo metadata --format-version=1 --no-deps | + cargo metadata --format-version=1 --no-deps | jq -r '.packages[].targets[] | select(.kind | index("bin")) | .name' ) test_exe_paths=$( - "${cov_prefix[@]}" cargo test --message-format=json --no-run | + cargo test --message-format=json --no-run | jq -r '.executable | select(. != null)' ) @@ -166,34 +156,15 @@ jobs: SRC=target/$BUILD_TYPE/$bin DST=/tmp/zenith/bin/$bin cp $SRC $DST - echo $DST >> /tmp/zenith/etc/binaries.list done - # Install test executables (for code coverage) - if [[ $BUILD_TYPE == "debug" ]]; then - for bin in $test_exe_paths; do - SRC=$bin - DST=/tmp/zenith/test_bin/$(basename $bin) - cp $SRC $DST - echo $DST >> /tmp/zenith/etc/binaries.list - done - fi - # Install the postgres binaries, for use by test jobs - run: name: Install postgres binaries command: | cp -a tmp_install /tmp/zenith/pg_install - - run: - name: Merge coverage data - command: | - # This will speed up workspace uploads - if [[ $BUILD_TYPE == "debug" ]]; then - scripts/coverage "--profraw-prefix=$CIRCLE_JOB" --dir=/tmp/zenith/coverage merge - fi - - # Save the rust binaries and coverage data for other jobs in this workflow. + # Save rust binaries for other jobs in the workflow - persist_to_workspace: root: /tmp/zenith paths: @@ -314,12 +285,6 @@ jobs: export GITHUB_SHA=$CIRCLE_SHA1 - if [[ $BUILD_TYPE == "debug" ]]; then - cov_prefix=(scripts/coverage "--profraw-prefix=$CIRCLE_JOB" --dir=/tmp/zenith/coverage run) - elif [[ $BUILD_TYPE == "release" ]]; then - cov_prefix=() - fi - # Run the tests. # # The junit.xml file allows CircleCI to display more fine-grained test information @@ -330,7 +295,7 @@ jobs: # -n4 uses four processes to run tests via pytest-xdist # -s is not used to prevent pytest from capturing output, because tests are running # in parallel and logs are mixed between different tests - "${cov_prefix[@]}" ./scripts/pytest \ + ./scripts/pytest \ --junitxml=$TEST_OUTPUT/junit.xml \ --tb=short \ --verbose \ @@ -359,67 +324,12 @@ jobs: # The store_test_results step tells CircleCI where to find the junit.xml file. - store_test_results: path: /tmp/test_output - - run: - name: Merge coverage data - command: | - # This will speed up workspace uploads - if [[ $BUILD_TYPE == "debug" ]]; then - scripts/coverage "--profraw-prefix=$CIRCLE_JOB" --dir=/tmp/zenith/coverage merge - fi - # Save coverage data (if any) + # Save data (if any) - persist_to_workspace: root: /tmp/zenith paths: - "*" - coverage-report: - executor: neon-xlarge-executor - steps: - - attach_workspace: - at: /tmp/zenith - - checkout - - restore_cache: - name: Restore rust cache - keys: - # Require an exact match. While an out of date cache might speed up the build, - # there's no way to clean out old packages, so the cache grows every time something - # changes. - - v04-rust-cache-deps-debug-{{ checksum "Cargo.lock" }} - - run: - name: Build coverage report - command: | - COMMIT_URL=https://github.com/neondatabase/neon/commit/$CIRCLE_SHA1 - - scripts/coverage \ - --dir=/tmp/zenith/coverage report \ - --input-objects=/tmp/zenith/etc/binaries.list \ - --commit-url=$COMMIT_URL \ - --format=github - - run: - name: Upload coverage report - command: | - LOCAL_REPO=$CIRCLE_PROJECT_USERNAME/$CIRCLE_PROJECT_REPONAME - REPORT_URL=https://neondatabase.github.io/zenith-coverage-data/$CIRCLE_SHA1 - COMMIT_URL=https://github.com/neondatabase/neon/commit/$CIRCLE_SHA1 - - scripts/git-upload \ - --repo=https://$VIP_VAP_ACCESS_TOKEN@github.com/neondatabase/zenith-coverage-data.git \ - --message="Add code coverage for $COMMIT_URL" \ - copy /tmp/zenith/coverage/report $CIRCLE_SHA1 # COPY FROM TO_RELATIVE - - # Add link to the coverage report to the commit - curl -f -X POST \ - https://api.github.com/repos/$LOCAL_REPO/statuses/$CIRCLE_SHA1 \ - -H "Accept: application/vnd.github.v3+json" \ - --user "$CI_ACCESS_TOKEN" \ - --data \ - "{ - \"state\": \"success\", - \"context\": \"zenith-coverage\", - \"description\": \"Coverage report is ready\", - \"target_url\": \"$REPORT_URL\" - }" - # Build neondatabase/neon:latest image and push it to Docker hub docker-image: docker: @@ -730,12 +640,6 @@ workflows: save_perf_report: true requires: - build-neon-release - - coverage-report: - # Context passes credentials for gh api - context: CI_ACCESS_TOKEN - requires: - # TODO: consider adding more - - other-tests-debug - docker-image: # Context gives an ability to login context: Docker Hub diff --git a/.github/actions/run-python-test-set/action.yml b/.github/actions/run-python-test-set/action.yml index 4831cdaed1..48c0c2b925 100644 --- a/.github/actions/run-python-test-set/action.yml +++ b/.github/actions/run-python-test-set/action.yml @@ -92,7 +92,7 @@ runs: fi if [[ "${{ inputs.build_type }}" == "debug" ]]; then - cov_prefix=(scripts/coverage "--profraw-prefix=$GITHUB_JOB" --dir=/tmp/neon/coverage run) + cov_prefix=(scripts/coverage "--profraw-prefix=$GITHUB_JOB" --dir=/tmp/coverage run) elif [[ "${{ inputs.build_type }}" == "release" ]]; then cov_prefix=() fi diff --git a/.github/actions/save-coverage-data/action.yml b/.github/actions/save-coverage-data/action.yml index 7b228f636f..7ad04cf1fe 100644 --- a/.github/actions/save-coverage-data/action.yml +++ b/.github/actions/save-coverage-data/action.yml @@ -6,7 +6,7 @@ runs: steps: - name: Merge coverage data shell: bash -ex {0} - run: scripts/coverage "--profraw-prefix=$GITHUB_JOB" --dir=/tmp/neon/coverage/ merge + run: scripts/coverage "--profraw-prefix=$GITHUB_JOB" --dir=/tmp/coverage merge - name: Upload coverage data uses: actions/upload-artifact@v3 @@ -14,4 +14,4 @@ runs: retention-days: 7 if-no-files-found: error name: coverage-data-artifact - path: /tmp/neon/coverage/ + path: /tmp/coverage/ diff --git a/.github/workflows/build_and_test.yml b/.github/workflows/build_and_test.yml index 2508c32bff..81b4585714 100644 --- a/.github/workflows/build_and_test.yml +++ b/.github/workflows/build_and_test.yml @@ -17,11 +17,6 @@ concurrency: env: RUST_BACKTRACE: 1 COPT: '-Werror' - AWS_ACCESS_KEY_ID: ${{ secrets.CACHEPOT_AWS_ACCESS_KEY_ID }} - AWS_SECRET_ACCESS_KEY: ${{ secrets.CACHEPOT_AWS_SECRET_ACCESS_KEY }} - CACHEPOT_BUCKET: zenith-rust-cachepot - RUSTC_WRAPPER: cachepot - jobs: build-postgres: @@ -54,7 +49,7 @@ jobs: - name: Build postgres if: steps.cache_pg.outputs.cache-hit != 'true' - run: COPT='-Werror' mold -run make postgres -j$(nproc) + run: mold -run make postgres -j$(nproc) # actions/cache@v3 does not allow concurrently using the same cache across job steps, so use a separate cache - name: Prepare postgres artifact @@ -106,12 +101,15 @@ jobs: ~/.cargo/registry/ ~/.cargo/git/ target/ - key: v2-${{ runner.os }}-${{ matrix.build_type }}-cargo-${{ matrix.rust_toolchain }}-${{ hashFiles('Cargo.lock') }} + # Fall back to older versions of the key, if no cache for current Cargo.lock was found + key: | + v2-${{ runner.os }}-${{ matrix.build_type }}-cargo-${{ matrix.rust_toolchain }}-${{ hashFiles('Cargo.lock') }} + v2-${{ runner.os }}-${{ matrix.build_type }}-cargo-${{ matrix.rust_toolchain }}- - name: Run cargo build run: | if [[ $BUILD_TYPE == "debug" ]]; then - cov_prefix=(scripts/coverage "--profraw-prefix=$GITHUB_JOB" --dir=/tmp/neon/coverage run) + cov_prefix=(scripts/coverage "--profraw-prefix=$GITHUB_JOB" --dir=/tmp/coverage run) CARGO_FLAGS= elif [[ $BUILD_TYPE == "release" ]]; then cov_prefix=() @@ -119,12 +117,11 @@ jobs: fi "${cov_prefix[@]}" mold -run cargo build $CARGO_FLAGS --features failpoints --bins --tests - cachepot -s - name: Run cargo test run: | if [[ $BUILD_TYPE == "debug" ]]; then - cov_prefix=(scripts/coverage "--profraw-prefix=$GITHUB_JOB" --dir=/tmp/neon/coverage run) + cov_prefix=(scripts/coverage "--profraw-prefix=$GITHUB_JOB" --dir=/tmp/coverage run) CARGO_FLAGS= elif [[ $BUILD_TYPE == "release" ]]; then cov_prefix=() @@ -136,7 +133,7 @@ jobs: - name: Install rust binaries run: | if [[ $BUILD_TYPE == "debug" ]]; then - cov_prefix=(scripts/coverage "--profraw-prefix=$GITHUB_JOB" --dir=/tmp/neon/coverage run) + cov_prefix=(scripts/coverage "--profraw-prefix=$GITHUB_JOB" --dir=/tmp/coverage run) elif [[ $BUILD_TYPE == "release" ]]; then cov_prefix=() fi @@ -154,7 +151,9 @@ jobs: mkdir -p /tmp/neon/bin/ mkdir -p /tmp/neon/test_bin/ mkdir -p /tmp/neon/etc/ - mkdir -p /tmp/neon/coverage/ + + # Keep bloated coverage data files away from the rest of the artifact + mkdir -p /tmp/coverage/ # Install target binaries for bin in $binaries; do @@ -166,13 +165,13 @@ jobs: # Install test executables and write list of all binaries (for code coverage) if [[ $BUILD_TYPE == "debug" ]]; then for bin in $binaries; do - echo "/tmp/neon/bin/$bin" >> /tmp/neon/coverage/binaries.list + echo "/tmp/neon/bin/$bin" >> /tmp/coverage/binaries.list done for bin in $test_exe_paths; do SRC=$bin DST=/tmp/neon/test_bin/$(basename $bin) cp "$SRC" "$DST" - echo "$DST" >> /tmp/neon/coverage/binaries.list + echo "$DST" >> /tmp/coverage/binaries.list done fi @@ -316,7 +315,10 @@ jobs: uses: actions/download-artifact@v3 with: name: coverage-data-artifact - path: /tmp/neon/coverage/ + path: /tmp/coverage/ + + - name: Merge coverage data + run: scripts/coverage "--profraw-prefix=$GITHUB_JOB" --dir=/tmp/coverage merge - name: Build and upload coverage report run: | @@ -325,8 +327,8 @@ jobs: COMMIT_URL=https://github.com/${{ github.repository }}/commit/$COMMIT_SHA scripts/coverage \ - --dir=/tmp/neon/coverage report \ - --input-objects=/tmp/neon/coverage/binaries.list \ + --dir=/tmp/coverage report \ + --input-objects=/tmp/coverage/binaries.list \ --commit-url=$COMMIT_URL \ --format=github @@ -335,7 +337,7 @@ jobs: scripts/git-upload \ --repo=https://${{ secrets.VIP_VAP_ACCESS_TOKEN }}@github.com/${{ github.repository_owner }}/zenith-coverage-data.git \ --message="Add code coverage for $COMMIT_URL" \ - copy /tmp/neon/coverage/report $COMMIT_SHA # COPY FROM TO_RELATIVE + copy /tmp/coverage/report $COMMIT_SHA # COPY FROM TO_RELATIVE # Add link to the coverage report to the commit curl -f -X POST \ diff --git a/.github/workflows/codestyle.yml b/.github/workflows/codestyle.yml index 292c2c903b..2b8a01e94e 100644 --- a/.github/workflows/codestyle.yml +++ b/.github/workflows/codestyle.yml @@ -26,7 +26,7 @@ jobs: # Rust toolchains (e.g. nightly or 1.37.0), add them here. rust_toolchain: [1.58] os: [ubuntu-latest, macos-latest] - timeout-minutes: 30 + timeout-minutes: 50 name: run regression test suite runs-on: ${{ matrix.os }} diff --git a/pageserver/src/tenant_config.rs b/pageserver/src/tenant_config.rs index 1722c1a13a..8811009743 100644 --- a/pageserver/src/tenant_config.rs +++ b/pageserver/src/tenant_config.rs @@ -37,7 +37,7 @@ pub mod defaults { pub const DEFAULT_PITR_INTERVAL: &str = "30 days"; pub const DEFAULT_WALRECEIVER_CONNECT_TIMEOUT: &str = "2 seconds"; pub const DEFAULT_WALRECEIVER_LAGGING_WAL_TIMEOUT: &str = "10 seconds"; - pub const DEFAULT_MAX_WALRECEIVER_LSN_WAL_LAG: u64 = 10_000; + pub const DEFAULT_MAX_WALRECEIVER_LSN_WAL_LAG: u64 = 10 * 1024 * 1024; } /// Per-tenant configuration options diff --git a/pageserver/src/walreceiver.rs b/pageserver/src/walreceiver.rs index fd9468a101..2b5a3123c1 100644 --- a/pageserver/src/walreceiver.rs +++ b/pageserver/src/walreceiver.rs @@ -178,7 +178,7 @@ async fn shutdown_all_wal_connections( /// That may lead to certain events not being observed by the listener. #[derive(Debug)] struct TaskHandle { - handle: JoinHandle<()>, + handle: JoinHandle>, events_receiver: watch::Receiver>, cancellation: watch::Sender<()>, } @@ -205,8 +205,8 @@ impl TaskHandle { let sender = Arc::clone(&events_sender); let handle = tokio::task::spawn(async move { - let task_result = task(sender, cancellation_receiver).await; - events_sender.send(TaskEvent::End(task_result)).ok(); + events_sender.send(TaskEvent::Started).ok(); + task(sender, cancellation_receiver).await }); TaskHandle { @@ -216,6 +216,16 @@ impl TaskHandle { } } + async fn next_task_event(&mut self) -> TaskEvent { + select! { + next_task_event = self.events_receiver.changed() => match next_task_event { + Ok(()) => self.events_receiver.borrow().clone(), + Err(_task_channel_part_dropped) => join_on_handle(&mut self.handle).await, + }, + task_completion_result = join_on_handle(&mut self.handle) => task_completion_result, + } + } + /// Aborts current task, waiting for it to finish. async fn shutdown(self) { self.cancellation.send(()).ok(); @@ -225,6 +235,19 @@ impl TaskHandle { } } +async fn join_on_handle(handle: &mut JoinHandle>) -> TaskEvent { + match handle.await { + Ok(task_result) => TaskEvent::End(task_result), + Err(e) => { + if e.is_cancelled() { + TaskEvent::End(Ok(())) + } else { + TaskEvent::End(Err(format!("WAL receiver task panicked: {e}"))) + } + } + } +} + /// A step to process timeline attach/detach events to enable/disable the corresponding WAL receiver machinery. /// In addition to WAL streaming management, the step ensures that corresponding tenant has its service threads enabled or disabled. /// This is done here, since only walreceiver knows when a certain tenant has no streaming enabled. diff --git a/pageserver/src/walreceiver/connection_manager.rs b/pageserver/src/walreceiver/connection_manager.rs index d5ca1d5159..614bca50ad 100644 --- a/pageserver/src/walreceiver/connection_manager.rs +++ b/pageserver/src/walreceiver/connection_manager.rs @@ -104,49 +104,29 @@ async fn connection_manager_loop_step( Some(wal_connection_update) = async { match walreceiver_state.wal_connection.as_mut() { - Some(wal_connection) => { - let receiver = &mut wal_connection.connection_task.events_receiver; - Some(match receiver.changed().await { - Ok(()) => receiver.borrow().clone(), - Err(_cancellation_error) => TaskEvent::End(Ok(())), - }) - } + Some(wal_connection) => Some(wal_connection.connection_task.next_task_event().await), None => None, } } => { - let (connection_update, reset_connection_attempts) = match &wal_connection_update { - TaskEvent::Started => (Some(Utc::now().naive_utc()), true), - TaskEvent::NewEvent(replication_feedback) => (Some(DateTime::::from(replication_feedback.ps_replytime).naive_utc()), true), + let wal_connection = walreceiver_state.wal_connection.as_mut().expect("Should have a connection, as checked by the corresponding select! guard"); + match &wal_connection_update { + TaskEvent::Started => { + wal_connection.latest_connection_update = Utc::now().naive_utc(); + *walreceiver_state.wal_connection_attempts.entry(wal_connection.sk_id).or_insert(0) += 1; + }, + TaskEvent::NewEvent(replication_feedback) => { + wal_connection.latest_connection_update = DateTime::::from(replication_feedback.ps_replytime).naive_utc(); + // reset connection attempts here only, the only place where both nodes + // explicitly confirmn with replication feedback that they are connected to each other + walreceiver_state.wal_connection_attempts.remove(&wal_connection.sk_id); + }, TaskEvent::End(end_result) => { - let should_reset_connection_attempts = match end_result { - Ok(()) => { - debug!("WAL receiving task finished"); - true - }, - Err(e) => { - warn!("WAL receiving task failed: {e}"); - false - }, + match end_result { + Ok(()) => debug!("WAL receiving task finished"), + Err(e) => warn!("WAL receiving task failed: {e}"), }; walreceiver_state.wal_connection = None; - (None, should_reset_connection_attempts) }, - }; - - if let Some(connection_update) = connection_update { - match &mut walreceiver_state.wal_connection { - Some(wal_connection) => { - wal_connection.latest_connection_update = connection_update; - - let attempts_entry = walreceiver_state.wal_connection_attempts.entry(wal_connection.sk_id).or_insert(0); - if reset_connection_attempts { - *attempts_entry = 0; - } else { - *attempts_entry += 1; - } - }, - None => error!("Received connection update for WAL connection that is not active, update: {wal_connection_update:?}"), - } } }, @@ -406,10 +386,8 @@ impl WalreceiverState { Some(existing_wal_connection) => { let connected_sk_node = existing_wal_connection.sk_id; - let (new_sk_id, new_safekeeper_etcd_data, new_wal_producer_connstr) = self - .applicable_connection_candidates() - .filter(|&(sk_id, _, _)| sk_id != connected_sk_node) - .max_by_key(|(_, info, _)| info.commit_lsn)?; + let (new_sk_id, new_safekeeper_etcd_data, new_wal_producer_connstr) = + self.select_connection_candidate(Some(connected_sk_node))?; let now = Utc::now().naive_utc(); if let Ok(latest_interaciton) = @@ -462,9 +440,8 @@ impl WalreceiverState { } } None => { - let (new_sk_id, _, new_wal_producer_connstr) = self - .applicable_connection_candidates() - .max_by_key(|(_, info, _)| info.commit_lsn)?; + let (new_sk_id, _, new_wal_producer_connstr) = + self.select_connection_candidate(None)?; return Some(NewWalConnectionCandidate { safekeeper_id: new_sk_id, wal_producer_connstr: new_wal_producer_connstr, @@ -476,6 +453,49 @@ impl WalreceiverState { None } + /// Selects the best possible candidate, based on the data collected from etcd updates about the safekeepers. + /// Optionally, omits the given node, to support gracefully switching from a healthy safekeeper to another. + /// + /// The candidate that is chosen: + /// * has fewest connection attempts from pageserver to safekeeper node (reset every time the WAL replication feedback is sent) + /// * has greatest data Lsn among the ones that are left + /// + /// NOTE: + /// We evict timeline data received from etcd based on time passed since it was registered, along with its connection attempts values, but + /// otherwise to reset the connection attempts, a successful connection to that node is needed. + /// That won't happen now, before all nodes with less connection attempts are connected to first, which might leave the sk node with more advanced state to be ignored. + fn select_connection_candidate( + &self, + node_to_omit: Option, + ) -> Option<(NodeId, &SkTimelineInfo, String)> { + let all_candidates = self + .applicable_connection_candidates() + .filter(|&(sk_id, _, _)| Some(sk_id) != node_to_omit) + .collect::>(); + + let smallest_attempts_allowed = all_candidates + .iter() + .map(|(sk_id, _, _)| { + self.wal_connection_attempts + .get(sk_id) + .copied() + .unwrap_or(0) + }) + .min()?; + + all_candidates + .into_iter() + .filter(|(sk_id, _, _)| { + smallest_attempts_allowed + >= self + .wal_connection_attempts + .get(sk_id) + .copied() + .unwrap_or(0) + }) + .max_by_key(|(_, info, _)| info.commit_lsn) + } + fn applicable_connection_candidates( &self, ) -> impl Iterator { @@ -500,15 +520,25 @@ impl WalreceiverState { } fn cleanup_old_candidates(&mut self) { - self.wal_stream_candidates.retain(|_, etcd_info| { + let mut node_ids_to_remove = Vec::with_capacity(self.wal_stream_candidates.len()); + + self.wal_stream_candidates.retain(|node_id, etcd_info| { if let Ok(time_since_latest_etcd_update) = (Utc::now().naive_utc() - etcd_info.latest_update).to_std() { - time_since_latest_etcd_update < self.lagging_wal_timeout + let should_retain = time_since_latest_etcd_update < self.lagging_wal_timeout; + if !should_retain { + node_ids_to_remove.push(*node_id); + } + should_retain } else { true } }); + + for node_id in node_ids_to_remove { + self.wal_connection_attempts.remove(&node_id); + } } } @@ -843,6 +873,64 @@ mod tests { Ok(()) } + #[tokio::test] + async fn candidate_with_many_connection_failures() -> anyhow::Result<()> { + let harness = RepoHarness::create("candidate_with_many_connection_failures")?; + let mut state = dummy_state(&harness); + let now = Utc::now().naive_utc(); + + let current_lsn = Lsn(100_000).align(); + let bigger_lsn = Lsn(current_lsn.0 + 100).align(); + + state.wal_connection = None; + state.wal_stream_candidates = HashMap::from([ + ( + NodeId(0), + EtcdSkTimeline { + timeline: SkTimelineInfo { + last_log_term: None, + flush_lsn: None, + commit_lsn: Some(bigger_lsn), + backup_lsn: None, + remote_consistent_lsn: None, + peer_horizon_lsn: None, + safekeeper_connstr: Some(DUMMY_SAFEKEEPER_CONNSTR.to_string()), + }, + etcd_version: 0, + latest_update: now, + }, + ), + ( + NodeId(1), + EtcdSkTimeline { + timeline: SkTimelineInfo { + last_log_term: None, + flush_lsn: None, + commit_lsn: Some(current_lsn), + backup_lsn: None, + remote_consistent_lsn: None, + peer_horizon_lsn: None, + safekeeper_connstr: Some(DUMMY_SAFEKEEPER_CONNSTR.to_string()), + }, + etcd_version: 0, + latest_update: now, + }, + ), + ]); + state.wal_connection_attempts = HashMap::from([(NodeId(0), 1), (NodeId(1), 0)]); + + let candidate_with_less_errors = state + .next_connection_candidate() + .expect("Expected one candidate selected, but got none"); + assert_eq!( + candidate_with_less_errors.safekeeper_id, + NodeId(1), + "Should select the node with less connection errors" + ); + + Ok(()) + } + #[tokio::test] async fn connection_no_etcd_data_candidate() -> anyhow::Result<()> { let harness = RepoHarness::create("connection_no_etcd_data_candidate")?;