Make document deletion durable before physical file cleanup

This commit is contained in:
2026-09-06 10:44:47 +03:00
parent fa56e0fe83
commit 3b40061d96
5 changed files with 81 additions and 13 deletions
+8
View File
@@ -647,3 +647,11 @@ Hazırlık taslağı daha önce analizden yalnız kalem/belge listesi, kaynak s
Kaynak karşılaştırma imzasına bu bilgiler dahil edildi. Aynı doküman sürümünden yeni bir kritik şart çıkarılması artık eski kaynak onayını geçerli bırakmaz. Eski kayıtlar yeni imzayla yeniden kontrol gerektirir; fiyatlar/kullanıcı düzeltmeleri silinmez. Hazırlık formunda yeterlik/risk/koşullar başlangıçta açık bir bölümde gösterilir; genel karşılaştırma beyanı bunları da kapsar. Listenin eksiksiz veya koşulların karşılanmış olduğu iddia edilmez.
Test: taslak kaynak verisinin JSON kaydetme/okuma turunda yeterlik, ceza ve kanıtı koruması; kayıt öncesi/sonrası imzanın eşitliği; sekiz koşul kategorisindeki değişiklikte eski onayın geçersizleşmesi. Tüm 138 test ve üretim derlemesi geçti. Yeni koşul bölümünün tarayıcı/gerçek dosya kabulü açık. Büyük paket uyarıları sürüyor; canlı yayın yapılmadı.
### D01 — Belge silme ve fiziksel temizleme tutarlılığı
Önceden dosya önce siliniyor, ardından veritabanı kaydı kaldırılıyordu; SQL hatası sağlam belgenin baytlarını geri getiremiyordu. Şimdi belge/review kayıtlarının kaldırılması ve dosya temizleme kuyruğu aynı transaction içinde kaydedilir. Fiziksel unlink yalnız commit sonrasında yapılır. SQL hatasında dosya ve kayıt korunur.
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.
+4
View File
@@ -27,3 +27,7 @@ Yedek aracı SQLite snapshot oluşturur, bütünlük kontrolü yapar, referansl
## Mevcut kanıt
`tests/backup-user-data.test.mjs` izole SQLite ve gerçek Türkçe dosya baytlarıyla yedek/restore, hazırlık fiyatı, hedef üzerine yazmama ve değiştirilmiş yedeği reddetme senaryolarını doğrular. Üretim servis durdurma, volume yedeği ve platform geri dönüşü henüz denenmedi. Bu dosya yayın onayı veya tüm D11 tamamlandı beyanı değildir.
## Bekleyen dosya temizliği
`20260906_document_file_cleanup` migration'ı, mantıksal olarak silinmiş belgelerin fiziksel dosyalarını kalıcı kuyrukta tutar. Erişim kaydı ve temizleme görevi aynı transaction'da değişir; sonraki veritabanı açılışları temizliği yeniden dener. Dosya sistemi sorunu sürüyorsa kuyrukta kalır. Bu dosyalar aktif belge yedeğine dahil değildir; yedekte kuyruk kaydı bulunup dosya bulunmazsa yeniden açılışta ENOENT başarılı temizleme olarak ele alınır. Fiziksel silmenin anında tamamlandığı varsayılmamalı; işletim kontrolünde kuyruk büyüklüğü ve volume izinleri izlenmelidir.
+28 -6
View File
@@ -116,12 +116,16 @@ export function openDb() {
.run('20260906_document_processing', new Date().toISOString());
db.prepare('INSERT OR IGNORE INTO schema_migrations VALUES (?,?)')
.run('20260906_document_text_reviews', new Date().toISOString());
db.exec('CREATE TABLE IF NOT EXISTS document_file_cleanup (dosya TEXT PRIMARY KEY, queued_at TEXT NOT NULL)');
db.prepare('INSERT OR IGNORE INTO schema_migrations VALUES (?,?)').run('20260906_document_file_cleanup', now());
db.exec('COMMIT');
} catch (error) {
try { db.exec('ROLLBACK'); } catch { /* original error is authoritative */ }
db.close();
throw error;
}
// Cleanup is recoverable maintenance; a failure cannot undo committed deletion.
try { cleanupDocumentFiles(db); } catch { /* durable queue retries on the next database open */ }
return db;
}
@@ -334,18 +338,36 @@ export function updateDocument(email, id, patch = {}) {
export function listUsers() {
return withDb((db) => db.prepare('SELECT email, ad FROM kullanicilar').all(), []);
}
function cleanupDocumentFiles(db) {
db.exec('BEGIN IMMEDIATE');
try {
for (const row of db.prepare('SELECT dosya FROM document_file_cleanup ORDER BY queued_at LIMIT 100').all()) {
// Legacy records may share a path. Never remove a still-referenced file.
if (db.prepare('SELECT 1 FROM belgeler WHERE dosya=? LIMIT 1').get(row.dosya)) continue;
const destination = path.resolve(BELGE_DIR,row.dosya);
if (path.dirname(destination) !== path.resolve(BELGE_DIR)) continue;
try { fs.unlinkSync(destination); }
catch (error) { if (error.code !== 'ENOENT') continue; }
db.prepare('DELETE FROM document_file_cleanup WHERE dosya=?').run(row.dosya);
}
db.exec('COMMIT');
} catch (error) { db.exec('ROLLBACK'); throw error; }
}
export function deleteDocument(email, id) {
const rec = getDocument(email, id);
if (!rec) return false;
try { fs.unlinkSync(path.join(BELGE_DIR, rec.dosya)); }
catch (error) { if (error.code !== 'ENOENT') throw error; }
return withDb((db) => {
db.exec('BEGIN IMMEDIATE');
try {
const rec = db.prepare('SELECT dosya FROM belgeler WHERE id=? AND email=?').get(id,email);
if (!rec) { db.exec('COMMIT'); return false; }
db.prepare('INSERT OR IGNORE INTO document_file_cleanup (dosya,queued_at) VALUES (?,?)').run(rec.dosya,now());
db.prepare('DELETE FROM document_text_reviews WHERE document_id=? AND email=?').run(id,email);
const changed=db.prepare('DELETE FROM belgeler WHERE id=? AND email=?').run(id,email).changes>0;
db.exec('COMMIT');return changed;
db.prepare('DELETE FROM belgeler WHERE id=? AND email=?').run(id,email);
db.exec('COMMIT');
} catch(error) { db.exec('ROLLBACK'); throw error; }
// The document is now inaccessible. Unlink only after the durable commit.
try { cleanupDocumentFiles(db); } catch { /* retry through the durable queue */ }
return true;
});
}
+10 -4
View File
@@ -22,14 +22,20 @@ test('failed document insert removes only the new upload and preserves previous
assert.equal(store.listDocuments('a').length,1);
});
test('filesystem deletion error preserves document metadata instead of reporting success',()=>{
test('filesystem cleanup failure keeps a durable deletion task and denies document access',()=>{
const destination=join(store.BELGE_DIR,'not-a-file');
fs.mkdirSync(destination);
const db=store.openDb(); db.exec('DROP TRIGGER reject_document'); db.close();
const id=store.addDocument('a',{ad:'test',boyut:1,dosya:'not-a-file'});
assert.throws(()=>store.deleteDocument('a',id));
assert.ok(store.getDocument('a',id));
assert.equal(store.deleteDocument('b',id),false);
fs.rmdirSync(destination);
assert.ok(store.getDocument('a',id));
assert.equal(store.deleteDocument('a',id),true);
assert.equal(store.getDocument('a',id),null);
const pending=store.openDb();
assert.ok(pending.prepare('SELECT 1 FROM document_file_cleanup WHERE dosya=?').get('not-a-file'));
pending.close();
fs.rmdirSync(destination);
const recovered=store.openDb();
assert.equal(recovered.prepare('SELECT COUNT(*) n FROM document_file_cleanup').get().n,0);
recovered.close();
});
+30 -2
View File
@@ -1,6 +1,6 @@
import { test, after } from 'node:test';
import assert from 'node:assert/strict';
import { mkdtempSync, rmSync } from 'node:fs';
import { mkdtempSync, rmSync, existsSync, readFileSync, unlinkSync, mkdirSync, rmdirSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { DatabaseSync } from 'node:sqlite';
@@ -15,7 +15,7 @@ after(() => rmSync(dir, { recursive: true, force: true }));
test('old volume migrates before indexing, preserves files and migration is repeatable', () => {
for (let i = 0; i < 2; i++) {
const db = store.openDb();
assert.equal(db.prepare('SELECT COUNT(*) n FROM schema_migrations').get().n, 4);
assert.equal(db.prepare('SELECT COUNT(*) n FROM schema_migrations').get().n, 5);
assert.ok(db.prepare('PRAGMA table_info(belgeler)').all().some(c => c.name === 'ikn'));
db.close();
}
@@ -69,3 +69,31 @@ test('API key quota is transactional and revoked slots can be reused', () => {
for(const ad of [{},[],42,'x'.repeat(121)])assert.throws(()=>store.createApiKey('invalid-name@example.test',ad));
assert.deepEqual(store.listApiKeys('invalid-name@example.test'),[]);
});
test('failed document deletion leaves the original bytes and metadata intact',()=>{
const email='delete-rollback@example.test', bytes=Buffer.from('Korunması gereken belge');
const id=store.saveDocumentFile(email,{ad:'rollback.txt'},bytes);
const file=join(store.BELGE_DIR,store.getDocument(email,id).dosya);
const db=store.openDb();
db.exec("CREATE TRIGGER reject_doc_delete BEFORE DELETE ON belgeler BEGIN SELECT RAISE(FAIL,'delete rejected'); END;");db.close();
try {
assert.throws(()=>store.deleteDocument(email,id),/delete rejected/);
assert.deepEqual(readFileSync(file),bytes);
assert.ok(store.getDocument(email,id));
} finally {const db=store.openDb();db.exec('DROP TRIGGER reject_doc_delete');db.close();}
assert.equal(store.deleteDocument(email,id),true);
assert.equal(existsSync(file),false);
});
test('committed deletion remains inaccessible and pending physical cleanup survives reopen',()=>{
const email='cleanup@example.test';
const id=store.saveDocumentFile(email,{ad:'cleanup.txt'},Buffer.from('QA'));
const file=join(store.BELGE_DIR,store.getDocument(email,id).dosya);
unlinkSync(file);mkdirSync(file); // force unlink failure without relying on OS permissions
assert.equal(store.deleteDocument(email,id),true);
assert.equal(store.getDocument(email,id),null);
const db=store.openDb();assert.equal(db.prepare('SELECT COUNT(*) n FROM document_file_cleanup').get().n,1);db.close();
rmdirSync(file);writeFileSync(file,'QA cleanup recovered');
const reopened=store.openDb();assert.equal(reopened.prepare('SELECT COUNT(*) n FROM document_file_cleanup').get().n,0);reopened.close();
assert.equal(existsSync(file),false);
});