Przygotowanie do rozmowy kwalifikacyjnej z Rust

Pytania rekrutacyjne dla programisty backendowego Rust

15 wybranych pytań rekrutacyjnych dla programistów backendowych Rust, pogrupowanych według poziomu doświadczenia. Wykorzystaj je do powtórzenia podstaw, rozważenia praktycznych kompromisów i wnioskowania na poziomie seniora w kontekście produkcyjnym.

Rozpocznij wywiad AI z RustKarta kredytowa nie jest wymagana. Dostępna 1 darmowa sesja.
Trening technicznych rozmów rekrutacyjnych po angielskuTryb dla osób, dla których angielski nie jest językiem ojczystym, do ćwiczenia rozmów technicznych.

Pytania dla Juniorów

1Wyjaśnij zasady własności Rust i jak kształtują one praktyczne wybory w tworzeniu backendu, takie jak klonowanie, pożyczanie i współdzielenie stanu aplikacji.

Rust przypisuje każdej wartości jednego właściciela. Przeniesienie wartości niebędącej typu `Copy` przenosi własność, a wartość jest usuwana (drop) po wyjściu jej właściciela poza zakres. Kod może pożyczać dane niemutowalnie poprzez współdzielone referencje lub mutowalnie poprzez wyłączną mutowalną referencję. W kodzie backendowym wpływa to na to, czy funkcja przejmuje własność, pożycza tymczasowo, klonuje, aby uzyskać niezależną wartość, której jest właścicielem, czy też współdzieli długożyjący stan za pomocą uchwytów, takich jak `Arc`, z synchronizacją lub innymi prymitywami współbieżności, gdy potrzebna jest współdzielona mutacja.

use std::sync::Arc;

struct AppState {
    service_name: String,
}

async fn handler(state: Arc<AppState>, request_id: String) -> String {
    // Borrow: no ownership transfer of the String inside state.
    let name: &str = &state.service_name;

    // Clone only if an owned independent value is needed.
    let owned_name = name.to_owned();

    format!("{owned_name}:{request_id}")
}

fn configure(state: Arc<AppState>) {
    // Cloning Arc increments the reference count; it does not clone AppState.
    let state_for_route = Arc::clone(&state);
    let _ = state_for_route;
}
Odpowiedz na to pytanie z trenerem AI

2Jaka jest różnica między pożyczaniem za pomocą `&T` i `&mut T`, i jak te zasady wpływają na interfejsy programistyczne (API) i sygnatury funkcji obsługujących (handler signatures)?

`&T` to współdzielone odniesienie (shared reference): umożliwia dostęp tylko do odczytu, a wiele współdzielonych odniesień do tej samej wartości może być aktywnych jednocześnie. `&mut T` to wyłączne, mutowalne odniesienie (exclusive mutable reference): pozwala na modyfikację, ale gdy jest aktywne, żadne inne współdzielone ani mutowalne odniesienia do tej samej wartości nie mogą być używane. Te zasady kształtują interfejsy programistyczne (API), sprawiając, że funkcje tylko do odczytu akceptują `&T` lub węższe typy pożyczone, takie jak `&str`, podczas gdy funkcje modyfikujące akceptują `&mut T`, przejmują własność (ownership) lub używają wewnętrznej mutowalności (interior mutability)/synchronizacji. Funkcje obsługujące (handlery) w warstwie backendowej zazwyczaj unikają prostego dostępu `&mut` do współdzielonego stanu aplikacji (shared application state) w przypadku równoczesnych żądań, a zamiast tego używają współdzielonych uchwytów (shared handles) wraz z synchronizacją lub innymi wzorcami własności.

struct User {
    name: String,
    login_count: u64,
}

fn display_name(user: &User) -> &str {
    &user.name
}

fn record_login(user: &mut User) {
    user.login_count += 1;
}

fn main() {
    let mut user = User { name: "Ada".into(), login_count: 0 };

    let name = display_name(&user); // shared borrow for reading
    println!("{name}");

    record_login(&mut user); // exclusive mutable borrow for mutation
}
Odpowiedz na to pytanie z trenerem AI

3Jak działa `Box` i jakie problemy rozwiązuje alokacja na stercie za pomocą `Box`?

