diff --git a/TEST-SCENARIOS.md b/TEST-SCENARIOS.md index fc29f30..17ec586 100644 --- a/TEST-SCENARIOS.md +++ b/TEST-SCENARIOS.md @@ -16,7 +16,8 @@ geçerliliğini yitiren senaryo **silinmez**, `deprecated` işaretlenip sebebi y | Senaryo | Test Edilen vNext Feature Seti | Neden Eklendi | Integration Test | Python Test | Durum | |---|---|---|---|---|---| | **error-boundary-lab** | Error boundary çözümlemesi: `CompiledBoundaryChain` Task→State→Global seviye baskınlığı · seviye içi sıra (`EffectivePriority` ASC → specificity DESC; default wildcard'ın 999'a düşmesi) · aksiyonlar abort/retry/rollback/notify/ignore/log · `TaskExecutionEngine` retry döngüsü (`1 + maxRetries`) ve tükenince `ResolveExcluding(Retry)` fallback'i · `BoundaryOutcomeHandler` → fault vs `RequestNextTransition` · `FinalizeTransitionStep`'in boundary transition'ı bitince incident'ı resolve etmesi · `InstanceIncidents` tablosu + denormalize `HasActiveIncident` · state function `incident` bloğu (link tabanlı, ResponseShapeVersion v9, ETag materyali) · `GET .../instances/{id}/incidents/active` (404 = açık arıza yok) · `GET .../instances/{id}/incidents` sayfalama · `metadata.incident` (state bloğuyla aynı şekil) · `POST .../retry` (400 `Instance:100027`, yeniden fault, veriyle kurtarma) · `queryRoles` kapısının incident geçmişine de uygulanması | vnext `feature/incident-table` (issue #865) incident'ları jsonb'den kendi tablosuna taşıdı ve iki yeni client yüzeyi ekledi; ayrıca error boundary bu repoda hiç senaryo olarak yoktu ve `POST .../retry` hiçbir testte çağrılmıyordu (2026-09-06) | `Tests/ErrorBoundaryLab` (6 sınıf, 31 test) | — (bilinçli: davranış/çözümleme senaryosu, eşzamanlılık iddiası yok) | ✅ **Aktif — 31/31 yeşil** (lokal runtime `feature/incident-table`, art arda iki koşu, ~1 dk 46 sn; ilk koşu 2026-09-06, üç davranışsal kusurun düzeltilmesinden sonra 2026-09-07'de yeniden) | -| **chain-busy** | Accept-time subflow chain reserve · Busy-as-mutex · `$self` shared transition vs `updateData` lifecycle sınırı · start `initial → initial` semantiği · cancel propagasyonu (in-process ↕ distributed) · scheduled transition re-arm | `updateData`-only self-target profil sınırını pinlemek — `target: $self` "hook'ları atla" demek değil (2026-08-17) | `Tests/ChainBusy` (5 sınıf) | `api-tests/chain-busy/chain-busy-behaviour-test.py`, `chain-busy-accept-test.py` | ✅ Aktif | +| **event-driven-lab** | Olay tetiklemeli start + `triggerType: 3` transition · Dapr Subscription route'u · CloudEvent açma · `IEventMapping` ile iş anahtarı korelasyonu · eşleşmeyen olayın ack'lenmesi | `POST .../instances/events` ucunun **hiç testi yoktu** (council `2026-09-11-route-trace-coverage`, adı konmuş boşluk); ayrıca `Event.Intake` span'i bu ucu ölçüyor ve testsiz route'a span gönderilmez (2026-09-13) | `Tests/EventDrivenLab` (1 sınıf) | — | ✅ Aktif | +| **chain-busy** | Accept-time subflow chain reserve **ve başarısız forward'da geri alınması (E31)** · Busy-as-mutex · `$self` shared transition vs `updateData` lifecycle sınırı · start `initial → initial` semantiği · cancel propagasyonu (in-process ↕ distributed) · scheduled transition re-arm | `updateData`-only self-target profil sınırını pinlemek — `target: $self` "hook'ları atla" demek değil (2026-08-17). **E31 (2026-09-12):** post-commit forward istemci hatasıyla düşünce rezervasyon salınmıyordu; üretimde 30 günde 51 mahsur instance (council `2026-09-08-parent-notification-mechanism`, P0) | `Tests/ChainBusy` (5 sınıf) | `api-tests/chain-busy/chain-busy-behaviour-test.py`, `chain-busy-accept-test.py` | ✅ Aktif | | **script-race-lab** | Script engine: paylaşılan `AssemblyLoadContext`'te çift-derleme yarışı · `scripts.helpers` · subflow output mapping · parent kalıcı fault riski | `Script_XXXX already loaded` / `FileLoadException` yarışının fixture'ı; fix'siz runtime'da kaybedenler parent'ı kalıcı fault'lar (2026-08-18) | `Tests/ScriptRaceLab` | `api-tests/script-race-lab/race-load.py` (yük), `publish.py` | ✅ Aktif | | **data-integrity-lab** | InstanceData v2: anında persist · lock altında kimlik · sıralı/paralel task yazımları · DataHash dedup (task + updateData) · versiyon satırı bütünlüğü | `feature/busy-as-mutex-locking` + InstanceData v2 geliştirmesini uçtan uca ölçmek (2026-08-13) | `Tests/DataIntegrityLab` | `api-tests/data-integrity-lab/integrity-lab-test.py` | ⚠️ Kısmen kırmızı — `run-parallel` konteynerli ortamda settle olmuyor (120s'te doğrulandı, hang) | | **subflow-orchestration** | 3 seviyeli subflow (parent → child → grandchild) · `updateData`'nın auto transition'ı tetiklemesi · aktif subflow'lu parent'ta `updateData` data-only kısa devresi · parent `$self` shared transition · eşzamanlı updateData tutarlılığı | Platformun subflow referans akışı; `feature/busy-as-mutex-locking` F1/F1a/F8 fix'lerinin doğrulaması (2026-04-28) | `Tests/SubflowOrchestration` | `api-tests/subflow-orchestration/updatedata-concurrency-test.py` | ✅ Aktif | diff --git a/core/Tasks/event-driven-lab/event-lab-script-task.json b/core/Tasks/event-driven-lab/event-lab-script-task.json new file mode 100644 index 0000000..cf24c76 --- /dev/null +++ b/core/Tasks/event-driven-lab/event-lab-script-task.json @@ -0,0 +1,16 @@ +{ + "key": "event-lab-script-task", + "version": "1.0.0", + "domain": "core", + "flow": "sys-tasks", + "flowVersion": "1.0.0", + "tags": [ + "integration-test", + "event-driven-lab", + "script-task" + ], + "attributes": { + "type": "7", + "config": {} + } +} diff --git a/core/Workflows/event-driven-lab/build-event-driven-lab.py b/core/Workflows/event-driven-lab/build-event-driven-lab.py new file mode 100644 index 0000000..0cad2c0 --- /dev/null +++ b/core/Workflows/event-driven-lab/build-event-driven-lab.py @@ -0,0 +1,104 @@ +#!/usr/bin/env python3 +"""Regenerates event-driven-lab.json from the .csx sources in ./src. + +Every mapping is embedded as base64 in `code` next to its `location`; edit the .csx files and +re-run this script — never hand-edit the base64 blobs. + + python3 core/Workflows/event-driven-lab/build-event-driven-lab.py +""" + +import base64 +import json +from pathlib import Path + +ROOT = Path(__file__).resolve().parent + + +def code(name): + raw = (ROOT / "src" / name).read_bytes() + return {"location": f"./src/{name}", "code": base64.b64encode(raw).decode()} + + +def label(text): + return [{"language": "en-US", "label": text}] + + +SCRIPT_TASK = { + "key": "event-lab-script-task", + "domain": "core", + "version": "1.0.0", + "flow": "sys-tasks", +} + +workflow = { + "key": "event-driven-lab", + "flow": "sys-flows", + "flowVersion": "1.0.0", + "domain": "core", + # Publishing is version-immutable: the same version with different content is refused with + # Instance:100002. Every content change here needs a patch bump. + "version": "1.0.1", + "tags": ["integration-test", "event-driven-lab", "event", "pubsub", "tracing"], + "attributes": { + "type": "F", + "labels": label("Event Driven Lab"), + "functions": [], + "extensions": [], + # Workflow-level event mapping = the action=start subscription's entry point. + "event": {"mapping": code("StartEventMapping.csx")}, + "startTransition": { + "key": "start-event-driven-lab", + "target": "awaiting-approval", + "triggerType": 0, + "versionStrategy": "Major", + "labels": label("Start Event Driven Lab"), + }, + "states": [ + { + "key": "awaiting-approval", + "stateType": 1, + "subType": 0, + "versionStrategy": "Major", + "labels": label("Awaiting Approval"), + "view": None, + "subFlow": None, + "onEntries": [], + "onExits": [], + "transitions": [ + { + "key": "approve-by-event", + "target": "approved", + # 3 = Event. Only an event delivery may drive this transition. + "triggerType": 3, + "versionStrategy": "Minor", + "labels": label("Approve By Event"), + "event": {"mapping": code("ApproveEventMapping.csx")}, + "onExecutionTasks": [ + { + "order": 1, + "task": SCRIPT_TASK, + "mapping": code("RecordApprovalMapping.csx"), + } + ], + } + ], + }, + { + "key": "approved", + "stateType": 3, + "subType": 1, + "versionStrategy": "Major", + "labels": label("Approved"), + "view": None, + "subFlow": None, + "onEntries": [], + "onExits": [], + "transitions": [], + }, + ], + }, +} + +out = ROOT / "event-driven-lab.json" +out.write_text(json.dumps(workflow, indent=2, ensure_ascii=False) + "\n", encoding="utf-8") +print(f"wrote {out}") diff --git a/core/Workflows/event-driven-lab/event-driven-lab.json b/core/Workflows/event-driven-lab/event-driven-lab.json new file mode 100644 index 0000000..c82a607 --- /dev/null +++ b/core/Workflows/event-driven-lab/event-driven-lab.json @@ -0,0 +1,113 @@ +{ + "key": "event-driven-lab", + "flow": "sys-flows", + "flowVersion": "1.0.0", + "domain": "core", + "version": "1.0.1", + "tags": [ + "integration-test", + "event-driven-lab", + "event", + "pubsub", + "tracing" + ], + "attributes": { + "type": "F", + "labels": [ + { + "language": "en-US", + "label": "Event Driven Lab" + } + ], + "functions": [], + "extensions": [], + "event": { + "mapping": { + "location": "./src/StartEventMapping.csx", + "code": "dXNpbmcgU3lzdGVtLlRocmVhZGluZy5UYXNrczsKdXNpbmcgQkJULldvcmtmbG93LlNjcmlwdGluZzsKCi8vLyA8c3VtbWFyeT4KLy8vIFdvcmtmbG93LWxldmVsIGV2ZW50IG1hcHBpbmc6IHR1cm5zIGEgcmF3IGJyb2tlciBtZXNzYWdlIGludG8gYSBuZXcgaW5zdGFuY2UuCi8vLyA8cGFyYT4KLy8vIFJlYWNoZWQgdGhyb3VnaCA8Yz5QT1NUIC4uLi9pbnN0YW5jZXMvZXZlbnRzP2FjdGlvbj1zdGFydDwvYz4sIHdoaWNoIGEgRGFwciBTdWJzY3JpcHRpb24gcm91dGVzCi8vLyB0byBmcm9tIHRoZSB0b3BpYy4gVGhlIHJ1bnRpbWUgaGFzIGFscmVhZHkgdW53cmFwcGVkIHRoZSBDbG91ZEV2ZW50IGVudmVsb3BlLCBzbwovLy8gPGM+Y29udGV4dC5FdmVudFBheWxvYWQ8L2M+IGlzIHRoZSBtZXNzYWdlIHRoZSBwdWJsaXNoZXIgYWN0dWFsbHkgc2VudC4KLy8vIDwvcGFyYT4KLy8vIDxwYXJhPgovLy8gPGM+SW5zdGFuY2VLZXk8L2M+IGlzIHRoZSBjb3JyZWxhdGlvbiBjb250cmFjdCBmb3IgZXZlcnl0aGluZyB0aGF0IGZvbGxvd3M6IHRoZSBhcHByb3ZlIGV2ZW50Ci8vLyBsYXRlciBmaW5kcyB0aGlzIGluc3RhbmNlIGJ5IGV4YWN0bHkgdGhpcyB2YWx1ZSwgc28gaXQgbXVzdCBjb21lIGZyb20gdGhlIHBheWxvYWQgYW5kIG5vdCBiZQovLy8gZ2VuZXJhdGVkIGhlcmUuCi8vLyA8L3BhcmE+Ci8vLyA8L3N1bW1hcnk+CnB1YmxpYyBjbGFzcyBTdGFydEV2ZW50TWFwcGluZyA6IElFdmVudE1hcHBpbmcKewogICAgcHVibGljIGFzeW5jIFRhc2s8RXZlbnRNYXBwaW5nUmVzdWx0PiBIYW5kbGVyKFNjcmlwdENvbnRleHQgY29udGV4dCkKICAgIHsKICAgICAgICB2YXIgcGF5bG9hZCA9IGNvbnRleHQuRXZlbnRQYXlsb2FkOwoKICAgICAgICByZXR1cm4gYXdhaXQgVGFzay5Gcm9tUmVzdWx0KG5ldyBFdmVudE1hcHBpbmdSZXN1bHQKICAgICAgICB7CiAgICAgICAgICAgIEluc3RhbmNlS2V5ID0gKHN0cmluZylwYXlsb2FkLm9yZGVySWQsCiAgICAgICAgICAgIEJvZHkgPSBuZXcKICAgICAgICAgICAgewogICAgICAgICAgICAgICAgb3JkZXJJZCA9IChzdHJpbmcpcGF5bG9hZC5vcmRlcklkLAogICAgICAgICAgICAgICAgYW1vdW50ID0gcGF5bG9hZC5hbW91bnQsCiAgICAgICAgICAgICAgICAvLyBQcm92ZXMgdGhlIGluc3RhbmNlIHdhcyBjcmVhdGVkIGJ5IHRoZSBldmVudCBwYXRoIHJhdGhlciB0aGFuIGJ5IGFuIEhUVFAgc3RhcnQuCiAgICAgICAgICAgICAgICBzdGFydGVkQnkgPSAiZXZlbnQiLAogICAgICAgICAgICAgICAgYXBwcm92YWxzID0gMAogICAgICAgICAgICB9CiAgICAgICAgfSk7CiAgICB9Cn0K" + } + }, + "startTransition": { + "key": "start-event-driven-lab", + "target": "awaiting-approval", + "triggerType": 0, + "versionStrategy": "Major", + "labels": [ + { + "language": "en-US", + "label": "Start Event Driven Lab" + } + ] + }, + "states": [ + { + "key": "awaiting-approval", + "stateType": 1, + "subType": 0, + "versionStrategy": "Major", + "labels": [ + { + "language": "en-US", + "label": "Awaiting Approval" + } + ], + "view": null, + "subFlow": null, + "onEntries": [], + "onExits": [], + "transitions": [ + { + "key": "approve-by-event", + "target": "approved", + "triggerType": 3, + "versionStrategy": "Minor", + "labels": [ + { + "language": "en-US", + "label": "Approve By Event" + } + ], + "event": { + "mapping": { + "location": "./src/ApproveEventMapping.csx", + "code": "dXNpbmcgU3lzdGVtLlRocmVhZGluZy5UYXNrczsKdXNpbmcgQkJULldvcmtmbG93LlNjcmlwdGluZzsKCi8vLyA8c3VtbWFyeT4KLy8vIFRyYW5zaXRpb24tbGV2ZWwgZXZlbnQgbWFwcGluZyBmb3IgPGM+YXBwcm92ZS1ieS1ldmVudDwvYz4gKDxjPnRyaWdnZXJUeXBlOiAzPC9jPikuCi8vLyA8cGFyYT4KLy8vIENvcnJlbGF0ZXMgYnkgYnVzaW5lc3Mga2V5OiB0aGUgcnVudGltZSBsb29rcyB1cCB0aGUgQUNUSVZFIGluc3RhbmNlIHdob3NlIGtleSBlcXVhbHMKLy8vIDxjPkluc3RhbmNlS2V5PC9jPi4gTm8gbWF0Y2ggaXMgYSBub3JtYWwgYW5zd2VyIOKAlCB0aGUgZW5kcG9pbnQgYWNrcyBzbyB0aGUgYnJva2VyIGRvZXMgbm90Ci8vLyByZWRlbGl2ZXIgZm9yZXZlciDigJQgd2hpY2ggaXMgd2h5IHRoZSB0ZXN0IGFzc2VydHMgb24gdGhlIGluc3RhbmNlIHJhdGhlciB0aGFuIG9uIHRoZSByZXNwb25zZS4KLy8vIDwvcGFyYT4KLy8vIDwvc3VtbWFyeT4KcHVibGljIGNsYXNzIEFwcHJvdmVFdmVudE1hcHBpbmcgOiBJRXZlbnRNYXBwaW5nCnsKICAgIHB1YmxpYyBhc3luYyBUYXNrPEV2ZW50TWFwcGluZ1Jlc3VsdD4gSGFuZGxlcihTY3JpcHRDb250ZXh0IGNvbnRleHQpCiAgICB7CiAgICAgICAgdmFyIHBheWxvYWQgPSBjb250ZXh0LkV2ZW50UGF5bG9hZDsKCiAgICAgICAgcmV0dXJuIGF3YWl0IFRhc2suRnJvbVJlc3VsdChuZXcgRXZlbnRNYXBwaW5nUmVzdWx0CiAgICAgICAgewogICAgICAgICAgICBJbnN0YW5jZUtleSA9IChzdHJpbmcpcGF5bG9hZC5vcmRlcklkLAogICAgICAgICAgICBCb2R5ID0gbmV3CiAgICAgICAgICAgIHsKICAgICAgICAgICAgICAgIGRlY2lzaW9uID0gKHN0cmluZylwYXlsb2FkLmRlY2lzaW9uLAogICAgICAgICAgICAgICAgYXBwcm92ZWRCeSA9IChzdHJpbmcpcGF5bG9hZC5hcHByb3ZlZEJ5CiAgICAgICAgICAgIH0KICAgICAgICB9KTsKICAgIH0KfQo=" + } + }, + "onExecutionTasks": [ + { + "order": 1, + "task": { + "key": "event-lab-script-task", + "domain": "core", + "version": "1.0.0", + "flow": "sys-tasks" + }, + "mapping": { + "location": "./src/RecordApprovalMapping.csx", + "code": "dXNpbmcgU3lzdGVtLkNvbGxlY3Rpb25zLkdlbmVyaWM7CnVzaW5nIFN5c3RlbS5EeW5hbWljOwp1c2luZyBTeXN0ZW0uVGhyZWFkaW5nLlRhc2tzOwp1c2luZyBCQlQuV29ya2Zsb3cuRGVmaW5pdGlvbnM7CnVzaW5nIEJCVC5Xb3JrZmxvdy5TY3JpcHRpbmc7CgovLy8gPHN1bW1hcnk+Ci8vLyBXcml0ZXMgd2hhdCB0aGUgZXZlbnQgY2FycmllZCBpbnRvIGluc3RhbmNlIGRhdGEsIHNvIHRoZSB0ZXN0IGNhbiBhc3NlcnQgdGhhdCB0aGUgdHJhbnNpdGlvbiByYW4KLy8vIHdpdGggdGhlIG1hcHBlZCBib2R5IHJhdGhlciB0aGFuIG1lcmVseSB0aGF0IHRoZSBzdGF0ZSBtb3ZlZC4KLy8vIDwvc3VtbWFyeT4KcHVibGljIGNsYXNzIFJlY29yZEFwcHJvdmFsTWFwcGluZyA6IElNYXBwaW5nCnsKICAgIHB1YmxpYyBhc3luYyBUYXNrPFNjcmlwdFJlc3BvbnNlPiBJbnB1dEhhbmRsZXIoV29ya2Zsb3dUYXNrIHRhc2ssIFNjcmlwdENvbnRleHQgY29udGV4dCkKICAgIHsKICAgICAgICBkeW5hbWljIGJvZHkgPSBuZXcgRXhwYW5kb09iamVjdCgpOwogICAgICAgIGJvZHkuYXBwcm92YWxzID0gMTsKICAgICAgICBib2R5LmRlY2lzaW9uID0gY29udGV4dC5Cb2R5Py5kZWNpc2lvbjsKICAgICAgICBib2R5LmFwcHJvdmVkQnkgPSBjb250ZXh0LkJvZHk/LmFwcHJvdmVkQnk7CgogICAgICAgIHJldHVybiBhd2FpdCBUYXNrLkZyb21SZXN1bHQobmV3IFNjcmlwdFJlc3BvbnNlIHsgRGF0YSA9IGJvZHkgfSk7CiAgICB9CgogICAgcHVibGljIGFzeW5jIFRhc2s8U2NyaXB0UmVzcG9uc2U+IE91dHB1dEhhbmRsZXIoU2NyaXB0Q29udGV4dCBjb250ZXh0KQogICAgICAgID0+IGF3YWl0IFRhc2suRnJvbVJlc3VsdChuZXcgU2NyaXB0UmVzcG9uc2UgeyBEYXRhID0gY29udGV4dC5Cb2R5IH0pOwp9Cg==" + } + } + ] + } + ] + }, + { + "key": "approved", + "stateType": 3, + "subType": 1, + "versionStrategy": "Major", + "labels": [ + { + "language": "en-US", + "label": "Approved" + } + ], + "view": null, + "subFlow": null, + "onEntries": [], + "onExits": [], + "transitions": [] + } + ] + } +} \ No newline at end of file diff --git a/core/Workflows/event-driven-lab/src/ApproveEventMapping.csx b/core/Workflows/event-driven-lab/src/ApproveEventMapping.csx new file mode 100644 index 0000000..dacb858 --- /dev/null +++ b/core/Workflows/event-driven-lab/src/ApproveEventMapping.csx @@ -0,0 +1,28 @@ +using System.Threading.Tasks; +using BBT.Workflow.Scripting; + +/// +/// Transition-level event mapping for approve-by-event (triggerType: 3). +/// +/// Correlates by business key: the runtime looks up the ACTIVE instance whose key equals +/// InstanceKey. No match is a normal answer — the endpoint acks so the broker does not +/// redeliver forever — which is why the test asserts on the instance rather than on the response. +/// +/// +public class ApproveEventMapping : IEventMapping +{ + public async Task Handler(ScriptContext context) + { + var payload = context.EventPayload; + + return await Task.FromResult(new EventMappingResult + { + InstanceKey = (string)payload.orderId, + Body = new + { + decision = (string)payload.decision, + approvedBy = (string)payload.approvedBy + } + }); + } +} diff --git a/core/Workflows/event-driven-lab/src/RecordApprovalMapping.csx b/core/Workflows/event-driven-lab/src/RecordApprovalMapping.csx new file mode 100644 index 0000000..a21779e --- /dev/null +++ b/core/Workflows/event-driven-lab/src/RecordApprovalMapping.csx @@ -0,0 +1,25 @@ +using System.Collections.Generic; +using System.Dynamic; +using System.Threading.Tasks; +using BBT.Workflow.Definitions; +using BBT.Workflow.Scripting; + +/// +/// Writes what the event carried into instance data, so the test can assert that the transition ran +/// with the mapped body rather than merely that the state moved. +/// +public class RecordApprovalMapping : IMapping +{ + public async Task InputHandler(WorkflowTask task, ScriptContext context) + { + dynamic body = new ExpandoObject(); + body.approvals = 1; + body.decision = context.Body?.decision; + body.approvedBy = context.Body?.approvedBy; + + return await Task.FromResult(new ScriptResponse { Data = body }); + } + + public async Task OutputHandler(ScriptContext context) + => await Task.FromResult(new ScriptResponse { Data = context.Body }); +} diff --git a/core/Workflows/event-driven-lab/src/StartEventMapping.csx b/core/Workflows/event-driven-lab/src/StartEventMapping.csx new file mode 100644 index 0000000..151a530 --- /dev/null +++ b/core/Workflows/event-driven-lab/src/StartEventMapping.csx @@ -0,0 +1,36 @@ +using System.Threading.Tasks; +using BBT.Workflow.Scripting; + +/// +/// Workflow-level event mapping: turns a raw broker message into a new instance. +/// +/// Reached through POST .../instances/events?action=start, which a Dapr Subscription routes +/// to from the topic. The runtime has already unwrapped the CloudEvent envelope, so +/// context.EventPayload is the message the publisher actually sent. +/// +/// +/// InstanceKey is the correlation contract for everything that follows: the approve event +/// later finds this instance by exactly this value, so it must come from the payload and not be +/// generated here. +/// +/// +public class StartEventMapping : IEventMapping +{ + public async Task Handler(ScriptContext context) + { + var payload = context.EventPayload; + + return await Task.FromResult(new EventMappingResult + { + InstanceKey = (string)payload.orderId, + Body = new + { + orderId = (string)payload.orderId, + amount = payload.amount, + // Proves the instance was created by the event path rather than by an HTTP start. + startedBy = "event", + approvals = 0 + } + }); + } +} diff --git a/tests/Core.IntegrationTests/Infrastructure/DaprComponents/orchestration/subscription-event-driven-lab-approve.yaml b/tests/Core.IntegrationTests/Infrastructure/DaprComponents/orchestration/subscription-event-driven-lab-approve.yaml new file mode 100644 index 0000000..7331957 --- /dev/null +++ b/tests/Core.IntegrationTests/Infrastructure/DaprComponents/orchestration/subscription-event-driven-lab-approve.yaml @@ -0,0 +1,16 @@ +# Routes the approval topic to the same endpoint with action=transition. +# +# transitionKey is required for action=transition and must name a transition whose triggerType is 3; +# anything else is answered 400 NotAnEventTransition. sync=true makes the delivery block until the +# pipeline reaches a rest point, which is what lets a test assert on the instance immediately after +# the publish is acknowledged instead of polling for an indeterminate window. +apiVersion: dapr.io/v1alpha1 +kind: Subscription +metadata: + name: event-driven-lab-approve-subscription +spec: + topic: core.event-driven-lab.approve + route: "/api/v1/core/workflows/event-driven-lab/instances/events?action=transition&transitionKey=approve-by-event&sync=true" + pubsubname: vnext-pubsub +scopes: + - vnext-app diff --git a/tests/Core.IntegrationTests/Infrastructure/DaprComponents/orchestration/subscription-event-driven-lab-start.yaml b/tests/Core.IntegrationTests/Infrastructure/DaprComponents/orchestration/subscription-event-driven-lab-start.yaml new file mode 100644 index 0000000..150bab5 --- /dev/null +++ b/tests/Core.IntegrationTests/Infrastructure/DaprComponents/orchestration/subscription-event-driven-lab-start.yaml @@ -0,0 +1,15 @@ +# Routes the start topic to the runtime's event endpoint. +# +# Subscriptions are DOMAIN-OWNED: the runtime exposes the endpoint and the domain decides which +# topic reaches it. That is why this file lives with the example domain rather than in the runtime, +# and why the route carries the domain and workflow in its path. +apiVersion: dapr.io/v1alpha1 +kind: Subscription +metadata: + name: event-driven-lab-start-subscription +spec: + topic: core.event-driven-lab + route: /api/v1/core/workflows/event-driven-lab/instances/events?action=start + pubsubname: vnext-pubsub +scopes: + - vnext-app diff --git a/tests/Core.IntegrationTests/Tests/ChainBusy/ChainBusyAcceptTests.cs b/tests/Core.IntegrationTests/Tests/ChainBusy/ChainBusyAcceptTests.cs index 2649ce7..aacc3b9 100644 --- a/tests/Core.IntegrationTests/Tests/ChainBusy/ChainBusyAcceptTests.cs +++ b/tests/Core.IntegrationTests/Tests/ChainBusy/ChainBusyAcceptTests.cs @@ -35,6 +35,59 @@ public async Task AsyncAccept_MarksTheChainBusyDownToTheLeaf_BeforeAnsweringTheC Assert.Equal("B", after.Status); } + [Fact] + public async Task PostCommitForwardFailure_ReleasesTheChainReserve_SoNoLevelStaysBusy() + { + // E31 (council 2026-09-08, P0). The other half of the reserve: what happens when the + // forward it was taken for never succeeds. + // + // 'auto-leaf-to-waiting' exists in the leaf's definition but is not available in + // 'leaf-waiting'. The root has no such transition, so it accepts the request, marks the + // chain Busy down to the leaf and forwards — and the leaf rejects it with a Validation + // error. The post-commit failure policy classifies that as the client's error, so it + // declines to fault; that exit runs neither settlement nor fault, and before the fix + // nothing undid the reservation. Every level stayed Busy permanently: Busy has no + // recovery API (retry requires Faulted) and no incident is raised, so the instance was + // unreachable without direct database intervention. Measured in production: 51 stranded + // instances in 30 days, across all five live runtime versions. + var chain = await BuildChainAsync("reserve-release"); + + var (status, _) = await RunTransitionAsyncModeAsync( + RootWorkflow, chain.RootId, "auto-leaf-to-waiting"); + Assert.True(status is HttpStatusCode.Accepted or HttpStatusCode.OK, + $"the accept was rejected with {status} — the reserve was never taken, so this test " + + "would pass without exercising the compensation"); + + // The client polls the root and only ever sees the leaf. That is the observation the + // compensation has to restore; before the fix it read "B" forever. + await WaitUntilAsync( + async () => (await GetObservedStateAsync(RootWorkflow, chain.RootId)).Status == "A", + $"the chain stayed Busy after the forward failed — the accept-time reserve was not " + + $"released. root={await DescribeAsync(RootWorkflow, chain.RootId)}", + TimeSpan.FromSeconds(30)); + + var observed = await GetObservedStateAsync(RootWorkflow, chain.RootId); + Assert.Equal(LeafRestingState, observed.State); + + // The leaf owns its own visible status, so the release flips it back. + var leaf = await GetInstanceStateAsync(LeafWorkflow, chain.LeafId); + Assert.Equal("A", leaf.Status); + Assert.Equal(LeafRestingState, leaf.State); + + // The ancestors must NOT be released: each holds an open SubFlow correlation and is + // legitimately Busy for its child's whole lifetime. Releasing them would settle a parent + // that is still mid-subflow — the compensation undoes only what the reserve flipped. + Assert.Equal("B", (await GetInstanceStateAsync(RootWorkflow, chain.RootId)).Status); + Assert.Equal("B", (await GetInstanceStateAsync(MiddleWorkflow, chain.MiddleId)).Status); + + // Not merely un-Busied: the chain still works. This is what "stranded" cost in + // production — the flow could never be driven to completion again. + await RunTransitionAsyncModeAsync(RootWorkflow, chain.RootId, "finish-leaf"); + await WaitUntilTerminalAsync(RootWorkflow, chain.RootId, TimeSpan.FromSeconds(90)); + + Assert.Equal("root-done", (await GetInstanceStateAsync(RootWorkflow, chain.RootId)).State); + } + [Fact] public async Task AsyncAccept_RelaysTheTransitionAllTheWayToTheLeaf() { diff --git a/tests/Core.IntegrationTests/Tests/ChainBusy/README.md b/tests/Core.IntegrationTests/Tests/ChainBusy/README.md index be186e6..fb7923b 100644 --- a/tests/Core.IntegrationTests/Tests/ChainBusy/README.md +++ b/tests/Core.IntegrationTests/Tests/ChainBusy/README.md @@ -28,11 +28,41 @@ erişimi gerekmez. | Sınıf | Doğruladığı | | --- | --- | | `ChainBusyStartTests` | Start, başlangıç state'inin onEntry'sini çalıştırır (üst seviye + subflow); zincir kurulduğunda A/B `Busy`, C `Active`. | -| `ChainBusyAcceptTests` | Async accept, 202 dönmeden **önce** zinciri leaf'e kadar `Busy`'e çeker; relay gerçekten leaf'e ulaşır. | +| `ChainBusyAcceptTests` | Async accept, 202 dönmeden **önce** zinciri leaf'e kadar `Busy`'e çeker; relay gerçekten leaf'e ulaşır; **forward başarısız olduğunda rezervasyon geri alınır** (E31, aşağı bak). | | `ChainBusySharedTransitionTests` | `$self` shared transition kendi işini yapar **ve** state yaşam döngüsünü koşar (onEntry/onExit girer, zamanlayıcıyı yeniden kurar) — `target: $self` "instance'ı oynatma" der, "hook'ları atla" demez; parent'ın kendi shared'ı parent'ta karşılanır (forward edilmez); yalnız leaf'te tanımlı olan aşağı forward edilir. | | `ChainBusyUpdateDataTests` | `updateData` onEntry/onExit çalıştırmaz, zamanlayıcıyı yeniden kurmaz, state'i değiştirmez. Yaşam döngüsü atlamasını alan **tek** transition; `ChainBusySharedTransitionTests` ile birlikte sınırı pinler — birini diğeri olmadan değiştirmek sınırı sessizce siler. | | `ChainBusyCancelTests` | Leaf'ten cancel → yukarı tamamlanma + korelasyon kapanır. Root'tan cancel → aşağı kaskad. | +## E31 — forward başarısız olduğunda zincir rezervasyonu + +`PostCommitForwardFailure_ReleasesTheChainReserve_SoNoLevelStaysBusy` accept-time rezervasyonun +diğer yarısını pinler: uğruna alındığı forward hiç başarılı olmazsa ne olacağı. + +`auto-leaf-to-waiting` leaf'in tanımında vardır ama `leaf-waiting` state'inde **kullanılabilir +değildir**. Root'ta böyle bir transition olmadığı için root isteği kabul eder, zinciri leaf'e kadar +`Busy` damgalar ve aşağı iletir; leaf de onu `Transition:100020` (Validation) ile reddeder. +Post-commit hata politikası Validation'ı **istemci hatası** sayıp fault'lamayı reddeder — ve o çıkış +ne settlement ne fault koşturur. Düzeltmeden önce rezervasyonu geri alan hiçbir şey yoktu. + +Sonuç kalıcı bir mahsur kalmaydı: `Busy`'nin kurtarma API'si yok (retry `Faulted` ister), incident +de açılmıyor (`hasActiveIncident: false`), dolayısıyla instance veritabanına elle müdahale +edilmeden erişilemez hale geliyordu. Üretimde ölçülen: **30 günde 51 mahsur instance**, beş canlı +runtime sürümünün hepsinde (agent council `2026-09-08-parent-notification-mechanism`, P0). + +Testin pinlediği üç şey: + +1. **Client yeniden ilerleyebilir** — root'tan gözlenen durum `A`'ya döner (düzeltmeden önce + süresiz `B` okunuyordu; ölçüldü: 90 saniye sonra hâlâ `B`). +2. **Atalar salınmaz** — root ve middle `B` kalır. Her biri açık SubFlow korelasyonu tutar ve + çocuğunun ömrü boyunca meşru olarak `Busy`'dir; telafi yalnızca rezervasyonun çevirdiğini geri + alır, hâlâ subflow ortasında olan bir parent'ı settle etmez. +3. **Akış gerçekten kullanılabilir** — ardından `finish-leaf` çalışır ve zincir `root-done`'a ulaşır. + Mahsur kalmanın üretimdeki bedeli buydu: akış bir daha asla tamamlanamıyordu. + +Ayırt edici koşu (2026-09-12, lokal stack): düzeltme `git stash` ile çıkarılıp host'lar yeniden +derlendiğinde test düşer (31 sn, zincir `B`'de kalır); geri konduğunda ChainBusy takımının tamamı +(15 test) geçer. + ## Çalıştırma ### Konteynerli ortam (varsayılan) diff --git a/tests/Core.IntegrationTests/Tests/EventDrivenLab/EventDrivenLabTests.cs b/tests/Core.IntegrationTests/Tests/EventDrivenLab/EventDrivenLabTests.cs new file mode 100644 index 0000000..325a432 --- /dev/null +++ b/tests/Core.IntegrationTests/Tests/EventDrivenLab/EventDrivenLabTests.cs @@ -0,0 +1,167 @@ +using System.Net; +using System.Net.Http.Json; +using System.Text.Json; +using Core.IntegrationTests.Infrastructure; + +namespace Core.IntegrationTests.Tests.EventDrivenLab; + +/// +/// The event-driven entry point, end to end over a real broker. +/// +/// This is the first coverage the instances/events endpoint has ever had. It is reached only +/// through a Dapr Subscription, so a test that posted to the endpoint directly would prove the +/// mapping script and nothing about the wiring that actually delivers production traffic — the +/// topic, the subscription route, the CloudEvent unwrap, and the triggerType: 3 gate. +/// +/// +/// Both messages are published to the broker; nothing here calls the runtime's HTTP API to drive the +/// flow. The instance is created by an event and advanced by an event. +/// +/// +public class EventDrivenLabTests : IntegrationTestBase +{ + private const string Workflow = "event-driven-lab"; + private const string StartTopic = "core.event-driven-lab"; + private const string ApproveTopic = "core.event-driven-lab.approve"; + private const string PubSub = "vnext-pubsub"; + + private readonly HttpClient _dapr; + + public EventDrivenLabTests(VNextTestEnvironment environment) : base(environment) + { + // Published through the orchestration sidecar, because that is the sidecar whose + // subscriptions are under test. Its port is the runtime's Dapr HTTP port, recorded per + // domain in ai-docs/local-environments/.md. + var daprUrl = System.Environment.GetEnvironmentVariable("VNEXT_DAPR_HTTP_URL") + ?? "http://localhost:42110"; + _dapr = new HttpClient { BaseAddress = new Uri(daprUrl.TrimEnd('/') + "/") }; + } + + [SkippableFact] + public async Task AnEventStartsTheInstance_AndASecondEventAdvancesIt() + { + Skip.If(System.Environment.GetEnvironmentVariable("VNEXT_DAPR_HTTP_URL") is null + && System.Environment.GetEnvironmentVariable("VNEXT_BASE_URL") is null, + "needs a running stack with a reachable Dapr sidecar"); + + var orderId = $"evt-{Guid.NewGuid():N}"[..16]; + + // 1. Start by event. The workflow-level mapping turns the payload into a new instance whose + // key is the orderId — the correlation contract the second event depends on. + await PublishAsync(StartTopic, new { orderId, amount = 250 }); + + var instanceId = await WaitForInstanceAsync(orderId); + var afterStart = await GetStateAsync(instanceId); + Assert.Equal("awaiting-approval", afterStart.State); + + var attributes = await GetAttributesAsync(instanceId); + Assert.Equal("event", attributes.GetProperty("startedBy").GetString()); + Assert.Equal(orderId, attributes.GetProperty("orderId").GetString()); + + // 2. Advance by event. Correlation is by business key only — no instance id is published, + // which is the whole point of the mapping's InstanceKey. + await PublishAsync(ApproveTopic, new { orderId, decision = "approved", approvedBy = "ops" }); + + await WaitUntilAsync( + async () => (await GetStateAsync(instanceId)).State == "approved", + $"the approval event never advanced {instanceId}", + TimeSpan.FromSeconds(60)); + + // The transition ran with the MAPPED body, not merely with a state change: an event that + // moved the state but dropped its payload would pass a state-only assertion. + var final = await GetAttributesAsync(instanceId); + Assert.Equal("approved", final.GetProperty("decision").GetString()); + Assert.Equal("ops", final.GetProperty("approvedBy").GetString()); + } + + /// + /// An event naming no active instance is acked, not retried. The endpoint answers the broker's + /// protocol, so a "nothing matched" result must not look like a failure — otherwise the broker + /// redelivers a message this runtime can never satisfy. + /// + [SkippableFact] + public async Task AnApprovalForAnUnknownOrder_IsAcceptedAndChangesNothing() + { + Skip.If(System.Environment.GetEnvironmentVariable("VNEXT_DAPR_HTTP_URL") is null + && System.Environment.GetEnvironmentVariable("VNEXT_BASE_URL") is null, + "needs a running stack with a reachable Dapr sidecar"); + + var response = await PublishAsync( + ApproveTopic, + new { orderId = $"missing-{Guid.NewGuid():N}"[..20], decision = "approved", approvedBy = "ops" }); + + Assert.True( + response.IsSuccessStatusCode, + $"the broker publish was rejected with {response.StatusCode}"); + } + + private async Task PublishAsync(string topic, object payload) + { + var response = await _dapr.PostAsJsonAsync($"v1.0/publish/{PubSub}/{topic}", payload); + Assert.True( + response.IsSuccessStatusCode, + $"publish to {topic} failed with {response.StatusCode}"); + return response; + } + + /// + /// Finds the instance the start event created, by its business key. The publish returns as soon + /// as the broker accepts the message, so the instance appears asynchronously. + /// + private async Task WaitForInstanceAsync(string key) + { + string? instanceId = null; + + await WaitUntilAsync( + async () => + { + // Filtered by business key, because the publish returns no instance id — correlation + // by key is exactly what the mapping's InstanceKey establishes. + var response = await Api.ListInstancesAsync( + Workflow, + new Dictionary + { + ["filter"] = $"{{\"key\":{{\"eq\":\"{key}\"}}}}" + }); + if (!response.IsSuccessStatusCode) return false; + + if (!response.Body.TryGetProperty("items", out var items) || + items.GetArrayLength() == 0) + { + return false; + } + + instanceId = items[0].GetProperty("id").GetString(); + return instanceId is not null; + }, + $"no instance was created for key {key} — the start subscription never reached the runtime", + TimeSpan.FromSeconds(60)); + + return instanceId!; + } + + private async Task<(string State, string Status)> GetStateAsync(string instanceId) + { + var response = await Api.CallInstanceFunctionAsync(Workflow, instanceId, "state"); + return (response.Body.GetProperty("state").GetString() ?? "", + response.Body.GetProperty("status").GetString() ?? ""); + } + + private async Task GetAttributesAsync(string instanceId) + { + var response = await Api.GetInstanceAsync(Workflow, instanceId); + return response.Body.GetProperty("attributes"); + } + + private static async Task WaitUntilAsync(Func> condition, string because, TimeSpan timeout) + { + var deadline = DateTime.UtcNow + timeout; + while (DateTime.UtcNow < deadline) + { + if (await condition()) return; + await Task.Delay(500); + } + + Assert.Fail($"Timed out after {timeout.TotalSeconds:0}s: {because}"); + } +} diff --git a/tests/Core.IntegrationTests/Tests/EventDrivenLab/README.md b/tests/Core.IntegrationTests/Tests/EventDrivenLab/README.md new file mode 100644 index 0000000..bee9e50 --- /dev/null +++ b/tests/Core.IntegrationTests/Tests/EventDrivenLab/README.md @@ -0,0 +1,59 @@ +# event-driven-lab + +Olay tetiklemeli akış: instance bir broker mesajıyla **doğar**, ikinci bir mesajla **ilerler**. +Testin hiçbir adımı akışı sürmek için runtime'ın HTTP API'sini çağırmaz. + +## Neden eklendi + +`POST .../instances/events` ucunun bu senaryodan önce **hiçbir entegrasyon testi yoktu** — agent +council `2026-09-11-route-trace-coverage` bunu adı konmuş bir boşluk olarak kaydetmişti. Uca +doğrudan POST atan bir test yalnız mapping script'ini kanıtlardı; üretim trafiğini gerçekten taşıyan +tesisatı (topic, subscription route'u, CloudEvent açma, `triggerType: 3` kapısı) kanıtlamazdı. +Bu yüzden iki mesaj da broker'a yayınlanır. + +İkincil amaç: `Event.Intake` span'i bu ucu ölçüyor ve "testi olmayan route'a span gönderilmez" +kuralı gereği span ile senaryo aynı commit'te gelir. + +## Yapılandırma + +Doküman: https://burgan-tech.github.io/vnext-docs/docs/how-to/event-driven-workflows + +| Parça | Yer | +| --- | --- | +| Akış-seviyesi `event.mapping` (action=start) | `core/Workflows/event-driven-lab/src/StartEventMapping.csx` | +| `triggerType: 3` geçiş + kendi mapping'i | `approve-by-event`, `src/ApproveEventMapping.csx` | +| Subscription'lar | `tests/.../DaprComponents/orchestration/subscription-event-driven-lab-*.yaml` | + +Topic'ler: `core.event-driven-lab` (start) ve `core.event-driven-lab.approve` (transition). +Approve subscription'ı `sync=true` taşır — teslimat pipeline dinlenme noktasına ulaşana kadar +bloklar, böylece test belirsiz bir pencere boyunca yoklamak yerine hemen doğrulayabilir. + +`InstanceKey` korelasyon sözleşmesidir: start mapping'i onu payload'daki `orderId`'den üretir, +approve mapping'i aynı değerle **aktif** instance'ı bulur. Yayınlanan mesajda instance id yoktur. + +## Neyi pinler + +1. Instance olayla doğar ve `startedBy = "event"` verisiyle `awaiting-approval`'da bekler. +2. İkinci olay onu `approved`'a taşır **ve mapping'lenmiş gövdeyi yazar** — yalnız state kontrol eden + bir assertion, payload'ı düşüren bir olayı yakalayamazdı. +3. Aktif instance'ı olmayan bir onay olayı **ack'lenir**, yeniden denenmez. Uç broker'ın protokolünü + yanıtlar; "eşleşme yok" bir hata gibi görünürse broker asla karşılanamayacak bir mesajı sonsuza + kadar yeniden teslim eder. + +## Çalıştırma + +Ayakta olan bir stack'e karşı: + +```bash +VNEXT_BASE_URL=http://localhost:4201 VNEXT_DAPR_HTTP_URL=http://localhost:42110 \ + dotnet test tests/Core.IntegrationTests --filter "FullyQualifiedName~EventDrivenLab" +``` + +`VNEXT_DAPR_HTTP_URL` orkestrasyon sidecar'ının Dapr HTTP portudur (domain başına +`ai-docs/local-environments/.md` içinde yazılı), çünkü abonelikleri test edilen sidecar odur. + +## Bilinen tuzak + +**Dapr declarative subscription'ları YALNIZ açılışta okur.** Subscription YAML'ı sidecar ayaktayken +eklerseniz mesaj sessizce hiçbir yere gitmez — hata da log da yok. `docker restart +vnext-orchestration-dapr` ve log'da `Found Subscription: …` satırını görün.