From f51f9b4eb05eea9047f02ec9fd4a0d529a5f74db Mon Sep 17 00:00:00 2001 From: ht Date: Sun, 6 Sep 2026 10:45:45 +0300 Subject: [PATCH] Verify document deletion recovery across forced process termination --- docs/quality/uygulama-durumu.md | 8 +++ tests/document-delete-crash.test.mjs | 83 ++++++++++++++++++++++++++++ 2 files changed, 91 insertions(+) create mode 100644 tests/document-delete-crash.test.mjs diff --git a/docs/quality/uygulama-durumu.md b/docs/quality/uygulama-durumu.md index 1f04879..130701b 100644 --- a/docs/quality/uygulama-durumu.md +++ b/docs/quality/uygulama-durumu.md @@ -655,3 +655,11 @@ Test: taslak kaynak verisinin JSON kaydetme/okuma turunda yeterlik, ceza ve kan Fiziksel temizleme başarısızsa belge uygulamadan erişilemez durumdadır; `document_file_cleanup` kuyruğu korunur ve sonraki veritabanı açılışlarında en fazla 100 dosya yeniden denenir. Yeniden başlatma için bellek içi görev gerekmez. Hâlen başka kayıt tarafından kullanılan dosya silinmez; belge dizini dışındaki yollar işlenmez. Bu, mantıksal silme ile fiziksel temizlemeyi ayırır; API başarısı kaydın erişimden kaldırıldığını ifade eder, bütün fiziksel baytların hemen temizlendiğini değil. Migration sayısı 5 oldu. Testler: DELETE trigger hatasında gerçek dosya baytlarının ve kaydın korunması; dosya sistemi hatası için dosya yolunda dizinle unlink başarısızlığı; kayıt erişiminin kapanması; kuyruk kaydının yeniden açılışta korunması; sorun giderilince temizlenmesi. Eski unlink-hatasında-kayıt-kalır testi yeni kalıcı kuyruk sözleşmesine güncellendi. Tüm 140 test geçti. Üretim kodunda yalnız sunucu/depo değiştiğinden ön yüz derlemesi tekrarlanmadı. Zorla süreç öldürme/güç kesilmesi kabulü yapılmadı. Canlı yayın ve tam plan açık. + +### D01 — Belge silmede gerçek süreç kesintisi kabulü + +İzole geçici volume kullanan iki çocuk süreçte gerçek SIGKILL testi eklendi. İlkinde belge DELETE işlemi çalıştıktan sonra COMMIT öncesinde süreç durduruldu; ikinci durumda COMMIT sonrası fiziksel unlink başlamadan durduruldu. Kontrol noktasında dosyanın hâlâ diskte bulunduğu doğrulandı. Enjeksiyon yalnız test çocuk sürecindeki metotlarla sınırlıdır; üretimde hata tetikleyicisi eklenmedi. + +Her durumda ayrı yeni süreç kullanıcı deposunu açtı. Commit öncesi kesintide kayıt ve özgün Türkçe dosya baytları korundu; commit sonrası kesintide kayıt yoktu ve kalıcı temizleme görevi yeni süreçte dosyayı kaldırdı. İki durumda da PRAGMA integrity_check sonucu ok, kuyruk sıfırdı. Bu, uygulama süreci kesilmesinin kabulüdür; donanım güç kaybı/dosya sistemi arızası veya bütün production rollback kabulü değildir. + +Tüm 142 test geçti. Üretim kodu değişmediği için derleme tekrarlanmadı. D01'in bu kesinti maddesi kanıtlandı; diğer plan maddeleri ve canlı yayın açık. diff --git a/tests/document-delete-crash.test.mjs b/tests/document-delete-crash.test.mjs new file mode 100644 index 0000000..d1ac063 --- /dev/null +++ b/tests/document-delete-crash.test.mjs @@ -0,0 +1,83 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {spawn} from 'node:child_process'; +import {mkdtempSync,rmSync,existsSync,readFileSync} from 'node:fs'; +import {tmpdir} from 'node:os'; +import {join} from 'node:path'; + +// All fault injection is confined to a disposable child process and temp volume. +async function killedDeletion(phase,dir) { + const moduleUrl=new URL('../server/user-store.mjs',import.meta.url).href; + const code=` + import fs from 'node:fs'; + import {DatabaseSync} from 'node:sqlite'; + const store=await import(${JSON.stringify(moduleUrl)}); + const email='crash@example.test'; + const bytes=Buffer.from('Kesinti sırasında korunacak gerçek belge baytları.'); + const id=store.saveDocumentFile(email,{ad:'crash.txt'},bytes); + const path=store.BELGE_DIR+'/'+store.getDocument(email,id).dosya; + const pause=()=>{process.send({phase:${JSON.stringify(phase)},id,path});Atomics.wait(new Int32Array(new SharedArrayBuffer(4)),0,0);}; + if(${JSON.stringify(phase)}==='before-commit') { + const original=DatabaseSync.prototype.prepare; + DatabaseSync.prototype.prepare=function(sql){ + const statement=original.call(this,sql); + if(sql==='DELETE FROM belgeler WHERE id=? AND email=?') { + const run=statement.run.bind(statement); + statement.run=(...args)=>{const result=run(...args);pause();return result;}; + } + return statement; + }; + } else { + const original=fs.unlinkSync; + fs.unlinkSync=function(candidate){if(candidate===path)pause();return original(candidate);}; + } + store.deleteDocument(email,id); + process.exit(3); + `; + const child=spawn(process.execPath,['--input-type=module','-e',code],{ + env:{...process.env,IHALEIST_USER_DATA_DIR:dir},stdio:['ignore','ignore','pipe','ipc'], + }); + let stderr='';child.stderr.on('data',chunk=>{stderr+=chunk;}); + let timer; + try { + const message=await new Promise((resolve,reject)=>{ + child.once('message',resolve); + child.once('error',reject); + child.once('exit',(code,signal)=>reject(new Error(`Child exited before checkpoint: ${code}/${signal} ${stderr}`))); + timer=setTimeout(()=>reject(new Error('Child did not reach deletion checkpoint')),10000); + }); + clearTimeout(timer); + const stopped=new Promise(resolve=>child.once('exit',(code,signal)=>resolve({code,signal}))); + child.kill('SIGKILL'); + assert.equal((await stopped).signal,'SIGKILL'); + return message; + } finally {clearTimeout(timer);if(child.exitCode===null && child.signalCode===null)child.kill('SIGKILL');} +} + +for(const phase of ['before-commit','after-commit']) { + test(`document deletion recovers after real SIGKILL ${phase}`,async()=>{ + const dir=mkdtempSync(join(tmpdir(),'ihaleist-crash-')); + try { + const checkpoint=await killedDeletion(phase,dir); + assert.equal(existsSync(checkpoint.path),true,'file must still exist at the forced crash checkpoint'); + // Inspect recovery in another process: no cached connection can mask WAL recovery. + const code=` + const store=await import(${JSON.stringify(new URL('../server/user-store.mjs',import.meta.url).href)}); + const record=store.getDocument('crash@example.test',${JSON.stringify(checkpoint.id)}); + const db=store.openDb(); + const pending=db.prepare('SELECT COUNT(*) n FROM document_file_cleanup').get().n; + const integrity=db.prepare('PRAGMA integrity_check').get().integrity_check; + db.close();console.log(JSON.stringify({exists:!!record,pending,integrity})); + `; + const child=spawn(process.execPath,['--input-type=module','-e',code],{env:{...process.env,IHALEIST_USER_DATA_DIR:dir},stdio:['ignore','pipe','pipe']}); + let output='',errors='';child.stdout.on('data',chunk=>output+=chunk);child.stderr.on('data',chunk=>errors+=chunk); + const exit=await new Promise((resolve,reject)=>{child.once('error',reject);child.once('exit',resolve);}); + assert.equal(exit,0,errors); + const result=JSON.parse(output); + assert.equal(result.integrity,'ok');assert.equal(result.pending,0); + assert.equal(result.exists,phase==='before-commit'); + assert.equal(existsSync(checkpoint.path),phase==='before-commit'); + if(phase==='before-commit')assert.equal(readFileSync(checkpoint.path,'utf8'),'Kesinti sırasında korunacak gerçek belge baytları.'); + } finally {rmSync(dir,{recursive:true,force:true});} + }); +}