`Box<T>` to wskaźnik inteligentny (owning smart pointer) posiadający własność, którego `T` jest przechowywane na stercie, podczas gdy uchwyt `Box` jest wartością stałego rozmiaru przypominającą wskaźnik. Posiada pojedyncze prawo własności i zwalnia alokację na stercie, gdy wychodzi poza zakres. `Box` zapewnia indyrekcję, co pomaga w przypadku dużych wartości, typów rekurencyjnych, które wymagają znanego rozmiaru, wartości o dynamicznym rozmiarze oraz obiektów cech (trait objects), takich jak `Box<dyn Trait>`. Przeniesienie `Box` przenosi prawo własności alokacji bez przenoszenia ani kopiowania samej wartości przechowywanej na stercie.

enum List {
    Cons(i32, Box<List>),
    Nil,
}

let list = List::Cons(1, Box::new(List::Cons(2, Box::new(List::Nil))));
Odpowiedz na to pytanie z trenerem AI

4Opisz `Result` i `Option` oraz główne idiomatyczne sposoby pracy z brakiem wartości i operacjami, które mogą zakończyć się niepowodzeniem.

`Option<T>` reprezentuje `Some(T)` lub `None` i jest używany do oczekiwanego braku wartości. `Result<T, E>` reprezentuje `Ok(T)` lub `Err(E)` i jest używany dla operacji, które mogą zakończyć się niepowodzeniem, gdzie informacja o błędzie jest istotna. Idiomatyczne sposoby obsługi obejmują `match`, `if let`/`let else`, kombinatory takie jak `map`, `and_then`, `ok_or`, `unwrap_or` i `unwrap_or_else`, a także operator `?` do wczesnego propagowania z funkcji zwracających kompatybilne typy `Option` lub `Result`. Metody `unwrap` i `expect` najlepiej rezerwować dla niezmienników, testów, prototypów lub sytuacji, z których nie da się odzyskać, a nie dla normalnych, możliwych do odzyskania błędów w systemach backendowych.

fn parse_port(s: Option<&str>) -> u16 {
    s.and_then(|v| v.parse::<u16>().ok())
     .unwrap_or(8080)
}

assert_eq!(parse_port(Some("3000")), 3000);
assert_eq!(parse_port(None), 8080);
Odpowiedz na to pytanie z trenerem AI

5Jak działa operator `?` i co umożliwia konwersję między różnymi typami błędów?

Operator `?` jest skrótem do propagacji błędu. Dla typu `Result`, `expr?` zwraca wartość `Ok` i kontynuuje działanie, lub wcześnie zwraca `Err` z bieżącej funkcji; dla typu `Option`, zwraca wartość `Some` lub `None`. Funkcja nadrzędna musi zwracać kompatybilny typ. W przypadku `Result`, różne typy błędów mogą się komponować, ponieważ błąd źródłowy jest konwertowany na typ błędu funkcji za pomocą konwersji w stylu `From`/`Into`, często implementowanej ręcznie lub wyprowadzonej za pomocą pomocniczych bibliotek (crates), takich jak `thiserror`.

use std::{fs, io, num::ParseIntError};

#[derive(Debug)]
enum AppError {
    Io(io::Error),
    Parse(ParseIntError),
}

impl From<io::Error> for AppError {
    fn from(e: io::Error) -> Self { AppError::Io(e) }
}
impl From<ParseIntError> for AppError {
    fn from(e: ParseIntError) -> Self { AppError::Parse(e) }
}

fn read_number(path: &str) -> Result<i32, AppError> {
    let s = fs::read_to_string(path)?; // io::Error -> AppError
    let n = s.trim().parse::<i32>()?;  // ParseIntError -> AppError
    Ok(n)
}
Odpowiedz na to pytanie z trenerem AI

6Wyjaśnij dopasowywanie wzorców (pattern matching) na typach wyliczeniowych (enumach) i jak wyczerpujące dopasowywanie (exhaustive matching) poprawia poprawność dziedziny i API.

