Verify document deletion recovery across forced process termination

This commit is contained in:
2026-09-06 10:45:45 +03:00
parent 3b40061d96
commit f51f9b4eb0
2 changed files with 91 additions and 0 deletions
+8
View File
@@ -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.
+83
View File
@@ -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});}
});
}