From f2b33749a7241bf74b0c7d163eebb402d9880c34 Mon Sep 17 00:00:00 2001 From: ht Date: Sun, 6 Sep 2026 12:48:29 +0300 Subject: [PATCH] Verify durable AI worker recovery after real process termination --- docs/quality/uygulama-durumu.md | 6 +++ docs/quality/yedek-geri-yukleme.md | 8 +++ tests/ai-job-crash.test.mjs | 83 ++++++++++++++++++++++++++++++ 3 files changed, 97 insertions(+) create mode 100644 tests/ai-job-crash.test.mjs diff --git a/docs/quality/uygulama-durumu.md b/docs/quality/uygulama-durumu.md index d526602..deabf0b 100644 --- a/docs/quality/uygulama-durumu.md +++ b/docs/quality/uygulama-durumu.md @@ -754,3 +754,9 @@ Taslak türü seçicisine erişilebilir ad eklendi. Üretim derlemesi geçti. Ö - Sunucu anahtarı anlatan teknik uyarı normal kullanıcı için hizmet durumu mesajıyla değiştirildi. Simgeli kapatma düğmesi erişilebilir ad aldı. - Kanıt: `qa-general-analysis-recovery.json`, 8 kontrol doğru. 167 otomatik test ve üretim derlemesi geçti. - QA yalnız sentetik sağlayıcı sonucu kullanır; gerçek SQLite iş kaydı/okuması ve genel analiz yürütücüsü kullanıldı. İş başlatma QA tarafından tutulur: üretim işçi yaşam döngüsü, gerçek AI doğruluğu veya sunucu yeniden başlatma kabulü olarak değerlendirilmez. + +### Kalıcı AI işçisi gerçek süreç kesintisi — 6 Eylül 2026 +- Genel analiz ve teklif taslağı için kaynak kaydı tamamlandıktan sonra çalışan işçi alt süreci SIGKILL ile durduruldu. Kurtarma ayrı yeni süreçte gerçek iş deposu ve runner ile sınandı. +- Lease dolmadan çağrı yapılmaması, lease dolunca interrupted durumu, açık retry olmadan çağrı yapılmaması, retry sonrasında aynı kaynak/sonuç ve başka hesaba erişim engeli doğrulandı. Uzak kaynak B çözücüsü hiç çağrılmadı; sabit kaynak A kullanıldı. Her iki SQLite integrity_check sonucu ok. +- 169 otomatik test geçti. Üretim kodu değişmediği için önceki başarılı derleme tekrar edilmedi. +- Test lease değerlendirme saatini ilerletir; gerçek 60 saniyelik bekleme, gerçek sağlayıcı isteği, elektrik kesintisi veya platform deploy/volume kurtarma kabulü değildir. Tam D11 yayın/geri dönüş provası açık. diff --git a/docs/quality/yedek-geri-yukleme.md b/docs/quality/yedek-geri-yukleme.md index 3b551ef..8f7d2cf 100644 --- a/docs/quality/yedek-geri-yukleme.md +++ b/docs/quality/yedek-geri-yukleme.md @@ -51,3 +51,11 @@ Bu araç seçilen dizin dışındaki worker depolarını, ortam değişkenlerini `tests/data-volume-backup.test.mjs`, iki SQLite deposu, committed WAL verisi, alt dizin dosyası, restore, hedef üzerine yazmama, değişmiş/ek dosya ve sembolik bağlantı reddini doğrular. Gerçek üretim volume'larına bu komutlar henüz uygulanmadı; platform geri dönüş provası açık. Kalıcı teklif asistanı işleri `IHALEIST_USER_DATA_DIR/ai-jobs.sqlite` içinde özel kaynak ve sonuç metni içerir. `backup-user-data.mjs` yalnız users.sqlite ve referanslı belgeleri aldığı için bu dosya ayrıca volume aracıyla yedeklenmelidir. Bütün yazıcılar durdurulmadan job/user yedek seti tutarlı kabul edilmez. + +Kalıcı genel analiz ve teklif işleri için süreç kesintisi davranışı: + +- İşçi kaynak metnini sabitledikten sonra aniden kapanırsa kayıt hemen başarısız sayılmaz. Son heartbeat üzerinden 60 saniye dolunca iş tarayıcısı kaydı `interrupted` durumuna geçirir. +- Kesilen işi otomatik tekrar çağırmaz; kullanıcı Kayıtlı AI işleri üzerinden yeniden başlatır. Kaynak sabitlenmişse aynı girdi kullanılır. Sağlayıcıya daha önce ulaşmış çağrının ücretini veya sonucunu geri alma garantisi yoktur. +- İnsan incelemeli genel analizde dosya/inceleme sürümü değişmişse eski işi yeniden başlatmak yerine güncel incelemeyle yeni iş gerekir. +- Kaynağın sabitlenmesinden önce kesilen işin, geçmişte hangi uzak metni edinmiş olabileceği kanıtlanamaz. Eski sürümde tamamlanan kayıtların kaynakları geriye dönük üretilemez. +- `node --test tests/ai-job-crash.test.mjs`: iki gerçek alt süreç SIGKILL testi; yeni süreçte kaynak/sonuç ve SQLite bütünlüğü kontrol edilir. Lease süresi testte saat parametresiyle ilerletilir. Gerçek AI sağlayıcısı ve üretim platformunun volume yeniden bağlama davranışı bu testin kapsamı dışındadır. diff --git a/tests/ai-job-crash.test.mjs b/tests/ai-job-crash.test.mjs new file mode 100644 index 0000000..6d139b6 --- /dev/null +++ b/tests/ai-job-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} from 'node:fs'; +import {tmpdir} from 'node:os'; +import {join} from 'node:path'; +const storeUrl=new URL('../server/ai-job-store.mjs',import.meta.url).href; +const runnerUrl=new URL('../server/ai-job-runner.mjs',import.meta.url).href; +const sourceUrl=new URL('../server/ai-job-source.mjs',import.meta.url).href; +const analysisUrl=new URL('../server/analysis-job.mjs',import.meta.url).href; +const source='Kesintiden önce kaydedilen laboratuvar şartnamesi. '.repeat(4); +const imports=` + import assert from 'node:assert/strict'; + import {DatabaseSync} from 'node:sqlite'; + import * as jobs from ${JSON.stringify(storeUrl)}; + import {startAiJobRunner} from ${JSON.stringify(runnerUrl)}; + import {resolveAiJobInput} from ${JSON.stringify(sourceUrl)}; + import {executeAnalysisJob} from ${JSON.stringify(analysisUrl)}; +`; +async function childAtCheckpoint(dir,code,kill) { + const child=spawn(process.execPath,['--input-type=module','-e',imports+code],{env:{...process.env,IHALEIST_USER_DATA_DIR:dir},stdio:['ignore','ignore','pipe','ipc']}); + let errors='',timer;child.stderr.on('data',chunk=>errors+=chunk); + const exit=new Promise((resolve,reject)=>{child.once('error',reject);child.once('exit',(code,signal)=>resolve({code,signal}));}); + try { + const checkpoint=await new Promise((resolve,reject)=>{ + child.once('message',resolve);child.once('error',reject); + child.once('exit',(code,signal)=>reject(new Error(`Before checkpoint: ${code}/${signal} ${errors}`))); + timer=setTimeout(()=>reject(new Error('AI worker checkpoint timeout: '+errors)),10000); + }); + if(kill)child.kill('SIGKILL'); + const ended=await exit; + if(kill)assert.equal(ended.signal,'SIGKILL');else assert.equal(ended.code,0,errors); + return checkpoint; + }finally {clearTimeout(timer);if(child.exitCode===null&&child.signalCode===null)child.kill('SIGKILL');} +} +for(const operation of ['analiz','teklif-taslak']) { + test(`real worker SIGKILL preserves ${operation} source and requires explicit retry`,async()=>{ + const dir=mkdtempSync(join(tmpdir(),'ihaleist-ai-crash-')); + try { + const first=await childAtCheckpoint(dir,` + const operation=${JSON.stringify(operation)},text=${JSON.stringify(source)}; + const created=jobs.createAiJob('owner','crash-request',operation,{tender:{ikn:'2026/1'},text}); + const hold=async()=>{process.send({id:created.id,input:jobs.getAiJob('owner',created.id).input});await new Promise(()=>{});}; + const runner=startAiJobRunner(async(job,{signal})=>{ + if(operation==='analiz')return executeAnalysisJob(job,{signal,pin:jobs.pinAiJobInput,analyze:hold}); + await resolveAiJobInput(job,async()=>({text,kaynak:'test source A'}),jobs.pinAiJobInput,signal);return hold(); + }); + runner.wake(); + `,true); + assert.equal(first.input.text,source); + const recovered=await childAtCheckpoint(dir,` + const id=${JSON.stringify(first.id)},operation=${JSON.stringify(operation)}; + let calls=0,fetches=0; + assert.equal(jobs.getAiJob('owner',id).state,'running'); + const runner=startAiJobRunner(async(job,{signal})=>{ + calls++; + if(operation==='analiz')return executeAnalysisJob(job,{signal,pin:jobs.pinAiJobInput,analyze:async input=>({result:{text:input.text},model:{label:'test'}})}); + const input=await resolveAiJobInput(job,async()=>{fetches++;return {text:'Source B must not replace source A'};},jobs.pinAiJobInput,signal); + return {success:true,result:{text:input.text}}; + },{intervalMs:100000}); + runner.wake();assert.equal(calls,0,'unexpired lease must not replay'); + // Advance only lease assessment; no wall-clock delay or production setting change. + assert.equal(jobs.claimAiJob(Date.now()+61000),null); + assert.equal(jobs.getAiJob('owner',id).state,'interrupted'); + runner.wake();assert.equal(calls,0,'interrupted work must not replay'); + assert.equal(jobs.getAiJob('other-owner',id),null); + assert.equal(jobs.retryAiJob('owner',id),true);runner.wake(); + const poll=setInterval(()=>{ + const saved=jobs.getAiJob('owner',id); + if(saved.state==='completed'){ + clearInterval(poll);runner.stop(); + const db=new DatabaseSync(process.env.IHALEIST_USER_DATA_DIR+'/ai-jobs.sqlite'); + const integrity=db.prepare('PRAGMA integrity_check').get().integrity_check;db.close(); + process.send({input:saved.input,result:saved.result,calls,fetches,integrity});process.disconnect(); + } + },10); + `,false); + assert.deepEqual(recovered.input,first.input); + assert.equal(recovered.result.result.text,source); + assert.equal(recovered.calls,1);assert.equal(recovered.fetches,0);assert.equal(recovered.integrity,'ok'); + }finally {rmSync(dir,{recursive:true,force:true});} + }); +}