Typy wyliczeniowe (enumy) modelują wartość, która może być jednym z ustalonego zestawu wariantów, a warianty mogą przenosić dane. Instrukcja `match` rozgałęzia się według wariantu i może destrukturyzować wewnętrzne dane. Rust sprawdza, czy dopasowania enumów są wyczerpujące, chyba że użyto wzorca wieloznacznego lub „catch-all”. Poprawia to poprawność dziedziny i API, ponieważ gdy dodany zostanie nowy stan lub wariant odpowiedzi, kompilator może wymusić na kodzie, który go dopasowuje, podjęcie decyzji, jak obsłużyć nową sytuację, zamiast po cichu ją ignorować.

enum PaymentStatus {
    Pending,
    Paid { receipt_id: String },
    Failed(String),
}

fn message(status: PaymentStatus) -> String {
    match status {
        PaymentStatus::Pending => "Payment is pending".to_string(),
        PaymentStatus::Paid { receipt_id } => format!("Paid, receipt {receipt_id}"),
        PaymentStatus::Failed(reason) => format!("Payment failed: {reason}"),
    }
}
Odpowiedz na to pytanie z trenerem AI

7Czym są opakowania newtype (newtype wrappers) i jak poprawiają bezpieczeństwo typów (type safety) dla identyfikatorów, pieniędzy, tokenów i poufnych danych (secrets)?

Opakowanie newtype (newtype wrapper) to odrębny typ w Rust, często struktura krotkowa z jednym polem, opakowująca istniejącą reprezentację, taką jak `struct UserId(Uuid);` lub `struct AccessToken(String);`. Poprawia bezpieczeństwo typów (type safety), ponieważ kompilator nie pomyli semantycznie różnych wartości, które współdzielą ten sam bazowy typ, takich jak `UserId` w porównaniu z `OrderId`, centy w porównaniu z dolarami, lub surowe ciągi znaków w porównaniu ze zweryfikowanymi tokenami. Newtypes mogą również centralizować reguły konstrukcji i walidacji oraz kontrolować cechy (traits), takie jak `Display`, `Debug`, `Serialize`, lub zachowanie redakcji (redaction behavior) dla poufnych danych (secrets).

use uuid::Uuid;

#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash)]
struct UserId(Uuid);

#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash)]
struct OrderId(Uuid);

struct SecretToken(String);

impl std::fmt::Debug for SecretToken {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.write_str("SecretToken(**redacted**)")
    }
}

fn load_user(id: UserId) {
    // query by user id
}

fn example(user_id: UserId, order_id: OrderId) {
    load_user(user_id);
    // load_user(order_id); // does not compile: expected UserId, found OrderId
}
Odpowiedz na to pytanie z trenerem AI

Pytania dla Midów

8Jak działa cecha `Drop`, uwzględniając kolejność zwalniania, czyszczenie RAII (Resource Acquisition Is Initialization) i ograniczenia dotyczące asynchronicznego czyszczenia?

`Drop` to deterministyczny hak czyszczący w Rust. Kiedy posiadana wartość zostaje zniszczona, na przykład gdy wychodzi poza zakres, Rust automatycznie wywołuje jej metodę `drop(&mut self)`, jeśli implementuje ona cechę `Drop`, a następnie zwalnia jej pola. To wspiera RAII: zasoby takie jak pliki, gniazda, blokady, transakcje, pozwolenia lub bufory są powiązane z wartościami/strażnikami posiadającymi i są zwalniane, gdy te wartości zostają usunięte. Zmienne lokalne są zwalniane w odwrotnej kolejności tworzenia; dla struktury z `Drop`, metoda `drop` struktury uruchamia się, zanim jej pola zostaną zwolnione, a pola są zwalniane w kolejności deklaracji. `Drop::drop` jest synchroniczne i nie może być `async` ani `await`owane, więc eleganckie asynchroniczne czyszczenie zazwyczaj wymaga jawnej asynchronicznej metody zamknięcia/wyłączenia/opróżnienia lub innego projektu; `Drop` może wykonywać tylko synchroniczne lub na miarę możliwości czyszczenie.

