diff --git a/.github/workflows/develop-to-release.yml b/.github/workflows/develop-to-release.yml new file mode 100644 index 0000000..13048e2 --- /dev/null +++ b/.github/workflows/develop-to-release.yml @@ -0,0 +1,133 @@ +name: Develop to Release + +on: + push: + branches: + - develop + +jobs: + analyze-and-create-release-pr: + runs-on: ubuntu-latest + permissions: + contents: write + pull-requests: write + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Determine version bump + id: version + run: | + # Pega a última tag + LAST_TAG=$(git describe --tags --abbrev=0 2>/dev/null || echo "v0.0.0") + echo "Last tag: $LAST_TAG" + + # Remove 'v' prefix + LAST_VERSION=${LAST_TAG#v} + IFS='.' read -ra VERSION_PARTS <<< "$LAST_VERSION" + MAJOR=${VERSION_PARTS[0]:-0} + MINOR=${VERSION_PARTS[1]:-0} + PATCH=${VERSION_PARTS[2]:-0} + + # Analisa commits desde a última tag + COMMITS=$(git log ${LAST_TAG}..HEAD --pretty=format:"%s" 2>/dev/null || git log --pretty=format:"%s") + + # Determina o tipo de bump baseado em conventional commits + if echo "$COMMITS" | grep -qiE "^(BREAKING CHANGE|feat!|fix!):"; then + MAJOR=$((MAJOR + 1)) + MINOR=0 + PATCH=0 + BUMP_TYPE="major" + elif echo "$COMMITS" | grep -qiE "^feat(\(.+\))?:"; then + MINOR=$((MINOR + 1)) + PATCH=0 + BUMP_TYPE="minor" + else + PATCH=$((PATCH + 1)) + BUMP_TYPE="patch" + fi + + NEW_VERSION="${MAJOR}.${MINOR}.${PATCH}" + echo "new_version=$NEW_VERSION" >> $GITHUB_OUTPUT + echo "bump_type=$BUMP_TYPE" >> $GITHUB_OUTPUT + echo "New version will be: v$NEW_VERSION (${BUMP_TYPE})" + + - name: Create or update release branch + env: + NEW_VERSION: ${{ steps.version.outputs.new_version }} + run: | + RELEASE_BRANCH="release/v${NEW_VERSION}" + + # Verifica se a branch já existe + if git ls-remote --heads origin "$RELEASE_BRANCH" | grep -q "$RELEASE_BRANCH"; then + echo "Branch $RELEASE_BRANCH já existe, atualizando..." + git fetch origin "$RELEASE_BRANCH" + git checkout "$RELEASE_BRANCH" + git merge origin/develop --no-edit + else + echo "Criando nova branch $RELEASE_BRANCH" + git checkout -b "$RELEASE_BRANCH" + fi + + git push origin "$RELEASE_BRANCH" + echo "RELEASE_BRANCH=$RELEASE_BRANCH" >> $GITHUB_ENV + + - name: Check if PR exists + id: check-pr + env: + GH_TOKEN: ${{ github.token }} + NEW_VERSION: ${{ steps.version.outputs.new_version }} + run: | + RELEASE_BRANCH="release/v${NEW_VERSION}" + PR_EXISTS=$(gh pr list --head "$RELEASE_BRANCH" --base main --json number --jq 'length') + if [ "$PR_EXISTS" -gt 0 ]; then + PR_NUMBER=$(gh pr list --head "$RELEASE_BRANCH" --base main --json number --jq '.[0].number') + echo "pr_number=$PR_NUMBER" >> $GITHUB_OUTPUT + fi + echo "pr_exists=$PR_EXISTS" >> $GITHUB_OUTPUT + + - name: Create or update PR to main + env: + GH_TOKEN: ${{ github.token }} + NEW_VERSION: ${{ steps.version.outputs.new_version }} + BUMP_TYPE: ${{ steps.version.outputs.bump_type }} + run: | + RELEASE_BRANCH="release/v${NEW_VERSION}" + + if [ "${{ steps.check-pr.outputs.pr_exists }}" -eq 0 ]; then + # Cria novo PR com template + gh pr create \ + --base main \ + --head "$RELEASE_BRANCH" \ + --title "Release v${NEW_VERSION}" \ + --body "## 🚀 Release v${NEW_VERSION} + + **Bump type:** ${BUMP_TYPE} + + ### ⚙️ Release Configuration + + **Antes de mergear, responda:** + + \`\`\` + Release Type: [alpha/beta/lts] + \`\`\` + + _Por favor, edite este PR e substitua o placeholder acima com:_ + - \`alpha\` - Para releases alpha (instável, desenvolvimento) + - \`beta\` - Para releases beta (pré-release, testes) + - \`lts\` - Para releases estáveis de longo prazo + + ### 📝 Changelog + + $(git log --pretty=format:'- %s (%h)' origin/main..HEAD | head -20) + + --- + + _Este PR foi gerado automaticamente pelo workflow develop-to-release_" \ + --draft + + echo "✅ PR criado: release/v${NEW_VERSION} → main" + else + echo "ℹ️ PR já existe (#${{ steps.check-pr.outputs.pr_number }}), atualizando..." + fi diff --git a/.github/workflows/feature-fix-workflow.yml b/.github/workflows/feature-fix-workflow.yml new file mode 100644 index 0000000..d139aef --- /dev/null +++ b/.github/workflows/feature-fix-workflow.yml @@ -0,0 +1,73 @@ +name: Feature/Fix Workflow + +on: + push: + branches: + - "feature/**" + - "fix/**" + +jobs: + build-and-test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Setup Rust + uses: actions-rs/toolchain@v1 + with: + toolchain: stable + override: true + + - name: Cache cargo registry + uses: actions/cache@v3 + with: + path: ~/.cargo/registry + key: ${{ runner.os }}-cargo-registry-${{ hashFiles('**/Cargo.lock') }} + + - name: Cache cargo index + uses: actions/cache@v3 + with: + path: ~/.cargo/git + key: ${{ runner.os }}-cargo-index-${{ hashFiles('**/Cargo.lock') }} + + - name: Build + run: cargo build --release --all-features + + - name: Run tests + run: cargo test --all-features + + create-pr-to-develop: + needs: build-and-test + runs-on: ubuntu-latest + permissions: + pull-requests: write + contents: read + steps: + - uses: actions/checkout@v4 + + - name: Check if PR already exists + id: check-pr + env: + GH_TOKEN: ${{ github.token }} + run: | + BRANCH_NAME="${{ github.ref_name }}" + PR_EXISTS=$(gh pr list --head "$BRANCH_NAME" --base develop --json number --jq 'length') + echo "pr_exists=$PR_EXISTS" >> $GITHUB_OUTPUT + + - name: Create Pull Request to develop + if: steps.check-pr.outputs.pr_exists == '0' + env: + GH_TOKEN: ${{ github.token }} + run: | + gh pr create \ + --base develop \ + --head ${{ github.ref_name }} \ + --title "[${{ github.ref_name }}] Merge to develop" \ + --body "## Automated PR + + ✅ Build: Passed + ✅ Tests: Passed + + Branch: \`${{ github.ref_name }}\` + Commit: ${{ github.sha }}" \ + --reviewer "" diff --git a/.github/workflows/release-workflow.yml b/.github/workflows/release-workflow.yml new file mode 100644 index 0000000..c61c1c6 --- /dev/null +++ b/.github/workflows/release-workflow.yml @@ -0,0 +1,110 @@ +name: Release Workflow + +on: + pull_request: + types: [closed] + branches: + - main + +jobs: + create-release: + if: | + github.event.pull_request.merged == true && + startsWith(github.event.pull_request.head.ref, 'release/') + runs-on: ubuntu-latest + permissions: + contents: write + pull-requests: write + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Extract version and release type + id: extract + run: | + # Extrai versão do nome da branch + BRANCH_NAME="${{ github.event.pull_request.head.ref }}" + VERSION=$(echo "$BRANCH_NAME" | sed 's/release\/v//') + echo "version=$VERSION" >> $GITHUB_OUTPUT + + # Extrai tipo de release do corpo do PR + PR_BODY="${{ github.event.pull_request.body }}" + RELEASE_TYPE=$(echo "$PR_BODY" | grep -oP "Release Type:\s*\K(alpha|beta|lts)" || echo "lts") + echo "release_type=$RELEASE_TYPE" >> $GITHUB_OUTPUT + + # Monta tag completa + if [ "$RELEASE_TYPE" = "lts" ]; then + FULL_TAG="v${VERSION}" + else + FULL_TAG="v${VERSION}-${RELEASE_TYPE}" + fi + echo "full_tag=$FULL_TAG" >> $GITHUB_OUTPUT + + echo "Version: $VERSION" + echo "Release Type: $RELEASE_TYPE" + echo "Full Tag: $FULL_TAG" + + - name: Build release artifacts + run: | + cargo build --release + mkdir -p artifacts + cp target/release/* artifacts/ 2>/dev/null || true + + - name: Create and push tag + env: + FULL_TAG: ${{ steps.extract.outputs.full_tag }} + run: | + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + git tag -a "$FULL_TAG" -m "Release $FULL_TAG" + git push origin "$FULL_TAG" + + - name: Generate changelog + id: changelog + run: | + LAST_TAG=$(git describe --tags --abbrev=0 HEAD^ 2>/dev/null || echo "") + if [ -z "$LAST_TAG" ]; then + CHANGELOG=$(git log --pretty=format:"- %s (%h)" | head -50) + else + CHANGELOG=$(git log ${LAST_TAG}..HEAD --pretty=format:"- %s (%h)") + fi + + # Salva changelog em arquivo + echo "$CHANGELOG" > /tmp/changelog.txt + echo "changelog_file=/tmp/changelog.txt" >> $GITHUB_OUTPUT + + - name: Create GitHub Release + env: + GH_TOKEN: ${{ github.token }} + FULL_TAG: ${{ steps.extract.outputs.full_tag }} + VERSION: ${{ steps.extract.outputs.version }} + RELEASE_TYPE: ${{ steps.extract.outputs.release_type }} + run: | + PRERELEASE_FLAG="" + if [ "$RELEASE_TYPE" = "alpha" ] || [ "$RELEASE_TYPE" = "beta" ]; then + PRERELEASE_FLAG="--prerelease" + fi + + # Lê o changelog + CHANGELOG=$(cat /tmp/changelog.txt) + + # Cria a release + gh release create "$FULL_TAG" \ + --title "Release $FULL_TAG" \ + --notes "## 🎉 Release $VERSION ($RELEASE_TYPE) + + ### 📦 Type: ${RELEASE_TYPE^^} + + ### 📝 Changes + + $CHANGELOG + + --- + + Released on: $(date +'%Y-%m-%d %H:%M:%S UTC') + " \ + $PRERELEASE_FLAG \ + artifacts/* 2>/dev/null || true + + echo "✅ Release $FULL_TAG criada com sucesso!" diff --git a/.gitignore b/.gitignore index a8a7c1c..b06c3c8 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,4 @@ target .lsm_data -stress* +/examples/*.json credentials \ No newline at end of file diff --git a/Cargo.toml b/Cargo.toml index 1506ce8..3f531b6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -2,7 +2,7 @@ name = "lsm-kv-store" version = "0.1.0" edition = "2021" -authors = ["Elío Neto "] +authors = ["Elio Neto "] description = "High-performance Key-Value Store using LSM-Tree architecture" repository = "https://github.com/ElioNeto/lsm-kv-store" @@ -11,6 +11,7 @@ repository = "https://github.com/ElioNeto/lsm-kv-store" serde = { version = "1.0", features = ["derive"] } serde_derive = "1.0" bincode = "1.3.3" +serde_json = "1.0" # Checksum & Bloom Filter crc32fast = "1.3" @@ -23,11 +24,10 @@ thiserror = "1.0" tracing = "0.1" tracing-subscriber = "0.3" -# HTTP Server (opcionais - apenas se feature "api" estiver ativa) +# HTTP Server (opcionais) actix-web = { version = "4", optional = true } actix-cors = { version = "0.7", optional = true } tokio = { version = "1", features = ["full"], optional = true } -serde_json = { version = "1.0", optional = true } [dev-dependencies] criterion = "0.5" @@ -42,10 +42,6 @@ codegen-units = 1 [profile.dev] opt-level = 0 -[lib] -name = "lsm_kv_store" -path = "src/lib.rs" - [[bin]] name = "lsm-kv-store" path = "src/main.rs" @@ -55,10 +51,6 @@ name = "lsm-server" path = "src/bin/server.rs" required-features = ["api"] -[[bin]] -name = "server" -path = "src/server.rs" - [features] default = [] -api = ["actix-web", "actix-cors", "tokio", "serde_json"] +api = ["actix-web", "actix-cors", "tokio"] diff --git a/README.md b/README.md index 81e1d16..81d3ee7 100644 --- a/README.md +++ b/README.md @@ -320,7 +320,7 @@ Contributions are welcome! Priority areas for v1 → v2 transition: **Contribution workflow:** 1. Fork repository -2. Create feature branch: `git checkout -b feat/my-feature` +2. Create feature branch: `git checkout -b feature/my-feature` 3. Commit changes with clear messages 4. Run tests and linters 5. Open Pull Request with detailed description diff --git a/requests/api.rest b/requests/api.rest index 17fa2e5..9e12967 100644 --- a/requests/api.rest +++ b/requests/api.rest @@ -5,6 +5,7 @@ ### Variáveis globais @baseUrl = http://localhost:8080 +#@baseUrl = https://steadfast-connection-dev.up.railway.app @contentType = application/json ### ============================================================================ @@ -19,7 +20,7 @@ GET {{baseUrl}}/health ### Estatísticas do Engine # @name stats -GET {{baseUrl}}/stats +GET {{baseUrl}}/stats/all ### ============================================================================ ### 2. OPERAÇÕES BÁSICAS (CRUD Único) @@ -31,8 +32,8 @@ POST {{baseUrl}}/keys Content-Type: {{contentType}} { - "key": "user:alice", - "value": "Alice Silva" + "key": "user:texuguito", + "value": "Texugo Limão" } ### @@ -81,7 +82,7 @@ DELETE {{baseUrl}}/keys/user:alice ### Verificar chave deletada # @name verifyDeleted -GET {{baseUrl}}/keys/user:alice +GET {{baseUrl}}/keys/user:alice1 ### ============================================================================ ### 3. OPERAÇÕES BATCH (Múltiplos registros) @@ -447,4 +448,10 @@ GET {{baseUrl}}/scan POST {{baseUrl}}/keys/batch Content-Type: application/json -< ../examples/stress_test_data.json \ No newline at end of file +< ../examples/stress_test_data.json +### +# +POST {{baseUrl}}/keys/batch +Content-Type: application/json + +< ../examples/carga.json \ No newline at end of file diff --git a/requests/features.rest b/requests/features.rest new file mode 100644 index 0000000..978aeeb --- /dev/null +++ b/requests/features.rest @@ -0,0 +1,27 @@ +### Variáveis globais +@baseUrl = http://localhost:8080 +#@baseUrl = https://steadfast-connection-dev.up.railway.app +@contentType = application/json + +### ============================================================================ +### Listar todas as feature flags +# @name listFeatures +GET {{baseUrl}}/features +### ============================================================================ +### Obter status de uma feature flag específica +# @name getFeature +GET {{baseUrl}}/features/feature:admin:frontend +### ============================================================================ +### criar ou editar uma features flag +# @name createOrUpdateFeature +POST {{baseUrl}}/features/admin:frontend:statiscs:chart +Content-Type: {{contentType}} + +{ + "enabled": false, + "description": "Habilita o gráfico de estatísticas no painel administrativo" +} +### ============================================================================ +### Buscar chave específica +# @name getKey +GET {{baseUrl}}/keys/user:alice \ No newline at end of file diff --git a/src/api.rs b/src/api.rs index 629b1e8..b462c17 100644 --- a/src/api.rs +++ b/src/api.rs @@ -2,34 +2,37 @@ use actix_cors::Cors; use actix_web::{delete, get, post, web, App, HttpResponse, HttpServer, Responder}; use serde::{Deserialize, Serialize}; use std::sync::Arc; +use std::time::Duration; // ADICIONAR use crate::engine::LsmEngine; +use crate::features::FeatureClient; // CORRIGIR (remover FeatureFlag) -/// Estado compartilhado entre threads +// Estado compartilhado entre threads pub struct AppState { pub engine: Arc, + pub features: Arc, } -/// Request body para SET +// Request body para SET #[derive(Deserialize)] pub struct SetRequest { pub key: String, pub value: String, } -/// Request body para SET BATCH +// Request body para SET BATCH #[derive(Deserialize)] pub struct BatchSetRequest { pub records: Vec, } -/// Request body para DELETE BATCH +// Request body para DELETE BATCH #[derive(Deserialize)] pub struct BatchDeleteRequest { pub keys: Vec, } -/// Query params para busca +// Query params para busca #[derive(Deserialize)] pub struct SearchQuery { pub q: String, // query string (substring) @@ -37,7 +40,7 @@ pub struct SearchQuery { pub prefix: bool, // se true, busca por prefixo; se false, substring } -/// Response padrão +// Response padrão #[derive(Serialize)] pub struct ApiResponse { pub success: bool, @@ -46,7 +49,23 @@ pub struct ApiResponse { pub data: Option, } -/// GET /health - Healthcheck +// Request body para features +#[derive(Deserialize)] +pub struct SetFeatureRequest { + pub enabled: bool, + #[serde(default)] + pub description: String, +} + +// Response para features +#[derive(Serialize)] +pub struct FeatureResponse { + pub name: String, + pub enabled: bool, + pub description: String, +} + +// GET /health - Healthcheck #[get("/health")] async fn health() -> impl Responder { HttpResponse::Ok().json(ApiResponse { @@ -56,7 +75,7 @@ async fn health() -> impl Responder { }) } -/// GET /stats - Estatísticas do engine +// GET /stats - Estatísticas do engine #[get("/stats")] async fn get_stats(data: web::Data) -> impl Responder { let stats = data.engine.stats(); @@ -67,7 +86,7 @@ async fn get_stats(data: web::Data) -> impl Responder { }) } -/// GET /statsAll - Estatísticas do engine +// GET /stats/all - Estatísticas completas #[get("/stats/all")] async fn get_stats_all(data: web::Data) -> impl Responder { let stats = data.engine.stats_all(); @@ -78,7 +97,7 @@ async fn get_stats_all(data: web::Data) -> impl Responder { }) } -/// GET /keys/{key} - Buscar valor por chave +// GET /keys/{key} - Buscar valor por chave #[get("/keys/{key}")] async fn get_key(path: web::Path, data: web::Data) -> impl Responder { let key = path.into_inner(); @@ -108,7 +127,7 @@ async fn get_key(path: web::Path, data: web::Data) -> impl Res } } -/// POST /keys - Inserir ou atualizar chave +// POST /keys - Inserir ou atualizar chave #[post("/keys")] async fn set_key(req: web::Json, data: web::Data) -> impl Responder { let value_bytes = req.value.as_bytes().to_vec(); @@ -127,7 +146,7 @@ async fn set_key(req: web::Json, data: web::Data) -> impl } } -/// POST /keys/batch - Inserir múltiplas chaves +// POST /keys/batch - Inserir múltiplas chaves #[post("/keys/batch")] async fn set_batch(req: web::Json, data: web::Data) -> impl Responder { let records: Vec<(String, Vec)> = req @@ -150,7 +169,7 @@ async fn set_batch(req: web::Json, data: web::Data) - } } -/// DELETE /keys/{key} - Deletar chave +// DELETE /keys/{key} - Deletar chave #[delete("/keys/{key}")] async fn delete_key(path: web::Path, data: web::Data) -> impl Responder { let key = path.into_inner(); @@ -169,7 +188,7 @@ async fn delete_key(path: web::Path, data: web::Data) -> impl } } -/// DELETE /keys/batch - Deletar múltiplas chaves +// DELETE /keys/batch - Deletar múltiplas chaves #[delete("/keys/batch")] async fn delete_batch( req: web::Json, @@ -189,15 +208,23 @@ async fn delete_batch( } } -/// GET /keys - Listar todas as chaves +// GET /keys - Listar todas as chaves (FILTRADO - sem feature:*) #[get("/keys")] async fn list_keys(data: web::Data) -> impl Responder { match data.engine.keys() { - Ok(keys) => HttpResponse::Ok().json(ApiResponse { - success: true, - message: format!("{} keys found", keys.len()), - data: Some(serde_json::json!({ "keys": keys })), - }), + Ok(keys) => { + // Filtrar chaves que começam com "feature:" + let filtered_keys: Vec = keys + .into_iter() + .filter(|k| !k.starts_with("feature:")) + .collect(); + + HttpResponse::Ok().json(ApiResponse { + success: true, + message: format!("{} keys found", filtered_keys.len()), + data: Some(serde_json::json!({ "keys": filtered_keys })), + }) + } Err(e) => HttpResponse::InternalServerError().json(ApiResponse { success: false, message: format!("Error: {}", e), @@ -206,7 +233,7 @@ async fn list_keys(data: web::Data) -> impl Responder { } } -/// GET /keys/search?q=pattern&prefix=false - Buscar por substring/prefixo +// GET /keys/search?q=...&prefix=false - Buscar por substring/prefixo #[get("/keys/search")] async fn search_keys(query: web::Query, data: web::Data) -> impl Responder { let results = if query.prefix { @@ -241,13 +268,15 @@ async fn search_keys(query: web::Query, data: web::Data) } } -/// GET /scan - Retornar todos os dados +// GET /scan - Retornar todos os dados (FILTRADO - sem feature:*) #[get("/scan")] async fn scan_all(data: web::Data) -> impl Responder { match data.engine.scan() { Ok(records) => { + // Filtrar registros com chave feature:* let records_json: Vec = records .into_iter() + .filter(|(k, _)| !k.starts_with("feature:")) .map(|(k, v)| { serde_json::json!({ "key": k, @@ -270,22 +299,149 @@ async fn scan_all(data: web::Data) -> impl Responder { } } -/// Inicia o servidor HTTP +// ==================== FEATURE FLAGS ENDPOINTS ==================== + +// GET /features - Listar todas as features +#[get("/features")] +async fn list_features(data: web::Data) -> impl Responder { + match data.features.list_all() { + Ok(features) => { + let feature_list: Vec = features + .flags + .iter() + .map(|(name, flag)| FeatureResponse { + name: name.clone(), + enabled: flag.enabled, + description: flag.description.clone(), + }) + .collect(); + + HttpResponse::Ok().json(ApiResponse { + success: true, + message: format!("{} features found", feature_list.len()), + data: Some(serde_json::json!({ + "version": features.version, + "features": feature_list + })), + }) + } + Err(e) => HttpResponse::InternalServerError().json(ApiResponse { + success: false, + message: format!("Error: {}", e), + data: None, + }), + } +} + +// GET /features/{name} - Verificar se uma feature está habilitada +#[get("/features/{name}")] +async fn get_feature(path: web::Path, data: web::Data) -> impl Responder { + let name = path.into_inner(); + + match data.features.is_enabled(&name) { + Ok(enabled) => HttpResponse::Ok().json(ApiResponse { + success: true, + message: "Feature retrieved".to_string(), + data: Some(serde_json::json!({ + "name": name, + "enabled": enabled + })), + }), + Err(e) => HttpResponse::InternalServerError().json(ApiResponse { + success: false, + message: format!("Error: {}", e), + data: None, + }), + } +} + +// POST /features/{name} - Criar ou atualizar feature +#[post("/features/{name}")] +async fn set_feature( + path: web::Path, + req: web::Json, + data: web::Data, +) -> impl Responder { + let name = path.into_inner(); + + match data + .features + .set_flag(name.clone(), req.enabled, Some(req.description.clone())) + { + Ok(_) => HttpResponse::Ok().json(ApiResponse { + success: true, + message: format!("Feature '{}' updated successfully", name), + data: Some(serde_json::json!({ + "name": name, + "enabled": req.enabled + })), + }), + Err(e) => HttpResponse::InternalServerError().json(ApiResponse { + success: false, + message: format!("Error: {}", e), + data: None, + }), + } +} + +// DELETE /features/{name} - Remover feature +#[delete("/features/{name}")] +async fn delete_feature(path: web::Path, data: web::Data) -> impl Responder { + let name = path.into_inner(); + + match data.features.remove_flag(&name) { + Ok(removed) => { + if removed { + HttpResponse::Ok().json(ApiResponse { + success: true, + message: format!("Feature '{}' deleted successfully", name), + data: None, + }) + } else { + HttpResponse::NotFound().json(ApiResponse { + success: false, + message: format!("Feature '{}' not found", name), + data: None, + }) + } + } + Err(e) => HttpResponse::InternalServerError().json(ApiResponse { + success: false, + message: format!("Error: {}", e), + data: None, + }), + } +} + +// Inicia o servidor HTTP pub async fn start_server(engine: LsmEngine, host: &str, port: u16) -> std::io::Result<()> { let engine = Arc::new(engine); + let features = Arc::new(FeatureClient::new( + Arc::clone(&engine), + Duration::from_secs(10), // Cache de 10 segundos + )); - println!("🚀 LSM-Tree REST API iniciando em http://{}:{}", host, port); - println!("📚 Documentação:"); - println!(" GET /health - Healthcheck"); - println!(" GET /stats - Estatísticas"); - println!(" GET /keys - Listar todas as chaves"); - println!(" GET /keys/{{key}} - Buscar valor"); - println!(" GET /keys/search?q=... - Buscar por substring/prefixo"); - println!(" POST /keys - Inserir/atualizar (JSON body)"); - println!(" POST /keys/batch - Inserir múltiplos (JSON array)"); - println!(" DELETE /keys/{{key}} - Deletar chave"); - println!(" DELETE /keys/batch - Deletar múltiplas (JSON array)"); - println!(" GET /scan - Retornar todos os dados\n"); + println!( + "\n🚀 LSM-Tree REST API iniciando em http://{}:{}", + host, port + ); + println!("\n📚 Documentação:"); + println!(" GET /health - Healthcheck"); + println!(" GET /stats - Estatísticas"); + println!(" GET /stats_all - Estatísticas completas"); + println!(" GET /keys - Listar chaves (exceto feature:*)"); + println!(" GET /keys/{{key}} - Buscar valor"); + println!(" GET /keys/search?q=... - Buscar por substring/prefixo"); + println!(" POST /keys - Inserir/atualizar"); + println!(" POST /keys/batch - Inserir múltiplos"); + println!(" DELETE /keys/{{key}} - Deletar chave"); + println!(" DELETE /keys/batch - Deletar múltiplas"); + println!(" GET /scan - Scan completo (exceto feature:*)"); + println!("\n🚩 Feature Flags:"); + println!(" GET /features - Listar todas as features"); + println!(" GET /features/{{name}} - Verificar feature"); + println!(" POST /features/{{name}} - Criar/atualizar feature"); + println!(" DELETE /features/{{name}} - Remover feature"); HttpServer::new(move || { let cors = Cors::default() @@ -298,11 +454,14 @@ pub async fn start_server(engine: LsmEngine, host: &str, port: u16) -> std::io:: .wrap(cors) .app_data(web::Data::new(AppState { engine: Arc::clone(&engine), + features: Arc::clone(&features), })) - .app_data(web::JsonConfig::default().limit(20 * 1024 * 1024)) // Limite de 20MB + .app_data(web::JsonConfig::default().limit(20 * 1024 * 1024)) + // Endpoints gerais .service(health) .service(get_stats) - .service(search_keys) // IMPORTANTE: antes de get_key + .service(get_stats_all) + .service(search_keys) .service(get_key) .service(set_key) .service(set_batch) @@ -310,7 +469,11 @@ pub async fn start_server(engine: LsmEngine, host: &str, port: u16) -> std::io:: .service(delete_batch) .service(list_keys) .service(scan_all) - .service(get_stats_all) + // Feature flags + .service(list_features) + .service(get_feature) + .service(set_feature) + .service(delete_feature) }) .bind((host, port))? .run() diff --git a/src/engine.rs b/src/engine.rs index ce9657c..1b98d12 100644 --- a/src/engine.rs +++ b/src/engine.rs @@ -27,6 +27,19 @@ impl Default for LsmConfig { } } +use serde::Serialize; + +#[derive(Serialize)] +pub struct LsmStats { + pub mem_records: usize, + pub mem_kb: usize, + pub sst_files: usize, + pub sst_records: u64, + pub sst_kb: u64, + pub wal_kb: u64, + pub total_records: u64, +} + pub struct LsmEngine { pub(crate) memtable: Mutex, pub(crate) wal: WriteAheadLog, @@ -177,7 +190,6 @@ impl LsmEngine { fn flush(&self) -> Result<()> { let mut memtable = self.memtable_lock()?; - let records: Vec<(String, LogRecord)> = memtable .iter_ordered() .map(|(k, v)| (k.clone(), v.clone())) @@ -189,11 +201,12 @@ impl LsmEngine { let timestamp = SystemTime::now().duration_since(UNIX_EPOCH)?.as_nanos(); + // 1. Criar SSTable e garantir fsync let sst = SStable::create(&self.dir_path, timestamp, &records)?; + // 2. Adicionar à lista em memória let mut sstables = self.sstables_lock()?; sstables.insert(0, sst); - let cleared = memtable.clear(); info!( @@ -205,9 +218,9 @@ impl LsmEngine { drop(memtable); drop(sstables); + // 3. SOMENTE AGORA limpar WAL (após SSTable estar durável) self.wal.clear()?; - // TODO: compaction Ok(()) } @@ -318,15 +331,13 @@ impl LsmEngine { ) } - pub fn stats_all(&self) -> String { - let memtable = match self.memtable_lock() { - Ok(g) => g, - Err(e) => return format!("LSM Stats:\n Lock error: {e}"), - }; - let sstables = match self.sstables_lock() { - Ok(g) => g, - Err(e) => return format!("LSM Stats:\n Lock error: {e}"), - }; + pub fn stats_all(&self) -> std::result::Result { + let memtable = self + .memtable_lock() + .map_err(|e| format!("Lock error: {e}"))?; + let sstables = self + .sstables_lock() + .map_err(|e| format!("Lock error: {e}"))?; let mem_records = memtable.data.len(); let mem_kb = memtable.size_bytes / 1024; @@ -346,17 +357,17 @@ impl LsmEngine { .map(|m| m.len()) .unwrap_or(0); - format!( - "LSM Stats:\n\ - MemTable: {} records, ~{} KB\n\ - SSTables: {} files, {} records (raw), ~{} KB on disk\n\ - WAL: ~{} KB", + // Cálculo do total geral + let total_records = (mem_records as u64) + sst_records_total; + + Ok(LsmStats { mem_records, mem_kb, sst_files, - sst_records_total, - (sst_bytes_total / 1024), - (wal_bytes / 1024), - ) + sst_records: sst_records_total, + sst_kb: sst_bytes_total / 1024, + wal_kb: wal_bytes / 1024, + total_records, + }) } } diff --git a/src/error.rs b/src/error.rs index 00e8ec4..c00bb2a 100644 --- a/src/error.rs +++ b/src/error.rs @@ -28,6 +28,17 @@ pub enum LsmError { #[error("WAL corruption detected")] WalCorruption, + #[error("Serialization failed: {0}")] + SerializationFailed(String), + + #[error("Deserialization failed: {0}")] + DeserializationFailed(String), + + #[error("Concurrent modification detected")] + ConcurrentModification, + + #[error("Key not found")] + NotFound, } pub type Result = std::result::Result; diff --git a/src/features.rs b/src/features.rs new file mode 100644 index 0000000..6f622b1 --- /dev/null +++ b/src/features.rs @@ -0,0 +1,172 @@ +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; +use std::sync::{Arc, RwLock}; +use std::time::{Duration, Instant}; + +use crate::engine::LsmEngine; +use crate::error::Result; + +/// Configuração de uma feature flag individual +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct FeatureFlag { + pub enabled: bool, + #[serde(default)] + pub description: String, +} + +/// Container de todas as feature flags +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Features { + #[serde(default)] + pub version: u64, + pub flags: HashMap, +} + +impl Default for Features { + fn default() -> Self { + Self { + version: 0, + flags: HashMap::new(), + } + } +} + +/// Cliente para gerenciar feature flags com cache +pub struct FeatureClient { + engine: Arc, + cache: Arc>>, + cache_ttl: Duration, +} + +impl FeatureClient { + const KEY: &'static str = "feature:all"; + + pub fn new(engine: Arc, cache_ttl: Duration) -> Self { + Self { + engine, + cache: Arc::new(RwLock::new(None)), + cache_ttl, + } + } + + /// Carrega todas as features (com cache) + fn load_features(&self) -> Result { + // Verificar cache + { + let cache = self.cache.read().unwrap(); + if let Some((features, timestamp)) = cache.as_ref() { + if timestamp.elapsed() < self.cache_ttl { + return Ok(features.clone()); + } + } + } + + // Cache miss ou expirado - carregar do engine + let bytes = match self.engine.get(Self::KEY)? { + Some(b) => b, + None => { + // Primeira vez - criar estrutura vazia + let features = Features::default(); + let json = serde_json::to_vec(&features) + .map_err(|e| crate::error::LsmError::SerializationFailed(e.to_string()))?; + self.engine.set(Self::KEY.to_string(), json)?; + return Ok(features); + } + }; + + let features: Features = serde_json::from_slice(&bytes) + .map_err(|e| crate::error::LsmError::DeserializationFailed(e.to_string()))?; + + // Atualizar cache + let mut cache = self.cache.write().unwrap(); + *cache = Some((features.clone(), Instant::now())); + + Ok(features) + } + + /// Invalida o cache + fn invalidate_cache(&self) { + let mut cache = self.cache.write().unwrap(); + *cache = None; + } + + /// Verifica se uma feature está habilitada + pub fn is_enabled(&self, flag_name: &str) -> Result { + let features = self.load_features()?; + Ok(features + .flags + .get(flag_name) + .map(|f| f.enabled) + .unwrap_or(false)) + } + + /// Lista todas as features + pub fn list_all(&self) -> Result { + self.load_features() + } + + /// Atualiza uma feature flag específica + pub fn set_flag( + &self, + flag_name: String, + enabled: bool, + description: Option, + ) -> Result<()> { + // Retry com optimistic locking + for attempt in 0..5 { + let mut features = self.load_features()?; + + // Atualizar ou criar flag + features + .flags + .entry(flag_name.clone()) + .and_modify(|f| { + f.enabled = enabled; + if let Some(desc) = &description { + f.description = desc.clone(); + } + }) + .or_insert(FeatureFlag { + enabled, + description: description.clone().unwrap_or_default(), + }); + + features.version += 1; + + // Serializar e salvar + let json = serde_json::to_vec(&features) + .map_err(|e| crate::error::LsmError::SerializationFailed(e.to_string()))?; + + match self.engine.set(Self::KEY.to_string(), json) { + Ok(_) => { + self.invalidate_cache(); + return Ok(()); + } + Err(_) if attempt < 4 => { + // Retry com backoff exponencial + std::thread::sleep(Duration::from_millis(10 * 2u64.pow(attempt))); + continue; + } + Err(e) => return Err(e), + } + } + + Err(crate::error::LsmError::ConcurrentModification) + } + + /// Remove uma feature flag + pub fn remove_flag(&self, flag_name: &str) -> Result { + let mut features = self.load_features()?; + let removed = features.flags.remove(flag_name).is_some(); + + if removed { + features.version += 1; + let json = serde_json::to_vec(&features) + .map_err(|e| crate::error::LsmError::SerializationFailed(e.to_string()))?; + self.engine.set(Self::KEY.to_string(), json)?; + self.invalidate_cache(); + } + + Ok(removed) + } +} diff --git a/src/lib.rs b/src/lib.rs index d172b85..6ad6493 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,15 +1,16 @@ mod codec; mod engine; mod error; +mod features; mod log_record; mod memtable; mod sstable; mod wal; -// Módulo API (condicional para não afetar lib pura) #[cfg(feature = "api")] pub mod api; pub use crate::engine::{LsmConfig, LsmEngine}; pub use crate::error::{LsmError, Result}; +pub use crate::features::{FeatureClient, FeatureFlag, Features}; pub use crate::log_record::LogRecord;