struct Guard(&'static str);

impl Drop for Guard {
    fn drop(&mut self) {
        println!("drop {}", self.0);
    }
}

struct Pair {
    first: Guard,
    second: Guard,
}

impl Drop for Pair {
    fn drop(&mut self) {
        println!("drop Pair");
    }
}

fn main() {
    let _a = Guard("a");
    let _pair = Pair {
        first: Guard("first"),
        second: Guard("second"),
    };
    let _b = Guard("b");
}
Odpowiedz na to pytanie z trenerem AI

9Wyjaśnij, czym są czasy życia (lifetimes) i dlaczego jawne adnotacje czasu życia są czasami wymagane w API w Rust.

Czas życia (lifetime) opisuje, jak długo referencja jest ważna, co pozwala kompilatorowi odrzucać zwisające referencje. Adnotacje czasu życia to ograniczenia czasu kompilacji, które wyrażają relacje między referencjami; nie wydłużają one czasu życia bazowych danych ani nie dodają zachowania w czasie wykonania. Wiele prostych sygnatur jest obsługiwanych przez elisionowanie czasów życia, ale jawne adnotacje są potrzebne, gdy kompilator nie jest w stanie wydedukować, jak referencje wejściowe i wyjściowe są ze sobą powiązane, gdy typy przechowują referencje, lub gdy API musi wyrażać generyczne ograniczenia pożyczania (borrowing constraints).

fn longest<'a>(left: &'a str, right: &'a str) -> &'a str {
    if left.len() >= right.len() { left } else { right }
}

struct UserView<'a> {
    name: &'a str,
}

fn main() {
    let a = String::from("short");
    let b = String::from("longer");
    let result = longest(&a, &b);
    let view = UserView { name: result };
    println!("{}", view.name);
}
Odpowiedz na to pytanie z trenerem AI

10Co oznacza czas życia `'static` i jak współdziała z tworzonymi zadaniami (spawned tasks) oraz projektowaniem API (Application Programming Interface) zaplecza (backendu)?

`'static` na referencji oznacza, że dane, do których się odnosi, są ważne przez cały czas trwania programu, tak jak w przypadku literałów ciągu znaków (string literals) lub elementów statycznych (`static` items). Ograniczenie typu `T: 'static` oznacza, że wartości typu `T` nie zawierają niestatycznych pożyczonych referencji (non-`'static` borrowed references), więc mogą być przechowywane przez dowolny czas życia; nie oznacza to, że sama wartość musi żyć wiecznie lub nie może zostać usunięta. Tworzone zadania (spawned tasks) często wymagają `futures` z `'static` ze względu na to, że egzekutor (executor) może kontynuować ich uruchamianie po zakończeniu ramki stosu (stack frame) tworzącej zadanie. W kodzie zaplecza (backend code) oznacza to zazwyczaj przenoszenie danych na własność (owned data) do zadania za pomocą `async move`, używanie typów na własność (owned types) lub klonowanie współdzielonych uchwytów (shared handles), takich jak `Arc`, zamiast pożyczania zmiennych lokalnych. Interfejsy API (Application Programming Interface) powinny używać ograniczeń `'static` dla przechowywanych wywołań zwrotnych (callbacks), zadań w tle (background jobs) i długotrwałego stanu (long-lived state), ale unikać niepotrzebnych ograniczeń `'static` dla krótkotrwałych operacji pożyczania (borrowed operations).

use std::sync::Arc;

struct AppState {
    name: String,
}

fn spawn_background(state: Arc<AppState>, user_id: String) {
    tokio::spawn(async move {
        // The task owns an Arc handle and a String, so it does not borrow this function's stack.
        println!("{}:{user_id}", state.name);
    });
}
Odpowiedz na to pytanie z trenerem AI

11Opisz, w jaki sposób Rust używa cech (traits) do polimorfizmu, i porównaj `impl Trait`, generyki oraz `dyn Trait`.

Rust używa cech (traits) do wyrażania współdzielonego zachowania. Ograniczenia generyczne, takie jak `fn f<T: Trait>(x: T)`, zazwyczaj wykorzystują statyczne wysyłanie (static dispatch) poprzez monomorfizację. `impl Trait` w pozycji argumentu jest głównie skrótem dla anonimowego parametru generycznego. `impl Trait` w pozycji zwracanej ukrywa jeden konkretny typ zwracany, wybrany przez funkcję. `dyn Trait` to obiekt cechy (trait object) używany za wskaźnikiem, np. `&dyn Trait` lub `Box<dyn Trait)`; wykorzystuje dynamiczne wysyłanie (dynamic dispatch) poprzez vtable i obsługuje wymazywanie typów (type erasure) w czasie wykonania / wartości heterogeniczne, z zastrzeżeniem ograniczeń bezpieczeństwa obiektów (object-safety restrictions).

trait Render { fn render(&self) -> String; }

struct Html;
struct Json;

impl Render for Html { fn render(&self) -> String { "html".into() } }
impl Render for Json { fn render(&self) -> String { "json".into() } }

fn render_generic<T: Render>(x: &T) -> String { x.render() } // static dispatch
fn render_impl_arg(x: &impl Render) -> String { x.render() } // anonymous generic
fn make_renderer() -> impl Render { Html } // one hidden concrete type
fn render_dyn(x: &dyn Render) -> String { x.render() } // dynamic dispatch

fn main() {
    let items: Vec<Box<dyn Render>> = vec![Box::new(Html), Box::new(Json)];
    for item in items { println!("{}", render_dyn(item.as_ref())); }
}
Odpowiedz na to pytanie z trenerem AI

12Czym są typy skojarzone (associated types) w cechach (traits) i kiedy są preferowane w stosunku do generycznych parametrów typów?

Typy skojarzone to nazwane symbole zastępcze (placeholders) typów, deklarowane wewnątrz cechy i sprecyzowane przez każdą implementację, na przykład `Iterator::Item`. Są one preferowane, gdy typ jest częścią kontraktu cechy i każda implementacja ma dla niego jeden naturalny wybór. Generyczne parametry typów są preferowane, gdy wywołujący powinien wybrać typ lub gdy ta sama implementacja może potrzebować wielu implementacji dla różnych wyborów typów.

trait Repository {
    type Entity;
    fn get(&self, id: u64) -> Option<Self::Entity>;
}

struct User;
struct UserRepo;

impl Repository for UserRepo {
    type Entity = User;
    fn get(&self, _id: u64) -> Option<Self::Entity> { Some(User) }
}

fn load<R: Repository>(repo: &R) -> Option<R::Entity> {
    repo.get(1)
}
Odpowiedz na to pytanie z trenerem AI

Pytania dla Seniorów

13Jak zaimplementowałbyś wzorzec skrzynki nadawczej transakcji (Transactional Outbox pattern) w usłudze Rust?

Aby zaimplementować wzorzec skrzynki nadawczej transakcji (Transactional Outbox pattern) w usłudze Rust, należy atomowo zapisać aktualizacje danych domenowych i ładunki wychodzących zdarzeń w ramach jednej transakcji bazodanowej (np. używając `sqlx::Transaction`). Tabela `outbox` przechowuje temat docelowy, ładunek oraz status lub znacznik czasu utworzenia. Asynchroniczny proces roboczy działający w tle (lub proces Change Data Capture, taki jak Debezium) następnie odpytuje lub strumieniuje oczekujące zdarzenia i publikuje je do brokera wiadomości (takiego jak Kafka, RabbitMQ lub NATS). W przypadku użycia procesu roboczego odpytującego w Rust (np. w pętli `tokio::spawn`), użycie `SELECT ... FOR UPDATE SKIP LOCKED` pozwala wielu instancjom usługi pobierać i wysyłać wiersze `outbox` współbieżnie bez rywalizacji o blokady. Gdy dostarczenie przez brokera zostanie potwierdzone, proces roboczy aktualizuje status rekordu `outbox` lub usuwa wiersz. Ponieważ ponowne próby sieciowe mogą skutkować dostarczeniem co najmniej raz (at-least-once delivery), konsumenci końcowi muszą być zaprojektowani tak, aby obsługiwać wiadomości idempotentnie.

use sqlx::{PgPool, Postgres, Transaction};
use uuid::Uuid;

pub async fn create_user_with_outbox(
    mut tx: Transaction<'_, Postgres>,
    username: &str,
    email: &str,
) -> Result<(), sqlx::Error> {
    let user_id = Uuid::new_v4();
    sqlx::query!(
        "INSERT INTO users (id, username, email) VALUES ($1, $2, $3)",
        user_id, username, email
    )
    .execute(&mut *tx)
    .await?;

    let payload = serde_json::json!({"event": "UserCreated", "user_id": user_id, "email": email});
    sqlx::query!(
        "INSERT INTO outbox_events (id, topic, payload, status) VALUES ($1, $2, $3, 'PENDING')",
        Uuid::new_v4(),
        "user_events",
        payload
    )
    .execute(&mut *tx)
    .await?;

    tx.commit().await?;
    Ok(())
}

pub async fn run_outbox_relay(pool: PgPool) {
    loop {
        let mut tx = match pool.begin().await { Ok(t) => t, Err(_) => continue };
        let rows = sqlx::query!(
            "SELECT id, topic, payload FROM outbox_events WHERE status = 'PENDING' LIMIT 50 FOR UPDATE SKIP LOCKED"
        )
        .fetch_all(&mut *tx)
        .await;

        if let Ok(events) = rows {
            for event in events {
                // publish_to_broker(&event.topic, &event.payload).await;
                let _ = sqlx::query!("UPDATE outbox_events SET status = 'PROCESSED' WHERE id = $1", event.id)
                    .execute(&mut *tx)
                    .await;
            }
            let _ = tx.commit().await;
        }
        tokio::time::sleep(tokio::time::Duration::from_millis(500)).await;
    }
}
Odpowiedz na to pytanie z trenerem AI

14Jak zaprojektować wieloetapową operację, która aktualizuje bazę danych i wywołuje zewnętrzne API (Application Programming Interface)?

Projektowanie wieloetapowej operacji, która obejmuje zapis do bazy danych i wywołanie zewnętrznego API, wymaga zarządzania częściowymi awariami i partycjami sieciowymi bez polegania na zatwierdzaniu dwufazowym (2PC - Two-Phase Commit). 1. **Najpierw utrwal stan / zamiar**: Zapisz stan początkowy (taki jak `PENDING` lub `INITIATED`) i wymagany ładunek w ramach lokalnej transakcji bazodanowej. Zatwierdź tę transakcję przed zainicjowaniem zewnętrznego wywołania sieciowego, aby uniknąć blokowania bazy danych i slotów puli połączeń podczas wolnych operacji I/O (Input/Output) sieciowych. 2. **Idempotentne żądanie zewnętrzne**: Wywołaj zewnętrzne API, używając deterministycznego klucza idempotencji (np. wywodzącego się z UUID (Universally Unique Identifier) operacji). Zapewnia to, że powtarzające się próby nie spowodują zduplikowanych efektów ubocznych w świecie rzeczywistym (takich jak podwójne obciążenie). 3. **Zmiana stanu i obsługa limitów czasu**: Jeśli wywołanie API zwróci pomyślną odpowiedź, zaktualizuj rekord w bazie danych na `COMPLETED`. Jeśli zakończy się trwale niepowodzeniem, oznacz go jako `FAILED` i wykonaj akcje kompensujące. Jeśli wywołanie przekroczy limit czasu lub zwróci błąd sieci, oznacz rekord jako `INDETERMINATE` / `REQUIRES_RECONCILIATION` i pozwól procesowi roboczemu w tle uzgodnić stan, odpytując punkt końcowy statusu dostawcy lub ponawiając próbę z tym samym kluczem idempotencji.

use uuid::Uuid;

pub async fn process_external_charge(pool: &sqlx::PgPool, user_id: Uuid, amount: i64) -> Result<(), String> {
    let payment_id = Uuid::new_v4();
    let idempotency_key = format!("pay_{}", payment_id);

    // 1. Commit intent locally before making external network calls
    sqlx::query!(
        "INSERT INTO payments (id, user_id, amount, status, idempotency_key) VALUES ($1, $2, $3, 'PENDING', $4)",
        payment_id, user_id, amount, idempotency_key
    )
    .execute(pool)
    .await
    .map_err(|e| e.to_string())?;

    // 2. Call external API with timeout and idempotency key
    let client = reqwest::Client::new();
    let api_result = client.post("https://api.payment.com/v1/charges")
        .header("Idempotency-Key", &idempotency_key)
        .json(&serde_json::json!({ "amount": amount }))
        .timeout(std::time::Duration::from_secs(5))
        .send()
        .await;

    // 3. Update state based on outcome; handle timeouts via reconciliation
    match api_result {
        Ok(resp) if resp.status().is_success() => {
            sqlx::query!("UPDATE payments SET status = 'SUCCESS' WHERE id = $1", payment_id)
                .execute(pool)
                .await
                .map_err(|e| e.to_string())?;
        }
        Ok(_) => {
            sqlx::query!("UPDATE payments SET status = 'FAILED' WHERE id = $1", payment_id)
                .execute(pool)
                .await
                .map_err(|e| e.to_string())?;
        }
        Err(_) => {
            sqlx::query!("UPDATE payments SET status = 'REQUIRES_RECONCILIATION' WHERE id = $1", payment_id)
                .execute(pool)
                .await
                .map_err(|e| e.to_string())?;
        }
    }
    Ok(())
}
Odpowiedz na to pytanie z trenerem AI

15Jak zaprojektowałbyś wielodostępny backend w Rust ze ścisłą izolacją danych i dynamicznym routowaniem?

Projektowanie wielodostępnego backendu w Rust obejmuje trzy kluczowe obszary: identyfikację dzierżawcy, strategię izolacji danych i dynamiczne routowanie. 1. **Identyfikacja dzierżawcy i propagacja kontekstu**: Oprogramowanie pośredniczące HTTP (middleware HTTP) (np. w Axum lub Actix-web) rozstrzyga tożsamość dzierżawcy z roszczeń JWT (JSON Web Token), subdomen lub nagłówków. Konstruuje silnie typowany `TenantContext` przechowywany w rozszerzeniach żądania. Handlery używają ekstraktorów typów Rust, aby wymusić, aby logika biznesowa nie mogła być wykonana bez prawidłowego kontekstu dzierżawcy. 2. **Strategia izolacji danych**: - *Wspólna baza danych z zabezpieczeniami na poziomie kolumn / wierszy (RLS - Row-Level Security)*: Wszyscy dzierżawcy współdzielą tabele z kolumną `tenant_id`. Izolacja jest wymuszana za pomocą RLS w PostgreSQL przy użyciu zmiennych sesji na poziomie połączenia (np. `SET LOCAL app.tenant_id = $1`) lub opakowań konstruktora zapytań. - *Schemat na dzierżawcę*: Dzierżawcy współdzielą instancję bazy danych, ale mają izolowane schematy; routowanie dostosowuje ścieżkę wyszukiwania dla każdego połączenia. - *Baza danych na dzierżawcę*: Dzierżawcy mają oddzielne bazy danych. Usługa utrzymuje rejestr pul (np. `Arc<DashMap<TenantId, PgPool>>` lub pamięć podręczną puli LRU (Least Recently Used)) do dynamicznego rozwiązywania pul połączeń. 3. **Zapobieganie wyciekom**: Aby uniknąć zanieczyszczenia między dzierżawcami w asynchronicznych aplikacjach Tokio, kontekst dzierżawcy musi być przekazywany jawnie przez granice zadań, a nie przechowywany w stanie globalnym lub pamięci lokalnej wątku (`thread_local!`), co powoduje problemy podczas migracji między wątkami roboczymi asynchronicznymi.

use axum::{extract::{FromRequestParts, State}, http::request::Parts, async_trait};
use std::sync::Arc;
use dashmap::DashMap;
use sqlx::PgPool;

#[derive(Clone, Debug, Hash, PartialEq, Eq)]
pub struct TenantId(pub String);

#[derive(Clone)]
pub struct AppState {
    pub pool_registry: Arc<DashMap<TenantId, PgPool>>,
}

#[derive(Clone)]
pub struct TenantContext {
    pub tenant_id: TenantId,
    pub pool: PgPool,
}

#[async_trait]
impl FromRequestParts<AppState> for TenantContext {
    type Rejection = (axum::http::StatusCode, &'static str);

    async fn from_request_parts(parts: &mut Parts, state: &AppState) -> Result<Self, Self::Rejection> {
        let tenant_header = parts.headers.get("X-Tenant-ID")
            .and_then(|v| v.to_str().ok())
            .ok_or((axum::http::StatusCode::BAD_REQUEST, "Missing tenant ID"))?;
        
        let tenant_id = TenantId(tenant_header.to_string());
        
        let pool = match state.pool_registry.get(&tenant_id) {
            Some(p) => p.value().clone(),
            None => return Err((axum::http::StatusCode::NOT_FOUND, "Tenant database pool not found")),
        };

        Ok(TenantContext { tenant_id, pool })
    }
}
Odpowiedz na to pytanie z trenerem AI