Compare commits
359 Commits
| Author | SHA1 | Date |
|---|---|---|
|
|
101929b89e | |
|
|
4d75599988 | |
|
|
b1cdf80fad | |
|
|
4a6e101410 | |
|
|
cf92370bea | |
|
|
d4e4c4480a | |
|
|
11d76270dc | |
|
|
e2b96b4148 | |
|
|
c2a22e5215 | |
|
|
7f677418af | |
|
|
3adad03f15 | |
|
|
7fb74dbbb0 | |
|
|
8c20e23ea6 | |
|
|
b8d0d85c99 | |
|
|
3671c70aa1 | |
|
|
f631fd4b78 | |
|
|
f68b797a9f | |
|
|
c611d4b535 | |
|
|
ed03d754ec | |
|
|
9dcb62ce23 | |
|
|
d9ab3940eb | |
|
|
638be8c52e | |
|
|
853aa23e37 | |
|
|
8293a6edd1 | |
|
|
67b04612a4 | |
|
|
89618c1d10 | |
|
|
a7f2d3605b | |
|
|
f051f32bfa | |
|
|
6c61030c8a | |
|
|
ac6e66f59f | |
|
|
b849beb2ee | |
|
|
72dab46bde | |
|
|
027ff6bd49 | |
|
|
32f2de0db3 | |
|
|
23f2f57fa7 | |
|
|
eaafb00739 | |
|
|
ad9e05413c | |
|
|
c722f775f6 | |
|
|
bf42815ee5 | |
|
|
7fe21480e1 | |
|
|
866217a805 | |
|
|
8e8790924c | |
|
|
eb3185c78d | |
|
|
46b26b7238 | |
|
|
969fa550b5 | |
|
|
73d81ea580 | |
|
|
96c09a65bb | |
|
|
eb4b6e7f8f | |
|
|
81bc41c118 | |
|
|
3f20948cde | |
|
|
ef2cafbc38 | |
|
|
1d256ea386 | |
|
|
8eaae4dda3 | |
|
|
17ef0904d9 | |
|
|
fa48596dbf | |
|
|
abee22b31a | |
|
|
304a4a7bdb | |
|
|
8f3bbeacc1 | |
|
|
651e2a0b9f | |
|
|
58f7a1c286 | |
|
|
7d9cf10a6e | |
|
|
e139eca389 | |
|
|
7b94d81485 | |
|
|
265b89da0a | |
|
|
ed59a0c031 | |
|
|
3a75210c70 | |
|
|
dc10315fc3 | |
|
|
2c73775389 | |
|
|
80235c9a3d | |
|
|
1661ffc4c6 | |
|
|
9b8e56557f | |
|
|
97ac3e21a3 | |
|
|
e7b12a1100 | |
|
|
67589f6b6f | |
|
|
05f90a5639 | |
|
|
a537f18294 | |
|
|
329c9c2928 | |
|
|
3b45de6de3 | |
|
|
4375ca5b4a | |
|
|
6dbeae9884 | |
|
|
5561246e8c | |
|
|
204b361748 | |
|
|
42b40a92c1 | |
|
|
294a3e9609 | |
|
|
4e91d24fdb | |
|
|
6f5c57af6a | |
|
|
96a2f90535 | |
|
|
910b0deab8 | |
|
|
4ca3f51632 | |
|
|
4a8c961d87 | |
|
|
ab1af9fcfa | |
|
|
5fa2fa9d73 | |
|
|
0f9cf6289e | |
|
|
8e9a0b47c1 | |
|
|
fc63be3226 | |
|
|
c062ded9a4 | |
|
|
b59b42d381 | |
|
|
0ad9250e4c | |
|
|
8f72593741 | |
|
|
9a53aa1d73 | |
|
|
37559b6dc4 | |
|
|
affa85e5c5 | |
|
|
61509f1b95 | |
|
|
06a9efd7f9 | |
|
|
8b8abb7cbc | |
|
|
c6e1fa8efc | |
|
|
939ce9c39b | |
|
|
dc16f493d5 | |
|
|
71ccff3ad4 | |
|
|
fe2fef55da | |
|
|
d0ab01d08e | |
|
|
19c470251d | |
|
|
4343ab4d53 | |
|
|
a5d2cd0b0e | |
|
|
e73e1b6364 | |
|
|
82a407ff82 | |
|
|
83350e43f6 | |
|
|
8ff75de55c | |
|
|
27cac570be | |
|
|
f825552ae2 | |
|
|
3e00876c0f | |
|
|
55e53c248f | |
|
|
cfc18d9c8e | |
|
|
7b1bce711e | |
|
|
d060508bd8 | |
|
|
26c8cd85ce | |
|
|
accb38cf75 | |
|
|
9f909f6380 | |
|
|
91194fcfa3 | |
|
|
05808d0d13 | |
|
|
2d04b9f8f6 | |
|
|
d147398698 | |
|
|
3b7007002a | |
|
|
cdd54d3196 | |
|
|
32018e906a | |
|
|
f06bd5004d | |
|
|
a120cd65ff | |
|
|
bbae524e8d | |
|
|
5ba2684ac4 | |
|
|
9150df6982 | |
|
|
0cb2b6c2d8 | |
|
|
896ee257c4 | |
|
|
75974bf238 | |
|
|
4a33872b9d | |
|
|
7b84ab8111 | |
|
|
fdff39c44b | |
|
|
541cebbed8 | |
|
|
cb97b33ca0 | |
|
|
ffd537b5eb | |
|
|
03d606164c | |
|
|
0b27532f17 | |
|
|
17daf0fef2 | |
|
|
cdcb200643 | |
|
|
fbe142c6f3 | |
|
|
e606cd171b | |
|
|
0e912891b1 | |
|
|
16daad2917 | |
|
|
2f99b4e3b7 | |
|
|
c84c26048c | |
|
|
76ce60b7f0 | |
|
|
68c25b2381 | |
|
|
11a15bfa64 | |
|
|
e0f546dde6 | |
|
|
d900442468 | |
|
|
4ab8ede6ca | |
|
|
bbed1caf44 | |
|
|
a1a2a90ef7 | |
|
|
155cef4500 | |
|
|
7e3fbe7a52 | |
|
|
97ece85ee1 | |
|
|
15da68fe25 | |
|
|
0ff40a6777 | |
|
|
7902922195 | |
|
|
2aa2eecccd | |
|
|
8f665c5c4d | |
|
|
686424b813 | |
|
|
60f963bb36 | |
|
|
03e5cd9f29 | |
|
|
8eeaa6725e | |
|
|
b5680bc0e4 | |
|
|
e6afd21fef | |
|
|
5ebcd03e87 | |
|
|
19b15554cf | |
|
|
48c737024f | |
|
|
fe35e60649 | |
|
|
cb2ee24a4c | |
|
|
3e935cad2f | |
|
|
dcd9783b3b | |
|
|
77ea512c1f | |
|
|
deffb77de4 | |
|
|
e2269d3ecf | |
|
|
d40e40a45a | |
|
|
418d9f839a | |
|
|
98d7a27245 | |
|
|
b244d9219a | |
|
|
7977feb36a | |
|
|
9973b6be12 | |
|
|
11e71336b0 | |
|
|
03f5809e8a | |
|
|
7284856dda | |
|
|
dcd44cf705 | |
|
|
e30efff56c | |
|
|
6f88306e54 | |
|
|
1af939ac4d | |
|
|
58cf9578c7 | |
|
|
e50dca93fa | |
|
|
12b3244aa3 | |
|
|
4ef65ee501 | |
|
|
896f720109 | |
|
|
bfab7c16b9 | |
|
|
7e7918e071 | |
|
|
a0a6ac8ef4 | |
|
|
f84dcb773d | |
|
|
96409fe321 | |
|
|
4c6342aa2b | |
|
|
97e4315d12 | |
|
|
991e8f6038 | |
|
|
270f1b8265 | |
|
|
0bde311aa1 | |
|
|
97b7813633 | |
|
|
b38fd480d8 | |
|
|
b37d873792 | |
|
|
5a174ced4c | |
|
|
c1dc203dad | |
|
|
6b7f4bf44f | |
|
|
9b4fa1159a | |
|
|
e5d4b1091f | |
|
|
a87dc37b8b | |
|
|
e8a0d36e43 | |
|
|
097c1e8efe | |
|
|
cd3d65b5f4 | |
|
|
1410ee71f0 | |
|
|
18370879ec | |
|
|
3da902b575 | |
|
|
b7cb6256a0 | |
|
|
bc62488965 | |
|
|
9b151fd6cf | |
|
|
23af1fc98b | |
|
|
1c41eba96e | |
|
|
056a66d713 | |
|
|
c7f44906e7 | |
|
|
dbacc2da80 | |
|
|
946148cc3d | |
|
|
2eca18ca3f | |
|
|
f778d4faa9 | |
|
|
c766a83178 | |
|
|
94dfa1b5f5 | |
|
|
d0e2637741 | |
|
|
f313d5d9ea | |
|
|
466782007d | |
|
|
11ac2beb71 | |
|
|
ff73901ba8 | |
|
|
e73fd7ae4a | |
|
|
1a77ee4bf9 | |
|
|
145cc0a493 | |
|
|
6df5c53937 | |
|
|
40fb4950a6 | |
|
|
de2727ac8a | |
|
|
852ab53af3 | |
|
|
cdf3cf34f8 | |
|
|
905b1c404d | |
|
|
75823d593b | |
|
|
8f35f172f0 | |
|
|
7d133a4b24 | |
|
|
6dbe4d76c1 | |
|
|
dc2526da7e | |
|
|
09a61539fa | |
|
|
8161ddade4 | |
|
|
2ab640b375 | |
|
|
858d43b881 | |
|
|
63ab695a0b | |
|
|
e3a66857aa | |
|
|
a4c99853ce | |
|
|
ba23251644 | |
|
|
024b19b830 | |
|
|
c631006303 | |
|
|
de532c3bc7 | |
|
|
93d38d427f | |
|
|
9312298032 | |
|
|
3400cbc65a | |
|
|
223036f8e9 | |
|
|
d2caa2234d | |
|
|
bbb21dbb67 | |
|
|
028c80db94 | |
|
|
46c12bf5be | |
|
|
05e91aab60 | |
|
|
ceb8c3d886 | |
|
|
1f2f06ff8c | |
|
|
fd3487c12b | |
|
|
65a1d25586 | |
|
|
4b9e1490ef | |
|
|
9ad84ec21e | |
|
|
60ca154c6f | |
|
|
f875738b08 | |
|
|
f388afcede | |
|
|
28a818b1ae | |
|
|
a72f9883b4 | |
|
|
0552268ac1 | |
|
|
9f8c027366 | |
|
|
9fad870960 | |
|
|
a2f392a247 | |
|
|
d35c7bd066 | |
|
|
f839013b5b | |
|
|
4b2d3f4e75 | |
|
|
cde2b5e529 | |
|
|
762088caf7 | |
|
|
b8143e4897 | |
|
|
a832d8e86d | |
|
|
bc30a3aede | |
|
|
b17c2df6c2 | |
|
|
271f23d0f6 | |
|
|
5601c0d3e2 | |
|
|
3b5a895fbc | |
|
|
74b4bbfd30 | |
|
|
2f1c00fd5c | |
|
|
489ef7486c | |
|
|
ac6735ebd8 | |
|
|
60238952d8 | |
|
|
9f1c31bd15 | |
|
|
056aae97a5 | |
|
|
f69b14c195 | |
|
|
6faed09f9f | |
|
|
1a9e760b68 | |
|
|
09a4604e52 | |
|
|
2147d981db | |
|
|
b9e83e2ef8 | |
|
|
a945db9b09 | |
|
|
ad426feba4 | |
|
|
8e7869b3da | |
|
|
c29b3daa0e | |
|
|
5abff05031 | |
|
|
a0445e6d1e | |
|
|
f9dc9a65fb | |
|
|
166d97106d | |
|
|
f3ec941774 | |
|
|
da5ba340f7 | |
|
|
2ecc7b1a7a | |
|
|
1131b5675b | |
|
|
a67bd4f698 | |
|
|
a724fce2f6 | |
|
|
e0666027b3 | |
|
|
dd5dc68862 | |
|
|
545b543abe | |
|
|
bb3cc3c37c | |
|
|
c1a707139c | |
|
|
a24f0c1681 | |
|
|
6f4f87ddd9 | |
|
|
ecdd922be2 | |
|
|
0ee715ed97 | |
|
|
a56f96903e | |
|
|
981f86c701 | |
|
|
f26f04eec5 | |
|
|
e865a0535a | |
|
|
7609e94f18 | |
|
|
9657e7449f | |
|
|
1a73253867 | |
|
|
d8360a3bd5 | |
|
|
9673b629c6 | |
|
|
77a6f26a5c |
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
|
|
@ -0,0 +1,48 @@
|
|||
# META — Known Issues (workspace meta-tooling)
|
||||
|
||||
For planned/actionable work see `META_TODO.md`.
|
||||
|
||||
Active issues for the workspace meta-tooling itself: protocol stack, skills, registry conventions, `.github/` files. Distinct from code-domain topics (LOG, BIN, SIG, etc.) which track code/feature concerns.
|
||||
|
||||
## In scope
|
||||
|
||||
- Skill correctness or coverage gaps (`docs-discovery`, `docs-check`, `protocol-audit`, `adr-author`, `docs-archive`)
|
||||
- Registry / convention drift (`REPO_PREFIXES.md`, `TOPIC_CODES.md`, `protocol-audit/references/REPOS.md`)
|
||||
- Protocol-stack inconsistencies (Rule #1-6 wording, `@repo` block format, ID-format edge cases)
|
||||
- `.github/` infrastructure issues (folder structure, file naming conventions, generation rules)
|
||||
- ADR / Decision Log governance issues (e.g., `Current protocol state` summary staleness)
|
||||
|
||||
## Out of scope
|
||||
|
||||
- Code-domain issues (those go to `LOG_ISSUES.md`, `BIN_ISSUES.md`, etc.)
|
||||
- One-off historical events — those go to `LLM_PROTOCOL_DECISIONS.md` as decisions
|
||||
- Forward-looking work that has been formally decided — that's a TODO (`META_TODO.md`) not an issue
|
||||
|
||||
## Active entries
|
||||
|
||||
*(No `ACCORE-META-I-*` entries yet — file just created (LLMP-DEC-62). Add the first entry below as concrete issues are observed.)*
|
||||
|
||||
## Issue entry template
|
||||
|
||||
```
|
||||
## ACCORE-META-I-XXXX: Short title
|
||||
|
||||
**Severity:** Trivial / Low / Minor / Major / Critical · **Status:** Open / InProgress / Closed (YYYY-MM-DD) · **Area:** <skill / registry / convention name>
|
||||
|
||||
### Description
|
||||
What's broken or inconsistent in the meta-tooling, with quotable evidence (file path + line number, or skill behaviour).
|
||||
|
||||
### Root cause
|
||||
Why it's like that.
|
||||
|
||||
### Fix direction
|
||||
Proposed approach.
|
||||
|
||||
### Resolution
|
||||
(Filled when Status moves to `Closed` — what / where / why per `TOPIC_CODES.md` Status conventions.)
|
||||
|
||||
### Related
|
||||
- Sibling: `META_TODO.md#accore-meta-t-xxxx` (if applicable)
|
||||
- Cross-topic: `<other-topic>_ISSUES.md#accore-...` (if domain-spillover)
|
||||
- LLMP-DEC: `LLMP-DEC-N` (if a decision documents the resolution)
|
||||
```
|
||||
File diff suppressed because one or more lines are too long
|
|
@ -0,0 +1,236 @@
|
|||
# MIGRATION_ID_MAPPING — Phase 4 (deletion-pending)
|
||||
|
||||
> ⚠️ **LLM-targeted deletion reminder**: This file was a Phase 4 transient artifact for the ID-format migration. **From 2026-05-10 onward** (2 weeks after Phase 5 completion 2026-04-26), any LLM agent that reads this file MUST surface the following single-line reminder to the user at the start of its next response:
|
||||
>
|
||||
> *"`MIGRATION_ID_MAPPING.md` is past its 2-week retention window (since 2026-05-10) — confirm if you want me to delete it now."*
|
||||
>
|
||||
> Until 2026-05-10 the file remains untouched as a reverse-lookup aid (git history, old chat transcripts, external references may still mention OLD IDs).
|
||||
>
|
||||
> **Phase 4 of the ID-format migration** (see `LLMP-DEC-50`). Maps existing 3-component IDs (`<TOPIC>-<TYPE>-<N>`) to the new 4-component format (`<PREFIX>-<TOPIC>-<TYPE>-<RAND>`).
|
||||
>
|
||||
> **Status:** generated 2026-04-26; Phase 5 (per-topic rename) completed 2026-04-26; Phase 6 (cross-ref cleanup) consumes this table. **Original Phase 7 plan (delete this file) is superseded** — this file now persists as a historical reverse-lookup aid, with a 2-week deletion-review window starting 2026-05-10 (per user instruction 2026-04-26).
|
||||
>
|
||||
> **Generation rules:**
|
||||
> - Repo prefix per `REPO_PREFIXES.md` (file → repo → prefix).
|
||||
> - Random 4-char `[A-Z0-9]` suffix; unique within each `<PREFIX>-<TOPIC>-<TYPE>` triplet.
|
||||
> - `LLMP-DEC-N` entries are NOT migrated (workspace-meta exception).
|
||||
> - Template/placeholder IDs (e.g. `## GRID-I-1: ...` example line, `AUTH-I-N` placeholder) are NOT migrated.
|
||||
> - "Pending" references (mentioned in docs but not yet defined as `_ISSUES.md`/`_TODO.md` entries) are NOT migrated — see "Pending forward-references" section below; they get rewritten/removed in Phase 6.
|
||||
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
| Repo | Prefix | Issue IDs | TODO IDs | Total |
|
||||
|---|---|---:|---:|---:|
|
||||
| AyCode.Core | `ACCORE` | 41 | 36 | 77 |
|
||||
| AyCode.Blazor | `ACBLAZOR` | 0 | 2 | 2 |
|
||||
| Mango.Nop Libraries | `MGNOPLIB` | 0 | 0 | 0 |
|
||||
| Mango.Nop.Core (sub-folder) | `MGNOPCORE` | 0 | 0 | 0 |
|
||||
| Nop.Plugin.Misc.AIPlugin | `MGFBANKPLUG` | 0 | 0 | 0 |
|
||||
| FruitBank | `FBANKNOP` | 0 | 0 | 0 |
|
||||
| FruitBankHybridApp | `FBANKAPP` | 0 | 0 | 0 |
|
||||
| **Total** | | **41** | **38** | **79** |
|
||||
|
||||
---
|
||||
|
||||
## ACCORE — AyCode.Core
|
||||
|
||||
### BINARY (BIN)
|
||||
|
||||
Canonical home: `AyCode.Core/docs/BINARY/BINARY_ISSUES.md`, `AyCode.Core/docs/BINARY/BINARY_TODO.md`.
|
||||
|
||||
| OLD_ID | FILE (repo-relative) | NEW_ID |
|
||||
|---|---|---|
|
||||
| `BIN-I-1` | `AyCode.Core/docs/BINARY/BINARY_ISSUES.md` | `ACCORE-BIN-I-D2J5` |
|
||||
| `BIN-I-2` | `AyCode.Core/docs/BINARY/BINARY_ISSUES.md` | `ACCORE-BIN-I-G7N3` |
|
||||
| `BIN-I-3` | `AyCode.Core/docs/BINARY/BINARY_ISSUES.md` | `ACCORE-BIN-I-S1F8` |
|
||||
| `BIN-I-4` | `AyCode.Core/docs/BINARY/BINARY_ISSUES.md` | `ACCORE-BIN-I-V5L2` |
|
||||
| `BIN-I-5` | `AyCode.Core/docs/BINARY/BINARY_ISSUES.md` | `ACCORE-BIN-I-K8R4` |
|
||||
| `BIN-I-6` | `AyCode.Core/docs/BINARY/BINARY_ISSUES.md` | `ACCORE-BIN-I-P3M6` |
|
||||
| `BIN-I-7` | `AyCode.Core/docs/BINARY/BINARY_ISSUES.md` | `ACCORE-BIN-I-T9X1` |
|
||||
| `BIN-I-8` | `AyCode.Core/docs/BINARY/BINARY_ISSUES.md` | `ACCORE-BIN-I-B4Y7` |
|
||||
| `BIN-I-9` | `AyCode.Core/docs/BINARY/BINARY_ISSUES.md` | `ACCORE-BIN-I-H2C5` |
|
||||
| `BIN-I-10` | `AyCode.Core/docs/BINARY/BINARY_ISSUES.md` | `ACCORE-BIN-I-N6Q3` |
|
||||
| `BIN-I-11` | `AyCode.Core/docs/BINARY/BINARY_ISSUES.md` | `ACCORE-BIN-I-F1W8` |
|
||||
| `BIN-I-12` | `AyCode.Core/docs/BINARY/BINARY_ISSUES.md` | `ACCORE-BIN-I-J4D2` |
|
||||
| `BIN-I-13` | `AyCode.Core/docs/BINARY/BINARY_ISSUES.md` | `ACCORE-BIN-I-R5V9` |
|
||||
| `BIN-I-14` | `AyCode.Core/docs/BINARY/BINARY_ISSUES.md` | `ACCORE-BIN-I-L7G3` |
|
||||
| `BIN-I-15` | `AyCode.Core/docs/BINARY/BINARY_ISSUES.md` | `ACCORE-BIN-I-M3K6` |
|
||||
| `BIN-T-1` | `AyCode.Core/docs/BINARY/BINARY_TODO.md` | `ACCORE-BIN-T-S8P4` |
|
||||
| `BIN-T-2` | `AyCode.Core/docs/BINARY/BINARY_TODO.md` | `ACCORE-BIN-T-Q2N7` |
|
||||
| `BIN-T-3` | `AyCode.Core/docs/BINARY/BINARY_TODO.md` | `ACCORE-BIN-T-W9F1` |
|
||||
| `BIN-T-4` | `AyCode.Core/docs/BINARY/BINARY_TODO.md` | `ACCORE-BIN-T-T5J8` |
|
||||
|
||||
### LOGGING (LOG)
|
||||
|
||||
Canonical home: `AyCode.Core/docs/LOGGING/LOGGING_ISSUES.md`, `AyCode.Core/docs/LOGGING/LOGGING_TODO.md`.
|
||||
|
||||
| OLD_ID | FILE (repo-relative) | NEW_ID |
|
||||
|---|---|---|
|
||||
| `LOG-I-1` | `AyCode.Core/docs/LOGGING/LOGGING_ISSUES.md` | `ACCORE-LOG-I-K7M2` |
|
||||
| `LOG-I-2` | `AyCode.Core/docs/LOGGING/LOGGING_ISSUES.md` | `ACCORE-LOG-I-R9P3` |
|
||||
| `LOG-I-3` | `AyCode.Core/docs/LOGGING/LOGGING_ISSUES.md` | `ACCORE-LOG-I-L4N8` |
|
||||
| `LOG-I-4` | `AyCode.Core/docs/LOGGING/LOGGING_ISSUES.md` | `ACCORE-LOG-I-B2H5` |
|
||||
| `LOG-I-5` | `AyCode.Core/docs/LOGGING/LOGGING_ISSUES.md` | `ACCORE-LOG-I-X7Q1` |
|
||||
| `LOG-I-6` | `AyCode.Core/docs/LOGGING/LOGGING_ISSUES.md` | `ACCORE-LOG-I-V3J6` |
|
||||
| `LOG-I-7` | `AyCode.Core/docs/LOGGING/LOGGING_ISSUES.md` | `ACCORE-LOG-I-T8F2` |
|
||||
| `LOG-I-8` | `AyCode.Core/docs/LOGGING/LOGGING_ISSUES.md` | `ACCORE-LOG-I-M4C9` |
|
||||
| `LOG-I-9` | `AyCode.Core/docs/LOGGING/LOGGING_ISSUES.md` | `ACCORE-LOG-I-P5W3` |
|
||||
| `LOG-I-10` | `AyCode.Core/docs/LOGGING/LOGGING_ISSUES.md` | `ACCORE-LOG-I-K1Z7` |
|
||||
| `LOG-T-1` | `AyCode.Core/docs/LOGGING/LOGGING_TODO.md` | `ACCORE-LOG-T-H6Y4` |
|
||||
| `LOG-T-2` | `AyCode.Core/docs/LOGGING/LOGGING_TODO.md` | `ACCORE-LOG-T-N2D8` |
|
||||
| `LOG-T-3` | `AyCode.Core/docs/LOGGING/LOGGING_TODO.md` | `ACCORE-LOG-T-R7L3` |
|
||||
| `LOG-T-4` | `AyCode.Core/docs/LOGGING/LOGGING_TODO.md` | `ACCORE-LOG-T-F4S6` |
|
||||
| `LOG-T-5` | `AyCode.Core/docs/LOGGING/LOGGING_TODO.md` | `ACCORE-LOG-T-J9G2` |
|
||||
| `LOG-T-6` | `AyCode.Core/docs/LOGGING/LOGGING_TODO.md` | `ACCORE-LOG-T-B8K5` |
|
||||
| `LOG-T-7` | `AyCode.Core/docs/LOGGING/LOGGING_TODO.md` | `ACCORE-LOG-T-X1V4` |
|
||||
| `LOG-T-8` | `AyCode.Core/docs/LOGGING/LOGGING_TODO.md` | `ACCORE-LOG-T-M7P2` |
|
||||
| `LOG-T-9` | `AyCode.Core/docs/LOGGING/LOGGING_TODO.md` | `ACCORE-LOG-T-L3T8` |
|
||||
| `LOG-T-10` | `AyCode.Core/docs/LOGGING/LOGGING_TODO.md` | `ACCORE-LOG-T-Q6Z1` |
|
||||
| `LOG-T-11` | `AyCode.Core/docs/LOGGING/LOGGING_TODO.md` | `ACCORE-LOG-T-W4H9` |
|
||||
|
||||
### SIGNALR_BINARY_PROTOCOL (SBP)
|
||||
|
||||
Canonical home: `AyCode.Services/docs/SIGNALR_BINARY_PROTOCOL/SIGNALR_BINARY_PROTOCOL_ISSUES.md`, `..._TODO.md`.
|
||||
|
||||
| OLD_ID | FILE (repo-relative) | NEW_ID |
|
||||
|---|---|---|
|
||||
| `SBP-I-1` | `AyCode.Services/docs/SIGNALR_BINARY_PROTOCOL/SIGNALR_BINARY_PROTOCOL_ISSUES.md` | `ACCORE-SBP-I-F6T2` |
|
||||
| `SBP-I-2` | `AyCode.Services/docs/SIGNALR_BINARY_PROTOCOL/SIGNALR_BINARY_PROTOCOL_ISSUES.md` | `ACCORE-SBP-I-G4B5` |
|
||||
| `SBP-T-1` | `AyCode.Services/docs/SIGNALR_BINARY_PROTOCOL/SIGNALR_BINARY_PROTOCOL_TODO.md` | `ACCORE-SBP-T-P8X9` |
|
||||
| `SBP-T-2` | `AyCode.Services/docs/SIGNALR_BINARY_PROTOCOL/SIGNALR_BINARY_PROTOCOL_TODO.md` | `ACCORE-SBP-T-K3J7` |
|
||||
| `SBP-T-3` | `AyCode.Services/docs/SIGNALR_BINARY_PROTOCOL/SIGNALR_BINARY_PROTOCOL_TODO.md` | `ACCORE-SBP-T-L1V4` |
|
||||
| `SBP-T-4` | `AyCode.Services/docs/SIGNALR_BINARY_PROTOCOL/SIGNALR_BINARY_PROTOCOL_TODO.md` | `ACCORE-SBP-T-R6D2` |
|
||||
| `SBP-T-5` | `AyCode.Services/docs/SIGNALR_BINARY_PROTOCOL/SIGNALR_BINARY_PROTOCOL_TODO.md` | `ACCORE-SBP-T-H7M5` |
|
||||
| `SBP-T-6` | `AyCode.Services/docs/SIGNALR_BINARY_PROTOCOL/SIGNALR_BINARY_PROTOCOL_TODO.md` | `ACCORE-SBP-T-N9F3` |
|
||||
| `SBP-T-7` | `AyCode.Services/docs/SIGNALR_BINARY_PROTOCOL/SIGNALR_BINARY_PROTOCOL_TODO.md` | `ACCORE-SBP-T-J5W8` |
|
||||
| `SBP-T-8` | `AyCode.Services/docs/SIGNALR_BINARY_PROTOCOL/SIGNALR_BINARY_PROTOCOL_TODO.md` | `ACCORE-SBP-T-B3K6` |
|
||||
|
||||
### SIGNALR (SIG)
|
||||
|
||||
Canonical home: `AyCode.Services/docs/SIGNALR/SIGNALR_ISSUES.md`, `AyCode.Services/docs/SIGNALR/SIGNALR_TODO.md`.
|
||||
|
||||
| OLD_ID | FILE (repo-relative) | NEW_ID |
|
||||
|---|---|---|
|
||||
| `SIG-I-1` | `AyCode.Services/docs/SIGNALR/SIGNALR_ISSUES.md` | `ACCORE-SIG-I-R4W7` |
|
||||
| `SIG-I-2` | `AyCode.Services/docs/SIGNALR/SIGNALR_ISSUES.md` | `ACCORE-SIG-I-L5K3` |
|
||||
| `SIG-I-3` | `AyCode.Services/docs/SIGNALR/SIGNALR_ISSUES.md` | `ACCORE-SIG-I-H8D6` |
|
||||
| `SIG-I-4` | `AyCode.Services/docs/SIGNALR/SIGNALR_ISSUES.md` | `ACCORE-SIG-I-P1J4` |
|
||||
| `SIG-I-5` | `AyCode.Services/docs/SIGNALR/SIGNALR_ISSUES.md` | `ACCORE-SIG-I-N3V8` |
|
||||
| `SIG-I-6` | `AyCode.Services/docs/SIGNALR/SIGNALR_ISSUES.md` | `ACCORE-SIG-I-T7S2` |
|
||||
| `SIG-I-7` | `AyCode.Services/docs/SIGNALR/SIGNALR_ISSUES.md` | `ACCORE-SIG-I-B5G9` |
|
||||
| `SIG-I-8` | `AyCode.Services/docs/SIGNALR/SIGNALR_ISSUES.md` | `ACCORE-SIG-I-K6F1` |
|
||||
| `SIG-I-9` | `AyCode.Services/docs/SIGNALR/SIGNALR_ISSUES.md` | `ACCORE-SIG-I-X4M7` |
|
||||
| `SIG-T-1` | `AyCode.Services/docs/SIGNALR/SIGNALR_TODO.md` | `ACCORE-SIG-T-J2P5` |
|
||||
| `SIG-T-2` | `AyCode.Services/docs/SIGNALR/SIGNALR_TODO.md` | `ACCORE-SIG-T-W8R3` |
|
||||
| `SIG-T-3` | `AyCode.Services/docs/SIGNALR/SIGNALR_TODO.md` | `ACCORE-SIG-T-D7Q4` |
|
||||
| `SIG-T-4` | `AyCode.Services/docs/SIGNALR/SIGNALR_TODO.md` | `ACCORE-SIG-T-V9H1` |
|
||||
| `SIG-T-5` | `AyCode.Services/docs/SIGNALR/SIGNALR_TODO.md` | `ACCORE-SIG-T-M5L6` |
|
||||
| `SIG-T-6` | `AyCode.Services/docs/SIGNALR/SIGNALR_TODO.md` | `ACCORE-SIG-T-S3N8` |
|
||||
|
||||
### TOON (TOON)
|
||||
|
||||
Canonical home: `AyCode.Core/docs/TOON/TOON_ISSUES.md`, `AyCode.Core/docs/TOON/TOON_TODO.md`.
|
||||
|
||||
| OLD_ID | FILE (repo-relative) | NEW_ID |
|
||||
|---|---|---|
|
||||
| `TOON-I-1` | `AyCode.Core/docs/TOON/TOON_ISSUES.md` | `ACCORE-TOON-I-B7L4` |
|
||||
| `TOON-I-2` | `AyCode.Core/docs/TOON/TOON_ISSUES.md` | `ACCORE-TOON-I-X3H2` |
|
||||
| `TOON-I-3` | `AyCode.Core/docs/TOON/TOON_ISSUES.md` | `ACCORE-TOON-I-P6V5` |
|
||||
| `TOON-I-4` | `AyCode.Core/docs/TOON/TOON_ISSUES.md` | `ACCORE-TOON-I-K4Z9` |
|
||||
| `TOON-T-1` | `AyCode.Core/docs/TOON/TOON_TODO.md` | `ACCORE-TOON-T-D1R7` |
|
||||
| `TOON-T-2` | `AyCode.Core/docs/TOON/TOON_TODO.md` | `ACCORE-TOON-T-G5M3` |
|
||||
| `TOON-T-3` | `AyCode.Core/docs/TOON/TOON_TODO.md` | `ACCORE-TOON-T-J8N6` |
|
||||
| `TOON-T-4` | `AyCode.Core/docs/TOON/TOON_TODO.md` | `ACCORE-TOON-T-V2T4` |
|
||||
| `TOON-T-5` | `AyCode.Core/docs/TOON/TOON_TODO.md` | `ACCORE-TOON-T-S6B9` |
|
||||
| `TOON-T-6` | `AyCode.Core/docs/TOON/TOON_TODO.md` | `ACCORE-TOON-T-F3X1` |
|
||||
| `TOON-T-7` | `AyCode.Core/docs/TOON/TOON_TODO.md` | `ACCORE-TOON-T-M9Q2` |
|
||||
|
||||
### XCUT (XCUT)
|
||||
|
||||
Canonical home: `AyCode.Core/docs/XCUT/XCUT_ISSUES.md`. Cross-ref pointer entries with the same ID exist in `BINARY_ISSUES.md` (line 148) and `SIGNALR_ISSUES.md` (line 131) — they are renamed to the same NEW_ID in Phase 5 (cross-ref pointers, not duplicate entries).
|
||||
|
||||
| OLD_ID | FILE (repo-relative) | NEW_ID |
|
||||
|---|---|---|
|
||||
| `XCUT-I-1` | `AyCode.Core/docs/XCUT/XCUT_ISSUES.md` (canonical) + cross-refs in `BINARY_ISSUES.md`, `AyCode.Services/docs/SIGNALR/SIGNALR_ISSUES.md` | `ACCORE-XCUT-I-X8Q1` |
|
||||
|
||||
---
|
||||
|
||||
## ACBLAZOR — AyCode.Blazor
|
||||
|
||||
### MGGRID (GRID)
|
||||
|
||||
Canonical home: `AyCode.Blazor.Components/docs/MGGRID/MGGRID_TODO.md`. (No issues yet — `MGGRID_ISSUES.md` only contains a placeholder example line.)
|
||||
|
||||
| OLD_ID | FILE (repo-relative) | NEW_ID |
|
||||
|---|---|---|
|
||||
| `GRID-T-1` | `AyCode.Blazor.Components/docs/MGGRID/MGGRID_TODO.md` | `ACBLAZOR-GRID-T-V4P7` |
|
||||
| `GRID-T-2` | `AyCode.Blazor.Components/docs/MGGRID/MGGRID_TODO.md` | `ACBLAZOR-GRID-T-S2L9` |
|
||||
|
||||
---
|
||||
|
||||
## Pending forward-references (NOT migrated — Phase 6 cleanup)
|
||||
|
||||
These IDs are referenced in docs but not yet defined as `_ISSUES.md`/`_TODO.md` entries. They have no OLD_ID body to migrate. Phase 6 must decide per-case: (a) actually create the entry under the new format, or (b) rewrite/remove the reference.
|
||||
|
||||
| Referenced ID | Mentioned in | Decision deferred to Phase 6 |
|
||||
|---|---|---|
|
||||
| `LOG-T-12` | `docs/AUTH/README.md`, `docs/adr/0001-user-bearer-token-flow.md` | Tentative TODO ("Never log secrets" framework guideline). Either define as `ACCORE-LOG-T-<RAND>` and add to `LOGGING_TODO.md`, or rewrite reference. |
|
||||
| `SBP-T-9` | `AyCode.Services/docs/adr/0001-acbinary-decorator-feature-stack-design.md` | Reserved for `AcHubProtocolDecoratorBase` impl + handshake; deferred until at least one leaf ADR (0002-0005) reaches `Status: Accepted`. Either define on demand or rewrite reference. |
|
||||
|
||||
## Template/placeholder IDs (NOT migrated)
|
||||
|
||||
These appear in template/example lines, not as real entries. They stay as-is (or get reformatted to the new placeholder syntax in Phase 5 alongside the topic rename).
|
||||
|
||||
| Placeholder | File | Note |
|
||||
|---|---|---|
|
||||
| `GRID-I-1` | `AyCode.Blazor.Components/docs/MGGRID/MGGRID_ISSUES.md:7` | Example line: "Add the first `## GRID-I-1: ...` entry below..." — should become "`## ACBLAZOR-GRID-I-<RAND>:`" example. |
|
||||
| `AUTH-I-N` | `AyCode.Core/docs/AUTH/AUTH_ISSUES.md`, `AyCode.Core/docs/AUTH/README.md` | Generic placeholder — the `N` is literal "N", not a digit. Should become `ACCORE-AUTH-I-<RAND>` example. |
|
||||
|
||||
## Workspace-meta IDs (NOT migrated — bare exception)
|
||||
|
||||
`LLMP-DEC-N` Decision Log entries do NOT receive a repo prefix per `REPO_PREFIXES.md` "LLMP exception" section. They stay bare. Highest entry as of 2026-04-26: `LLMP-DEC-55` (Phase 3 closure).
|
||||
|
||||
---
|
||||
|
||||
## Within-triplet duplicate-check (sanity)
|
||||
|
||||
| Triplet | Count | Suffixes (sorted) | Unique? |
|
||||
|---|---:|---|---|
|
||||
| `ACCORE-BIN-I-` | 15 | B4Y7, D2J5, F1W8, G7N3, H2C5, J4D2, K8R4, L7G3, M3K6, N6Q3, P3M6, R5V9, S1F8, T9X1, V5L2 | ✅ |
|
||||
| `ACCORE-BIN-T-` | 4 | Q2N7, S8P4, T5J8, W9F1 | ✅ |
|
||||
| `ACCORE-LOG-I-` | 10 | B2H5, K1Z7, K7M2, L4N8, M4C9, P5W3, R9P3, T8F2, V3J6, X7Q1 | ✅ |
|
||||
| `ACCORE-LOG-T-` | 11 | B8K5, F4S6, H6Y4, J9G2, L3T8, M7P2, N2D8, Q6Z1, R7L3, W4H9, X1V4 | ✅ |
|
||||
| `ACCORE-SBP-I-` | 2 | F6T2, G4B5 | ✅ |
|
||||
| `ACCORE-SBP-T-` | 8 | B3K6, H7M5, J5W8, K3J7, L1V4, N9F3, P8X9, R6D2 | ✅ |
|
||||
| `ACCORE-SIG-I-` | 9 | B5G9, H8D6, K6F1, L5K3, N3V8, P1J4, R4W7, T7S2, X4M7 | ✅ |
|
||||
| `ACCORE-SIG-T-` | 6 | D7Q4, J2P5, M5L6, S3N8, V9H1, W8R3 | ✅ |
|
||||
| `ACCORE-TOON-I-` | 4 | B7L4, K4Z9, P6V5, X3H2 | ✅ |
|
||||
| `ACCORE-TOON-T-` | 7 | D1R7, F3X1, G5M3, J8N6, M9Q2, S6B9, V2T4 | ✅ |
|
||||
| `ACCORE-XCUT-I-` | 1 | X8Q1 | ✅ (trivially) |
|
||||
| `ACBLAZOR-GRID-T-` | 2 | S2L9, V4P7 | ✅ |
|
||||
|
||||
All 12 triplets pass uniqueness within-triplet (the only collision domain that matters per `REPO_PREFIXES.md`'s suffix specification — collisions ACROSS triplets are non-issues since the full ID disambiguates).
|
||||
|
||||
---
|
||||
|
||||
## Phase 5 readiness checklist
|
||||
|
||||
For each topic-pair (`_ISSUES.md` + `_TODO.md`) Phase 5 will:
|
||||
1. Rename headers (`## LOG-I-1: ...` → `## ACCORE-LOG-I-K7M2: ...`).
|
||||
2. Rewrite intra-file cross-refs (e.g., body text "see LOG-I-3" → "see ACCORE-LOG-I-L4N8").
|
||||
3. Rewrite TOC/anchor references if present.
|
||||
4. Verify with grep: zero remaining bare `<TOPIC>-<TYPE>-<N>` matches in the file (post-rename), excluding example/placeholder lines.
|
||||
5. One commit per topic (12 commits total: BIN, LOG, SBP, SIG, TOON, XCUT, GRID — but BIN/LOG/SIG/SBP/TOON each pair I+T → 5 commits; XCUT and GRID each 1 commit → 7 commits total).
|
||||
|
||||
Inter-file cross-refs (e.g., `BINARY_ISSUES.md` mentioning `BIN-T-3`, `BINARY_TODO.md` mentioning `BIN-T-3` from another entry, ADRs mentioning `LOG-I-9` etc.) are handled in Phase 6.
|
||||
|
||||
---
|
||||
|
||||
## Related
|
||||
|
||||
- `REPO_PREFIXES.md` — repo prefix registry (canonical authority for the `<PREFIX>` component).
|
||||
- `skills/docs-check/references/TOPIC_CODES.md` — topic registry (canonical authority for the `<TOPIC>` component).
|
||||
- `LLM_PROTOCOL_DECISIONS.md` `LLMP-DEC-50` — migration design decision (7-phase plan).
|
||||
- `LLM_PROTOCOL_DECISIONS.md` `LLMP-DEC-53`, `LLMP-DEC-54`, `LLMP-DEC-55` — Phases 1-3 closure entries.
|
||||
|
|
@ -0,0 +1,106 @@
|
|||
# Repo Prefixes — format spec for repo-level namespace in topic IDs
|
||||
|
||||
Specification of the `<PREFIX>` component in the workspace's 4-component ID format. **Each repo declares its own prefix in its own `copilot-instructions.md` `@repo` block** — there is no central prefix listing here, in line with the Framework-First Design Principle (a framework `.md` does not enumerate consumer repos).
|
||||
|
||||
This file lives in `AyCode.Core/.github/` because the **format spec** is workspace-meta (used by every repo). It does NOT list non-framework prefixes.
|
||||
|
||||
## Full ID format
|
||||
|
||||
```
|
||||
<PREFIX>-<TOPIC>-<TYPE>-<RAND>
|
||||
```
|
||||
|
||||
| Component | Source | Description |
|
||||
|---|---|---|
|
||||
| `<PREFIX>` | Each repo's own `copilot-instructions.md` `@repo` block (`prefix = "..."` field) | Repo-level namespace |
|
||||
| `<TOPIC>` | `docs-check/references/TOPIC_CODES.md` | Topic code (e.g., `LOG`, `BIN`, `SIG`) |
|
||||
| `<TYPE>` | `docs-check/references/TOPIC_CODES.md` | Entry type (`I` = issue, `T` = TODO, `B` = bug, `C` = critical severity override) |
|
||||
| `<RAND>` | Generated at creation | 4-character random alphanumeric suffix from `[A-Z0-9]` |
|
||||
|
||||
**Format rules**: all uppercase, hyphen-separated, no underscores, no spaces. Hash anchors in markdown cross-refs use lowercase: `accore-log-i-k7m2`.
|
||||
|
||||
**Examples** (using this repo's own prefix only — see Framework-First note above):
|
||||
```
|
||||
ACCORE-LOG-I-K7M2 # AyCode.Core's logger issue, random suffix K7M2
|
||||
ACCORE-BIN-T-W9F1 # AyCode.Core's BINARY TODO, random suffix W9F1
|
||||
ACCORE-XCUT-I-X8Q1 # AyCode.Core's cross-cutting issue
|
||||
LLMP-DEC-50 # workspace-meta Decision (no prefix — see "LLMP exception" below)
|
||||
```
|
||||
|
||||
## Why per-repo prefixes
|
||||
|
||||
Without prefixes, IDs like `LOG-I-5` are not globally unique across repos. Two peers may independently create logger-related issues with colliding IDs. More importantly: **framework docs cannot reference consumer-side issues** per the Framework-First Design Principle (a lower-layer framework cannot depend on a higher-layer consumer). Per-repo prefixes provide:
|
||||
|
||||
1. **Globally unique IDs** — `ACCORE-LOG-I-K7M2` ≠ `<other-prefix>-LOG-I-K7M2`, even when topic, type, and random suffix all match.
|
||||
2. **Layer enforcement is visible** — a framework doc body referencing a higher-layer-prefixed ID becomes an immediate red flag in review (the prefix mismatch reveals the dependency-direction violation).
|
||||
3. **Cross-repo search via wildcard** — `*-LOG-I-*` glob/regex finds all logger issues workspace-wide, with no central registry needed; the LLM filters by prefix after retrieval.
|
||||
4. **Distributed parallel work** — combined with the random `<RAND>` suffix, multiple developers can create entries in parallel branches without ID-collision at merge time.
|
||||
|
||||
## ACCORE — this repo's own prefix
|
||||
|
||||
This repo (`AyCode.Core`) uses prefix **`ACCORE`** (declared in this repo's own `copilot-instructions.md` `@repo` block, `prefix = "ACCORE"` field).
|
||||
|
||||
## Per-repo prefix declaration convention
|
||||
|
||||
Every repo participating in the workspace declares its own prefix in its `.github/copilot-instructions.md` `@repo` block:
|
||||
|
||||
```
|
||||
@repo {
|
||||
name = "<RepoName>"
|
||||
prefix = "<PREFIX>" # ← prefix declared here
|
||||
type = "framework" | "product" | "consumer" | ...
|
||||
layer = 0..N
|
||||
own-dep-repos = [...]
|
||||
}
|
||||
```
|
||||
|
||||
To discover a peer's prefix at agent runtime: read that peer's `copilot-instructions.md` (already loaded if the peer is in `own-dep-repos`). For peers NOT in `own-dep-repos` (i.e., higher-layer consumers from a framework's perspective): cross-repo wildcard search (next section) avoids needing to know the prefix in advance.
|
||||
|
||||
## Cross-repo ID search (no central registry needed)
|
||||
|
||||
When searching for entries across the workspace (e.g., "all logger issues" — across framework AND any consumer repos), agents use the prefix-wildcard glob:
|
||||
|
||||
```
|
||||
*-LOG-I-* # all logger issues, any prefix
|
||||
*-BIN-T-* # all BINARY TODOs, any prefix
|
||||
*-SIG-* # all SIGNALR entries (issues + TODOs + bugs)
|
||||
```
|
||||
|
||||
The wildcard pattern is workspace-discovery-agnostic — no central prefix list required. Result enumeration finds all matches; the LLM filters by prefix per the user's intent. The `docs-discovery` skill includes the cross-repo wildcard convention in its discovery flow.
|
||||
|
||||
## Random suffix spec
|
||||
|
||||
The `<RAND>` suffix is **4 characters from `[A-Z0-9]`** (36⁴ ≈ 1.7 million combinations per topic-type-prefix triple).
|
||||
|
||||
**Generation rules**:
|
||||
|
||||
1. Each new entry receives a fresh random suffix at creation time.
|
||||
2. Before finalizing: the agent globs existing entries (active topic file + all year-bucketed archive files for that topic) and verifies the suffix is not yet used.
|
||||
3. If collision detected (extremely rare — birthday-paradox 50% probability at ~1300 entries per topic-type-prefix triple): regenerate the suffix.
|
||||
4. The suffix is **append-only** once assigned — never renumbered, never recycled, never reassigned to a different entry.
|
||||
|
||||
**At archive time** (`docs-archive` skill): performs collision-check before moving entries to year-bucketed archive files. If collision detected with an existing archive entry: skill aborts, signals the user, awaits manual resolution — no silent corruption.
|
||||
|
||||
## LLMP exception
|
||||
|
||||
`LLMP-DEC-N` Decision Log entries **do NOT receive a repo prefix**. They are workspace-meta — there is exactly one `LLM_PROTOCOL_DECISIONS.md` file (in AyCode.Core), and decisions are workspace-wide, not repo-specific. Bare format: `LLMP-DEC-1`, `LLMP-DEC-2`, ...
|
||||
|
||||
Sequential numbering for LLMP-DEC entries is **preserved** (legacy from before this registry existed; no random suffix). This is acceptable because the single Decision Log file enforces serialization — no parallel branches create concurrent LLMP-DEC entries that would collide.
|
||||
|
||||
If a future scenario emerges where workspace-meta decisions span multiple Decision Logs (unlikely), this exception will be revisited.
|
||||
|
||||
## Cross-references
|
||||
|
||||
- **Topic codes registry**: `docs-check/references/TOPIC_CODES.md` — the `<TOPIC>` component
|
||||
- **Decision Log**: `LLM_PROTOCOL_DECISIONS.md` — registry of `LLMP-DEC-N` entries (workspace-meta, no prefix)
|
||||
- **Per-repo prefix declarations**: each repo's own `.github/copilot-instructions.md` `@repo` block
|
||||
|
||||
## Picking a prefix for a new repo
|
||||
|
||||
When a new repo joins the workspace, it picks its own prefix (no central approval needed):
|
||||
|
||||
1. Choose a prefix (4-12 chars, uppercase, alphanumeric, no hyphens / underscores).
|
||||
2. Verify it does NOT collide with `Ac*` / `Mg*` C# class-name prefixes (prefix must be ≥ 4 chars to avoid 2-char visual collision in mixed code/markdown content).
|
||||
3. Verify it is visually distinct from prefixes of repos this new repo will reference or interoperate with (workspace-discovery-time check, not centrally enforced).
|
||||
4. Declare the prefix in the new repo's `.github/copilot-instructions.md` `@repo` block (`prefix = "<PREFIX>"` field).
|
||||
5. (Optional) Add an `LLMP-DEC-N` entry recording the new repo's join + prefix choice, if the new repo's existence is workspace-meta-significant.
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
|
|
@ -0,0 +1,82 @@
|
|||
# ADR NNNN: <imperative short title>
|
||||
|
||||
<!--
|
||||
Template for Architecture Decision Records, per the `adr-author` skill.
|
||||
Copy this file to `<active-repo>/docs/adr/NNNN-<slug>.md` and fill in.
|
||||
Remove this HTML comment and any placeholder lines you don't use.
|
||||
|
||||
NNNN = zero-padded 4 digits, one-past-max in the target docs/adr/ folder.
|
||||
<slug> = short kebab-case derived from the title.
|
||||
-->
|
||||
|
||||
## Status
|
||||
|
||||
<!--
|
||||
One of: Proposed (YYYY-MM-DD) | Accepted (YYYY-MM-DD) | Superseded by ADR-XXXX (YYYY-MM-DD) | Rejected (YYYY-MM-DD)
|
||||
|
||||
Note: ADR Status uses Nygard's classic 4-value vocabulary, distinct from
|
||||
`_ISSUES.md` / `_TODO.md` 3-value vocabulary (Open / InProgress / Closed).
|
||||
See `docs-check/references/TOPIC_CODES.md` "Status field conventions" for the latter.
|
||||
-->
|
||||
|
||||
Proposed (YYYY-MM-DD)
|
||||
|
||||
## Context
|
||||
|
||||
<!--
|
||||
Objective description of the situation.
|
||||
What's the problem or opportunity? What's the timing constraint — why decide now?
|
||||
What's in scope / out of scope?
|
||||
No opinion or recommendation here — just the facts that frame the decision.
|
||||
-->
|
||||
|
||||
## Decision
|
||||
|
||||
<!--
|
||||
What we decided, in one sentence or one short paragraph.
|
||||
Imperative tense: "Adopt X", "Use Y", "Migrate to Z".
|
||||
This is the headline — someone skimming the file should understand the decision
|
||||
without reading any other section.
|
||||
-->
|
||||
|
||||
## Consequences
|
||||
|
||||
**Positive:**
|
||||
- <what this enables / improves>
|
||||
- <...>
|
||||
|
||||
**Negative:**
|
||||
- <what this costs / breaks / constrains>
|
||||
- <...>
|
||||
|
||||
**Follow-ups required:**
|
||||
- <what needs to happen next to fully realize this decision — typically a `_TODO.md` entry or a subsequent ADR>
|
||||
- <...>
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
<!--
|
||||
Minimum 2 alternatives. If you only had 1 "option", it wasn't a decision —
|
||||
it was an implementation path. Consider whether this file is the right artifact.
|
||||
|
||||
For "obviously bad" rejections, the one-line reason is enough. Only add
|
||||
the dimension sub-bullets (Reversibility / Cost / Future flexibility) when
|
||||
alternatives differ MATERIALLY on those dimensions — don't pad every
|
||||
rejection with all four dimensions.
|
||||
-->
|
||||
|
||||
- **<alternative name>** (rejected): <one-sentence reason>
|
||||
- *Reversibility:* <only if cost-of-undo materially differs>
|
||||
- *Cost:* <only if implementation cost materially differs>
|
||||
- *Future flexibility:* <only if it closes doors on later options>
|
||||
- **<alternative name>** (rejected): <one-sentence reason>
|
||||
|
||||
## Related
|
||||
|
||||
<!-- Remove lines that don't apply. -->
|
||||
|
||||
- Supersedes: <ADR-XXXX, if applicable>
|
||||
- Superseded by: <ADR-YYYY, if this ADR was later overturned>
|
||||
- Related ADRs: <ADR-ZZZZ, ...>
|
||||
- Related TODOs/Issues: <e.g., `ACCORE-LOG-T-K7M2`, `ACCORE-BIN-I-3R9P`, ... — per `TOPIC_CODES.md` ID format rules and `REPO_PREFIXES.md` prefix scheme>
|
||||
- External references: <URLs, RFCs, blog posts that informed this decision>
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
|
|
@ -0,0 +1,145 @@
|
|||
# Topic Codes — registry for AyCode.Core's own topics (`ACCORE`)
|
||||
|
||||
Per the Framework-First Design Principle, this Layer 0 registry lists **only the framework's own (`ACCORE`) topics**. Each higher-layer repo hosts its own `TOPIC_CODES.md` for repo-specific topics — see `## Per-repo extension convention` below. A consumer's topic-search at runtime walks `own-dep-repos` to gather both its own and all inherited (lower-layer) topic registries.
|
||||
|
||||
Full ID format: `<PREFIX>-<TOPIC>-<TYPE>-<RAND>` — see `AyCode.Core/.github/REPO_PREFIXES.md` for the `<PREFIX>` and `<RAND>` components. The `<TOPIC>` and `<TYPE>` components are defined in this file (for the framework) and in each higher-layer repo's own equivalent (for consumer-side topics).
|
||||
|
||||
## Why this registry exists
|
||||
|
||||
To make IDs like `ACCORE-LOG-I-K7M2`, `ACCORE-SIG-B-3R9P`, `ACCORE-XCUT-I-A4B7` unambiguous within the framework. The `<TOPIC>` component (registered here for ACCORE) combines with `<PREFIX>` (per `REPO_PREFIXES.md`) and `<RAND>` (4-character random alphanumeric suffix) to form the full ID.
|
||||
|
||||
## Framework's own (ACCORE) topic codes
|
||||
|
||||
| Code | Topic | Scope | Docs location |
|
||||
|---------|-----------------------------|-----------------------------------------------------------------------------------|----------------------------------------------------------------------------------------------------------|
|
||||
| `LOG` | LOGGING | Logger system: levels, writers, config-reading vs DI factory | `AyCode.Core/AyCode.Core/docs/LOGGING/` (+ variants in `AyCode.Core.Server`, `AyCode.Services`) |
|
||||
| `AUTH` | AUTH | User authentication: bearer tokens, JWT, login flow, hub authorization | `AyCode.Core/docs/AUTH/` |
|
||||
| `SIG` | SIGNALR | SignalR transport: tags, client base, dispatch, session | `AyCode.Core/AyCode.Services/docs/SIGNALR/` (+ variant in `AyCode.Services.Server`) |
|
||||
| `SBP` | SIGNALR_BINARY_PROTOCOL | Binary wire protocol over SignalR: framing, chunking, argument read | `AyCode.Core/AyCode.Services/docs/SIGNALR_BINARY_PROTOCOL/` |
|
||||
| `SIGDS` | SIGNALR_DATASOURCE | Client-server DataSource on SignalR transport: change tracking, rollback, sync state, load lifecycle, IList<T> wrapper | `AyCode.Core/AyCode.Services.Server/docs/SIGNALR_DATASOURCE/` |
|
||||
| `BIN` | BINARY | AcBinary serializer: features, format, writers, source generator | `AyCode.Core/AyCode.Core/docs/BINARY/` |
|
||||
| `TOON` | TOON | Toon serializer: LLM-optimized format with @meta/@types/@data sections | `AyCode.Core/AyCode.Core/docs/TOON/` |
|
||||
| `XCUT` | cross-cutting | Issues / TODOs spanning ≥2 ACCORE topics — one canonical home, referenced from each affected topic | `AyCode.Core/AyCode.Core/docs/XCUT/` |
|
||||
| `META` | meta-tooling | Issues/TODOs **about the workspace meta-tooling itself**: skills, registries, `.github/` conventions, ADR/Decision Log governance, protocol-stack edge cases. Distinct from code-domain topics (LOG, BIN, etc.). | `AyCode.Core/.github/META_ISSUES.md` + `AyCode.Core/.github/META_TODO.md` |
|
||||
| `LLMP` | LLM-protocol meta | LLM protocol decisions (Decision Log entries only — uses `LLMP-DEC-N` form, no prefix) | `AyCode.Core/.github/LLM_PROTOCOL_DECISIONS.md` |
|
||||
|
||||
## Type codes (universal across all repos)
|
||||
|
||||
| Code | Type | Used in file | Notes |
|
||||
|-------|----------------|----------------------------------------------|----------------------------------------------------------------------------------------------------|
|
||||
| `I` | Issue | `{TOPIC}_ISSUES.md` | Concrete concern: spec inconsistency, broken contract, observable edge case |
|
||||
| `T` | TODO | `{TOPIC}_TODO.md` | Forward-looking planned work: refactor, missing feature, optimization |
|
||||
| `B` | Bug | `{TOPIC}_ISSUES.md` (alongside `I` entries) | Confirmed broken behaviour, reproducible, needs a code fix |
|
||||
| `C` | Critical | `{TOPIC}_ISSUES.md` or `{TOPIC}_TODO.md` | **Severity override** — emergency priority, supersedes `I`/`B`/`T` category; body explains type |
|
||||
| `DEC` | Decision | `LLM_PROTOCOL_DECISIONS.md` | **LLMP-only.** Append-only protocol decision entries. |
|
||||
|
||||
### Distinctions
|
||||
|
||||
- **I vs B**: Both tracked together in `_ISSUES.md`. Use `B` only when the behaviour is confirmed broken with a reproducer. `I` covers concerns, inconsistencies, doc drift, edge cases without an active bug.
|
||||
- **C (Critical)**: A severity flag, not a category. `ACCORE-LOG-C-K7M2` means "AyCode.Core's logger critical item with random suffix K7M2" — body must state whether it's an underlying bug / issue / todo. Prefer `C` over `I`/`B`/`T` when severity is emergency. Do NOT double-classify (no `ACCORE-LOG-IB-K7M2` or similar).
|
||||
- **DEC**: LLMP exception — long form because "LLMP-D-1" is unreadable. Decision Log entries only.
|
||||
|
||||
## Per-repo extension convention
|
||||
|
||||
Each higher-layer repo MAY host its own `TOPIC_CODES.md` for repo-specific topics. Recommended location:
|
||||
|
||||
```
|
||||
<repo>/.github/TOPIC_CODES.md
|
||||
```
|
||||
|
||||
This per-repo file lists ONLY that repo's own topic codes. Lower-layer (inherited) topics are reachable through the dependency tree — at runtime, the `docs-check` skill walks `own-dep-repos` from the invocation point to gather both this repo's own topics AND all inherited topics from deps.
|
||||
|
||||
Topic codes need NOT be globally unique across repos — the `<PREFIX>` component disambiguates. Two repos may legitimately use the same topic code for repo-local concepts (e.g., one framework's `DAL` ≠ another framework's `DAL`).
|
||||
|
||||
If a higher-layer repo has no repo-specific topics, the file is omitted (default = the repo uses only inherited topics from its deps).
|
||||
|
||||
The framework (this file) does NOT enumerate higher-layer topics — that would violate Framework-First. To find all topics workspace-wide, agents walk the dep tree from a top-layer consumer (which transitively sees everything).
|
||||
|
||||
## ID format rules
|
||||
|
||||
1. **Format**: `<PREFIX>-<TOPIC>-<TYPE>-<RAND>` — all uppercase, hyphen-separated. The `<PREFIX>` component identifies the owning repo per `AyCode.Core/.github/REPO_PREFIXES.md` (and per each repo's own `@repo.prefix` field). **LLMP exception**: `LLMP-DEC-N` entries (workspace-meta Decision Log) skip the prefix and use sequential `N` instead of `<RAND>` — single-file serialization avoids parallel-branch collision.
|
||||
2. **Random suffix**: `<RAND>` is a 4-character alphanumeric suffix from `[A-Z0-9]` (~1.7M combinations per `<PREFIX>-<TOPIC>-<TYPE>` triple). Generated at entry creation; the agent globs existing entries (active topic file + all year-bucketed archive files) and verifies uniqueness; regenerate on rare collision.
|
||||
3. **Append-only**: once assigned, IDs never change. If an entry is reversed or superseded, add a NEW entry that references the prior one — do not renumber, do not re-randomize.
|
||||
4. **Hash anchor** (markdown cross-file refs): lowercase with hyphens preserved (`LOGGING_ISSUES.md#accore-log-i-k7m2` — GitHub auto-converts). Always use the full prefixed form; bare hash anchors without prefix are ambiguous across repos.
|
||||
5. **No sub-category in ID**: legacy sub-prefixes like `PROTO-`, `DISPATCH-`, `CONN-`, `DS-` are NOT allowed at ID level. Capture sub-category in the entry body header: `## ACCORE-SIG-I-K7M2 [PROTO]: ...`.
|
||||
|
||||
## Registry maintenance — adding a new ACCORE topic
|
||||
|
||||
To add a new topic code **for AyCode.Core specifically**:
|
||||
1. Propose the code (2-6 uppercase chars), short and mnemonic, scoped to ACCORE's domain (framework concerns only).
|
||||
2. Check it doesn't collide with C# class-name prefixes (`Ac*` / `Mg*`) — the topic code should be visually distinct in mixed code/markdown content.
|
||||
3. Check it doesn't collide with existing ACCORE topic codes in the table above.
|
||||
4. Add a row to the "Framework's own (ACCORE) topic codes" table.
|
||||
5. Create the topic folder: `AyCode.Core/<project>/docs/{TOPIC_FOLDER_NAME}/` with `README.md`, optional `{TOPIC_FOLDER_NAME}_ISSUES.md`, `{TOPIC_FOLDER_NAME}_TODO.md`.
|
||||
6. Add a Decision Log entry (`LLMP-DEC-N`, in the workspace-level `LLM_PROTOCOL_DECISIONS.md`) recording the new framework topic.
|
||||
|
||||
For higher-layer repos: each consumer registers its own topics in its own `TOPIC_CODES.md` per the per-repo extension convention. No framework-level approval is needed — the consumer is sovereign over its own domain.
|
||||
|
||||
## Collision avoidance with class-name prefixes
|
||||
|
||||
C# code conventions in this workspace:
|
||||
- `Ac*` — AyCode.Core framework types (e.g., `AcLoggerBase`, `AcBinarySerializer`)
|
||||
- `Mg*` — Mango company types (e.g., `MgGrid`, `MgDbTableBase`, `MgEntityBase`)
|
||||
|
||||
Topic codes intentionally avoid these 2-char prefixes (`Ac`, `Mg`) to prevent visual confusion in mixed content. Topic codes are 2-6 chars and SHOULD NOT start with `Ac` or `Mg`. (Example principle: a hypothetical 2-char `MG` topic code would visually collide with `Mg*` class names; choose a more distinctive ≥3-char code.)
|
||||
|
||||
## Examples (ACCORE only)
|
||||
|
||||
```
|
||||
ACCORE-LOG-I-K7M2 # framework's logger issue (random suffix K7M2)
|
||||
ACCORE-LOG-T-3R9P # framework's logger TODO
|
||||
ACCORE-LOG-B-A4B7 # framework's logger bug (confirmed broken)
|
||||
ACCORE-LOG-C-X9Q4 # framework's logger CRITICAL — body: underlying bug / issue / todo
|
||||
ACCORE-SIG-I-M2K8 # framework's SignalR issue (body may note: [PROTO] sub-category)
|
||||
ACCORE-SBP-T-7N3F # framework's SignalR Binary Protocol TODO
|
||||
ACCORE-BIN-B-P5W2 # framework's Binary serializer bug
|
||||
ACCORE-TOON-I-D8R6 # framework's Toon issue
|
||||
ACCORE-XCUT-I-F4G1 # framework's cross-cutting issue (affects ≥2 ACCORE topics)
|
||||
LLMP-DEC-50 # workspace-meta Decision Log entry (no prefix — bare exception)
|
||||
```
|
||||
|
||||
The `<RAND>` suffixes shown above are illustrative. Real entries generate fresh random suffixes at creation time per `REPO_PREFIXES.md`'s "Random suffix spec".
|
||||
|
||||
## Cross-references to other files
|
||||
|
||||
- **Reference format** (cross-file in markdown): `LOGGING_ISSUES.md#accore-log-i-k7m2` (filename + lowercase hash anchor with full 4-component ID). Always use the full prefixed form — bare hash anchors without prefix are ambiguous across repos.
|
||||
- **Code comments**: `// See ACCORE-LOG-I-K7M2` — full prefixed form, since the ID is globally unique only with prefix.
|
||||
- **DB natural key** (future migration): `(prefix, topic, type, suffix)` tuple; or the full string `ACCORE-LOG-I-K7M2` as a single column.
|
||||
- **Workspace registries**: `AyCode.Core/.github/REPO_PREFIXES.md` (framework prefix spec); this file (ACCORE topics + format spec); each higher-layer repo's own `.github/TOPIC_CODES.md` (consumer-side topics); `AyCode.Core/.github/LLM_PROTOCOL_DECISIONS.md` (LLMP-DEC entries, workspace-meta history).
|
||||
|
||||
## Status field conventions
|
||||
|
||||
Every entry in `_ISSUES.md`, `_TODO.md`, and `LLM_PROTOCOL_DECISIONS.md` SHOULD carry an explicit `Status` field. **3 allowed values**:
|
||||
|
||||
| Status | Meaning | Archive eligible? |
|
||||
|---|---|---|
|
||||
| `Open` | Active / unresolved (default for new entries); also used for documented-current-behaviour entries that must remain visible | No |
|
||||
| `InProgress` | Partial work in flight; some scope addressed but more remains | No |
|
||||
| `Closed` | Done — bug fixed, decision made (won't fix / superseded by another entry / accepted), TODO completed. The body of the entry explains *what happened* (date, ref, rationale). | Yes |
|
||||
|
||||
### Defaults
|
||||
|
||||
- New entries default to `Status: Open`.
|
||||
- For documented current-behaviour entries (accepted limitations / "by design" / "this is how it works"), use `Status: Open` with an optional body callout: `> **Note:** This entry documents accepted current behaviour — not scheduled for change.` These never archive (Open status).
|
||||
|
||||
### Update workflow
|
||||
|
||||
When status changes, update the `Status` line in-place. **This is the ONE exception to append-only** — the Status field is mutable; entry body / ID / Description remain immutable.
|
||||
|
||||
When marking `Closed`:
|
||||
1. **Format the Status line as** `Status: Closed (YYYY-MM-DD)` — the inline date is what `docs-archive` uses to determine the destination year-bucket.
|
||||
2. **Add a `### Resolution` sub-section** documenting the closure. **Strongly recommended** — without it, future readers (and the `docs-archive` skill on lookup) have no context for "what changed, why, where". Suggested fields:
|
||||
- **What:** one-line summary of the change.
|
||||
- **Where:** code reference (file/class/commit hash) or doc reference (ADR / PR).
|
||||
- **Why:** the rationale (fix / "won't fix because X" / "superseded by ACCORE-LOG-I-XXXX" / "accepted as-is").
|
||||
- Optional: scope, date if different from Status line, related entries.
|
||||
|
||||
The body carries the **nuance**; the Status field only signals archive-eligibility.
|
||||
|
||||
### Lifecycle: archive
|
||||
|
||||
`Closed` entries are eligible for rotation into year-bucketed archive files (`<file>_<year>.md`) via the `docs-archive` skill. Year derived from a date in the entry body. Archive operation is user-invoked — closed entries don't disappear automatically. See `AyCode.Core/.github/skills/docs-archive/SKILL.md`.
|
||||
|
||||
## Change history
|
||||
|
||||
See the Decision Log (`../../../LLM_PROTOCOL_DECISIONS.md`) for the introduction of this registry and future topic-code additions.
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
|
|
@ -0,0 +1,75 @@
|
|||
# Repos under protocol-audit (framework-only registry)
|
||||
|
||||
Per the Framework-First Design Principle, this Layer 0 registry lists **only the framework's own files** participating in the AI AGENT CORE PROTOCOL. Consumer files (Layer 1+) are discovered at audit time via the invocation-point repo's `own-dep-repos` walk — see `## Cross-repo audit discovery (runtime)` below.
|
||||
|
||||
## Canonical protocol host
|
||||
|
||||
**`AyCode.Core`** — this repo hosts the shared agent skills (`.github/skills/`), the Decision Log (`.github/LLM_PROTOCOL_DECISIONS.md`), and these registry files. All inherit files reference AyCode.Core. Cross-cutting invariants (X1–X3) are skipped for the host itself (it does not cross-reference itself).
|
||||
|
||||
If the host designation is ever moved to a different repo, update this section AND the inherit-file substring checked by invariant I1 in `SKILL.md`.
|
||||
|
||||
## Framework's own primary protocol files
|
||||
|
||||
| # | Name | Absolute path | Layer | Host |
|
||||
|---|-------------|---------------------------------------------|---------------|------|
|
||||
| 1 | AyCode.Core | `H:\Applications\Aycode\Source\AyCode.Core` | framework (0) | ★ |
|
||||
|
||||
The instruction file is at `<abs-path>\.github\copilot-instructions.md`.
|
||||
|
||||
## Cross-repo audit discovery (runtime)
|
||||
|
||||
When `protocol-audit` is invoked from a higher-layer repo (Layer 1+), the skill discovers participating files by walking the invocation-point repo's `own-dep-repos` recursively:
|
||||
|
||||
1. Read the invocation-point repo's `.github/copilot-instructions.md` `@repo` block.
|
||||
2. For each `own-dep-repos` entry, resolve the path relative to the repo root and read that dep's `@repo` block.
|
||||
3. Continue transitively until no new deps are found.
|
||||
4. Audit set = {invocation-point repo} ∪ {all walked deps}.
|
||||
|
||||
Effective audit scope per invocation:
|
||||
- From `AyCode.Core` (Layer 0) → audits only `AyCode.Core` (this file's table).
|
||||
- From `AyCode.Blazor` (Layer 1) → audits `AyCode.Core + AyCode.Blazor`.
|
||||
- From a Layer 2/3 consumer → audits the full transitive dep tree below it (consumer + all its deps).
|
||||
|
||||
The framework cannot directly enumerate consumer files (Framework-First). Higher layers naturally see Layer ≤ N — that's what `own-dep-repos` already encodes.
|
||||
|
||||
## File-type classification (by content, not by central registry)
|
||||
|
||||
Each discovered file is classified per content inspection:
|
||||
|
||||
- **Primary** — contains the `🛑 AI AGENT CORE PROTOCOL (CRITICAL ENFORCEMENT)` header → full invariant set applies (Common + Primary + Cross-cutting).
|
||||
- **Inherit** — contains the `follows the AI Agent Core Protocol defined in <HOST>` blockquote AND lacks the primary header → reduced invariant set applies (Common + Inherit + Cross-cutting).
|
||||
- **Unknown** — matches neither pattern → flag as `UNKNOWN` for manual review (do not silently skip).
|
||||
|
||||
See `SKILL.md` for the invariant sets.
|
||||
|
||||
## Invariants by type
|
||||
|
||||
**Primary files** — full invariant set per `SKILL.md`:
|
||||
- `@repo` block has all 5 required fields (`name`, `prefix`, `type`, `layer`, `own-dep-repos`); paths resolve to existing directories; `prefix` has valid format
|
||||
- Rule numbering contiguous 1..N; rule count ≥ 5
|
||||
- Rule #1 uses count+delta format
|
||||
- Rule #2 contains `CROSS-REPO HARD-GATE` and `PER-QUESTION DOC-FIRST`
|
||||
- Rule #3 is `STRICT NO-RE-READ POLICY (ANTI-LOOP)` and contains "in context" definition (`lossy compressions`)
|
||||
- Rule #4 contains auto-detection triggers
|
||||
- Rule #5 contains broad scope wording (`any file (code, documentation, configuration, memory, or otherwise)`)
|
||||
- `strictly maintain rule 3` reference exists
|
||||
- `## Shared Agent Skills` section with all three skills listed (X1)
|
||||
- `## Protocol History` section referencing `AyCode.Core/.github/LLM_PROTOCOL_DECISIONS.md` (X2)
|
||||
- Docs-sync rule references the `docs-check` skill (X3)
|
||||
|
||||
**Inherit files** — reduced invariant set:
|
||||
- `@repo` block (if present) has all 5 required fields; paths resolve; `prefix` has valid format
|
||||
- References AyCode.Core's protocol via substring: `follows the AI Agent Core Protocol defined in AyCode.Core` (I1)
|
||||
- Does NOT duplicate the numbered Rules #1-5 (I2)
|
||||
- Has a link to the Decision Log (I3)
|
||||
- Has `## Shared Agent Skills` section with all three skills listed (X1)
|
||||
- Has `## Protocol History` section referencing the canonical Decision Log (X2)
|
||||
- Numbered rules are NOT required (they are inherited from AyCode.Core)
|
||||
|
||||
## Known issues
|
||||
|
||||
*(No open issues.)*
|
||||
|
||||
## Maintenance note
|
||||
|
||||
This file lists only the framework's own files. When the framework's own repo set changes (rare — currently a single fixed entry), update the table above. **Consumer participation is auto-discovered** at audit time via `own-dep-repos` walking — no central enumeration of consumer repos is needed here, in line with the Framework-First Design Principle.
|
||||
|
|
@ -18,6 +18,9 @@ bin/
|
|||
/.vs/*
|
||||
/.vs/**
|
||||
|
||||
/.claude/*
|
||||
/.claude/**
|
||||
|
||||
# User-specific files
|
||||
*.rsuser
|
||||
*.suo
|
||||
|
|
@ -372,4 +375,9 @@ MigrationBackup/
|
|||
.ionide/
|
||||
|
||||
# Fody - auto-generated XML schema
|
||||
FodyWeavers.xsd
|
||||
FodyWeavers.xsd
|
||||
/BenchmarkSuite1/Results
|
||||
/CoverageReport
|
||||
/Test_Benchmark_Results
|
||||
/size_output.txt
|
||||
/reports
|
||||
|
|
|
|||
|
|
@ -0,0 +1,210 @@
|
|||
# Buffer-in-Context terv: `_buffer`/`_position` visszahelyezése a context-be
|
||||
|
||||
## Probléma
|
||||
A TOutput generic refaktorálás ~30-40%-os serialization regressziót okozott.
|
||||
Ok: .NET JIT reference type generikusoknál SHARED kódot generál → minden `output.WriteByte()` virtuális dispatch, még sealed osztályoknál is.
|
||||
|
||||
## Megoldás
|
||||
`_buffer` + `_position` visszakerül a `BinarySerializationContext<TOutput>`-ba.
|
||||
Minden hot path write metódus inline context metódus lesz: `_buffer[_position++] = value`.
|
||||
A `TOutput Output` kizárólag cold path Grow/Flush-t kezel.
|
||||
|
||||
---
|
||||
|
||||
## 1. Új BinaryOutputBase (3 absztrakt metódus)
|
||||
|
||||
A jelenlegi 19 abstract + 9 virtual metódus helyett:
|
||||
|
||||
```csharp
|
||||
public abstract class BinaryOutputBase
|
||||
{
|
||||
public abstract void Initialize(out byte[] buffer, out int position, out int bufferEnd);
|
||||
public abstract void Grow(ref byte[] buffer, ref int position, ref int bufferEnd, int needed);
|
||||
public abstract int GetTotalPosition(int currentPosition);
|
||||
}
|
||||
```
|
||||
|
||||
- `Initialize`: kezdeti buffer kiadása
|
||||
- `Grow`: cold path — buffer betelik → ArrayPool.Rent/copy VAGY Advance+GetMemory
|
||||
- `GetTotalPosition`: Position property-hez (cold path, 1x hívás per serialize)
|
||||
- `IBinaryOutput.cs` törlése (nem implementálja többé senki)
|
||||
|
||||
## 2. BinarySerializationContext<TOutput> — új mezők + write metódusok
|
||||
|
||||
### Új mezők:
|
||||
```csharp
|
||||
internal byte[] _buffer = null!;
|
||||
internal int _position;
|
||||
internal int _bufferEnd; // writeable terület vége (_position < _bufferEnd)
|
||||
```
|
||||
|
||||
### Position property:
|
||||
```csharp
|
||||
public int Position => Output.GetTotalPosition(_position);
|
||||
// ArrayBinaryOutput: return currentPosition (CommittedBytes=0, bufferStart=0)
|
||||
// BufferWriterBinaryOutput: return _committedBytes + (currentPosition - _chunkStart)
|
||||
```
|
||||
|
||||
### EnsureCapacity (privát):
|
||||
```csharp
|
||||
[AggressiveInlining]
|
||||
private void EnsureCapacity(int additionalBytes)
|
||||
{
|
||||
if (_position + additionalBytes > _bufferEnd)
|
||||
Output.Grow(ref _buffer, ref _position, ref _bufferEnd, additionalBytes);
|
||||
}
|
||||
```
|
||||
|
||||
### Write metódusok (mind [AggressiveInlining], az ArrayBinaryOutput implementációiból portolva):
|
||||
1. WriteByte(byte)
|
||||
2. WriteTwoBytes(byte, byte)
|
||||
3. WriteBytes(ReadOnlySpan<byte>)
|
||||
4. WriteRaw<T>(T) where T : unmanaged
|
||||
5. WriteTypeCodeAndRaw<T>(byte, T)
|
||||
6. WriteVarUInt(uint) — fast path < 0x80
|
||||
7. WriteVarInt(int) — ZigZag + WriteVarUInt
|
||||
8. WriteVarULong(ulong)
|
||||
9. WriteVarLong(long)
|
||||
10. WriteDecimalBits(decimal)
|
||||
11. WriteDateTimeBits(DateTime)
|
||||
12. WriteGuidBits(Guid)
|
||||
13. WriteDateTimeOffsetBits(DateTimeOffset)
|
||||
14. WriteStringUtf8(string)
|
||||
15. WriteFixStr(string)
|
||||
16. WriteFixStrDirect(string)
|
||||
17. WriteFixStrBytes(ReadOnlySpan<byte>)
|
||||
18. WritePreencodedPropertyName(ReadOnlySpan<byte>)
|
||||
19. WriteDoubleArrayBulk(double[])
|
||||
20. WriteFloatArrayBulk(float[])
|
||||
21. WriteGuidArrayBulk(Guid[])
|
||||
22. WriteInt32ArrayOptimized(int[])
|
||||
23. WriteLongArrayOptimized(long[])
|
||||
24. WriteBytesSimd(ReadOnlySpan<byte>)
|
||||
|
||||
Mind `_buffer[_position++]` pattern-nel — NULLA virtual dispatch a hot path-on.
|
||||
|
||||
### WriteHeader / WriteInlineMetadata:
|
||||
- `Output.WriteByte()` → `WriteByte()` (self)
|
||||
- `WriteInlineMetadata` signature: `output` param eltávolítása
|
||||
|
||||
## 3. ArrayBinaryOutput (~430 → ~120 sor)
|
||||
|
||||
```csharp
|
||||
public sealed class ArrayBinaryOutput : BinaryOutputBase, IDisposable
|
||||
{
|
||||
private byte[] _rentedBuffer;
|
||||
|
||||
public override void Initialize(out byte[] buffer, out int position, out int bufferEnd)
|
||||
{
|
||||
buffer = _rentedBuffer; position = 0; bufferEnd = _rentedBuffer.Length;
|
||||
}
|
||||
|
||||
[NoInlining]
|
||||
public override void Grow(ref byte[] buffer, ref int position, ref int bufferEnd, int needed)
|
||||
{
|
||||
// ArrayPool.Rent bigger + copy + return old
|
||||
// position marad, bufferEnd = newBuffer.Length
|
||||
_rentedBuffer = newBuffer;
|
||||
}
|
||||
|
||||
public override int GetTotalPosition(int currentPosition) => currentPosition;
|
||||
|
||||
// Eredmény metódusok — buffer/position paramétert kapnak a context-ből:
|
||||
public ReadOnlySpan<byte> AsSpan(byte[] buffer, int position);
|
||||
public byte[] ToArray(byte[] buffer, int position);
|
||||
public BinarySerializationResult DetachResult(byte[] buffer, int position);
|
||||
public void WriteTo(IBufferWriter<byte> writer, byte[] buffer, int position);
|
||||
}
|
||||
```
|
||||
|
||||
## 4. BufferWriterBinaryOutput (~350 → ~100 sor)
|
||||
|
||||
```csharp
|
||||
public sealed class BufferWriterBinaryOutput : BinaryOutputBase
|
||||
{
|
||||
private readonly IBufferWriter<byte> _writer;
|
||||
private int _committedBytes;
|
||||
private int _currentChunkStart;
|
||||
private bool _ownedBuffer;
|
||||
|
||||
public override void Initialize(out byte[] buffer, out int position, out int bufferEnd)
|
||||
{
|
||||
_committedBytes = 0;
|
||||
AcquireChunk(MinChunkRequest, out buffer, out position, out bufferEnd);
|
||||
_currentChunkStart = position;
|
||||
}
|
||||
|
||||
[NoInlining]
|
||||
public override void Grow(ref byte[] buffer, ref int position, ref int bufferEnd, int needed)
|
||||
{
|
||||
// 1. Advance current chunk: _writer.Advance(position - _currentChunkStart)
|
||||
// 2. _committedBytes += bytesInChunk
|
||||
// 3. AcquireChunk(needed, out buffer, out position, out bufferEnd)
|
||||
// 4. _currentChunkStart = position
|
||||
}
|
||||
|
||||
public override int GetTotalPosition(int currentPosition)
|
||||
=> _committedBytes + (currentPosition - _currentChunkStart);
|
||||
|
||||
public void Flush(byte[] buffer, int position)
|
||||
{
|
||||
// Utolsó chunk commit-ja
|
||||
}
|
||||
|
||||
private void AcquireChunk(int requestSize, out byte[] buffer, out int position, out int bufferEnd)
|
||||
{
|
||||
// GetMemory() + TryGetArray() → buffer=segment.Array, position=segment.Offset
|
||||
// Fallback: ArrayPool.Rent owned buffer
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 5. AcBinarySerializer.cs (~130 call site változás)
|
||||
|
||||
### Signature változások:
|
||||
- `WriteInt32<TOutput>(int, TOutput output)` → `WriteInt32<TOutput>(int, BinarySerializationContext<TOutput> context)`
|
||||
- `WriteString<TOutput>(string, TOutput output, context)` → `WriteString<TOutput>(string, BinarySerializationContext<TOutput> context)`
|
||||
- Minden helper: `output` param eltávolítása, `context` marad
|
||||
- `var output = context.Output;` sorok törlése
|
||||
- `output.WriteByte(...)` → `context.WriteByte(...)`
|
||||
|
||||
### TryWritePrimitiveArrayCore:
|
||||
- Jelenleg non-generic `BinaryOutputBase output` param
|
||||
- Új: generic `BinarySerializationContext<TOutput> context` param (2 JIT copy elfogadható, per-array hívás)
|
||||
|
||||
### Public API metódusok:
|
||||
```csharp
|
||||
// Serialize<T> (byte[]):
|
||||
context.Output.Initialize(out context._buffer, out context._position, out context._bufferEnd);
|
||||
// ... serialize ...
|
||||
return context.Output.ToArray(context._buffer, context._position);
|
||||
|
||||
// Serialize<T> (IBufferWriter):
|
||||
output.Initialize(out context._buffer, out context._position, out context._bufferEnd);
|
||||
// ... serialize ...
|
||||
output.Flush(context._buffer, context._position);
|
||||
```
|
||||
|
||||
## 6. ScanPass.cs — NINCS VÁLTOZÁS
|
||||
Már most is csak context-et kap, nem ír semmit.
|
||||
|
||||
## 7. IBinaryOutput.cs — TÖRLÉS
|
||||
Senki nem implementálja többé.
|
||||
|
||||
## 8. Implementációs sorrend
|
||||
|
||||
1. Context: `_buffer`/`_position`/`_bufferEnd` mezők + 24 write metódus hozzáadása
|
||||
2. BinaryOutputBase: 28 metódus → 3 (Initialize, Grow, GetTotalPosition)
|
||||
3. ArrayBinaryOutput: egyszerűsítés (Grow + result metódusok)
|
||||
4. BufferWriterBinaryOutput: egyszerűsítés (Grow + Flush + AcquireChunk)
|
||||
5. AcBinarySerializer.cs: ~130 hívás átírása output→context
|
||||
6. Public API: Initialize/Flush/ToArray hívások buffer/position paraméterekkel
|
||||
7. Context WriteHeader/WriteInlineMetadata: output param eltávolítása
|
||||
8. IBinaryOutput.cs törlése
|
||||
9. Build + teszt
|
||||
|
||||
## Várható eredmény
|
||||
- Hot path: `_buffer[_position++]` — nulla virtual dispatch (baseline szintű teljesítmény)
|
||||
- Cold path (Grow): 1 virtual call buffer beteltkor → elhanyagolható
|
||||
- Position: 1 virtual call, de csak 1x hívódik per serialize → elhanyagolható
|
||||
- IBufferWriter streaming: 100% megmarad (Grow = Advance + GetMemory)
|
||||
|
|
@ -0,0 +1,82 @@
|
|||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset='utf-8'>
|
||||
<title>BenchmarkDotNet Riportok (Dropdown)</title>
|
||||
<style>
|
||||
body { font-family: Segoe UI, Arial, sans-serif; margin: 2em; background: #f8f9fa; }
|
||||
select { font-size: 1.1em; padding: 0.2em; }
|
||||
.report-content { background: #fff; border: 1px solid #ccc; padding: 1em; margin-top: 1em; border-radius: 6px; box-shadow: 0 2px 8px #0001; min-height: 200px; }
|
||||
h1 { font-size: 1.5em; }
|
||||
.filename { color: #888; font-size: 0.95em; }
|
||||
.compare { color: #007700; font-size: 1.1em; margin-bottom: 1em; }
|
||||
.compare.negative { color: #bb2222; }
|
||||
iframe { width: 100%; min-height: 600px; border: none; background: #fff; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>BenchmarkDotNet Riportok</h1>
|
||||
<label for='reportSelect'>Válassz riportot:</label>
|
||||
<select id='reportSelect'>
|
||||
<option value='AyCode.Core.Benchmarks.AcBinaryOptionsDeserializeBenchmark-report_486670772'>AyCode.Core.Benchmarks.AcBinaryOptionsDeserializeBenchmark-report</option>
|
||||
<option value='AyCode.Core.Benchmarks.AcBinaryOptionsDeserializeBenchmark-report_2144380973'>SwitcherRun_20251215T194244_217</option>
|
||||
<option value='AyCode.Core.Benchmarks.MessagePackComparisonBenchmark-report_742792311'>SwitcherRun_20251214T182029_626</option>
|
||||
<option value='AyCode.Core.Benchmarks.MessagePackComparisonBenchmark-report_889027207'>AyCode.Core.Benchmarks.MessagePackComparisonBenchmark-report</option>
|
||||
<option value='AyCode.Core.Benchmarks.AcBinaryVsMessagePackFullBenchmark-report_839282233'>SwitcherRun_20251214T182029_626</option>
|
||||
</select>
|
||||
<div class='filename' id='filename'></div>
|
||||
<div class='compare' id='compare'></div>
|
||||
<div class='report-content'><iframe id='reportFrame'></iframe></div>
|
||||
<script>
|
||||
// Relatív riport fájlok
|
||||
const reports = {
|
||||
'AyCode.Core.Benchmarks.AcBinaryOptionsDeserializeBenchmark-report_486670772': 'Test_Benchmark_Results/Benchmark/results/AyCode.Core.Benchmarks.AcBinaryOptionsDeserializeBenchmark-report.html',
|
||||
'AyCode.Core.Benchmarks.AcBinaryOptionsDeserializeBenchmark-report_2144380973': 'Test_Benchmark_Results/MemDiag/SwitcherRun_20251215T194244_217/results/AyCode.Core.Benchmarks.AcBinaryOptionsDeserializeBenchmark-report.html',
|
||||
'AyCode.Core.Benchmarks.MessagePackComparisonBenchmark-report_742792311': 'AyCode.Benchmark/Test_Benchmark_Results/MemDiag/SwitcherRun_20251214T182029_626/results/AyCode.Core.Benchmarks.MessagePackComparisonBenchmark-report.html',
|
||||
'AyCode.Core.Benchmarks.MessagePackComparisonBenchmark-report_889027207': 'AyCode.Benchmark/Test_Benchmark_Results/Benchmark/results/AyCode.Core.Benchmarks.MessagePackComparisonBenchmark-report.html',
|
||||
'AyCode.Core.Benchmarks.AcBinaryVsMessagePackFullBenchmark-report_839282233': 'AyCode.Benchmark/Test_Benchmark_Results/MemDiag/SwitcherRun_20251214T182029_626/results/AyCode.Core.Benchmarks.AcBinaryVsMessagePackFullBenchmark-report.html',
|
||||
};
|
||||
const means = {
|
||||
'AyCode.Core.Benchmarks.AcBinaryOptionsDeserializeBenchmark-report_486670772': '',
|
||||
'AyCode.Core.Benchmarks.AcBinaryOptionsDeserializeBenchmark-report_2144380973': '',
|
||||
'AyCode.Core.Benchmarks.MessagePackComparisonBenchmark-report_742792311': '',
|
||||
'AyCode.Core.Benchmarks.MessagePackComparisonBenchmark-report_889027207': '',
|
||||
'AyCode.Core.Benchmarks.AcBinaryVsMessagePackFullBenchmark-report_839282233': '',
|
||||
};
|
||||
const select = document.getElementById('reportSelect');
|
||||
const frame = document.getElementById('reportFrame');
|
||||
const filename = document.getElementById('filename');
|
||||
const compare = document.getElementById('compare');
|
||||
function showReport() {
|
||||
const id = select.value;
|
||||
if (reports[id]) {
|
||||
frame.src = reports[id];
|
||||
} else {
|
||||
frame.srcdoc = '<i>Nincs tartalom.</i>';
|
||||
}
|
||||
const opt = select.options[select.selectedIndex];
|
||||
filename.textContent = opt ? opt.text : '';
|
||||
// Összehasonlítás az eggyel korábbival
|
||||
const idx = select.selectedIndex;
|
||||
if (idx < select.options.length - 1) {
|
||||
const prevId = select.options[idx + 1].value;
|
||||
const currMean = parseFloat(means[id].replace(',','.'));
|
||||
const prevMean = parseFloat(means[prevId].replace(',','.'));
|
||||
if (!isNaN(currMean) && !isNaN(prevMean) && prevMean > 0) {
|
||||
const diff = currMean - prevMean;
|
||||
const percent = (diff / prevMean * 100).toFixed(2);
|
||||
const sign = percent > 0 ? '+' : '';
|
||||
compare.textContent = Eltérés az előzőhöz képest: % ( vs );
|
||||
compare.className = 'compare' + (percent > 0 ? ' negative' : '');
|
||||
} else {
|
||||
compare.textContent = '';
|
||||
}
|
||||
} else {
|
||||
compare.textContent = '';
|
||||
}
|
||||
}
|
||||
select.addEventListener('change', showReport);
|
||||
window.onload = showReport;
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -0,0 +1,86 @@
|
|||
using AyCode.Core.Benchmarks.Workloads.Scenarios;
|
||||
using AyCode.Core.Serializers;
|
||||
using AyCode.Core.Serializers.Binaries;
|
||||
using AyCode.Core.Tests.TestModels;
|
||||
using BenchmarkDotNet.Attributes;
|
||||
|
||||
namespace AyCode.Core.Benchmarks;
|
||||
|
||||
/// <summary>
|
||||
/// BDN benchmark mirroring the Console app's "F" menu (<c>SerializerSelectionMode.FastestByte</c>) —
|
||||
/// the focused 1:1 comparison between <b>AcBinary FastMode Byte[]</b> and <b>MemoryPack Default Byte[]</b>
|
||||
/// across the 5 production-shaped test data cells (Small / Medium / Large / Repeated / Deep).
|
||||
///
|
||||
/// <para>Why this exists: the Console app's adaptive measurement engine gives fast turnaround but is
|
||||
/// noise-prone; BDN's warmup + iteration + outlier-removal stack tightens the inter-engine delta to
|
||||
/// the point where ~1-2% micro-optimizations become detectable. Both runners feed the SAME
|
||||
/// <see cref="ISerializerBenchmark"/>-implementing workload (<see cref="AcBinaryBenchmark{T}"/> /
|
||||
/// <see cref="MemoryPackBenchmark{T}"/>) — so the BDN numbers are directly comparable to Console's
|
||||
/// <c>Console.FullBenchmark_Release_*.LLM</c> rows, only with tighter confidence intervals.</para>
|
||||
///
|
||||
/// <para>Output: BDN writes its native artifacts to <c>Test_Benchmark_Results/Benchmark/BDN/</c> (set
|
||||
/// globally in <c>Program.cs</c> via <c>WithArtifactsPath</c>). <see cref="BdnSummaryAdapter"/> then
|
||||
/// translates the <see cref="BenchmarkDotNet.Reports.Summary"/> into <see cref="Reporting.BenchmarkResult"/>
|
||||
/// rows and emits the unified <c>Bdn.FullBenchmark_*.{log,LLM,output}</c> triplet next to Console's
|
||||
/// counterparts in <c>Test_Benchmark_Results/Benchmark/</c>.</para>
|
||||
/// </summary>
|
||||
[MemoryDiagnoser]
|
||||
public class AcBinaryVsMemPackBenchmark
|
||||
{
|
||||
/// <summary>
|
||||
/// The 5 TestData cells matching Console's <c>BenchmarkLayer.Core</c> set —
|
||||
/// Small (2x2x2x2) / Medium (3x3x3x4) / Large (5x5x5x10) / Repeated (10 items) / Deep (2x4x4x8).
|
||||
/// Resolved at <see cref="GlobalSetup"/> time via <see cref="BenchmarkTestDataProvider_All_False.CreateTestDataSets"/>
|
||||
/// (same provider Console uses) so the workload graphs are bit-for-bit identical.
|
||||
/// </summary>
|
||||
public static IEnumerable<string> TestDataNames => new[] { "Small", "Medium", "Large", "Repeated", "Deep" };
|
||||
|
||||
[ParamsSource(nameof(TestDataNames))]
|
||||
public string TestData { get; set; } = "";
|
||||
|
||||
/// <summary>
|
||||
/// Engine axis: AcBinary FastMode + Compact wire (UTF-8) vs MemoryPack Default (UTF-8). Compact-on-both-sides
|
||||
/// keeps the string-encoding dimension constant so the comparison reflects engine differences only.
|
||||
/// </summary>
|
||||
[Params("AcBinary", "MemoryPack")]
|
||||
public string Engine { get; set; } = "";
|
||||
|
||||
private ISerializerBenchmark _serializer = null!;
|
||||
|
||||
[GlobalSetup]
|
||||
public void Setup()
|
||||
{
|
||||
// BDN runs each benchmark in an isolated child process — the parent's charset selection (a static
|
||||
// field) does NOT cross the process boundary, so the child would otherwise fall back to the
|
||||
// compile-time default (Latin1Long). Pin the BDN serializer benchmark to Latin1Short here so its
|
||||
// cells line up with the Console Latin1Short runs. (Mirrored in BdnSummaryAdapter.WriteResults
|
||||
// for the parent process — .LLM charset label + Size(B) column.)
|
||||
BenchmarkTestDataProvider.LongStringSuffix = CharsetSuffixes.Latin1Short;
|
||||
|
||||
var allTestData = BenchmarkTestDataProvider_All_False.CreateTestDataSets();
|
||||
var testDataSet = (TestDataSet<TestOrder_All_False>)allTestData.First(t => t.Name.StartsWith(TestData));
|
||||
|
||||
if (Engine == "AcBinary")
|
||||
{
|
||||
var options = AcBinarySerializerOptions.FastMode;
|
||||
options.WireMode = WireMode.Compact;
|
||||
_serializer = new AcBinaryBenchmark<TestOrder_All_False>(testDataSet.Order, options, "FastMode");
|
||||
}
|
||||
else
|
||||
{
|
||||
// MemoryPack's wire-mode-aligned ctor — Compact ↔ UTF-8 default for apples-to-apples vs AcBinary Compact.
|
||||
_serializer = new MemoryPackBenchmark<TestOrder_All_False>(testDataSet.Order, WireMode.Compact, "Default");
|
||||
}
|
||||
|
||||
// Round-trip correctness check before the BDN harness starts measuring — same gate the Console
|
||||
// runner enforces. Fails the run early if anything's broken (rather than producing meaningless numbers).
|
||||
if (!_serializer.VerifyRoundTrip())
|
||||
throw new InvalidOperationException($"Round-trip verification FAILED for {Engine} on {TestData}.");
|
||||
}
|
||||
|
||||
[Benchmark]
|
||||
public void Serialize() => _serializer.Serialize();
|
||||
|
||||
[Benchmark]
|
||||
public void Deserialize() => _serializer.Deserialize();
|
||||
}
|
||||
|
|
@ -0,0 +1,38 @@
|
|||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
</PropertyGroup>
|
||||
|
||||
<Import Project="..\AyCode.Core.targets" />
|
||||
|
||||
<!-- Exclude Test_Benchmark_Results from build to prevent path length issues -->
|
||||
<ItemGroup>
|
||||
<None Remove="Test_Benchmark_Results\**" />
|
||||
<Content Remove="Test_Benchmark_Results\**" />
|
||||
<Compile Remove="Test_Benchmark_Results\**" />
|
||||
<EmbeddedResource Remove="Test_Benchmark_Results\**" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="BenchmarkDotNet" Version="0.15.8" />
|
||||
<PackageReference Include="MemoryPack" Version="1.21.4" />
|
||||
<PackageReference Include="MessagePack" Version="3.1.4" />
|
||||
<PackageReference Include="Microsoft.VisualStudio.DiagnosticsHub.BenchmarkDotNetDiagnosers" Version="18.6.37110.2" />
|
||||
<PackageReference Include="MongoDB.Bson" Version="3.5.2" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\AyCode.Core\AyCode.Core.csproj" />
|
||||
<ProjectReference Include="..\AyCode.Core.Tests\AyCode.Core.Tests.csproj" />
|
||||
<ProjectReference Include="..\AyCode.Services\AyCode.Services.csproj" />
|
||||
<ProjectReference Include="..\AyCode.Services.Server\AyCode.Services.Server.csproj" />
|
||||
<ProjectReference Include="..\AyCode.Models.Server\AyCode.Models.Server.csproj" />
|
||||
<!-- Source Generator for [AcBinarySerializable] marked types -->
|
||||
<ProjectReference Include="..\AyCode.Core.Serializers.SourceGenerator\AyCode.Core.Serializers.SourceGenerator.csproj" OutputItemType="Analyzer" ReferenceOutputAssembly="false" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Folder Include="Results\" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
|
@ -0,0 +1,218 @@
|
|||
using AyCode.Core.Benchmarks.Reporting;
|
||||
using AyCode.Core.Benchmarks.Workloads.Scenarios;
|
||||
using AyCode.Core.Serializers;
|
||||
using AyCode.Core.Serializers.Binaries;
|
||||
using AyCode.Core.Tests.TestModels;
|
||||
using BenchmarkDotNet.Reports;
|
||||
using System.Text;
|
||||
|
||||
namespace AyCode.Core.Benchmarks;
|
||||
|
||||
/// <summary>
|
||||
/// Translates <see cref="BenchmarkDotNet.Reports.Summary"/> (BDN's post-run aggregate) into the unified
|
||||
/// <see cref="BenchmarkResult"/> rows consumed by <see cref="BenchmarkReportWriter"/>, then emits the
|
||||
/// <c>Bdn.FullBenchmark_*.{log,LLM,output}</c> triplet alongside Console's counterparts in
|
||||
/// <see cref="ReportingContext.ResolveResultsDirectory"/>'s <c>Test_Benchmark_Results/Benchmark/</c>.
|
||||
///
|
||||
/// <para><b>Why a separate adapter</b>: BDN's Summary is per-method (Serialize / Deserialize as separate
|
||||
/// <see cref="BenchmarkReport"/>s, parameterised by TestData + Engine). The unified format collapses these
|
||||
/// into per-cell rows (one row per <c>TestData × Engine</c> with both Ser and Des stats inline). The adapter
|
||||
/// groups, transposes, and converts ns → ms before handing off to the shared writer.</para>
|
||||
///
|
||||
/// <para><b>Mean vs Median</b>: maps BDN's <see cref="BenchmarkDotNet.Mathematics.Statistics.Median"/> into
|
||||
/// the BenchmarkResult's time columns — same convention as Console (which captures sample-median).
|
||||
/// Min/Max/StdDev populate the inter-sample range surfaced in <see cref="BenchmarkReportWriter.FormatMicrosWithRange"/>
|
||||
/// (incl. CV-warning ⚠️ marker when stddev/median exceeds <see cref="ReportingContext.UnstableCVThreshold"/>).</para>
|
||||
///
|
||||
/// <para><b>Iteration count = 1</b>: BDN reports <i>per-operation</i> time (ns) — already amortized across N
|
||||
/// invocations. The unified BenchmarkResult expects total-batch time + iteration count (so <c>µs/op =
|
||||
/// timeMs / iterations * 1000</c>). Storing Mean-in-ms with iterations = 1 makes the same formula yield
|
||||
/// Mean-in-µs directly. The actual BDN N count is recorded in the BDN-native artifacts (<c>.../BDN/...</c>)
|
||||
/// for anyone who wants the raw invocation count.</para>
|
||||
/// </summary>
|
||||
public static class BdnSummaryAdapter
|
||||
{
|
||||
/// <summary>
|
||||
/// Post-run entry point — call once after <c>BenchmarkRunner.Run<AcBinaryVsMemPackBenchmark>(...)</c>
|
||||
/// returns. Produces the BDN-side <c>Bdn.*</c> file triplet AND prints the grouped-results console table
|
||||
/// (same view Console produces post-run) so the user sees the cell-level deltas immediately, without
|
||||
/// having to open the .LLM file.
|
||||
/// </summary>
|
||||
public static void WriteResults(Summary summary)
|
||||
{
|
||||
// Parent-process counterpart of AcBinaryVsMemPackBenchmark.Setup's charset pin: the BDN child
|
||||
// processes ran Latin1Short, but this adapter runs in the parent process where LongStringSuffix
|
||||
// would still be the compile-time default (Latin1Long). Set it so GetCharsetName() labels the
|
||||
// .LLM correctly AND the CreateTestDataSets()/CreateWorkload calls below compute matching Size(B).
|
||||
BenchmarkTestDataProvider.LongStringSuffix = CharsetSuffixes.Latin1Short;
|
||||
|
||||
var allTestData = BenchmarkTestDataProvider_All_False.CreateTestDataSets();
|
||||
var results = Translate(summary, allTestData);
|
||||
var ctx = CreateContext();
|
||||
|
||||
BenchmarkReportWriter.PrintGroupedResults(results, allTestData);
|
||||
BenchmarkReportWriter.SaveAll(ctx, results, allTestData);
|
||||
}
|
||||
|
||||
private static ReportingContext CreateContext()
|
||||
{
|
||||
#if DEBUG
|
||||
const string buildConfig = "Debug";
|
||||
#elif SGEN_ONLY
|
||||
const string buildConfig = "SGenOnly";
|
||||
#else
|
||||
const string buildConfig = "Release";
|
||||
#endif
|
||||
return new ReportingContext(
|
||||
SourceTag: "Bdn",
|
||||
ResultsDirectory: ReportingContext.ResolveResultsDirectory(),
|
||||
BuildConfiguration: buildConfig,
|
||||
Utf8NoBom: new UTF8Encoding(encoderShouldEmitUTF8Identifier: false),
|
||||
CharsetName: GetCharsetName(),
|
||||
// Warmup / Samples / TargetSampleMs are BDN-managed (not Console's adaptive engine). Zeros here
|
||||
// signal "BDN handled internally" in the header; the BDN-native artifacts under .../BDN/ have
|
||||
// the exact BDN config (warmup count, iteration count, run strategy) for anyone who needs it.
|
||||
WarmupIterations: 0,
|
||||
BenchmarkSamples: 0,
|
||||
TargetSampleMs: 0,
|
||||
UnstableCVThreshold: 0.03,
|
||||
MicroOptCVThreshold: 0.015);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Looks up the human-readable name for the currently-active <see cref="BenchmarkTestDataProvider.LongStringSuffix"/>
|
||||
/// charset. Mirrors Console's <c>Configuration.GetCurrentCharsetName</c>. The BDN serializer benchmark
|
||||
/// pins the charset to <c>Latin1Short</c> — set in <see cref="WriteResults"/> (parent process) and in
|
||||
/// <c>AcBinaryVsMemPackBenchmark.Setup</c> (child process); see those sites for the process-isolation
|
||||
/// rationale (BDN's per-benchmark child processes don't inherit a parent static-field mutation).
|
||||
/// </summary>
|
||||
private static string GetCharsetName()
|
||||
{
|
||||
var s = BenchmarkTestDataProvider.LongStringSuffix;
|
||||
return s switch
|
||||
{
|
||||
CharsetSuffixes.AsciiFix => nameof(CharsetSuffixes.AsciiFix),
|
||||
CharsetSuffixes.AsciiShort => nameof(CharsetSuffixes.AsciiShort),
|
||||
CharsetSuffixes.AsciiLong => nameof(CharsetSuffixes.AsciiLong),
|
||||
CharsetSuffixes.Latin1Fix => nameof(CharsetSuffixes.Latin1Fix),
|
||||
CharsetSuffixes.Latin1Short => nameof(CharsetSuffixes.Latin1Short),
|
||||
CharsetSuffixes.Latin1Long => nameof(CharsetSuffixes.Latin1Long),
|
||||
CharsetSuffixes.CjkBmpShort => nameof(CharsetSuffixes.CjkBmpShort),
|
||||
CharsetSuffixes.CjkBmpLong => nameof(CharsetSuffixes.CjkBmpLong),
|
||||
CharsetSuffixes.CyrillicShort => nameof(CharsetSuffixes.CyrillicShort),
|
||||
CharsetSuffixes.CyrillicLong => nameof(CharsetSuffixes.CyrillicLong),
|
||||
CharsetSuffixes.MixedShort => nameof(CharsetSuffixes.MixedShort),
|
||||
CharsetSuffixes.MixedLong => nameof(CharsetSuffixes.MixedLong),
|
||||
_ => "Custom"
|
||||
};
|
||||
}
|
||||
|
||||
private static List<BenchmarkResult> Translate(Summary summary, List<TestDataSet> allTestData)
|
||||
{
|
||||
var grouped = summary.Reports
|
||||
.Where(r => r.Success && r.ResultStatistics != null)
|
||||
.GroupBy(r => (
|
||||
TestData: GetParam(r, "TestData"),
|
||||
Engine: GetParam(r, "Engine")
|
||||
))
|
||||
.Where(g => !string.IsNullOrEmpty(g.Key.TestData) && !string.IsNullOrEmpty(g.Key.Engine))
|
||||
.ToList();
|
||||
|
||||
var results = new List<BenchmarkResult>(grouped.Count);
|
||||
|
||||
foreach (var group in grouped)
|
||||
{
|
||||
var testDataSet = (TestDataSet<TestOrder_All_False>)allTestData.First(t => t.Name.StartsWith(group.Key.TestData));
|
||||
var engineEnum = group.Key.Engine switch
|
||||
{
|
||||
"AcBinary" => BenchmarkEngine.AcBinary,
|
||||
"MemoryPack" => BenchmarkEngine.MemoryPack,
|
||||
_ => throw new InvalidOperationException($"Unknown engine in BDN params: {group.Key.Engine}")
|
||||
};
|
||||
|
||||
// Construct the same workload instance AcBinaryVsMemPackBenchmark.Setup would build — same options,
|
||||
// same wire mode. Reading SerializedSize + OptionsDescription from it keeps the BDN-side metadata
|
||||
// in lockstep with what the workload actually serialised (no drift between hardcoded BDN strings
|
||||
// and the workload's own OptionsDescription / SerializedSize).
|
||||
var workload = CreateWorkload(testDataSet, group.Key.Engine);
|
||||
|
||||
var result = new BenchmarkResult
|
||||
{
|
||||
TestDataName = testDataSet.DisplayName,
|
||||
Engine = engineEnum,
|
||||
IoMode = BenchmarkIoMode.ByteArray,
|
||||
DispatchMode = BenchmarkDispatchMode.SGen,
|
||||
OptionsPreset = group.Key.Engine == "AcBinary" ? "FastMode" : "Default",
|
||||
OrderTypeName = nameof(TestOrder_All_False),
|
||||
SerializedSize = workload.SerializedSize,
|
||||
OptionsDescription = workload.OptionsDescription,
|
||||
};
|
||||
|
||||
// ns → ms (BenchmarkResult expects ms per op with iter=1, so µs/op = ms * 1000 / 1 = ms*1000).
|
||||
const double nsToMs = 1.0 / 1_000_000.0;
|
||||
|
||||
foreach (var report in group)
|
||||
{
|
||||
var methodName = report.BenchmarkCase.Descriptor.WorkloadMethod.Name;
|
||||
var stats = report.ResultStatistics!;
|
||||
var allocBytes = report.GcStats.GetBytesAllocatedPerOperation(report.BenchmarkCase) ?? 0;
|
||||
|
||||
if (methodName == "Serialize")
|
||||
{
|
||||
result.SerializeTimeMs = stats.Median * nsToMs;
|
||||
result.SerializeTimeMinMs = stats.Min * nsToMs;
|
||||
result.SerializeTimeMaxMs = stats.Max * nsToMs;
|
||||
result.SerializeTimeStdDevMs = stats.StandardDeviation * nsToMs;
|
||||
result.SerializeIterations = 1; // see class-doc "Iteration count = 1" note
|
||||
result.SerializeAllocBytesPerOp = allocBytes;
|
||||
}
|
||||
else if (methodName == "Deserialize")
|
||||
{
|
||||
result.DeserializeTimeMs = stats.Median * nsToMs;
|
||||
result.DeserializeTimeMinMs = stats.Min * nsToMs;
|
||||
result.DeserializeTimeMaxMs = stats.Max * nsToMs;
|
||||
result.DeserializeTimeStdDevMs = stats.StandardDeviation * nsToMs;
|
||||
result.DeserializeIterations = 1;
|
||||
result.DeserializeAllocBytesPerOp = allocBytes;
|
||||
}
|
||||
}
|
||||
|
||||
// Compose RT from Ser + Des per-op µs (same logic as Console BenchmarkLoop's in-memory
|
||||
// composition — since BDN measures Ser and Des independently, RT here is the analytic sum).
|
||||
var serPerOp = BenchmarkReportWriter.ToPerOpMicros(result.SerializeTimeMs, result.SerializeIterations);
|
||||
var desPerOp = BenchmarkReportWriter.ToPerOpMicros(result.DeserializeTimeMs, result.DeserializeIterations);
|
||||
var rtPerOp = serPerOp + desPerOp;
|
||||
result.RoundTripIterations = Math.Max(result.SerializeIterations, result.DeserializeIterations);
|
||||
result.RoundTripTimeMs = rtPerOp / 1000.0 * result.RoundTripIterations;
|
||||
result.RoundTripAllocBytesPerOp = result.SerializeAllocBytesPerOp + result.DeserializeAllocBytesPerOp;
|
||||
|
||||
results.Add(result);
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
private static string GetParam(BenchmarkReport report, string name) =>
|
||||
report.BenchmarkCase.Parameters.Items.FirstOrDefault(p => p.Name == name)?.Value?.ToString() ?? "";
|
||||
|
||||
/// <summary>
|
||||
/// Constructs the same workload instance <see cref="AcBinaryVsMemPackBenchmark.Setup"/> would build —
|
||||
/// same options, same wire mode. The adapter reads <see cref="ISerializerBenchmark.SerializedSize"/> and
|
||||
/// <see cref="ISerializerBenchmark.OptionsDescription"/> from this instance so the BDN-side BenchmarkResult
|
||||
/// rows carry the same workload-side metadata the Console rows have (no risk of drift between hardcoded
|
||||
/// adapter strings and what the workload actually used).
|
||||
///
|
||||
/// Cost: one Serialize call inside the ctor per (TestData × Engine) cell — runs once during summary
|
||||
/// translation, NOT in BDN's measured hot path. Negligible vs BDN's per-run cost.
|
||||
/// </summary>
|
||||
private static ISerializerBenchmark CreateWorkload(TestDataSet<TestOrder_All_False> testDataSet, string engine)
|
||||
{
|
||||
if (engine == "AcBinary")
|
||||
{
|
||||
var options = AcBinarySerializerOptions.FastMode;
|
||||
options.WireMode = WireMode.Compact;
|
||||
return new AcBinaryBenchmark<TestOrder_All_False>(testDataSet.Order, options, "FastMode");
|
||||
}
|
||||
return new MemoryPackBenchmark<TestOrder_All_False>(testDataSet.Order, WireMode.Compact, "Default");
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,59 @@
|
|||
using AyCode.Core.Serializers;
|
||||
using AyCode.Core.Serializers.Binaries;
|
||||
using AyCode.Core.Tests.TestModels;
|
||||
|
||||
namespace AyCode.Core.Benchmarks;
|
||||
|
||||
/// <summary>
|
||||
/// Direct JIT-disassembly harness for the AcBinary <b>Large Serialize</b> hot path — the cell where
|
||||
/// the PGO-driven inline bistability shows up (~120 µs/op fast mode ⇄ ~142 µs/op slow mode, same
|
||||
/// source, run-to-run).
|
||||
///
|
||||
/// <para><b>Not a BenchmarkDotNet benchmark.</b> BDN's <c>DisassemblyDiagnoser</c> produced no output
|
||||
/// ("No benchmarks were disassembled"); this harness leans on the runtime's own JIT disassembler
|
||||
/// instead. <see cref="Run"/> builds the workload and exercises the Large Ser FastMode path — when the
|
||||
/// process is launched with <c>DOTNET_JitDisasm=<pattern></c> the JIT dumps the x64 assembly of
|
||||
/// every matching method to stdout as it compiles them.</para>
|
||||
///
|
||||
/// <para><b>Run it</b> (via the <c>--jitasm</c> switch). Set:</para>
|
||||
/// <list type="bullet">
|
||||
/// <item><c>DOTNET_TieredCompilation=0</c> — each method compiled once, straight to full-opt Tier-1:
|
||||
/// deterministic codegen, no tiering/PGO lottery (so the disasm is reproducible).</item>
|
||||
/// <item><c>DOTNET_JitDisasm=<pattern></c> — e.g. <c>*GeneratedWriter*</c> for the SGen writer
|
||||
/// hot loop. The un-inlined <c>call</c>s in that loop are the candidates for the PGO-flipped inline
|
||||
/// site; pinning the right callee with <c>[MethodImpl(AggressiveInlining)]</c> locks in the fast mode.</item>
|
||||
/// </list>
|
||||
///
|
||||
/// <para>The workload mirrors <see cref="AcBinaryVsMemPackBenchmark"/> exactly — Large (5×5×5×10)
|
||||
/// <see cref="TestOrder_All_False"/> graph, AsciiShort charset, FastMode + Compact wire.</para>
|
||||
/// </summary>
|
||||
public sealed class JitDisassemblyBenchmark
|
||||
{
|
||||
/// <summary>
|
||||
/// Builds the Large workload and JITs + exercises the Large Ser FastMode hot path. With
|
||||
/// <c>DOTNET_JitDisasm</c> set, the JIT emits the matching methods' disassembly to stdout on
|
||||
/// first compile; the loop guarantees every reachable serializer method is JIT-compiled (and,
|
||||
/// if tiering is left on, promoted to Tier-1).
|
||||
/// </summary>
|
||||
public void Run()
|
||||
{
|
||||
// Mirror AcBinaryVsMemPackBenchmark exactly: AsciiShort charset (where the Large-Ser bimodality
|
||||
// was observed), Large (5×5×5×10) TestOrder_All_False graph, FastMode + Compact wire.
|
||||
BenchmarkTestDataProvider.LongStringSuffix = CharsetSuffixes.AsciiShort;
|
||||
|
||||
var allTestData = BenchmarkTestDataProvider_All_False.CreateTestDataSets();
|
||||
var largeSet = (TestDataSet<TestOrder_All_False>)allTestData.First(t => t.Name.StartsWith("Large"));
|
||||
var order = largeSet.Order;
|
||||
|
||||
var options = AcBinarySerializerOptions.FastMode;
|
||||
options.WireMode = WireMode.Compact;
|
||||
|
||||
Console.WriteLine("=== JIT-DISASM HARNESS: Large Ser FastMode (TestOrder_All_False, AsciiShort) — start ===");
|
||||
|
||||
byte[] last = null!;
|
||||
for (var i = 0; i < 50; i++)
|
||||
last = AcBinarySerializer.Serialize(order, options);
|
||||
|
||||
Console.WriteLine($"=== JIT-DISASM HARNESS: done — 50 Large Ser ops, last payload {last.Length} bytes ===");
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,564 @@
|
|||
using BenchmarkDotNet.Running;
|
||||
using AyCode.Core.Benchmarks;
|
||||
using AyCode.Core.Extensions;
|
||||
using AyCode.Core.Tests.TestModels;
|
||||
using System.Text;
|
||||
using MessagePack;
|
||||
using MessagePack.Resolvers;
|
||||
using BenchmarkDotNet.Configs;
|
||||
using System.IO;
|
||||
using AyCode.Core.Serializers.Jsons;
|
||||
using AyCode.Core.Serializers.Binaries;
|
||||
using System.Diagnostics;
|
||||
|
||||
namespace AyCode.Benchmark
|
||||
{
|
||||
internal class Program
|
||||
{
|
||||
static void Main(string[] args)
|
||||
{
|
||||
// Ensure centralized results directory is at the SOLUTION ROOT level (not benchmark project level)
|
||||
// This navigates from AyCode.Benchmark\bin\Debug\net9.0 up to AyCode.Core
|
||||
var currentDir = Directory.GetCurrentDirectory();
|
||||
var solutionRoot = FindSolutionRoot(currentDir);
|
||||
|
||||
var baseResultsDir = Path.Combine(solutionRoot, "Test_Benchmark_Results");
|
||||
var mstestDir = Path.Combine(baseResultsDir, "MSTest");
|
||||
var benchmarkDir = Path.Combine(baseResultsDir, "Benchmark");
|
||||
var coverageDir = Path.Combine(baseResultsDir, "CoverageReport");
|
||||
var memDiagDir = Path.Combine(baseResultsDir, "MemDiag");
|
||||
|
||||
Directory.CreateDirectory(mstestDir);
|
||||
Directory.CreateDirectory(benchmarkDir);
|
||||
Directory.CreateDirectory(coverageDir);
|
||||
Directory.CreateDirectory(memDiagDir);
|
||||
|
||||
// Create .gitignore in results folder to keep it out of source control except the file itself
|
||||
var gitignorePath = Path.Combine(baseResultsDir, ".gitignore");
|
||||
if (!File.Exists(gitignorePath))
|
||||
{
|
||||
File.WriteAllText(gitignorePath, "*\n!.gitignore\n");
|
||||
}
|
||||
|
||||
// If requested, save/move a coverage file into the CoverageReport folder
|
||||
if (args.Length > 0 && args[0] == "--save-coverage")
|
||||
{
|
||||
if (args.Length < 2)
|
||||
{
|
||||
Console.Error.WriteLine("Usage: --save-coverage <coverage-file-path>");
|
||||
return;
|
||||
}
|
||||
|
||||
var src = args[1];
|
||||
if (!File.Exists(src))
|
||||
{
|
||||
Console.Error.WriteLine("Coverage file not found: " + src);
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var dest = Path.Combine(coverageDir, Path.GetFileName(src));
|
||||
File.Copy(src, dest, overwrite: true);
|
||||
Console.WriteLine("Coverage file saved to: " + dest);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.Error.WriteLine("Failed to save coverage file: " + ex.Message);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// BDN-native artifacts go under <results>/Benchmark/BDN/ (per the unified output convention —
|
||||
// see ReportingContext docs). The unified Bdn.FullBenchmark_*.{log,LLM,output} triplet (emitted
|
||||
// by BdnSummaryAdapter after BDN finishes) lands one level up in <results>/Benchmark/, next to
|
||||
// the Console.*.* counterparts produced by the Console runner.
|
||||
var bdnArtifactsDir = Path.Combine(benchmarkDir, "BDN");
|
||||
Directory.CreateDirectory(bdnArtifactsDir);
|
||||
|
||||
var config = ManualConfig.Create(DefaultConfig.Instance)
|
||||
.WithArtifactsPath(bdnArtifactsDir);
|
||||
|
||||
if (args.Length > 0 && args[0] == "--quick")
|
||||
{
|
||||
RunQuickBenchmark();
|
||||
return;
|
||||
}
|
||||
|
||||
if (args.Length > 0 && args[0] == "--test")
|
||||
{
|
||||
var (inDir, outDir) = CreateMSTestDeployDirs(mstestDir);
|
||||
RunQuickTest(outDir);
|
||||
return;
|
||||
}
|
||||
|
||||
if (args.Length > 0 && args[0] == "--testmsgpack")
|
||||
{
|
||||
var (inDir, outDir) = CreateMSTestDeployDirs(mstestDir);
|
||||
RunMessagePackTest(outDir);
|
||||
return;
|
||||
}
|
||||
|
||||
if (args.Length > 0 && args[0] == "--serializers")
|
||||
{
|
||||
// Unified serializer benchmark mirroring Console's "F" menu (FastestByte) — AcBinary FastMode
|
||||
// Byte[] vs MemoryPack Default Byte[] across 5 TestData cells. BdnSummaryAdapter translates
|
||||
// the BDN Summary into BenchmarkResult rows and emits the Bdn.FullBenchmark_*.{log,LLM,output}
|
||||
// triplet to <results>/Benchmark/ (BDN-native artifacts go under .../BDN/ via the global config).
|
||||
WithProcessStabilization(() =>
|
||||
{
|
||||
var serializerSummary = BenchmarkRunner.Run<AcBinaryVsMemPackBenchmark>(config);
|
||||
BdnSummaryAdapter.WriteResults(serializerSummary);
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (args.Length > 0 && args[0] == "--jitasm")
|
||||
{
|
||||
// Direct JIT-disasm harness — NOT BenchmarkDotNet. BDN's DisassemblyDiagnoser produced
|
||||
// nothing here ("No benchmarks were disassembled"); this leans on the runtime's own JIT
|
||||
// disassembler instead. Launch with DOTNET_TieredCompilation=0 + DOTNET_JitDisasm=<pattern>
|
||||
// (e.g. *GeneratedWriter*) — the JIT dumps the matching methods' x64 asm to stdout.
|
||||
new JitDisassemblyBenchmark().Run();
|
||||
return;
|
||||
}
|
||||
|
||||
Console.WriteLine("Usage:");
|
||||
Console.WriteLine(" --quick Quick benchmark with tabular output (AcBinary vs MessagePack)");
|
||||
Console.WriteLine(" --test Quick AcBinary test");
|
||||
Console.WriteLine(" --testmsgpack Quick MessagePack test");
|
||||
Console.WriteLine(" --serializers AcBinary FastMode vs MemoryPack Default across 5 test data cells (mirrors Console F menu)");
|
||||
Console.WriteLine(" --jitasm JIT disassembly analysis (shows actual x64 assembly for hot path)");
|
||||
Console.WriteLine(" --save-coverage <file> Save coverage file into Test_Benchmark_Results/CoverageReport");
|
||||
|
||||
// Default path: hand control to BDN's BenchmarkSwitcher (no args → interactive picker; with
|
||||
// args → BDN parses them as benchmark filters / job options). Same code path either way — the
|
||||
// known custom switches above (--serializers, --jitasm, --quick, --test, --testmsgpack,
|
||||
// --save-coverage) return early before reaching this point.
|
||||
WithProcessStabilization(() =>
|
||||
{
|
||||
BenchmarkSwitcher.FromAssembly(typeof(Program).Assembly).Run(args, config);
|
||||
CollectBenchmarkArtifacts(benchmarkDir, memDiagDir, "SwitcherRun");
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Runs the given action with CPU affinity pinned to CPU 0 and process priority raised to High,
|
||||
/// restoring the original state in <c>finally</c>. Matches the stabilization block in
|
||||
/// <c>AyCode.Core.Serializers.Console.BenchmarkLoop.RunBenchmark</c> so BDN-side measurements
|
||||
/// receive the same OS-scheduler insulation the Console runner enjoys.
|
||||
/// <para><b>Worker process inheritance:</b> BDN spawns a per-job child process to host the
|
||||
/// workload. CPU affinity propagates from parent → child on both Windows (CreateProcess inherits
|
||||
/// affinity by default) and Linux (fork+exec inherits via sched_setaffinity). So pinning the
|
||||
/// orchestrator process here pins the actual measurement loop too — not just the BDN driver.</para>
|
||||
/// <para>Skipped on macOS where <see cref="Process.ProcessorAffinity"/> throws (priority still
|
||||
/// raised). Failures inside the try block fall through to a best-effort restore in finally.</para>
|
||||
/// </summary>
|
||||
static void WithProcessStabilization(Action action)
|
||||
{
|
||||
var process = Process.GetCurrentProcess();
|
||||
var origAffinity = (IntPtr)0;
|
||||
var origPriority = ProcessPriorityClass.Normal;
|
||||
var stabilizationApplied = false;
|
||||
|
||||
if (OperatingSystem.IsWindows() || OperatingSystem.IsLinux())
|
||||
{
|
||||
try
|
||||
{
|
||||
origAffinity = process.ProcessorAffinity;
|
||||
origPriority = process.PriorityClass;
|
||||
// Pin to CPU 0 (mask = 1). The choice is arbitrary — what matters is "exactly one
|
||||
// core, consistently" — not which one. BDN's child worker process inherits the
|
||||
// affinity, so the measurement loop itself runs pinned. Mirrors Console's pinning.
|
||||
process.ProcessorAffinity = (IntPtr)1;
|
||||
process.PriorityClass = ProcessPriorityClass.High;
|
||||
stabilizationApplied = true;
|
||||
Console.WriteLine("Stabilization: pinned to CPU 0 (affinity=0x1), priority=High (BDN workers inherit affinity).");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Affinity / priority changes may fail on locked-down hosts (group policies, containers
|
||||
// without CAP_SYS_NICE on Linux). Surface and continue — BDN still works, just without
|
||||
// scheduler insulation.
|
||||
Console.WriteLine($"Stabilization SKIPPED: {ex.GetType().Name}: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
action();
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (stabilizationApplied && (OperatingSystem.IsWindows() || OperatingSystem.IsLinux()))
|
||||
{
|
||||
try { process.ProcessorAffinity = origAffinity; } catch { /* best-effort */ }
|
||||
try { process.PriorityClass = origPriority; } catch { /* best-effort */ }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Quick benchmark comparing AcBinary vs MessagePack with tabular output.
|
||||
/// Tests: WithRef, NoRef, Serialize, Deserialize, Populate, Merge
|
||||
/// </summary>
|
||||
static void RunQuickBenchmark(int iterations = 1000)
|
||||
{
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("????????????????????????????????????????????????????????????????????????????????");
|
||||
Console.WriteLine("? AcBinary vs MessagePack Quick Benchmark ?");
|
||||
Console.WriteLine("????????????????????????????????????????????????????????????????????????????????");
|
||||
Console.WriteLine();
|
||||
|
||||
// Create test data with shared references
|
||||
TestDataFactory.ResetIdCounter();
|
||||
var sharedTag = TestDataFactory.CreateTag("SharedTag_All_True");
|
||||
var sharedUser = TestDataFactory.CreateUser("shareduser");
|
||||
var sharedMeta = TestDataFactory.CreateMetadata("shared", withChild: true);
|
||||
|
||||
var testOrder = TestDataFactory.CreateOrder(
|
||||
itemCount: 3,
|
||||
palletsPerItem: 3,
|
||||
measurementsPerPallet: 3,
|
||||
pointsPerMeasurement: 4,
|
||||
sharedTag: sharedTag,
|
||||
sharedUser: sharedUser,
|
||||
sharedMetadata: sharedMeta);
|
||||
|
||||
// Options
|
||||
var withRefOptions = new AcBinarySerializerOptions();
|
||||
var noRefOptions = AcBinarySerializerOptions.WithoutReferenceHandling;
|
||||
var msgPackOptions = ContractlessStandardResolver.Options.WithCompression(MessagePackCompression.None);
|
||||
|
||||
// Warm up
|
||||
Console.WriteLine("Warming up...");
|
||||
for (int i = 0; i < 100; i++)
|
||||
{
|
||||
_ = AcBinarySerializer.Serialize(testOrder, withRefOptions);
|
||||
_ = AcBinarySerializer.Serialize(testOrder, noRefOptions);
|
||||
_ = MessagePackSerializer.Serialize(testOrder, msgPackOptions);
|
||||
}
|
||||
|
||||
// Pre-serialize data for deserialization tests
|
||||
var acBinaryWithRef = AcBinarySerializer.Serialize(testOrder, withRefOptions);
|
||||
var acBinaryNoRef = AcBinarySerializer.Serialize(testOrder, noRefOptions);
|
||||
var msgPackData = MessagePackSerializer.Serialize(testOrder, msgPackOptions);
|
||||
|
||||
Console.WriteLine($"Iterations: {iterations:N0}");
|
||||
Console.WriteLine();
|
||||
|
||||
// Size comparison
|
||||
Console.WriteLine("???????????????????????????????????????????????????????????????????????????????");
|
||||
Console.WriteLine("? SIZE COMPARISON ?");
|
||||
Console.WriteLine("???????????????????????????????????????????????????????????????????????????????");
|
||||
Console.WriteLine("? Format ? Size (bytes) ? vs MessagePack ? Savings ?");
|
||||
Console.WriteLine("???????????????????????????????????????????????????????????????????????????????");
|
||||
Console.WriteLine($"? AcBinary (WithRef) ? {acBinaryWithRef.Length,14:N0} ? {100.0 * acBinaryWithRef.Length / msgPackData.Length,13:F1}% ? {msgPackData.Length - acBinaryWithRef.Length,14:N0} ?");
|
||||
Console.WriteLine($"? AcBinary (NoRef) ? {acBinaryNoRef.Length,14:N0} ? {100.0 * acBinaryNoRef.Length / msgPackData.Length,13:F1}% ? {msgPackData.Length - acBinaryNoRef.Length,14:N0} ?");
|
||||
Console.WriteLine($"? MessagePack ? {msgPackData.Length,14:N0} ? {100.0,13:F1}% ? {"(baseline)",14} ?");
|
||||
Console.WriteLine("???????????????????????????????????????????????????????????????????????????????");
|
||||
Console.WriteLine();
|
||||
|
||||
// Benchmark results storage
|
||||
var results = new List<(string Operation, string Mode, double AcBinaryMs, double MsgPackMs)>();
|
||||
|
||||
// Serialize benchmarks
|
||||
var sw = Stopwatch.StartNew();
|
||||
|
||||
// AcBinary WithRef Serialize
|
||||
sw.Restart();
|
||||
for (int i = 0; i < iterations; i++)
|
||||
_ = AcBinarySerializer.Serialize(testOrder, withRefOptions);
|
||||
var acWithRefSerialize = sw.Elapsed.TotalMilliseconds;
|
||||
|
||||
// AcBinary NoRef Serialize
|
||||
sw.Restart();
|
||||
for (int i = 0; i < iterations; i++)
|
||||
_ = AcBinarySerializer.Serialize(testOrder, noRefOptions);
|
||||
var acNoRefSerialize = sw.Elapsed.TotalMilliseconds;
|
||||
|
||||
// MessagePack Serialize
|
||||
sw.Restart();
|
||||
for (int i = 0; i < iterations; i++)
|
||||
_ = MessagePackSerializer.Serialize(testOrder, msgPackOptions);
|
||||
var msgPackSerialize = sw.Elapsed.TotalMilliseconds;
|
||||
|
||||
results.Add(("Serialize", "WithRef", acWithRefSerialize, msgPackSerialize));
|
||||
results.Add(("Serialize", "NoRef", acNoRefSerialize, msgPackSerialize));
|
||||
|
||||
// Deserialize benchmarks
|
||||
// AcBinary WithRef Deserialize
|
||||
sw.Restart();
|
||||
for (int i = 0; i < iterations; i++)
|
||||
_ = AcBinaryDeserializer.Deserialize<TestOrder_All_True>(acBinaryWithRef);
|
||||
var acWithRefDeserialize = sw.Elapsed.TotalMilliseconds;
|
||||
|
||||
// AcBinary NoRef Deserialize
|
||||
sw.Restart();
|
||||
for (int i = 0; i < iterations; i++)
|
||||
_ = AcBinaryDeserializer.Deserialize<TestOrder_All_True>(acBinaryNoRef);
|
||||
var acNoRefDeserialize = sw.Elapsed.TotalMilliseconds;
|
||||
|
||||
// MessagePack Deserialize
|
||||
sw.Restart();
|
||||
for (int i = 0; i < iterations; i++)
|
||||
_ = MessagePackSerializer.Deserialize<TestOrder_All_True>(msgPackData, msgPackOptions);
|
||||
var msgPackDeserialize = sw.Elapsed.TotalMilliseconds;
|
||||
|
||||
results.Add(("Deserialize", "WithRef", acWithRefDeserialize, msgPackDeserialize));
|
||||
results.Add(("Deserialize", "NoRef", acNoRefDeserialize, msgPackDeserialize));
|
||||
|
||||
// Populate benchmark (AcBinary only)
|
||||
sw.Restart();
|
||||
for (int i = 0; i < iterations; i++)
|
||||
{
|
||||
var target = CreatePopulateTarget(testOrder);
|
||||
AcBinaryDeserializer.Populate(acBinaryNoRef, target);
|
||||
}
|
||||
var acPopulate = sw.Elapsed.TotalMilliseconds;
|
||||
results.Add(("Populate", "NoRef", acPopulate, 0)); // MessagePack doesn't have Populate
|
||||
|
||||
// PopulateMerge benchmark (AcBinary only)
|
||||
sw.Restart();
|
||||
for (int i = 0; i < iterations; i++)
|
||||
{
|
||||
var target = CreatePopulateTarget(testOrder);
|
||||
AcBinaryDeserializer.PopulateMerge(acBinaryNoRef, target);
|
||||
}
|
||||
var acMerge = sw.Elapsed.TotalMilliseconds;
|
||||
results.Add(("Merge", "NoRef", acMerge, 0));
|
||||
|
||||
// Round-trip
|
||||
var acWithRefRoundTrip = acWithRefSerialize + acWithRefDeserialize;
|
||||
var acNoRefRoundTrip = acNoRefSerialize + acNoRefDeserialize;
|
||||
var msgPackRoundTrip = msgPackSerialize + msgPackDeserialize;
|
||||
results.Add(("Round-trip", "WithRef", acWithRefRoundTrip, msgPackRoundTrip));
|
||||
results.Add(("Round-trip", "NoRef", acNoRefRoundTrip, msgPackRoundTrip));
|
||||
|
||||
// Print performance table
|
||||
Console.WriteLine("???????????????????????????????????????????????????????????????????????????????");
|
||||
Console.WriteLine("? PERFORMANCE COMPARISON (lower is better) ?");
|
||||
Console.WriteLine("???????????????????????????????????????????????????????????????????????????????");
|
||||
Console.WriteLine("? Operation ? AcBinary (ms) ? MessagePack ? Ratio ?");
|
||||
Console.WriteLine("???????????????????????????????????????????????????????????????????????????????");
|
||||
|
||||
foreach (var r in results)
|
||||
{
|
||||
var opName = $"{r.Operation} ({r.Mode})";
|
||||
if (r.MsgPackMs > 0)
|
||||
{
|
||||
var ratio = r.AcBinaryMs / r.MsgPackMs;
|
||||
var ratioStr = ratio < 1 ? $"{ratio:F2}x faster" : $"{ratio:F2}x slower";
|
||||
Console.WriteLine($"? {opName,-24} ? {r.AcBinaryMs,14:F2} ? {r.MsgPackMs,14:F2} ? {ratioStr,14} ?");
|
||||
}
|
||||
else
|
||||
{
|
||||
Console.WriteLine($"? {opName,-24} ? {r.AcBinaryMs,14:F2} ? {"N/A",14} ? {"(unique)",14} ?");
|
||||
}
|
||||
}
|
||||
|
||||
Console.WriteLine("???????????????????????????????????????????????????????????????????????????????");
|
||||
Console.WriteLine();
|
||||
|
||||
// Summary
|
||||
Console.WriteLine("???????????????????????????????????????????????????????????????????????????????");
|
||||
Console.WriteLine("? SUMMARY ?");
|
||||
Console.WriteLine("???????????????????????????????????????????????????????????????????????????????");
|
||||
var sizeAdvantage = 100.0 - (100.0 * acBinaryNoRef.Length / msgPackData.Length);
|
||||
Console.WriteLine($"? Size advantage: AcBinary is {sizeAdvantage:F1}% smaller than MessagePack ?");
|
||||
|
||||
var serializeRatio = acNoRefSerialize / msgPackSerialize;
|
||||
var deserializeRatio = acNoRefDeserialize / msgPackDeserialize;
|
||||
Console.WriteLine($"? Serialize (NoRef): AcBinary is {(serializeRatio < 1 ? $"{1/serializeRatio:F2}x faster" : $"{serializeRatio:F2}x slower"),-20} ?");
|
||||
Console.WriteLine($"? Deserialize (NoRef): AcBinary is {(deserializeRatio < 1 ? $"{1/deserializeRatio:F2}x faster" : $"{deserializeRatio:F2}x slower"),-18} ?");
|
||||
Console.WriteLine("???????????????????????????????????????????????????????????????????????????????");
|
||||
Console.WriteLine();
|
||||
}
|
||||
|
||||
static TestOrder_All_True CreatePopulateTarget(TestOrder_All_True source)
|
||||
{
|
||||
var target = new TestOrder_All_True { Id = source.Id };
|
||||
foreach (var item in source.Items)
|
||||
{
|
||||
target.Items.Add(new TestOrderItem_All_True { Id = item.Id });
|
||||
}
|
||||
return target;
|
||||
}
|
||||
|
||||
static (string InDir, string OutDir) CreateMSTestDeployDirs(string mstestBase)
|
||||
{
|
||||
var user = Environment.UserName ?? "Deploy";
|
||||
var ts = DateTime.UtcNow.ToString("yyyyMMddTHHmmss_ffff");
|
||||
var deployBase = Path.Combine(mstestBase, $"Deploy_{user} {ts}");
|
||||
var inDir = Path.Combine(deployBase, "In");
|
||||
var outDir = Path.Combine(deployBase, "Out");
|
||||
Directory.CreateDirectory(inDir);
|
||||
Directory.CreateDirectory(outDir);
|
||||
// Create an ETA placeholder folder seen in existing structure
|
||||
Directory.CreateDirectory(Path.Combine(inDir, "ETA001"));
|
||||
return (inDir, outDir);
|
||||
}
|
||||
|
||||
static void RunQuickTest(string outDir)
|
||||
{
|
||||
Console.WriteLine("=== Quick AcBinary Test ===\n");
|
||||
|
||||
try
|
||||
{
|
||||
Console.WriteLine("Creating test data...");
|
||||
var order = TestDataFactory.CreateBenchmarkOrder(
|
||||
itemCount: 3,
|
||||
palletsPerItem: 2,
|
||||
measurementsPerPallet: 2,
|
||||
pointsPerMeasurement: 5);
|
||||
Console.WriteLine($"Created order with {order.Items.Count} items");
|
||||
|
||||
Console.WriteLine("\nTesting JSON serialization...");
|
||||
var jsonOptions = AcJsonSerializerOptions.WithoutReferenceHandling;
|
||||
var json = AcJsonSerializer.Serialize(order, jsonOptions);
|
||||
|
||||
// Log a quick summary to Out folder for convenience
|
||||
var logPath = Path.Combine(outDir, "quick_test_log.txt");
|
||||
File.WriteAllText(logPath, $"QuickTest: Order items={order.Items.Count}, JsonLength={json.Length}\n");
|
||||
|
||||
Console.WriteLine("Quick test completed. Log written to: " + logPath);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.Error.WriteLine("Quick test failed: " + ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
static void RunMessagePackTest(string outDir)
|
||||
{
|
||||
Console.WriteLine("=== Quick MessagePack Test ===\n");
|
||||
try
|
||||
{
|
||||
var order = TestDataFactory.CreateBenchmarkOrder(2,1,1,3);
|
||||
var bytes = MessagePackSerializer.Serialize(order, MessagePackSerializerOptions.Standard.WithResolver(ContractlessStandardResolver.Instance));
|
||||
|
||||
var logPath = Path.Combine(outDir, "quick_msgpack_test_log.txt");
|
||||
File.WriteAllText(logPath, $"MessagePack quick test: bytes={bytes.Length}\n");
|
||||
|
||||
Console.WriteLine("Quick MessagePack test completed. Log written to: " + logPath);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.Error.WriteLine("Quick MessagePack test failed: " + ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
static void RunSizeComparison()
|
||||
{
|
||||
Console.WriteLine("Running size comparisons (output to console)...");
|
||||
// Existing implementation
|
||||
}
|
||||
|
||||
static void RunBenchmark<T>(ManualConfig config, string benchmarkDir, string memDiagDir, string name)
|
||||
{
|
||||
// Run benchmark and then collect artifacts into MemDiag folder
|
||||
try
|
||||
{
|
||||
var summary = BenchmarkRunner.Run<T>(config);
|
||||
}
|
||||
finally
|
||||
{
|
||||
CollectBenchmarkArtifacts(benchmarkDir, memDiagDir, name);
|
||||
}
|
||||
}
|
||||
|
||||
static void CollectBenchmarkArtifacts(string benchmarkDir, string memDiagDir, string runName)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!Directory.Exists(benchmarkDir)) return;
|
||||
var ts = DateTime.UtcNow.ToString("yyyyMMddTHHmmss_fff");
|
||||
var destDir = Path.Combine(memDiagDir, $"{runName}_{ts}");
|
||||
Directory.CreateDirectory(destDir);
|
||||
|
||||
foreach (var file in Directory.GetFiles(benchmarkDir))
|
||||
{
|
||||
try
|
||||
{
|
||||
var dest = Path.Combine(destDir, Path.GetFileName(file));
|
||||
File.Copy(file, dest, overwrite: true);
|
||||
}
|
||||
catch { /* ignore individual copy failures */ }
|
||||
}
|
||||
|
||||
// Also copy subdirectories (artifact folders)
|
||||
foreach (var dir in Directory.GetDirectories(benchmarkDir))
|
||||
{
|
||||
try
|
||||
{
|
||||
var name = Path.GetFileName(dir);
|
||||
var target = Path.Combine(destDir, name);
|
||||
CopyDirectory(dir, target);
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
|
||||
Console.WriteLine($"Benchmark artifacts copied to: {destDir}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.Error.WriteLine("Failed to collect benchmark artifacts: " + ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
static void CopyDirectory(string sourceDir, string destDir)
|
||||
{
|
||||
Directory.CreateDirectory(destDir);
|
||||
foreach (var file in Directory.GetFiles(sourceDir))
|
||||
{
|
||||
var dest = Path.Combine(destDir, Path.GetFileName(file));
|
||||
File.Copy(file, dest, overwrite: true);
|
||||
}
|
||||
foreach (var dir in Directory.GetDirectories(sourceDir))
|
||||
{
|
||||
CopyDirectory(dir, Path.Combine(destDir, Path.GetFileName(dir)));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Finds the solution root directory by looking for the .sln file or known markers.
|
||||
/// Walks up the directory tree from the current directory.
|
||||
/// </summary>
|
||||
static string FindSolutionRoot(string startDir)
|
||||
{
|
||||
var dir = startDir;
|
||||
|
||||
// Walk up the directory tree looking for solution markers
|
||||
while (!string.IsNullOrEmpty(dir))
|
||||
{
|
||||
// Check for .sln file
|
||||
if (Directory.GetFiles(dir, "*.sln").Length > 0)
|
||||
{
|
||||
return dir;
|
||||
}
|
||||
|
||||
// Check for known solution root markers (Directory.Build.props, AyCode.Core folder)
|
||||
if (File.Exists(Path.Combine(dir, "Directory.Build.props")) ||
|
||||
Directory.Exists(Path.Combine(dir, "AyCode.Core")) ||
|
||||
Directory.Exists(Path.Combine(dir, "AyCode.Benchmark")))
|
||||
{
|
||||
// Verify this looks like the solution root
|
||||
if (Directory.Exists(Path.Combine(dir, "AyCode.Core")) &&
|
||||
Directory.Exists(Path.Combine(dir, "AyCode.Benchmark")))
|
||||
{
|
||||
return dir;
|
||||
}
|
||||
}
|
||||
|
||||
var parent = Directory.GetParent(dir);
|
||||
if (parent == null) break;
|
||||
dir = parent.FullName;
|
||||
}
|
||||
|
||||
// Fallback: return the current directory if solution root not found
|
||||
Console.WriteLine($"Warning: Could not find solution root, using current directory: {startDir}");
|
||||
return startDir;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,129 @@
|
|||
# AyCode.Benchmark
|
||||
|
||||
BenchmarkDotNet performance suite **plus** the shared workload / reporting infrastructure used by both BDN and the Console runner. Targets .NET 9.
|
||||
|
||||
## Role: dual-purpose project
|
||||
|
||||
This project plays **two roles**:
|
||||
|
||||
1. **BDN runner Exe** — standalone benchmark host (`Program.cs` + `[Benchmark]`-decorated classes). Invoke via `dotnet run -c Release --project AyCode.Benchmark -- <switch>`.
|
||||
2. **Shared workload + reporting library** — exposes `public` types under [`Workloads/Scenarios/`](Workloads/Scenarios/) and [`Reporting/`](Reporting/) that [`AyCode.Core.Serializers.Console`](../AyCode.Core.Serializers.Console/README.md) consumes via `<ProjectReference>`.
|
||||
|
||||
Both runners feed the SAME `ISerializerBenchmark` workload (same test data graphs, same wire options, same payload sizes) — so Console's adaptive-engine numbers and BDN's iteration-based numbers are **directly comparable**.
|
||||
|
||||
## Output convention
|
||||
|
||||
Both runners emit a unified `.log` / `.LLM` / `.output` triplet to `Test_Benchmark_Results/Benchmark/` (resolved at runtime via walk-up to the nearest `AyCode.Core.sln` — worktree-aware):
|
||||
|
||||
| File | Source | Content |
|
||||
|---|---|---|
|
||||
| `Console.FullBenchmark_<Build>_<ts>.log` | Console runner | Human-readable formatted view |
|
||||
| `Console.FullBenchmark_<Build>_<ts>.LLM` | Console runner | Markdown table, LLM-paste-friendly |
|
||||
| `Console.FullBenchmark_<Build>_<ts>.output` | Console runner | Hex dump of Large cell binary |
|
||||
| `Bdn.FullBenchmark_<Build>_<ts>.log` | BDN runner | Same format as Console |
|
||||
| `Bdn.FullBenchmark_<Build>_<ts>.LLM` | BDN runner | Same |
|
||||
| `Bdn.FullBenchmark_<Build>_<ts>.output` | BDN runner | Same |
|
||||
|
||||
BDN-native artifacts (BDN's own reports, raw measurements, run logs) go to `Test_Benchmark_Results/Benchmark/BDN/` — kept separate so the unified Console+BDN `.log/.LLM/.output` triplet stays uncluttered.
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
┌────────────────────────────────────────────────────────────────┐
|
||||
│ AyCode.Benchmark (this project) │
|
||||
│ │
|
||||
│ Workloads/Scenarios/ public — shared workload types │
|
||||
│ ISerializerBenchmark, BenchmarkOptions, BenchmarkEnums, │
|
||||
│ AcBinaryBenchmark<T>, MemoryPackBenchmark<T>, │
|
||||
│ AcBinaryBufferWriterBenchmark<T>, ... (12 concretes), │
|
||||
│ RoundTripValidator │
|
||||
│ │
|
||||
│ Reporting/ public — shared reporting types │
|
||||
│ BenchmarkResult, ReportingContext, BenchmarkReportWriter │
|
||||
│ │
|
||||
│ AcBinaryVsMemPackBenchmark.cs BDN [Benchmark] class │
|
||||
│ (mirrors Console "F" menu) │
|
||||
│ BdnSummaryAdapter.cs Summary → BenchmarkResult → │
|
||||
│ BenchmarkReportWriter.SaveAll │
|
||||
│ Program.cs BDN entry + CLI dispatch │
|
||||
│ │
|
||||
│ + KEEP: JitDisassemblyBenchmark, RefForeachBenchmark, │
|
||||
│ TaskHelperBenchmarks, ValueTypePassingBenchmark, │
|
||||
│ SourceGeneratorBenchmarks, │
|
||||
│ SignalRCommunicationBenchmarks, │
|
||||
│ SignalRRoundTripBenchmarks │
|
||||
└────────────────────────────────────────────────────────────────┘
|
||||
▲
|
||||
│ ProjectReference (one-way)
|
||||
│
|
||||
┌────────────────────────────────────────────────────────────────┐
|
||||
│ AyCode.Core.Serializers.Console │
|
||||
│ │
|
||||
│ BenchmarkLoop.cs custom adaptive measure engine │
|
||||
│ (CPU 0 pin, High priority, phase-isolated warmup, │
|
||||
│ 10-sample median + pilot, ~250ms/cell calibration) │
|
||||
│ Menu.cs / Configuration.cs / Program.cs Console UX │
|
||||
│ │
|
||||
│ Uses Benchmark's: │
|
||||
│ - Workloads/Scenarios/* (interface + concrete benchmarks) │
|
||||
│ - Reporting/BenchmarkReportWriter (SaveAll, Print...) │
|
||||
└────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## Two runners — same workload, different measurement engines
|
||||
|
||||
| Aspect | Console (custom engine) | BDN |
|
||||
|---|---|---|
|
||||
| Use case | Fast iteration during micro-opt loops | Statistically confident before-commit validation |
|
||||
| Measurement | Adaptive per-cell iter (target ~250ms), 10 samples + pilot, median | Warmup + N iterations, outlier removal, JIT-stabilized, process-spawn isolation |
|
||||
| Time per full run | ~1-3 min | ~5-15 min |
|
||||
| Noise floor | ~3-5% inter-engine delta visible | ~1-2% |
|
||||
| Output format | Identical (same `BenchmarkReportWriter` writes both) | |
|
||||
|
||||
The Console and BDN outputs use the SAME `BenchmarkResult` DTO and the SAME formatter, so cells are directly comparable: pick a cell in `Console.FullBenchmark_*.LLM`, find the same cell in `Bdn.FullBenchmark_*.LLM` — deltas should agree within BDN's tighter CI.
|
||||
|
||||
## CLI
|
||||
|
||||
```
|
||||
dotnet run -c Release --project AyCode.Benchmark -- <switch>
|
||||
```
|
||||
|
||||
| Switch | Description |
|
||||
|---|---|
|
||||
| `--serializers` | AcBinary FastMode Byte[] vs MemoryPack Default Byte[] across 5 TestData cells (mirrors Console "F" menu / FastestByte). Emits `Bdn.FullBenchmark_*.{log,LLM,output}` + BDN-native artifacts under `BDN/`. |
|
||||
| `--jitasm` | JIT disassembly analysis (x64 asm of serialize/deserialize hot path). |
|
||||
| `--quick` | Quick inline benchmark (custom Stopwatch-based, not BDN). |
|
||||
| `--test` / `--testmsgpack` | Quick smoke tests. |
|
||||
| `--save-coverage <file>` | Save coverage file into `Test_Benchmark_Results/CoverageReport/`. |
|
||||
| _(no args)_ | Interactive `BenchmarkSwitcher` — pick from all `[Benchmark]` classes in the assembly. |
|
||||
|
||||
## Key files
|
||||
|
||||
### Serializer benchmark stack (the refactor scope)
|
||||
- [`AcBinaryVsMemPackBenchmark.cs`](AcBinaryVsMemPackBenchmark.cs) — BDN `[MemoryDiagnoser]` class. `[ParamsSource]`(TestData = Small/Medium/Large/Repeated/Deep) × `[Params]`(Engine = AcBinary/MemoryPack). `[GlobalSetup]` hidrátálja a Workloads scenario-ját + round-trip-verify.
|
||||
- [`BdnSummaryAdapter.cs`](BdnSummaryAdapter.cs) — `Summary → List<BenchmarkResult>` translator (groups per `(TestData × Engine)`, ns → ms conversion, GcStats → allocated-bytes-per-op). Calls `BenchmarkReportWriter.PrintGroupedResults` + `SaveAll(ctx with SourceTag="Bdn", ...)`.
|
||||
- [`Program.cs`](Program.cs) — BDN entry. Sets global `WithArtifactsPath(.../Benchmark/BDN)`; `--serializers` switch wires `BenchmarkRunner.Run<AcBinaryVsMemPackBenchmark>` + adapter.
|
||||
- [`Workloads/Scenarios/`](Workloads/Scenarios/) — shared workload types (see folder README).
|
||||
- [`Reporting/`](Reporting/) — shared reporting types (see folder README).
|
||||
|
||||
### KEEP benchmarks (independent — not in the serializer-refactor scope)
|
||||
- [`JitDisassemblyBenchmark.cs`](JitDisassemblyBenchmark.cs) — JIT analysis: emits `.asm` files for serialize/deserialize hot paths.
|
||||
- [`TaskHelperBenchmarks.cs`](TaskHelperBenchmarks.cs) — Task/timing utilities (WaitToAsync, custom ThreadPool, UtcNow.Ticks vs TickCount64).
|
||||
- [`ValueTypePassingBenchmark.cs`](ValueTypePassingBenchmark.cs) — Copy-by-value vs `in` parameter for 16-byte types.
|
||||
- [`RefForeachBenchmark.cs`](RefForeachBenchmark.cs) — Collection iteration patterns (array vs list, foreach vs index, ref readonly).
|
||||
- [`SourceGeneratorBenchmarks.cs`](SourceGeneratorBenchmarks.cs) — Source-generated vs runtime reflection serializers (PureContractlessBenchmark, RepeatedStringBenchmark).
|
||||
- [`SignalRCommunicationBenchmarks.cs`](SignalRCommunicationBenchmarks.cs) — Full-stack SignalR perf (client → server → response → round-trip).
|
||||
- [`SignalRRoundTripBenchmarks.cs`](SignalRRoundTripBenchmarks.cs) — SignalR primitives/complex/collections benchmarks.
|
||||
|
||||
## Dependencies
|
||||
|
||||
| Dependency | Purpose |
|
||||
|---|---|
|
||||
| `BenchmarkDotNet` | BDN harness |
|
||||
| `MemoryPack` | Comparison target (used by Workloads scenarios + BDN class) |
|
||||
| `MessagePack` | Comparison target (KEEP benchmarks + Workloads MessagePackBenchmark scenario) |
|
||||
| `MongoDB.Bson` | KEEP-side comparison target |
|
||||
| `Microsoft.VisualStudio.DiagnosticsHub.BenchmarkDotNetDiagnosers` | VS Profiler integration |
|
||||
| `AyCode.Core` (ProjectReference) | AcBinary serializer |
|
||||
| `AyCode.Core.Tests` (ProjectReference) | Test data factory (`TestDataFactory`, `TestOrder_All_False/True`, `BenchmarkTestDataProvider*`) |
|
||||
| `AyCode.Core.Serializers.SourceGenerator` (Analyzer-only) | SGen for `[AcBinarySerializable]`-tagged types |
|
||||
|
|
@ -0,0 +1,158 @@
|
|||
using BenchmarkDotNet.Attributes;
|
||||
using System.Runtime.CompilerServices;
|
||||
using System.Runtime.InteropServices;
|
||||
using Microsoft.VSDiagnostics;
|
||||
|
||||
namespace AyCode.Benchmark;
|
||||
[CPUUsageDiagnoser]
|
||||
public class RefForeachBenchmark
|
||||
{
|
||||
// Simulates BinaryPropertyAccessor (large struct ~80 bytes)
|
||||
public struct PropertyAccessor
|
||||
{
|
||||
public int PropertyIndex;
|
||||
public TypeCode PropertyTypeCode;
|
||||
public int AccessorType;
|
||||
public long Field1, Field2, Field3, Field4, Field5, Field6, Field7, Field8;
|
||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||
public readonly int GetValue() => PropertyIndex + (int)PropertyTypeCode + AccessorType;
|
||||
}
|
||||
|
||||
private PropertyAccessor[] _properties = null !;
|
||||
private List<PropertyAccessor> _propertiesList = null !;
|
||||
[GlobalSetup]
|
||||
public void Setup()
|
||||
{
|
||||
_properties = new PropertyAccessor[20]; // Typical property count
|
||||
_propertiesList = new List<PropertyAccessor>(20);
|
||||
for (int i = 0; i < 20; i++)
|
||||
{
|
||||
var prop = new PropertyAccessor
|
||||
{
|
||||
PropertyIndex = i,
|
||||
PropertyTypeCode = TypeCode.Int32,
|
||||
AccessorType = i % 5,
|
||||
Field1 = i,
|
||||
Field2 = i,
|
||||
Field3 = i,
|
||||
Field4 = i,
|
||||
Field5 = i,
|
||||
Field6 = i,
|
||||
Field7 = i,
|
||||
Field8 = i
|
||||
};
|
||||
_properties[i] = prop;
|
||||
_propertiesList.Add(prop);
|
||||
}
|
||||
}
|
||||
|
||||
// ============ ARRAY ITERATION ============
|
||||
[Benchmark(Baseline = true)]
|
||||
public int Array_ForEach_ByValue()
|
||||
{
|
||||
int total = 0;
|
||||
for (int iter = 0; iter < 1000; iter++)
|
||||
{
|
||||
foreach (var prop in _properties)
|
||||
{
|
||||
total += prop.GetValue();
|
||||
}
|
||||
}
|
||||
|
||||
return total;
|
||||
}
|
||||
|
||||
[Benchmark]
|
||||
public int Array_ForEach_RefReadonly()
|
||||
{
|
||||
int total = 0;
|
||||
for (int iter = 0; iter < 1000; iter++)
|
||||
{
|
||||
foreach (ref readonly var prop in _properties.AsSpan())
|
||||
{
|
||||
total += prop.GetValue();
|
||||
}
|
||||
}
|
||||
|
||||
return total;
|
||||
}
|
||||
|
||||
[Benchmark]
|
||||
public int Array_ForLoop_Index()
|
||||
{
|
||||
int total = 0;
|
||||
var props = _properties;
|
||||
for (int iter = 0; iter < 1000; iter++)
|
||||
{
|
||||
for (int i = 0; i < props.Length; i++)
|
||||
{
|
||||
total += props[i].GetValue();
|
||||
}
|
||||
}
|
||||
|
||||
return total;
|
||||
}
|
||||
|
||||
[Benchmark]
|
||||
public int Array_ForLoop_Span()
|
||||
{
|
||||
int total = 0;
|
||||
for (int iter = 0; iter < 1000; iter++)
|
||||
{
|
||||
var span = _properties.AsSpan();
|
||||
for (int i = 0; i < span.Length; i++)
|
||||
{
|
||||
total += span[i].GetValue();
|
||||
}
|
||||
}
|
||||
|
||||
return total;
|
||||
}
|
||||
|
||||
// ============ LIST ITERATION ============
|
||||
[Benchmark]
|
||||
public int List_ForEach_ByValue()
|
||||
{
|
||||
int total = 0;
|
||||
for (int iter = 0; iter < 1000; iter++)
|
||||
{
|
||||
foreach (var prop in _propertiesList)
|
||||
{
|
||||
total += prop.GetValue();
|
||||
}
|
||||
}
|
||||
|
||||
return total;
|
||||
}
|
||||
|
||||
[Benchmark]
|
||||
public int List_CollectionsMarshal_RefReadonly()
|
||||
{
|
||||
int total = 0;
|
||||
for (int iter = 0; iter < 1000; iter++)
|
||||
{
|
||||
foreach (ref readonly var prop in CollectionsMarshal.AsSpan(_propertiesList))
|
||||
{
|
||||
total += prop.GetValue();
|
||||
}
|
||||
}
|
||||
|
||||
return total;
|
||||
}
|
||||
|
||||
[Benchmark]
|
||||
public int List_ForLoop_Index()
|
||||
{
|
||||
int total = 0;
|
||||
var props = _propertiesList;
|
||||
for (int iter = 0; iter < 1000; iter++)
|
||||
{
|
||||
for (int i = 0; i < props.Count; i++)
|
||||
{
|
||||
total += props[i].GetValue();
|
||||
}
|
||||
}
|
||||
|
||||
return total;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,741 @@
|
|||
using AyCode.Core.Benchmarks.Workloads.Scenarios;
|
||||
using AyCode.Core.Serializers.Binaries;
|
||||
using AyCode.Core.Tests.TestModels;
|
||||
using System.Globalization;
|
||||
using System.Runtime.CompilerServices;
|
||||
using System.Text;
|
||||
|
||||
namespace AyCode.Core.Benchmarks.Reporting;
|
||||
|
||||
/// <summary>
|
||||
/// Output formatters for the benchmark suite: per-row console table, .log file, .LLM markdown file, .output
|
||||
/// binary hex dump. Consumes <see cref="BenchmarkResult"/> rows produced by the benchmark execution loop
|
||||
/// (Console-side <c>BenchmarkLoop</c> or BDN-side <c>BdnSummaryAdapter</c>) and emits human-readable +
|
||||
/// LLM-friendly outputs.
|
||||
///
|
||||
/// <para>The <see cref="ReportingContext"/> parameter encapsulates per-run state — <see cref="ReportingContext.SourceTag"/>
|
||||
/// drives the filename prefix ("Console" / "Bdn"), <see cref="ReportingContext.ResultsDirectory"/> is the
|
||||
/// resolved (walk-up-to-.sln) output folder, and the remaining fields (charset, iter counts, target sample
|
||||
/// window, CV threshold) carry the run-header info embedded in every emitted artifact.</para>
|
||||
/// </summary>
|
||||
public static class BenchmarkReportWriter
|
||||
{
|
||||
/// <summary>
|
||||
/// Per-cell-paired aggregation of an overall comparison. Captures three different aggregation
|
||||
/// strategies so the reader can judge whether the headline delta is dominated by one large cell
|
||||
/// (arithmetic mean) or representative of typical workload (geometric mean / median).
|
||||
/// </summary>
|
||||
/// <param name="ArithMeanPct">Arithmetic mean of µs/op — magnitude-weighted; biased toward Large cell.</param>
|
||||
/// <param name="GeoMeanPct">Geometric mean of per-cell ratios — magnitude-neutral; each cell weighted equally.</param>
|
||||
/// <param name="MedianPct">Median of per-cell ratios — outlier-resistant.</param>
|
||||
/// <param name="AcAvg">Arithmetic mean AcBinary value (µs/op or bytes).</param>
|
||||
/// <param name="MpAvg">Arithmetic mean MemPack value.</param>
|
||||
/// <param name="CellCount">Number of paired cells contributing to the geo/median.</param>
|
||||
public record OverallStats(double ArithMeanPct, double GeoMeanPct, double MedianPct, double AcAvg, double MpAvg, int CellCount);
|
||||
|
||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||
public static double ToPerOpMicros(double totalMs, int iterations) => iterations > 0 ? totalMs / iterations * 1000.0 : 0;
|
||||
|
||||
// Per-row per-op µs accessors — pull batch-time + iter from BenchmarkResult and convert. Used wherever
|
||||
// averaging or comparison happens across rows with potentially different iter counts (Winners summary,
|
||||
// Overall comparison, per-cell summary row). Keeping these as methods rather than properties on
|
||||
// BenchmarkResult preserves the result-as-data-bag distinction.
|
||||
public static double SerPerOp(BenchmarkResult r) => ToPerOpMicros(r.SerializeTimeMs, r.SerializeIterations);
|
||||
public static double DesPerOp(BenchmarkResult r) => ToPerOpMicros(r.DeserializeTimeMs, r.DeserializeIterations);
|
||||
public static double RtPerOp(BenchmarkResult r) => ToPerOpMicros(r.RoundTripTimeMs, r.RoundTripIterations);
|
||||
|
||||
/// <summary>
|
||||
/// Converts a byte count to KB (1 KB = 1024 B). Display-only helper so allocation columns can
|
||||
/// render compact F2 KB values (e.g. <c>4.05 KB</c> instead of <c>4,144 B</c>) — header carries
|
||||
/// the unit so per-row entries stay numbers-only. CSV / raw-data outputs keep the precise byte
|
||||
/// integers untouched.
|
||||
/// </summary>
|
||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||
public static double ToKilobytes(long bytes) => bytes / 1024.0;
|
||||
|
||||
/// <summary>
|
||||
/// Computes arithmetic + geometric + median aggregation of an AcBinary-vs-MemPack comparison
|
||||
/// across paired cells (joined by <c>TestDataName</c>). Per-cell pairing is required for the
|
||||
/// geo/median variants — a cell where AcBinary or MemPack is missing is dropped from all stats.
|
||||
/// Returns null when no paired cell has a valid value.
|
||||
/// </summary>
|
||||
public static OverallStats? ComputeOverallStats(List<BenchmarkResult> acResults, List<BenchmarkResult> mpResults, Func<BenchmarkResult, double> getValue)
|
||||
{
|
||||
if (acResults.Count == 0 || mpResults.Count == 0) return null;
|
||||
|
||||
var pairs = (from ac in acResults
|
||||
join mp in mpResults on ac.TestDataName equals mp.TestDataName
|
||||
let acV = getValue(ac)
|
||||
let mpV = getValue(mp)
|
||||
where acV > 0 && mpV > 0
|
||||
select (ac: acV, mp: mpV)).ToList();
|
||||
|
||||
if (pairs.Count == 0) return null;
|
||||
|
||||
var acAvg = pairs.Average(p => p.ac);
|
||||
var mpAvg = pairs.Average(p => p.mp);
|
||||
var ratios = pairs.Select(p => p.ac / p.mp).ToList();
|
||||
|
||||
// Geometric mean: exp(avg(ln(ratios))) — numerically stable vs Π ratios then ^(1/N).
|
||||
var geoMean = Math.Exp(ratios.Sum(Math.Log) / ratios.Count);
|
||||
|
||||
// Median (paired-ratio): for even N use the midpoint of the two middle values.
|
||||
var sorted = ratios.OrderBy(r => r).ToList();
|
||||
var median = sorted.Count % 2 == 1
|
||||
? sorted[sorted.Count / 2]
|
||||
: (sorted[sorted.Count / 2 - 1] + sorted[sorted.Count / 2]) / 2.0;
|
||||
|
||||
return new OverallStats(
|
||||
ArithMeanPct: (acAvg / mpAvg - 1) * 100,
|
||||
GeoMeanPct: (geoMean - 1) * 100,
|
||||
MedianPct: (median - 1) * 100,
|
||||
AcAvg: acAvg,
|
||||
MpAvg: mpAvg,
|
||||
CellCount: ratios.Count);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Formats a per-op micros value with its inter-sample range and CV-threshold marker as
|
||||
/// <c>"26.86 (24.5..29.1)"</c> or <c>"26.86 (24.5..29.1) ⚠️5.2%"</c>. Median first, range in parentheses,
|
||||
/// CV warning suffix only when CV > <paramref name="unstableCvThreshold"/>. When min == max == median
|
||||
/// (single-sample / Debug / quick mode), collapses to bare median to avoid visual clutter.
|
||||
/// All time inputs are total-batch milliseconds; <paramref name="iterations"/> is the per-row iter
|
||||
/// count (post-adaptive-calibration).
|
||||
/// </summary>
|
||||
public static string FormatMicrosWithRange(double medianMs, double minMs, double maxMs, double stdDevMs, int iterations, CultureInfo inv, double unstableCvThreshold, double microOptCvThreshold = 0.0)
|
||||
{
|
||||
var med = ToPerOpMicros(medianMs, iterations);
|
||||
// No range data (single-sample fast path) — surface as bare median, identical to the prior format.
|
||||
if (minMs <= 0 && maxMs <= 0) return med.ToString("F2", inv);
|
||||
if (minMs >= medianMs && maxMs <= medianMs) return med.ToString("F2", inv);
|
||||
|
||||
var min = ToPerOpMicros(minMs, iterations);
|
||||
var max = ToPerOpMicros(maxMs, iterations);
|
||||
var range = $"{med.ToString("F2", inv)} ({min.ToString("F2", inv)}..{max.ToString("F2", inv)})";
|
||||
|
||||
// CV (coefficient of variation = stddev / mean) — two-band flagging:
|
||||
// ⚠️ X.X% : above the unstable threshold (e.g. 3%) — sub-threshold inter-engine
|
||||
// deltas on this row are essentially noise; entirely dismissable.
|
||||
// ⚠️micro X.X% : above the micro-opt threshold (e.g. 1.5%) but below unstable — not
|
||||
// noise but sub-2% deltas are at the edge of reliability; cross-check
|
||||
// with re-run or BDN before declaring a regression / improvement.
|
||||
// microOptCvThreshold = 0 disables the soft-flag band (backward-compat for callers that
|
||||
// only want the original unstable-only behaviour).
|
||||
if (medianMs > 0 && stdDevMs > 0)
|
||||
{
|
||||
var cv = stdDevMs / medianMs;
|
||||
var cvPct = (cv * 100).ToString("F1", inv);
|
||||
if (cv > unstableCvThreshold) return $"{range} ⚠️{cvPct}%";
|
||||
if (microOptCvThreshold > 0 && cv > microOptCvThreshold) return $"{range} ⚠️micro {cvPct}%";
|
||||
}
|
||||
|
||||
return range;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Formats a signed percent delta with explicit sign for positive values (`+1.5%`, `-3.0%`, `0.0%`).
|
||||
/// Padded to 7 chars (e.g. ` +12.3%`, `-100.0%`) for column alignment in the Overall block.
|
||||
/// </summary>
|
||||
public static string FormatPctSigned(double pct) => pct.ToString("+0.0;-0.0;0.0", CultureInfo.InvariantCulture).PadLeft(6) + "%";
|
||||
|
||||
/// <summary>
|
||||
/// Renders one Overall row with arith / geo / median deltas + AcBinary/MemPack absolute means.
|
||||
/// Color is driven by the geometric-mean delta (magnitude-neutral signal). Skips silently when
|
||||
/// stats is null (no paired data).
|
||||
/// </summary>
|
||||
public static void WriteOverallLine(string label, string unit, OverallStats? stats, string fmt = "F2")
|
||||
{
|
||||
if (stats == null) return;
|
||||
|
||||
// Color follows geo-mean (the magnitude-neutral signal). The arith-mean column may show a
|
||||
// different sign when a single big cell dominates — that's exactly the signal we want to surface.
|
||||
System.Console.ForegroundColor = stats.GeoMeanPct <= 0 ? ConsoleColor.Green : ConsoleColor.Red;
|
||||
System.Console.WriteLine($" {label,-12} arith {FormatPctSigned(stats.ArithMeanPct)} │ geo {FormatPctSigned(stats.GeoMeanPct)} │ median {FormatPctSigned(stats.MedianPct)} ({stats.AcAvg.ToString(fmt, CultureInfo.InvariantCulture)} {unit} vs {stats.MpAvg.ToString(fmt, CultureInfo.InvariantCulture)} {unit}, {stats.CellCount} cells)");
|
||||
System.Console.ResetColor();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Same as <see cref="WriteOverallLine"/> but appends to a <see cref="StringBuilder"/> (no color).
|
||||
/// Used by the .log and .LLM file writers.
|
||||
/// </summary>
|
||||
public static void AppendOverallLine(StringBuilder sb, string label, string unit, OverallStats? stats, string fmt = "F2")
|
||||
{
|
||||
if (stats == null) return;
|
||||
sb.AppendLine($" {label,-12} arith {FormatPctSigned(stats.ArithMeanPct)} | geo {FormatPctSigned(stats.GeoMeanPct)} | median {FormatPctSigned(stats.MedianPct)} ({stats.AcAvg.ToString(fmt, CultureInfo.InvariantCulture)} {unit} vs {stats.MpAvg.ToString(fmt, CultureInfo.InvariantCulture)} {unit}, {stats.CellCount} cells)");
|
||||
}
|
||||
|
||||
public static void PrintResult(BenchmarkResult result)
|
||||
{
|
||||
// Numbers-only per-row entries; the column-headers carry units (µs/op, KB/op).
|
||||
var ser = result.SerializeTimeMs > 0 ? $"{SerPerOp(result),7:F2}" : " N/A";
|
||||
var des = result.DeserializeTimeMs > 0 ? $"{DesPerOp(result),7:F2}" : " N/A";
|
||||
var serAlloc = result.SerializeTimeMs > 0 ? $"{ToKilobytes(result.SerializeAllocBytesPerOp),7:F2}" : " N/A";
|
||||
var desAlloc = result.DeserializeTimeMs > 0 ? $"{ToKilobytes(result.DeserializeAllocBytesPerOp),7:F2}" : " N/A";
|
||||
System.Console.WriteLine($" {result.SerializerName,-40} | Size: {result.SerializedSize,8:N0} B | Ser: {ser} µs/op ({serAlloc} KB/op) | Des: {des} µs/op ({desAlloc} KB/op)");
|
||||
}
|
||||
|
||||
public static void PrintGroupedResults(List<BenchmarkResult> results, List<TestDataSet> testDataSets)
|
||||
{
|
||||
System.Console.WriteLine("\n");
|
||||
System.Console.WriteLine("╔══════════════════════════════════════════════════════════════════════════════════════════════════════╗");
|
||||
System.Console.WriteLine("║ GROUPED RESULTS BY TEST DATA ║");
|
||||
System.Console.WriteLine("╚══════════════════════════════════════════════════════════════════════════════════════════════════════╝");
|
||||
|
||||
// Print serializer options. [OrderType] suffix shows which TestOrder variant each preset serialised.
|
||||
var optionsMap = results
|
||||
.Where(r => r.OptionsDescription != null)
|
||||
.Select(r => (r.SerializerName, r.OrderTypeName, r.OptionsDescription!))
|
||||
.Distinct()
|
||||
.ToList();
|
||||
|
||||
if (optionsMap.Count > 0)
|
||||
{
|
||||
System.Console.WriteLine();
|
||||
System.Console.WriteLine(" Serializer Options:");
|
||||
foreach (var (name, orderType, opts) in optionsMap)
|
||||
System.Console.WriteLine($" {name} [{orderType}]: {opts}");
|
||||
}
|
||||
|
||||
foreach (var testData in testDataSets)
|
||||
{
|
||||
// Order by Engine (so the same engine column-position stays stable across cells, especially
|
||||
// when two engines are within noise floor on a given cell — flip-flopping speed-rank produces
|
||||
// diff-hostile output across runs). RtPerOp is the secondary tiebreaker for cells where
|
||||
// multiple variants of the same engine exist (e.g. AcBinary SGen vs Runtime).
|
||||
var testResults = results.Where(r => r.TestDataName == testData.DisplayName).OrderBy(r => r.Engine).ThenBy(r => RtPerOp(r)).ToList();
|
||||
// Baseline switched MessagePack → MemoryPack: MemoryPack is the SOTA performance leader.
|
||||
var memPackResult = testResults.FirstOrDefault(r => (r.Engine == BenchmarkEngine.MemoryPack && r.IoMode == BenchmarkIoMode.ByteArray));
|
||||
// Pin the comparison to AcBinary's SGen variant — apples-to-apples vs MemoryPack (also source-generated).
|
||||
// The Runtime variant is shown alongside in the table for context, not used as the headline number.
|
||||
var acBinaryResult = testResults.FirstOrDefault(r => (r.Engine == BenchmarkEngine.AcBinary && r.IoMode == BenchmarkIoMode.ByteArray && r.DispatchMode == BenchmarkDispatchMode.SGen));
|
||||
|
||||
System.Console.WriteLine($"\n┌─ {testData.DisplayName} ─".PadRight(172, '─') + "┐");
|
||||
// Header-only units; per-row entries are numbers (µs/op for time, KB/op for alloc, KB pair "ser / des" for Setup, B for Size).
|
||||
System.Console.WriteLine($"│ {"#",-4} │ {"Engine",-11} │ {"Options",-22} │ {"IO",-12} │ {"Mode",-8} │ {"Setup S/D KB",-14} │ {"Size B",-8} │ {"Ser µs/op",-10} │ {"SerAlc KB",-10} │ {"Des µs/op",-10} │ {"DesAlc KB",-10} │ {"RT µs/op",-10} │ {"RTAlc KB",-10} │");
|
||||
System.Console.WriteLine($"├{"─".PadRight(6, '─')}┼{"─".PadRight(13, '─')}┼{"─".PadRight(24, '─')}┼{"─".PadRight(14, '─')}┼{"─".PadRight(10, '─')}┼{"─".PadRight(16, '─')}┼{"─".PadRight(10, '─')}┼{"─".PadRight(12, '─')}┼{"─".PadRight(12, '─')}┼{"─".PadRight(12, '─')}┼{"─".PadRight(12, '─')}┼{"─".PadRight(12, '─')}┼{"─".PadRight(12, '─')}┤");
|
||||
|
||||
var rank = 1;
|
||||
foreach (var result in testResults)
|
||||
{
|
||||
var size = $"{result.SerializedSize:N0}";
|
||||
var setup = $"{ToKilobytes(result.SetupSerializeAllocBytes):F2} / {ToKilobytes(result.SetupDeserializeAllocBytes):F2}";
|
||||
var ser = result.SerializeTimeMs > 0 ? $"{SerPerOp(result):F2}" : "N/A";
|
||||
var des = result.DeserializeTimeMs > 0 ? $"{DesPerOp(result):F2}" : "N/A";
|
||||
var rt = result.RoundTripTimeMs > 0 ? $"{RtPerOp(result):F2}" : "N/A";
|
||||
var serAlloc = result.SerializeTimeMs > 0 ? $"{ToKilobytes(result.SerializeAllocBytesPerOp):F2}" : "N/A";
|
||||
var desAlloc = result.DeserializeTimeMs > 0 ? $"{ToKilobytes(result.DeserializeAllocBytesPerOp):F2}" : "N/A";
|
||||
var rtAlloc = result.RoundTripAllocBytesPerOp > 0 ? $"{ToKilobytes(result.RoundTripAllocBytesPerOp):F2}" : "N/A";
|
||||
|
||||
// Highlight MemoryPack baseline (any Byte[]) and AcBinary headline contender (Byte[] + SGen) with win/lose colors.
|
||||
// The AcBinary Byte[]+Runtime variant is shown unhighlighted — it's contextual (SGen speed-up reference), not the headline.
|
||||
var isHighlighted = (result.Engine == BenchmarkEngine.MemoryPack && result.IoMode == BenchmarkIoMode.ByteArray)
|
||||
|| (result.Engine == BenchmarkEngine.AcBinary && result.IoMode == BenchmarkIoMode.ByteArray && result.DispatchMode == BenchmarkDispatchMode.SGen);
|
||||
|
||||
var prefix = isHighlighted ? "│►" : "│ ";
|
||||
var suffix = isHighlighted ? "◄│" : " │";
|
||||
|
||||
// Color logic: Green = winner (faster), Red = loser (slower)
|
||||
if (isHighlighted && memPackResult != null && acBinaryResult != null)
|
||||
{
|
||||
var isMemPack = (result.Engine == BenchmarkEngine.MemoryPack && result.IoMode == BenchmarkIoMode.ByteArray);
|
||||
var memPackFaster = RtPerOp(memPackResult) < RtPerOp(acBinaryResult);
|
||||
|
||||
if (isMemPack)
|
||||
{
|
||||
System.Console.ForegroundColor = memPackFaster ? ConsoleColor.Green : ConsoleColor.Red;
|
||||
}
|
||||
else
|
||||
{
|
||||
System.Console.ForegroundColor = memPackFaster ? ConsoleColor.Red : ConsoleColor.Green;
|
||||
}
|
||||
}
|
||||
|
||||
System.Console.WriteLine($"{prefix}{rank++,4} │ {result.Engine.ToDisplay(),-11} │ {result.OptionsPreset,-22} │ {result.IoMode.ToDisplay(),-12} │ {result.DispatchMode.ToDisplay(),-8} │ {setup,14} │ {size,8} │ {ser,10} │ {serAlloc,10} │ {des,10} │ {desAlloc,10} │ {rt,10} │ {rtAlloc,10}{suffix}");
|
||||
|
||||
if (isHighlighted)
|
||||
{
|
||||
System.Console.ResetColor();
|
||||
}
|
||||
}
|
||||
|
||||
// Footer row: AcBinary (Byte[]) vs MemoryPack (Byte[]) comparison per column
|
||||
if (memPackResult != null && acBinaryResult != null)
|
||||
{
|
||||
var sizePct = (acBinaryResult.SerializedSize / (double)memPackResult.SerializedSize - 1) * 100;
|
||||
// Per-op µs ratio (iter-independent) — Ser/Des may have different iter counts on the two rows.
|
||||
var serPct = SerPerOp(memPackResult) > 0 ? (SerPerOp(acBinaryResult) / SerPerOp(memPackResult) - 1) * 100 : 0;
|
||||
var desPct = DesPerOp(memPackResult) > 0 ? (DesPerOp(acBinaryResult) / DesPerOp(memPackResult) - 1) * 100 : 0;
|
||||
var rtPct = RtPerOp(memPackResult) > 0 ? (RtPerOp(acBinaryResult) / RtPerOp(memPackResult) - 1) * 100 : 0;
|
||||
|
||||
var serAllocPct = memPackResult.SerializeAllocBytesPerOp > 0 ? (acBinaryResult.SerializeAllocBytesPerOp / (double)memPackResult.SerializeAllocBytesPerOp - 1) * 100 : 0;
|
||||
var desAllocPct = memPackResult.DeserializeAllocBytesPerOp > 0 ? (acBinaryResult.DeserializeAllocBytesPerOp / (double)memPackResult.DeserializeAllocBytesPerOp - 1) * 100 : 0;
|
||||
var rtAllocPct = memPackResult.RoundTripAllocBytesPerOp > 0 ? (acBinaryResult.RoundTripAllocBytesPerOp / (double)memPackResult.RoundTripAllocBytesPerOp - 1) * 100 : 0;
|
||||
|
||||
// Footer separator: merge first 5 cols (#, Engine, Options, IO, Mode) → comparison label;
|
||||
// remaining 8 cols stay aligned (Setup S/D KB, Size, Ser µs/op, SerAlc KB, Des µs/op, DesAlc KB, RT µs/op, RTAlc KB).
|
||||
System.Console.WriteLine($"├{"─".PadRight(6, '─')}┴{"─".PadRight(13, '─')}┴{"─".PadRight(24, '─')}┴{"─".PadRight(14, '─')}┴{"─".PadRight(10, '─')}┼{"─".PadRight(16, '─')}┼{"─".PadRight(10, '─')}┼{"─".PadRight(12, '─')}┼{"─".PadRight(12, '─')}┼{"─".PadRight(12, '─')}┼{"─".PadRight(12, '─')}┼{"─".PadRight(12, '─')}┼{"─".PadRight(12, '─')}┤");
|
||||
// Merged label cell width = 4 + 11 + 22 + 12 + 8 + 4*3 (dropped separators) = 69
|
||||
System.Console.Write($"│ {"► AcBinary (Byte[]) vs MemoryPack (Byte[])",-69} │ ");
|
||||
|
||||
// Setup S/D KB (n/a for Byte[] vs Byte[] — neither pre-allocates)
|
||||
System.Console.Write($"{"—",14}");
|
||||
System.Console.Write(" │ ");
|
||||
|
||||
// Size
|
||||
System.Console.ForegroundColor = sizePct <= 0 ? ConsoleColor.Green : ConsoleColor.Red;
|
||||
System.Console.Write($"{sizePct,+7:+0;-0}%");
|
||||
System.Console.ResetColor();
|
||||
System.Console.Write(" │ ");
|
||||
|
||||
// Serialize
|
||||
System.Console.ForegroundColor = serPct <= 0 ? ConsoleColor.Green : ConsoleColor.Red;
|
||||
System.Console.Write($"{serPct,+9:+0;-0}%");
|
||||
System.Console.ResetColor();
|
||||
System.Console.Write(" │ ");
|
||||
|
||||
// Serialize Alloc
|
||||
System.Console.ForegroundColor = serAllocPct <= 0 ? ConsoleColor.Green : ConsoleColor.Red;
|
||||
System.Console.Write($"{serAllocPct,+9:+0;-0}%");
|
||||
System.Console.ResetColor();
|
||||
System.Console.Write(" │ ");
|
||||
|
||||
// Deserialize
|
||||
System.Console.ForegroundColor = desPct <= 0 ? ConsoleColor.Green : ConsoleColor.Red;
|
||||
System.Console.Write($"{desPct,+9:+0;-0}%");
|
||||
System.Console.ResetColor();
|
||||
System.Console.Write(" │ ");
|
||||
|
||||
// Deserialize Alloc
|
||||
System.Console.ForegroundColor = desAllocPct <= 0 ? ConsoleColor.Green : ConsoleColor.Red;
|
||||
System.Console.Write($"{desAllocPct,+9:+0;-0}%");
|
||||
System.Console.ResetColor();
|
||||
System.Console.Write(" │ ");
|
||||
|
||||
// Round-trip
|
||||
System.Console.ForegroundColor = rtPct <= 0 ? ConsoleColor.Green : ConsoleColor.Red;
|
||||
System.Console.Write($"{rtPct,+9:+0;-0}%");
|
||||
System.Console.ResetColor();
|
||||
System.Console.Write(" │ ");
|
||||
|
||||
// Round-trip Alloc
|
||||
System.Console.ForegroundColor = rtAllocPct <= 0 ? ConsoleColor.Green : ConsoleColor.Red;
|
||||
System.Console.Write($"{rtAllocPct,+9:+0;-0}%");
|
||||
System.Console.ResetColor();
|
||||
System.Console.WriteLine(" │");
|
||||
}
|
||||
|
||||
// Closing line: merged on left (─ between cols 1-5), ┴ on the right (cols 6-13 boundary, 8 unmerged cells).
|
||||
System.Console.WriteLine($"└{"─".PadRight(6, '─')}─{"─".PadRight(13, '─')}─{"─".PadRight(24, '─')}─{"─".PadRight(14, '─')}─{"─".PadRight(10, '─')}┴{"─".PadRight(16, '─')}┴{"─".PadRight(10, '─')}┴{"─".PadRight(12, '─')}┴{"─".PadRight(12, '─')}┴{"─".PadRight(12, '─')}┴{"─".PadRight(12, '─')}┴{"─".PadRight(12, '─')}┴{"─".PadRight(12, '─')}┘");
|
||||
}
|
||||
|
||||
// Summary: Best serializer for each category
|
||||
System.Console.WriteLine("\n");
|
||||
System.Console.WriteLine("╔══════════════════════════════════════════════════════════════════════════════════════════════════════╗");
|
||||
System.Console.WriteLine("║ SUMMARY: WINNERS ║");
|
||||
System.Console.WriteLine("╚══════════════════════════════════════════════════════════════════════════════════════════════════════╝");
|
||||
|
||||
System.Console.WriteLine($"\n{"Category",-20} │ {"Winner",-40} │ {"Avg Value",-18}");
|
||||
System.Console.WriteLine($"{"─".PadRight(20, '─')}─┼─{"─".PadRight(40, '─')}─┼─{"─".PadRight(18, '─')}");
|
||||
|
||||
// Fastest Serialize — round-trip-only serializers (NamedPipe etc.) excluded:
|
||||
// their Serialize() captures the full round-trip and isn't comparable to a pure Ser metric.
|
||||
var fastestSer = results.Where(r => r.SerializeTimeMs > 0 && !r.IsRoundTripOnly)
|
||||
.GroupBy(r => r.SerializerName)
|
||||
.Select(g => new { Name = g.Key, AvgPerOp = g.Average(r => SerPerOp(r)) })
|
||||
.OrderBy(x => x.AvgPerOp)
|
||||
.FirstOrDefault();
|
||||
|
||||
if (fastestSer != null)
|
||||
System.Console.WriteLine($"{"Fastest Serialize",-20} │ {fastestSer.Name,-40} │ {fastestSer.AvgPerOp,12:F2} µs/op");
|
||||
|
||||
// Fastest Deserialize — round-trip-only serializers excluded (their Deserialize() is a no-op).
|
||||
var fastestDes = results.Where(r => r.DeserializeTimeMs > 0 && !r.IsRoundTripOnly)
|
||||
.GroupBy(r => r.SerializerName)
|
||||
.Select(g => new { Name = g.Key, AvgPerOp = g.Average(r => DesPerOp(r)) })
|
||||
.OrderBy(x => x.AvgPerOp)
|
||||
.FirstOrDefault();
|
||||
|
||||
if (fastestDes != null)
|
||||
System.Console.WriteLine($"{"Fastest Deserialize",-20} │ {fastestDes.Name,-40} │ {fastestDes.AvgPerOp,12:F2} µs/op");
|
||||
|
||||
// Smallest Size
|
||||
var smallestSize = results
|
||||
.GroupBy(r => r.SerializerName)
|
||||
.Select(g => new { Name = g.Key, AvgSize = g.Average(r => r.SerializedSize) })
|
||||
.OrderBy(x => x.AvgSize)
|
||||
.FirstOrDefault();
|
||||
|
||||
if (smallestSize != null)
|
||||
System.Console.WriteLine($"{"Smallest Size",-20} │ {smallestSize.Name,-40} │ {smallestSize.AvgSize,15:F0} B");
|
||||
|
||||
// Fastest Round-trip — iter-independent per-op average.
|
||||
var fastestRt = results.Where(r => r.RoundTripTimeMs > 0)
|
||||
.GroupBy(r => r.SerializerName)
|
||||
.Select(g => new { Name = g.Key, AvgPerOp = g.Average(r => RtPerOp(r)) })
|
||||
.OrderBy(x => x.AvgPerOp)
|
||||
.FirstOrDefault();
|
||||
|
||||
if (fastestRt != null)
|
||||
System.Console.WriteLine($"{"Fastest Round-trip",-20} │ {fastestRt.Name,-40} │ {fastestRt.AvgPerOp,12:F2} µs/op");
|
||||
|
||||
// Overall AcBinary (SGen) vs MemoryPack comparison.
|
||||
var memPackSerResults = results.Where(r => (r.Engine == BenchmarkEngine.MemoryPack && r.IoMode == BenchmarkIoMode.ByteArray) && r.SerializeTimeMs > 0).ToList();
|
||||
var memPackDesResults = results.Where(r => (r.Engine == BenchmarkEngine.MemoryPack && r.IoMode == BenchmarkIoMode.ByteArray) && r.DeserializeTimeMs > 0).ToList();
|
||||
var memPackRtResults = results.Where(r => (r.Engine == BenchmarkEngine.MemoryPack && r.IoMode == BenchmarkIoMode.ByteArray) && r.RoundTripTimeMs > 0).ToList();
|
||||
|
||||
var acBinarySerResults = results.Where(r => (r.Engine == BenchmarkEngine.AcBinary && r.IoMode == BenchmarkIoMode.ByteArray && r.DispatchMode == BenchmarkDispatchMode.SGen) && r.SerializeTimeMs > 0).ToList();
|
||||
var acBinaryDesResults = results.Where(r => (r.Engine == BenchmarkEngine.AcBinary && r.IoMode == BenchmarkIoMode.ByteArray && r.DispatchMode == BenchmarkDispatchMode.SGen) && r.DeserializeTimeMs > 0).ToList();
|
||||
var acBinaryRtResults = results.Where(r => (r.Engine == BenchmarkEngine.AcBinary && r.IoMode == BenchmarkIoMode.ByteArray && r.DispatchMode == BenchmarkDispatchMode.SGen) && r.RoundTripTimeMs > 0).ToList();
|
||||
|
||||
// Skip comparison if no data available
|
||||
if (memPackRtResults.Count == 0 || acBinaryRtResults.Count == 0)
|
||||
{
|
||||
System.Console.WriteLine();
|
||||
System.Console.WriteLine("── AcBinary (Byte[], SGen) vs MemoryPack (Byte[]) (Overall) ──");
|
||||
System.Console.WriteLine(" (Comparison requires both serialize and deserialize data)");
|
||||
return;
|
||||
}
|
||||
|
||||
// All averages are over per-op µs (iter-independent). Three aggregations per metric.
|
||||
var sizeAcResults = results.Where(r => (r.Engine == BenchmarkEngine.AcBinary && r.IoMode == BenchmarkIoMode.ByteArray && r.DispatchMode == BenchmarkDispatchMode.SGen)).ToList();
|
||||
var sizeMpResults = results.Where(r => (r.Engine == BenchmarkEngine.MemoryPack && r.IoMode == BenchmarkIoMode.ByteArray)).ToList();
|
||||
|
||||
var serStats = ComputeOverallStats(acBinarySerResults, memPackSerResults, SerPerOp);
|
||||
var desStats = ComputeOverallStats(acBinaryDesResults, memPackDesResults, DesPerOp);
|
||||
var rtStats = ComputeOverallStats(acBinaryRtResults, memPackRtResults, RtPerOp);
|
||||
var sizeStats = ComputeOverallStats(sizeAcResults, sizeMpResults, r => r.SerializedSize);
|
||||
var serAllocStats = ComputeOverallStats(acBinarySerResults, memPackSerResults, r => r.SerializeAllocBytesPerOp);
|
||||
var desAllocStats = ComputeOverallStats(acBinaryDesResults, memPackDesResults, r => r.DeserializeAllocBytesPerOp);
|
||||
|
||||
System.Console.WriteLine();
|
||||
System.Console.WriteLine("── AcBinary (Byte[], SGen) vs MemoryPack (Byte[]) (Overall) ──");
|
||||
|
||||
WriteOverallLine("Serialize", "µs/op", serStats);
|
||||
WriteOverallLine("Deserialize", "µs/op", desStats);
|
||||
WriteOverallLine("Round-trip", "µs/op", rtStats);
|
||||
WriteOverallLine("Size", "B", sizeStats, "F0");
|
||||
WriteOverallLine("Ser Alloc", "B/op", serAllocStats, "F0");
|
||||
WriteOverallLine("Des Alloc", "B/op", desAllocStats, "F0");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Writes the unified file triplet — <c>{SourceTag}.FullBenchmark_{Build}_{timestamp}.{log, LLM, output}</c>
|
||||
/// — to <see cref="ReportingContext.ResultsDirectory"/>. The <c>.log</c> is the human-readable formatted
|
||||
/// view, the <c>.LLM</c> is the markdown LLM-paste-friendly view, and the <c>.output</c> is a binary hex
|
||||
/// dump of the Large test data's AcBinary-Default serialization (for raw inspection / wire-debugging).
|
||||
/// </summary>
|
||||
public static void SaveAll(ReportingContext ctx, List<BenchmarkResult> results, List<TestDataSet> testDataSets)
|
||||
{
|
||||
Directory.CreateDirectory(ctx.ResultsDirectory);
|
||||
|
||||
var timestamp = DateTime.Now.ToString("yyyy-MM-dd_HH-mm-ss");
|
||||
var baseFileName = $"{ctx.SourceTag}.FullBenchmark_{ctx.BuildConfiguration}_{timestamp}";
|
||||
var logFilePath = Path.Combine(ctx.ResultsDirectory, $"{baseFileName}.log");
|
||||
var outputFilePath = Path.Combine(ctx.ResultsDirectory, $"{baseFileName}.output");
|
||||
var llmFilePath = Path.Combine(ctx.ResultsDirectory, $"{baseFileName}.LLM");
|
||||
|
||||
// Save binary output to separate .output file.
|
||||
// Cast to TestDataSet<TestOrder_All_False> because Phase 1 hardcodes the benchmark variant.
|
||||
// Phase 2 will replace the cast with an options-driven dispatch (matching CreateSerializers).
|
||||
var largeTestData = testDataSets.FirstOrDefault(t => t.Name.StartsWith("Large")) as TestDataSet<TestOrder_All_False>;
|
||||
if (largeTestData != null)
|
||||
{
|
||||
var outputSb = new StringBuilder();
|
||||
outputSb.AppendLine("╔══════════════════════════════════════════════════════════════════════════════════════════════════════╗");
|
||||
outputSb.AppendLine("║ SERIALIZED BINARY OUTPUT ║");
|
||||
outputSb.AppendLine($"║ Generated: {DateTime.Now:yyyy-MM-dd HH:mm:ss}".PadRight(100) + "║");
|
||||
outputSb.AppendLine("╚══════════════════════════════════════════════════════════════════════════════════════════════════════╝");
|
||||
outputSb.AppendLine();
|
||||
|
||||
outputSb.AppendLine("=== SERIALIZED BYTES: Large (5x5x5x10) - AcBinary (Default) ===");
|
||||
var serializedBytes = AcBinarySerializer.Serialize(largeTestData.Order, AcBinarySerializerOptions.Default);
|
||||
outputSb.AppendLine($"Size: {serializedBytes.Length:N0} bytes");
|
||||
outputSb.AppendLine();
|
||||
outputSb.AppendLine("Hex dump:");
|
||||
outputSb.AppendLine(FormatHexDump(serializedBytes));
|
||||
|
||||
File.WriteAllText(outputFilePath, outputSb.ToString(), ctx.Utf8NoBom);
|
||||
System.Console.WriteLine($"✓ Binary output saved to: {outputFilePath}");
|
||||
}
|
||||
|
||||
// Save benchmark results to .log file
|
||||
var sb = new StringBuilder();
|
||||
sb.AppendLine("╔══════════════════════════════════════════════════════════════════════════════════════════════════════╗");
|
||||
sb.AppendLine("║ SERIALIZER BENCHMARK RESULTS ║");
|
||||
sb.AppendLine($"║ Generated: {DateTime.Now:yyyy-MM-dd HH:mm:ss}".PadRight(100) + "║");
|
||||
sb.AppendLine($"║ Source: {ctx.SourceTag}".PadRight(100) + "║");
|
||||
sb.AppendLine($"║ Build: {ctx.BuildConfiguration}".PadRight(100) + "║");
|
||||
sb.AppendLine($"║ Charset: {ctx.CharsetName}".PadRight(100) + "║");
|
||||
sb.AppendLine($"║ .NET: {System.Runtime.InteropServices.RuntimeInformation.FrameworkDescription} ({Environment.Version})".PadRight(100) + "║");
|
||||
// For BDN-sourced contexts, warmup / samples / target are managed inside BDN's job config (not by
|
||||
// our adaptive engine) — surfacing the placeholder zeros as concrete numbers would be misleading.
|
||||
// Print "BDN-managed" instead; raw BDN config is recoverable from the BDN-native artifacts under .../BDN/.
|
||||
var isBdn = ctx.SourceTag == "Bdn";
|
||||
var iterationsHeader = isBdn ? "Iterations: BDN-managed" : $"Iterations: per-cell adaptive (~{ctx.TargetSampleMs} ms target)";
|
||||
var samplesHeader = isBdn ? "Samples: BDN-managed" : $"Samples: {ctx.BenchmarkSamples} (median) + 1 pilot discarded";
|
||||
sb.AppendLine($"║ {iterationsHeader}".PadRight(100) + "║");
|
||||
sb.AppendLine($"║ {samplesHeader}".PadRight(100) + "║");
|
||||
sb.AppendLine("╚══════════════════════════════════════════════════════════════════════════════════════════════════════╝");
|
||||
sb.AppendLine();
|
||||
|
||||
// Serializer options summary. The bracketed [OrderType] suffix shows which TestOrder variant
|
||||
// graph each benchmark serialised — AcBinary picks variant per options preset
|
||||
// (FastMode → _All_False, Default → _All_True; see BenchmarkLoop.UsesAllFalseVariant),
|
||||
// MemPack / MsgPack always use _All_False. Distinct() de-dupes across cells (each preset
|
||||
// appears once even though it runs on every test data set).
|
||||
var optionsMap = results
|
||||
.Where(r => r.OptionsDescription != null)
|
||||
.Select(r => (r.SerializerName, r.OrderTypeName, r.OptionsDescription!))
|
||||
.Distinct()
|
||||
.ToList();
|
||||
if (optionsMap.Count > 0)
|
||||
{
|
||||
sb.AppendLine("=== SERIALIZER OPTIONS ===");
|
||||
foreach (var (name, orderType, opts) in optionsMap)
|
||||
sb.AppendLine($" {name} [{orderType}]: {opts}");
|
||||
sb.AppendLine();
|
||||
}
|
||||
|
||||
// CSV-like data for easy import — keeps raw byte integers (no KB rounding) so external tools can compute precisely.
|
||||
// InvariantCulture is mandatory here: the decimal-separator dimension MUST be `.` so the comma-separated
|
||||
// field delimiters don't collide with locale-specific decimal commas (e.g. Hungarian "7,38" would split
|
||||
// a single F2 value across two CSV fields).
|
||||
var inv = CultureInfo.InvariantCulture;
|
||||
sb.AppendLine("=== RAW DATA (CSV) ===");
|
||||
sb.AppendLine("TestData,Engine,IO,Mode,Options,Size,SerializeMicrosPerOp,DeserializeMicrosPerOp,RoundTripMicrosPerOp,SerializeAllocBytesPerOp,DeserializeAllocBytesPerOp,RoundTripAllocBytesPerOp,SetupSerializeAllocBytes,SetupDeserializeAllocBytes");
|
||||
|
||||
foreach (var testData in testDataSets)
|
||||
{
|
||||
var testResults = results.Where(r => r.TestDataName == testData.DisplayName).ToList();
|
||||
foreach (var result in testResults)
|
||||
{
|
||||
sb.AppendLine($"{result.TestDataName},{result.Engine.ToDisplay()},{result.IoMode.ToDisplay()},{result.DispatchMode.ToDisplay()},{result.OptionsPreset},{result.SerializedSize},{SerPerOp(result).ToString("F2", inv)},{DesPerOp(result).ToString("F2", inv)},{RtPerOp(result).ToString("F2", inv)},{result.SerializeAllocBytesPerOp},{result.DeserializeAllocBytesPerOp},{result.RoundTripAllocBytesPerOp},{result.SetupSerializeAllocBytes},{result.SetupDeserializeAllocBytes}");
|
||||
}
|
||||
}
|
||||
sb.AppendLine();
|
||||
|
||||
// Formatted results
|
||||
sb.AppendLine("=== FORMATTED RESULTS BY TEST DATA ===");
|
||||
sb.AppendLine("(►) = Highlighted: MemoryPack (Byte[]) (baseline) and AcBinary (Byte[])");
|
||||
sb.AppendLine();
|
||||
|
||||
foreach (var testData in testDataSets)
|
||||
{
|
||||
// Order by Engine (stable column-position across cells, see PrintGroupedResults for rationale);
|
||||
// RtPerOp is the secondary tiebreaker between same-engine variants (SGen vs Runtime).
|
||||
var testResults = results.Where(r => r.TestDataName == testData.DisplayName).OrderBy(r => r.Engine).ThenBy(r => RtPerOp(r)).ToList();
|
||||
var memPackResult = testResults.FirstOrDefault(r => (r.Engine == BenchmarkEngine.MemoryPack && r.IoMode == BenchmarkIoMode.ByteArray));
|
||||
var acBinaryResult = testResults.FirstOrDefault(r => (r.Engine == BenchmarkEngine.AcBinary && r.IoMode == BenchmarkIoMode.ByteArray && r.DispatchMode == BenchmarkDispatchMode.SGen));
|
||||
|
||||
sb.AppendLine();
|
||||
sb.AppendLine($"--- {testData.DisplayName} ---");
|
||||
sb.AppendLine($"{"#",-4} {"Serializer",-42} {"Size B",-12} {"Setup S/D KB",-14} {"Ser µs/op",-12} {"Des µs/op",-12} {"RT µs/op",-12} {"SerAlc KB",-11} {"DesAlc KB",-11}");
|
||||
sb.AppendLine(new string('-', 140));
|
||||
|
||||
var rank = 1;
|
||||
foreach (var result in testResults)
|
||||
{
|
||||
var isHighlighted = ((result.Engine == BenchmarkEngine.MemoryPack || result.Engine == BenchmarkEngine.AcBinary) && result.IoMode == BenchmarkIoMode.ByteArray);
|
||||
var prefix = isHighlighted ? "► " : " ";
|
||||
|
||||
var size = $"{result.SerializedSize:N0}";
|
||||
var setup = $"{ToKilobytes(result.SetupSerializeAllocBytes):F2} / {ToKilobytes(result.SetupDeserializeAllocBytes):F2}";
|
||||
var ser = result.SerializeTimeMs > 0 ? $"{SerPerOp(result):F2}" : "N/A";
|
||||
var des = result.DeserializeTimeMs > 0 ? $"{DesPerOp(result):F2}" : "N/A";
|
||||
var rt = result.RoundTripTimeMs > 0 ? $"{RtPerOp(result):F2}" : "N/A";
|
||||
var serAlloc = result.SerializeTimeMs > 0 ? $"{ToKilobytes(result.SerializeAllocBytesPerOp):F2}" : "N/A";
|
||||
var desAlloc = result.DeserializeTimeMs > 0 ? $"{ToKilobytes(result.DeserializeAllocBytesPerOp):F2}" : "N/A";
|
||||
|
||||
sb.AppendLine($"{rank++,2} {prefix}{result.SerializerName,-40} {size,-12} {setup,-14} {ser,-12} {des,-12} {rt,-12} {serAlloc,-11} {desAlloc,-11}");
|
||||
}
|
||||
|
||||
// Summary row for this test data (vs MemoryPack)
|
||||
if (memPackResult != null && acBinaryResult != null)
|
||||
{
|
||||
var sizePct = (acBinaryResult.SerializedSize / (double)memPackResult.SerializedSize - 1) * 100;
|
||||
var serPct = SerPerOp(memPackResult) > 0 ? (SerPerOp(acBinaryResult) / SerPerOp(memPackResult) - 1) * 100 : 0;
|
||||
var desPct = DesPerOp(memPackResult) > 0 ? (DesPerOp(acBinaryResult) / DesPerOp(memPackResult) - 1) * 100 : 0;
|
||||
var rtPct = RtPerOp(memPackResult) > 0 ? (RtPerOp(acBinaryResult) / RtPerOp(memPackResult) - 1) * 100 : 0;
|
||||
|
||||
sb.AppendLine($" AcBinary (Byte[]) vs MemoryPack (Byte[]): Size {sizePct:+0;-0}% │ Ser {serPct:+0;-0}% │ Des {desPct:+0;-0}% │ RT {rtPct:+0;-0}%");
|
||||
}
|
||||
}
|
||||
|
||||
// Summary comparison (vs MemoryPack)
|
||||
sb.AppendLine();
|
||||
sb.AppendLine("=== AcBinary (Byte[], SGen) vs MemoryPack (Byte[]) (Overall) ===");
|
||||
|
||||
var memPackSerResults2 = results.Where(r => (r.Engine == BenchmarkEngine.MemoryPack && r.IoMode == BenchmarkIoMode.ByteArray) && r.SerializeTimeMs > 0).ToList();
|
||||
var memPackDesResults2 = results.Where(r => (r.Engine == BenchmarkEngine.MemoryPack && r.IoMode == BenchmarkIoMode.ByteArray) && r.DeserializeTimeMs > 0).ToList();
|
||||
var memPackRtResults2 = results.Where(r => (r.Engine == BenchmarkEngine.MemoryPack && r.IoMode == BenchmarkIoMode.ByteArray) && r.RoundTripTimeMs > 0).ToList();
|
||||
|
||||
var acBinarySerResults2 = results.Where(r => (r.Engine == BenchmarkEngine.AcBinary && r.IoMode == BenchmarkIoMode.ByteArray && r.DispatchMode == BenchmarkDispatchMode.SGen) && r.SerializeTimeMs > 0).ToList();
|
||||
var acBinaryDesResults2 = results.Where(r => (r.Engine == BenchmarkEngine.AcBinary && r.IoMode == BenchmarkIoMode.ByteArray && r.DispatchMode == BenchmarkDispatchMode.SGen) && r.DeserializeTimeMs > 0).ToList();
|
||||
var acBinaryRtResults2 = results.Where(r => (r.Engine == BenchmarkEngine.AcBinary && r.IoMode == BenchmarkIoMode.ByteArray && r.DispatchMode == BenchmarkDispatchMode.SGen) && r.RoundTripTimeMs > 0).ToList();
|
||||
|
||||
// Skip comparison block if either side has no Byte[] data
|
||||
if (memPackRtResults2.Count == 0 || acBinaryRtResults2.Count == 0)
|
||||
{
|
||||
sb.AppendLine(" (Comparison requires both serialize and deserialize data)");
|
||||
File.WriteAllText(logFilePath, sb.ToString(), ctx.Utf8NoBom);
|
||||
System.Console.WriteLine($"✓ Results saved to: {logFilePath}");
|
||||
|
||||
SaveLlmResults(ctx, llmFilePath, results, testDataSets);
|
||||
return;
|
||||
}
|
||||
|
||||
var sizeAcResults2 = results.Where(r => (r.Engine == BenchmarkEngine.AcBinary && r.IoMode == BenchmarkIoMode.ByteArray && r.DispatchMode == BenchmarkDispatchMode.SGen)).ToList();
|
||||
var sizeMpResults2 = results.Where(r => (r.Engine == BenchmarkEngine.MemoryPack && r.IoMode == BenchmarkIoMode.ByteArray)).ToList();
|
||||
|
||||
AppendOverallLine(sb, "Serialize", "µs/op", ComputeOverallStats(acBinarySerResults2, memPackSerResults2, SerPerOp));
|
||||
AppendOverallLine(sb, "Ser Alloc", "B/op", ComputeOverallStats(acBinarySerResults2, memPackSerResults2, r => r.SerializeAllocBytesPerOp), "F0");
|
||||
AppendOverallLine(sb, "Deserialize", "µs/op", ComputeOverallStats(acBinaryDesResults2, memPackDesResults2, DesPerOp));
|
||||
AppendOverallLine(sb, "Des Alloc", "B/op", ComputeOverallStats(acBinaryDesResults2, memPackDesResults2, r => r.DeserializeAllocBytesPerOp), "F0");
|
||||
AppendOverallLine(sb, "Round-trip", "µs/op", ComputeOverallStats(acBinaryRtResults2, memPackRtResults2, RtPerOp));
|
||||
AppendOverallLine(sb, "Size", "B", ComputeOverallStats(sizeAcResults2, sizeMpResults2, r => r.SerializedSize), "F0");
|
||||
|
||||
File.WriteAllText(logFilePath, sb.ToString(), ctx.Utf8NoBom);
|
||||
System.Console.WriteLine($"✓ Results saved to: {logFilePath}");
|
||||
|
||||
// Save LLM-optimized results
|
||||
SaveLlmResults(ctx, llmFilePath, results, testDataSets);
|
||||
}
|
||||
|
||||
private static void SaveLlmResults(ReportingContext ctx, string filePath, List<BenchmarkResult> results, List<TestDataSet> testDataSets)
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
sb.AppendLine($"# AcBinary Benchmark [{ctx.SourceTag}] {ctx.BuildConfiguration} {DateTime.Now:yyyy-MM-dd HH:mm:ss}");
|
||||
// BDN-sourced: warmup / iter / samples are BDN-job-config-managed (see .../BDN/ artifacts for raw N).
|
||||
// Console-sourced: our adaptive engine emits real numbers.
|
||||
var runStatsHeader = ctx.SourceTag == "Bdn"
|
||||
? "Iterations: BDN-managed | Warmup: BDN-managed | Samples: BDN-managed"
|
||||
: $"Iterations: per-cell adaptive (target ~{ctx.TargetSampleMs} ms/sample) | Warmup: {ctx.WarmupIterations} per phase (Ser/Des isolated) | Samples: {ctx.BenchmarkSamples} (median) + 1 pilot discarded";
|
||||
// F1 formatter without an explicit IFormatProvider would pick the current culture (e.g. "3,0%"
|
||||
// in Hungarian locale) — break parsability of the .LLM. Force InvariantCulture so the header
|
||||
// matches the row values which already go through CultureInfo.InvariantCulture in FormatMicrosWithRange.
|
||||
var unstablePct = (ctx.UnstableCVThreshold * 100).ToString("F1", CultureInfo.InvariantCulture);
|
||||
var microPct = (ctx.MicroOptCVThreshold * 100).ToString("F1", CultureInfo.InvariantCulture);
|
||||
sb.AppendLine($"Charset: {ctx.CharsetName} | {runStatsHeader} | .NET: {Environment.Version} | UnstableCV: {unstablePct}% | MicroOptCV: {microPct}%");
|
||||
sb.AppendLine("Baseline: MemoryPack (Byte[]) (SOTA reference) | Verified: round-trip correctness checked once per cell before warmup");
|
||||
|
||||
// Options summary. Bracketed [OrderType] surfaces the TestOrder variant each preset serialised —
|
||||
// see SaveAll for the variant-dispatch rationale.
|
||||
var optionsMap = results
|
||||
.Where(r => r.OptionsDescription != null)
|
||||
.Select(r => (r.SerializerName, r.OrderTypeName, r.OptionsDescription!))
|
||||
.Distinct()
|
||||
.ToList();
|
||||
if (optionsMap.Count > 0)
|
||||
{
|
||||
sb.AppendLine();
|
||||
sb.AppendLine("## Options");
|
||||
sb.AppendLine();
|
||||
foreach (var (name, orderType, opts) in optionsMap)
|
||||
sb.AppendLine($"- **{name} [{orderType}]**: {opts}");
|
||||
}
|
||||
|
||||
sb.AppendLine();
|
||||
sb.AppendLine("## Results");
|
||||
sb.AppendLine();
|
||||
sb.AppendLine("TestData | Engine | IO | Mode | Options | Size(B) | Ser(µs/op) | Deser(µs/op) | RT(µs/op) | SerAlloc(KB/op) | DesAlloc(KB/op) | RTAlloc(KB/op) | Setup S/D(KB) | Iter Ser/Des");
|
||||
sb.AppendLine("---|---|---|---|---|---|---|---|---|---|---|---|---|---");
|
||||
|
||||
var inv = CultureInfo.InvariantCulture;
|
||||
|
||||
foreach (var testData in testDataSets)
|
||||
{
|
||||
// Order by Engine for stable column-position across cells (see PrintGroupedResults for rationale).
|
||||
var testResults = results
|
||||
.Where(r => r.TestDataName == testData.DisplayName)
|
||||
.OrderBy(r => r.Engine).ThenBy(RtPerOp)
|
||||
.ToList();
|
||||
|
||||
foreach (var r in testResults)
|
||||
{
|
||||
var ser = r.SerializeTimeMs > 0 ? FormatMicrosWithRange(r.SerializeTimeMs, r.SerializeTimeMinMs, r.SerializeTimeMaxMs, r.SerializeTimeStdDevMs, r.SerializeIterations, inv, ctx.UnstableCVThreshold, ctx.MicroOptCVThreshold) : "-";
|
||||
var des = r.DeserializeTimeMs > 0 ? FormatMicrosWithRange(r.DeserializeTimeMs, r.DeserializeTimeMinMs, r.DeserializeTimeMaxMs, r.DeserializeTimeStdDevMs, r.DeserializeIterations, inv, ctx.UnstableCVThreshold, ctx.MicroOptCVThreshold) : "-";
|
||||
var rt = r.RoundTripTimeMs > 0
|
||||
? (r.IsRoundTripOnly
|
||||
? FormatMicrosWithRange(r.RoundTripTimeMs, r.RoundTripTimeMinMs, r.RoundTripTimeMaxMs, r.RoundTripTimeStdDevMs, r.RoundTripIterations, inv, ctx.UnstableCVThreshold, ctx.MicroOptCVThreshold)
|
||||
: RtPerOp(r).ToString("F2", inv))
|
||||
: "-";
|
||||
|
||||
var serAlloc = r.SerializeTimeMs > 0 ? ToKilobytes(r.SerializeAllocBytesPerOp).ToString("F2", inv) : "-";
|
||||
var desAlloc = r.DeserializeTimeMs > 0 ? ToKilobytes(r.DeserializeAllocBytesPerOp).ToString("F2", inv) : "-";
|
||||
var rtAlloc = r.RoundTripAllocBytesPerOp > 0 ? ToKilobytes(r.RoundTripAllocBytesPerOp).ToString("F2", inv) : "-";
|
||||
var setupAlloc = $"{ToKilobytes(r.SetupSerializeAllocBytes).ToString("F2", inv)} / {ToKilobytes(r.SetupDeserializeAllocBytes).ToString("F2", inv)}";
|
||||
|
||||
var iterCol = r.IsRoundTripOnly
|
||||
? r.RoundTripIterations.ToString(inv)
|
||||
: $"{(r.SerializeIterations > 0 ? r.SerializeIterations.ToString(inv) : "-")} / {(r.DeserializeIterations > 0 ? r.DeserializeIterations.ToString(inv) : "-")}";
|
||||
sb.AppendLine($"{r.TestDataName} | {r.Engine.ToDisplay()} | {r.IoMode.ToDisplay()} | {r.DispatchMode.ToDisplay()} | {r.OptionsPreset} | {r.SerializedSize} | {ser} | {des} | {rt} | {serAlloc} | {desAlloc} | {rtAlloc} | {setupAlloc} | {iterCol}");
|
||||
}
|
||||
}
|
||||
|
||||
// Overall AcBinary (SGen, Byte[]) vs MemoryPack (Byte[]) comparison
|
||||
var memPackByteArrayResults = results.Where(r => r.Engine == BenchmarkEngine.MemoryPack && r.IoMode == BenchmarkIoMode.ByteArray).ToList();
|
||||
var acBinarySGenByteArrayResults = results.Where(r => r.Engine == BenchmarkEngine.AcBinary && r.IoMode == BenchmarkIoMode.ByteArray && r.DispatchMode == BenchmarkDispatchMode.SGen).ToList();
|
||||
var memPackSerResultsLlm = memPackByteArrayResults.Where(r => r.SerializeTimeMs > 0).ToList();
|
||||
var memPackDesResultsLlm = memPackByteArrayResults.Where(r => r.DeserializeTimeMs > 0).ToList();
|
||||
var memPackRtResultsLlm = memPackByteArrayResults.Where(r => r.RoundTripTimeMs > 0).ToList();
|
||||
var acBinarySerResultsLlm = acBinarySGenByteArrayResults.Where(r => r.SerializeTimeMs > 0).ToList();
|
||||
var acBinaryDesResultsLlm = acBinarySGenByteArrayResults.Where(r => r.DeserializeTimeMs > 0).ToList();
|
||||
var acBinaryRtResultsLlm = acBinarySGenByteArrayResults.Where(r => r.RoundTripTimeMs > 0).ToList();
|
||||
|
||||
if (memPackRtResultsLlm.Count > 0 && acBinaryRtResultsLlm.Count > 0)
|
||||
{
|
||||
sb.AppendLine();
|
||||
sb.AppendLine("## Overall: AcBinary (Byte[], SGen) vs MemoryPack (Byte[])");
|
||||
sb.AppendLine();
|
||||
sb.AppendLine("Three aggregations of per-cell results: **arith** = arithmetic mean of µs/op (magnitude-weighted, Large cell dominates); **geo** = geometric mean of per-cell ratios (each cell weighted equally); **median** = median of per-cell ratios (outlier-resistant). Negative % = AcBinary faster/smaller; positive % = MemPack faster/smaller. The geo/median variants surface when a single big cell skews the arithmetic mean.");
|
||||
sb.AppendLine();
|
||||
sb.AppendLine("```");
|
||||
AppendOverallLine(sb, "Serialize", "µs/op", ComputeOverallStats(acBinarySerResultsLlm, memPackSerResultsLlm, SerPerOp));
|
||||
AppendOverallLine(sb, "Ser Alloc", "B/op", ComputeOverallStats(acBinarySerResultsLlm, memPackSerResultsLlm, r => r.SerializeAllocBytesPerOp), "F0");
|
||||
AppendOverallLine(sb, "Deserialize", "µs/op", ComputeOverallStats(acBinaryDesResultsLlm, memPackDesResultsLlm, DesPerOp));
|
||||
AppendOverallLine(sb, "Des Alloc", "B/op", ComputeOverallStats(acBinaryDesResultsLlm, memPackDesResultsLlm, r => r.DeserializeAllocBytesPerOp), "F0");
|
||||
AppendOverallLine(sb, "Round-trip", "µs/op", ComputeOverallStats(acBinaryRtResultsLlm, memPackRtResultsLlm, RtPerOp));
|
||||
AppendOverallLine(sb, "Size", "B", ComputeOverallStats(acBinarySGenByteArrayResults, memPackByteArrayResults, r => r.SerializedSize), "F0");
|
||||
sb.AppendLine("```");
|
||||
}
|
||||
|
||||
File.WriteAllText(filePath, sb.ToString(), ctx.Utf8NoBom);
|
||||
System.Console.WriteLine($"✓ LLM results saved to: {filePath}");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Formats byte array as hex dump with offset, hex values, and ASCII representation.
|
||||
/// </summary>
|
||||
public static string FormatHexDump(byte[] bytes, int bytesPerLine = 16)
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
for (var i = 0; i < bytes.Length; i += bytesPerLine)
|
||||
{
|
||||
// Offset
|
||||
sb.Append($"{i:X8} ");
|
||||
|
||||
// Hex bytes
|
||||
for (var j = 0; j < bytesPerLine; j++)
|
||||
{
|
||||
if (i + j < bytes.Length)
|
||||
sb.Append($"{bytes[i + j]:X2} ");
|
||||
else
|
||||
sb.Append(" ");
|
||||
|
||||
if (j == 7) sb.Append(' '); // Extra space in middle
|
||||
}
|
||||
|
||||
sb.Append(" |");
|
||||
|
||||
// ASCII representation
|
||||
for (var j = 0; j < bytesPerLine && i + j < bytes.Length; j++)
|
||||
{
|
||||
var b = bytes[i + j];
|
||||
sb.Append(b is >= 32 and < 127 ? (char)b : '.');
|
||||
}
|
||||
|
||||
sb.AppendLine("|");
|
||||
}
|
||||
return sb.ToString();
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,93 @@
|
|||
using AyCode.Core.Benchmarks.Workloads.Scenarios;
|
||||
|
||||
namespace AyCode.Core.Benchmarks.Reporting;
|
||||
|
||||
/// <summary>
|
||||
/// Per-cell benchmark result row. Populated by the benchmark execution loop (Console-side
|
||||
/// <c>BenchmarkLoop.RunBenchmarksForTestData</c> / BDN-side <c>BdnSummaryAdapter</c>); consumed by the
|
||||
/// output formatters in <c>BenchmarkReportWriter</c> (console table + .log + .LLM file writers).
|
||||
/// Pure DTO — no behaviour.
|
||||
/// </summary>
|
||||
public sealed class BenchmarkResult
|
||||
{
|
||||
public string TestDataName { get; set; } = "";
|
||||
public BenchmarkEngine Engine { get; set; }
|
||||
public BenchmarkIoMode IoMode { get; set; }
|
||||
public BenchmarkDispatchMode DispatchMode { get; set; }
|
||||
public string OptionsPreset { get; set; } = "";
|
||||
|
||||
/// <summary>
|
||||
/// CLR type name of the order graph serialised in this row (e.g. <c>"TestOrder_All_False"</c>,
|
||||
/// <c>"TestOrder_All_True"</c>). Captured from <see cref="ISerializerBenchmark.OrderTypeName"/> in
|
||||
/// the runner loop; surfaced in the SERIALIZER OPTIONS section of every output
|
||||
/// (.log, .LLM, console) so the reader can correlate each preset with its TestOrder variant
|
||||
/// without inflating the per-row tables with an extra column.
|
||||
/// </summary>
|
||||
public string OrderTypeName { get; set; } = "";
|
||||
|
||||
/// <summary>True if Serialize() captures a full round-trip and Deserialize() is a no-op
|
||||
/// (single-use streaming transports like NamedPipe). Excluded from "Fastest Serialize" / "Fastest Deserialize"
|
||||
/// winners rankings; still ranked in "Fastest Round-trip". Display-side: Ser µs/op / SerAlloc / Des µs/op / DesAlloc
|
||||
/// all show "N/A" since they were never measured separately; RT µs/op / RT Alloc carry the full round-trip values.</summary>
|
||||
public bool IsRoundTripOnly { get; set; }
|
||||
|
||||
/// <summary>Synthesized display name for backwards compatibility / single-string-row scenarios. Includes DispatchMode so SGen and Runtime variants of the same preset don't collide in grouping (e.g. SUMMARY: WINNERS).</summary>
|
||||
public string SerializerName => $"{Engine.ToDisplay()} ({IoMode.ToDisplay()}, {OptionsPreset}, {DispatchMode.ToDisplay()})";
|
||||
|
||||
public string? OptionsDescription { get; set; }
|
||||
public int SerializedSize { get; set; }
|
||||
public double SerializeTimeMs { get; set; }
|
||||
public double DeserializeTimeMs { get; set; }
|
||||
|
||||
// Per-sample min/max alongside the median (median is the *Time*Ms field above). Surfaces
|
||||
// inter-sample range — the visible noise floor for the row. 0 when the operation was skipped
|
||||
// (mode != "all"/"ser"/"des") or when a single-sample fast path was used (min == max == median).
|
||||
public double SerializeTimeMinMs { get; set; }
|
||||
public double SerializeTimeMaxMs { get; set; }
|
||||
public double DeserializeTimeMinMs { get; set; }
|
||||
public double DeserializeTimeMaxMs { get; set; }
|
||||
|
||||
// Sample-population stddev (ms). Used by FormatMicrosWithRange to compute CV (stddev/mean)
|
||||
// and emit the ⚠️ marker on rows above Configuration.UnstableCVThreshold. 0 in single-sample mode.
|
||||
public double SerializeTimeStdDevMs { get; set; }
|
||||
public double DeserializeTimeStdDevMs { get; set; }
|
||||
|
||||
// Per-row adaptive iteration count (post-CalibrateIterations). Each Ser and Des function calibrates
|
||||
// independently to land its sample window at ~Configuration.TargetSampleMs; per-op µs is then iter-independent
|
||||
// (`SerializeTimeMs / SerializeIterations * 1000`). For round-trip-only rows (NamedPipe etc.),
|
||||
// RoundTripIterations carries the calibrated iter count; SerializeIterations and DeserializeIterations
|
||||
// stay 0 (Ser and Des are not separately measurable on those rows).
|
||||
//
|
||||
// BDN-sourced rows (populated by <c>BdnSummaryAdapter</c>) follow a different convention: per-op time
|
||||
// is stored directly in <c>*TimeMs</c> with <c>Iterations = 1</c>, so the same <c>TimeMs / Iterations * 1000</c>
|
||||
// formula yields per-op µs. The actual BDN N count is NOT preserved on these rows — consumers that
|
||||
// read <c>SerializeIterations</c> as a nominal loop count (e.g. "bytes allocated over N iterations")
|
||||
// will misinterpret BDN rows. For the raw N, read the BDN-native artifacts under
|
||||
// <c>Test_Benchmark_Results/Benchmark/BDN/</c>.
|
||||
public int SerializeIterations { get; set; }
|
||||
public int DeserializeIterations { get; set; }
|
||||
public int RoundTripIterations { get; set; }
|
||||
|
||||
public long SerializeAllocBytesPerOp { get; set; }
|
||||
public long DeserializeAllocBytesPerOp { get; set; }
|
||||
public long SetupSerializeAllocBytes { get; set; }
|
||||
public long SetupDeserializeAllocBytes { get; set; }
|
||||
|
||||
/// <summary>Total round-trip time. For in-memory benchmarks: synthesized so that
|
||||
/// <c>RoundTripTimeMs / RoundTripIterations</c> yields the correct <c>SerPerOp + DesPerOp</c> µs/op
|
||||
/// (necessary because Ser and Des may have different iter counts post-calibration).
|
||||
/// For round-trip-only benchmarks (NamedPipe etc.): the directly-measured pipe round-trip time.</summary>
|
||||
public double RoundTripTimeMs { get; set; }
|
||||
|
||||
// Round-trip min/max + stddev — only populated for round-trip-only benchmarks (NamedPipe etc.) where
|
||||
// RT is directly measured. For in-memory rows RT = Ser + Des, which has no single-sample
|
||||
// distribution; surface Ser/Des range separately instead.
|
||||
public double RoundTripTimeMinMs { get; set; }
|
||||
public double RoundTripTimeMaxMs { get; set; }
|
||||
public double RoundTripTimeStdDevMs { get; set; }
|
||||
|
||||
/// <summary>Total round-trip allocation per op. For in-memory benchmarks: <c>SerializeAlloc + DeserializeAlloc</c>.
|
||||
/// For round-trip-only benchmarks: process-wide allocation measured via <see cref="GC.GetTotalAllocatedBytes"/>
|
||||
/// (covers ALL threads — client, server-drain, channel internals — not just the caller).</summary>
|
||||
public long RoundTripAllocBytesPerOp { get; set; }
|
||||
}
|
||||
|
|
@ -0,0 +1,30 @@
|
|||
# Reporting
|
||||
|
||||
Shared reporting types — the `BenchmarkResult` DTO that captures one cell of a benchmark run + the `BenchmarkReportWriter` that turns a list of these into the unified `.log` / `.LLM` / `.output` triplet + the `ReportingContext` bundle that parameterises both runners.
|
||||
|
||||
## Layout
|
||||
|
||||
- [`BenchmarkResult.cs`](BenchmarkResult.cs) — per-cell result row. `(TestData × Engine × IoMode × OptionsPreset × DispatchMode)` tuple + Ser / Des / RT timings (median, min, max, stddev — all ms-batch units), iter counts (post-calibration), allocated bytes per op, setup-side one-time alloc, `IsRoundTripOnly` flag, derived `SerializerName`. Pure DTO — no behaviour. Populated by either:
|
||||
- Console `BenchmarkLoop.RunBenchmarksForTestData` (after adaptive measurement)
|
||||
- BDN `BdnSummaryAdapter.Translate` (after BDN Summary is in hand)
|
||||
|
||||
- [`ReportingContext.cs`](ReportingContext.cs) — record bundle for the writer:
|
||||
- `SourceTag` — `"Console"` / `"Bdn"`; drives the filename prefix
|
||||
- `ResultsDirectory` — resolved at startup via `ResolveResultsDirectory()` walking up from `AppContext.BaseDirectory` to the nearest `AyCode.Core.sln`, then `Test_Benchmark_Results/Benchmark/`. Worktree-aware.
|
||||
- `BuildConfiguration` — `"Debug"` / `"Release"` / `"NativeAOT"`; rendered into both the filename AND the report header
|
||||
- `Utf8NoBom` — shared `UTF8Encoding(false)` for all `File.WriteAllText` calls
|
||||
- `CharsetName`, `WarmupIterations`, `BenchmarkSamples`, `TargetSampleMs`, `UnstableCVThreshold` — run-header info embedded in every emitted artifact
|
||||
|
||||
- [`BenchmarkReportWriter.cs`](BenchmarkReportWriter.cs) — the writer itself:
|
||||
- `SaveAll(ctx, results, testDataSets)` — orchestrator. Writes the `.log` (formatted text + CSV + per-cell tables + Overall aggregation), `.LLM` (markdown table + Overall aggregation), and `.output` (hex dump of the Large cell's AcBinary serialization). All three land in `ctx.ResultsDirectory` with the `{ctx.SourceTag}.FullBenchmark_{Build}_{ts}.<ext>` filename pattern.
|
||||
- `PrintGroupedResults(results, testDataSets)` — colored per-cell tables to `System.Console`. Highlights MemoryPack (baseline) and AcBinary (SGen-Byte[]) rows with green/red win/lose colors, footer row shows pct deltas per metric.
|
||||
- `PrintResult(result)` — single-line summary printed during the per-cell loop (real-time progress signal).
|
||||
- `ComputeOverallStats(acResults, mpResults, valueSelector)` — paired-cell aggregation across `TestDataName` (arithmetic mean / geometric mean / median of per-cell ratios). Null-safe.
|
||||
- `FormatMicrosWithRange(...)` — `26.86 (24.50..29.10)` style with ⚠️CV-warning suffix when stddev/median exceeds the `UnstableCVThreshold`. All formatting goes through `CultureInfo.InvariantCulture` so the CSV section in `.log` stays parseable regardless of the host locale.
|
||||
- `ToPerOpMicros` / `SerPerOp` / `DesPerOp` / `RtPerOp` / `ToKilobytes` / `FormatPctSigned` / `FormatHexDump` / `AppendOverallLine` — helper utilities used inline by the report-rendering methods.
|
||||
|
||||
## Conventions
|
||||
|
||||
- **Time units in `BenchmarkResult`**: all `*TimeMs` fields are total-batch milliseconds. Per-op µs = `TimeMs / Iterations * 1000`. For BDN-sourced rows the adapter stores `Mean_ns / 1e6` with `Iterations = 1`, so the same formula yields per-op µs directly (`ms * 1000 = µs`).
|
||||
- **InvariantCulture** is enforced everywhere a numeric value is rendered to file (`.log` CSV section, `.LLM` markdown cells). Console-output (the colored tables) uses default culture for human-friendliness.
|
||||
- **`SourceTag` discriminator**: appears in three places — the filename prefix (`Console.` / `Bdn.`), the `.log` header (`║ Source: Console`), the `.LLM` H1 (`# AcBinary Benchmark [Console] Release ...`). Anyone diffing or grepping outputs can pin the source unambiguously.
|
||||
|
|
@ -0,0 +1,45 @@
|
|||
using System.Text;
|
||||
|
||||
namespace AyCode.Core.Benchmarks.Reporting;
|
||||
|
||||
/// <summary>
|
||||
/// Context bundle for the unified benchmark report writer. Same record on both sides (Console / BDN),
|
||||
/// the <see cref="SourceTag"/> differs ("Console" / "Bdn") and drives the filename prefix
|
||||
/// (e.g. <c>Console.FullBenchmark_Release_{timestamp}.LLM</c> vs <c>Bdn.FullBenchmark_Release_{timestamp}.LLM</c>).
|
||||
/// The <see cref="ResultsDirectory"/> resolution walks up from <see cref="AppContext.BaseDirectory"/> to the
|
||||
/// nearest <c>AyCode.Core.sln</c> and combines with <c>Test_Benchmark_Results\Benchmark</c> — works across
|
||||
/// build modes (Debug / Release / AOT publish) and worktrees (each worktree has its own .sln, so its bench
|
||||
/// results land alongside its code). The remaining fields capture run-header information (charset, iter
|
||||
/// counts, target sample window, CV threshold) so the writer can render a self-documenting header in both
|
||||
/// the <c>.log</c> and <c>.LLM</c> outputs.
|
||||
/// </summary>
|
||||
public sealed record ReportingContext(
|
||||
string SourceTag,
|
||||
string ResultsDirectory,
|
||||
string BuildConfiguration,
|
||||
UTF8Encoding Utf8NoBom,
|
||||
string CharsetName,
|
||||
int WarmupIterations,
|
||||
int BenchmarkSamples,
|
||||
int TargetSampleMs,
|
||||
double UnstableCVThreshold,
|
||||
double MicroOptCVThreshold)
|
||||
{
|
||||
/// <summary>
|
||||
/// Walks up from the assembly's BaseDirectory to find the repo root (marker: <c>AyCode.Core.sln</c>).
|
||||
/// Returns <c>{repoRoot}\Test_Benchmark_Results\Benchmark</c>. Worktree-aware: if running from a
|
||||
/// worktree, the walk finds the worktree's own .sln (each worktree has its own checkout), so
|
||||
/// results land in the worktree's results folder — the natural place when the worktree's code
|
||||
/// changes are what produced the numbers.
|
||||
/// </summary>
|
||||
public static string ResolveResultsDirectory()
|
||||
{
|
||||
var dir = new DirectoryInfo(AppContext.BaseDirectory);
|
||||
while (dir != null && !File.Exists(Path.Combine(dir.FullName, "AyCode.Core.sln")))
|
||||
dir = dir.Parent;
|
||||
if (dir == null)
|
||||
throw new InvalidOperationException(
|
||||
"Cannot locate repo root (AyCode.Core.sln) from AppContext.BaseDirectory: " + AppContext.BaseDirectory);
|
||||
return Path.Combine(dir.FullName, "Test_Benchmark_Results", "Benchmark");
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,189 @@
|
|||
using AyCode.Core.Extensions;
|
||||
using AyCode.Core.Serializers.Jsons;
|
||||
using AyCode.Core.Tests.TestModels;
|
||||
using BenchmarkDotNet.Attributes;
|
||||
using MessagePack;
|
||||
|
||||
namespace AyCode.Core.Benchmarks;
|
||||
|
||||
/// <summary>
|
||||
/// SignalR communication benchmarks measuring the full serialization workflow:
|
||||
/// Client ? IdMessage ? MessagePack ? Server ? Deserialize ? Response ? MessagePack ? Client
|
||||
/// </summary>
|
||||
[MemoryDiagnoser]
|
||||
public class SignalRCommunicationBenchmarks
|
||||
{
|
||||
// Shared test data
|
||||
private SignalRBenchmarkData _data = null !;
|
||||
// Pre-serialized messages for deserialization benchmarks
|
||||
private byte[] _singleIntMessage = null !;
|
||||
private byte[] _twoIntMessage = null !;
|
||||
private byte[] _fiveParamsMessage = null !;
|
||||
private byte[] _complexOrderItemMessage = null !;
|
||||
private byte[] _complexOrderMessage = null !;
|
||||
private byte[] _intArrayMessage = null !;
|
||||
private byte[] _mixedParamsMessage = null !;
|
||||
// Pre-serialized response for client-side deserialization
|
||||
private byte[] _successResponseMessage = null !;
|
||||
private byte[] _complexResponseMessage = null !;
|
||||
[GlobalSetup]
|
||||
public void Setup()
|
||||
{
|
||||
_data = new SignalRBenchmarkData();
|
||||
// Copy pre-serialized messages
|
||||
_singleIntMessage = _data.SingleIntMessage;
|
||||
_twoIntMessage = _data.TwoIntMessage;
|
||||
_fiveParamsMessage = _data.FiveParamsMessage;
|
||||
_complexOrderItemMessage = _data.ComplexOrderItemMessage;
|
||||
_complexOrderMessage = _data.ComplexOrderMessage;
|
||||
_intArrayMessage = _data.IntArrayMessage;
|
||||
_mixedParamsMessage = _data.MixedParamsMessage;
|
||||
// Pre-serialize response messages
|
||||
_successResponseMessage = SignalRMessageFactory.CreateSuccessResponse(CommonSignalRTags.SingleIntParam, "Received: 42");
|
||||
_complexResponseMessage = SignalRMessageFactory.CreateSuccessResponse(CommonSignalRTags.TestOrderParam, _data.TestOrder);
|
||||
Console.WriteLine("=== SignalR Message Size Comparison ===");
|
||||
Console.WriteLine($"Single int message: {_singleIntMessage.Length} bytes");
|
||||
Console.WriteLine($"Two int message: {_twoIntMessage.Length} bytes");
|
||||
Console.WriteLine($"Five params message: {_fiveParamsMessage.Length} bytes");
|
||||
Console.WriteLine($"Complex OrderItem message: {_complexOrderItemMessage.Length} bytes");
|
||||
Console.WriteLine($"Complex Order message: {_complexOrderMessage.Length} bytes");
|
||||
Console.WriteLine($"Int array message: {_intArrayMessage.Length} bytes");
|
||||
Console.WriteLine($"Mixed params message: {_mixedParamsMessage.Length} bytes");
|
||||
Console.WriteLine($"Success response: {_successResponseMessage.Length} bytes");
|
||||
Console.WriteLine($"Complex response: {_complexResponseMessage.Length} bytes");
|
||||
}
|
||||
|
||||
#region Client-Side: Message Creation (IdMessage + MessagePack Serialization)
|
||||
[Benchmark(Description = "Client: Create single int message")]
|
||||
[BenchmarkCategory("Client", "Create")]
|
||||
public byte[] Client_CreateSingleIntMessage() => SignalRMessageFactory.CreateSingleParamMessage(42);
|
||||
[Benchmark(Description = "Client: Create two int message")]
|
||||
[BenchmarkCategory("Client", "Create")]
|
||||
public byte[] Client_CreateTwoIntMessage() => SignalRMessageFactory.CreateIdMessage(10, 20);
|
||||
[Benchmark(Description = "Client: Create five params message")]
|
||||
[BenchmarkCategory("Client", "Create")]
|
||||
public byte[] Client_CreateFiveParamsMessage() => SignalRMessageFactory.CreateIdMessage(42, "hello", true, _data.TestGuid, 99.99m);
|
||||
[Benchmark(Description = "Client: Create complex OrderItem message")]
|
||||
[BenchmarkCategory("Client", "Create")]
|
||||
public byte[] Client_CreateComplexOrderItemMessage() => SignalRMessageFactory.CreateComplexObjectMessage(_data.TestOrderItem);
|
||||
[Benchmark(Description = "Client: Create complex Order message")]
|
||||
[BenchmarkCategory("Client", "Create")]
|
||||
public byte[] Client_CreateComplexOrderMessage() => SignalRMessageFactory.CreateComplexObjectMessage(_data.TestOrder);
|
||||
#endregion
|
||||
#region Server-Side: Message Deserialization (MessagePack + JSON)
|
||||
[Benchmark(Description = "Server: Deserialize single int")]
|
||||
[BenchmarkCategory("Server", "Deserialize")]
|
||||
public int Server_DeserializeSingleInt()
|
||||
{
|
||||
var postMessage = MessagePackSerializer.Deserialize<SignalRPostMessageDto>(_singleIntMessage, SignalRMessageFactory.ContractlessOptions);
|
||||
var idMessage = postMessage.PostDataJson!.JsonTo<SignalRIdMessageDto>()!;
|
||||
return AcJsonDeserializer.Deserialize<int>(idMessage.Ids[0]);
|
||||
}
|
||||
|
||||
[Benchmark(Description = "Server: Deserialize two ints")]
|
||||
[BenchmarkCategory("Server", "Deserialize")]
|
||||
public (int, int) Server_DeserializeTwoInts()
|
||||
{
|
||||
var postMessage = MessagePackSerializer.Deserialize<SignalRPostMessageDto>(_twoIntMessage, SignalRMessageFactory.ContractlessOptions);
|
||||
var idMessage = postMessage.PostDataJson!.JsonTo<SignalRIdMessageDto>()!;
|
||||
var a = AcJsonDeserializer.Deserialize<int>(idMessage.Ids[0]);
|
||||
var b = AcJsonDeserializer.Deserialize<int>(idMessage.Ids[1]);
|
||||
return (a, b);
|
||||
}
|
||||
|
||||
[Benchmark(Description = "Server: Deserialize five params")]
|
||||
[BenchmarkCategory("Server", "Deserialize")]
|
||||
public (int, string, bool, Guid, decimal) Server_DeserializeFiveParams()
|
||||
{
|
||||
var postMessage = MessagePackSerializer.Deserialize<SignalRPostMessageDto>(_fiveParamsMessage, SignalRMessageFactory.ContractlessOptions);
|
||||
var idMessage = postMessage.PostDataJson!.JsonTo<SignalRIdMessageDto>()!;
|
||||
var a = AcJsonDeserializer.Deserialize<int>(idMessage.Ids[0]);
|
||||
var b = AcJsonDeserializer.Deserialize<string>(idMessage.Ids[1])!;
|
||||
var c = AcJsonDeserializer.Deserialize<bool>(idMessage.Ids[2]);
|
||||
var d = AcJsonDeserializer.Deserialize<Guid>(idMessage.Ids[3]);
|
||||
var e = AcJsonDeserializer.Deserialize<decimal>(idMessage.Ids[4]);
|
||||
return (a, b, c, d, e);
|
||||
}
|
||||
|
||||
[Benchmark(Description = "Server: Deserialize complex OrderItem")]
|
||||
[BenchmarkCategory("Server", "Deserialize")]
|
||||
public TestOrderItem_All_True Server_DeserializeComplexOrderItem()
|
||||
{
|
||||
var postMessage = MessagePackSerializer.Deserialize<SignalRPostMessageDto>(_complexOrderItemMessage, SignalRMessageFactory.ContractlessOptions);
|
||||
return postMessage.PostDataJson!.JsonTo<TestOrderItem_All_True>()!;
|
||||
}
|
||||
|
||||
[Benchmark(Description = "Server: Deserialize complex Order")]
|
||||
[BenchmarkCategory("Server", "Deserialize")]
|
||||
public TestOrder_All_True Server_DeserializeComplexOrder()
|
||||
{
|
||||
var postMessage = MessagePackSerializer.Deserialize<SignalRPostMessageDto>(_complexOrderMessage, SignalRMessageFactory.ContractlessOptions);
|
||||
return postMessage.PostDataJson!.JsonTo<TestOrder_All_True>()!;
|
||||
}
|
||||
|
||||
#endregion
|
||||
#region Server-Side: Response Creation (JSON + MessagePack Serialization)
|
||||
[Benchmark(Description = "Server: Create success response (string)")]
|
||||
[BenchmarkCategory("Server", "Response")]
|
||||
public byte[] Server_CreateSuccessStringResponse() => SignalRMessageFactory.CreateSuccessResponse(CommonSignalRTags.SingleIntParam, "Received: 42");
|
||||
[Benchmark(Description = "Server: Create success response (OrderItem)")]
|
||||
[BenchmarkCategory("Server", "Response")]
|
||||
public byte[] Server_CreateSuccessOrderItemResponse() => SignalRMessageFactory.CreateSuccessResponse(CommonSignalRTags.TestOrderItemParam, _data.TestOrderItem);
|
||||
[Benchmark(Description = "Server: Create success response (Order)")]
|
||||
[BenchmarkCategory("Server", "Response")]
|
||||
public byte[] Server_CreateSuccessOrderResponse() => SignalRMessageFactory.CreateSuccessResponse(CommonSignalRTags.TestOrderParam, _data.TestOrder);
|
||||
#endregion
|
||||
#region Client-Side: Response Deserialization
|
||||
[Benchmark(Description = "Client: Deserialize string response")]
|
||||
[BenchmarkCategory("Client", "Response")]
|
||||
public string? Client_DeserializeStringResponse()
|
||||
{
|
||||
var response = SignalRMessageFactory.DeserializeResponse(_successResponseMessage);
|
||||
return response?.ResponseData;
|
||||
}
|
||||
|
||||
[Benchmark(Description = "Client: Deserialize complex Order response")]
|
||||
[BenchmarkCategory("Client", "Response")]
|
||||
public TestOrder_All_True? Client_DeserializeOrderResponse()
|
||||
{
|
||||
var response = SignalRMessageFactory.DeserializeResponse(_complexResponseMessage);
|
||||
return response?.ResponseData?.JsonTo<TestOrder_All_True>();
|
||||
}
|
||||
|
||||
#endregion
|
||||
#region Full Round-Trip Benchmarks
|
||||
[Benchmark(Description = "Full: Single int round-trip")]
|
||||
[BenchmarkCategory("Full")]
|
||||
public string? Full_SingleIntRoundTrip()
|
||||
{
|
||||
// Client creates message
|
||||
var requestBytes = SignalRMessageFactory.CreateSingleParamMessage(42);
|
||||
// Server deserializes
|
||||
var postMessage = MessagePackSerializer.Deserialize<SignalRPostMessageDto>(requestBytes, SignalRMessageFactory.ContractlessOptions);
|
||||
var idMessage = postMessage.PostDataJson!.JsonTo<SignalRIdMessageDto>()!;
|
||||
var value = AcJsonDeserializer.Deserialize<int>(idMessage.Ids[0]);
|
||||
// Server creates response
|
||||
var responseBytes = SignalRMessageFactory.CreateSuccessResponse(CommonSignalRTags.SingleIntParam, $"Received: {value}");
|
||||
// Client deserializes response
|
||||
var response = SignalRMessageFactory.DeserializeResponse(responseBytes);
|
||||
return response?.ResponseData;
|
||||
}
|
||||
|
||||
[Benchmark(Description = "Full: Complex Order round-trip")]
|
||||
[BenchmarkCategory("Full")]
|
||||
public TestOrder_All_True? Full_ComplexOrderRoundTrip()
|
||||
{
|
||||
// Client creates message
|
||||
var requestBytes = SignalRMessageFactory.CreateComplexObjectMessage(_data.TestOrder);
|
||||
// Server deserializes
|
||||
var postMessage = MessagePackSerializer.Deserialize<SignalRPostMessageDto>(requestBytes, SignalRMessageFactory.ContractlessOptions);
|
||||
var order = postMessage.PostDataJson!.JsonTo<TestOrder_All_True>()!;
|
||||
// Server modifies and creates response
|
||||
order.OrderNumber = "PROCESSED-" + order.OrderNumber;
|
||||
var responseBytes = SignalRMessageFactory.CreateSuccessResponse(CommonSignalRTags.TestOrderParam, order);
|
||||
// Client deserializes response
|
||||
var response = SignalRMessageFactory.DeserializeResponse(responseBytes);
|
||||
return response?.ResponseData?.JsonTo<TestOrder_All_True>();
|
||||
}
|
||||
#endregion
|
||||
}
|
||||
|
|
@ -0,0 +1,308 @@
|
|||
using System.Security.Claims;
|
||||
using AyCode.Core.Extensions;
|
||||
using AyCode.Core.Tests.TestModels;
|
||||
using AyCode.Models.Server.DynamicMethods;
|
||||
using AyCode.Services.Server.SignalRs;
|
||||
using AyCode.Services.SignalRs;
|
||||
using BenchmarkDotNet.Attributes;
|
||||
using Microsoft.AspNetCore.SignalR.Client;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.VSDiagnostics;
|
||||
|
||||
namespace AyCode.Core.Benchmarks;
|
||||
|
||||
/// <summary>
|
||||
/// Benchmarks for SignalR round-trip communication using the same infrastructure as SignalRClientToHubTest.
|
||||
/// Measures: Client -> Server -> Service -> Response -> Client
|
||||
/// </summary>
|
||||
[MemoryDiagnoser]
|
||||
[CPUUsageDiagnoser]
|
||||
public class SignalRRoundTripBenchmarks
|
||||
{
|
||||
private BenchmarkSignalRClient _client = null!;
|
||||
private BenchmarkSignalRHub _hub = null!;
|
||||
private BenchmarkSignalRService _service = null!;
|
||||
|
||||
// Pre-created test data
|
||||
private TestOrderItem_All_True _testOrderItem = null!;
|
||||
private TestOrder_All_True _testOrder = null!;
|
||||
private SharedTag_All_True _sharedTag = null!;
|
||||
private int[] _intArray = null!;
|
||||
private List<string> _stringList = null!;
|
||||
private Guid _testGuid;
|
||||
|
||||
[GlobalSetup]
|
||||
public void Setup()
|
||||
{
|
||||
var logger = new TestLogger();
|
||||
_hub = new BenchmarkSignalRHub(logger);
|
||||
_service = new BenchmarkSignalRService();
|
||||
_client = new BenchmarkSignalRClient(_hub, logger);
|
||||
_hub.RegisterService(_service, _client);
|
||||
|
||||
// Pre-create test data
|
||||
_testOrderItem = new TestOrderItem_All_True { Id = 1, ProductName = "Widget", Quantity = 5, UnitPrice = 10.50m };
|
||||
_testOrder = TestDataFactory.CreateOrder(itemCount: 3);
|
||||
_sharedTag = new SharedTag_All_True { Id = 1, Name = "Important", Color = "#FF0000" };
|
||||
_intArray = [1, 2, 3, 4, 5];
|
||||
_stringList = ["apple", "banana", "cherry"];
|
||||
_testGuid = Guid.NewGuid();
|
||||
}
|
||||
|
||||
#region Primitive Parameter Benchmarks
|
||||
|
||||
[Benchmark(Description = "RoundTrip: Single int")]
|
||||
[BenchmarkCategory("Primitives")]
|
||||
public string? RoundTrip_SingleInt()
|
||||
{
|
||||
return _client.PostDataSync<int, string>(BenchmarkSignalRTags.SingleIntParam, 42);
|
||||
}
|
||||
|
||||
[Benchmark(Description = "RoundTrip: Two ints")]
|
||||
[BenchmarkCategory("Primitives")]
|
||||
public int RoundTrip_TwoInts()
|
||||
{
|
||||
return _client.PostSync<int>(BenchmarkSignalRTags.TwoIntParams, [10, 20]);
|
||||
}
|
||||
|
||||
[Benchmark(Description = "RoundTrip: Bool")]
|
||||
[BenchmarkCategory("Primitives")]
|
||||
public bool RoundTrip_Bool()
|
||||
{
|
||||
return _client.PostDataSync<bool, bool>(BenchmarkSignalRTags.BoolParam, true);
|
||||
}
|
||||
|
||||
[Benchmark(Description = "RoundTrip: String")]
|
||||
[BenchmarkCategory("Primitives")]
|
||||
public string? RoundTrip_String()
|
||||
{
|
||||
return _client.PostDataSync<string, string>(BenchmarkSignalRTags.StringParam, "Hello");
|
||||
}
|
||||
|
||||
[Benchmark(Description = "RoundTrip: Guid")]
|
||||
[BenchmarkCategory("Primitives")]
|
||||
public Guid RoundTrip_Guid()
|
||||
{
|
||||
return _client.PostDataSync<Guid, Guid>(BenchmarkSignalRTags.GuidParam, _testGuid);
|
||||
}
|
||||
|
||||
[Benchmark(Description = "RoundTrip: No params")]
|
||||
[BenchmarkCategory("Primitives")]
|
||||
public string? RoundTrip_NoParams()
|
||||
{
|
||||
return _client.GetAllSync<string>(BenchmarkSignalRTags.NoParams);
|
||||
}
|
||||
|
||||
[Benchmark(Description = "RoundTrip: Multiple types (3 params)")]
|
||||
[BenchmarkCategory("Primitives")]
|
||||
public string? RoundTrip_MultipleTypes()
|
||||
{
|
||||
return _client.PostSync<string>(BenchmarkSignalRTags.MultipleTypesParams, [true, "test", 42]);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Complex Object Benchmarks
|
||||
|
||||
[Benchmark(Description = "RoundTrip: TestOrderItem_All_True")]
|
||||
[BenchmarkCategory("Complex")]
|
||||
public TestOrderItem_All_True? RoundTrip_TestOrderItem()
|
||||
{
|
||||
return _client.PostDataSync<TestOrderItem_All_True, TestOrderItem_All_True>(BenchmarkSignalRTags.TestOrderItemParam, _testOrderItem);
|
||||
}
|
||||
|
||||
[Benchmark(Description = "RoundTrip: TestOrder_All_True (3 items)")]
|
||||
[BenchmarkCategory("Complex")]
|
||||
public TestOrder_All_True? RoundTrip_TestOrder()
|
||||
{
|
||||
return _client.PostDataSync<TestOrder_All_True, TestOrder_All_True>(BenchmarkSignalRTags.TestOrderParam, _testOrder);
|
||||
}
|
||||
|
||||
[Benchmark(Description = "RoundTrip: SharedTag_All_True")]
|
||||
[BenchmarkCategory("Complex")]
|
||||
public SharedTag_All_True? RoundTrip_SharedTag()
|
||||
{
|
||||
return _client.PostDataSync<SharedTag_All_True, SharedTag_All_True>(BenchmarkSignalRTags.SharedTagParam, _sharedTag);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Collection Benchmarks
|
||||
|
||||
[Benchmark(Description = "RoundTrip: int[] (5 elements)")]
|
||||
[BenchmarkCategory("Collections")]
|
||||
public int[]? RoundTrip_IntArray()
|
||||
{
|
||||
return _client.PostDataSync<int[], int[]>(BenchmarkSignalRTags.IntArrayParam, _intArray);
|
||||
}
|
||||
|
||||
[Benchmark(Description = "RoundTrip: List<string> (3 elements)")]
|
||||
[BenchmarkCategory("Collections")]
|
||||
public List<string>? RoundTrip_StringList()
|
||||
{
|
||||
return _client.PostDataSync<List<string>, List<string>>(BenchmarkSignalRTags.StringListParam, _stringList);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Mixed Parameter Benchmarks
|
||||
|
||||
[Benchmark(Description = "RoundTrip: Int + DTO")]
|
||||
[BenchmarkCategory("Mixed")]
|
||||
public string? RoundTrip_IntAndDto()
|
||||
{
|
||||
return _client.PostSync<string>(BenchmarkSignalRTags.IntAndDtoParam, [42, _testOrderItem]);
|
||||
}
|
||||
|
||||
[Benchmark(Description = "RoundTrip: 5 mixed params")]
|
||||
[BenchmarkCategory("Mixed")]
|
||||
public string? RoundTrip_FiveParams()
|
||||
{
|
||||
return _client.PostSync<string>(BenchmarkSignalRTags.FiveParams, [42, "hello", true, _testGuid, 99.99m]);
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
|
||||
#region Benchmark Infrastructure (minimal, reuses production code)
|
||||
|
||||
/// <summary>
|
||||
/// SignalR tags for benchmarks - matches TestSignalRTags structure
|
||||
/// </summary>
|
||||
public abstract class BenchmarkSignalRTags : AcSignalRTags
|
||||
{
|
||||
public const int SingleIntParam = 100;
|
||||
public const int TwoIntParams = 101;
|
||||
public const int BoolParam = 102;
|
||||
public const int StringParam = 103;
|
||||
public const int GuidParam = 104;
|
||||
public const int NoParams = 107;
|
||||
public const int MultipleTypesParams = 109;
|
||||
public const int TestOrderItemParam = 120;
|
||||
public const int TestOrderParam = 121;
|
||||
public const int SharedTagParam = 122;
|
||||
public const int IntArrayParam = 130;
|
||||
public const int StringListParam = 132;
|
||||
public const int IntAndDtoParam = 160;
|
||||
public const int FiveParams = 164;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Benchmark-optimized SignalR client with synchronous methods for accurate timing
|
||||
/// </summary>
|
||||
public class BenchmarkSignalRClient : AcSignalRClientBase, IAcSignalRHubItemServer
|
||||
{
|
||||
private readonly BenchmarkSignalRHub _hub;
|
||||
|
||||
public BenchmarkSignalRClient(BenchmarkSignalRHub hub, TestLogger logger) : base(logger)
|
||||
{
|
||||
_hub = hub;
|
||||
// Eliminate polling delay for benchmarks
|
||||
MsDelay = 0;
|
||||
MsFirstDelay = 0;
|
||||
}
|
||||
|
||||
// Synchronous wrappers for benchmarking (avoids async overhead measurement)
|
||||
public TResponse? PostDataSync<TPost, TResponse>(int tag, TPost data)
|
||||
=> PostDataAsync<TPost, TResponse>(tag, data).GetAwaiter().GetResult();
|
||||
|
||||
public TResponse? PostSync<TResponse>(int tag, object[] parameters)
|
||||
=> PostAsync<TResponse>(tag, parameters).GetAwaiter().GetResult();
|
||||
|
||||
public TResponse? GetAllSync<TResponse>(int tag)
|
||||
=> GetAllAsync<TResponse>(tag).GetAwaiter().GetResult();
|
||||
|
||||
protected override Task MessageReceived(int messageTag, SignalParams signalParams, object data) => Task.CompletedTask;
|
||||
protected override HubConnectionState GetConnectionState() => HubConnectionState.Connected;
|
||||
protected override bool IsConnected() => true;
|
||||
protected override Task StartConnectionInternal() => Task.CompletedTask;
|
||||
protected override Task StopConnectionInternal() => Task.CompletedTask;
|
||||
protected override ValueTask DisposeConnectionInternal() => ValueTask.CompletedTask;
|
||||
|
||||
protected override async Task SendToHubAsync(int messageTag, int? requestId, SignalParams signalParams, object? data)
|
||||
{
|
||||
await _hub.OnReceiveMessage(messageTag, requestId, signalParams, data ?? Array.Empty<byte>());
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Benchmark-optimized SignalR hub
|
||||
/// </summary>
|
||||
public class BenchmarkSignalRHub : AcWebSignalRHubBase<BenchmarkSignalRTags, TestLogger>
|
||||
{
|
||||
private IAcSignalRHubItemServer _callerClient = null!;
|
||||
|
||||
public BenchmarkSignalRHub(TestLogger logger) : base(new ConfigurationBuilder().Build(), logger)
|
||||
{
|
||||
}
|
||||
|
||||
public void RegisterService(object service, IAcSignalRHubItemServer client)
|
||||
{
|
||||
_callerClient = client;
|
||||
DynamicMethodRegistry.Register(service);
|
||||
}
|
||||
|
||||
protected override string GetConnectionId() => "benchmark-connection";
|
||||
protected override bool IsConnectionAborted() => false;
|
||||
protected override string? GetUserIdentifier() => "benchmark-user";
|
||||
protected override ClaimsPrincipal? GetUser() => null;
|
||||
|
||||
protected override Task ResponseToCaller(int messageTag, SignalResponseStatus status, object? responseData, int? requestId, SignalParams? clientSignalParams = null)
|
||||
=> SendMessageToClient(_callerClient, messageTag, status, responseData, requestId, clientSignalParams);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Benchmark service handlers - same logic as TestSignalRService2
|
||||
/// </summary>
|
||||
public class BenchmarkSignalRService
|
||||
{
|
||||
[SignalR(BenchmarkSignalRTags.SingleIntParam)]
|
||||
public string HandleSingleInt(int value) => $"{value}";
|
||||
|
||||
[SignalR(BenchmarkSignalRTags.TwoIntParams)]
|
||||
public int HandleTwoInts(int a, int b) => a + b;
|
||||
|
||||
[SignalR(BenchmarkSignalRTags.BoolParam)]
|
||||
public bool HandleBool(bool value) => value;
|
||||
|
||||
[SignalR(BenchmarkSignalRTags.StringParam)]
|
||||
public string HandleString(string text) => $"Echo: {text}";
|
||||
|
||||
[SignalR(BenchmarkSignalRTags.GuidParam)]
|
||||
public Guid HandleGuid(Guid id) => id;
|
||||
|
||||
[SignalR(BenchmarkSignalRTags.NoParams)]
|
||||
public string HandleNoParams() => "OK";
|
||||
|
||||
[SignalR(BenchmarkSignalRTags.MultipleTypesParams)]
|
||||
public string HandleMultipleTypes(bool flag, string text, int number) => $"{flag}-{text}-{number}";
|
||||
|
||||
[SignalR(BenchmarkSignalRTags.TestOrderItemParam)]
|
||||
public TestOrderItem_All_True HandleTestOrderItem(TestOrderItem_All_True item) => new()
|
||||
{
|
||||
Id = item.Id,
|
||||
ProductName = $"Processed: {item.ProductName}",
|
||||
Quantity = item.Quantity * 2,
|
||||
UnitPrice = item.UnitPrice * 2,
|
||||
};
|
||||
|
||||
[SignalR(BenchmarkSignalRTags.TestOrderParam)]
|
||||
public TestOrder_All_True HandleTestOrder(TestOrder_All_True order) => order;
|
||||
|
||||
[SignalR(BenchmarkSignalRTags.SharedTagParam)]
|
||||
public SharedTag_All_True HandleSharedTag(SharedTag_All_True tag) => tag;
|
||||
|
||||
[SignalR(BenchmarkSignalRTags.IntArrayParam)]
|
||||
public int[] HandleIntArray(int[] values) => values.Select(x => x * 2).ToArray();
|
||||
|
||||
[SignalR(BenchmarkSignalRTags.StringListParam)]
|
||||
public List<string> HandleStringList(List<string> items) => items.Select(x => x.ToUpper()).ToList();
|
||||
|
||||
[SignalR(BenchmarkSignalRTags.IntAndDtoParam)]
|
||||
public string HandleIntAndDto(int id, TestOrderItem_All_True item) => $"{id}-{item?.ProductName}";
|
||||
|
||||
[SignalR(BenchmarkSignalRTags.FiveParams)]
|
||||
public string HandleFiveParams(int a, string b, bool c, Guid d, decimal e) => $"{a}-{b}-{c}-{d}-{e}";
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
|
@ -0,0 +1,294 @@
|
|||
using AyCode.Core.Serializers.Attributes;
|
||||
using AyCode.Core.Serializers.Binaries;
|
||||
using BenchmarkDotNet.Attributes;
|
||||
using MessagePack;
|
||||
using MessagePack.Resolvers;
|
||||
|
||||
namespace AyCode.Core.Benchmarks;
|
||||
|
||||
/// <summary>
|
||||
/// Pure contractless model - NO MessagePack attributes.
|
||||
/// This tests TRUE runtime serialization without any source generation.
|
||||
/// </summary>
|
||||
[AcBinarySerializable]
|
||||
public class PureContractlessModel
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public string Name { get; set; } = "";
|
||||
public string Description { get; set; } = "";
|
||||
public bool IsActive { get; set; }
|
||||
public double Value { get; set; }
|
||||
public decimal Price { get; set; }
|
||||
public long BigNumber { get; set; }
|
||||
public DateTime CreatedAt { get; set; }
|
||||
public Guid UniqueId { get; set; }
|
||||
public int Count { get; set; }
|
||||
public string Category { get; set; } = "";
|
||||
public string Status { get; set; } = "";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Benchmark model with only primitive types - fully supported by Source Generator.
|
||||
/// MessagePack attributes added for fair comparison with Source Generator.
|
||||
/// </summary>
|
||||
[AcBinarySerializable]
|
||||
[MessagePackObject]
|
||||
public class PrimitiveBenchmarkModel
|
||||
{
|
||||
[Key(0)] public int Id { get; set; }
|
||||
[Key(1)] public string Name { get; set; } = "";
|
||||
[Key(2)] public string Description { get; set; } = "";
|
||||
[Key(3)] public bool IsActive { get; set; }
|
||||
[Key(4)] public double Value { get; set; }
|
||||
[Key(5)] public decimal Price { get; set; }
|
||||
[Key(6)] public long BigNumber { get; set; }
|
||||
[Key(7)] public DateTime CreatedAt { get; set; }
|
||||
[Key(8)] public Guid UniqueId { get; set; }
|
||||
[Key(9)] public int Count { get; set; }
|
||||
[Key(10)] public string Category { get; set; } = "";
|
||||
[Key(11)] public string Status { get; set; } = "";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// TRUE Contractless benchmark - no attributes, pure runtime serialization.
|
||||
/// </summary>
|
||||
[ShortRunJob]
|
||||
[MemoryDiagnoser]
|
||||
[RankColumn]
|
||||
public class PureContractlessBenchmark
|
||||
{
|
||||
private PureContractlessModel _testData = null!;
|
||||
private byte[] _acBinaryData = null!;
|
||||
private byte[] _msgPackContractlessData = null!;
|
||||
|
||||
private AcBinarySerializerOptions _binaryOptions = null!;
|
||||
private MessagePackSerializerOptions _msgPackContractlessOptions = null!;
|
||||
|
||||
[GlobalSetup]
|
||||
public void Setup()
|
||||
{
|
||||
_testData = new PureContractlessModel
|
||||
{
|
||||
Id = 12345,
|
||||
Name = "Test Product Name",
|
||||
Description = "This is a longer description for testing string serialization performance",
|
||||
IsActive = true,
|
||||
Value = 123.456789,
|
||||
Price = 99.99m,
|
||||
BigNumber = 9876543210L,
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
UniqueId = Guid.NewGuid(),
|
||||
Count = 42,
|
||||
Category = "Electronics",
|
||||
Status = "Available"
|
||||
};
|
||||
|
||||
_binaryOptions = AcBinarySerializerOptions.WithoutReferenceHandling;
|
||||
_msgPackContractlessOptions = ContractlessStandardResolver.Options.WithCompression(MessagePackCompression.None);
|
||||
|
||||
_acBinaryData = AcBinarySerializer.Serialize(_testData, _binaryOptions);
|
||||
_msgPackContractlessData = MessagePackSerializer.Serialize(_testData, _msgPackContractlessOptions);
|
||||
|
||||
Console.WriteLine($"=== Pure Contractless (NO attributes) ===");
|
||||
Console.WriteLine($"AcBinary: {_acBinaryData.Length} bytes");
|
||||
Console.WriteLine($"MsgPack Contractless: {_msgPackContractlessData.Length} bytes");
|
||||
}
|
||||
|
||||
[Benchmark(Description = "AcBinary Serialize")]
|
||||
public byte[] Serialize_AcBinary()
|
||||
=> AcBinarySerializer.Serialize(_testData, _binaryOptions);
|
||||
|
||||
[Benchmark(Description = "MsgPack Contractless Serialize", Baseline = true)]
|
||||
public byte[] Serialize_MsgPack_Contractless()
|
||||
=> MessagePackSerializer.Serialize(_testData, _msgPackContractlessOptions);
|
||||
|
||||
[Benchmark(Description = "AcBinary Deserialize")]
|
||||
public PureContractlessModel? Deserialize_AcBinary()
|
||||
=> AcBinaryDeserializer.Deserialize<PureContractlessModel>(_acBinaryData);
|
||||
|
||||
[Benchmark(Description = "MsgPack Contractless Deserialize")]
|
||||
public PureContractlessModel? Deserialize_MsgPack_Contractless()
|
||||
=> MessagePackSerializer.Deserialize<PureContractlessModel>(_msgPackContractlessData, _msgPackContractlessOptions);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Benchmark comparing Source Generator vs Runtime serialization for primitive-only types.
|
||||
/// Uses MessagePack with [MessagePackObject] attributes for fair Source Generator comparison.
|
||||
/// </summary>
|
||||
[ShortRunJob]
|
||||
[MemoryDiagnoser]
|
||||
[RankColumn]
|
||||
public class SourceGeneratorVsRuntimeBenchmark
|
||||
{
|
||||
private PrimitiveBenchmarkModel _testData = null!;
|
||||
private byte[] _runtimeSerializedData = null!;
|
||||
private byte[] _msgPackData = null!;
|
||||
private byte[] _msgPackContractlessData = null!;
|
||||
|
||||
private AcBinarySerializerOptions _binaryOptions = null!;
|
||||
private MessagePackSerializerOptions _msgPackOptions = null!;
|
||||
private MessagePackSerializerOptions _msgPackContractlessOptions = null!;
|
||||
|
||||
[GlobalSetup]
|
||||
public void Setup()
|
||||
{
|
||||
_testData = new PrimitiveBenchmarkModel
|
||||
{
|
||||
Id = 12345,
|
||||
Name = "Test Product Name",
|
||||
Description = "This is a longer description for testing string serialization performance",
|
||||
IsActive = true,
|
||||
Value = 123.456789,
|
||||
Price = 99.99m,
|
||||
BigNumber = 9876543210L,
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
UniqueId = Guid.NewGuid(),
|
||||
Count = 42,
|
||||
Category = "Electronics",
|
||||
Status = "Available"
|
||||
};
|
||||
|
||||
_binaryOptions = AcBinarySerializerOptions.WithoutReferenceHandling;
|
||||
|
||||
// MessagePack with Source Generator (uses [MessagePackObject] + [Key] attributes)
|
||||
_msgPackOptions = MessagePackSerializerOptions.Standard;
|
||||
|
||||
// MessagePack without Source Generator (Contractless - reflection based, like AcBinary runtime)
|
||||
_msgPackContractlessOptions = ContractlessStandardResolver.Options.WithCompression(MessagePackCompression.None);
|
||||
|
||||
// Pre-serialize for deserialize benchmarks
|
||||
_runtimeSerializedData = AcBinarySerializer.Serialize(_testData, _binaryOptions);
|
||||
_msgPackData = MessagePackSerializer.Serialize(_testData, _msgPackOptions);
|
||||
_msgPackContractlessData = MessagePackSerializer.Serialize(_testData, _msgPackContractlessOptions);
|
||||
|
||||
// Print sizes
|
||||
Console.WriteLine($"=== Primitive Model Serialization ===");
|
||||
Console.WriteLine($"AcBinary Runtime: {_runtimeSerializedData.Length} bytes");
|
||||
Console.WriteLine($"MessagePack SourceGen: {_msgPackData.Length} bytes");
|
||||
Console.WriteLine($"MessagePack Contractless:{_msgPackContractlessData.Length} bytes");
|
||||
|
||||
// Verify generated serializer exists
|
||||
var generatedType = typeof(PrimitiveBenchmarkModel).Assembly.GetType(
|
||||
$"{typeof(PrimitiveBenchmarkModel).FullName}_AcBinarySerializer");
|
||||
Console.WriteLine($"AcBinary Generated serializer found: {generatedType != null}");
|
||||
}
|
||||
|
||||
#region Serialize Benchmarks
|
||||
|
||||
[Benchmark(Description = "AcBinary Runtime Serialize")]
|
||||
public byte[] Serialize_AcBinary_Runtime()
|
||||
=> AcBinarySerializer.Serialize(_testData, _binaryOptions);
|
||||
|
||||
[Benchmark(Description = "MsgPack SourceGen Serialize", Baseline = true)]
|
||||
public byte[] Serialize_MsgPack_SourceGen()
|
||||
=> MessagePackSerializer.Serialize(_testData, _msgPackOptions);
|
||||
|
||||
[Benchmark(Description = "MsgPack Contractless Serialize")]
|
||||
public byte[] Serialize_MsgPack_Contractless()
|
||||
=> MessagePackSerializer.Serialize(_testData, _msgPackContractlessOptions);
|
||||
|
||||
#endregion
|
||||
|
||||
#region Deserialize Benchmarks
|
||||
|
||||
[Benchmark(Description = "AcBinary Runtime Deserialize")]
|
||||
public PrimitiveBenchmarkModel? Deserialize_AcBinary_Runtime()
|
||||
=> AcBinaryDeserializer.Deserialize<PrimitiveBenchmarkModel>(_runtimeSerializedData);
|
||||
|
||||
[Benchmark(Description = "MsgPack SourceGen Deserialize")]
|
||||
public PrimitiveBenchmarkModel? Deserialize_MsgPack_SourceGen()
|
||||
=> MessagePackSerializer.Deserialize<PrimitiveBenchmarkModel>(_msgPackData, _msgPackOptions);
|
||||
|
||||
[Benchmark(Description = "MsgPack Contractless Deserialize")]
|
||||
public PrimitiveBenchmarkModel? Deserialize_MsgPack_Contractless()
|
||||
=> MessagePackSerializer.Deserialize<PrimitiveBenchmarkModel>(_msgPackContractlessData, _msgPackContractlessOptions);
|
||||
|
||||
#endregion
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Repeated string benchmark model - tests string interning performance.
|
||||
/// MessagePack attributes added for fair comparison.
|
||||
/// </summary>
|
||||
[AcBinarySerializable]
|
||||
[MessagePackObject]
|
||||
public class RepeatedStringBenchmarkModel
|
||||
{
|
||||
[Key(0)] public int Id { get; set; }
|
||||
[Key(1)] public string Status { get; set; } = "";
|
||||
[Key(2)] public string Category { get; set; } = "";
|
||||
[Key(3)] public string Priority { get; set; } = "";
|
||||
[Key(4)] public string Type { get; set; } = "";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Benchmark for types with repeated string values - where AcBinary string interning helps.
|
||||
/// Compares against both MessagePack SourceGen and Contractless modes.
|
||||
/// </summary>
|
||||
[ShortRunJob]
|
||||
[MemoryDiagnoser]
|
||||
[RankColumn]
|
||||
public class RepeatedStringBenchmark
|
||||
{
|
||||
private List<RepeatedStringBenchmarkModel> _items = null!;
|
||||
private byte[] _acBinaryData = null!;
|
||||
private byte[] _msgPackData = null!;
|
||||
private byte[] _msgPackContractlessData = null!;
|
||||
|
||||
private AcBinarySerializerOptions _binaryOptions = null!;
|
||||
private MessagePackSerializerOptions _msgPackOptions = null!;
|
||||
private MessagePackSerializerOptions _msgPackContractlessOptions = null!;
|
||||
|
||||
[Params(100, 500)]
|
||||
public int ItemCount { get; set; }
|
||||
|
||||
[GlobalSetup]
|
||||
public void Setup()
|
||||
{
|
||||
_items = Enumerable.Range(0, ItemCount).Select(i => new RepeatedStringBenchmarkModel
|
||||
{
|
||||
Id = i,
|
||||
Status = i % 3 == 0 ? "Pending" : i % 3 == 1 ? "Processing" : "Completed",
|
||||
Category = $"Category_{i % 5}",
|
||||
Priority = i % 2 == 0 ? "High" : "Low",
|
||||
Type = i % 4 == 0 ? "TypeA" : i % 4 == 1 ? "TypeB" : i % 4 == 2 ? "TypeC" : "TypeD"
|
||||
}).ToList();
|
||||
|
||||
_binaryOptions = AcBinarySerializerOptions.WithoutReferenceHandling;
|
||||
_msgPackOptions = MessagePackSerializerOptions.Standard;
|
||||
_msgPackContractlessOptions = ContractlessStandardResolver.Options.WithCompression(MessagePackCompression.None);
|
||||
|
||||
_acBinaryData = AcBinarySerializer.Serialize(_items, _binaryOptions);
|
||||
_msgPackData = MessagePackSerializer.Serialize(_items, _msgPackOptions);
|
||||
_msgPackContractlessData = MessagePackSerializer.Serialize(_items, _msgPackContractlessOptions);
|
||||
|
||||
Console.WriteLine($"=== Repeated Strings ({ItemCount} items) ===");
|
||||
Console.WriteLine($"AcBinary: {_acBinaryData.Length} bytes");
|
||||
Console.WriteLine($"MsgPack SourceGen: {_msgPackData.Length} bytes");
|
||||
Console.WriteLine($"MsgPack Contractless: {_msgPackContractlessData.Length} bytes");
|
||||
}
|
||||
|
||||
[Benchmark(Description = "AcBinary Serialize")]
|
||||
public byte[] Serialize_AcBinary()
|
||||
=> AcBinarySerializer.Serialize(_items, _binaryOptions);
|
||||
|
||||
[Benchmark(Description = "MsgPack SourceGen Serialize", Baseline = true)]
|
||||
public byte[] Serialize_MsgPack_SourceGen()
|
||||
=> MessagePackSerializer.Serialize(_items, _msgPackOptions);
|
||||
|
||||
[Benchmark(Description = "MsgPack Contractless Serialize")]
|
||||
public byte[] Serialize_MsgPack_Contractless()
|
||||
=> MessagePackSerializer.Serialize(_items, _msgPackContractlessOptions);
|
||||
|
||||
[Benchmark(Description = "AcBinary Deserialize")]
|
||||
public List<RepeatedStringBenchmarkModel>? Deserialize_AcBinary()
|
||||
=> AcBinaryDeserializer.Deserialize<List<RepeatedStringBenchmarkModel>>(_acBinaryData);
|
||||
|
||||
[Benchmark(Description = "MsgPack SourceGen Deserialize")]
|
||||
public List<RepeatedStringBenchmarkModel>? Deserialize_MsgPack_SourceGen()
|
||||
=> MessagePackSerializer.Deserialize<List<RepeatedStringBenchmarkModel>>(_msgPackData, _msgPackOptions);
|
||||
|
||||
[Benchmark(Description = "MsgPack Contractless Deserialize")]
|
||||
public List<RepeatedStringBenchmarkModel>? Deserialize_MsgPack_Contractless()
|
||||
=> MessagePackSerializer.Deserialize<List<RepeatedStringBenchmarkModel>>(_msgPackContractlessData, _msgPackContractlessOptions);
|
||||
}
|
||||
|
|
@ -0,0 +1,66 @@
|
|||
using AyCode.Core.Helpers;
|
||||
using BenchmarkDotNet.Attributes;
|
||||
using Microsoft.VSDiagnostics;
|
||||
|
||||
namespace AyCode.Core.Benchmarks;
|
||||
[CPUUsageDiagnoser]
|
||||
public class TaskHelperBenchmarks
|
||||
{
|
||||
private volatile bool _flag;
|
||||
private int _counter;
|
||||
private Action _incrementAction = null !;
|
||||
private Func<int> _incrementFunc = null !;
|
||||
private Func<Task<int>> _incrementAsyncFunc = null !;
|
||||
[GlobalSetup]
|
||||
public void Setup()
|
||||
{
|
||||
_incrementAction = () => _counter++;
|
||||
_incrementFunc = () => ++_counter;
|
||||
_incrementAsyncFunc = async () =>
|
||||
{
|
||||
await Task.Yield();
|
||||
return ++_counter;
|
||||
};
|
||||
}
|
||||
|
||||
[IterationSetup]
|
||||
public void IterationSetup()
|
||||
{
|
||||
_flag = true; // Pre-set for immediate success
|
||||
_counter = 0;
|
||||
}
|
||||
|
||||
#region WaitToAsync Benchmarks
|
||||
[Benchmark(Description = "WaitToAsync - immediate success")]
|
||||
[BenchmarkCategory("WaitToAsync")]
|
||||
public Task<bool> WaitToAsync_ImmediateSuccess() => TaskHelper.WaitToAsync(() => _flag, 1000, 1);
|
||||
[Benchmark(Description = "WaitToAsync - short timeout (100ms)")]
|
||||
[BenchmarkCategory("WaitToAsync")]
|
||||
public Task<bool> WaitToAsync_ShortTimeout() => TaskHelper.WaitToAsync(() => true, 100, 1);
|
||||
#endregion
|
||||
#region ToThreadPoolTask Benchmarks
|
||||
[Benchmark(Description = "ToThreadPoolTask - Action")]
|
||||
[BenchmarkCategory("ThreadPool")]
|
||||
public Task ToThreadPoolTask_Action() => _incrementAction.ToThreadPoolTask();
|
||||
[Benchmark(Description = "ToThreadPoolTask - Func<T>")]
|
||||
[BenchmarkCategory("ThreadPool")]
|
||||
public Task<int> ToThreadPoolTask_FuncT() => _incrementFunc.ToThreadPoolTask();
|
||||
[Benchmark(Description = "ToThreadPoolTask - Func<Task<T>>")]
|
||||
[BenchmarkCategory("ThreadPool")]
|
||||
public Task<int> ToThreadPoolTask_FuncTaskT() => _incrementAsyncFunc.ToThreadPoolTask();
|
||||
[Benchmark(Description = "Task.Run baseline - Action")]
|
||||
[BenchmarkCategory("ThreadPool")]
|
||||
public Task TaskRun_Action_Baseline() => Task.Run(_incrementAction);
|
||||
#endregion
|
||||
#region Timing Method Comparison
|
||||
[Benchmark(Description = "DateTime.UtcNow.Ticks")]
|
||||
[BenchmarkCategory("Timing")]
|
||||
public long DateTimeUtcNow_Ticks() => DateTime.UtcNow.Ticks;
|
||||
[Benchmark(Description = "Environment.TickCount64")]
|
||||
[BenchmarkCategory("Timing")]
|
||||
public long EnvironmentTickCount64() => Environment.TickCount64;
|
||||
[Benchmark(Description = "DateTime.UtcNow.AddMilliseconds")]
|
||||
[BenchmarkCategory("Timing")]
|
||||
public long DateTimeUtcNow_AddMilliseconds() => DateTime.UtcNow.AddMilliseconds(1000).Ticks;
|
||||
#endregion
|
||||
}
|
||||
|
|
@ -0,0 +1,149 @@
|
|||
using BenchmarkDotNet.Attributes;
|
||||
using System.Runtime.CompilerServices;
|
||||
|
||||
namespace AyCode.Benchmark;
|
||||
|
||||
/// <summary>
|
||||
/// Benchmarks comparing value-by-copy vs 'in' parameter passing for large value types.
|
||||
/// Tests decimal (16 bytes), DateTimeOffset (16 bytes), and Guid (16 bytes).
|
||||
/// </summary>
|
||||
[MemoryDiagnoser]
|
||||
public class ValueTypePassingBenchmark
|
||||
{
|
||||
private decimal _decimal;
|
||||
private DateTimeOffset _dateTimeOffset;
|
||||
private Guid _guid;
|
||||
private byte[] _buffer = null !;
|
||||
private int _position;
|
||||
[GlobalSetup]
|
||||
public void Setup()
|
||||
{
|
||||
_decimal = 12345.6789m;
|
||||
_dateTimeOffset = DateTimeOffset.Now;
|
||||
_guid = Guid.NewGuid();
|
||||
_buffer = new byte[1024];
|
||||
}
|
||||
|
||||
// ============ DECIMAL (16 bytes) ============
|
||||
[Benchmark]
|
||||
public void WriteDecimal_ByValue()
|
||||
{
|
||||
_position = 0;
|
||||
for (int i = 0; i < 1000; i++)
|
||||
{
|
||||
WriteDecimalByValue(_decimal);
|
||||
}
|
||||
}
|
||||
|
||||
[Benchmark]
|
||||
public void WriteDecimal_ByIn()
|
||||
{
|
||||
_position = 0;
|
||||
for (int i = 0; i < 1000; i++)
|
||||
{
|
||||
WriteDecimalByIn(in _decimal);
|
||||
}
|
||||
}
|
||||
|
||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||
private void WriteDecimalByValue(decimal value)
|
||||
{
|
||||
Span<int> bits = stackalloc int[4];
|
||||
decimal.TryGetBits(value, bits, out _);
|
||||
System.Runtime.InteropServices.MemoryMarshal.AsBytes(bits).CopyTo(_buffer.AsSpan(_position, 16));
|
||||
_position += 16;
|
||||
if (_position > 900)
|
||||
_position = 0;
|
||||
}
|
||||
|
||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||
private void WriteDecimalByIn(in decimal value)
|
||||
{
|
||||
Span<int> bits = stackalloc int[4];
|
||||
decimal.TryGetBits(value, bits, out _);
|
||||
System.Runtime.InteropServices.MemoryMarshal.AsBytes(bits).CopyTo(_buffer.AsSpan(_position, 16));
|
||||
_position += 16;
|
||||
if (_position > 900)
|
||||
_position = 0;
|
||||
}
|
||||
|
||||
// ============ DATETIMEOFFSET (16 bytes) ============
|
||||
[Benchmark]
|
||||
public void WriteDateTimeOffset_ByValue()
|
||||
{
|
||||
_position = 0;
|
||||
for (int i = 0; i < 1000; i++)
|
||||
{
|
||||
WriteDateTimeOffsetByValue(_dateTimeOffset);
|
||||
}
|
||||
}
|
||||
|
||||
[Benchmark]
|
||||
public void WriteDateTimeOffset_ByIn()
|
||||
{
|
||||
_position = 0;
|
||||
for (int i = 0; i < 1000; i++)
|
||||
{
|
||||
WriteDateTimeOffsetByIn(in _dateTimeOffset);
|
||||
}
|
||||
}
|
||||
|
||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||
private void WriteDateTimeOffsetByValue(DateTimeOffset value)
|
||||
{
|
||||
Unsafe.WriteUnaligned(ref _buffer[_position], value.UtcTicks);
|
||||
Unsafe.WriteUnaligned(ref _buffer[_position + 8], (short)value.Offset.TotalMinutes);
|
||||
_position += 10;
|
||||
if (_position > 900)
|
||||
_position = 0;
|
||||
}
|
||||
|
||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||
private void WriteDateTimeOffsetByIn(in DateTimeOffset value)
|
||||
{
|
||||
Unsafe.WriteUnaligned(ref _buffer[_position], value.UtcTicks);
|
||||
Unsafe.WriteUnaligned(ref _buffer[_position + 8], (short)value.Offset.TotalMinutes);
|
||||
_position += 10;
|
||||
if (_position > 900)
|
||||
_position = 0;
|
||||
}
|
||||
|
||||
// ============ GUID (16 bytes) ============
|
||||
[Benchmark]
|
||||
public void WriteGuid_ByValue()
|
||||
{
|
||||
_position = 0;
|
||||
for (int i = 0; i < 1000; i++)
|
||||
{
|
||||
WriteGuidByValue(_guid);
|
||||
}
|
||||
}
|
||||
|
||||
[Benchmark]
|
||||
public void WriteGuid_ByIn()
|
||||
{
|
||||
_position = 0;
|
||||
for (int i = 0; i < 1000; i++)
|
||||
{
|
||||
WriteGuidByIn(in _guid);
|
||||
}
|
||||
}
|
||||
|
||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||
private void WriteGuidByValue(Guid value)
|
||||
{
|
||||
value.TryWriteBytes(_buffer.AsSpan(_position, 16));
|
||||
_position += 16;
|
||||
if (_position > 900)
|
||||
_position = 0;
|
||||
}
|
||||
|
||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||
private void WriteGuidByIn(in Guid value)
|
||||
{
|
||||
value.TryWriteBytes(_buffer.AsSpan(_position, 16));
|
||||
_position += 16;
|
||||
if (_position > 900)
|
||||
_position = 0;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,49 @@
|
|||
using AyCode.Core.Serializers.Binaries;
|
||||
using AyCode.Core.Tests.TestModels;
|
||||
using System.Runtime.CompilerServices;
|
||||
|
||||
namespace AyCode.Core.Benchmarks.Workloads.Scenarios;
|
||||
|
||||
/// <summary>
|
||||
/// AcBinary benchmark, Byte[] I/O mode. The headline AcBinary row in every cell — compared
|
||||
/// against <see cref="MemoryPackBenchmark{T}"/> as the SOTA baseline.
|
||||
/// </summary>
|
||||
public sealed class AcBinaryBenchmark<T> : ISerializerBenchmark where T : class
|
||||
{
|
||||
private readonly T _order;
|
||||
private readonly AcBinarySerializerOptions _options;
|
||||
private readonly byte[] _serialized;
|
||||
|
||||
public BenchmarkEngine Engine => BenchmarkEngine.AcBinary;
|
||||
public BenchmarkIoMode IoMode => BenchmarkIoMode.ByteArray;
|
||||
public BenchmarkDispatchMode DispatchMode => _options.UseGeneratedCode ? BenchmarkDispatchMode.SGen : BenchmarkDispatchMode.Runtime;
|
||||
public Type OrderType => typeof(T);
|
||||
public string OptionsPreset { get; }
|
||||
public int SerializedSize => _serialized.Length;
|
||||
public long SetupSerializeAllocBytes => 0;
|
||||
public long SetupDeserializeAllocBytes => 0;
|
||||
public string OptionsDescription => BenchmarkOptions.BuildAcBinary(_options);
|
||||
|
||||
public AcBinaryBenchmark(T order, AcBinarySerializerOptions options, string optionsPreset)
|
||||
{
|
||||
_order = order;
|
||||
_options = options;
|
||||
OptionsPreset = optionsPreset;
|
||||
|
||||
_serialized = AcBinarySerializer.Serialize(order, options);
|
||||
}
|
||||
|
||||
[MethodImpl(MethodImplOptions.NoInlining)]
|
||||
public void Serialize() => AcBinarySerializer.Serialize(_order, _options);
|
||||
|
||||
[MethodImpl(MethodImplOptions.NoInlining)]
|
||||
public void Deserialize() => AcBinaryDeserializer.Deserialize<T>(_serialized, _options);
|
||||
|
||||
public bool VerifyRoundTrip()
|
||||
{
|
||||
var bytes = AcBinarySerializer.Serialize(_order, _options);
|
||||
var roundTripped = AcBinaryDeserializer.Deserialize<T>(bytes, _options);
|
||||
|
||||
return RoundTripValidator.DeepEqualsViaJson(_order, roundTripped);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,70 @@
|
|||
using AyCode.Core.Serializers.Binaries;
|
||||
using AyCode.Core.Tests.TestModels;
|
||||
using System.Buffers;
|
||||
using System.Runtime.CompilerServices;
|
||||
|
||||
namespace AyCode.Core.Benchmarks.Workloads.Scenarios;
|
||||
|
||||
/// <summary>
|
||||
/// Benchmarks AcBinary via the IBufferWriter overload with a pre-allocated, reused ArrayBufferWriter.
|
||||
/// Realistic IBufferWriter usage pattern: caller owns + reuses the writer (zero alloc per call after warmup).
|
||||
/// </summary>
|
||||
public sealed class AcBinaryBufferWriterBenchmark<T> : ISerializerBenchmark where T : class
|
||||
{
|
||||
private readonly T _order;
|
||||
private readonly AcBinarySerializerOptions _options;
|
||||
private readonly byte[] _serialized;
|
||||
private readonly ArrayBufferWriter<byte> _bufferWriter;
|
||||
|
||||
public BenchmarkEngine Engine => BenchmarkEngine.AcBinary;
|
||||
public BenchmarkIoMode IoMode => BenchmarkIoMode.BufWrReuse;
|
||||
public BenchmarkDispatchMode DispatchMode => _options.UseGeneratedCode ? BenchmarkDispatchMode.SGen : BenchmarkDispatchMode.Runtime;
|
||||
public Type OrderType => typeof(T);
|
||||
public string OptionsPreset { get; }
|
||||
public int SerializedSize => _serialized.Length;
|
||||
public long SetupSerializeAllocBytes { get; }
|
||||
public long SetupDeserializeAllocBytes => 0;
|
||||
public string OptionsDescription => BenchmarkOptions.BuildAcBinary(_options);
|
||||
|
||||
public AcBinaryBufferWriterBenchmark(T order, AcBinarySerializerOptions options, string optionsPreset)
|
||||
{
|
||||
_order = order;
|
||||
_options = options;
|
||||
OptionsPreset = optionsPreset;
|
||||
|
||||
_serialized = AcBinarySerializer.Serialize(order, options);
|
||||
|
||||
// Measure ONLY the BufferWriter infrastructure setup on the serialize side (excluding the
|
||||
// helper Serialize above). Deserialize side reads directly from `_serialized` byte[] — no
|
||||
// dedicated setup allocation, hence SetupDeserializeAllocBytes = 0.
|
||||
GC.Collect(); GC.WaitForPendingFinalizers(); GC.Collect();
|
||||
var beforeSetup = GC.GetAllocatedBytesForCurrentThread();
|
||||
_bufferWriter = new ArrayBufferWriter<byte>(_serialized.Length * 2);
|
||||
var afterSetup = GC.GetAllocatedBytesForCurrentThread();
|
||||
SetupSerializeAllocBytes = afterSetup - beforeSetup;
|
||||
}
|
||||
|
||||
[MethodImpl(MethodImplOptions.NoInlining)]
|
||||
public void Serialize()
|
||||
{
|
||||
_bufferWriter.ResetWrittenCount(); // reuse — no alloc, no zeroing
|
||||
AcBinarySerializer.Serialize(_order, _bufferWriter, _options);
|
||||
}
|
||||
|
||||
// BufWr semantic: read from a ReadOnlySequence<byte> (the ROS overload), NOT from byte[] —
|
||||
// single-segment array-backed sequence triggers the fast-path in AcBinaryDeserializer.cs:298 which
|
||||
// redirects to the byte[] overload. This means the bench actually exercises the ROS-input path
|
||||
// (the production-realistic surface for SignalR / Pipe consumers) rather than secretly testing
|
||||
// byte[] Deser under the BufWr label.
|
||||
[MethodImpl(MethodImplOptions.NoInlining)]
|
||||
public void Deserialize() => AcBinaryDeserializer.Deserialize<T>(new ReadOnlySequence<byte>(_serialized), _options);
|
||||
|
||||
public bool VerifyRoundTrip()
|
||||
{
|
||||
_bufferWriter.ResetWrittenCount();
|
||||
AcBinarySerializer.Serialize(_order, _bufferWriter, _options);
|
||||
|
||||
var roundTripped = AcBinaryDeserializer.Deserialize<T>(new ReadOnlySequence<byte>(_bufferWriter.WrittenMemory), _options);
|
||||
return RoundTripValidator.DeepEqualsViaJson(_order, roundTripped);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,64 @@
|
|||
using AyCode.Core.Serializers.Binaries;
|
||||
using AyCode.Core.Tests.TestModels;
|
||||
using System.Buffers;
|
||||
using System.Runtime.CompilerServices;
|
||||
|
||||
namespace AyCode.Core.Benchmarks.Workloads.Scenarios;
|
||||
|
||||
/// <summary>
|
||||
/// Benchmarks AcBinary via the IBufferWriter overload, allocating a FRESH ArrayBufferWriter on EVERY call.
|
||||
/// One-shot scenario — represents code that doesn't reuse a writer across calls.
|
||||
/// Uses BufferWriterChunkSize=4096 (production-realistic, SignalR-aligned) instead of the 65535 default —
|
||||
/// otherwise AcBinary would request 64KB upfront via GetSpan(), forcing the fresh ABW to allocate 64KB
|
||||
/// regardless of payload size (heavy over-allocation for small payloads).
|
||||
/// </summary>
|
||||
public sealed class AcBinaryFreshBufferWriterBenchmark<T> : ISerializerBenchmark where T : class
|
||||
{
|
||||
private readonly T _order;
|
||||
private readonly AcBinarySerializerOptions _options;
|
||||
private readonly byte[] _serialized;
|
||||
|
||||
public BenchmarkEngine Engine => BenchmarkEngine.AcBinary;
|
||||
public BenchmarkIoMode IoMode => BenchmarkIoMode.BufWrNew;
|
||||
public BenchmarkDispatchMode DispatchMode => _options.UseGeneratedCode ? BenchmarkDispatchMode.SGen : BenchmarkDispatchMode.Runtime;
|
||||
public Type OrderType => typeof(T);
|
||||
public string OptionsPreset { get; }
|
||||
public int SerializedSize => _serialized.Length;
|
||||
public long SetupSerializeAllocBytes => 0;
|
||||
public long SetupDeserializeAllocBytes => 0;
|
||||
public string OptionsDescription => BenchmarkOptions.BuildAcBinary(_options, $", BufferSize={_options.BufferWriterChunkSize}B");
|
||||
|
||||
public AcBinaryFreshBufferWriterBenchmark(T order, AcBinarySerializerOptions options, string optionsPreset)
|
||||
{
|
||||
_order = order;
|
||||
// BufferWriterChunkSize comes from the caller (central source of truth in CreateSerializers
|
||||
// — the binaryFastMode4KbChunk options instance). Do NOT mutate _options here; tune the chunk
|
||||
// size in CreateSerializers only.
|
||||
_options = options;
|
||||
OptionsPreset = optionsPreset;
|
||||
_serialized = AcBinarySerializer.Serialize(order, _options);
|
||||
}
|
||||
|
||||
[MethodImpl(MethodImplOptions.NoInlining)]
|
||||
public void Serialize()
|
||||
{
|
||||
var abw = new ArrayBufferWriter<byte>(); // FRESH every call — alloc + grow as needed
|
||||
AcBinarySerializer.Serialize(_order, abw, _options);
|
||||
}
|
||||
|
||||
// BufWr semantic: read from a ReadOnlySequence<byte> (the ROS overload), NOT from byte[] —
|
||||
// single-segment array-backed sequence triggers the fast-path in AcBinaryDeserializer.cs:298 which
|
||||
// redirects to the byte[] overload. This means the bench actually exercises the ROS-input path
|
||||
// (the production-realistic surface for SignalR / Pipe consumers) rather than secretly testing
|
||||
// byte[] Deser under the BufWr label.
|
||||
[MethodImpl(MethodImplOptions.NoInlining)]
|
||||
public void Deserialize() => AcBinaryDeserializer.Deserialize<T>(new ReadOnlySequence<byte>(_serialized), _options);
|
||||
|
||||
public bool VerifyRoundTrip()
|
||||
{
|
||||
var abw = new ArrayBufferWriter<byte>();
|
||||
AcBinarySerializer.Serialize(_order, abw, _options);
|
||||
var roundTripped = AcBinaryDeserializer.Deserialize<T>(new ReadOnlySequence<byte>(abw.WrittenMemory), _options);
|
||||
return RoundTripValidator.DeepEqualsViaJson(_order, roundTripped);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,191 @@
|
|||
using AyCode.Core.Serializers.Binaries;
|
||||
using AyCode.Core.Tests.Serialization; // DrainFromAsync extension (test-only, used by benchmark)
|
||||
using AyCode.Core.Tests.TestModels;
|
||||
using System.IO.Pipelines;
|
||||
using System.Runtime.CompilerServices;
|
||||
|
||||
namespace AyCode.Core.Benchmarks.Workloads.Scenarios;
|
||||
|
||||
/// <summary>
|
||||
/// Same chunked-framed AsyncPipe code path as <see cref="AcBinaryNamedPipeBenchmark{T}"/>, but the transport
|
||||
/// is an in-memory <see cref="System.IO.Pipelines.Pipe"/> instead of a kernel <c>NamedPipe</c>. The Pipe's
|
||||
/// <c>Writer</c>/<c>Reader</c> pair is a managed-only zero-copy slab handoff — no syscalls, no kernel
|
||||
/// buffer copy, no IRP queueing.
|
||||
///
|
||||
/// <para><b>Why this benchmark matters</b>: by holding ALL other variables constant (same SerializeChunkedFramed,
|
||||
/// same AsyncPipeReaderInput, same drain task, same consumer task, same multi-message wire format), this
|
||||
/// row isolates the <b>kernel-NamedPipe transport overhead</b> from the chunked-streaming framework's pure
|
||||
/// CPU cost. The expected delta vs <see cref="AcBinaryNamedPipeBenchmark{T}"/>: per-chunk overhead drops from
|
||||
/// ~25-30 µs (kernel-syscall pair + IRP) to ~1-2 µs (managed slab handoff). Multi-chunk Large-message rows
|
||||
/// should converge dramatically toward <see cref="AcBinaryNamedPipeRawByteArrayBenchmark{T}"/>.</para>
|
||||
///
|
||||
/// <para><b>Real-world relevance</b>: in-memory Pipe is the typical primitive used for cross-thread serializer
|
||||
/// pipelines inside a single process (e.g. SignalR's Kestrel transport adapter, gRPC framework internals,
|
||||
/// custom message brokers). The numbers from this row reflect that scenario, NOT the kernel-pipe loopback
|
||||
/// of the NamedPipe benchmark.</para>
|
||||
/// </summary>
|
||||
public sealed class AcBinaryInMemoryPipeBenchmark<T> : ISerializerBenchmark, IDisposable where T : class
|
||||
{
|
||||
private readonly T _order;
|
||||
private readonly AcBinarySerializerOptions _options;
|
||||
private readonly byte[] _serialized; // for SerializedSize reporting only
|
||||
|
||||
// Long-lived in-memory pipe lifecycle (set up once in ctor — NOT timed).
|
||||
private readonly Pipe _pipe;
|
||||
private readonly PipeWriter _pipeWriter;
|
||||
private readonly PipeReader _pipeReader;
|
||||
|
||||
// Long-lived multi-message receive infrastructure (set up once in ctor) — same pattern as the NamedPipe
|
||||
// variant: drain pumps reader into AsyncPipeReaderInput, consumer task drives Deserialize<T>(input).
|
||||
private readonly AsyncPipeReaderInput _input;
|
||||
private readonly CancellationTokenSource _cts;
|
||||
private readonly Task _drainTask;
|
||||
private readonly Task _consumerTask;
|
||||
private readonly ManualResetEventSlim _consumeRequest = new(false);
|
||||
private readonly ManualResetEventSlim _consumeDone = new(false);
|
||||
private object? _lastResult;
|
||||
private bool _captureResult;
|
||||
private bool _disposed;
|
||||
|
||||
public BenchmarkEngine Engine => BenchmarkEngine.AcBinary;
|
||||
public BenchmarkIoMode IoMode => BenchmarkIoMode.InMemoryPipe;
|
||||
public BenchmarkDispatchMode DispatchMode => _options.UseGeneratedCode ? BenchmarkDispatchMode.SGen : BenchmarkDispatchMode.Runtime;
|
||||
public Type OrderType => typeof(T);
|
||||
public string OptionsPreset { get; }
|
||||
public int SerializedSize => _serialized.Length;
|
||||
public long SetupSerializeAllocBytes { get; }
|
||||
public long SetupDeserializeAllocBytes { get; }
|
||||
public bool IsRoundTripOnly => true;
|
||||
public string OptionsDescription => BenchmarkOptions.BuildAcBinary(_options, $", BufferSize={_options.BufferWriterChunkSize}B, Transport=Pipe(in-memory,multiMessage,2-task)");
|
||||
|
||||
public AcBinaryInMemoryPipeBenchmark(T order, AcBinarySerializerOptions options, string optionsPreset)
|
||||
{
|
||||
_order = order;
|
||||
_options = options;
|
||||
OptionsPreset = optionsPreset;
|
||||
|
||||
_serialized = AcBinarySerializer.Serialize(order, _options);
|
||||
|
||||
// === SERIALIZE-side setup measurement ===
|
||||
// In-memory Pipe construction. NO kernel-pipe pair, NO Connect handshake — just a managed Pipe object
|
||||
// and a reference to its Writer side. PipeWriterImpl (parallel-flush capable, NOT StreamPipeWriter).
|
||||
GC.Collect(); GC.WaitForPendingFinalizers(); GC.Collect();
|
||||
var beforeSer = GC.GetAllocatedBytesForCurrentThread();
|
||||
_pipe = new Pipe();
|
||||
_pipeWriter = _pipe.Writer;
|
||||
var afterSer = GC.GetAllocatedBytesForCurrentThread();
|
||||
SetupSerializeAllocBytes = afterSer - beforeSer;
|
||||
|
||||
// === DESERIALIZE-side setup measurement ===
|
||||
// PipeReader reference + AsyncPipeReaderInput (ArrayPool rent + ManualResetEventSlim) + drain task +
|
||||
// consumer task scaffolding. Identical to the NamedPipe variant on the receive side.
|
||||
GC.Collect(); GC.WaitForPendingFinalizers(); GC.Collect();
|
||||
var beforeDes = GC.GetAllocatedBytesForCurrentThread();
|
||||
|
||||
_pipeReader = _pipe.Reader;
|
||||
_input = new AsyncPipeReaderInput(_options.BufferWriterChunkSize * 2, multiMessage: true);
|
||||
_cts = new CancellationTokenSource();
|
||||
_drainTask = Task.Run(() => _input.DrainFromAsync(_pipeReader, _cts.Token));
|
||||
_consumerTask = Task.Run(ConsumeLoop);
|
||||
|
||||
var afterDes = GC.GetAllocatedBytesForCurrentThread();
|
||||
SetupDeserializeAllocBytes = afterDes - beforeDes;
|
||||
}
|
||||
|
||||
// BG consumer: parks on _consumeRequest, runs Deserialize<T>(_input) when signaled, signals _consumeDone.
|
||||
// Mirror of AcBinaryNamedPipeBenchmark.ConsumeLoop — same pattern, same MRES protocol.
|
||||
private void ConsumeLoop()
|
||||
{
|
||||
var ct = _cts.Token;
|
||||
try
|
||||
{
|
||||
while (true)
|
||||
{
|
||||
_consumeRequest.Wait(ct);
|
||||
if (ct.IsCancellationRequested) return;
|
||||
_consumeRequest.Reset();
|
||||
|
||||
try
|
||||
{
|
||||
var result = AcBinaryDeserializer.Deserialize<T>(_input, _options);
|
||||
if (_captureResult) _lastResult = result;
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Swallow — see ConsumeLoop in NamedPipe variant for rationale.
|
||||
}
|
||||
finally
|
||||
{
|
||||
_consumeDone.Set();
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
// Cooperative cancel — Dispose path. Swallow.
|
||||
}
|
||||
}
|
||||
|
||||
[MethodImpl(MethodImplOptions.NoInlining)]
|
||||
public void Serialize()
|
||||
{
|
||||
// Same 2-task streaming pipeline as NamedPipe variant — only the transport differs (in-memory Pipe
|
||||
// instead of kernel NamedPipe). Per-chunk SerializeChunkedFramed → PipeWriter slab → drain task
|
||||
// reads from PipeReader → input.Feed → consumer Deserialize<T> consumes byte-by-byte.
|
||||
//
|
||||
// Uses the Pipe-overload (instead of the PipeWriter-overload) so the FlushPolicy parameter is
|
||||
// exposed for tuning. Toggle between FlushPolicy.PerChunk (bounded peak memory, per-chunk await
|
||||
// FlushAsync) and FlushPolicy.Coalesced (fire-and-forget per chunk, pipe-coalesced flushes up to
|
||||
// PauseWriterThreshold ~64 KB) to A/B-test the streaming-pipeline overhead. FlushPolicy.PerChunk
|
||||
// is functionally equivalent to the PipeWriter-overload (both internally route to
|
||||
// SerializeToPipeWriterCore with FlushPolicy.PerChunk).
|
||||
_consumeDone.Reset();
|
||||
_consumeRequest.Set();
|
||||
|
||||
AcBinarySerializer.SerializeChunkedFramed(_order, _pipe, _options, FlushPolicy.Coalesced);
|
||||
|
||||
_consumeDone.Wait();
|
||||
}
|
||||
|
||||
[MethodImpl(MethodImplOptions.NoInlining)]
|
||||
public void Deserialize()
|
||||
{
|
||||
// No-op: per-iter round-trip is captured in Serialize(). See IsRoundTripOnly contract.
|
||||
}
|
||||
|
||||
public bool VerifyRoundTrip()
|
||||
{
|
||||
_captureResult = true;
|
||||
try
|
||||
{
|
||||
Serialize();
|
||||
var result = _lastResult as T;
|
||||
return result != null && RoundTripValidator.DeepEqualsViaJson(_order, result);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_captureResult = false;
|
||||
_lastResult = null;
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (_disposed) return;
|
||||
_disposed = true;
|
||||
|
||||
// Cancel drain + consumer tasks → both exit. Pulse _consumeRequest in case consumer is parked.
|
||||
try { _cts.Cancel(); } catch { /* swallow on teardown */ }
|
||||
try { _consumeRequest.Set(); } catch { /* nudge in case consumer Wait is parked */ }
|
||||
try { _drainTask.Wait(TimeSpan.FromSeconds(2)); } catch { /* swallow on teardown */ }
|
||||
try { _consumerTask.Wait(TimeSpan.FromSeconds(2)); } catch { /* swallow on teardown */ }
|
||||
|
||||
// Complete writer + reader (in-memory Pipe — no underlying stream to dispose).
|
||||
try { _pipeWriter.CompleteAsync().AsTask().Wait(TimeSpan.FromSeconds(2)); } catch { /* swallow on teardown */ }
|
||||
try { _pipeReader.Complete(); } catch { /* swallow on teardown */ }
|
||||
try { _input.Dispose(); } catch { /* swallow on teardown */ }
|
||||
try { _consumeRequest.Dispose(); } catch { /* swallow on teardown */ }
|
||||
try { _consumeDone.Dispose(); } catch { /* swallow on teardown */ }
|
||||
try { _cts.Dispose(); } catch { /* swallow on teardown */ }
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,169 @@
|
|||
using AyCode.Core.Serializers.Binaries;
|
||||
using AyCode.Core.Tests.TestModels;
|
||||
using System.Runtime.CompilerServices;
|
||||
|
||||
namespace AyCode.Core.Benchmarks.Workloads.Scenarios;
|
||||
|
||||
/// <summary>
|
||||
/// Raw <c>byte[]</c> over an in-memory cross-thread handoff — NO transport (no NamedPipe, no Pipe, no
|
||||
/// Channel<see langword="<T>"/>). Calling thread serialises into a fresh <c>byte[]</c>, hands it to a
|
||||
/// background consumer task via a single byte[] slot + MRES pair; the consumer deserialises and signals done.
|
||||
///
|
||||
/// <para><b>Why this benchmark matters</b>: completes the 2x2 transport × wire-format matrix:</para>
|
||||
/// <list type="bullet">
|
||||
/// <item><description><b>NamedPipe + Chunked</b> = <see cref="AcBinaryNamedPipeBenchmark{T}"/></description></item>
|
||||
/// <item><description><b>NamedPipe + Raw</b> = <see cref="AcBinaryNamedPipeRawByteArrayBenchmark{T}"/></description></item>
|
||||
/// <item><description><b>In-memory Pipe + Chunked</b> = <see cref="AcBinaryInMemoryPipeBenchmark{T}"/></description></item>
|
||||
/// <item><description><b>In-memory + Raw</b> = THIS row — apples-to-apples baseline for the in-memory chunked row</description></item>
|
||||
/// </list>
|
||||
/// <para>Side-by-side with <see cref="AcBinaryInMemoryPipeBenchmark{T}"/> this isolates the chunked-streaming
|
||||
/// framework's pure CPU cost, with the same in-memory transport (zero kernel involvement) on both sides.
|
||||
/// Side-by-side with <see cref="AcBinaryNamedPipeRawByteArrayBenchmark{T}"/> this isolates the kernel-NamedPipe
|
||||
/// overhead on the raw-byte[] side.</para>
|
||||
/// </summary>
|
||||
public sealed class AcBinaryInMemoryRawByteArrayBenchmark<T> : ISerializerBenchmark, IDisposable where T : class
|
||||
{
|
||||
private readonly T _order;
|
||||
private readonly AcBinarySerializerOptions _options;
|
||||
private readonly byte[] _serialized; // for SerializedSize reporting only
|
||||
|
||||
// Long-lived consumer-task infrastructure (Deserialize on BG thread, signaled per iter).
|
||||
// No transport — just a byte[] slot for handoff between calling thread and consumer task.
|
||||
private readonly CancellationTokenSource _cts;
|
||||
private readonly Task _consumerTask;
|
||||
private readonly ManualResetEventSlim _consumeRequest = new(false);
|
||||
private readonly ManualResetEventSlim _consumeDone = new(false);
|
||||
private byte[]? _pendingBytes; // calling thread → consumer task handoff slot
|
||||
private object? _lastResult; // captured during VerifyRoundTrip; null in benchmark iters
|
||||
private bool _captureResult;
|
||||
private bool _disposed;
|
||||
|
||||
public BenchmarkEngine Engine => BenchmarkEngine.AcBinary;
|
||||
public BenchmarkIoMode IoMode => BenchmarkIoMode.InMemoryRaw;
|
||||
public BenchmarkDispatchMode DispatchMode => _options.UseGeneratedCode ? BenchmarkDispatchMode.SGen : BenchmarkDispatchMode.Runtime;
|
||||
public Type OrderType => typeof(T);
|
||||
public string OptionsPreset { get; }
|
||||
public int SerializedSize => _serialized.Length;
|
||||
public long SetupSerializeAllocBytes { get; }
|
||||
public long SetupDeserializeAllocBytes { get; }
|
||||
public bool IsRoundTripOnly => true;
|
||||
public string OptionsDescription => BenchmarkOptions.BuildAcBinary(_options, $", BufferSize={_options.BufferWriterChunkSize}B, Transport=in-memory(raw,2-task)");
|
||||
|
||||
public AcBinaryInMemoryRawByteArrayBenchmark(T order, AcBinarySerializerOptions options, string optionsPreset)
|
||||
{
|
||||
_order = order;
|
||||
_options = options;
|
||||
OptionsPreset = optionsPreset;
|
||||
|
||||
_serialized = AcBinarySerializer.Serialize(order, _options);
|
||||
|
||||
// === SERIALIZE-side setup measurement ===
|
||||
// Nothing to set up — calling thread allocates byte[] per iter via AcBinarySerializer.Serialize.
|
||||
SetupSerializeAllocBytes = 0;
|
||||
|
||||
// === DESERIALIZE-side setup measurement ===
|
||||
// 1× background consumer-task + 2× MRES (request / done) + cancellation source.
|
||||
GC.Collect(); GC.WaitForPendingFinalizers(); GC.Collect();
|
||||
var beforeDes = GC.GetAllocatedBytesForCurrentThread();
|
||||
_cts = new CancellationTokenSource();
|
||||
_consumerTask = Task.Run(ConsumerLoop);
|
||||
var afterDes = GC.GetAllocatedBytesForCurrentThread();
|
||||
SetupDeserializeAllocBytes = afterDes - beforeDes;
|
||||
}
|
||||
|
||||
// BG consumer: parks on _consumeRequest, picks up the byte[] from _pendingBytes, runs Deserialize<T>(bytes),
|
||||
// signals _consumeDone. Direct in-process handoff — no transport syscall, no buffer copy beyond the byte[]
|
||||
// reference itself (zero-copy by reference).
|
||||
private void ConsumerLoop()
|
||||
{
|
||||
var ct = _cts.Token;
|
||||
try
|
||||
{
|
||||
while (true)
|
||||
{
|
||||
_consumeRequest.Wait(ct);
|
||||
if (ct.IsCancellationRequested) return;
|
||||
_consumeRequest.Reset();
|
||||
|
||||
try
|
||||
{
|
||||
var bytes = _pendingBytes;
|
||||
if (bytes != null)
|
||||
{
|
||||
var result = AcBinaryDeserializer.Deserialize<T>(bytes, _options);
|
||||
if (_captureResult) _lastResult = result;
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Swallow — see ConsumerLoop in NamedPipe variant for rationale.
|
||||
}
|
||||
finally
|
||||
{
|
||||
_consumeDone.Set();
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
// Cooperative cancel — Dispose path. Swallow.
|
||||
}
|
||||
}
|
||||
|
||||
[MethodImpl(MethodImplOptions.NoInlining)]
|
||||
public void Serialize()
|
||||
{
|
||||
// 2-task in-memory pipeline:
|
||||
// 1. Calling thread serialises → fresh byte[] (per-iter alloc, matches AcBinaryBenchmark contract).
|
||||
// 2. Calling thread parks the byte[] into _pendingBytes and signals consumer task. Consumer task
|
||||
// picks up the reference (zero-copy) and runs Deserialize<T>(bytes).
|
||||
// 3. Calling thread waits for _consumeDone (consumer task finished Des).
|
||||
//
|
||||
// Same architectural limitation as the NamedPipe-raw variant: Des cannot start until full bytes
|
||||
// are available. Only the per-iter Ser↔Des thread-handoff overlaps slightly (calling thread starts
|
||||
// signalling and waiting while consumer thread takes the byte[]).
|
||||
var bytes = AcBinarySerializer.Serialize(_order, _options);
|
||||
|
||||
_pendingBytes = bytes;
|
||||
_consumeDone.Reset();
|
||||
_consumeRequest.Set();
|
||||
|
||||
_consumeDone.Wait();
|
||||
}
|
||||
|
||||
[MethodImpl(MethodImplOptions.NoInlining)]
|
||||
public void Deserialize()
|
||||
{
|
||||
// No-op: per-iter round-trip is captured in Serialize(). See IsRoundTripOnly contract.
|
||||
}
|
||||
|
||||
public bool VerifyRoundTrip()
|
||||
{
|
||||
_captureResult = true;
|
||||
try
|
||||
{
|
||||
Serialize();
|
||||
var result = _lastResult as T;
|
||||
return result != null && RoundTripValidator.DeepEqualsViaJson(_order, result);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_captureResult = false;
|
||||
_lastResult = null;
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (_disposed) return;
|
||||
_disposed = true;
|
||||
|
||||
try { _cts.Cancel(); } catch { /* swallow on teardown */ }
|
||||
try { _consumeRequest.Set(); } catch { /* nudge in case consumer Wait is parked */ }
|
||||
try { _consumerTask.Wait(TimeSpan.FromSeconds(2)); } catch { /* swallow on teardown */ }
|
||||
|
||||
try { _consumeRequest.Dispose(); } catch { /* swallow on teardown */ }
|
||||
try { _consumeDone.Dispose(); } catch { /* swallow on teardown */ }
|
||||
try { _cts.Dispose(); } catch { /* swallow on teardown */ }
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,238 @@
|
|||
using AyCode.Core.Serializers.Binaries;
|
||||
using AyCode.Core.Tests.Serialization; // DrainFromAsync extension (test-only, used by benchmark)
|
||||
using AyCode.Core.Tests.TestModels;
|
||||
using System.IO.Pipelines;
|
||||
using System.IO.Pipes;
|
||||
using System.Runtime.CompilerServices;
|
||||
|
||||
namespace AyCode.Core.Benchmarks.Workloads.Scenarios;
|
||||
|
||||
/// <summary>
|
||||
/// Benchmarks AcBinary over a long-lived NamedPipe IPC connection using the AcBinary native streaming API
|
||||
/// (<see cref="AcBinarySerializer.SerializeChunked{T}(T, System.IO.Pipelines.PipeWriter, AcBinarySerializerOptions)"/>
|
||||
/// + <see cref="AsyncPipeReaderInput"/> + <see cref="AsyncPipeReaderInputExtensions.DrainFromAsync"/>).
|
||||
/// Mirrors what a real consumer (e.g. <c>DeserializeFromPipeReaderAsync</c>) does per message:
|
||||
/// long-lived <see cref="AsyncPipeReaderInput"/> with multi-message wire framing on top of a long-lived NamedPipe.
|
||||
///
|
||||
/// <para><b>Architecture</b>:</para>
|
||||
/// <list type="bullet">
|
||||
/// <item>Constructor (NOT timed): sets up <see cref="NamedPipeServerStream"/> + <see cref="NamedPipeClientStream"/>,
|
||||
/// waits for connection, creates one long-lived <see cref="System.IO.Pipelines.PipeWriter"/> /
|
||||
/// <see cref="System.IO.Pipelines.PipeReader"/> pair, ONE long-lived <see cref="AsyncPipeReaderInput"/>
|
||||
/// in <c>multiMessage = true</c> mode, ONE drain Task that pumps <see cref="AsyncPipeReaderInputExtensions.DrainFromAsync"/>
|
||||
/// forever, and ONE deserialize Task that loops <c>AcBinaryDeserializer.Deserialize<T>(input, opts)</c>
|
||||
/// producing into a <see cref="System.Threading.Channels.Channel{T}"/>.</item>
|
||||
/// <item>Per-iteration <see cref="Serialize"/> (timed): sender writes via
|
||||
/// <see cref="AcBinarySerializer.SerializeChunkedFramed{T}(T, System.IO.Pipelines.PipeWriter, AcBinarySerializerOptions)"/>
|
||||
/// — multi-message wire (<c>[201][UINT16][data]...[202]</c>); the <c>[202]</c> end marker arms the input's
|
||||
/// <c>_readPos = -1</c> sentinel, so the next message's first <c>AppendToBuffer</c> recycles the buffer to 0.
|
||||
/// Then receiver awaits the channel for the deserialized result.</item>
|
||||
/// <item><see cref="Deserialize"/> is a no-op (full round-trip captured in <see cref="Serialize"/>);
|
||||
/// <see cref="IsRoundTripOnly"/>=true → Ser ms / SerAlloc oszlopok N/A, RT ms = full round-trip.</item>
|
||||
/// </list>
|
||||
///
|
||||
/// <para><b>Per-iter overhead</b>: 0 new <c>Task.Run</c>, 0 new <c>AsyncPipeReaderInput</c>, 0 new <c>CancellationTokenSource</c>.
|
||||
/// Pure cost = <c>SerializeChunkedFramed</c> (CPU + chunk-onkénti flush) + kernel write/read syscalls + 1 sync barrier
|
||||
/// (channel) + deserialized graph alloc. The "multi-message reuse" pattern enabled by Q4T8 fix (R5K2 minimum: <c>_readPos = -1</c>
|
||||
/// sentinel + <c>AppendToBuffer</c> sliding-window cycling).</para>
|
||||
///
|
||||
/// <para><b>Approximation note</b>: single-process loopback NamedPipe. Real cross-process / cross-machine SignalR
|
||||
/// adds further transport latency (TCP, WebSocket framing) on top. The benchmark gives a lower bound.</para>
|
||||
/// </summary>
|
||||
public sealed class AcBinaryNamedPipeBenchmark<T> : ISerializerBenchmark, IDisposable where T : class
|
||||
{
|
||||
private readonly T _order;
|
||||
private readonly AcBinarySerializerOptions _options;
|
||||
private readonly byte[] _serialized; // for SerializedSize reporting only
|
||||
|
||||
// Long-lived pipe lifecycle (set up once in ctor — NOT timed).
|
||||
private readonly NamedPipeServerStream _pipeServer;
|
||||
private readonly NamedPipeClientStream _pipeClient;
|
||||
private readonly PipeWriter _pipeWriter;
|
||||
private readonly PipeReader _pipeReader;
|
||||
|
||||
// Long-lived multi-message receive infrastructure (set up once in ctor).
|
||||
private readonly AsyncPipeReaderInput _input;
|
||||
private readonly CancellationTokenSource _cts;
|
||||
private readonly Task _drainTask; // BG: PipeReader → input.Feed (continuous pump)
|
||||
private readonly Task _consumerTask; // BG: per-iter Deserialize<T>(input) loop, signaled by calling thread
|
||||
private readonly ManualResetEventSlim _consumeRequest = new(false);
|
||||
private readonly ManualResetEventSlim _consumeDone = new(false);
|
||||
private object? _lastResult; // captured during VerifyRoundTrip; null in benchmark iters
|
||||
private bool _captureResult; // toggle: when true, ConsumeLoop stores result; otherwise discards
|
||||
private bool _disposed;
|
||||
|
||||
public BenchmarkEngine Engine => BenchmarkEngine.AcBinary;
|
||||
public BenchmarkIoMode IoMode => BenchmarkIoMode.NamedPipe;
|
||||
public BenchmarkDispatchMode DispatchMode => _options.UseGeneratedCode ? BenchmarkDispatchMode.SGen : BenchmarkDispatchMode.Runtime;
|
||||
public Type OrderType => typeof(T);
|
||||
public string OptionsPreset { get; }
|
||||
public int SerializedSize => _serialized.Length;
|
||||
public long SetupSerializeAllocBytes { get; }
|
||||
public long SetupDeserializeAllocBytes { get; }
|
||||
public bool IsRoundTripOnly => true;
|
||||
public string OptionsDescription => BenchmarkOptions.BuildAcBinary(_options, $", BufferSize={_options.BufferWriterChunkSize}B, Transport=NamedPipe(long-lived,multiMessage,2-task)");
|
||||
|
||||
public AcBinaryNamedPipeBenchmark(T order, AcBinarySerializerOptions options, string optionsPreset)
|
||||
{
|
||||
_order = order;
|
||||
// BufferWriterChunkSize comes from the caller (central source of truth in CreateSerializers
|
||||
// — the binaryFastMode4KbChunk options instance). Do NOT mutate _options here; tune the chunk
|
||||
// size in CreateSerializers only.
|
||||
_options = options;
|
||||
OptionsPreset = optionsPreset;
|
||||
|
||||
_serialized = AcBinarySerializer.Serialize(order, _options);
|
||||
|
||||
// 1× pipe setup. Kernel-side pipe buffer (inBufferSize / outBufferSize on the server ctor — the
|
||||
// client inherits the server-defined buffer size at connect time) matches BufferWriterChunkSize
|
||||
// exactly: AsyncPipeWriterOutput now treats chunkSize as the chunk-on-wire total size (header +
|
||||
// data), so one WriteFile(chunkSize) syscall lands in exactly one kernel-page slot — page-aligned,
|
||||
// no fragmentation, no IRP reordering. _options.BufferWriterChunkSize is the single tunable source.
|
||||
var pipeName = $"AcBinaryBench-{Guid.NewGuid():N}";
|
||||
|
||||
// === SERIALIZE-side setup measurement ===
|
||||
// pipe-pair (server + client) + connect handshake + writer-side PipeWriter wrapper.
|
||||
GC.Collect(); GC.WaitForPendingFinalizers(); GC.Collect();
|
||||
var beforeSer = GC.GetAllocatedBytesForCurrentThread();
|
||||
|
||||
_pipeServer = new NamedPipeServerStream(pipeName, PipeDirection.In, 1, PipeTransmissionMode.Byte,
|
||||
System.IO.Pipes.PipeOptions.Asynchronous,
|
||||
inBufferSize: _options.BufferWriterChunkSize,
|
||||
outBufferSize: _options.BufferWriterChunkSize);
|
||||
|
||||
_pipeClient = new NamedPipeClientStream(".", pipeName, PipeDirection.Out, System.IO.Pipes.PipeOptions.Asynchronous);
|
||||
|
||||
var serverWait = _pipeServer.WaitForConnectionAsync();
|
||||
_pipeClient.Connect();
|
||||
serverWait.GetAwaiter().GetResult();
|
||||
|
||||
_pipeWriter = PipeWriter.Create(_pipeClient);
|
||||
var afterSer = GC.GetAllocatedBytesForCurrentThread();
|
||||
SetupSerializeAllocBytes = afterSer - beforeSer;
|
||||
|
||||
// === DESERIALIZE-side setup measurement ===
|
||||
// PipeReader wrapper + AsyncPipeReaderInput (ArrayPool rent + ManualResetEventSlim) + drain
|
||||
// task + consumer task scaffolding. Two long-lived BG tasks total: drain pumps bytes from the
|
||||
// kernel pipe into input; consumer drives Deserialize<T>(input) per iter on signal.
|
||||
GC.Collect(); GC.WaitForPendingFinalizers(); GC.Collect();
|
||||
var beforeDes = GC.GetAllocatedBytesForCurrentThread();
|
||||
|
||||
_pipeReader = PipeReader.Create(_pipeServer);
|
||||
_input = new AsyncPipeReaderInput(_options.BufferWriterChunkSize * 2, multiMessage: true);
|
||||
_cts = new CancellationTokenSource();
|
||||
|
||||
// Drain task: pumps PipeReader → input.Feed forever (or until cancel). Single Task.Run for
|
||||
// the full benchmark lifetime — its overhead is amortised across all messages.
|
||||
_drainTask = Task.Run(() => _input.DrainFromAsync(_pipeReader, _cts.Token));
|
||||
|
||||
// Consumer task: per-iter Deserialize<T>(input) loop. Started here once; signaled per-iter via
|
||||
// _consumeRequest. Enables Ser↔Des streaming overlap — calling thread runs SerializeChunkedFramed
|
||||
// while THIS task simultaneously runs Deserialize<T>, both consuming/producing through the
|
||||
// sliding-window buffer pipelined by the drain task.
|
||||
_consumerTask = Task.Run(ConsumeLoop);
|
||||
|
||||
var afterDes = GC.GetAllocatedBytesForCurrentThread();
|
||||
SetupDeserializeAllocBytes = afterDes - beforeDes;
|
||||
}
|
||||
|
||||
// BG consumer: parks on _consumeRequest, runs Deserialize<T>(_input) when signaled, signals _consumeDone.
|
||||
// The Deserialize call internally blocks on the input's MRES whenever the drain hasn't yet fed enough
|
||||
// bytes for the next read — that's where the streaming-pipeline overlap with the calling thread (Ser)
|
||||
// happens.
|
||||
private void ConsumeLoop()
|
||||
{
|
||||
var ct = _cts.Token;
|
||||
try
|
||||
{
|
||||
while (true)
|
||||
{
|
||||
_consumeRequest.Wait(ct);
|
||||
if (ct.IsCancellationRequested) return;
|
||||
_consumeRequest.Reset();
|
||||
|
||||
try
|
||||
{
|
||||
var result = AcBinaryDeserializer.Deserialize<T>(_input, _options);
|
||||
if (_captureResult) _lastResult = result;
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Swallow — calling thread sees the failure via missing/incorrect _lastResult during VerifyRoundTrip,
|
||||
// or the benchmark loop just continues (timing impacted). Production teardown handled in Dispose.
|
||||
}
|
||||
finally
|
||||
{
|
||||
_consumeDone.Set();
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
// Cooperative cancel — Dispose path. Swallow.
|
||||
}
|
||||
}
|
||||
|
||||
[MethodImpl(MethodImplOptions.NoInlining)]
|
||||
public void Serialize()
|
||||
{
|
||||
// 2-task streaming pipeline:
|
||||
// 1. Calling thread signals consumer task to begin Deserialize<T>(input). Consumer immediately
|
||||
// starts; first read blocks on input's MRES because no bytes flowed yet.
|
||||
// 2. Calling thread starts SerializeChunkedFramed → chunks flow through PipeWriter → kernel pipe →
|
||||
// drain task (BG) feeds input.Feed → MRES pulses → consumer's Deserialize<T> consumes bytes
|
||||
// chunk by chunk. Ser↔Des truly overlap here.
|
||||
// 3. Calling thread waits for _consumeDone (signaling Deserialize<T> returned).
|
||||
_consumeDone.Reset();
|
||||
_consumeRequest.Set();
|
||||
|
||||
AcBinarySerializer.SerializeChunkedFramed(_order, _pipeWriter, _options);
|
||||
|
||||
_consumeDone.Wait();
|
||||
}
|
||||
|
||||
[MethodImpl(MethodImplOptions.NoInlining)]
|
||||
public void Deserialize()
|
||||
{
|
||||
// No-op: per-iter round-trip is captured in Serialize(). See IsRoundTripOnly contract.
|
||||
}
|
||||
|
||||
public bool VerifyRoundTrip()
|
||||
{
|
||||
// Use the same 2-task streaming path as the benchmark, but capture the result for graph-equality.
|
||||
_captureResult = true;
|
||||
try
|
||||
{
|
||||
Serialize();
|
||||
var result = _lastResult as T;
|
||||
return result != null && RoundTripValidator.DeepEqualsViaJson(_order, result);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_captureResult = false;
|
||||
_lastResult = null;
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (_disposed) return;
|
||||
_disposed = true;
|
||||
|
||||
// Cancel drain + consumer tasks → both exit. Pulse _consumeRequest in case consumer is parked.
|
||||
try { _cts.Cancel(); } catch { /* swallow on teardown */ }
|
||||
try { _consumeRequest.Set(); } catch { /* nudge in case consumer Wait is parked */ }
|
||||
try { _drainTask.Wait(TimeSpan.FromSeconds(2)); } catch { /* swallow on teardown */ }
|
||||
try { _consumerTask.Wait(TimeSpan.FromSeconds(2)); } catch { /* swallow on teardown */ }
|
||||
|
||||
// Complete writer + dispose pipe lifecycle.
|
||||
try { _pipeWriter.CompleteAsync().AsTask().Wait(TimeSpan.FromSeconds(2)); } catch { /* swallow on teardown */ }
|
||||
try { _pipeReader.Complete(); } catch { /* swallow on teardown */ }
|
||||
try { _pipeClient.Dispose(); } catch { /* swallow on teardown */ }
|
||||
try { _pipeServer.Dispose(); } catch { /* swallow on teardown */ }
|
||||
try { _input.Dispose(); } catch { /* swallow on teardown */ }
|
||||
try { _consumeRequest.Dispose(); } catch { /* swallow on teardown */ }
|
||||
try { _consumeDone.Dispose(); } catch { /* swallow on teardown */ }
|
||||
try { _cts.Dispose(); } catch { /* swallow on teardown */ }
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,214 @@
|
|||
using AyCode.Core.Serializers.Binaries;
|
||||
using AyCode.Core.Tests.TestModels;
|
||||
using System.IO.Pipes;
|
||||
using System.Runtime.CompilerServices;
|
||||
|
||||
namespace AyCode.Core.Benchmarks.Workloads.Scenarios;
|
||||
|
||||
/// <summary>
|
||||
/// Raw <c>byte[]</c> over a long-lived NamedPipe — NO chunk-framing, NO <c>AsyncPipeReaderInput</c>,
|
||||
/// NO sliding-window buffer. Calling thread serialises + writes; a long-lived background consumer task
|
||||
/// reads and deserialises. Two-task pattern enables Ser↔Read overlap (kernel-pipe-pipelined) AND
|
||||
/// avoids the kernel-buffer-full deadlock when <c>bytes.Length > inBufferSize</c>.
|
||||
///
|
||||
/// Side-by-side with <see cref="AcBinaryNamedPipeBenchmark{T}"/> (chunked-framed AsyncPipe stack) this
|
||||
/// isolates two cost components on the SAME kernel-pipe transport with the SAME <c>inBufferSize</c>:
|
||||
/// <list type="bullet">
|
||||
/// <item><description><b>This row vs <see cref="AcBinaryBenchmark{T}"/> (Byte[])</b> — pure kernel-NamedPipe
|
||||
/// overhead (WriteFile / ReadFile syscalls + IRP queueing + buffer-copy + thread-handoff).</description></item>
|
||||
/// <item><description><b>This row vs <see cref="AcBinaryNamedPipeBenchmark{T}"/> (chunked-framed)</b> — pure
|
||||
/// AsyncPipe-framework overhead (chunk header writes + sliding-window <c>Feed</c> + MRES wait inside
|
||||
/// <c>AsyncPipeReaderInput</c>) AND the streaming-pipeline benefit of intra-message Ser↔Des overlap (which
|
||||
/// raw lacks — raw can only Ser↔Read overlap, with Des sequential after Read completes).</description></item>
|
||||
/// </list>
|
||||
/// Per-iter <c>byte[]</c> allocation from <c>AcBinarySerializer.Serialize</c> is part of the cost (matches
|
||||
/// <see cref="AcBinaryBenchmark{T}"/>'s API contract); the receive-side scratch buffer is also allocated per-iter
|
||||
/// on the consumer-task (counted via <c>GC.GetTotalAllocatedBytes</c> in <c>BenchmarkLoop.MeasureAllocationTotal</c>).
|
||||
/// </summary>
|
||||
public sealed class AcBinaryNamedPipeRawByteArrayBenchmark<T> : ISerializerBenchmark, IDisposable where T : class
|
||||
{
|
||||
private readonly T _order;
|
||||
private readonly AcBinarySerializerOptions _options;
|
||||
private readonly byte[] _serialized; // for SerializedSize reporting + receive-side size known upfront
|
||||
|
||||
// Long-lived pipe lifecycle (set up once in ctor — NOT timed).
|
||||
private readonly NamedPipeServerStream _pipeServer;
|
||||
private readonly NamedPipeClientStream _pipeClient;
|
||||
|
||||
// Long-lived consumer-task infrastructure (Read + Deserialize on BG thread, signaled per iter).
|
||||
// Mirrors AcBinaryNamedPipeBenchmark's drain+consumer pair, but raw byte[] doesn't have an
|
||||
// intermediate sliding-window buffer, so Read+Des happen sequentially in one BG task: Read N bytes
|
||||
// → Deserialize<T>(bytes) → signal done. Calling thread's Ser↔Write overlaps with this BG Read+Des
|
||||
// through kernel-pipe pipelining.
|
||||
private readonly CancellationTokenSource _cts;
|
||||
private readonly Task _consumerTask;
|
||||
private readonly ManualResetEventSlim _consumeRequest = new(false);
|
||||
private readonly ManualResetEventSlim _consumeDone = new(false);
|
||||
private int _pendingReadSize;
|
||||
private object? _lastResult; // captured during VerifyRoundTrip; null in benchmark iters
|
||||
private bool _captureResult; // toggle: when true, ConsumerLoop stores result; otherwise discards
|
||||
private bool _disposed;
|
||||
|
||||
public BenchmarkEngine Engine => BenchmarkEngine.AcBinary;
|
||||
public BenchmarkIoMode IoMode => BenchmarkIoMode.NamedPipeRaw;
|
||||
public BenchmarkDispatchMode DispatchMode => _options.UseGeneratedCode ? BenchmarkDispatchMode.SGen : BenchmarkDispatchMode.Runtime;
|
||||
public Type OrderType => typeof(T);
|
||||
public string OptionsPreset { get; }
|
||||
public int SerializedSize => _serialized.Length;
|
||||
public long SetupSerializeAllocBytes { get; }
|
||||
public long SetupDeserializeAllocBytes { get; }
|
||||
public bool IsRoundTripOnly => true;
|
||||
public string OptionsDescription => BenchmarkOptions.BuildAcBinary(_options, $", BufferSize={_options.BufferWriterChunkSize}B, Transport=NamedPipe(raw,2-task)");
|
||||
|
||||
public AcBinaryNamedPipeRawByteArrayBenchmark(T order, AcBinarySerializerOptions options, string optionsPreset)
|
||||
{
|
||||
_order = order;
|
||||
// BufferWriterChunkSize comes from the caller — same source-of-truth contract as
|
||||
// AcBinaryNamedPipeBenchmark. The kernel pipe-buffer (inBufferSize) is wired to it so the
|
||||
// raw-vs-chunked comparison runs on identical transport conditions.
|
||||
_options = options;
|
||||
OptionsPreset = optionsPreset;
|
||||
|
||||
_serialized = AcBinarySerializer.Serialize(order, _options);
|
||||
|
||||
var pipeName = $"AcBinaryBenchRaw-{Guid.NewGuid():N}";
|
||||
|
||||
// === SERIALIZE-side setup measurement ===
|
||||
// pipe-pair (server + client) + connect handshake. NO PipeWriter wrapper — we use the raw
|
||||
// Stream.Write API directly, matching the no-framing semantics of this benchmark.
|
||||
GC.Collect(); GC.WaitForPendingFinalizers(); GC.Collect();
|
||||
var beforeSer = GC.GetAllocatedBytesForCurrentThread();
|
||||
_pipeServer = new NamedPipeServerStream(pipeName, PipeDirection.In, 1, PipeTransmissionMode.Byte,
|
||||
System.IO.Pipes.PipeOptions.Asynchronous,
|
||||
inBufferSize: _options.BufferWriterChunkSize,
|
||||
outBufferSize: _options.BufferWriterChunkSize);
|
||||
_pipeClient = new NamedPipeClientStream(".", pipeName, PipeDirection.Out, System.IO.Pipes.PipeOptions.Asynchronous);
|
||||
|
||||
var serverWait = _pipeServer.WaitForConnectionAsync();
|
||||
_pipeClient.Connect();
|
||||
serverWait.GetAwaiter().GetResult();
|
||||
var afterSer = GC.GetAllocatedBytesForCurrentThread();
|
||||
SetupSerializeAllocBytes = afterSer - beforeSer;
|
||||
|
||||
// === DESERIALIZE-side setup measurement ===
|
||||
// 1× background consumer-task + 2× MRES (request / done) + cancellation source. Matches the
|
||||
// chunked benchmark's deserialize-side setup cost shape.
|
||||
GC.Collect(); GC.WaitForPendingFinalizers(); GC.Collect();
|
||||
var beforeDes = GC.GetAllocatedBytesForCurrentThread();
|
||||
_cts = new CancellationTokenSource();
|
||||
_consumerTask = Task.Run(ConsumerLoop);
|
||||
var afterDes = GC.GetAllocatedBytesForCurrentThread();
|
||||
SetupDeserializeAllocBytes = afterDes - beforeDes;
|
||||
}
|
||||
|
||||
// BG consumer: parks on _consumeRequest, reads N bytes from pipe, runs Deserialize<T>(bytes), signals
|
||||
// _consumeDone. The Read overlaps with the calling thread's Write through the kernel-pipe; Des happens
|
||||
// sequentially after Read completes (raw byte[] needs the full message to deserialize).
|
||||
private void ConsumerLoop()
|
||||
{
|
||||
var ct = _cts.Token;
|
||||
try
|
||||
{
|
||||
while (true)
|
||||
{
|
||||
_consumeRequest.Wait(ct);
|
||||
if (ct.IsCancellationRequested) return;
|
||||
_consumeRequest.Reset();
|
||||
|
||||
try
|
||||
{
|
||||
var size = _pendingReadSize;
|
||||
var bytes = new byte[size]; // per-iter alloc — counted by BenchmarkLoop.MeasureAllocationTotal
|
||||
var totalRead = 0;
|
||||
while (totalRead < size)
|
||||
{
|
||||
var n = _pipeServer.Read(bytes, totalRead, size - totalRead);
|
||||
if (n == 0) break; // pipe closed / EOF — partial read swallowed
|
||||
totalRead += n;
|
||||
}
|
||||
var result = AcBinaryDeserializer.Deserialize<T>(bytes, _options);
|
||||
if (_captureResult) _lastResult = result;
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Swallow — calling thread sees the failure via missing/incorrect _lastResult during VerifyRoundTrip,
|
||||
// or the benchmark loop just continues (timing impacted). Production teardown handled in Dispose.
|
||||
}
|
||||
finally
|
||||
{
|
||||
_consumeDone.Set();
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
// Cooperative cancel — Dispose path. Swallow.
|
||||
}
|
||||
}
|
||||
|
||||
[MethodImpl(MethodImplOptions.NoInlining)]
|
||||
public void Serialize()
|
||||
{
|
||||
// 2-task streaming pipeline:
|
||||
// 1. Calling thread serialises → fresh byte[] (per-iter alloc, matches AcBinaryBenchmark contract).
|
||||
// 2. Calling thread hands off expected size + signals consumer task. Consumer task starts Read loop
|
||||
// on the pipe (BG thread). Calling thread proceeds to Write the bytes — Read and Write overlap
|
||||
// through the kernel-pipe (kernel buffer fills, drains as consumer reads, sender resumes).
|
||||
// 3. Calling thread waits for _consumeDone (consumer task finished Read+Des).
|
||||
//
|
||||
// Note: unlike chunked, raw byte[] cannot do Ser↔Des overlap (Des needs the full bytes before
|
||||
// starting). Only Write↔Read overlaps here. The Des sequence on BG thread is: Read full bytes →
|
||||
// Des the full graph → signal done. This is the architectural difference between raw and chunked.
|
||||
var bytes = AcBinarySerializer.Serialize(_order, _options);
|
||||
|
||||
_pendingReadSize = bytes.Length;
|
||||
_consumeDone.Reset();
|
||||
_consumeRequest.Set();
|
||||
|
||||
_pipeClient.Write(bytes, 0, bytes.Length);
|
||||
_pipeClient.Flush();
|
||||
|
||||
_consumeDone.Wait();
|
||||
}
|
||||
|
||||
[MethodImpl(MethodImplOptions.NoInlining)]
|
||||
public void Deserialize()
|
||||
{
|
||||
// No-op: per-iter round-trip is captured in Serialize(). See IsRoundTripOnly contract.
|
||||
}
|
||||
|
||||
public bool VerifyRoundTrip()
|
||||
{
|
||||
// Use the same 2-task streaming path as the benchmark, but capture the result for graph-equality.
|
||||
_captureResult = true;
|
||||
try
|
||||
{
|
||||
Serialize();
|
||||
var result = _lastResult as T;
|
||||
return result != null && RoundTripValidator.DeepEqualsViaJson(_order, result);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_captureResult = false;
|
||||
_lastResult = null;
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (_disposed) return;
|
||||
_disposed = true;
|
||||
|
||||
// Cancel the consumer task → ConsumerLoop exits its Wait via OperationCanceledException.
|
||||
try { _cts.Cancel(); } catch { /* swallow on teardown */ }
|
||||
try { _consumeRequest.Set(); } catch { /* nudge in case consumer Wait is parked */ }
|
||||
try { _consumerTask.Wait(TimeSpan.FromSeconds(2)); } catch { /* swallow on teardown */ }
|
||||
|
||||
// Symmetric teardown — close client first (writer side), then server.
|
||||
try { _pipeClient.Dispose(); } catch { /* swallow on teardown */ }
|
||||
try { _pipeServer.Dispose(); } catch { /* swallow on teardown */ }
|
||||
try { _consumeRequest.Dispose(); } catch { /* swallow on teardown */ }
|
||||
try { _consumeDone.Dispose(); } catch { /* swallow on teardown */ }
|
||||
try { _cts.Dispose(); } catch { /* swallow on teardown */ }
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,143 @@
|
|||
namespace AyCode.Core.Benchmarks.Workloads.Scenarios;
|
||||
|
||||
/// <summary>
|
||||
/// Serializer engine identifier — replaces the prior <c>Configuration.EngineXxx</c> string constants
|
||||
/// with a type-safe enum. The benchmark-result <c>Engine</c> column uses <see cref="ToDisplay"/> for
|
||||
/// the human-readable form.
|
||||
/// </summary>
|
||||
public enum BenchmarkEngine
|
||||
{
|
||||
AcBinary,
|
||||
MemoryPack,
|
||||
#if !AYCODE_NATIVEAOT
|
||||
MessagePack,
|
||||
#endif
|
||||
SystemTextJson,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// I/O mode identifier — replaces the prior <c>Configuration.IoXxx</c> string constants. Note that
|
||||
/// <see cref="NamedPipe"/> and <see cref="NamedPipeRaw"/> share the display string <c>"NamedPipe"</c>
|
||||
/// (they distinguish chunked-framed vs raw-byte[] semantics, but render identically in the IO column);
|
||||
/// the same applies to <see cref="InMemoryPipe"/> + <see cref="InMemoryRaw"/> (<c>"Pipe(in-mem)"</c>).
|
||||
/// </summary>
|
||||
public enum BenchmarkIoMode
|
||||
{
|
||||
ByteArray,
|
||||
BufWrReuse,
|
||||
BufWrNew,
|
||||
String,
|
||||
NamedPipe,
|
||||
NamedPipeRaw,
|
||||
InMemoryPipe,
|
||||
InMemoryRaw,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Dispatch mode identifier — replaces the prior <c>Configuration.ModeXxx</c> string constants.
|
||||
/// Describes how property access / type dispatch happens for a given benchmark row:
|
||||
/// <list type="bullet">
|
||||
/// <item><see cref="SGen"/> — compile-time source generator path (Unsafe.As<T> direct fields, slot-array wrapper lookup).</item>
|
||||
/// <item><see cref="Runtime"/> — reflection / compiled-delegate path.</item>
|
||||
/// <item><see cref="Hybrid"/> — SGen root with non-SGen child types reached via bridge methods (see docs/BINARY/BINARY_SGEN.md).</item>
|
||||
/// </list>
|
||||
/// </summary>
|
||||
public enum BenchmarkDispatchMode
|
||||
{
|
||||
SGen,
|
||||
Runtime,
|
||||
Hybrid,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Test-data layer filter — selects which test data cells participate in the run.
|
||||
/// Replaces the prior string-typed <c>layer</c> parameter; CLI/menu callers parse user input via
|
||||
/// <see cref="Enum.TryParse{T}(string, bool, out T)"/> with <c>ignoreCase: true</c>.
|
||||
/// <list type="bullet">
|
||||
/// <item><see cref="All"/> — no filter; every test data set runs.</item>
|
||||
/// <item><see cref="Core"/>/<see cref="Comprehensive"/>/<see cref="Edge"/> — preset bundles (Comprehensive ⊇ Core, Edge ⊇ Comprehensive).</item>
|
||||
/// <item><see cref="Small"/>/<see cref="Medium"/>/<see cref="Large"/>/<see cref="Repeated"/>/<see cref="Deep"/> — single-cell mini-suites for tight A/B iteration loops.</item>
|
||||
/// </list>
|
||||
/// </summary>
|
||||
public enum BenchmarkLayer
|
||||
{
|
||||
All,
|
||||
Core,
|
||||
Comprehensive,
|
||||
Edge,
|
||||
Small,
|
||||
Medium,
|
||||
Large,
|
||||
Repeated,
|
||||
Deep,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Per-phase operation filter — selects which sides of the benchmark (Ser, Des, both) run for each
|
||||
/// serializer. Round-trip-only benchmarks (NamedPipe etc.) treat <see cref="Deserialize"/> alone as
|
||||
/// a no-op and only run on <see cref="Serialize"/> or <see cref="All"/>. Replaces the prior string-typed
|
||||
/// <c>mode</c>/<c>opMode</c> parameter.
|
||||
/// </summary>
|
||||
public enum BenchmarkOpMode
|
||||
{
|
||||
All,
|
||||
Serialize,
|
||||
Deserialize,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Serializer-set selection — drives the runner's serializer-factory to return one of three
|
||||
/// preset bundles instead of a magic string. Replaces the prior string-typed <c>serializerMode</c>
|
||||
/// parameter.
|
||||
/// <list type="bullet">
|
||||
/// <item><see cref="Standard"/> — full suite minus AsyncPipe (the streaming benchmark is opt-in).</item>
|
||||
/// <item><see cref="FastestByte"/> — focused AcBinary FastMode Byte[] vs MemoryPack Byte[] 1:1 comparison.</item>
|
||||
/// <item><see cref="AsyncPipe"/> — streaming I/O isolation (NamedPipe + in-memory Pipe variants only).</item>
|
||||
/// </list>
|
||||
/// </summary>
|
||||
public enum SerializerSelectionMode
|
||||
{
|
||||
Standard,
|
||||
FastestByte,
|
||||
AsyncPipe,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Display-string converters for the benchmark enums. Renders enum values into the column-friendly
|
||||
/// human-readable form used by the per-row console table, the <c>.log</c> file CSV/formatted output,
|
||||
/// and the <c>.LLM</c> markdown table. Centralised here so every output formatter renders identically.
|
||||
/// </summary>
|
||||
public static class BenchmarkEnumExtensions
|
||||
{
|
||||
public static string ToDisplay(this BenchmarkEngine engine) => engine switch
|
||||
{
|
||||
BenchmarkEngine.AcBinary => "AcBinary",
|
||||
BenchmarkEngine.MemoryPack => "MemoryPack",
|
||||
#if !AYCODE_NATIVEAOT
|
||||
BenchmarkEngine.MessagePack => "MessagePack",
|
||||
#endif
|
||||
BenchmarkEngine.SystemTextJson => "System.Text.Json",
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(engine), engine, null),
|
||||
};
|
||||
|
||||
public static string ToDisplay(this BenchmarkIoMode mode) => mode switch
|
||||
{
|
||||
BenchmarkIoMode.ByteArray => "Byte[]",
|
||||
BenchmarkIoMode.BufWrReuse => "BufWr reuse",
|
||||
BenchmarkIoMode.BufWrNew => "BufWr new",
|
||||
BenchmarkIoMode.String => "String",
|
||||
BenchmarkIoMode.NamedPipe => "NamedPipe",
|
||||
BenchmarkIoMode.NamedPipeRaw => "NamedPipe",
|
||||
BenchmarkIoMode.InMemoryPipe => "Pipe(in-mem)",
|
||||
BenchmarkIoMode.InMemoryRaw => "Pipe(in-mem)",
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(mode), mode, null),
|
||||
};
|
||||
|
||||
public static string ToDisplay(this BenchmarkDispatchMode mode) => mode switch
|
||||
{
|
||||
BenchmarkDispatchMode.SGen => "SGen",
|
||||
BenchmarkDispatchMode.Runtime => "Runtime",
|
||||
BenchmarkDispatchMode.Hybrid => "Hybrid",
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(mode), mode, null),
|
||||
};
|
||||
}
|
||||
|
|
@ -0,0 +1,89 @@
|
|||
using AyCode.Core.Serializers;
|
||||
using AyCode.Core.Serializers.Attributes;
|
||||
using AyCode.Core.Serializers.Binaries;
|
||||
using MemoryPack;
|
||||
using System.Reflection;
|
||||
|
||||
namespace AyCode.Core.Benchmarks.Workloads.Scenarios;
|
||||
|
||||
/// <summary>
|
||||
/// Per-engine options-formatting + selection helpers shared by all benchmark rows. Centralizes
|
||||
/// the Options-column display string (so the .log / .LLM / console headers stay consistent), the
|
||||
/// MemoryPack <c>WireMode</c>-aligned options selection (so AcBinary FastWire ↔ MemoryPack UTF-16
|
||||
/// comparisons stay apples-to-apples), and the cached <see cref="AttrFlags"/> attribute-flag aggregation.
|
||||
/// </summary>
|
||||
public static class BenchmarkOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Aggregated <see cref="AcBinarySerializableAttribute"/> feature flags across every type tagged with
|
||||
/// the attribute in the loaded assemblies. Cached on first access (single reflection scan at startup).
|
||||
/// Used by <see cref="BuildAcBinary"/> so the per-row Options column shows BOTH the configured
|
||||
/// options-level value AND the effective attribute-level enable flag — a feature flagged off at the
|
||||
/// type level overrides the options regardless of preset, and that asymmetry must surface in the log
|
||||
/// to avoid misreading a "RefHandling=OnlyId" / "Interning=All" line as actually active.
|
||||
/// Aggregation rule: if ALL tagged types have the feature enabled → <c>true</c>; if any tagged type
|
||||
/// disables it → <c>false</c> (a single disabling type suppresses the feature on the type-graph).
|
||||
/// </summary>
|
||||
public static readonly (bool refHandling, bool internString, bool metadata, bool idTracking, bool propertyFilter) AttrFlags
|
||||
= ScanAttributeFlags();
|
||||
|
||||
private static (bool refHandling, bool internString, bool metadata, bool idTracking, bool propertyFilter) ScanAttributeFlags()
|
||||
{
|
||||
var attrs = AppDomain.CurrentDomain.GetAssemblies()
|
||||
.SelectMany(a => { try { return a.GetTypes(); } catch { return Array.Empty<Type>(); } })
|
||||
.Select(t => t.GetCustomAttribute<AcBinarySerializableAttribute>())
|
||||
.Where(a => a != null)
|
||||
.ToList();
|
||||
|
||||
if (attrs.Count == 0) return (false, false, false, false, false);
|
||||
|
||||
return (
|
||||
refHandling: attrs.All(a => a!.EnableRefHandlingFeature),
|
||||
internString: attrs.All(a => a!.EnableInternStringFeature),
|
||||
metadata: attrs.All(a => a!.EnableMetadataFeature),
|
||||
idTracking: attrs.All(a => a!.EnableIdTrackingFeature),
|
||||
propertyFilter: attrs.All(a => a!.EnablePropertyFilterFeature));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Common Options-column formatter for every AcBinary serializer benchmark row. Renders the
|
||||
/// configured options-level value AND the effective attribute-level enable flag side-by-side
|
||||
/// (e.g. <c>Interning=All(opt) | False (attr)</c>) so attribute-suppressed features cannot
|
||||
/// silently mislead. Pass any benchmark-specific extras (e.g. <c>", BufferSize=4096B"</c>)
|
||||
/// in <paramref name="extra"/> — they are appended after the common fields.
|
||||
/// </summary>
|
||||
public static string BuildAcBinary(AcBinarySerializerOptions options, string extra = "")
|
||||
{
|
||||
// PropertyFilter: opt-side is "Set"/"None" depending on whether a callback is registered (the callback
|
||||
// itself isn't a meaningful display value); attr-side is the cross-type-aggregated bool (true = every
|
||||
// tagged type has the feature enabled, false = at least one type opted out via
|
||||
// [AcBinarySerializable(enablePropertyFilterFeature: false)] → SGen-emit + Runtime hot-loop both gate).
|
||||
var propFilterOpt = options.PropertyFilter == null ? "None" : "Set";
|
||||
|
||||
return $"WireMode={options.WireMode}, " +
|
||||
$"RefHandling={options.ReferenceHandling}(opt) | {AttrFlags.refHandling} (attr), " +
|
||||
$"Interning={options.UseStringInterning}(opt) | {AttrFlags.internString} (attr), " +
|
||||
$"Metadata={options.UseMetadata}(opt) | {AttrFlags.metadata} (attr), " +
|
||||
$"PropertyFilter={propFilterOpt}(opt) | {AttrFlags.propertyFilter} (attr), " +
|
||||
$"SGen={options.UseGeneratedCode}, " +
|
||||
$"Compression={options.UseCompression}{extra}";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns MemoryPack serializer options aligned with the given <paramref name="wireMode"/> for a fair
|
||||
/// apples-to-apples wire-format comparison:
|
||||
/// <list type="bullet">
|
||||
/// <item><see cref="WireMode.Compact"/> → <see cref="MemoryPackSerializerOptions.Default"/> (UTF-8) — both
|
||||
/// engines encode UTF-8, comparison is purely about header / tier / dispatch overhead.</item>
|
||||
/// <item><see cref="WireMode.Fast"/> → <see cref="MemoryPackSerializerOptions.Utf16"/> (UTF-16 raw memcpy) —
|
||||
/// both engines write UTF-16 raw bytes, so wire-size and CPU comparison reflect the same string-encoding family.</item>
|
||||
/// </list>
|
||||
/// Without this alignment the FastWire vs MemPack-default comparison conflates two unrelated dimensions
|
||||
/// (UTF-16 raw vs UTF-8 encoded) and produces a misleading +40% wire-size delta that is structurally
|
||||
/// the encoding-family difference, NOT an AcBinary-specific overhead.
|
||||
/// </summary>
|
||||
public static MemoryPackSerializerOptions GetMemPack(WireMode wireMode) =>
|
||||
wireMode == WireMode.Fast
|
||||
? MemoryPackSerializerOptions.Utf16
|
||||
: MemoryPackSerializerOptions.Default;
|
||||
}
|
||||
|
|
@ -0,0 +1,72 @@
|
|||
namespace AyCode.Core.Benchmarks.Workloads.Scenarios;
|
||||
|
||||
/// <summary>
|
||||
/// Common contract for every per-engine, per-I/O-mode benchmark row. Each implementation captures
|
||||
/// one (Engine × IoMode × OptionsPreset) combination — e.g. <c>AcBinary Byte[] FastMode SGen</c> —
|
||||
/// and exposes a uniform <c>Serialize</c> / <c>Deserialize</c> hot-path that the benchmark loop
|
||||
/// drives through warmup + adaptive-iter calibration + measurement.
|
||||
///
|
||||
/// <para>The default <see cref="WarmupSerialize"/> + <see cref="WarmupDeserialize"/> methods iterate
|
||||
/// the hot path N times — overrides are only needed when an implementor wants Ser/Des-specific
|
||||
/// warmup state (rare). Round-trip-only benchmarks (NamedPipe etc.) set <see cref="IsRoundTripOnly"/>
|
||||
/// to true so the bench loop skips the Des-phase and routes timing into the RT columns.</para>
|
||||
/// </summary>
|
||||
public interface ISerializerBenchmark
|
||||
{
|
||||
/// <summary>Serializer engine — typed enum, see <see cref="BenchmarkEnumExtensions.ToDisplay(BenchmarkEngine)"/> for the human-readable form.</summary>
|
||||
BenchmarkEngine Engine { get; }
|
||||
/// <summary>I/O mode — typed enum, see <see cref="BenchmarkEnumExtensions.ToDisplay(BenchmarkIoMode)"/> for the human-readable form.</summary>
|
||||
BenchmarkIoMode IoMode { get; }
|
||||
/// <summary>Dispatch mode — typed enum (<see cref="BenchmarkDispatchMode.SGen"/> / <see cref="BenchmarkDispatchMode.Runtime"/> / <see cref="BenchmarkDispatchMode.Hybrid"/>). For AcBinary derived from <c>UseGeneratedCode</c> + child-type SGen coverage; non-AcBinary engines report their own native dispatch model.</summary>
|
||||
BenchmarkDispatchMode DispatchMode { get; }
|
||||
/// <summary>Options preset name — e.g. "FastMode", "Default", "NoIntern", "WithCompression". Stays string because preset names are open-ended (per-instance constructor argument).</summary>
|
||||
string OptionsPreset { get; }
|
||||
/// <summary>
|
||||
/// CLR type of the order graph this benchmark serializes (e.g. <c>typeof(TestOrder_All_False)</c>,
|
||||
/// <c>typeof(TestOrder_All_True)</c>). Per-instance: AcBinary picks variant by options preset
|
||||
/// (caller-side dispatch rule), MemPack / MsgPack always use <c>_All_False</c>.
|
||||
/// Concrete benchmarks return <c>typeof(T)</c> for their generic parameter.
|
||||
/// </summary>
|
||||
Type OrderType { get; }
|
||||
/// <summary>
|
||||
/// Derived display name for the <see cref="OrderType"/>. Default-interface impl reads
|
||||
/// <c>OrderType.Name</c>; concrete classes don't need to override. Surfaced in the SERIALIZER
|
||||
/// OPTIONS section of every output (.log, .LLM, console) — not in the per-row tables — so the
|
||||
/// reader correlates each preset with its TestOrder variant without inflating the result columns.
|
||||
/// </summary>
|
||||
string OrderTypeName => OrderType.Name;
|
||||
/// <summary>Synthesized display name from Engine + IoMode + OptionsPreset.</summary>
|
||||
string Name => $"{Engine.ToDisplay()} ({IoMode.ToDisplay()}, {OptionsPreset})";
|
||||
int SerializedSize { get; }
|
||||
string? OptionsDescription => null;
|
||||
/// <summary>One-time SERIALIZER-side setup allocation cost (e.g., pre-allocated ArrayBufferWriter with internal buffer). Captured in constructor; 0 for byte[] API and Fresh-BufWriter variants.</summary>
|
||||
long SetupSerializeAllocBytes { get; }
|
||||
/// <summary>One-time DESERIALIZER-side setup allocation cost (e.g., long-lived AsyncPipeReaderInput's ArrayPool rent + ManualResetEventSlim, drain-task scaffolding). Captured in constructor; 0 for byte[] API and any setup-free deserialize path.</summary>
|
||||
long SetupDeserializeAllocBytes { get; }
|
||||
/// <summary>True when Serialize() does a full round-trip (e.g. NamedPipe) and Deserialize() is a no-op.
|
||||
/// Used by the SUMMARY: WINNERS section to skip such cells from "Fastest Serialize" and "Fastest Deserialize"
|
||||
/// rankings (because both metrics are misleading there) — they still participate in "Fastest Round-trip".
|
||||
/// Default false for in-memory IO modes which measure Ser and Des separately.</summary>
|
||||
bool IsRoundTripOnly => false;
|
||||
/// <summary>Warm only the Serialize path. Default body iterates <see cref="Serialize"/> N times.
|
||||
/// Overrides are only needed when the implementor wants Ser-specific warmup-state (e.g. pre-allocate buffers).
|
||||
/// On <see cref="IsRoundTripOnly"/> benchmarks (NamedPipe-style) <see cref="Serialize"/> performs the full RT,
|
||||
/// so this warms the entire round-trip path.</summary>
|
||||
void WarmupSerialize(int iterations)
|
||||
{
|
||||
for (var i = 0; i < iterations; i++) Serialize();
|
||||
}
|
||||
|
||||
/// <summary>Warm only the Deserialize path. Default body iterates <see cref="Deserialize"/> N times.
|
||||
/// On <see cref="IsRoundTripOnly"/> benchmarks <see cref="Deserialize"/> is a no-op, so the bench loop
|
||||
/// skips the Des-phase entirely for those cells.</summary>
|
||||
void WarmupDeserialize(int iterations)
|
||||
{
|
||||
for (var i = 0; i < iterations; i++) Deserialize();
|
||||
}
|
||||
|
||||
void Serialize();
|
||||
void Deserialize();
|
||||
/// <summary>Round-trip correctness check — called once per cell before warmup. Returns true if Serialize+Deserialize preserves data.</summary>
|
||||
bool VerifyRoundTrip();
|
||||
}
|
||||
|
|
@ -0,0 +1,49 @@
|
|||
using AyCode.Core.Serializers;
|
||||
using AyCode.Core.Tests.TestModels;
|
||||
using MemoryPack;
|
||||
using System.Runtime.CompilerServices;
|
||||
|
||||
namespace AyCode.Core.Benchmarks.Workloads.Scenarios;
|
||||
|
||||
/// <summary>
|
||||
/// MemoryPack benchmark, Byte[] I/O mode. The SOTA baseline AcBinary is compared against in every
|
||||
/// cell. WireMode-aligned options via <see cref="BenchmarkOptions.GetMemPack"/> so Compact ↔ UTF-8
|
||||
/// and FastWire ↔ UTF-16 are apples-to-apples on the string-encoding axis.
|
||||
/// </summary>
|
||||
public sealed class MemoryPackBenchmark<T> : ISerializerBenchmark where T : class
|
||||
{
|
||||
private readonly T _order;
|
||||
private readonly MemoryPackSerializerOptions _options;
|
||||
private readonly byte[] _serialized;
|
||||
|
||||
public BenchmarkEngine Engine => BenchmarkEngine.MemoryPack;
|
||||
public BenchmarkIoMode IoMode => BenchmarkIoMode.ByteArray;
|
||||
public BenchmarkDispatchMode DispatchMode => BenchmarkDispatchMode.SGen; // MemoryPack always uses [MemoryPackable] source-generated formatters
|
||||
public Type OrderType => typeof(T);
|
||||
public string OptionsPreset { get; }
|
||||
public int SerializedSize => _serialized.Length;
|
||||
public long SetupSerializeAllocBytes => 0;
|
||||
public long SetupDeserializeAllocBytes => 0;
|
||||
public string? OptionsDescription => $"StringEncoding={_options.StringEncoding}";
|
||||
|
||||
public MemoryPackBenchmark(T order, WireMode wireMode, string optionsPreset)
|
||||
{
|
||||
_order = order;
|
||||
OptionsPreset = optionsPreset;
|
||||
_options = BenchmarkOptions.GetMemPack(wireMode);
|
||||
_serialized = MemoryPackSerializer.Serialize(order, _options);
|
||||
}
|
||||
|
||||
[MethodImpl(MethodImplOptions.NoInlining)]
|
||||
public void Serialize() => MemoryPackSerializer.Serialize(_order, _options);
|
||||
|
||||
[MethodImpl(MethodImplOptions.NoInlining)]
|
||||
public void Deserialize() => MemoryPackSerializer.Deserialize<T>(_serialized, _options);
|
||||
|
||||
public bool VerifyRoundTrip()
|
||||
{
|
||||
var bytes = MemoryPackSerializer.Serialize(_order, _options);
|
||||
var roundTripped = MemoryPackSerializer.Deserialize<T>(bytes, _options);
|
||||
return RoundTripValidator.DeepEqualsViaJson(_order, roundTripped);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,65 @@
|
|||
using AyCode.Core.Serializers;
|
||||
using AyCode.Core.Tests.TestModels;
|
||||
using MemoryPack;
|
||||
using System.Buffers;
|
||||
using System.Runtime.CompilerServices;
|
||||
|
||||
namespace AyCode.Core.Benchmarks.Workloads.Scenarios;
|
||||
|
||||
/// <summary>
|
||||
/// Benchmarks MemoryPack via the IBufferWriter overload with a pre-allocated, reused ArrayBufferWriter.
|
||||
/// Apples-to-apples counterpart to <see cref="AcBinaryBufferWriterBenchmark{T}"/> — MemoryPack's IBufferWriter
|
||||
/// is the path it's designed for.
|
||||
/// </summary>
|
||||
public sealed class MemoryPackBufferWriterBenchmark<T> : ISerializerBenchmark where T : class
|
||||
{
|
||||
private readonly T _order;
|
||||
private readonly MemoryPackSerializerOptions _options;
|
||||
private readonly byte[] _serialized;
|
||||
private readonly ArrayBufferWriter<byte> _bufferWriter;
|
||||
|
||||
public BenchmarkEngine Engine => BenchmarkEngine.MemoryPack;
|
||||
public BenchmarkIoMode IoMode => BenchmarkIoMode.BufWrReuse;
|
||||
public BenchmarkDispatchMode DispatchMode => BenchmarkDispatchMode.SGen; // MemoryPack always uses [MemoryPackable] source-generated formatters
|
||||
public Type OrderType => typeof(T);
|
||||
public string OptionsPreset { get; }
|
||||
public int SerializedSize => _serialized.Length;
|
||||
public long SetupSerializeAllocBytes { get; }
|
||||
public long SetupDeserializeAllocBytes => 0;
|
||||
public string? OptionsDescription => $"StringEncoding={_options.StringEncoding}";
|
||||
|
||||
public MemoryPackBufferWriterBenchmark(T order, WireMode wireMode, string optionsPreset)
|
||||
{
|
||||
_order = order;
|
||||
OptionsPreset = optionsPreset;
|
||||
_options = BenchmarkOptions.GetMemPack(wireMode);
|
||||
_serialized = MemoryPackSerializer.Serialize(order, _options);
|
||||
|
||||
// Serialize-side setup only — see AcBinaryBufferWriterBenchmark for the full rationale.
|
||||
GC.Collect(); GC.WaitForPendingFinalizers(); GC.Collect();
|
||||
var beforeSetup = GC.GetAllocatedBytesForCurrentThread();
|
||||
_bufferWriter = new ArrayBufferWriter<byte>(_serialized.Length * 2);
|
||||
var afterSetup = GC.GetAllocatedBytesForCurrentThread();
|
||||
SetupSerializeAllocBytes = afterSetup - beforeSetup;
|
||||
}
|
||||
|
||||
[MethodImpl(MethodImplOptions.NoInlining)]
|
||||
public void Serialize()
|
||||
{
|
||||
_bufferWriter.ResetWrittenCount();
|
||||
MemoryPackSerializer.Serialize(_bufferWriter, _order, _options);
|
||||
}
|
||||
|
||||
// BufWr semantic: read from a ReadOnlySequence<byte> overload (apples-to-apples with AcBinary's
|
||||
// BufWr Deser path). MemoryPack's ROS overload also single-segment-fast-paths internally.
|
||||
[MethodImpl(MethodImplOptions.NoInlining)]
|
||||
public void Deserialize() => MemoryPackSerializer.Deserialize<T>(new ReadOnlySequence<byte>(_serialized), _options);
|
||||
|
||||
public bool VerifyRoundTrip()
|
||||
{
|
||||
_bufferWriter.ResetWrittenCount();
|
||||
MemoryPackSerializer.Serialize(_bufferWriter, _order, _options);
|
||||
var roundTripped = MemoryPackSerializer.Deserialize<T>(new ReadOnlySequence<byte>(_bufferWriter.WrittenMemory), _options);
|
||||
return RoundTripValidator.DeepEqualsViaJson(_order, roundTripped);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,56 @@
|
|||
using AyCode.Core.Serializers;
|
||||
using AyCode.Core.Tests.TestModels;
|
||||
using MemoryPack;
|
||||
using System.Buffers;
|
||||
using System.Runtime.CompilerServices;
|
||||
|
||||
namespace AyCode.Core.Benchmarks.Workloads.Scenarios;
|
||||
|
||||
/// <summary>
|
||||
/// Benchmarks MemoryPack via the IBufferWriter overload, allocating a FRESH ArrayBufferWriter on EVERY call.
|
||||
/// Apples-to-apples counterpart to <see cref="AcBinaryFreshBufferWriterBenchmark{T}"/>.
|
||||
/// </summary>
|
||||
public sealed class MemoryPackFreshBufferWriterBenchmark<T> : ISerializerBenchmark where T : class
|
||||
{
|
||||
private readonly T _order;
|
||||
private readonly MemoryPackSerializerOptions _options;
|
||||
private readonly byte[] _serialized;
|
||||
|
||||
public BenchmarkEngine Engine => BenchmarkEngine.MemoryPack;
|
||||
public BenchmarkIoMode IoMode => BenchmarkIoMode.BufWrNew;
|
||||
public BenchmarkDispatchMode DispatchMode => BenchmarkDispatchMode.SGen; // MemoryPack always uses [MemoryPackable] source-generated formatters
|
||||
public Type OrderType => typeof(T);
|
||||
public string OptionsPreset { get; }
|
||||
public int SerializedSize => _serialized.Length;
|
||||
public long SetupSerializeAllocBytes => 0;
|
||||
public long SetupDeserializeAllocBytes => 0;
|
||||
public string? OptionsDescription => $"StringEncoding={_options.StringEncoding}";
|
||||
|
||||
public MemoryPackFreshBufferWriterBenchmark(T order, WireMode wireMode, string optionsPreset)
|
||||
{
|
||||
_order = order;
|
||||
OptionsPreset = optionsPreset;
|
||||
_options = BenchmarkOptions.GetMemPack(wireMode);
|
||||
_serialized = MemoryPackSerializer.Serialize(order, _options);
|
||||
}
|
||||
|
||||
[MethodImpl(MethodImplOptions.NoInlining)]
|
||||
public void Serialize()
|
||||
{
|
||||
var abw = new ArrayBufferWriter<byte>();
|
||||
MemoryPackSerializer.Serialize(abw, _order, _options);
|
||||
}
|
||||
|
||||
// BufWr semantic: read from a ReadOnlySequence<byte> overload (apples-to-apples with AcBinary's
|
||||
// BufWr Deser path). MemoryPack's ROS overload also single-segment-fast-paths internally.
|
||||
[MethodImpl(MethodImplOptions.NoInlining)]
|
||||
public void Deserialize() => MemoryPackSerializer.Deserialize<T>(new ReadOnlySequence<byte>(_serialized), _options);
|
||||
|
||||
public bool VerifyRoundTrip()
|
||||
{
|
||||
var abw = new ArrayBufferWriter<byte>();
|
||||
MemoryPackSerializer.Serialize(abw, _order, _options);
|
||||
var roundTripped = MemoryPackSerializer.Deserialize<T>(new ReadOnlySequence<byte>(abw.WrittenMemory), _options);
|
||||
return RoundTripValidator.DeepEqualsViaJson(_order, roundTripped);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,59 @@
|
|||
#if !AYCODE_NATIVEAOT
|
||||
using AyCode.Core.Tests.TestModels;
|
||||
using MessagePack;
|
||||
using MessagePack.Resolvers;
|
||||
using System.Runtime.CompilerServices;
|
||||
|
||||
namespace AyCode.Core.Benchmarks.Workloads.Scenarios;
|
||||
|
||||
/// <summary>
|
||||
/// MessagePack benchmark, Byte[] I/O mode. Excluded from NativeAOT build because v3's StandardResolver
|
||||
/// falls back to DynamicGenericResolver for closed-generic types (List<TestOrderItem_All_True> et al.),
|
||||
/// which uses Activator.CreateInstance on formatter types the AOT trimmer drops →
|
||||
/// MissingMethodException at runtime. Available for regular JIT runs (<c>dotnet run</c>) only.
|
||||
/// </summary>
|
||||
public sealed class MessagePackBenchmark<T> : ISerializerBenchmark where T : class
|
||||
{
|
||||
private readonly T _order;
|
||||
private readonly MessagePackSerializerOptions _options;
|
||||
private readonly byte[] _serialized;
|
||||
|
||||
public BenchmarkEngine Engine => BenchmarkEngine.MessagePack;
|
||||
public BenchmarkIoMode IoMode => BenchmarkIoMode.ByteArray;
|
||||
public BenchmarkDispatchMode DispatchMode => BenchmarkDispatchMode.SGen; // MessagePack uses [MessagePackObject] source-generated formatters (StandardResolver)
|
||||
public Type OrderType => typeof(T);
|
||||
public string OptionsPreset { get; }
|
||||
public int SerializedSize => _serialized.Length;
|
||||
public long SetupSerializeAllocBytes => 0;
|
||||
public long SetupDeserializeAllocBytes => 0;
|
||||
public string OptionsDescription { get; }
|
||||
|
||||
public MessagePackBenchmark(T order, string optionsPreset)
|
||||
{
|
||||
_order = order;
|
||||
OptionsPreset = optionsPreset;
|
||||
|
||||
//_options = ContractlessStandardResolver.Options.WithCompression(MessagePackCompression.None);
|
||||
//_options = ContractlessStandardResolver.Options.WithCompression(MessagePackCompression.Lz4Block);
|
||||
_options = MessagePackSerializerOptions.Standard.WithCompression(MessagePackCompression.None);
|
||||
|
||||
var isContractless = _options.Resolver is ContractlessStandardResolver;
|
||||
OptionsDescription = $"Mode={( isContractless ? "Contractless" : "ContractBased")}, Compression={_options.Compression}";
|
||||
|
||||
_serialized = MessagePackSerializer.Serialize(order, _options);
|
||||
}
|
||||
|
||||
[MethodImpl(MethodImplOptions.NoInlining)]
|
||||
public void Serialize() => MessagePackSerializer.Serialize(_order, _options);
|
||||
|
||||
[MethodImpl(MethodImplOptions.NoInlining)]
|
||||
public void Deserialize() => MessagePackSerializer.Deserialize<T>(_serialized, _options);
|
||||
|
||||
public bool VerifyRoundTrip()
|
||||
{
|
||||
var bytes = MessagePackSerializer.Serialize(_order, _options);
|
||||
var roundTripped = MessagePackSerializer.Deserialize<T>(bytes, _options);
|
||||
return RoundTripValidator.DeepEqualsViaJson(_order, roundTripped);
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
|
@ -0,0 +1,43 @@
|
|||
# Workloads / Scenarios
|
||||
|
||||
Shared workload + scenario types used by **both** the Console runner (custom adaptive measure engine) and the BDN runner (`AcBinaryVsMemPackBenchmark` in the parent folder). Same wire payloads, same options, same round-trip-verify gate → Console and BDN cells are directly comparable.
|
||||
|
||||
## Layout
|
||||
|
||||
### Contract types
|
||||
|
||||
- [`ISerializerBenchmark.cs`](ISerializerBenchmark.cs) — common contract for every (Engine × IoMode × OptionsPreset) row. `Serialize()` / `Deserialize()` hot-path + warmup hooks + `VerifyRoundTrip()` for the pre-warmup correctness gate. Round-trip-only benchmarks (NamedPipe / in-memory Pipe) set `IsRoundTripOnly = true` and let the bench loop skip the Des-phase.
|
||||
- [`BenchmarkEnums.cs`](BenchmarkEnums.cs) — `BenchmarkEngine` / `BenchmarkIoMode` / `BenchmarkDispatchMode` / `BenchmarkLayer` / `BenchmarkOpMode` / `SerializerSelectionMode` + `ToDisplay()` extensions for the column-friendly rendering used by every output formatter.
|
||||
- [`BenchmarkOptions.cs`](BenchmarkOptions.cs) — per-engine options-formatting helpers + the cached `AttrFlags` aggregation (assembly-scan of `[AcBinarySerializable]` feature flags) + `GetMemPack(WireMode)` for the wire-mode-aligned MemoryPack-options selection.
|
||||
- [`RoundTripValidator.cs`](RoundTripValidator.cs) — universal deep-equality oracle via canonical System.Text.Json. Called by every benchmark's `VerifyRoundTrip()` before warmup. AOT-skipped (STJ reflection path incompatible).
|
||||
|
||||
### Concrete benchmarks (12 implementations)
|
||||
|
||||
**AcBinary** (7 variants — different I/O modes):
|
||||
- [`AcBinaryBenchmark.cs`](AcBinaryBenchmark.cs) — `Byte[]` API. Headline AcBinary row.
|
||||
- [`AcBinaryBufferWriterBenchmark.cs`](AcBinaryBufferWriterBenchmark.cs) — pre-allocated, reused `ArrayBufferWriter<byte>`.
|
||||
- [`AcBinaryFreshBufferWriterBenchmark.cs`](AcBinaryFreshBufferWriterBenchmark.cs) — fresh `ArrayBufferWriter` per call (one-shot scenario, 4 KB chunk).
|
||||
- [`AcBinaryNamedPipeBenchmark.cs`](AcBinaryNamedPipeBenchmark.cs) — chunked-framed `AsyncPipe` over kernel NamedPipe (long-lived, multi-message, 2-task pipeline).
|
||||
- [`AcBinaryNamedPipeRawByteArrayBenchmark.cs`](AcBinaryNamedPipeRawByteArrayBenchmark.cs) — raw `byte[]` over kernel NamedPipe (no chunk-framing, Read+Des sequential after Read completes).
|
||||
- [`AcBinaryInMemoryPipeBenchmark.cs`](AcBinaryInMemoryPipeBenchmark.cs) — chunked-framed `AsyncPipe` over in-memory `System.IO.Pipelines.Pipe` (zero kernel involvement, isolates streaming-framework CPU cost from kernel-pipe transport overhead).
|
||||
- [`AcBinaryInMemoryRawByteArrayBenchmark.cs`](AcBinaryInMemoryRawByteArrayBenchmark.cs) — raw `byte[]` over in-memory cross-thread handoff (no transport at all, completes the 2×2 [chunked|raw] × [kernel|memory] matrix).
|
||||
|
||||
**MemoryPack** (3 variants — apples-to-apples with the AcBinary I/O modes):
|
||||
- [`MemoryPackBenchmark.cs`](MemoryPackBenchmark.cs) — `Byte[]` API. SOTA baseline.
|
||||
- [`MemoryPackBufferWriterBenchmark.cs`](MemoryPackBufferWriterBenchmark.cs) — reused `ArrayBufferWriter`.
|
||||
- [`MemoryPackFreshBufferWriterBenchmark.cs`](MemoryPackFreshBufferWriterBenchmark.cs) — fresh `ArrayBufferWriter` per call.
|
||||
|
||||
**Other** (reference comparison, typically disabled in active suite):
|
||||
- [`MessagePackBenchmark.cs`](MessagePackBenchmark.cs) — JIT-only (AOT-incompatible — v3 StandardResolver falls back to `Activator.CreateInstance` on trimmed closed-generic types).
|
||||
- [`SystemTextJsonBenchmark.cs`](SystemTextJsonBenchmark.cs) — String I/O mode, reflection-based metadata. Far behind binary serializers on µs/op; useful as a JSON baseline when activated.
|
||||
|
||||
## Convention
|
||||
|
||||
Every concrete benchmark:
|
||||
|
||||
1. Stores the test data graph + serializer options in its ctor and pre-computes a `_serialized` byte array for `SerializedSize` reporting.
|
||||
2. Implements `Serialize()` / `Deserialize()` as `[MethodImpl(NoInlining)]` hot-paths — the bench loop drives these directly through warmup + adaptive-iter calibration + measurement.
|
||||
3. Implements `VerifyRoundTrip()` by calling `RoundTripValidator.DeepEqualsViaJson(original, roundTripped)` on the result of a single Ser+Des pass.
|
||||
4. Round-trip-only variants (NamedPipe / in-memory Pipe) override `IsRoundTripOnly => true`, route the full Ser+wire+Des roundtrip through `Serialize()`, and leave `Deserialize()` as a no-op.
|
||||
|
||||
The runner (Console `BenchmarkLoop` or BDN `AcBinaryVsMemPackBenchmark`) creates the appropriate concrete via factory helpers and drives the contract — no scenario-specific knowledge in the runner.
|
||||
|
|
@ -0,0 +1,49 @@
|
|||
using System.Text.Json;
|
||||
|
||||
namespace AyCode.Core.Benchmarks.Workloads.Scenarios;
|
||||
|
||||
/// <summary>
|
||||
/// Round-trip correctness validator — serializes both sides to canonical System.Text.Json form
|
||||
/// and compares the resulting strings. Works for any object graph without a custom comparer (slower
|
||||
/// than property-by-property but universal). Used by every benchmark's <c>VerifyRoundTrip()</c>
|
||||
/// implementation as the deep-equality oracle before warmup begins.
|
||||
/// </summary>
|
||||
public static class RoundTripValidator
|
||||
{
|
||||
#if !AYCODE_NATIVEAOT
|
||||
private static readonly JsonSerializerOptions VerifyJsonOpts = new()
|
||||
{
|
||||
WriteIndented = false,
|
||||
DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull,
|
||||
ReferenceHandler = System.Text.Json.Serialization.ReferenceHandler.IgnoreCycles
|
||||
};
|
||||
#endif
|
||||
|
||||
/// <summary>
|
||||
/// Round-trip equality check via canonical System.Text.Json. Returns true if both sides serialize
|
||||
/// to identical JSON strings.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// AOT publish skip: <c>System.Text.Json</c>'s reflection path uses runtime closed-generic instantiation
|
||||
/// (<c>JsonPropertyInfo<TestStatus></c> et al.) that the trimmer drops, causing
|
||||
/// <c>NotSupportedException: missing native code or metadata</c>. The validation is JIT-only — the actual
|
||||
/// benchmark Serialize/Deserialize loops don't touch this path. Under AOT we return <c>true</c> so all
|
||||
/// <c>VerifyRoundTrip()</c> calls pass without running the cross-format validation.
|
||||
/// </remarks>
|
||||
public static bool DeepEqualsViaJson(object? a, object? b)
|
||||
{
|
||||
#if AYCODE_NATIVEAOT
|
||||
// Skip cross-format validation under AOT — STJ reflection path is incompatible. The roundtrip
|
||||
// itself still runs (caller-side Serialize+Deserialize), just the JSON-canonical compare is bypassed.
|
||||
return true;
|
||||
#else
|
||||
if (a == null && b == null) return true;
|
||||
if (a == null || b == null) return false;
|
||||
|
||||
var jsonA = JsonSerializer.Serialize(a, VerifyJsonOpts);
|
||||
var jsonB = JsonSerializer.Serialize(b, VerifyJsonOpts);
|
||||
|
||||
return jsonA == jsonB;
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,59 @@
|
|||
using AyCode.Core.Tests.TestModels;
|
||||
using System.Runtime.CompilerServices;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace AyCode.Core.Benchmarks.Workloads.Scenarios;
|
||||
|
||||
/// <summary>
|
||||
/// System.Text.Json benchmark, String I/O mode. Reference comparison — uses reflection-based metadata
|
||||
/// (no source-generator opt-in here). Typically NOT in the active suite (commented out in the
|
||||
/// caller-side <c>CreateSerializers</c>); ranks far behind binary serializers on µs/op but provides
|
||||
/// a familiar JSON baseline when needed.
|
||||
/// </summary>
|
||||
public sealed class SystemTextJsonBenchmark<T> : ISerializerBenchmark where T : class
|
||||
{
|
||||
private readonly T _order;
|
||||
private readonly JsonSerializerOptions _options;
|
||||
private readonly string _serialized;
|
||||
private readonly byte[] _serializedUtf8;
|
||||
|
||||
public BenchmarkEngine Engine => BenchmarkEngine.SystemTextJson;
|
||||
public BenchmarkIoMode IoMode => BenchmarkIoMode.String;
|
||||
public BenchmarkDispatchMode DispatchMode => BenchmarkDispatchMode.Runtime; // System.Text.Json default uses reflection-based metadata (no source generator opt-in here)
|
||||
public Type OrderType => typeof(T);
|
||||
public string OptionsPreset { get; }
|
||||
public int SerializedSize => _serializedUtf8.Length;
|
||||
public long SetupSerializeAllocBytes => 0;
|
||||
public long SetupDeserializeAllocBytes => 0;
|
||||
|
||||
public SystemTextJsonBenchmark(T order, string optionsPreset)
|
||||
{
|
||||
_order = order;
|
||||
OptionsPreset = optionsPreset;
|
||||
_options = new JsonSerializerOptions
|
||||
{
|
||||
WriteIndented = false,
|
||||
DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull,
|
||||
ReferenceHandler = System.Text.Json.Serialization.ReferenceHandler.IgnoreCycles
|
||||
};
|
||||
_serialized = JsonSerializer.Serialize(order, _options);
|
||||
// Encoding.UTF8.GetBytes(string) does NOT prepend the BOM (the preamble is only emitted by
|
||||
// GetPreamble() / stream-level writes), so this produces identical bytes to the prior
|
||||
// `new UTF8Encoding(false).GetBytes(_serialized)` call. Size-reporting only.
|
||||
_serializedUtf8 = Encoding.UTF8.GetBytes(_serialized);
|
||||
}
|
||||
|
||||
[MethodImpl(MethodImplOptions.NoInlining)]
|
||||
public void Serialize() => JsonSerializer.Serialize(_order, _options);
|
||||
|
||||
[MethodImpl(MethodImplOptions.NoInlining)]
|
||||
public void Deserialize() => JsonSerializer.Deserialize<T>(_serialized, _options);
|
||||
|
||||
public bool VerifyRoundTrip()
|
||||
{
|
||||
var json = JsonSerializer.Serialize(_order, _options);
|
||||
var roundTripped = JsonSerializer.Deserialize<T>(json, _options);
|
||||
return RoundTripValidator.DeepEqualsViaJson(_order, roundTripped);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,53 @@
|
|||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\AyCode.Core\AyCode.Core.csproj" />
|
||||
<ProjectReference Include="..\AyCode.Core.Tests\AyCode.Core.Tests.csproj" />
|
||||
<ProjectReference Include="..\AyCode.Benchmark\AyCode.Benchmark.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="MemoryPack" Version="1.21.4" />
|
||||
<PackageReference Include="MessagePack" Version="3.1.4" />
|
||||
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
|
||||
</ItemGroup>
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<StartupObject>AyCode.Core.Serializers.Console.Program</StartupObject>
|
||||
</PropertyGroup>
|
||||
|
||||
<Import Project="..\AyCode.Core.targets" />
|
||||
|
||||
<!-- AOT-mode is publish-time only.
|
||||
Why conditional on $(_IsPublishing): with .NET 8+, an unconditional <PublishAot>true</PublishAot>
|
||||
forces the SDK to auto-set <IsDynamicCodeSupported>false</IsDynamicCodeSupported> as a runtime
|
||||
host config option — meaning even regular `dotnet build` / `dotnet run` outputs report
|
||||
RuntimeFeature.IsDynamicCodeSupported == false at runtime. That makes AcSerializerCommon's
|
||||
Runtime path take the reflection fallback (ctor.Invoke / PropertyInfo.GetValue) instead of
|
||||
Expression.Compile during JIT testing — Release benchmark numbers measure reflection, not
|
||||
compiled expressions. Restricting PublishAot to actual publish keeps JIT semantics for
|
||||
`dotnet build` / `dotnet run` while preserving full AOT analysis on `dotnet publish`.
|
||||
|
||||
AYCODE_NATIVEAOT define moved here too — it's the publish-time #if symbol that gates out
|
||||
MessagePack benchmark + STJ-based DeepEqualsViaJson validation in Program.cs (both
|
||||
incompatible with AOT trim/runtime constraints). Same conditioning ensures the symbol is
|
||||
defined exactly when PublishAot is in effect. -->
|
||||
<PropertyGroup Condition="'$(_IsPublishing)' == 'true'">
|
||||
<PublishAot>true</PublishAot>
|
||||
<InvariantGlobalization>true</InvariantGlobalization>
|
||||
<DefineConstants>$(DefineConstants);AYCODE_NATIVEAOT</DefineConstants>
|
||||
|
||||
<!-- DAMs propagation chain landed across the public Deserialize<T>/Serialize<T> entry points down to
|
||||
AcSerializerCommon factory methods. Remaining trim warnings concentrate on:
|
||||
(a) serialize-side polymorphism via obj.GetType() — fundamental trimmer blind spot
|
||||
(b) internal Type-flow through serialize helpers (ScanValueGenerated, WritePropertyOrSkip)
|
||||
(c) external dependencies (MemoryPack/MessagePack/AutoMapper/MongoDB/STJ) — out of scope
|
||||
Suppress for now so builds succeed; revisit if AOT runtime issues surface beyond ctor metadata. -->
|
||||
<SuppressTrimAnalysisWarnings>true</SuppressTrimAnalysisWarnings>
|
||||
<TrimmerSingleWarn>false</TrimmerSingleWarn>
|
||||
|
||||
<JsonSerializerIsReflectionEnabledByDefault>true</JsonSerializerIsReflectionEnabledByDefault>
|
||||
</PropertyGroup>
|
||||
|
||||
</Project>
|
||||
|
|
@ -0,0 +1,888 @@
|
|||
using AyCode.Core.Benchmarks.Reporting;
|
||||
using AyCode.Core.Benchmarks.Workloads.Scenarios;
|
||||
using AyCode.Core.Serializers.Binaries;
|
||||
using AyCode.Core.Tests.TestModels;
|
||||
using MemoryPack;
|
||||
using System.Diagnostics;
|
||||
using System.Runtime.CompilerServices;
|
||||
|
||||
namespace AyCode.Core.Serializers.Console;
|
||||
|
||||
/// <summary>
|
||||
/// Benchmark execution: end-to-end orchestration (<see cref="RunBenchmark"/>), per-cell loop
|
||||
/// (<see cref="RunBenchmarksForTestData"/>), serializer factory (<see cref="CreateSerializers"/>),
|
||||
/// and the timing / calibration / allocation helpers. Pure benchmark-execution infrastructure —
|
||||
/// no display formatting (that lives in <c>Output</c>) and no UX-flow (that lives in <c>Program</c>
|
||||
/// + <c>Menu</c>).
|
||||
/// </summary>
|
||||
internal static class BenchmarkLoop
|
||||
{
|
||||
/// <summary>
|
||||
/// Runs the benchmark suite end-to-end for the given configuration: pre-warmup → per-cell warmup
|
||||
/// + measurement → grouped results print → save to disk. Used by both the CLI and interactive
|
||||
/// menu paths; the interactive loop calls this repeatedly without restarting the process.
|
||||
/// </summary>
|
||||
internal static void RunBenchmark(BenchmarkLayer layer, BenchmarkOpMode opMode, SerializerSelectionMode serializerMode)
|
||||
{
|
||||
System.Console.WriteLine("╔══════════════════════════════════════════════════════════════════════╗");
|
||||
System.Console.WriteLine("║ COMPREHENSIVE SERIALIZER BENCHMARK SUITE ║");
|
||||
System.Console.WriteLine("╚══════════════════════════════════════════════════════════════════════╝");
|
||||
|
||||
// Stabilization: pin the entire benchmark process to a single logical CPU and bump priority
|
||||
// class. Single-core affinity stops Windows from migrating the bench thread between cores
|
||||
// mid-sample (a migration evicts L1/L2 caches and corrupts a measurement); High priority
|
||||
// reduces preemption by background tasks (Defender scans, indexer, etc.) that otherwise
|
||||
// randomly inflate samples by 5-15%.
|
||||
// Try/finally guarantees the original state is restored even if a benchmark throws — leaving
|
||||
// a developer machine pinned to one core after a crashed run is a real foot-gun.
|
||||
// Skipped on Debug single-sample mode (Configuration.BenchmarkSamples <= 1) where stabilization is moot.
|
||||
var process = Process.GetCurrentProcess();
|
||||
var origAffinity = (IntPtr)0;
|
||||
var origPriority = ProcessPriorityClass.Normal;
|
||||
var stabilizationApplied = false;
|
||||
|
||||
// ProcessorAffinity is only supported on Windows + Linux (CA1416). macOS would throw at
|
||||
// runtime; skip the affinity step there but still raise priority class (which IS supported
|
||||
// on macOS, just less effective for stabilization than affinity pinning).
|
||||
if (Configuration.BenchmarkSamples > 1 && (OperatingSystem.IsWindows() || OperatingSystem.IsLinux()))
|
||||
{
|
||||
try
|
||||
{
|
||||
origAffinity = process.ProcessorAffinity;
|
||||
origPriority = process.PriorityClass;
|
||||
// Pin to CPU 0 (mask = 1). Choosing CPU 0 is arbitrary; what matters is "exactly one
|
||||
// core, consistently" — not which one. If CPU 0 is heavily contended on the host
|
||||
// (e.g. dedicated to system-wide IRQs on some Windows configs), the user can tweak
|
||||
// the mask here. The benchmark is single-threaded for the in-memory rows so single
|
||||
// core is sufficient; round-trip-only NamedPipe rows have a server-drain thread
|
||||
// that will share the core (acceptable — the bench measures end-to-end RT anyway).
|
||||
process.ProcessorAffinity = (IntPtr)1;
|
||||
process.PriorityClass = ProcessPriorityClass.High;
|
||||
stabilizationApplied = true;
|
||||
System.Console.WriteLine($"Stabilization: pinned to CPU 0 (affinity=0x1), priority=High.");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Affinity/priority changes may fail on locked-down hosts (group policies, containers
|
||||
// without CAP_SYS_NICE on Linux, etc.). Surface and continue — the benchmark still
|
||||
// works, just with the platform default scheduling.
|
||||
System.Console.WriteLine($"Stabilization SKIPPED: {ex.GetType().Name}: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var allResults = new List<BenchmarkResult>();
|
||||
var allTestDataSets = BuildMultiVariantTestDataSets();
|
||||
var testDataSets = FilterByLayer(allTestDataSets, layer);
|
||||
|
||||
System.Console.WriteLine($"Layer: {layer} | OpMode: {opMode} | SerializerMode: {serializerMode} | Charset: {Configuration.GetCurrentCharsetName()} | Iterations: per-cell adaptive (~{Configuration.TargetSampleMs} ms target) | Warmup: {Configuration.WarmupIterations} per phase (Ser/Des isolated) | Samples: {Configuration.BenchmarkSamples} (median) + pilot discard");
|
||||
System.Console.WriteLine($"Build: {Configuration.BuildConfiguration} | .NET: {Environment.Version} | Test Cells: {testDataSets.Count}/{allTestDataSets.Count}");
|
||||
System.Console.WriteLine();
|
||||
|
||||
// Global JIT pre-warmup — touches every (testdata × serializer) code path BEFORE any timing happens.
|
||||
// Without this, the FIRST test data measured carries JIT-tier-promotion latency: the per-cell warmup
|
||||
// alone doesn't ensure that every Serialize<T>/IBufferWriter overload is fully Tier 1 by the time we
|
||||
// start measuring. Symptom: first cell's BufferWriter variants run ~2x slower than the SAME variants
|
||||
// on later cells (e.g. Small BufWr reuse 9ms vs Medium BufWr reuse 4ms — even though Medium is bigger).
|
||||
// Pre-warmup runs every overload at least once with each data shape so .NET 9's tiered JIT promotes
|
||||
// them all in the background; the per-cell warmup that follows then locks in cache + branch state.
|
||||
if (Configuration.BenchmarkSamples > 1) // skip in DEBUG (single-sample fast iteration)
|
||||
{
|
||||
System.Console.WriteLine($"Global JIT pre-warmup ({testDataSets.Count} cells × all serializers, light pass)...");
|
||||
|
||||
foreach (var testData in testDataSets)
|
||||
{
|
||||
var preSerializers = CreateSerializers(testData, serializerMode);
|
||||
try
|
||||
{
|
||||
foreach (var s in preSerializers)
|
||||
{
|
||||
// Light warmup just to trigger Tier 0 → Tier 1 promotion. Phase-isolated:
|
||||
// Ser path first, then Des path — same pattern as the per-cell warmup in
|
||||
// RunBenchmarksForTestData (which still runs afterwards for cache/BTB warming).
|
||||
s.WarmupSerialize(2000);
|
||||
s.WarmupDeserialize(2000);
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
// Dispose any IDisposable serializers (NamedPipe / FileStream variants own OS resources).
|
||||
foreach (var s in preSerializers) (s as IDisposable)?.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
// Let background tiered-JIT compilation drain before we begin measuring.
|
||||
if (Configuration.JitSleep > 0) Thread.Sleep(Configuration.JitSleep);
|
||||
System.Console.WriteLine("✓ Global pre-warmup complete.\n");
|
||||
}
|
||||
|
||||
foreach (var testData in testDataSets)
|
||||
{
|
||||
System.Console.WriteLine($"\n{'═'.ToString().PadRight(70, '═')}");
|
||||
System.Console.WriteLine($"TEST DATA: {testData.DisplayName}");
|
||||
System.Console.WriteLine($"{'═'.ToString().PadRight(70, '═')}");
|
||||
|
||||
var results = RunBenchmarksForTestData(testData, opMode, serializerMode);
|
||||
allResults.AddRange(results);
|
||||
}
|
||||
|
||||
// Build the reporting context (resolves path via walk-up to .sln, snapshots run-config).
|
||||
var ctx = new ReportingContext(
|
||||
SourceTag: "Console",
|
||||
ResultsDirectory: ReportingContext.ResolveResultsDirectory(),
|
||||
BuildConfiguration: Configuration.BuildConfiguration,
|
||||
Utf8NoBom: Configuration.Utf8NoBom,
|
||||
CharsetName: Configuration.GetCurrentCharsetName(),
|
||||
WarmupIterations: Configuration.WarmupIterations,
|
||||
BenchmarkSamples: Configuration.BenchmarkSamples,
|
||||
TargetSampleMs: Configuration.TargetSampleMs,
|
||||
UnstableCVThreshold: Configuration.UnstableCVThreshold,
|
||||
MicroOptCVThreshold: Configuration.MicroOptCVThreshold);
|
||||
|
||||
// Print grouped results
|
||||
BenchmarkReportWriter.PrintGroupedResults(allResults, testDataSets);
|
||||
|
||||
// Save results to file (.log + .LLM + .output)
|
||||
BenchmarkReportWriter.SaveAll(ctx, allResults, testDataSets);
|
||||
|
||||
System.Console.WriteLine("\n✓ Benchmark complete!");
|
||||
}
|
||||
finally
|
||||
{
|
||||
// Restore process state — affinity/priority changes are process-wide and persist across
|
||||
// interactive-mode iterations of the menu. Without restore, the second menu run would
|
||||
// already be on CPU-0 + High priority before its own try-block applied them, masking
|
||||
// any stabilization-disabled comparison.
|
||||
if (stabilizationApplied && (OperatingSystem.IsWindows() || OperatingSystem.IsLinux()))
|
||||
{
|
||||
try { process.ProcessorAffinity = origAffinity; } catch { /* best-effort */ }
|
||||
try { process.PriorityClass = origPriority; } catch { /* best-effort */ }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static List<BenchmarkResult> RunBenchmarksForTestData(TestDataSet testData, BenchmarkOpMode mode, SerializerSelectionMode serializerMode)
|
||||
{
|
||||
var results = new List<BenchmarkResult>();
|
||||
var serializers = CreateSerializers(testData, serializerMode);
|
||||
|
||||
// Round-trip correctness check — once per (cell × serializer), BEFORE warmup. Aborts the entire benchmark on failure.
|
||||
System.Console.WriteLine("Verifying round-trip correctness...");
|
||||
|
||||
foreach (var serializer in serializers)
|
||||
{
|
||||
if (!serializer.VerifyRoundTrip())
|
||||
{
|
||||
System.Console.Error.WriteLine($"❌ FATAL: Round-trip verification FAILED for {serializer.Name} on {testData.DisplayName}");
|
||||
System.Console.Error.WriteLine("Benchmark numbers from a serializer with broken round-trip would be meaningless. Aborting.");
|
||||
|
||||
Environment.Exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
System.Console.WriteLine("✓ All serializers passed round-trip verification.");
|
||||
|
||||
// Per-serializer, PER-PHASE (warmup → calibrate → measurement) cycle: each serializer's Ser-path and
|
||||
// Des-path get COMPLETELY ISOLATED warmup→measure rounds, with a GC.Collect at every phase boundary.
|
||||
//
|
||||
// Why phase-isolation: a combined warmup (Ser+Des interleaved) leaves the CPU I-cache + branch-predictor
|
||||
// in a "compromise state" — neither Ser nor Des code-set dominates. The first phase to measure pays a
|
||||
// cache-miss penalty as its code-set displaces the leftover-warmup-state. Isolated warmup→measure pairs
|
||||
// keep the I-cache HOT for ONLY the measured path, both in the warmup (priming) and the measurement
|
||||
// (steady-state). Branch-predictor history also stays clean per path.
|
||||
//
|
||||
// GC.Collect at every boundary: removes residual allocation pressure from the previous phase (write-buffer
|
||||
// pool churn from Ser, deserialized object graph from Des) so the next phase starts with a quiescent
|
||||
// heap — GC tier-promotion timing during measurement is then driven only by THAT phase's allocations.
|
||||
//
|
||||
// Configuration.JitSleep per-phase: tiered JIT background promotion drain after each warmup (mode-aware: 0 ms in AOT).
|
||||
// Each phase's freshly-promoted methods settle before its timing starts.
|
||||
System.Console.WriteLine($"Running benchmarks (target ~{Configuration.TargetSampleMs} ms/sample × {Configuration.BenchmarkSamples} samples median, phase-isolated warmup/measure per Ser/Des)...\n");
|
||||
|
||||
foreach (var serializer in serializers)
|
||||
{
|
||||
var result = new BenchmarkResult
|
||||
{
|
||||
TestDataName = testData.DisplayName, // Use DisplayName for IId% info
|
||||
Engine = serializer.Engine,
|
||||
IoMode = serializer.IoMode,
|
||||
DispatchMode = serializer.DispatchMode,
|
||||
OptionsPreset = serializer.OptionsPreset,
|
||||
OrderTypeName = serializer.OrderTypeName,
|
||||
OptionsDescription = serializer.OptionsDescription,
|
||||
SerializedSize = serializer.SerializedSize,
|
||||
SetupSerializeAllocBytes = serializer.SetupSerializeAllocBytes,
|
||||
SetupDeserializeAllocBytes = serializer.SetupDeserializeAllocBytes,
|
||||
IsRoundTripOnly = serializer.IsRoundTripOnly
|
||||
};
|
||||
|
||||
// Group label for in-place \r progress. Identifies (cell × serializer) so a stuck benchmark
|
||||
// is visibly stuck on a specific row at a specific %% rather than silently hanging.
|
||||
var groupLabel = $"{result.SerializerName}";
|
||||
|
||||
if (serializer.IsRoundTripOnly)
|
||||
{
|
||||
// Round-trip-only benchmarks (NamedPipe etc.): single phase — Serialize() performs the full RT,
|
||||
// Deserialize() is a no-op. We use the Ser-phase entry-points (WarmupSerialize) to warm the
|
||||
// entire round-trip path, then record into the RT result columns.
|
||||
if (mode is BenchmarkOpMode.All or BenchmarkOpMode.Serialize)
|
||||
{
|
||||
ForceGcCollect();
|
||||
serializer.WarmupSerialize(Configuration.WarmupIterations);
|
||||
if (Configuration.JitSleep > 0) Thread.Sleep(Configuration.JitSleep);
|
||||
|
||||
var rtIter = CalibrateIterations(() => serializer.Serialize(), Configuration.TargetSampleMs);
|
||||
var (rtMed, rtMin, rtMax, rtStd) = RunTimed(() => serializer.Serialize(), rtIter, $"{groupLabel} [RT timing]");
|
||||
result.RoundTripTimeMs = rtMed;
|
||||
result.RoundTripTimeMinMs = rtMin;
|
||||
result.RoundTripTimeMaxMs = rtMax;
|
||||
result.RoundTripTimeStdDevMs = rtStd;
|
||||
result.RoundTripIterations = rtIter;
|
||||
// Process-wide allocation measurement: server-drain-thread allocations (server-side new byte[len])
|
||||
// also show up — otherwise current-thread alloc would only count the client side and look ~halved.
|
||||
result.RoundTripAllocBytesPerOp = MeasureAllocation(() => serializer.Serialize(), rtIter, $"{groupLabel} [RT alloc]", processWide: true);
|
||||
}
|
||||
// mode == BenchmarkOpMode.Deserialize alone is meaningless for a round-trip-only benchmark; skip silently.
|
||||
}
|
||||
else
|
||||
{
|
||||
// ── Ser phase ── isolated warmup → Configuration.JitSleep → calibrate → time → alloc; preceded by GC.Collect.
|
||||
if (mode is BenchmarkOpMode.All or BenchmarkOpMode.Serialize)
|
||||
{
|
||||
ForceGcCollect();
|
||||
serializer.WarmupSerialize(Configuration.WarmupIterations);
|
||||
if (Configuration.JitSleep > 0) Thread.Sleep(Configuration.JitSleep);
|
||||
|
||||
var serIter = CalibrateIterations(() => serializer.Serialize(), Configuration.TargetSampleMs);
|
||||
var (serMed, serMin, serMax, serStd) = RunTimed(() => serializer.Serialize(), serIter, $"{groupLabel} [Ser timing]");
|
||||
result.SerializeTimeMs = serMed;
|
||||
result.SerializeTimeMinMs = serMin;
|
||||
result.SerializeTimeMaxMs = serMax;
|
||||
result.SerializeTimeStdDevMs = serStd;
|
||||
result.SerializeIterations = serIter;
|
||||
// Dedicated alloc-only sample (separate from timing samples; keeps timing pure)
|
||||
result.SerializeAllocBytesPerOp = MeasureAllocation(() => serializer.Serialize(), serIter, $"{groupLabel} [Ser alloc]");
|
||||
}
|
||||
|
||||
// ── Des phase ── isolated warmup → Configuration.JitSleep → calibrate → time → alloc; preceded by GC.Collect.
|
||||
// The GC.Collect here is critical: it discards the Ser-phase's write-buffer pool churn so the
|
||||
// Des-phase's allocation measurement reflects ONLY Des-side allocations (deserialized object graph).
|
||||
if (mode is BenchmarkOpMode.All or BenchmarkOpMode.Deserialize)
|
||||
{
|
||||
ForceGcCollect();
|
||||
serializer.WarmupDeserialize(Configuration.WarmupIterations);
|
||||
if (Configuration.JitSleep > 0) Thread.Sleep(Configuration.JitSleep);
|
||||
|
||||
var desIter = CalibrateIterations(() => serializer.Deserialize(), Configuration.TargetSampleMs);
|
||||
var (desMed, desMin, desMax, desStd) = RunTimed(() => serializer.Deserialize(), desIter, $"{groupLabel} [Des timing]");
|
||||
result.DeserializeTimeMs = desMed;
|
||||
result.DeserializeTimeMinMs = desMin;
|
||||
result.DeserializeTimeMaxMs = desMax;
|
||||
result.DeserializeTimeStdDevMs = desStd;
|
||||
result.DeserializeIterations = desIter;
|
||||
result.DeserializeAllocBytesPerOp = MeasureAllocation(() => serializer.Deserialize(), desIter, $"{groupLabel} [Des alloc]");
|
||||
}
|
||||
|
||||
// Compose RT from Ser+Des. Because Ser and Des may have DIFFERENT iter counts post-calibration,
|
||||
// batch-time addition would be misleading. Instead: compute per-op µs (iter-independent),
|
||||
// then synthesize RoundTripTimeMs against RoundTripIterations = max(serIter, desIter) so that
|
||||
// RoundTripTimeMs / RoundTripIterations * 1000 == Output.SerPerOp + Output.DesPerOp.
|
||||
var serPerOp = BenchmarkReportWriter.ToPerOpMicros(result.SerializeTimeMs, result.SerializeIterations);
|
||||
var desPerOp = BenchmarkReportWriter.ToPerOpMicros(result.DeserializeTimeMs, result.DeserializeIterations);
|
||||
var rtPerOp = serPerOp + desPerOp;
|
||||
result.RoundTripIterations = Math.Max(result.SerializeIterations, result.DeserializeIterations);
|
||||
result.RoundTripTimeMs = rtPerOp / 1000.0 * result.RoundTripIterations;
|
||||
result.RoundTripAllocBytesPerOp = result.SerializeAllocBytesPerOp + result.DeserializeAllocBytesPerOp;
|
||||
}
|
||||
|
||||
results.Add(result);
|
||||
BenchmarkReportWriter.PrintResult(result);
|
||||
}
|
||||
|
||||
// Dispose any IDisposable serializers (NamedPipe / FileStream variants own OS resources that must be released
|
||||
// before the next test data builds new ones — otherwise pipes / handles leak across test cells).
|
||||
foreach (var s in serializers) (s as IDisposable)?.Dispose();
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Phase 2 multi-variant test-data builder. Constructs each cell in both the _All_False and
|
||||
/// _All_True families, then cross-registers _All_True on the _All_False primaries so the
|
||||
/// CreateSerializers downstream can pick the matching variant per AcBinary options preset.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Memory cost: ~600 KB across 5 cells (Large dominates at ~340 KB for both variants). The two
|
||||
/// families are built independently — same data values + same numeric sequence (per-family
|
||||
/// _idCounter reset). MemPack/MsgPack benchmarks consume the _All_True variant canonically;
|
||||
/// AcBinary's variant is preset-dependent (see CreateSerializers).
|
||||
/// </remarks>
|
||||
private static List<TestDataSet> BuildMultiVariantTestDataSets()
|
||||
{
|
||||
var allFalse = BenchmarkTestDataProvider_All_False.CreateTestDataSets();
|
||||
var allTrue = BenchmarkTestDataProvider.CreateTestDataSets();
|
||||
|
||||
// Zip by ordinal — both providers emit the same 5 cells in the same order
|
||||
// (Small / Medium / Large / Repeated / Deep), confirmed by their identical
|
||||
// CreateTestDataSets call sequence on the generic base.
|
||||
for (var i = 0; i < allFalse.Count; i++)
|
||||
{
|
||||
var falseDs = (TestDataSet<TestOrder_All_False>)allFalse[i];
|
||||
var trueDs = (TestDataSet<TestOrder_All_True>)allTrue[i];
|
||||
falseDs.RegisterVariant(trueDs.Order);
|
||||
}
|
||||
return allFalse;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Phase 2 variant dispatch rule for AcBinary: a preset uses <c>TestOrder_All_False</c> iff every
|
||||
/// AcBinary "feature flag" is off (no string interning, no reference handling, no metadata, no
|
||||
/// property filter). Any "true"-flagged feature promotes the benchmark to <c>TestOrder_All_True</c>
|
||||
/// — the richer graph + opt-out attribute model exercises the feature's deduplication / dispatch
|
||||
/// path on real shared-reference content. WireMode, SGen mode, and Compression are encoding-axis
|
||||
/// options and intentionally NOT part of this decision (they don't change which graph shape is
|
||||
/// meaningful to feed).
|
||||
/// </summary>
|
||||
private static bool UsesAllFalseVariant(AcBinarySerializerOptions options) =>
|
||||
options.UseStringInterning == StringInterningMode.None &&
|
||||
options.ReferenceHandling == ReferenceHandlingMode.None &&
|
||||
!options.UseMetadata &&
|
||||
options.PropertyFilter == null;
|
||||
|
||||
// Per-class factory helpers — each returns ISerializerBenchmark closed over the variant T
|
||||
// selected by UsesAllFalseVariant(options). Compile-time T at the new T() call site preserves
|
||||
// SGen apples-to-apples (no runtime reflection, no type erasure across the JIT boundary).
|
||||
private static ISerializerBenchmark MakeAcBinary(TestDataSet td, AcBinarySerializerOptions opt, string preset) =>
|
||||
UsesAllFalseVariant(opt)
|
||||
? new AcBinaryBenchmark<TestOrder_All_False>(td.GetOrder<TestOrder_All_False>(), opt, preset)
|
||||
: new AcBinaryBenchmark<TestOrder_All_True>(td.GetOrder<TestOrder_All_True>(), opt, preset);
|
||||
|
||||
private static ISerializerBenchmark MakeAcBinaryBufferWriter(TestDataSet td, AcBinarySerializerOptions opt, string preset) =>
|
||||
UsesAllFalseVariant(opt)
|
||||
? new AcBinaryBufferWriterBenchmark<TestOrder_All_False>(td.GetOrder<TestOrder_All_False>(), opt, preset)
|
||||
: new AcBinaryBufferWriterBenchmark<TestOrder_All_True>(td.GetOrder<TestOrder_All_True>(), opt, preset);
|
||||
|
||||
private static ISerializerBenchmark MakeAcBinaryFreshBufferWriter(TestDataSet td, AcBinarySerializerOptions opt, string preset) =>
|
||||
UsesAllFalseVariant(opt)
|
||||
? new AcBinaryFreshBufferWriterBenchmark<TestOrder_All_False>(td.GetOrder<TestOrder_All_False>(), opt, preset)
|
||||
: new AcBinaryFreshBufferWriterBenchmark<TestOrder_All_True>(td.GetOrder<TestOrder_All_True>(), opt, preset);
|
||||
|
||||
private static ISerializerBenchmark MakeAcBinaryNamedPipe(TestDataSet td, AcBinarySerializerOptions opt, string preset) =>
|
||||
UsesAllFalseVariant(opt)
|
||||
? new AcBinaryNamedPipeBenchmark<TestOrder_All_False>(td.GetOrder<TestOrder_All_False>(), opt, preset)
|
||||
: new AcBinaryNamedPipeBenchmark<TestOrder_All_True>(td.GetOrder<TestOrder_All_True>(), opt, preset);
|
||||
|
||||
private static ISerializerBenchmark MakeAcBinaryNamedPipeRaw(TestDataSet td, AcBinarySerializerOptions opt, string preset) =>
|
||||
UsesAllFalseVariant(opt)
|
||||
? new AcBinaryNamedPipeRawByteArrayBenchmark<TestOrder_All_False>(td.GetOrder<TestOrder_All_False>(), opt, preset)
|
||||
: new AcBinaryNamedPipeRawByteArrayBenchmark<TestOrder_All_True>(td.GetOrder<TestOrder_All_True>(), opt, preset);
|
||||
|
||||
private static ISerializerBenchmark MakeAcBinaryInMemoryPipe(TestDataSet td, AcBinarySerializerOptions opt, string preset) =>
|
||||
UsesAllFalseVariant(opt)
|
||||
? new AcBinaryInMemoryPipeBenchmark<TestOrder_All_False>(td.GetOrder<TestOrder_All_False>(), opt, preset)
|
||||
: new AcBinaryInMemoryPipeBenchmark<TestOrder_All_True>(td.GetOrder<TestOrder_All_True>(), opt, preset);
|
||||
|
||||
private static ISerializerBenchmark MakeAcBinaryInMemoryRaw(TestDataSet td, AcBinarySerializerOptions opt, string preset) =>
|
||||
UsesAllFalseVariant(opt)
|
||||
? new AcBinaryInMemoryRawByteArrayBenchmark<TestOrder_All_False>(td.GetOrder<TestOrder_All_False>(), opt, preset)
|
||||
: new AcBinaryInMemoryRawByteArrayBenchmark<TestOrder_All_True>(td.GetOrder<TestOrder_All_True>(), opt, preset);
|
||||
|
||||
private static List<ISerializerBenchmark> CreateSerializers(TestDataSet testData, SerializerSelectionMode serializerMode)
|
||||
{
|
||||
// Phase 2 variant dispatch (refined): AcBinary picks variant per UsesAllFalseVariant(options).
|
||||
// MemPack / MsgPack canonically use _All_False (no AcBinary opt-in/opt-out axis — both
|
||||
// produce identical MemPack/MsgPack wire on either variant since their contract is family-
|
||||
// agnostic). `orderFalse` is the cell primary; `orderTrue` is fetched on-demand by the AcBinary
|
||||
// factory helpers when an options preset has a "true" flag.
|
||||
var orderFalse = testData.GetOrder<TestOrder_All_False>();
|
||||
|
||||
// FastestByte mode — focused 1:1 comparison on the "fastest Byte[]" path.
|
||||
// TWO benchmarks: AcBinary FastMode Byte[] (Compact UTF-8) + MemoryPack Byte[].
|
||||
// - Compact: smallest wire, UTF-8 encode/decode CPU cost vs MemPack head-to-head.
|
||||
// Tight optimization-iteration loop: ~30-45 sec vs full 2-3 min.
|
||||
//
|
||||
// FastWire row (UTF-16 raw memcpy) commented out for the current optimization sprint —
|
||||
// we are tuning Compact mode against MemPack directly; FastWire was used as a noise-floor
|
||||
// reference earlier. Re-enable when revisiting Fast wire-mode performance.
|
||||
if (serializerMode == SerializerSelectionMode.FastestByte)
|
||||
{
|
||||
var fastestByteOptions = AcBinarySerializerOptions.FastMode;
|
||||
fastestByteOptions.WireMode = Configuration.SelectedWireMode;
|
||||
|
||||
return new List<ISerializerBenchmark>
|
||||
{
|
||||
MakeAcBinary(testData, fastestByteOptions, "FastMode"),
|
||||
//MakeAcBinary(testData, fastWireOptions, "FastMode (FastWire)"),
|
||||
// MemPack uses _All_False (the AcBinary opt-in/opt-out axis doesn't apply — MemoryPackable
|
||||
// serialises identical bytes either way; _All_False matches the orderFalse variant the test
|
||||
// data factory already built, no extra graph allocation needed).
|
||||
new MemoryPackBenchmark<TestOrder_All_False>(orderFalse, Configuration.SelectedWireMode, "Default"),
|
||||
};
|
||||
}
|
||||
|
||||
// AsyncPipe-only mode — return ONLY the AsyncPipe streaming benchmark (no other serializer).
|
||||
// Streaming I/O has long-lived pipe setup + kernel-buffer overhead that, when interleaved with
|
||||
// the standard byte-array / IBufferWriter measurements, masks the steady-state numbers. Run it
|
||||
// in isolation so the timing numbers reflect ONLY the streaming path.
|
||||
if (serializerMode == SerializerSelectionMode.AsyncPipe)
|
||||
{
|
||||
// NamedPipe — pipe-aligned chunk size for the long-lived IPC scenario. The chunkSize here
|
||||
// drives the AsyncPipeWriterOutput's chunk-on-wire size (header + data, page-aligned thanks to
|
||||
// the AcquireChunk fix) AND the kernel pipe buffer size (inBufferSize/outBufferSize on the
|
||||
// NamedPipeServerStream ctor). Same value across both layers = one WriteFile(chunkSize) syscall
|
||||
// fits blocking-free in one kernel pipe-buffer slot. Single source of truth for both app-level
|
||||
// wire chunk AND kernel transfer unit; change ONLY this line when tuning.
|
||||
var binaryFastModePipeChunkOnly = AcBinarySerializerOptions.FastMode;
|
||||
binaryFastModePipeChunkOnly.BufferWriterChunkSize = Configuration.PipeChunkSize;
|
||||
binaryFastModePipeChunkOnly.WireMode = Configuration.SelectedWireMode;
|
||||
|
||||
return new List<ISerializerBenchmark>
|
||||
{
|
||||
// Chunked-framed AsyncPipe: SerializeChunkedFramed + AsyncPipeReaderInput.DrainFromAsync.
|
||||
// Measures the FULL streaming-I/O stack — wire framing + drain task + sliding-window buffer +
|
||||
// MRES wait-on-byte-shortage — over a kernel NamedPipe.
|
||||
MakeAcBinaryNamedPipe(testData, binaryFastModePipeChunkOnly, "FastMode (PipeChunk)"),
|
||||
// Raw byte[] over NamedPipe (sync receive, no chunk-framing). Same kernel-pipe transport,
|
||||
// same inBufferSize, but: serialize → byte[] → Stream.Write → Stream.Read → Deserialize<T>(byte[]).
|
||||
// No drain task, no AsyncPipeReaderInput, no [201][UINT16][data]…[202] framing. Side-by-side with
|
||||
// the chunked-row above this isolates AsyncPipe-framework-overhead (Δ vs raw) from
|
||||
// kernel-transport-overhead (raw vs in-process Byte[]).
|
||||
MakeAcBinaryNamedPipeRaw(testData, binaryFastModePipeChunkOnly, "FastMode (PipeRaw)"),
|
||||
// Chunked-framed AsyncPipe over an IN-MEMORY System.IO.Pipelines.Pipe (NO NamedPipe, NO kernel).
|
||||
// Same chunked-streaming code path (SerializeChunkedFramed → AsyncPipeReaderInput) but with the
|
||||
// kernel-pipe replaced by a managed-only Pipe. Eliminates per-chunk syscall overhead (~30 µs/chunk
|
||||
// on NamedPipe → ~1-2 µs/chunk on in-memory Pipe). Side-by-side with the NamedPipe row above this
|
||||
// isolates pure CPU cost of the chunked-streaming framework (vs kernel-pipe transport cost) — the
|
||||
// in-memory Pipe row should be much closer to the raw-byte[] row, validating that NamedPipe loopback
|
||||
// is the worst-case benchmark scenario for chunked-streaming and not representative of real network
|
||||
// / file / cross-thread Pipe scenarios.
|
||||
MakeAcBinaryInMemoryPipe(testData, binaryFastModePipeChunkOnly, "FastMode (PipeChunk)"),
|
||||
// Raw byte[] over IN-MEMORY direct cross-thread handoff (no transport at all). Apples-to-apples
|
||||
// baseline for the in-memory chunked row above: same in-memory transport (zero kernel), but raw
|
||||
// byte[] vs chunked-streaming wire format. Completes the 2x2 matrix [chunked,raw] × [kernel,memory].
|
||||
MakeAcBinaryInMemoryRaw(testData, binaryFastModePipeChunkOnly, "FastMode (PipeRaw)"),
|
||||
};
|
||||
}
|
||||
|
||||
// Standard mode — all serializers EXCEPT AsyncPipe (the streaming benchmark is opt-in via the
|
||||
// AsyncPipe menu / CLI mode, never bundled with the steady-state suite).
|
||||
|
||||
var binaryNoInternOption = AcBinarySerializerOptions.Default;
|
||||
binaryNoInternOption.UseStringInterning = StringInterningMode.None;
|
||||
binaryNoInternOption.WireMode = Configuration.SelectedWireMode;
|
||||
|
||||
var binaryDefaultNoSgenOption = AcBinarySerializerOptions.Default;
|
||||
binaryDefaultNoSgenOption.UseGeneratedCode = false;
|
||||
binaryDefaultNoSgenOption.WireMode = Configuration.SelectedWireMode;
|
||||
|
||||
var binaryFastModeNoSgenOption = AcBinarySerializerOptions.FastMode;
|
||||
binaryFastModeNoSgenOption.UseGeneratedCode = false;
|
||||
binaryFastModeNoSgenOption.WireMode = Configuration.SelectedWireMode;
|
||||
|
||||
var binaryFastModeOption = AcBinarySerializerOptions.FastMode;
|
||||
binaryFastModeOption.WireMode = Configuration.SelectedWireMode;
|
||||
|
||||
// BufWr new — 4 KB chunk size for the FRESH ArrayBufferWriter scenario. The chunkSize here drives
|
||||
// the serializer's GetSpan(N) request → the ArrayBufferWriter's internal allocation per call.
|
||||
// Small chunk = small per-call allocation, optimum for one-shot serialization where each iteration
|
||||
// allocates a fresh ABW. Independent of the AsyncPipe profile (different mechanism: alloc overhead
|
||||
// vs syscall count).
|
||||
var binaryFastModeBufWrChunk = AcBinarySerializerOptions.FastMode;
|
||||
binaryFastModeBufWrChunk.BufferWriterChunkSize = Configuration.PipeChunkSize;
|
||||
binaryFastModeBufWrChunk.WireMode = Configuration.SelectedWireMode;
|
||||
|
||||
// In-memory Pipe variant — same 4 KB chunkSize as the AsyncPipe mode, no kernel-pipe alignment
|
||||
// concern (managed slabs are not page-aligned anyway). Drives SerializeChunkedFramed via the in-memory
|
||||
// System.IO.Pipelines.Pipe (zero-copy slab handoff between producer and drain task).
|
||||
var binaryFastModePipeChunkInMem = AcBinarySerializerOptions.FastMode;
|
||||
binaryFastModePipeChunkInMem.BufferWriterChunkSize = Configuration.PipeChunkSize;
|
||||
binaryFastModePipeChunkInMem.WireMode = Configuration.SelectedWireMode;
|
||||
|
||||
var defaultOptions = AcBinarySerializerOptions.Default;
|
||||
defaultOptions.UseStringInterning = StringInterningMode.None;
|
||||
defaultOptions.ReferenceHandling = ReferenceHandlingMode.OnlyId;
|
||||
defaultOptions.WireMode = Configuration.SelectedWireMode;
|
||||
|
||||
return new List<ISerializerBenchmark>
|
||||
{
|
||||
// ============================================================
|
||||
// AcBinary — Byte[] API (uncomment to compare option presets side-by-side)
|
||||
// ============================================================
|
||||
// Fastest Byte[] — SGen path (UseGeneratedCode=true, default).
|
||||
MakeAcBinary(testData, binaryFastModeOption, "FastMode"),
|
||||
// Fastest Byte[] — Runtime path (UseGeneratedCode=false). Same wire/options, no source-generated dispatch.
|
||||
// Always paired with the SGen variant so every layer can compare the SGen speed-up apples-to-apples.
|
||||
// NativeAOT-safe: AcSerializerCommon.Create*Getter/Setter falls back to reflection-based delegates
|
||||
// when RuntimeFeature.IsDynamicCodeSupported is false (slower but works under AOT publish).
|
||||
MakeAcBinary(testData, binaryFastModeNoSgenOption, "FastMode"),
|
||||
// Default preset Byte[] — RefHandling=OnlyId (deduplicates IId-shared references on the wire) +
|
||||
// UseStringInterning=All (deduplicates repeated strings). Showcases the Default preset's wire-size
|
||||
// and CPU trade-off vs FastMode on the ~20% IId-ref / repeated-string test data.
|
||||
|
||||
// Default preset (ReferenceHandling=OnlyId + StringInterning) → _All_True graph.
|
||||
// Phase 2 variant-dispatch rule: any options preset with a "true"-flagged feature uses
|
||||
// the _All_True family (rich graph, opt-out AcBinarySerializable attribute matches).
|
||||
MakeAcBinary(testData, defaultOptions, "Default"),
|
||||
//MakeAcBinary(testData, binaryDefaultNoSgenOption, "Default"),
|
||||
//MakeAcBinary(testData, AcBinarySerializerOptions.WithoutReferenceHandling, "NoRef"),
|
||||
//MakeAcBinary(testData, binaryNoInternOption, "NoIntern"),
|
||||
|
||||
// AcBinary via IBufferWriter (reused ArrayBufferWriter — long-running service / batch scenario)
|
||||
MakeAcBinaryBufferWriter(testData, binaryFastModeOption, "FastMode"),
|
||||
|
||||
// AcBinary via IBufferWriter (FRESH ArrayBufferWriter per call — one-shot scenario).
|
||||
// 4 KB chunk size from binaryFastModeBufWrChunk — minimises the per-call ArrayBufferWriter
|
||||
// allocation. Optimum for this scenario.
|
||||
MakeAcBinaryFreshBufferWriter(testData, binaryFastModeBufWrChunk, "FastMode (4KB)"),
|
||||
|
||||
// AcBinary chunked-streaming over an IN-MEMORY Pipe (no kernel transport). Side-by-side with the
|
||||
// Byte[] / IBufferWriter rows above this shows the chunked-streaming framework's pure CPU cost
|
||||
// (no NamedPipe loopback noise) vs the simpler in-process serialize-then-deserialize patterns.
|
||||
// The IO column shows "Pipe(in-mem)" — distinct from the NamedPipe AsyncPipe rows in [P] mode.
|
||||
MakeAcBinaryInMemoryPipe(testData, binaryFastModePipeChunkInMem, "FastMode (PipeChunk)"),
|
||||
|
||||
// Raw byte[] over IN-MEMORY direct cross-thread handoff (no transport, no kernel, no Pipe). Apples-to-
|
||||
// apples baseline for the in-memory chunked row above: same in-memory pattern, but raw byte[] vs
|
||||
// chunked-streaming wire format. The IO column shows "Bytes(in-mem)".
|
||||
MakeAcBinaryInMemoryRaw(testData, binaryFastModePipeChunkInMem, "FastMode (PipeRaw)"),
|
||||
|
||||
// AsyncPipe streaming over kernel NamedPipe (AcBinaryNamedPipeBenchmark) is intentionally OMITTED
|
||||
// here — run it via the dedicated AsyncPipe menu [P] / CLI mode for isolated kernel-transport
|
||||
// measurements.
|
||||
|
||||
// ============================================================
|
||||
// MemoryPack — three I/O modes for apples-to-apples comparison
|
||||
// ============================================================
|
||||
// MemPack uses _All_False (see FastestByte-mode comment above for rationale).
|
||||
new MemoryPackBenchmark<TestOrder_All_False>(orderFalse, Configuration.SelectedWireMode, "Default"),
|
||||
new MemoryPackBufferWriterBenchmark<TestOrder_All_False>(orderFalse, Configuration.SelectedWireMode, "Default"),
|
||||
new MemoryPackFreshBufferWriterBenchmark<TestOrder_All_False>(orderFalse, Configuration.SelectedWireMode, "Default"),
|
||||
|
||||
// ============================================================
|
||||
// MessagePack — for legacy comparison
|
||||
// ============================================================
|
||||
#if !AYCODE_NATIVEAOT
|
||||
// MessagePack v3's DynamicGenericResolver uses Activator.CreateInstance on trimmed
|
||||
// ListFormatter<T> et al. — fails under NativeAOT publish with "No parameterless constructor".
|
||||
// Excluded from the AOT build; available for regular JIT runs only.
|
||||
new MessagePackBenchmark<TestOrder_All_False>(orderFalse, "ContractBased"),
|
||||
#endif
|
||||
|
||||
// System.Text.Json (commented — JSON serializer for reference; not in active suite)
|
||||
//new SystemTextJsonBenchmark<TestOrder_All_False>(orderFalse, "Default")
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Forces a full GC cycle at a phase boundary in the benchmark loop. Two-pass collect with finalizer drain
|
||||
/// in between: the first pass moves managed garbage to the finalization queue, <c>WaitForPendingFinalizers</c>
|
||||
/// runs the finalizers, the second pass reclaims any objects the finalizers released. After this returns the
|
||||
/// heap is in a known-quiescent state — the next warmup/measurement phase starts on a clean slate, isolated
|
||||
/// from the previous phase's residual allocations (write-buffer pools, intern cache, write-plan arrays, etc.).
|
||||
/// Called between every Ser-phase / Des-phase boundary in <see cref="RunBenchmarksForTestData"/>.
|
||||
/// </summary>
|
||||
[MethodImpl(MethodImplOptions.NoInlining)]
|
||||
internal static void ForceGcCollect()
|
||||
{
|
||||
GC.Collect(2, GCCollectionMode.Forced, blocking: true);
|
||||
GC.WaitForPendingFinalizers();
|
||||
GC.Collect(2, GCCollectionMode.Forced, blocking: true);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Runs the action <paramref name="iterations"/> times for <see cref="Configuration.BenchmarkSamples"/> independent samples,
|
||||
/// returning the median, min, and max elapsed time. Multi-sample design reduces single-run variance
|
||||
/// from ~±15% to ~±5% by smoothing transient effects (background activity, thermal/turbo state).
|
||||
/// When <see cref="Configuration.BenchmarkSamples"/> <= 1, falls back to single-sample timing (Debug / quick mode).
|
||||
/// When <paramref name="progressLabel"/> is non-null, emits in-place <c>\r</c> progress updates so a
|
||||
/// stuck benchmark (e.g. deadlocked NamedPipe row) is visibly stuck at a specific %% rather than
|
||||
/// silently hanging.
|
||||
///
|
||||
/// Stabilization (added 2026-05-07):
|
||||
/// 1) Pilot sample is run BEFORE the recorded loop and discarded. The first measurement after
|
||||
/// warmup tends to absorb residual JIT bookkeeping and GC bookkeeping; dropping it tightens
|
||||
/// the min/max range without throwing away signal (the median is the SAME data as before).
|
||||
/// 2) GC.Collect / WaitForPendingFinalizers / GC.Collect runs BEFORE every recorded sample.
|
||||
/// Without this, GC pressure from sample N occasionally triggered a Gen-2 pause inside
|
||||
/// sample N+1, painting it as an outlier; collecting up-front gives every sample the
|
||||
/// same starting heap shape.
|
||||
/// 3) Returns (median, min, max) so the caller can surface the inter-sample range — visible
|
||||
/// noise floor for the row, replacing the previous "median only" view.
|
||||
/// </summary>
|
||||
internal static (double medianMs, double minMs, double maxMs, double stdDevMs) RunTimed(Action action, int iterations, string? progressLabel = null)
|
||||
{
|
||||
var samples = Configuration.BenchmarkSamples;
|
||||
if (samples <= 1)
|
||||
{
|
||||
// Single-sample fast path (Debug or trivial run) — no allocation, no sort, no stddev.
|
||||
var sw = Stopwatch.StartNew();
|
||||
RunWithProgress(action, iterations, progressLabel, samples: 1, sampleIndex: 0);
|
||||
sw.Stop();
|
||||
var ms = sw.Elapsed.TotalMilliseconds;
|
||||
EndProgress(progressLabel, ms);
|
||||
return (ms, ms, ms, 0);
|
||||
}
|
||||
|
||||
// Pilot sample (discarded). Counts as sample index 0 of (samples + 1) for progress display
|
||||
// so the user sees an extra "warmup-ish" tick before the recorded samples start.
|
||||
GC.Collect();
|
||||
GC.WaitForPendingFinalizers();
|
||||
GC.Collect();
|
||||
|
||||
var pilotSw = Stopwatch.StartNew();
|
||||
RunWithProgress(action, iterations, progressLabel, samples + 1, sampleIndex: 0);
|
||||
pilotSw.Stop();
|
||||
// intentionally not stored
|
||||
|
||||
var times = new double[samples];
|
||||
for (var s = 0; s < samples; s++)
|
||||
{
|
||||
// Per-sample GC settle. Forces every sample to start from the same heap state, so
|
||||
// a Gen-2 pause caused by the previous sample doesn't bleed into the next sample's
|
||||
// timing. Cost is paid OUTSIDE the Stopwatch window — no impact on the measurement.
|
||||
GC.Collect();
|
||||
GC.WaitForPendingFinalizers();
|
||||
GC.Collect();
|
||||
|
||||
// Inter-sample thermal-settle: CPU boost-clock can drop mid-batch under sustained load
|
||||
// (e.g. 10×250ms = 2.5 sec burst). InterSampleSettleMs lets the boost-clock state
|
||||
// settle so later samples don't read systematically slower than early ones. Skip before
|
||||
// the first sample (no prior heat to settle from). Set to 0 in Configuration to disable.
|
||||
if (s > 0 && Configuration.InterSampleSettleMs > 0)
|
||||
Thread.Sleep(Configuration.InterSampleSettleMs);
|
||||
|
||||
var sw = Stopwatch.StartNew();
|
||||
RunWithProgress(action, iterations, progressLabel, samples + 1, sampleIndex: s + 1);
|
||||
sw.Stop();
|
||||
times[s] = sw.Elapsed.TotalMilliseconds;
|
||||
}
|
||||
|
||||
// Capture min/max/sum/sumSq BEFORE sort to avoid order ambiguity (Array.Sort is in-place).
|
||||
var minMs = double.MaxValue;
|
||||
var maxMs = double.MinValue;
|
||||
var sum = 0.0;
|
||||
var sumSq = 0.0;
|
||||
|
||||
for (var i = 0; i < times.Length; i++)
|
||||
{
|
||||
var t = times[i];
|
||||
sum += t;
|
||||
sumSq += t * t;
|
||||
if (t < minMs) minMs = t;
|
||||
if (t > maxMs) maxMs = t;
|
||||
}
|
||||
// Population stddev (not sample-stddev — we treat the captured samples as the population for
|
||||
// CV computation). variance = E[X²] - E[X]² with Math.Max(0, ...) guard against tiny negative
|
||||
// values from FP rounding when samples are nearly identical.
|
||||
var mean = sum / times.Length;
|
||||
var variance = (sumSq / times.Length) - (mean * mean);
|
||||
var stdDevMs = Math.Sqrt(Math.Max(0.0, variance));
|
||||
|
||||
Array.Sort(times);
|
||||
|
||||
// Trimmed median: when samples >= 4, drop the single min and single max (sorted-array
|
||||
// first and last) and compute median on the remaining (samples - 2) entries. Removes the
|
||||
// worst per-sample contamination (a thermal spike, OS preempt, or a GC pause that escaped
|
||||
// the per-sample GC.Collect settle) without throwing away too much signal. The min/max /
|
||||
// stdDev outputs still reflect the FULL sample population — the trim affects only the
|
||||
// headline median figure, so the visible range still shows the actual measurement extremes.
|
||||
var trimStart = samples >= 4 ? 1 : 0;
|
||||
var trimCount = samples >= 4 ? samples - 2 : samples;
|
||||
var medianMs = trimCount % 2 == 1
|
||||
? times[trimStart + trimCount / 2]
|
||||
: (times[trimStart + trimCount / 2 - 1] + times[trimStart + trimCount / 2]) / 2.0;
|
||||
EndProgress(progressLabel, medianMs);
|
||||
|
||||
return (medianMs, minMs, maxMs, stdDevMs);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Per-cell adaptive iteration calibration. Runs a 100-iter measurement after warmup and computes
|
||||
/// how many iterations are needed to reach <see cref="Configuration.TargetSampleMs"/> wall-clock per sample.
|
||||
/// Returns iter rounded UP to the nearest 1000, floored at 1000 (the prior fixed minimum) and
|
||||
/// ceiling-capped at 200_000 (sanity bound for pathologically fast ops). In Debug single-sample mode
|
||||
/// (<c>Configuration.BenchmarkSamples <= 1</c>) returns the global <see cref="Configuration.TestIterations"/> unchanged —
|
||||
/// calibration overhead is unjustified there. Calibration runs OUTSIDE the timed sample loop and
|
||||
/// does NOT count toward warmup; its sole purpose is to measure per-op cost.
|
||||
/// </summary>
|
||||
internal static int CalibrateIterations(Action action, int targetMs)
|
||||
{
|
||||
if (Configuration.BenchmarkSamples <= 1) return Configuration.TestIterations; // Debug fast path
|
||||
|
||||
GC.Collect();
|
||||
GC.WaitForPendingFinalizers();
|
||||
GC.Collect();
|
||||
|
||||
const int calibIter = 100;
|
||||
var sw = Stopwatch.StartNew();
|
||||
for (var i = 0; i < calibIter; i++) action();
|
||||
sw.Stop();
|
||||
var ms = sw.Elapsed.TotalMilliseconds;
|
||||
|
||||
// Pathologically-fast op below Stopwatch resolution — cap at ceiling (further calibration won't help).
|
||||
if (ms <= 0.0001) return 200_000;
|
||||
|
||||
var iterPerMs = calibIter / ms;
|
||||
var raw = (int)Math.Ceiling(targetMs * iterPerMs);
|
||||
// Round UP to nearest 1000 — keeps numbers human-readable in the markdown output.
|
||||
var rounded = ((raw + 999) / 1000) * 1000;
|
||||
|
||||
return rounded switch
|
||||
{
|
||||
< 1000 => 1000,
|
||||
> 200_000 => 200_000,
|
||||
_ => rounded
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Measures per-call allocation in bytes after a clean GC. Single dedicated sample (no median) — keeps
|
||||
/// timing samples pure. When <paramref name="processWide"/> is <c>true</c>, uses
|
||||
/// <see cref="GC.GetTotalAllocatedBytes"/> instead of <see cref="GC.GetAllocatedBytesForCurrentThread"/>
|
||||
/// — needed for round-trip-only benchmarks (NamedPipe etc.) where the work happens across multiple
|
||||
/// threads (server-side <c>new byte[len]</c> buffers, drain-pump-thread allocations). Per-thread mode
|
||||
/// is slightly cleaner for in-memory benchmarks; process-wide mode is slightly noisier (background
|
||||
/// threads / GC bookkeeping leak in) but over 1000 iterations the signal dominates.
|
||||
/// </summary>
|
||||
internal static long MeasureAllocation(Action action, int iterations, string? progressLabel = null, bool processWide = false)
|
||||
{
|
||||
GC.Collect();
|
||||
GC.WaitForPendingFinalizers();
|
||||
GC.Collect();
|
||||
|
||||
var sw = Stopwatch.StartNew();
|
||||
var before = processWide ? GC.GetTotalAllocatedBytes(precise: true) : GC.GetAllocatedBytesForCurrentThread();
|
||||
RunWithProgress(action, iterations, progressLabel, samples: 1, sampleIndex: 0);
|
||||
var after = processWide ? GC.GetTotalAllocatedBytes(precise: true) : GC.GetAllocatedBytesForCurrentThread();
|
||||
sw.Stop();
|
||||
EndProgress(progressLabel, sw.Elapsed.TotalMilliseconds);
|
||||
return (after - before) / iterations;
|
||||
}
|
||||
|
||||
// ============================================================================================
|
||||
// Progress reporting — \r-driven in-place updates so a stuck benchmark surfaces the exact phase
|
||||
// and % where it stopped, instead of appearing as a silent hang. Used by RunTimed and the
|
||||
// MeasureAllocation* helpers when the caller passes a non-null progressLabel.
|
||||
// ============================================================================================
|
||||
|
||||
// Tracks the longest line written by the current progress session, so EndProgress can clear
|
||||
// any leftover characters from a prior longer line (avoids "ghost" trailing chars after \r).
|
||||
private static int _progressLastLineLen;
|
||||
|
||||
/// <summary>
|
||||
/// Runs <paramref name="action"/> <paramref name="iterations"/> times, emitting \r-overwriting
|
||||
/// progress every ~10% (approx. 10 progress prints per sample). When <paramref name="label"/>
|
||||
/// is null, runs without any progress output (zero overhead beyond a null check per iter).
|
||||
/// </summary>
|
||||
private static void RunWithProgress(Action action, int iterations, string? label, int samples, int sampleIndex)
|
||||
{
|
||||
if (label is null)
|
||||
{
|
||||
for (var i = 0; i < iterations; i++) action();
|
||||
return;
|
||||
}
|
||||
|
||||
// Batch-based progress emit — ~10 progress prints per sample. The inner loop is branchless
|
||||
// (no per-iter modulo / progress check), so the per-iter overhead is bare `action()` cost.
|
||||
// The outer loop drives the batches; progress emit happens once per batch on the boundary.
|
||||
// This keeps sub-µs ops cleanly measurable — the prior `if ((i + 1) % step == 0)` check
|
||||
// added a 1-2 cycle per-iter branch that distorted hot loops near the Stopwatch resolution.
|
||||
var step = Math.Max(1, iterations / 10);
|
||||
var done = 0;
|
||||
while (done < iterations)
|
||||
{
|
||||
var batch = Math.Min(step, iterations - done);
|
||||
|
||||
// Inner tight loop: no progress check, no modulo. Just the measured action() calls.
|
||||
for (var i = 0; i < batch; i++) action();
|
||||
done += batch;
|
||||
|
||||
var pct = (int)(done * 100L / iterations);
|
||||
var line = samples > 1
|
||||
? $" > {label} sample {sampleIndex + 1}/{samples} {pct,3}% ({done}/{iterations})"
|
||||
: $" > {label} {pct,3}% ({done}/{iterations})";
|
||||
|
||||
System.Console.Write('\r');
|
||||
System.Console.Write(line);
|
||||
|
||||
if (line.Length < _progressLastLineLen)
|
||||
System.Console.Write(new string(' ', _progressLastLineLen - line.Length));
|
||||
|
||||
_progressLastLineLen = line.Length;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Closes a progress line cleanly: clears any leftover chars and writes a final "done" line on
|
||||
/// the same row, terminated by \n so subsequent <c>WriteLine</c> calls render below.
|
||||
/// </summary>
|
||||
private static void EndProgress(string? label, double elapsedMs)
|
||||
{
|
||||
if (label is null) return;
|
||||
var done = $" > {label} done in {elapsedMs,7:F1} ms";
|
||||
|
||||
System.Console.Write('\r');
|
||||
System.Console.Write(done);
|
||||
|
||||
if (done.Length < _progressLastLineLen)
|
||||
System.Console.Write(new string(' ', _progressLastLineLen - done.Length));
|
||||
|
||||
System.Console.WriteLine();
|
||||
_progressLastLineLen = 0;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validates MemoryPack setup at startup. Aborts the benchmark if TestOrder_All_True is not [MemoryPackable].
|
||||
/// Without this attribute, MemoryPack falls back to runtime resolver (slower) — comparison would be INVALID.
|
||||
/// </summary>
|
||||
internal static void ValidateMemoryPackSetup()
|
||||
{
|
||||
var typesToCheck = new[] { typeof(TestOrder_All_True) };
|
||||
|
||||
foreach (var type in typesToCheck)
|
||||
{
|
||||
var hasAttr = type.GetCustomAttributes(typeof(MemoryPackableAttribute), inherit: true).Any();
|
||||
if (!hasAttr)
|
||||
{
|
||||
System.Console.Error.WriteLine($"❌ FATAL: {type.FullName} is not [MemoryPackable] — MemoryPack would fall back to runtime resolver, comparison is INVALID for SGen-vs-SGen claim.");
|
||||
System.Console.Error.WriteLine("Add [MemoryPackable] to the type and any nested types referenced from it.");
|
||||
|
||||
Environment.Exit(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Filters test data sets by layer keyword. Layered approach lets you run only what's needed for the iteration cadence.
|
||||
/// P1: only "Core" data exists (Small/Medium/Large/Repeated/Deep). Comprehensive and Edge layers will be expanded in P2.
|
||||
/// </summary>
|
||||
internal static List<TestDataSet> FilterByLayer(List<TestDataSet> all, BenchmarkLayer layer)
|
||||
{
|
||||
if (layer == BenchmarkLayer.All) return all.ToList();
|
||||
|
||||
var coreNames = new[] { "Small", "Medium", "Large", "Repeated", "Deep" };
|
||||
// P2 will add: "Flat", "Polymorphic", "Collection", "Numeric", "NonAscii", etc.
|
||||
var comprehensiveExtras = new string[] { /* P2 */ };
|
||||
// P3 will add: "ColdStart", "VeryLarge", "PathologicalString", etc.
|
||||
var edgeExtras = new string[] { /* P3 */ };
|
||||
|
||||
return layer switch
|
||||
{
|
||||
BenchmarkLayer.Core => all.Where(t => StartsWithAny(t.Name, coreNames)).ToList(),
|
||||
BenchmarkLayer.Comprehensive => all.Where(t => StartsWithAny(t.Name, coreNames) || StartsWithAny(t.Name, comprehensiveExtras)).ToList(),
|
||||
BenchmarkLayer.Edge => all.Where(t => StartsWithAny(t.Name, coreNames) || StartsWithAny(t.Name, comprehensiveExtras) || StartsWithAny(t.Name, edgeExtras)).ToList(),
|
||||
// Single-cell A/B mini-suite filters — match by case-insensitive prefix on Name.
|
||||
// Use case: tight optimization-iteration loop on one specific cell (e.g. `dotnet run -- repeated`
|
||||
// or interactive menu shortcut), avoiding the full ~110 sec suite when only one cell is in scope.
|
||||
BenchmarkLayer.Small => all.Where(t => t.Name.StartsWith("Small", StringComparison.OrdinalIgnoreCase)).ToList(),
|
||||
BenchmarkLayer.Medium => all.Where(t => t.Name.StartsWith("Medium", StringComparison.OrdinalIgnoreCase)).ToList(),
|
||||
BenchmarkLayer.Large => all.Where(t => t.Name.StartsWith("Large", StringComparison.OrdinalIgnoreCase)).ToList(),
|
||||
BenchmarkLayer.Repeated => all.Where(t => t.Name.StartsWith("Repeated", StringComparison.OrdinalIgnoreCase)).ToList(),
|
||||
BenchmarkLayer.Deep => all.Where(t => t.Name.StartsWith("Deep", StringComparison.OrdinalIgnoreCase)).ToList(),
|
||||
_ => all.ToList()
|
||||
};
|
||||
|
||||
static bool StartsWithAny(string name, string[] prefixes) => prefixes.Any(name.StartsWith);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,120 @@
|
|||
using AyCode.Core.Serializers.Binaries;
|
||||
using AyCode.Core.Tests.TestModels;
|
||||
using System.Runtime.CompilerServices;
|
||||
using System.Text;
|
||||
|
||||
namespace AyCode.Core.Serializers.Console;
|
||||
|
||||
/// <summary>
|
||||
/// Configuration state for the benchmark application. Holds compile-time and runtime constants,
|
||||
/// per-run mutable settings (WireMode, charset, iteration counts), and the attribute-flag
|
||||
/// aggregation that drives the per-row Options column. Split out from <c>Program.cs</c> so the
|
||||
/// entry-point file can focus on UX-flow and benchmark orchestration; everything in this class
|
||||
/// is config / state — no benchmark logic. Single instance (static class) — the application is
|
||||
/// a one-shot process, no multi-tenant state isolation needed.
|
||||
/// </summary>
|
||||
internal static class Configuration
|
||||
{
|
||||
#if AYCODE_NATIVEAOT
|
||||
internal const string BuildConfiguration = "NativeAOT";
|
||||
#elif DEBUG
|
||||
internal const string BuildConfiguration = "Debug";
|
||||
#elif SGEN_ONLY
|
||||
internal const string BuildConfiguration = "SGenOnly";
|
||||
#else
|
||||
internal const string BuildConfiguration = "Release";
|
||||
#endif
|
||||
|
||||
#if DEBUG
|
||||
internal static int WarmupIterations = 0;
|
||||
internal static int TestIterations = 1;
|
||||
internal static int BenchmarkSamples = 1; // Debug: single sample, fast iteration
|
||||
#else
|
||||
internal static int WarmupIterations = 5000; //10000 — per-phase (Ser AND Des get their own warmup separately)
|
||||
internal static int TestIterations = 1000; //1000
|
||||
internal static int BenchmarkSamples = 10;
|
||||
#endif
|
||||
|
||||
// Interactive settings: selected AcBinary wire mode for benchmark runs.
|
||||
// 1 = Compact, 2 = Fast
|
||||
internal static WireMode SelectedWireMode = WireMode.Compact;
|
||||
|
||||
// Engine / IO mode / Dispatch mode identifiers → Benchmarks/BenchmarkEnums.cs (typed enums with ToDisplay)
|
||||
|
||||
// Single source of truth for the chunk size used by ALL pipe-related benchmarks (NamedPipe PipeChunk,
|
||||
// NamedPipe PipeRaw, in-memory Pipe, in-memory RawMem) AND the NamedPipe server's inBufferSize/outBufferSize.
|
||||
// Same value across both layers ensures apples-to-apples comparison: chunked-streaming chunk-on-wire size
|
||||
// matches the kernel pipe-buffer slot exactly. Tweak HERE when experimenting; do NOT scatter chunkSize
|
||||
// overrides across individual benchmark rows.
|
||||
internal const int PipeChunkSize = 4096;
|
||||
|
||||
// Per-cell adaptive iteration target wall-clock duration. Each Ser/Des function calibrates its
|
||||
// own iteration count post-warmup so the sample batch lands in this range — equalizes the
|
||||
// per-sample window across cells of vastly different per-op cost (Small ~6 ns/op vs Large
|
||||
// ~140 µs/op). Below ~100 ms Stopwatch precision and OS preempt spikes start to dominate.
|
||||
internal const int TargetSampleMs = 250;
|
||||
|
||||
// CV (coefficient of variation = stddev / mean) threshold above which a row's range is flagged
|
||||
// as "unstable" in the markdown output (⚠️ marker). 3% is a reasonable noise-floor expectation
|
||||
// for stabilized in-memory benchmarks; rows above it should be discounted when reading
|
||||
// sub-3% inter-engine deltas.
|
||||
internal const double UnstableCVThreshold = 0.03;
|
||||
|
||||
// Lower-bound CV threshold for micro-optimization measurement reliability. Rows with CV in
|
||||
// the (MicroOptCVThreshold, UnstableCVThreshold] range get a softer "⚠️micro" flag — they are
|
||||
// not unstable enough to be entirely dismissable, but sub-2% inter-engine deltas observed on
|
||||
// such a row are at the edge of the noise floor and should be cross-checked (re-run, BDN).
|
||||
// Use case: micro-opt sprints where a ~1-2% signal lives below the unstable threshold but the
|
||||
// row's own CV is still high enough to make that signal suspect.
|
||||
internal const double MicroOptCVThreshold = 0.015;
|
||||
|
||||
// Inter-sample cool-down delay (ms) inserted between recorded samples in the timed loop.
|
||||
// Mitigates CPU thermal-throttling drift across a sustained burst (e.g. 10×250ms = 2.5 sec):
|
||||
// without it, boost-clock can drop mid-batch on thermally-constrained hosts (laptops esp.),
|
||||
// and the later samples in the batch read systematically slower than the early ones. 50ms is
|
||||
// enough for boost-clock state to settle but cheap in total (~500ms / cell) — quick-bench
|
||||
// workflow is not meaningfully slower.
|
||||
internal const int InterSampleSettleMs = 50;
|
||||
|
||||
// JIT-tier-promotion drain delay between warmup and measurement.
|
||||
// - JIT mode (RuntimeFeature.IsDynamicCodeCompiled == true): tiered JIT promotes hot methods
|
||||
// in a background thread; we wait briefly for the queue to drain so the first measurement
|
||||
// sample doesn't catch a Tier-0 → Tier-1 transition mid-flight.
|
||||
// - AOT mode (NativeAOT publish): no dynamic compilation happens; the sleep is pure noise.
|
||||
// 250ms (vs the historical 3000ms) is sufficient for a few-method working set under .NET 9's
|
||||
// tiered JIT — empirically the queue drains in <100ms for the bench's hot path.
|
||||
internal static int JitSleep => RuntimeFeature.IsDynamicCodeCompiled ? 250 : 0;
|
||||
|
||||
// OptionsPreset values are passed per-instance (constructor argument), not constants —
|
||||
// each CreateSerializers call line specifies its own preset name (e.g. "FastMode", "NoIntern").
|
||||
|
||||
internal static readonly UTF8Encoding Utf8NoBom = new(encoderShouldEmitUTF8Identifier: false);
|
||||
|
||||
/// <summary>
|
||||
/// Returns a human-readable name for the currently-active <c>BenchmarkTestDataProvider.LongStringSuffix</c>
|
||||
/// charset. Returns "Custom" when the suffix doesn't match any of the predefined
|
||||
/// <see cref="CharsetSuffixes"/> constants. Used in menu state display, console run header, and
|
||||
/// the .LLM / .log output headers so per-charset bench files are self-documenting.
|
||||
/// </summary>
|
||||
internal static string GetCurrentCharsetName()
|
||||
{
|
||||
var s = BenchmarkTestDataProvider.LongStringSuffix;
|
||||
|
||||
return s switch
|
||||
{
|
||||
CharsetSuffixes.AsciiFix => nameof(CharsetSuffixes.AsciiFix),
|
||||
CharsetSuffixes.AsciiShort => nameof(CharsetSuffixes.AsciiShort),
|
||||
CharsetSuffixes.AsciiLong => nameof(CharsetSuffixes.AsciiLong),
|
||||
CharsetSuffixes.Latin1Fix => nameof(CharsetSuffixes.Latin1Fix),
|
||||
CharsetSuffixes.Latin1Short => nameof(CharsetSuffixes.Latin1Short),
|
||||
CharsetSuffixes.Latin1Long => nameof(CharsetSuffixes.Latin1Long),
|
||||
CharsetSuffixes.CjkBmpShort => nameof(CharsetSuffixes.CjkBmpShort),
|
||||
CharsetSuffixes.CjkBmpLong => nameof(CharsetSuffixes.CjkBmpLong),
|
||||
CharsetSuffixes.CyrillicShort => nameof(CharsetSuffixes.CyrillicShort),
|
||||
CharsetSuffixes.CyrillicLong => nameof(CharsetSuffixes.CyrillicLong),
|
||||
CharsetSuffixes.MixedShort => nameof(CharsetSuffixes.MixedShort),
|
||||
CharsetSuffixes.MixedLong => nameof(CharsetSuffixes.MixedLong),
|
||||
_ => "Custom"
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,255 @@
|
|||
using AyCode.Core.Benchmarks.Workloads.Scenarios;
|
||||
using AyCode.Core.Serializers.Binaries;
|
||||
using AyCode.Core.Tests.TestModels;
|
||||
|
||||
namespace AyCode.Core.Serializers.Console;
|
||||
|
||||
/// <summary>
|
||||
/// Interactive console menu for the benchmark application. Shown when the user runs without CLI args:
|
||||
/// top-level layer/mode selection + nested Settings sub-menus (iteration counts, wire mode, charset).
|
||||
/// All settings mutate <see cref="Configuration"/> in place; the menu loop returns control to the
|
||||
/// caller (<c>Program.Main</c>) once the user picks a benchmark layer or quits.
|
||||
/// </summary>
|
||||
internal static class Menu
|
||||
{
|
||||
/// <summary>
|
||||
/// Interactive menu shown when no CLI args. Returns the (layer, serializerMode) tuple, or null on Quit.
|
||||
/// Loops on settings-changes ([S]) — user is returned to this menu after modifying iteration counts.
|
||||
/// </summary>
|
||||
internal static (BenchmarkLayer layer, SerializerSelectionMode serializerMode)? ShowInteractiveMenu()
|
||||
{
|
||||
while (true)
|
||||
{
|
||||
System.Console.WriteLine();
|
||||
System.Console.WriteLine("╔══════════════════════════════════════════════════════════╗");
|
||||
System.Console.WriteLine("║ AcBinary Benchmark Suite ║");
|
||||
System.Console.WriteLine("╚══════════════════════════════════════════════════════════╝");
|
||||
System.Console.WriteLine();
|
||||
System.Console.WriteLine("Select benchmark layer:");
|
||||
System.Console.WriteLine();
|
||||
System.Console.WriteLine(" [1] Core — daily iteration");
|
||||
System.Console.WriteLine(" [2] Comprehensive — release validation");
|
||||
System.Console.WriteLine(" [3] Edge cases — refactor verification");
|
||||
System.Console.WriteLine(" [A] All layers");
|
||||
System.Console.WriteLine(" [F] FastestByte — AcBinary FastMode Byte[] vs MemoryPack Byte[] only (tight optimization loop)");
|
||||
System.Console.WriteLine(" [P] AsyncPipe — streaming I/O isolation (only AsyncPipe, all test data)");
|
||||
System.Console.WriteLine($" [S] Settings — Iteration / WireMode (current: {Configuration.SelectedWireMode})");
|
||||
System.Console.WriteLine(" [Q] Quit");
|
||||
System.Console.Write("\nSelection: ");
|
||||
|
||||
var key = System.Console.ReadKey(intercept: false).KeyChar;
|
||||
System.Console.WriteLine();
|
||||
|
||||
switch (char.ToLower(key))
|
||||
{
|
||||
case '1': return (BenchmarkLayer.Core, SerializerSelectionMode.Standard);
|
||||
case '2': return (BenchmarkLayer.Comprehensive, SerializerSelectionMode.Standard);
|
||||
case '3': return (BenchmarkLayer.Edge, SerializerSelectionMode.Standard);
|
||||
case 'a': return (BenchmarkLayer.All, SerializerSelectionMode.Standard);
|
||||
case 'f': return (BenchmarkLayer.All, SerializerSelectionMode.FastestByte);
|
||||
case 'p': return (BenchmarkLayer.All, SerializerSelectionMode.AsyncPipe);
|
||||
case 's':
|
||||
ShowSettingsMenu();
|
||||
continue; // re-display the main menu after settings update
|
||||
case 'q': return null;
|
||||
default: return (BenchmarkLayer.All, SerializerSelectionMode.Standard);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Settings sub-menu — dispatches to per-area sub-menus (iteration counts, wire mode, charset).
|
||||
/// Returns to the caller (which re-displays the main menu) when [B]ack is pressed.
|
||||
/// </summary>
|
||||
private static void ShowSettingsMenu()
|
||||
{
|
||||
while (true)
|
||||
{
|
||||
System.Console.WriteLine();
|
||||
System.Console.WriteLine("─────────────────────────────────────────────");
|
||||
System.Console.WriteLine("Settings");
|
||||
System.Console.WriteLine("─────────────────────────────────────────────");
|
||||
System.Console.WriteLine(" [1] Iteration — Warmup / Iterations / Samples");
|
||||
System.Console.WriteLine($" [2] WireMode — current: {Configuration.SelectedWireMode}");
|
||||
System.Console.WriteLine($" [3] Charset — current: {Configuration.GetCurrentCharsetName()}");
|
||||
System.Console.WriteLine(" [B] Back");
|
||||
System.Console.Write("\nSelection: ");
|
||||
|
||||
var key = System.Console.ReadKey(intercept: false).KeyChar;
|
||||
System.Console.WriteLine();
|
||||
|
||||
switch (char.ToLower(key))
|
||||
{
|
||||
case '1':
|
||||
ShowIterationSettingsMenu();
|
||||
break;
|
||||
case '2':
|
||||
ShowWireModeSettingsMenu();
|
||||
break;
|
||||
case '3':
|
||||
ShowCharsetSettingsMenu();
|
||||
break;
|
||||
case 'b':
|
||||
return;
|
||||
default:
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void ShowCharsetSettingsMenu()
|
||||
{
|
||||
while (true)
|
||||
{
|
||||
System.Console.WriteLine();
|
||||
System.Console.WriteLine("─────────────────────────────────────────────");
|
||||
System.Console.WriteLine("Charset settings — long-string suffix profile");
|
||||
System.Console.WriteLine("─────────────────────────────────────────────");
|
||||
System.Console.WriteLine($"Current: {Configuration.GetCurrentCharsetName()}");
|
||||
System.Console.WriteLine();
|
||||
System.Console.WriteLine(" All *Short = 40 char, all *Long = 280 char (= Short × 7) — length-consistent across charsets.");
|
||||
System.Console.WriteLine();
|
||||
System.Console.WriteLine(" [1] AsciiFix — empty suffix; baseline-only short values → FixStrAscii tier");
|
||||
System.Console.WriteLine(" [2] AsciiShort — 40 char pure ASCII (quic × 8) → StringAscii tier");
|
||||
System.Console.WriteLine(" [3] AsciiLong — 280 char pure ASCII → StringAscii tier");
|
||||
System.Console.WriteLine(" [4] Latin1Fix — 5 char Hungarian (árví) → FixStr-lean tier");
|
||||
System.Console.WriteLine(" [5] Latin1Short — 40 char Hungarian (árví × 8) → StringSmall tier");
|
||||
System.Console.WriteLine(" [6] Latin1Long — 280 char Hungarian (default) → StringMedium tier");
|
||||
System.Console.WriteLine(" [7] CjkBmpShort — 40 char CJK BMP (3-byte runs) → StringSmall tier");
|
||||
System.Console.WriteLine(" [8] CjkBmpLong — 280 char CJK BMP → StringMedium tier");
|
||||
System.Console.WriteLine(" [9] CyrillicShort — 40 char Cyrillic (2-byte runs) → StringSmall tier");
|
||||
System.Console.WriteLine(" [0] CyrillicLong — 280 char Cyrillic → StringMedium tier");
|
||||
System.Console.WriteLine(" [A] MixedShort — 40 char multi-codepage → StringSmall tier");
|
||||
System.Console.WriteLine(" [C] MixedLong — 280 char multi-codepage → StringMedium tier");
|
||||
System.Console.WriteLine(" [B] Back");
|
||||
System.Console.Write("\nSelection: ");
|
||||
|
||||
var key = System.Console.ReadKey(intercept: false).KeyChar;
|
||||
System.Console.WriteLine();
|
||||
|
||||
switch (char.ToLower(key))
|
||||
{
|
||||
case '1':
|
||||
BenchmarkTestDataProvider.LongStringSuffix = CharsetSuffixes.AsciiFix;
|
||||
System.Console.WriteLine("✓ Charset set to AsciiFix");
|
||||
return;
|
||||
case '2':
|
||||
BenchmarkTestDataProvider.LongStringSuffix = CharsetSuffixes.AsciiShort;
|
||||
System.Console.WriteLine("✓ Charset set to AsciiShort");
|
||||
return;
|
||||
case '3':
|
||||
BenchmarkTestDataProvider.LongStringSuffix = CharsetSuffixes.AsciiLong;
|
||||
System.Console.WriteLine("✓ Charset set to AsciiLong");
|
||||
return;
|
||||
case '4':
|
||||
BenchmarkTestDataProvider.LongStringSuffix = CharsetSuffixes.Latin1Fix;
|
||||
System.Console.WriteLine("✓ Charset set to Latin1Fix");
|
||||
return;
|
||||
case '5':
|
||||
BenchmarkTestDataProvider.LongStringSuffix = CharsetSuffixes.Latin1Short;
|
||||
System.Console.WriteLine("✓ Charset set to Latin1Short");
|
||||
return;
|
||||
case '6':
|
||||
BenchmarkTestDataProvider.LongStringSuffix = CharsetSuffixes.Latin1Long;
|
||||
System.Console.WriteLine("✓ Charset set to Latin1Long");
|
||||
return;
|
||||
case '7':
|
||||
BenchmarkTestDataProvider.LongStringSuffix = CharsetSuffixes.CjkBmpShort;
|
||||
System.Console.WriteLine("✓ Charset set to CjkBmpShort");
|
||||
return;
|
||||
case '8':
|
||||
BenchmarkTestDataProvider.LongStringSuffix = CharsetSuffixes.CjkBmpLong;
|
||||
System.Console.WriteLine("✓ Charset set to CjkBmpLong");
|
||||
return;
|
||||
case '9':
|
||||
BenchmarkTestDataProvider.LongStringSuffix = CharsetSuffixes.CyrillicShort;
|
||||
System.Console.WriteLine("✓ Charset set to CyrillicShort");
|
||||
return;
|
||||
case '0':
|
||||
BenchmarkTestDataProvider.LongStringSuffix = CharsetSuffixes.CyrillicLong;
|
||||
System.Console.WriteLine("✓ Charset set to CyrillicLong");
|
||||
return;
|
||||
case 'a':
|
||||
BenchmarkTestDataProvider.LongStringSuffix = CharsetSuffixes.MixedShort;
|
||||
System.Console.WriteLine("✓ Charset set to MixedShort");
|
||||
return;
|
||||
case 'c':
|
||||
BenchmarkTestDataProvider.LongStringSuffix = CharsetSuffixes.MixedLong;
|
||||
System.Console.WriteLine("✓ Charset set to MixedLong");
|
||||
return;
|
||||
case 'b':
|
||||
return;
|
||||
default:
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void ShowIterationSettingsMenu()
|
||||
{
|
||||
System.Console.WriteLine();
|
||||
System.Console.WriteLine("─────────────────────────────────────────────");
|
||||
System.Console.WriteLine("Iteration settings — press Enter to keep current value");
|
||||
System.Console.WriteLine("─────────────────────────────────────────────");
|
||||
System.Console.WriteLine();
|
||||
|
||||
Configuration.WarmupIterations = PromptInt("WarmupIterations", Configuration.WarmupIterations, min: 0);
|
||||
Configuration.TestIterations = PromptInt("TestIterations ", Configuration.TestIterations, min: 1);
|
||||
Configuration.BenchmarkSamples = PromptInt("BenchmarkSamples", Configuration.BenchmarkSamples, min: 1);
|
||||
|
||||
System.Console.WriteLine();
|
||||
System.Console.WriteLine($"✓ Iteration settings updated: Warmup={Configuration.WarmupIterations} | Iterations={Configuration.TestIterations} | Samples={Configuration.BenchmarkSamples}");
|
||||
}
|
||||
|
||||
private static void ShowWireModeSettingsMenu()
|
||||
{
|
||||
while (true)
|
||||
{
|
||||
System.Console.WriteLine();
|
||||
System.Console.WriteLine("─────────────────────────────────────────────");
|
||||
System.Console.WriteLine("WireMode settings");
|
||||
System.Console.WriteLine("─────────────────────────────────────────────");
|
||||
System.Console.WriteLine($"Current: {Configuration.SelectedWireMode}");
|
||||
System.Console.WriteLine(" [1] Compact");
|
||||
System.Console.WriteLine(" [2] Fast");
|
||||
System.Console.WriteLine(" [B] Back");
|
||||
System.Console.Write("\nSelection: ");
|
||||
|
||||
var key = System.Console.ReadKey(intercept: false).KeyChar;
|
||||
System.Console.WriteLine();
|
||||
|
||||
switch (char.ToLower(key))
|
||||
{
|
||||
case '1':
|
||||
Configuration.SelectedWireMode = WireMode.Compact;
|
||||
System.Console.WriteLine("✓ WireMode set to Compact");
|
||||
return;
|
||||
case '2':
|
||||
Configuration.SelectedWireMode = WireMode.Fast;
|
||||
System.Console.WriteLine("✓ WireMode set to Fast");
|
||||
return;
|
||||
case 'b':
|
||||
return;
|
||||
default:
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Prompts the user for an integer with a default (current value). Returns the current value if
|
||||
/// the user presses Enter on empty input or if parsing fails / value is below the minimum.
|
||||
/// </summary>
|
||||
private static int PromptInt(string name, int currentValue, int min)
|
||||
{
|
||||
System.Console.Write($" {name} [{currentValue}]: ");
|
||||
|
||||
var input = System.Console.ReadLine()?.Trim() ?? "";
|
||||
if (input.Length == 0) return currentValue;
|
||||
|
||||
if (int.TryParse(input, out var newValue) && newValue >= min) return newValue;
|
||||
|
||||
System.Console.WriteLine($" ! Invalid value (need int ≥ {min}) — keeping {currentValue}");
|
||||
return currentValue;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,179 @@
|
|||
using AyCode.Core.Compression;
|
||||
using AyCode.Core.Serializers.Attributes;
|
||||
using AyCode.Core.Serializers.Binaries;
|
||||
using AyCode.Core.Tests.Serialization; // DrainFromAsync extension (test-only, used by benchmark)
|
||||
using AyCode.Core.Tests.TestModels;
|
||||
using MemoryPack;
|
||||
#if !AYCODE_NATIVEAOT
|
||||
using MessagePack;
|
||||
using MessagePack.Resolvers;
|
||||
#endif
|
||||
using Microsoft.Extensions.Options;
|
||||
using System.Buffers;
|
||||
using System.Diagnostics;
|
||||
using System.IO.Pipelines;
|
||||
using System.IO.Pipes;
|
||||
using System.Reflection;
|
||||
using System.Runtime.CompilerServices;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
|
||||
using AyCode.Core.Benchmarks.Workloads.Scenarios;
|
||||
|
||||
namespace AyCode.Core.Serializers.Console;
|
||||
|
||||
/// <summary>
|
||||
/// Comprehensive benchmark application for all serializers.
|
||||
/// Compares: AcBinary (all options), MemoryPack, MessagePack, Newtonsoft.Json, System.Text.Json
|
||||
///
|
||||
/// Usage:
|
||||
/// dotnet run # Run all benchmarks
|
||||
/// dotnet run -- quick # Quick mode (fewer iterations)
|
||||
/// dotnet run -- serialize # Serialize only
|
||||
/// dotnet run -- deserialize # Deserialize only
|
||||
/// </summary>
|
||||
public static class Program
|
||||
{
|
||||
// Configuration (constants, mutable state, attribute-flag aggregation) → Configuration.cs
|
||||
// BuildAcBinary + GetMemPack helpers → Benchmarks/BenchmarkOptions.cs
|
||||
|
||||
public static void Main(string[] args)
|
||||
{
|
||||
// Set console encoding to UTF-8 for proper Unicode character display
|
||||
System.Console.OutputEncoding = Encoding.UTF8;
|
||||
|
||||
// Setup validation — abort BEFORE any benchmark logic if MemoryPack baseline is invalid.
|
||||
// Done early so user is told immediately, not after warmup.
|
||||
BenchmarkLoop.ValidateMemoryPackSetup();
|
||||
|
||||
// CLI mode (args provided): run once, parse args, exit. Backward-compatible behaviour.
|
||||
if (args.Length > 0)
|
||||
{
|
||||
if (!TryParseCliArgs(args, out var layer, out var opMode, out var serializerMode))
|
||||
return; // invalid args
|
||||
|
||||
BenchmarkLoop.RunBenchmark(layer, opMode, serializerMode);
|
||||
return;
|
||||
}
|
||||
|
||||
// Interactive mode (no args): loop the menu so the user doesn't have to restart between runs.
|
||||
// Q exits the menu (and the application).
|
||||
while (true)
|
||||
{
|
||||
var selection = Menu.ShowInteractiveMenu();
|
||||
if (selection == null) return; // user pressed Q
|
||||
|
||||
BenchmarkLoop.RunBenchmark(selection.Value.layer, BenchmarkOpMode.All, selection.Value.serializerMode);
|
||||
|
||||
System.Console.WriteLine();
|
||||
System.Console.WriteLine("─────────────────────────────────────────────────────────────────────");
|
||||
System.Console.WriteLine("Returning to menu — press any key to continue, or Q to quit...");
|
||||
var key = System.Console.ReadKey(intercept: true);
|
||||
if (key.Key == ConsoleKey.Q) return;
|
||||
System.Console.WriteLine();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parses CLI arguments into (layer, opMode, serializerMode) and, as a side effect, the active
|
||||
/// charset (<see cref="BenchmarkTestDataProvider.LongStringSuffix"/>). Each arg is classified
|
||||
/// independently and case-insensitively, so multiple args combine in any order — e.g.
|
||||
/// <c>FastestByte AsciiShort</c> or <c>Serialize Large Latin1Short</c>. Per arg, in order:
|
||||
/// <c>"quick"</c> (mutates <see cref="Configuration"/> warmup/iter counts), <see cref="SerializerSelectionMode"/>,
|
||||
/// <see cref="BenchmarkOpMode"/>, <see cref="BenchmarkLayer"/>, then a charset name
|
||||
/// (see <see cref="TryApplyCharsetArg"/>). Unrecognized args are warned and ignored; dimensions left
|
||||
/// unset keep their defaults (All, All, Standard, and the <see cref="BenchmarkTestDataProvider.LongStringSuffix"/>
|
||||
/// field default for charset). Always returns <c>true</c> (kept for caller-side abort symmetry).
|
||||
/// </summary>
|
||||
private static bool TryParseCliArgs(string[] args, out BenchmarkLayer layer, out BenchmarkOpMode opMode, out SerializerSelectionMode serializerMode)
|
||||
{
|
||||
layer = BenchmarkLayer.All;
|
||||
opMode = BenchmarkOpMode.All;
|
||||
serializerMode = SerializerSelectionMode.Standard;
|
||||
|
||||
// Each arg is classified independently → multiple args combine in any order. Without the
|
||||
// charset branch the CLI path never sets the charset, so it silently used the Latin1Long
|
||||
// field default — diverging from interactive runs (where the menu pins it).
|
||||
foreach (var arg in args)
|
||||
{
|
||||
// Quick mode: short warmup, few iterations, small sample count. Not an enum value — it's a
|
||||
// Configuration meta-flag, so handle it before the enum-parse cascade.
|
||||
if (string.Equals(arg, "quick", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
Configuration.WarmupIterations = 5;
|
||||
Configuration.TestIterations = 100;
|
||||
Configuration.BenchmarkSamples = 3;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Serializer-selection (AsyncPipe/FastestByte/Standard).
|
||||
if (Enum.TryParse<SerializerSelectionMode>(arg, ignoreCase: true, out var sm))
|
||||
{
|
||||
serializerMode = sm;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Op-mode (Serialize/Deserialize/All).
|
||||
if (Enum.TryParse<BenchmarkOpMode>(arg, ignoreCase: true, out var om))
|
||||
{
|
||||
opMode = om;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Layer (Core/Comprehensive/Edge/Small/Medium/Large/Repeated/Deep/All).
|
||||
if (Enum.TryParse<BenchmarkLayer>(arg, ignoreCase: true, out var ly))
|
||||
{
|
||||
layer = ly;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Charset (long-string suffix profile) — mirrors the interactive ShowCharsetSettingsMenu.
|
||||
if (TryApplyCharsetArg(arg))
|
||||
continue;
|
||||
|
||||
// Unknown arg — ignored, defaults stand. Matches prior unrecognized-arg leniency.
|
||||
System.Console.Error.WriteLine($"Warning: unrecognized argument '{arg}'. Ignored (defaults: Layer=All, OpMode=All, SerializerMode=Standard, charset unchanged).");
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Maps a case-insensitive charset name to its <see cref="CharsetSuffixes"/> value and assigns
|
||||
/// <see cref="BenchmarkTestDataProvider.LongStringSuffix"/>. Names mirror the interactive
|
||||
/// <c>ShowCharsetSettingsMenu</c> options. <see cref="CharsetSuffixes"/> members are <c>const string</c>,
|
||||
/// so this is a name→value match rather than an <see cref="Enum.TryParse{T}(string, bool, out T)"/>.
|
||||
/// Returns <c>false</c> when the name is not a known charset (the caller then treats the arg as unknown).
|
||||
/// </summary>
|
||||
private static bool TryApplyCharsetArg(string arg)
|
||||
{
|
||||
string? suffix = arg.ToLowerInvariant() switch
|
||||
{
|
||||
"asciifix" => CharsetSuffixes.AsciiFix,
|
||||
"asciishort" => CharsetSuffixes.AsciiShort,
|
||||
"asciilong" => CharsetSuffixes.AsciiLong,
|
||||
"latin1fix" => CharsetSuffixes.Latin1Fix,
|
||||
"latin1short" => CharsetSuffixes.Latin1Short,
|
||||
"latin1long" => CharsetSuffixes.Latin1Long,
|
||||
"cjkbmpshort" => CharsetSuffixes.CjkBmpShort,
|
||||
"cjkbmplong" => CharsetSuffixes.CjkBmpLong,
|
||||
"cyrillicshort" => CharsetSuffixes.CyrillicShort,
|
||||
"cyrilliclong" => CharsetSuffixes.CyrillicLong,
|
||||
"mixedshort" => CharsetSuffixes.MixedShort,
|
||||
"mixedlong" => CharsetSuffixes.MixedLong,
|
||||
_ => null
|
||||
};
|
||||
if (suffix is null)
|
||||
return false;
|
||||
BenchmarkTestDataProvider.LongStringSuffix = suffix;
|
||||
return true;
|
||||
}
|
||||
|
||||
// RunBenchmark + RunBenchmarksForTestData + CreateSerializers → BenchmarkLoop.cs
|
||||
|
||||
// Serializer implementations (ISerializerBenchmark + 12 concrete benchmark classes) → Benchmarks/
|
||||
|
||||
// Results / output formatters → Output.cs
|
||||
// BenchmarkResult DTO → BenchmarkResult.cs
|
||||
|
||||
}
|
||||
|
|
@ -0,0 +1,89 @@
|
|||
# AyCode.Core.Serializers.Console
|
||||
|
||||
Interactive console runner for the serializer benchmark suite. Targets .NET 9.
|
||||
|
||||
> **Companion**: shares its workload + reporting infrastructure with the BDN runner in [`AyCode.Benchmark/`](../AyCode.Benchmark/README.md) via `<ProjectReference>`. See that project's README for the full dual-runner architecture.
|
||||
|
||||
## Role
|
||||
|
||||
This is the **fast-iteration** half of the benchmark stack — a custom adaptive measure engine optimized for short turnaround (~1-3 min full run) during micro-optimization loops. The BDN half lives in `AyCode.Benchmark` and produces statistically tighter numbers (~5-15 min full run) for before-commit validation. Both runners emit the **same** `.log` / `.LLM` / `.output` triplet to `Test_Benchmark_Results/Benchmark/` — Console prefixes with `Console.`, BDN with `Bdn.`.
|
||||
|
||||
## Compared serializers
|
||||
|
||||
- **AcBinary** — multiple options presets: `FastMode` (Compact wire, no ref handling, no interning), `Default` (with ref handling + interning), plus SGen / Runtime dispatch variants and Compact / Fast wire modes.
|
||||
- **MemoryPack** — SOTA baseline, wire-mode-aligned with AcBinary for apples-to-apples encoding comparison (UTF-8 ↔ Compact, UTF-16 ↔ Fast).
|
||||
- **MessagePack** — JIT-only (AOT incompatible due to dynamic resolver).
|
||||
- **System.Text.Json** — reference comparison (commented out in `CreateSerializers` by default).
|
||||
|
||||
## Key files
|
||||
|
||||
- [`Program.cs`](Program.cs) — entry point. Parses CLI args (`Core` / `Comprehensive` / `Edge` / per-cell / op-mode / serializer-set) or falls into interactive `Menu`.
|
||||
- [`Menu.cs`](Menu.cs) — interactive layer/serializer-set selection + nested settings (iteration counts, wire mode, charset).
|
||||
- [`BenchmarkLoop.cs`](BenchmarkLoop.cs) — custom adaptive measure engine. CPU 0 affinity pin + High priority for stabilization, JIT pre-warmup, phase-isolated Ser/Des warmup→measure with `GC.Collect` at every boundary, 10-sample median + pilot discard, adaptive iter calibration to ~250ms/cell wall-clock, dedicated allocation-only sample.
|
||||
- [`Configuration.cs`](Configuration.cs) — Console-side state (`SelectedWireMode`, `WarmupIterations`, `BenchmarkSamples`, `TargetSampleMs`, charset selection, `BuildConfiguration` const from `#if DEBUG/RELEASE/AYCODE_NATIVEAOT`).
|
||||
|
||||
Workload + reporting types — `ISerializerBenchmark`, `BenchmarkResult`, `BenchmarkOptions`, `BenchmarkEnums`, `BenchmarkReportWriter`, `ReportingContext`, the 12 concrete `*Benchmark<T>` classes (`AcBinaryBenchmark`, `MemoryPackBenchmark`, `AcBinaryBufferWriterBenchmark`, ...), `RoundTripValidator` — live in [`AyCode.Benchmark/Workloads/Scenarios/`](../AyCode.Benchmark/Workloads/Scenarios/) and [`AyCode.Benchmark/Reporting/`](../AyCode.Benchmark/Reporting/).
|
||||
|
||||
## Test data
|
||||
|
||||
5 cells, provided by `AyCode.Core.Tests.TestModels.BenchmarkTestDataProvider*`:
|
||||
|
||||
- **Small** (2×2×2×2)
|
||||
- **Medium** (3×3×3×4)
|
||||
- **Large** (5×5×5×10)
|
||||
- **Repeated Strings** (10 items, string-deduplication stress)
|
||||
- **Deep Nested** (2×4×4×8, depth stress)
|
||||
|
||||
20% IId reference rate by default. Two graph variants (`TestOrder_All_False` / `_All_True`) are built per cell — AcBinary's option preset picks which variant gets fed to it (`UsesAllFalseVariant` rule in `BenchmarkLoop`).
|
||||
|
||||
## Charset profiles (Menu → Settings → Charset)
|
||||
|
||||
Controls the `BenchmarkTestDataProvider.LongStringSuffix` — the string-tail appended to property values. Influences string-marker selection on the wire (FixStrAscii vs StringSmall / Medium / Big / StringAscii), interning hit rates, and UTF-8 encode cost.
|
||||
|
||||
**Consistent length across all charsets** (UTF-16 char count): every `*Short` = 40 char, every `*Long` = 280 char (= Short × 7). Isolates the workload variable to UTF-8 byte content per charset (1-byte ASCII vs 2-byte Latin1 / Cyrillic vs 3-byte CJK vs mixed) — wire-size and encode/decode cost differences are pure charset effects, not length effects.
|
||||
|
||||
| Profile | UTF-16 char | UTF-8 byte (approx) | Tier |
|
||||
|---|---|---|---|
|
||||
| `Latin1FixAscii` | 0 | 0 | FixStrAscii / FixStr-equivalent (baseline-only) |
|
||||
| `AsciiShort` | 40 | 40 | StringAscii (167) |
|
||||
| `AsciiLong` | 280 | 280 | StringAscii (167) |
|
||||
| `Latin1Short` | 40 | ~72 | StringSmall (91) |
|
||||
| `Latin1Long` (**default**) | 280 | ~504 | StringMedium (94) |
|
||||
| `CjkBmpShort` | 40 | ~104 | StringSmall |
|
||||
| `CjkBmpLong` | 280 | ~728 | StringMedium |
|
||||
| `CyrillicShort` | 40 | ~72 | StringSmall |
|
||||
| `CyrillicLong` | 280 | ~504 | StringMedium |
|
||||
| `MixedShort` | 40 | ~88 | StringSmall |
|
||||
| `MixedLong` | 280 | ~616 | StringMedium |
|
||||
|
||||
## CLI
|
||||
|
||||
```
|
||||
dotnet run -c Release --project AyCode.Core.Serializers.Console -- [arg]
|
||||
```
|
||||
|
||||
| Arg | Result |
|
||||
|---|---|
|
||||
| _(no args)_ | Interactive menu — pick layer (Core / Comprehensive / Edge / Small / Medium / Large / Repeated / Deep / All) × serializer-set (Standard / FastestByte ["F"] / AsyncPipe ["P"]). |
|
||||
| `Core` / `Comprehensive` / `Edge` / `Small` / `Medium` / `Large` / `Repeated` / `Deep` / `All` | Run that layer at `Standard` serializer-set, `All` op-mode. |
|
||||
| `FastestByte` / `AsyncPipe` / `Standard` | Run that serializer-set, `All` layer, `All` op-mode. |
|
||||
| `Serialize` / `Deserialize` / `All` | Run that op-mode, `All` layer, `Standard` serializer-set. |
|
||||
| `quick` | Single-sample fast mode (Debug-equivalent — very loose numbers, smoke-test only). |
|
||||
|
||||
Output: `Test_Benchmark_Results/Benchmark/Console.FullBenchmark_<Build>_<timestamp>.{log,LLM,output}`.
|
||||
|
||||
## Dependencies
|
||||
|
||||
| Dependency | Purpose |
|
||||
|---|---|
|
||||
| `AyCode.Core` (ProjectReference) | AcBinary serializer |
|
||||
| `AyCode.Core.Tests` (ProjectReference) | Test data factory + test models |
|
||||
| `AyCode.Benchmark` (ProjectReference) | Shared workload + reporting (`ISerializerBenchmark`, `BenchmarkResult`, `BenchmarkReportWriter`, `ReportingContext`, the 12 concrete benchmark classes) |
|
||||
| `MemoryPack` | Comparison target (also via Workloads) |
|
||||
| `MessagePack` | Comparison target |
|
||||
| `Newtonsoft.Json` | Comparison target (currently disabled) |
|
||||
|
||||
## Build & publish notes
|
||||
|
||||
- `<StartupObject>AyCode.Core.Serializers.Console.Program</StartupObject>` in the csproj explicitly disambiguates the entry point — necessary because this Exe references another Exe (`AyCode.Benchmark`), and the build would otherwise complain about multiple `Main` methods.
|
||||
- AOT publish (`dotnet publish -c Release`) is configured via `'$(_IsPublishing)' == 'true'` PropertyGroup. The Benchmark project's BDN-stack (BenchmarkDotNet, Iced disassembler, MongoDB.Bson) is pulled in transitively — accepted tradeoff for the unified workload sharing.
|
||||
|
|
@ -0,0 +1,182 @@
|
|||
using System.Collections.Generic;
|
||||
using Microsoft.CodeAnalysis;
|
||||
|
||||
namespace AyCode.Core.Serializers.SourceGenerator;
|
||||
|
||||
/// <summary>
|
||||
/// Build-time diagnostics for the AcBinary source generator.
|
||||
///
|
||||
/// <para><b>Registered diagnostics</b>:</para>
|
||||
/// <list type="bullet">
|
||||
/// <item><c>ACBIN001</c> — <see cref="CircularReferenceWarning"/>: detects circular type references
|
||||
/// among <c>[AcBinarySerializable]</c> types and warns the developer to consider ref-handling mode.</item>
|
||||
/// <item><c>ACBIN002</c> — <see cref="PolymorphicPropertyWithFeatureDisabledError"/>: ACCORE-BIN-I-T7K3
|
||||
/// compile-time guard. Fires when a type opts out of <c>EnablePolymorphDetectFeature</c> AND still
|
||||
/// declares an <c>object</c> property — the SGen-emitted writer would silently corrupt the wire.</item>
|
||||
/// </list>
|
||||
/// </summary>
|
||||
public partial class AcBinarySourceGenerator
|
||||
{
|
||||
private static readonly DiagnosticDescriptor CircularReferenceWarning = new(
|
||||
id: "ACBIN001",
|
||||
title: "Circular reference detected",
|
||||
messageFormat: "Type '{0}' participates in a circular reference chain: {1}. Consider using ReferenceHandling.OnlyId or .All to avoid exponential serialization size.",
|
||||
category: "AcBinarySerializer",
|
||||
defaultSeverity: DiagnosticSeverity.Warning,
|
||||
isEnabledByDefault: true);
|
||||
|
||||
/// <summary>
|
||||
/// ACCORE-BIN-I-T7K3 compile-time guard: a property declared as <c>System.Object</c> requires
|
||||
/// polymorphic-prefix emit (<c>ObjectWithTypeName</c>) so the deserializer can resolve the
|
||||
/// concrete runtime type. When the type opts out of the feature via
|
||||
/// <c>[AcBinarySerializable(enablePolymorphDetectFeature: false)]</c>, the prefix is suppressed
|
||||
/// and the wire silently corrupts on round-trip (FixObj slot byte against <c>typeof(object)</c>
|
||||
/// at read-time → 0-byte object wrapper → reader position drifts → downstream
|
||||
/// <c>DECIMAL_DRIFT</c> / <c>IndexOutOfRangeException</c>).
|
||||
///
|
||||
/// Surface the misconfiguration at build time so the silent corruption is structurally
|
||||
/// impossible. Three escape hatches for the developer:
|
||||
/// 1. Enable the polymorph-detect feature on the type
|
||||
/// (<c>[AcBinarySerializable(...enablePolymorphDetectFeature: true)]</c> — default true).
|
||||
/// 2. Change the property type to a concrete type (no polymorphism needed).
|
||||
/// 3. Mark the property with <c>[AcBinaryIgnore]</c> — ignored properties are filtered out
|
||||
/// at property enumeration, so this diagnostic does not fire for them.
|
||||
/// </summary>
|
||||
private static readonly DiagnosticDescriptor PolymorphicPropertyWithFeatureDisabledError = new(
|
||||
id: "ACBIN002",
|
||||
title: "Polymorphic property requires EnablePolymorphDetectFeature",
|
||||
messageFormat: "Type '{0}' contains property '{1}' declared as System.Object, but EnablePolymorphDetectFeature is disabled on the type. " +
|
||||
"The generated writer would silently corrupt the wire on round-trip. " +
|
||||
"To fix: (1) enable EnablePolymorphDetectFeature on [AcBinarySerializable], (2) change '{1}' to a concrete type, or (3) exclude it with [AcBinaryIgnore].",
|
||||
category: "AcBinarySerializer",
|
||||
defaultSeverity: DiagnosticSeverity.Error,
|
||||
isEnabledByDefault: true);
|
||||
|
||||
/// <summary>
|
||||
/// ACCORE-BIN-I-T7K3 guard: emits <see cref="PolymorphicPropertyWithFeatureDisabledError"/>
|
||||
/// (ACBIN002) for every <c>System.Object</c>-declared property on any
|
||||
/// <c>[AcBinarySerializable]</c> type whose <c>EnablePolymorphDetectFeature</c> is <c>false</c>.
|
||||
/// Per-class gating: types with the feature enabled (default) skip the check entirely; only
|
||||
/// opt-out types are scanned for misuse.
|
||||
/// </summary>
|
||||
private static void DetectAndReportPolymorphicMisuse(List<SerializableClassInfo> classes, SourceProductionContext spc)
|
||||
{
|
||||
foreach (var ci in classes)
|
||||
{
|
||||
if (ci.EnablePolymorphDetect) continue; // Feature enabled → polymorphic prefix is emitted, no misuse possible.
|
||||
|
||||
foreach (var p in ci.Properties)
|
||||
{
|
||||
if (p.IsObjectDeclaredType)
|
||||
{
|
||||
spc.ReportDiagnostic(Diagnostic.Create(
|
||||
PolymorphicPropertyWithFeatureDisabledError, Location.None,
|
||||
ci.ClassName, p.Name));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Detects circular reference chains among [AcBinarySerializable] types at compile time
|
||||
/// and reports ACBIN001 warnings. Uses DFS with 3-color marking to find back-edges.
|
||||
/// </summary>
|
||||
private static void DetectAndReportCycles(List<SerializableClassInfo> classes, SourceProductionContext spc)
|
||||
{
|
||||
// Build lookup: WriterClassName → FullTypeName
|
||||
var writerToFull = new Dictionary<string, string>(classes.Count);
|
||||
foreach (var ci in classes)
|
||||
{
|
||||
var writerName = string.IsNullOrEmpty(ci.Namespace)
|
||||
? $"{ci.ClassName}_GeneratedWriter"
|
||||
: $"{ci.Namespace}.{ci.ClassName}_GeneratedWriter";
|
||||
writerToFull[writerName] = ci.FullTypeName;
|
||||
}
|
||||
|
||||
// Build adjacency list: FullTypeName → set of referenced FullTypeNames
|
||||
var adjacency = new Dictionary<string, HashSet<string>>(classes.Count);
|
||||
foreach (var ci in classes)
|
||||
{
|
||||
var edges = new HashSet<string>();
|
||||
foreach (var p in ci.Properties)
|
||||
{
|
||||
if (p.TypeKind == PropertyTypeKind.Complex && p.HasGeneratedWriter && p.WriterClassName != null)
|
||||
{
|
||||
if (writerToFull.TryGetValue(p.WriterClassName, out var target))
|
||||
edges.Add(target);
|
||||
}
|
||||
if (p.ElementKind == PropertyTypeKind.Complex && p.ElementHasGeneratedWriter && p.ElementWriterClassName != null)
|
||||
{
|
||||
if (writerToFull.TryGetValue(p.ElementWriterClassName, out var target))
|
||||
edges.Add(target);
|
||||
}
|
||||
if (p.DictValueKind == PropertyTypeKind.Complex && p.DictValueHasGeneratedWriter && p.DictValueWriterClassName != null)
|
||||
{
|
||||
if (writerToFull.TryGetValue(p.DictValueWriterClassName, out var target))
|
||||
edges.Add(target);
|
||||
}
|
||||
}
|
||||
adjacency[ci.FullTypeName] = edges;
|
||||
}
|
||||
|
||||
// DFS with 3-color marking: White=0, Gray=1, Black=2
|
||||
var color = new Dictionary<string, int>(classes.Count);
|
||||
foreach (var ci in classes)
|
||||
color[ci.FullTypeName] = 0;
|
||||
|
||||
var stack = new List<string>();
|
||||
var reported = new HashSet<string>();
|
||||
|
||||
void Dfs(string node)
|
||||
{
|
||||
color[node] = 1; // Gray
|
||||
stack.Add(node);
|
||||
|
||||
if (adjacency.TryGetValue(node, out var neighbors))
|
||||
{
|
||||
foreach (var next in neighbors)
|
||||
{
|
||||
if (!color.TryGetValue(next, out var c)) continue;
|
||||
if (c == 1) // Gray → back-edge = cycle
|
||||
{
|
||||
var cycleStart = stack.IndexOf(next);
|
||||
var parts = new List<string>();
|
||||
for (var i = cycleStart; i < stack.Count; i++)
|
||||
parts.Add(ShortTypeName(stack[i]));
|
||||
parts.Add(ShortTypeName(next)); // close the cycle
|
||||
|
||||
var cycleDesc = string.Join(" → ", parts);
|
||||
for (var i = cycleStart; i < stack.Count; i++)
|
||||
{
|
||||
if (reported.Add(stack[i]))
|
||||
{
|
||||
spc.ReportDiagnostic(Diagnostic.Create(
|
||||
CircularReferenceWarning, Location.None,
|
||||
ShortTypeName(stack[i]), cycleDesc));
|
||||
}
|
||||
}
|
||||
}
|
||||
else if (c == 0) // White → unvisited
|
||||
{
|
||||
Dfs(next);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
stack.RemoveAt(stack.Count - 1);
|
||||
color[node] = 2; // Black
|
||||
}
|
||||
|
||||
foreach (var ci in classes)
|
||||
{
|
||||
if (color[ci.FullTypeName] == 0)
|
||||
Dfs(ci.FullTypeName);
|
||||
}
|
||||
}
|
||||
|
||||
private static string ShortTypeName(string fullTypeName)
|
||||
{
|
||||
var dot = fullTypeName.LastIndexOf('.');
|
||||
return dot >= 0 ? fullTypeName.Substring(dot + 1) : fullTypeName;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,43 @@
|
|||
using System.Collections.Generic;
|
||||
using System.Text;
|
||||
|
||||
namespace AyCode.Core.Serializers.SourceGenerator;
|
||||
|
||||
/// <summary>
|
||||
/// Module-init emit pass: generates the static class with a <c>[ModuleInitializer]</c> method that
|
||||
/// auto-registers every generated writer / reader instance into the runtime registries
|
||||
/// (<c>AcBinarySerializer.RegisterGeneratedWriter</c> / <c>AcBinaryDeserializer.RegisterGeneratedReader</c>).
|
||||
/// Emitted once per compilation as <c>AcBinaryGeneratedWriters_Init.g.cs</c>.
|
||||
/// </summary>
|
||||
public partial class AcBinarySourceGenerator
|
||||
{
|
||||
private static string GenInit(List<SerializableClassInfo> classes)
|
||||
{
|
||||
var sb = new StringBuilder(512);
|
||||
sb.AppendLine("// <auto-generated/>");
|
||||
sb.AppendLine("using System.Runtime.CompilerServices;");
|
||||
sb.AppendLine("using AyCode.Core.Serializers.Binaries;");
|
||||
sb.AppendLine();
|
||||
sb.AppendLine("namespace AyCode.Core.Serializers.Generated;");
|
||||
sb.AppendLine();
|
||||
sb.AppendLine("internal static class AcBinaryGeneratedWritersInit");
|
||||
sb.AppendLine("{");
|
||||
sb.AppendLine(" [ModuleInitializer]");
|
||||
sb.AppendLine(" internal static void Register()");
|
||||
sb.AppendLine(" {");
|
||||
foreach (var ci in classes)
|
||||
{
|
||||
var writerRef = string.IsNullOrEmpty(ci.Namespace)
|
||||
? $"{ci.ClassName}_GeneratedWriter"
|
||||
: $"{ci.Namespace}.{ci.ClassName}_GeneratedWriter";
|
||||
var readerRef = string.IsNullOrEmpty(ci.Namespace)
|
||||
? $"{ci.ClassName}_GeneratedReader"
|
||||
: $"{ci.Namespace}.{ci.ClassName}_GeneratedReader";
|
||||
sb.AppendLine($" AcBinarySerializer.RegisterGeneratedWriter(typeof({ci.FullTypeName}), {writerRef}.Instance);");
|
||||
sb.AppendLine($" AcBinaryDeserializer.RegisterGeneratedReader(typeof({ci.FullTypeName}), {readerRef}.Instance);");
|
||||
}
|
||||
sb.AppendLine(" }");
|
||||
sb.AppendLine("}");
|
||||
return sb.ToString();
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,909 @@
|
|||
using System.Collections.Generic;
|
||||
using System.Text;
|
||||
|
||||
namespace AyCode.Core.Serializers.SourceGenerator;
|
||||
|
||||
/// <summary>
|
||||
/// Reader-side emit pass: generates the <c>IGeneratedBinaryReader</c> implementation for each
|
||||
/// <c>[AcBinarySerializable]</c> type. Emits <c>ReadProperties</c> (inline property reads with marker
|
||||
/// dispatch) and <c>ReadObject</c> (entry point with cache-index registration).
|
||||
///
|
||||
/// <para>Sub-passes:</para>
|
||||
/// <list type="bullet">
|
||||
/// <item><c>EmitReadProp</c> — per-property read emit (markerless + markered variants).</item>
|
||||
/// <item><c>EmitReadString</c> — H2Q6 string-tier marker dispatch (FixStrAscii + tier-tables +
|
||||
/// intern cases gated by <c>EnableInternStringFeature</c>).</item>
|
||||
/// <item><c>EmitReadComplex</c> — Object / ObjectRef* / FixObj-slot dispatch for IId-typed children.</item>
|
||||
/// <item><c>EmitReadCollection</c> / <c>EmitReadCollectionInline</c> / <c>EmitReadCollectionElement</c> /
|
||||
/// <c>EmitReadNonComplexCollectionElement</c> — collection-shape inline reading.</item>
|
||||
/// <item><c>EmitReadDictionary</c> / <c>EmitReadDictElement</c> — dict-shape inline reading.</item>
|
||||
/// <item><c>EmitReadMarkeredValue</c> / <c>EmitReadMarkeredValueForKind</c> — primitive value-with-marker reads.</item>
|
||||
/// <item><c>EmitReadMarkerless</c> — markerless primitive reads (FastMode + per-property markerless types).</item>
|
||||
/// </list>
|
||||
/// </summary>
|
||||
public partial class AcBinarySourceGenerator
|
||||
{
|
||||
#region Reader Code Generation
|
||||
|
||||
/// <summary>
|
||||
/// Generates the IGeneratedBinaryReader implementation for a type.
|
||||
/// Phase 1: handles markerless path (no UseMetadata). UseMetadata/ChainMode → runtime fallback.
|
||||
/// Eliminates: GetWrapper dictionary lookup, CreateInstance delegate, property setter delegates,
|
||||
/// AccessorType switch dispatch, ReadValue dispatch table.
|
||||
/// </summary>
|
||||
private static string GenReader(SerializableClassInfo ci)
|
||||
{
|
||||
var sb = new StringBuilder(4096);
|
||||
sb.AppendLine("// <auto-generated/>");
|
||||
sb.AppendLine("#nullable enable");
|
||||
sb.AppendLine("using System.Runtime.CompilerServices;");
|
||||
sb.AppendLine("using AyCode.Core.Serializers.Binaries;");
|
||||
sb.AppendLine();
|
||||
if (!string.IsNullOrEmpty(ci.Namespace))
|
||||
sb.AppendLine($"namespace {ci.Namespace};");
|
||||
sb.AppendLine();
|
||||
sb.AppendLine($"internal sealed class {ci.ClassName}_GeneratedReader : IGeneratedBinaryReader");
|
||||
sb.AppendLine("{");
|
||||
sb.AppendLine($" internal static readonly {ci.ClassName}_GeneratedReader Instance = new();");
|
||||
sb.AppendLine();
|
||||
|
||||
// ReadProperties — reads all properties into an existing instance (mirrors WriteProperties)
|
||||
// No depth safety net on deserialize: wire format is linear + finite, the serializer-side counter
|
||||
// already prevents pathological depth in well-formed payloads.
|
||||
sb.AppendLine(" public void ReadProperties<TInput>(object value, AcBinaryDeserializer.BinaryDeserializationContext<TInput> context)");
|
||||
sb.AppendLine(" where TInput : struct, IBinaryInputBase");
|
||||
sb.AppendLine(" {");
|
||||
sb.AppendLine($" var obj = Unsafe.As<{ci.FullTypeName}>(value);");
|
||||
|
||||
// Emit property reads — markerless for primitive types, markered for the rest
|
||||
foreach (var p in ci.Properties)
|
||||
{
|
||||
sb.AppendLine();
|
||||
EmitReadProp(sb, p, " ", ci.EnableMetadata, ci.EnableInternString);
|
||||
}
|
||||
|
||||
sb.AppendLine(" }");
|
||||
sb.AppendLine();
|
||||
|
||||
// ReadObject — IGeneratedBinaryReader implementation (delegates to ReadProperties)
|
||||
sb.AppendLine(" public object? ReadObject<TInput>(AcBinaryDeserializer.BinaryDeserializationContext<TInput> context, int cacheIndex)");
|
||||
sb.AppendLine(" where TInput : struct, IBinaryInputBase");
|
||||
sb.AppendLine(" {");
|
||||
sb.AppendLine($" var obj = new {ci.FullTypeName}();");
|
||||
sb.AppendLine(" if (cacheIndex >= 0)");
|
||||
sb.AppendLine(" context.RegisterInternedValueAt(cacheIndex, obj);");
|
||||
sb.AppendLine(" ReadProperties<TInput>(obj, context);");
|
||||
sb.AppendLine(" return obj;");
|
||||
sb.AppendLine(" }");
|
||||
sb.AppendLine("}");
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Emits inline read code for a single property.
|
||||
/// Markerless types: read raw value directly (no type code in stream).
|
||||
/// Markered types: read type code byte, then dispatch.
|
||||
/// Mirrors the serializer's EmitProp symmetry.
|
||||
/// </summary>
|
||||
private static void EmitReadProp(StringBuilder sb, PropInfo p, string i, bool enableMetadata, bool enableInternString)
|
||||
{
|
||||
var a = $"obj.{p.Name}";
|
||||
|
||||
// Markerless types: read raw value directly — mirrors EmitMarkerless in writer
|
||||
if (IsMarkerless(p.TypeKind))
|
||||
{
|
||||
if (p.TypeKind == PropertyTypeKind.Enum)
|
||||
sb.AppendLine($"{i}{{ var ev = context.ReadVarInt(); {a} = Unsafe.As<int, {p.TypeNameForTypeof}>(ref ev); }}");
|
||||
else
|
||||
EmitReadMarkerless(sb, p.TypeKind, a, i);
|
||||
return;
|
||||
}
|
||||
|
||||
// ACCORE-BIN-T-K9M3 — caller-driven string marker dispatch. SGen-emit reads the marker byte
|
||||
// locally + handles FastWire on a separate branch; BinaryDeserializationContext.TryReadStringProperty
|
||||
// decodes every non-interning marker (FixStrAscii / StringAscii / StringSmall/Medium/Big / Null /
|
||||
// StringEmpty) in one inlinable body. The 3 interning markers go through TryReadStringColdPath
|
||||
// (AggressiveOptimization, Tier-1 direct). enableInternString gates the `|| TryReadStringColdPath`
|
||||
// emit: interning-enabled types get the short-circuit; non-interning types omit the cold call
|
||||
// entirely — the writer never produces interning markers for them, so TryReadStringProperty alone
|
||||
// is total. PropertySkip / unknown → TryReadStringProperty returns false → property left at
|
||||
// default (don't-touch contract preserved).
|
||||
if (p.TypeKind == PropertyTypeKind.String)
|
||||
{
|
||||
sb.AppendLine($"{i}if (context.FastWire)");
|
||||
sb.AppendLine($"{i}{{");
|
||||
sb.AppendLine($"{i} {a} = context.ReadStringUtf16Markerless()!;");
|
||||
sb.AppendLine($"{i}}}");
|
||||
sb.AppendLine($"{i}else");
|
||||
sb.AppendLine($"{i}{{");
|
||||
sb.AppendLine($"{i} var tc_{p.Name} = context.ReadByte();");
|
||||
sb.AppendLine($"{i} string? v_{p.Name};");
|
||||
if (enableInternString)
|
||||
sb.AppendLine($"{i} if (context.TryReadStringProperty(tc_{p.Name}, out v_{p.Name}) || context.TryReadStringColdPath(tc_{p.Name}, out v_{p.Name}))");
|
||||
else
|
||||
sb.AppendLine($"{i} if (context.TryReadStringProperty(tc_{p.Name}, out v_{p.Name}))");
|
||||
sb.AppendLine($"{i} {{");
|
||||
sb.AppendLine($"{i} {a} = v_{p.Name}!;");
|
||||
sb.AppendLine($"{i} }}");
|
||||
sb.AppendLine($"{i}}}");
|
||||
return;
|
||||
}
|
||||
|
||||
// Markered types: read type code, then dispatch
|
||||
var tc = $"tc_{p.Name}";
|
||||
sb.AppendLine($"{i}var {tc} = context.ReadByte();");
|
||||
|
||||
// PropertySkip → leave default
|
||||
sb.AppendLine($"{i}if ({tc} != BinaryTypeCode.PropertySkip)");
|
||||
sb.AppendLine($"{i}{{");
|
||||
|
||||
// Nullable value types
|
||||
if (IsNullableVTKind(p.TypeKind))
|
||||
{
|
||||
sb.AppendLine($"{i} if ({tc} == BinaryTypeCode.Null) {{ /* null */ }}");
|
||||
sb.AppendLine($"{i} else");
|
||||
sb.AppendLine($"{i} {{");
|
||||
EmitReadMarkeredValue(sb, Underlying(p.TypeKind), a, tc, i + " ", p, nullable: true);
|
||||
sb.AppendLine($"{i} }}");
|
||||
}
|
||||
else
|
||||
{
|
||||
switch (p.TypeKind)
|
||||
{
|
||||
case PropertyTypeKind.String:
|
||||
EmitReadString(sb, a, tc, i + " ", enableInternString);
|
||||
break;
|
||||
|
||||
case PropertyTypeKind.Complex:
|
||||
EmitReadComplex(sb, p, a, tc, i + " ");
|
||||
break;
|
||||
|
||||
case PropertyTypeKind.Collection:
|
||||
EmitReadCollection(sb, p, a, tc, i + " ", enableInternString);
|
||||
break;
|
||||
|
||||
case PropertyTypeKind.Dictionary:
|
||||
EmitReadDictionary(sb, p, a, tc, i + " ", enableInternString);
|
||||
break;
|
||||
|
||||
default:
|
||||
// Unknown markered type (char, sbyte, etc.) — rewind + runtime fallback
|
||||
sb.AppendLine($"{i} context._position--;");
|
||||
if (p.IsNullable)
|
||||
sb.AppendLine($"{i} {a} = ({p.TypeNameForTypeof}?)AcBinaryDeserializer.ReadValueGenerated(context, typeof({p.TypeNameForTypeof}));");
|
||||
else
|
||||
sb.AppendLine($"{i} {a} = ({p.TypeNameForTypeof})AcBinaryDeserializer.ReadValueGenerated(context, typeof({p.TypeNameForTypeof}))!;");
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
sb.AppendLine($"{i}}}");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Emits raw value read — no type code in stream. Mirrors EmitMarkerless exactly.
|
||||
/// </summary>
|
||||
private static void EmitReadMarkerless(StringBuilder sb, PropertyTypeKind k, string a, string i)
|
||||
{
|
||||
switch (k)
|
||||
{
|
||||
case PropertyTypeKind.Int32: sb.AppendLine($"{i}{a} = context.ReadVarInt();"); break;
|
||||
case PropertyTypeKind.Int64: sb.AppendLine($"{i}{a} = context.ReadVarLong();"); break;
|
||||
case PropertyTypeKind.Double: sb.AppendLine($"{i}{a} = context.ReadDoubleUnsafe();"); break;
|
||||
case PropertyTypeKind.Single: sb.AppendLine($"{i}{a} = context.ReadSingleUnsafe();"); break;
|
||||
case PropertyTypeKind.Decimal: sb.AppendLine($"{i}{a} = context.ReadDecimalUnsafe();"); break;
|
||||
case PropertyTypeKind.DateTime: sb.AppendLine($"{i}{a} = context.ReadDateTimeUnsafe();"); break;
|
||||
case PropertyTypeKind.Guid: sb.AppendLine($"{i}{a} = context.ReadGuidUnsafe();"); break;
|
||||
case PropertyTypeKind.Byte: sb.AppendLine($"{i}{a} = context.ReadByte();"); break;
|
||||
case PropertyTypeKind.Int16: sb.AppendLine($"{i}{a} = context.ReadInt16Unsafe();"); break;
|
||||
case PropertyTypeKind.UInt16: sb.AppendLine($"{i}{a} = context.ReadUInt16Unsafe();"); break;
|
||||
case PropertyTypeKind.UInt32: sb.AppendLine($"{i}{a} = context.ReadVarUInt();"); break;
|
||||
case PropertyTypeKind.UInt64: sb.AppendLine($"{i}{a} = context.ReadVarULong();"); break;
|
||||
case PropertyTypeKind.TimeSpan: sb.AppendLine($"{i}{a} = new System.TimeSpan(context.ReadRaw<long>());"); break;
|
||||
case PropertyTypeKind.DateTimeOffset: sb.AppendLine($"{i}{a} = context.ReadDateTimeOffsetUnsafe();"); break;
|
||||
case PropertyTypeKind.Boolean: sb.AppendLine($"{i}{a} = context.ReadByte() != 0;"); break;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Emits inline string read from type code. Handles all H2Q6 (v3 wire format) string markers:
|
||||
/// FixStr (short-form universal, 135-166), String (long-form universal, 167),
|
||||
/// StringUtf16 (FastWire marker, 91),
|
||||
/// StringInternFirstSmall/Medium (interning tiers, 104/105),
|
||||
/// StringInterned (cache ref, 92), StringEmpty (93), Null.
|
||||
///
|
||||
/// FixStr is checked first as the hot path for short strings; non-ASCII
|
||||
/// tier markers carry both <c>charLen</c> and <c>utf8Len</c> in fixed-width headers (1-pass decode).
|
||||
/// </summary>
|
||||
private static void EmitReadString(StringBuilder sb, string a, string tc, string i, bool enableInternString)
|
||||
{
|
||||
// FixStr is the hot path — short-form universal marker with charLength in the marker.
|
||||
sb.AppendLine($"{i}if (BinaryTypeCode.IsFixStr({tc}))");
|
||||
sb.AppendLine($"{i}{{");
|
||||
sb.AppendLine($"{i} {a} = context.ReadUniversalFixStr({tc});");
|
||||
sb.AppendLine($"{i}}}");
|
||||
// Switch gives O(1) dispatch via JIT jump table for the remaining markers.
|
||||
sb.AppendLine($"{i}else switch ({tc})");
|
||||
sb.AppendLine($"{i}{{");
|
||||
// Interning case (2nd+ occurrence ref) — only emit when EnableInternStringFeature is enabled
|
||||
// on this type. When disabled, the writer never emits StringInterned markers for this type's
|
||||
// properties, so the reader doesn't need to handle them. ACCORE-BIN-T-K9M3 Phase C.
|
||||
if (enableInternString)
|
||||
{
|
||||
sb.AppendLine($"{i} case BinaryTypeCode.StringInterned:");
|
||||
sb.AppendLine($"{i} {a} = context.GetInternedString((int)context.ReadVarUInt());");
|
||||
sb.AppendLine($"{i} break;");
|
||||
}
|
||||
// StringUtf16 marker + String. Wire-decode body is shared with the runtime path
|
||||
// (TypeReaderTable + cross-type populate) — see context.ReadStringUtf16Marker()
|
||||
// and ReadUniversalLongString.
|
||||
// These markers are feature-independent: writer emits them on any string property regardless of
|
||||
// intern setting (intern is opt-in per-property via [AcStringIntern] + InternBit).
|
||||
sb.AppendLine($"{i} case BinaryTypeCode.StringUtf16:");
|
||||
sb.AppendLine($"{i} {a} = context.ReadStringUtf16Marker();");
|
||||
sb.AppendLine($"{i} break;");
|
||||
sb.AppendLine($"{i} case BinaryTypeCode.String:");
|
||||
sb.AppendLine($"{i} {a} = context.ReadUniversalLongString();");
|
||||
sb.AppendLine($"{i} break;");
|
||||
// Interning first-occurrence cases — see comment above.
|
||||
if (enableInternString)
|
||||
{
|
||||
sb.AppendLine($"{i} case BinaryTypeCode.StringInternFirstSmall:");
|
||||
sb.AppendLine($"{i} {a} = context.ReadAndRegisterInternedStringSmall();");
|
||||
sb.AppendLine($"{i} break;");
|
||||
sb.AppendLine($"{i} case BinaryTypeCode.StringInternFirstMedium:");
|
||||
sb.AppendLine($"{i} {a} = context.ReadAndRegisterInternedStringMedium();");
|
||||
sb.AppendLine($"{i} break;");
|
||||
}
|
||||
sb.AppendLine($"{i} case BinaryTypeCode.Null:");
|
||||
sb.AppendLine($"{i} {a} = null;");
|
||||
sb.AppendLine($"{i} break;");
|
||||
sb.AppendLine($"{i} case BinaryTypeCode.StringEmpty:");
|
||||
sb.AppendLine($"{i} {a} = string.Empty;");
|
||||
sb.AppendLine($"{i} break;");
|
||||
sb.AppendLine($"{i}}}");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Emits inline read for a Complex property.
|
||||
/// SGen reader only runs in non-metadata mode → ObjectWithMetadata never appears.
|
||||
/// Compile-time ChildNeedsRefScan eliminates ObjectRefFirst/ObjectRef when provably unused.
|
||||
/// Non-nullable + no ref → ZERO branches (tc consumed but ignored).
|
||||
/// No SGen → runtime fallback via ReadValueGenerated.
|
||||
/// </summary>
|
||||
private static void EmitReadComplex(StringBuilder sb, PropInfo p, string a, string tc, string i)
|
||||
{
|
||||
if (!p.HasGeneratedWriter)
|
||||
{
|
||||
// No SGen reader — runtime fallback (rewind + ReadValueGenerated)
|
||||
if (p.IsNullable)
|
||||
{
|
||||
sb.AppendLine($"{i}if ({tc} == BinaryTypeCode.Null) {a} = null;");
|
||||
sb.AppendLine($"{i}else");
|
||||
sb.AppendLine($"{i}{{");
|
||||
sb.AppendLine($"{i} context._position--;");
|
||||
sb.AppendLine($"{i} {a} = ({p.TypeNameForTypeof}?)AcBinaryDeserializer.ReadValueGenerated(context, typeof({p.TypeNameForTypeof}));");
|
||||
sb.AppendLine($"{i}}}");
|
||||
}
|
||||
else
|
||||
{
|
||||
sb.AppendLine($"{i}context._position--;");
|
||||
sb.AppendLine($"{i}{a} = ({p.TypeNameForTypeof})AcBinaryDeserializer.ReadValueGenerated(context, typeof({p.TypeNameForTypeof}))!;");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
var reader = p.WriterClassName!.Replace("_GeneratedWriter", "_GeneratedReader");
|
||||
var cast = $"({p.TypeNameForTypeof})";
|
||||
|
||||
// Ref-aware switch decision routed through RefAwareEmitPredicate — single source of truth shared
|
||||
// with the writer-side EmitDirectCollectionWrite + the sibling EmitReadCollectionElement. The
|
||||
// decision depends EXCLUSIVELY on the child compile-time fact `ChildNeedsRefScan` — the parent
|
||||
// EnableRefHandlingFeature flag is NOT a factor here (it governs only the parent's SELF-tracking
|
||||
// emit in the scan pass, not the marker dispatch for child property reads). Asymmetry-bug fix:
|
||||
// see AcBinarySerializerIIdReferenceTests.Serialize_RefMarkerCollectionElement_ParentRefHandlingFeatureOff_DriftReproduction.
|
||||
if (!RefAwareEmitPredicate.ChildEmitsRefMarker(p))
|
||||
{
|
||||
// Compile-time proven: child never tracked → only Object (+ Null for nullable) in stream
|
||||
// Inline: parent creates instance, calls ReadProperties directly (mirrors EmitDirectObjectWrite)
|
||||
// FixObj slot bytes (0..SlotCount-1) are also valid markers here — populate slot cache
|
||||
// to keep _nextRuntimeSlot in sync with the serializer's _nextTypeSlot counter.
|
||||
if (p.IsNullable)
|
||||
{
|
||||
sb.AppendLine($"{i}if ({tc} == BinaryTypeCode.Null) {{ /* null */ }}");
|
||||
sb.AppendLine($"{i}else");
|
||||
sb.AppendLine($"{i}{{");
|
||||
sb.AppendLine($"{i} if ({tc} < BinaryTypeCode.Object) {{ context.GetWrapper(typeof({p.TypeNameForTypeof}), {tc}); if ({tc} >= context._nextRuntimeSlot) context._nextRuntimeSlot = {tc} + 1; }}");
|
||||
sb.AppendLine($"{i} var rc_{p.Name} = new {p.TypeNameForTypeof}();");
|
||||
sb.AppendLine($"{i} {reader}.Instance.ReadProperties(rc_{p.Name}, context);");
|
||||
sb.AppendLine($"{i} {a} = rc_{p.Name};");
|
||||
sb.AppendLine($"{i}}}");
|
||||
}
|
||||
else
|
||||
{
|
||||
// ZERO branches — tc is always Object or FixObj
|
||||
sb.AppendLine($"{i}{{");
|
||||
sb.AppendLine($"{i} if ({tc} < BinaryTypeCode.Object) {{ context.GetWrapper(typeof({p.TypeNameForTypeof}), {tc}); if ({tc} >= context._nextRuntimeSlot) context._nextRuntimeSlot = {tc} + 1; }}");
|
||||
sb.AppendLine($"{i} var rc_{p.Name} = new {p.TypeNameForTypeof}();");
|
||||
sb.AppendLine($"{i} {reader}.Instance.ReadProperties(rc_{p.Name}, context);");
|
||||
sb.AppendLine($"{i} {a} = rc_{p.Name};");
|
||||
sb.AppendLine($"{i}}}");
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// Ref tracking possible — switch on tc (Object / ObjectRefFirst / [Null] / ObjectRef / <Object).
|
||||
// The 4 known TypeCode constants are emitted as switch cases — the JIT compiles them as a
|
||||
// jump-table for O(1) dispatch (vs the previous if-else chain's sequential ==-compares).
|
||||
// The polymorphic FixObj range-check (tc < Object) goes into the default branch — runtime
|
||||
// bridge path is rare on a typical SGen graph, so default fall-through is acceptable.
|
||||
// Inline: parent creates instance + handles cache registration.
|
||||
sb.AppendLine($"{i}switch ({tc})");
|
||||
sb.AppendLine($"{i}{{");
|
||||
sb.AppendLine($"{i} case BinaryTypeCode.Object:");
|
||||
sb.AppendLine($"{i} {{");
|
||||
sb.AppendLine($"{i} var rc_{p.Name} = new {p.TypeNameForTypeof}();");
|
||||
sb.AppendLine($"{i} {reader}.Instance.ReadProperties(rc_{p.Name}, context);");
|
||||
sb.AppendLine($"{i} {a} = rc_{p.Name};");
|
||||
sb.AppendLine($"{i} break;");
|
||||
sb.AppendLine($"{i} }}");
|
||||
sb.AppendLine($"{i} case BinaryTypeCode.ObjectRefFirst:");
|
||||
sb.AppendLine($"{i} {{");
|
||||
sb.AppendLine($"{i} var ci_{p.Name} = (int)context.ReadVarUInt();");
|
||||
sb.AppendLine($"{i} var rc_{p.Name} = new {p.TypeNameForTypeof}();");
|
||||
sb.AppendLine($"{i} context.RegisterInternedValueAt(ci_{p.Name}, rc_{p.Name});");
|
||||
sb.AppendLine($"{i} {reader}.Instance.ReadProperties(rc_{p.Name}, context);");
|
||||
sb.AppendLine($"{i} {a} = rc_{p.Name};");
|
||||
sb.AppendLine($"{i} break;");
|
||||
sb.AppendLine($"{i} }}");
|
||||
if (p.IsNullable)
|
||||
sb.AppendLine($"{i} case BinaryTypeCode.Null: break;");
|
||||
sb.AppendLine($"{i} case BinaryTypeCode.ObjectRef:");
|
||||
sb.AppendLine($"{i} {a} = {cast}context.GetInternedObject((int)context.ReadVarUInt())!;");
|
||||
sb.AppendLine($"{i} break;");
|
||||
// FixObj slot (0..SlotCount-1): same type via FixObj marker (non-meta, non-ref mode).
|
||||
// Populate slot cache to keep _nextRuntimeSlot in sync with the serializer.
|
||||
sb.AppendLine($"{i} default:");
|
||||
sb.AppendLine($"{i} if ({tc} < BinaryTypeCode.Object)");
|
||||
sb.AppendLine($"{i} {{");
|
||||
sb.AppendLine($"{i} context.GetWrapper(typeof({p.TypeNameForTypeof}), {tc});");
|
||||
sb.AppendLine($"{i} if ({tc} >= context._nextRuntimeSlot) context._nextRuntimeSlot = {tc} + 1;");
|
||||
sb.AppendLine($"{i} var rc_{p.Name} = new {p.TypeNameForTypeof}();");
|
||||
sb.AppendLine($"{i} {reader}.Instance.ReadProperties(rc_{p.Name}, context);");
|
||||
sb.AppendLine($"{i} {a} = rc_{p.Name};");
|
||||
sb.AppendLine($"{i} }}");
|
||||
sb.AppendLine($"{i} break;");
|
||||
sb.AppendLine($"{i}}}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns true when collection element reading can be inlined (no runtime ReadValue dispatch needed).
|
||||
/// </summary>
|
||||
private static bool CanInlineCollectionRead(PropInfo p)
|
||||
{
|
||||
if (p.ElementKind == PropertyTypeKind.Complex && p.ElementHasGeneratedWriter) return true;
|
||||
if (p.ElementKind == PropertyTypeKind.String) return true;
|
||||
if (p.ElementKind == PropertyTypeKind.Enum) return true;
|
||||
if (IsMarkerless(p.ElementKind)) return true; // all primitives
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Emits inline read for a Collection property.
|
||||
/// Known collection kind + inlineable element → inline Array loop with direct element reads.
|
||||
/// Else → runtime fallback via ReadValueGenerated.
|
||||
/// </summary>
|
||||
private static void EmitReadCollection(StringBuilder sb, PropInfo p, string a, string tc, string i, bool enableInternString)
|
||||
{
|
||||
// Check if we can inline: known collection shape + inlineable element type
|
||||
if (p.CollectionKind != null && CanInlineCollectionRead(p))
|
||||
{
|
||||
EmitReadCollectionInline(sb, p, a, tc, i, enableInternString);
|
||||
return;
|
||||
}
|
||||
|
||||
// Runtime fallback
|
||||
if (p.IsNullable)
|
||||
{
|
||||
sb.AppendLine($"{i}if ({tc} == BinaryTypeCode.Null) {a} = null;");
|
||||
sb.AppendLine($"{i}else");
|
||||
sb.AppendLine($"{i}{{");
|
||||
sb.AppendLine($"{i} context._position--;");
|
||||
sb.AppendLine($"{i} {a} = ({p.TypeNameForTypeof}?)AcBinaryDeserializer.ReadValueGenerated(context, typeof({p.TypeNameForTypeof}));");
|
||||
sb.AppendLine($"{i}}}");
|
||||
}
|
||||
else
|
||||
{
|
||||
sb.AppendLine($"{i}context._position--;");
|
||||
sb.AppendLine($"{i}{a} = ({p.TypeNameForTypeof})AcBinaryDeserializer.ReadValueGenerated(context, typeof({p.TypeNameForTypeof}))!;");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Emits inline read for a Dictionary property.
|
||||
/// Wire format: [Dictionary][VarUInt count][key₁ value₁ key₂ value₂ ...].
|
||||
/// Keys and values are read inline when their types are known (primitive/string/Complex+SGen).
|
||||
/// </summary>
|
||||
private static void EmitReadDictionary(StringBuilder sb, PropInfo p, string a, string tc, string i, bool enableInternString)
|
||||
{
|
||||
var s = p.Name;
|
||||
var keyType = p.DictKeyTypeName ?? "object";
|
||||
var valType = p.DictValueTypeName ?? "object";
|
||||
|
||||
// Can we inline key/value reads?
|
||||
var canInlineKey = p.DictKeyKind == PropertyTypeKind.String || IsMarkerless(p.DictKeyKind) || p.DictKeyKind == PropertyTypeKind.Enum;
|
||||
var canInlineValue = p.DictValueKind == PropertyTypeKind.String || IsMarkerless(p.DictValueKind) || p.DictValueKind == PropertyTypeKind.Enum
|
||||
|| (p.DictValueKind == PropertyTypeKind.Complex && p.DictValueHasGeneratedWriter);
|
||||
var canInline = canInlineKey || canInlineValue; // partial inline is still beneficial
|
||||
|
||||
if (p.IsNullable)
|
||||
{
|
||||
sb.AppendLine($"{i}if ({tc} == BinaryTypeCode.Null) {a} = null;");
|
||||
sb.AppendLine($"{i}else if ({tc} == BinaryTypeCode.Dictionary)");
|
||||
}
|
||||
else
|
||||
{
|
||||
sb.AppendLine($"{i}if ({tc} == BinaryTypeCode.Dictionary)");
|
||||
}
|
||||
|
||||
sb.AppendLine($"{i}{{");
|
||||
sb.AppendLine($"{i} var cnt_{s} = (int)context.ReadVarUInt();");
|
||||
sb.AppendLine($"{i} var dict_{s} = new System.Collections.Generic.Dictionary<{keyType}, {valType}>(cnt_{s});");
|
||||
sb.AppendLine($"{i} for (var di_{s} = 0; di_{s} < cnt_{s}; di_{s}++)");
|
||||
sb.AppendLine($"{i} {{");
|
||||
|
||||
// Read key
|
||||
if (canInlineKey)
|
||||
EmitReadDictElement(sb, p.DictKeyKind, keyType, $"dk_{s}", s, i + " ", null, false, enableInternString);
|
||||
else
|
||||
sb.AppendLine($"{i} var dk_{s} = ({keyType})AcBinaryDeserializer.ReadValueGenerated(context, typeof({keyType}))!;");
|
||||
|
||||
// Read value
|
||||
if (p.DictValueKind == PropertyTypeKind.Complex && p.DictValueHasGeneratedWriter)
|
||||
{
|
||||
var valReader = p.DictValueWriterClassName!.Replace("_GeneratedWriter", "_GeneratedReader");
|
||||
var vtc = $"vtc_{s}";
|
||||
sb.AppendLine($"{i} var {vtc} = context.ReadByte();");
|
||||
sb.AppendLine($"{i} {valType}? dv_{s} = null;");
|
||||
sb.AppendLine($"{i} if ({vtc} == BinaryTypeCode.Object)");
|
||||
sb.AppendLine($"{i} {{");
|
||||
sb.AppendLine($"{i} var rv_{s} = new {valType}();");
|
||||
sb.AppendLine($"{i} {valReader}.Instance.ReadProperties(rv_{s}, context);");
|
||||
sb.AppendLine($"{i} dv_{s} = rv_{s};");
|
||||
sb.AppendLine($"{i} }}");
|
||||
// ObjectRefFirst / ObjectRef cases — routed through RefAwareEmitPredicate. Single source of
|
||||
// truth shared with EmitReadComplex / EmitReadCollectionElement / EmitDirectCollectionWrite.
|
||||
// The decision depends EXCLUSIVELY on the dict-value compile-time fact `DictValueNeedsRefScan`
|
||||
// — the parent EnableRefHandlingFeature flag is NOT a factor here (it governs only the parent's
|
||||
// SELF-tracking emit in the scan pass, GenWriter.cs:140). Symmetric with the writer-side
|
||||
// dict-value emit. Asymmetry-bug fix: see AcBinarySerializerIIdReferenceTests
|
||||
// .Serialize_RefMarkerCollectionElement_ParentRefHandlingFeatureOff_DriftReproduction.
|
||||
if (RefAwareEmitPredicate.DictValueEmitsRefMarker(p))
|
||||
{
|
||||
sb.AppendLine($"{i} else if ({vtc} == BinaryTypeCode.ObjectRefFirst)");
|
||||
sb.AppendLine($"{i} {{");
|
||||
sb.AppendLine($"{i} var rci_{s} = (int)context.ReadVarUInt();");
|
||||
sb.AppendLine($"{i} var rv_{s} = new {valType}();");
|
||||
sb.AppendLine($"{i} context.RegisterInternedValueAt(rci_{s}, rv_{s});");
|
||||
sb.AppendLine($"{i} {valReader}.Instance.ReadProperties(rv_{s}, context);");
|
||||
sb.AppendLine($"{i} dv_{s} = rv_{s};");
|
||||
sb.AppendLine($"{i} }}");
|
||||
sb.AppendLine($"{i} else if ({vtc} == BinaryTypeCode.ObjectRef)");
|
||||
sb.AppendLine($"{i} dv_{s} = ({valType})context.GetInternedObject((int)context.ReadVarUInt())!;");
|
||||
}
|
||||
sb.AppendLine($"{i} else if ({vtc} != BinaryTypeCode.Null)");
|
||||
sb.AppendLine($"{i} {{");
|
||||
sb.AppendLine($"{i} context._position--;");
|
||||
sb.AppendLine($"{i} dv_{s} = ({valType}?)AcBinaryDeserializer.ReadValueGenerated(context, typeof({valType}));");
|
||||
sb.AppendLine($"{i} }}");
|
||||
}
|
||||
else if (canInlineValue)
|
||||
EmitReadDictElement(sb, p.DictValueKind, valType, $"dv_{s}", s, i + " ", null, true, enableInternString);
|
||||
else
|
||||
sb.AppendLine($"{i} var dv_{s} = ({valType}?)AcBinaryDeserializer.ReadValueGenerated(context, typeof({valType}));");
|
||||
|
||||
// Add to dictionary
|
||||
sb.AppendLine($"{i} if (dk_{s} != null) dict_{s}[dk_{s}] = dv_{s}!;");
|
||||
|
||||
sb.AppendLine($"{i} }}");
|
||||
sb.AppendLine($"{i} {a} = dict_{s};");
|
||||
sb.AppendLine($"{i}}}");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Emits inline read for a single dictionary key or value element.
|
||||
/// Reads type code byte, then dispatches based on element kind.
|
||||
/// </summary>
|
||||
private static void EmitReadDictElement(StringBuilder sb, PropertyTypeKind kind, string typeName, string varName, string propSuffix, string i, PropInfo? p, bool isRefType, bool enableInternString)
|
||||
{
|
||||
var etc = $"{varName}_tc";
|
||||
sb.AppendLine($"{i}var {etc} = context.ReadByte();");
|
||||
|
||||
if (kind == PropertyTypeKind.String)
|
||||
{
|
||||
sb.AppendLine($"{i}{typeName}? {varName} = null;");
|
||||
EmitReadString(sb, varName, etc, i, enableInternString);
|
||||
}
|
||||
else if (kind == PropertyTypeKind.Enum)
|
||||
{
|
||||
sb.AppendLine($"{i}{typeName} {varName} = default;");
|
||||
sb.AppendLine($"{i}if ({etc} == BinaryTypeCode.Enum)");
|
||||
sb.AppendLine($"{i}{{");
|
||||
sb.AppendLine($"{i} var eb = context.ReadByte();");
|
||||
sb.AppendLine($"{i} int eiv;");
|
||||
sb.AppendLine($"{i} if (BinaryTypeCode.IsTinyInt(eb)) eiv = BinaryTypeCode.DecodeTinyInt(eb);");
|
||||
sb.AppendLine($"{i} else eiv = context.ReadVarInt();");
|
||||
sb.AppendLine($"{i} {varName} = ({typeName})(object)eiv;");
|
||||
sb.AppendLine($"{i}}}");
|
||||
sb.AppendLine($"{i}else if (BinaryTypeCode.IsTinyInt({etc})) {varName} = ({typeName})(object)BinaryTypeCode.DecodeTinyInt({etc});");
|
||||
}
|
||||
else
|
||||
{
|
||||
// Primitive value type — never nullable
|
||||
sb.AppendLine($"{i}{typeName} {varName} = default;");
|
||||
EmitReadMarkeredValueForKind(sb, kind, varName, etc, i);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Emits markered value read by kind only (no PropInfo needed). For dict key/value inline reads.
|
||||
/// </summary>
|
||||
private static void EmitReadMarkeredValueForKind(StringBuilder sb, PropertyTypeKind k, string a, string tc, string i)
|
||||
{
|
||||
switch (k)
|
||||
{
|
||||
case PropertyTypeKind.Int32:
|
||||
sb.AppendLine($"{i}if (BinaryTypeCode.IsTinyInt({tc})) {a} = BinaryTypeCode.DecodeTinyInt({tc});");
|
||||
sb.AppendLine($"{i}else if ({tc} == BinaryTypeCode.Int32) {a} = context.ReadVarInt();");
|
||||
break;
|
||||
case PropertyTypeKind.Int64:
|
||||
sb.AppendLine($"{i}if (BinaryTypeCode.IsTinyInt({tc})) {a} = BinaryTypeCode.DecodeTinyInt({tc});");
|
||||
sb.AppendLine($"{i}else if ({tc} == BinaryTypeCode.Int32) {a} = context.ReadVarInt();");
|
||||
sb.AppendLine($"{i}else if ({tc} == BinaryTypeCode.Int64) {a} = context.ReadVarLong();");
|
||||
break;
|
||||
case PropertyTypeKind.Boolean:
|
||||
sb.AppendLine($"{i}if ({tc} == BinaryTypeCode.True) {a} = true;");
|
||||
sb.AppendLine($"{i}else if ({tc} == BinaryTypeCode.False) {a} = false;");
|
||||
break;
|
||||
case PropertyTypeKind.Double:
|
||||
sb.AppendLine($"{i}if ({tc} == BinaryTypeCode.Float64) {a} = context.ReadDoubleUnsafe();");
|
||||
break;
|
||||
case PropertyTypeKind.Single:
|
||||
sb.AppendLine($"{i}if ({tc} == BinaryTypeCode.Float32) {a} = context.ReadSingleUnsafe();");
|
||||
break;
|
||||
case PropertyTypeKind.Decimal:
|
||||
sb.AppendLine($"{i}if ({tc} == BinaryTypeCode.Decimal) {a} = context.ReadDecimalUnsafe();");
|
||||
break;
|
||||
case PropertyTypeKind.DateTime:
|
||||
sb.AppendLine($"{i}if ({tc} == BinaryTypeCode.DateTime) {a} = context.ReadDateTimeUnsafe();");
|
||||
break;
|
||||
case PropertyTypeKind.Guid:
|
||||
sb.AppendLine($"{i}if ({tc} == BinaryTypeCode.Guid) {a} = context.ReadGuidUnsafe();");
|
||||
break;
|
||||
case PropertyTypeKind.Byte:
|
||||
sb.AppendLine($"{i}if (BinaryTypeCode.IsTinyInt({tc})) {a} = (byte)BinaryTypeCode.DecodeTinyInt({tc});");
|
||||
sb.AppendLine($"{i}else if ({tc} == BinaryTypeCode.UInt8) {a} = context.ReadByte();");
|
||||
break;
|
||||
case PropertyTypeKind.Int16:
|
||||
sb.AppendLine($"{i}if (BinaryTypeCode.IsTinyInt({tc})) {a} = (short)BinaryTypeCode.DecodeTinyInt({tc});");
|
||||
sb.AppendLine($"{i}else if ({tc} == BinaryTypeCode.Int16) {a} = context.ReadInt16Unsafe();");
|
||||
break;
|
||||
case PropertyTypeKind.UInt16:
|
||||
sb.AppendLine($"{i}if (BinaryTypeCode.IsTinyInt({tc})) {a} = (ushort)BinaryTypeCode.DecodeTinyInt({tc});");
|
||||
sb.AppendLine($"{i}else if ({tc} == BinaryTypeCode.UInt16) {a} = context.ReadUInt16Unsafe();");
|
||||
break;
|
||||
case PropertyTypeKind.UInt32:
|
||||
sb.AppendLine($"{i}if (BinaryTypeCode.IsTinyInt({tc})) {a} = (uint)BinaryTypeCode.DecodeTinyInt({tc});");
|
||||
sb.AppendLine($"{i}else if ({tc} == BinaryTypeCode.UInt32) {a} = context.ReadVarUInt();");
|
||||
break;
|
||||
case PropertyTypeKind.UInt64:
|
||||
sb.AppendLine($"{i}if (BinaryTypeCode.IsTinyInt({tc})) {a} = (ulong)BinaryTypeCode.DecodeTinyInt({tc});");
|
||||
sb.AppendLine($"{i}else if ({tc} == BinaryTypeCode.UInt64) {a} = context.ReadVarULong();");
|
||||
break;
|
||||
case PropertyTypeKind.TimeSpan:
|
||||
sb.AppendLine($"{i}if ({tc} == BinaryTypeCode.TimeSpan) {a} = context.ReadTimeSpanUnsafe();");
|
||||
break;
|
||||
case PropertyTypeKind.DateTimeOffset:
|
||||
sb.AppendLine($"{i}if ({tc} == BinaryTypeCode.DateTimeOffset) {a} = context.ReadDateTimeOffsetUnsafe();");
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Emits inline collection read: Array marker already consumed as tc.
|
||||
/// Reads count + loops with direct element reads (Complex with SGen, or primitive/string/enum).
|
||||
/// Eliminates per-element: ReadValue dispatch, ReadObjectCore dict lookup, Activator.CreateInstance.
|
||||
/// </summary>
|
||||
private static void EmitReadCollectionInline(StringBuilder sb, PropInfo p, string a, string tc, string i, bool enableInternString)
|
||||
{
|
||||
var isComplexElement = p.ElementKind == PropertyTypeKind.Complex && p.ElementHasGeneratedWriter;
|
||||
var elemType = p.ElementFullTypeName!;
|
||||
var s = p.Name;
|
||||
|
||||
// Null check
|
||||
if (p.IsNullable)
|
||||
{
|
||||
sb.AppendLine($"{i}if ({tc} == BinaryTypeCode.Null) {a} = null;");
|
||||
sb.AppendLine($"{i}else if ({tc} == BinaryTypeCode.Array)");
|
||||
}
|
||||
else
|
||||
{
|
||||
sb.AppendLine($"{i}if ({tc} == BinaryTypeCode.Array)");
|
||||
}
|
||||
|
||||
sb.AppendLine($"{i}{{");
|
||||
sb.AppendLine($"{i} var cnt_{s} = (int)context.ReadVarUInt();");
|
||||
|
||||
// Create collection + loop based on kind
|
||||
if (p.CollectionKind == "Array")
|
||||
{
|
||||
sb.AppendLine($"{i} var col_{s} = new {elemType}[cnt_{s}];");
|
||||
sb.AppendLine($"{i} for (var ri_{s} = 0; ri_{s} < cnt_{s}; ri_{s}++)");
|
||||
sb.AppendLine($"{i} {{");
|
||||
if (isComplexElement)
|
||||
EmitReadCollectionElement(sb, p.ElementWriterClassName!.Replace("_GeneratedWriter", "_GeneratedReader"), elemType, $"({elemType})", $"ri_{s}", s, i + " ", isArray: true, p.ElementNeedsRefScan, enableInternString);
|
||||
else
|
||||
EmitReadNonComplexCollectionElement(sb, p, $"ri_{s}", s, i + " ", isArray: true, null, enableInternString);
|
||||
sb.AppendLine($"{i} }}");
|
||||
}
|
||||
else if (p.CollectionKind == "Counted" && p.CollectionAddMethod != null)
|
||||
{
|
||||
// Concrete custom collection — use actual type + correct add method
|
||||
if (p.CollectionHasCapacityCtor)
|
||||
sb.AppendLine($"{i} var col_{s} = new {p.TypeNameForTypeof}(cnt_{s});");
|
||||
else
|
||||
sb.AppendLine($"{i} var col_{s} = new {p.TypeNameForTypeof}();");
|
||||
sb.AppendLine($"{i} for (var ri_{s} = 0; ri_{s} < cnt_{s}; ri_{s}++)");
|
||||
sb.AppendLine($"{i} {{");
|
||||
if (isComplexElement)
|
||||
EmitReadCollectionElement(sb, p.ElementWriterClassName!.Replace("_GeneratedWriter", "_GeneratedReader"), elemType, $"({elemType})", $"ri_{s}", s, i + " ", isArray: false, p.ElementNeedsRefScan, enableInternString, p.CollectionAddMethod);
|
||||
else
|
||||
EmitReadNonComplexCollectionElement(sb, p, $"ri_{s}", s, i + " ", isArray: false, p.CollectionAddMethod, enableInternString);
|
||||
sb.AppendLine($"{i} }}");
|
||||
}
|
||||
else // List, IndexedCollection, Counted-interface → List<T> with Add
|
||||
{
|
||||
sb.AppendLine($"{i} var col_{s} = new System.Collections.Generic.List<{elemType}>(cnt_{s});");
|
||||
sb.AppendLine($"{i} for (var ri_{s} = 0; ri_{s} < cnt_{s}; ri_{s}++)");
|
||||
sb.AppendLine($"{i} {{");
|
||||
if (isComplexElement)
|
||||
EmitReadCollectionElement(sb, p.ElementWriterClassName!.Replace("_GeneratedWriter", "_GeneratedReader"), elemType, $"({elemType})", $"ri_{s}", s, i + " ", isArray: false, p.ElementNeedsRefScan, enableInternString);
|
||||
else
|
||||
EmitReadNonComplexCollectionElement(sb, p, $"ri_{s}", s, i + " ", isArray: false, null, enableInternString);
|
||||
sb.AppendLine($"{i} }}");
|
||||
}
|
||||
|
||||
sb.AppendLine($"{i} {a} = col_{s};");
|
||||
sb.AppendLine($"{i}}}");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Emits per-element read inside collection loop.
|
||||
/// SGen reader = non-metadata mode → no ObjectWithMetadata fallback.
|
||||
/// !needsRefScan → only Object/Null possible → 1 branch per element.
|
||||
/// </summary>
|
||||
private static void EmitReadCollectionElement(StringBuilder sb, string reader, string elemTypeName, string elemCast, string indexVar, string propSuffix, string i, bool isArray, bool needsRefScan, bool enableInternString, string? addMethod = null)
|
||||
{
|
||||
var etc = $"etc_{propSuffix}";
|
||||
sb.AppendLine($"{i}var {etc} = context.ReadByte();");
|
||||
|
||||
var addCall = addMethod ?? "Add";
|
||||
var assignNull = isArray ? $"col_{propSuffix}[{indexVar}] = null!;" : $"col_{propSuffix}.{addCall}(null!);";
|
||||
var assignExpr = isArray ? $"col_{propSuffix}[{indexVar}] = re_{propSuffix};" : $"col_{propSuffix}.{addCall}(re_{propSuffix});";
|
||||
|
||||
// Ref-aware switch decision routed through RefAwareEmitPredicate — single source of truth shared
|
||||
// with the writer-side EmitDirectCollectionWrite + EmitReadComplex. The decision depends
|
||||
// EXCLUSIVELY on the element compile-time fact `needsRefScan` — the parent EnableRefHandlingFeature
|
||||
// flag is NOT a factor here. Asymmetry-bug fix:
|
||||
// see AcBinarySerializerIIdReferenceTests.Serialize_RefMarkerCollectionElement_ParentRefHandlingFeatureOff_DriftReproduction.
|
||||
if (!RefAwareEmitPredicate.ElementEmitsRefMarker(needsRefScan))
|
||||
{
|
||||
// No ref tracking → only Object, FixObj or Null in stream — inline ReadProperties
|
||||
// FixObj slot: populate slot cache to keep _nextRuntimeSlot in sync.
|
||||
sb.AppendLine($"{i}if ({etc} == BinaryTypeCode.Null) {{ {assignNull} }}");
|
||||
sb.AppendLine($"{i}else");
|
||||
sb.AppendLine($"{i}{{");
|
||||
sb.AppendLine($"{i} if ({etc} < BinaryTypeCode.Object) {{ context.GetWrapper(typeof({elemTypeName}), {etc}); if ({etc} >= context._nextRuntimeSlot) context._nextRuntimeSlot = {etc} + 1; }}");
|
||||
sb.AppendLine($"{i} var re_{propSuffix} = new {elemTypeName}();");
|
||||
sb.AppendLine($"{i} {reader}.Instance.ReadProperties(re_{propSuffix}, context);");
|
||||
sb.AppendLine($"{i} {assignExpr}");
|
||||
sb.AppendLine($"{i}}}");
|
||||
}
|
||||
else
|
||||
{
|
||||
// Switch on etc (Object / ObjectRefFirst / Null / ObjectRef / <Object). The JIT emits the
|
||||
// 4 known TypeCode constants as a jump-table (O(1) dispatch); the polymorphic FixObj
|
||||
// range-check (etc < Object) goes into the default branch. Object hot-path stays first.
|
||||
sb.AppendLine($"{i}switch ({etc})");
|
||||
sb.AppendLine($"{i}{{");
|
||||
sb.AppendLine($"{i} case BinaryTypeCode.Object:");
|
||||
sb.AppendLine($"{i} {{");
|
||||
sb.AppendLine($"{i} var re_{propSuffix} = new {elemTypeName}();");
|
||||
sb.AppendLine($"{i} {reader}.Instance.ReadProperties(re_{propSuffix}, context);");
|
||||
sb.AppendLine($"{i} {assignExpr}");
|
||||
sb.AppendLine($"{i} break;");
|
||||
sb.AppendLine($"{i} }}");
|
||||
sb.AppendLine($"{i} case BinaryTypeCode.ObjectRefFirst:");
|
||||
sb.AppendLine($"{i} {{");
|
||||
sb.AppendLine($"{i} var ci_{propSuffix} = (int)context.ReadVarUInt();");
|
||||
sb.AppendLine($"{i} var re_{propSuffix} = new {elemTypeName}();");
|
||||
sb.AppendLine($"{i} context.RegisterInternedValueAt(ci_{propSuffix}, re_{propSuffix});");
|
||||
sb.AppendLine($"{i} {reader}.Instance.ReadProperties(re_{propSuffix}, context);");
|
||||
sb.AppendLine($"{i} {assignExpr}");
|
||||
sb.AppendLine($"{i} break;");
|
||||
sb.AppendLine($"{i} }}");
|
||||
sb.AppendLine($"{i} case BinaryTypeCode.Null:");
|
||||
sb.AppendLine($"{i} {assignNull}");
|
||||
sb.AppendLine($"{i} break;");
|
||||
sb.AppendLine($"{i} case BinaryTypeCode.ObjectRef:");
|
||||
if (isArray)
|
||||
sb.AppendLine($"{i} col_{propSuffix}[{indexVar}] = {elemCast}context.GetInternedObject((int)context.ReadVarUInt())!;");
|
||||
else
|
||||
sb.AppendLine($"{i} col_{propSuffix}.{addCall}({elemCast}context.GetInternedObject((int)context.ReadVarUInt())!);");
|
||||
sb.AppendLine($"{i} break;");
|
||||
// FixObj slot (0..SlotCount-1): same type via FixObj marker.
|
||||
// Populate slot cache to keep _nextRuntimeSlot in sync with the serializer.
|
||||
sb.AppendLine($"{i} default:");
|
||||
sb.AppendLine($"{i} if ({etc} < BinaryTypeCode.Object)");
|
||||
sb.AppendLine($"{i} {{");
|
||||
sb.AppendLine($"{i} context.GetWrapper(typeof({elemTypeName}), {etc});");
|
||||
sb.AppendLine($"{i} if ({etc} >= context._nextRuntimeSlot) context._nextRuntimeSlot = {etc} + 1;");
|
||||
sb.AppendLine($"{i} var re_{propSuffix} = new {elemTypeName}();");
|
||||
sb.AppendLine($"{i} {reader}.Instance.ReadProperties(re_{propSuffix}, context);");
|
||||
sb.AppendLine($"{i} {assignExpr}");
|
||||
sb.AppendLine($"{i} }}");
|
||||
sb.AppendLine($"{i} break;");
|
||||
sb.AppendLine($"{i}}}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Emits per-element read for non-Complex collection elements (String, primitive, Enum).
|
||||
/// Reads type code byte, then dispatches based on ElementKind.
|
||||
/// </summary>
|
||||
private static void EmitReadNonComplexCollectionElement(StringBuilder sb, PropInfo p, string indexVar, string propSuffix, string i, bool isArray, string? addMethod, bool enableInternString)
|
||||
{
|
||||
var addCall = addMethod ?? "Add";
|
||||
var elemType = p.ElementFullTypeName!;
|
||||
var colRef = $"col_{propSuffix}";
|
||||
|
||||
// String element FastWire markerless fast-path — same wire as property-level (int32 sentinel header).
|
||||
// All FastWire string writes funnel through `WriteStringWithDispatch.FastWire = WriteStringUtf16Markerless`,
|
||||
// so collection elements use the same markerless format. Skips the etc-read entirely in FastWire mode.
|
||||
if (p.ElementKind == PropertyTypeKind.String)
|
||||
{
|
||||
var tempVar = $"sv_{propSuffix}";
|
||||
sb.AppendLine($"{i}string? {tempVar};");
|
||||
sb.AppendLine($"{i}if (context.FastWire)");
|
||||
sb.AppendLine($"{i}{{");
|
||||
sb.AppendLine($"{i} {tempVar} = context.ReadStringUtf16Markerless();");
|
||||
sb.AppendLine($"{i}}}");
|
||||
sb.AppendLine($"{i}else");
|
||||
sb.AppendLine($"{i}{{");
|
||||
sb.AppendLine($"{i} var etc_{propSuffix} = context.ReadByte();");
|
||||
sb.AppendLine($"{i} {tempVar} = null;");
|
||||
EmitReadString(sb, tempVar, $"etc_{propSuffix}", i + " ", enableInternString);
|
||||
sb.AppendLine($"{i}}}");
|
||||
if (isArray)
|
||||
sb.AppendLine($"{i}{colRef}[{indexVar}] = {tempVar}!;");
|
||||
else
|
||||
sb.AppendLine($"{i}{colRef}.{addCall}({tempVar}!);");
|
||||
return;
|
||||
}
|
||||
|
||||
var etc = $"etc_{propSuffix}";
|
||||
sb.AppendLine($"{i}var {etc} = context.ReadByte();");
|
||||
|
||||
if (p.ElementKind == PropertyTypeKind.Enum)
|
||||
{
|
||||
// Enum element: Enum marker or TinyInt
|
||||
var tempVar = $"ev_{propSuffix}";
|
||||
sb.AppendLine($"{i}{elemType} {tempVar} = default;");
|
||||
sb.AppendLine($"{i}if ({etc} == BinaryTypeCode.Enum)");
|
||||
sb.AppendLine($"{i}{{");
|
||||
sb.AppendLine($"{i} var eb = context.ReadByte();");
|
||||
sb.AppendLine($"{i} int eiv;");
|
||||
sb.AppendLine($"{i} if (BinaryTypeCode.IsTinyInt(eb)) eiv = BinaryTypeCode.DecodeTinyInt(eb);");
|
||||
sb.AppendLine($"{i} else eiv = context.ReadVarInt();");
|
||||
sb.AppendLine($"{i} {tempVar} = ({elemType})(object)eiv;");
|
||||
sb.AppendLine($"{i}}}");
|
||||
sb.AppendLine($"{i}else if (BinaryTypeCode.IsTinyInt({etc})) {tempVar} = ({elemType})(object)BinaryTypeCode.DecodeTinyInt({etc});");
|
||||
if (isArray)
|
||||
sb.AppendLine($"{i}{colRef}[{indexVar}] = {tempVar};");
|
||||
else
|
||||
sb.AppendLine($"{i}{colRef}.{addCall}({tempVar});");
|
||||
}
|
||||
else
|
||||
{
|
||||
// Primitive element: read markered value
|
||||
var tempVar = $"pv_{propSuffix}";
|
||||
sb.AppendLine($"{i}{elemType} {tempVar} = default;");
|
||||
// Create a minimal PropInfo-like context for EmitReadMarkeredValue
|
||||
EmitReadMarkeredValue(sb, p.ElementKind, tempVar, etc, i, p, nullable: false);
|
||||
if (isArray)
|
||||
sb.AppendLine($"{i}{colRef}[{indexVar}] = {tempVar};");
|
||||
else
|
||||
sb.AppendLine($"{i}{colRef}.{addCall}({tempVar});");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Emits markered value read for primitive types (with type code already read).
|
||||
/// Handles TinyInt encoding for integer types.
|
||||
/// </summary>
|
||||
private static void EmitReadMarkeredValue(StringBuilder sb, PropertyTypeKind k, string a, string tc, string i, PropInfo p, bool nullable)
|
||||
{
|
||||
var assign = nullable ? $"{a} = " : $"{a} = ";
|
||||
switch (k)
|
||||
{
|
||||
case PropertyTypeKind.Int32:
|
||||
sb.AppendLine($"{i}if (BinaryTypeCode.IsTinyInt({tc})) {assign}BinaryTypeCode.DecodeTinyInt({tc});");
|
||||
sb.AppendLine($"{i}else if ({tc} == BinaryTypeCode.Int32) {assign}context.ReadVarInt();");
|
||||
break;
|
||||
case PropertyTypeKind.Int64:
|
||||
sb.AppendLine($"{i}if (BinaryTypeCode.IsTinyInt({tc})) {assign}BinaryTypeCode.DecodeTinyInt({tc});");
|
||||
sb.AppendLine($"{i}else if ({tc} == BinaryTypeCode.Int32) {assign}context.ReadVarInt();");
|
||||
sb.AppendLine($"{i}else if ({tc} == BinaryTypeCode.Int64) {assign}context.ReadVarLong();");
|
||||
break;
|
||||
case PropertyTypeKind.Boolean:
|
||||
sb.AppendLine($"{i}if ({tc} == BinaryTypeCode.True) {assign}true;");
|
||||
sb.AppendLine($"{i}else if ({tc} == BinaryTypeCode.False) {assign}false;");
|
||||
break;
|
||||
case PropertyTypeKind.Double:
|
||||
sb.AppendLine($"{i}if ({tc} == BinaryTypeCode.Float64) {assign}context.ReadDoubleUnsafe();");
|
||||
break;
|
||||
case PropertyTypeKind.Single:
|
||||
sb.AppendLine($"{i}if ({tc} == BinaryTypeCode.Float32) {assign}context.ReadSingleUnsafe();");
|
||||
break;
|
||||
case PropertyTypeKind.Decimal:
|
||||
sb.AppendLine($"{i}if ({tc} == BinaryTypeCode.Decimal) {assign}context.ReadDecimalUnsafe();");
|
||||
break;
|
||||
case PropertyTypeKind.DateTime:
|
||||
sb.AppendLine($"{i}if ({tc} == BinaryTypeCode.DateTime) {assign}context.ReadDateTimeUnsafe();");
|
||||
break;
|
||||
case PropertyTypeKind.Guid:
|
||||
sb.AppendLine($"{i}if ({tc} == BinaryTypeCode.Guid) {assign}context.ReadGuidUnsafe();");
|
||||
break;
|
||||
case PropertyTypeKind.Byte:
|
||||
sb.AppendLine($"{i}if (BinaryTypeCode.IsTinyInt({tc})) {assign}(byte)BinaryTypeCode.DecodeTinyInt({tc});");
|
||||
sb.AppendLine($"{i}else if ({tc} == BinaryTypeCode.UInt8) {assign}context.ReadByte();");
|
||||
break;
|
||||
case PropertyTypeKind.Int16:
|
||||
sb.AppendLine($"{i}if (BinaryTypeCode.IsTinyInt({tc})) {assign}(short)BinaryTypeCode.DecodeTinyInt({tc});");
|
||||
sb.AppendLine($"{i}else if ({tc} == BinaryTypeCode.Int16) {assign}context.ReadInt16Unsafe();");
|
||||
break;
|
||||
case PropertyTypeKind.UInt16:
|
||||
sb.AppendLine($"{i}if (BinaryTypeCode.IsTinyInt({tc})) {assign}(ushort)BinaryTypeCode.DecodeTinyInt({tc});");
|
||||
sb.AppendLine($"{i}else if ({tc} == BinaryTypeCode.UInt16) {assign}context.ReadUInt16Unsafe();");
|
||||
break;
|
||||
case PropertyTypeKind.UInt32:
|
||||
sb.AppendLine($"{i}if (BinaryTypeCode.IsTinyInt({tc})) {assign}(uint)BinaryTypeCode.DecodeTinyInt({tc});");
|
||||
sb.AppendLine($"{i}else if ({tc} == BinaryTypeCode.UInt32) {assign}context.ReadVarUInt();");
|
||||
break;
|
||||
case PropertyTypeKind.UInt64:
|
||||
sb.AppendLine($"{i}if (BinaryTypeCode.IsTinyInt({tc})) {assign}(ulong)BinaryTypeCode.DecodeTinyInt({tc});");
|
||||
sb.AppendLine($"{i}else if ({tc} == BinaryTypeCode.UInt64) {assign}context.ReadVarULong();");
|
||||
break;
|
||||
case PropertyTypeKind.Enum:
|
||||
sb.AppendLine($"{i}if ({tc} == BinaryTypeCode.Enum)");
|
||||
sb.AppendLine($"{i}{{");
|
||||
sb.AppendLine($"{i} var eb = context.ReadByte();");
|
||||
sb.AppendLine($"{i} int ev;");
|
||||
sb.AppendLine($"{i} if (BinaryTypeCode.IsTinyInt(eb)) ev = BinaryTypeCode.DecodeTinyInt(eb);");
|
||||
sb.AppendLine($"{i} else ev = context.ReadVarInt();");
|
||||
sb.AppendLine($"{i} {assign}({p.TypeNameForTypeof})(object)ev;");
|
||||
sb.AppendLine($"{i}}}");
|
||||
sb.AppendLine($"{i}else if (BinaryTypeCode.IsTinyInt({tc})) {assign}({p.TypeNameForTypeof})(object)BinaryTypeCode.DecodeTinyInt({tc});");
|
||||
break;
|
||||
case PropertyTypeKind.TimeSpan:
|
||||
sb.AppendLine($"{i}if ({tc} == BinaryTypeCode.TimeSpan) {assign}context.ReadTimeSpanUnsafe();");
|
||||
break;
|
||||
case PropertyTypeKind.DateTimeOffset:
|
||||
sb.AppendLine($"{i}if ({tc} == BinaryTypeCode.DateTimeOffset) {assign}context.ReadDateTimeOffsetUnsafe();");
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,363 @@
|
|||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using Microsoft.CodeAnalysis;
|
||||
|
||||
namespace AyCode.Core.Serializers.SourceGenerator;
|
||||
|
||||
/// <summary>
|
||||
/// Class-info extraction pass — transforms a Roslyn <see cref="GeneratorAttributeSyntaxContext"/>
|
||||
/// (a class/struct annotated with <c>[AcBinarySerializable]</c>) into the <see cref="SerializableClassInfo"/>
|
||||
/// model consumed by the emit passes (writer / reader / scan / init).
|
||||
///
|
||||
/// <para>Reads the attribute's feature flags (1-, 4-, 5-, 6-bool ctor variants), walks the inheritance
|
||||
/// hierarchy via <c>GetAllSerializablePropertySymbols</c>, and computes per-property metadata: kind,
|
||||
/// nullability, intern eligibility, complex / collection / dictionary element types, generated-writer
|
||||
/// pointers, FNV hashes for inline-metadata, and recursive scan-need flags.</para>
|
||||
/// </summary>
|
||||
public partial class AcBinarySourceGenerator
|
||||
{
|
||||
private static SerializableClassInfo? GetClassInfo(GeneratorAttributeSyntaxContext context)
|
||||
{
|
||||
if (!(context.TargetSymbol is INamedTypeSymbol typeSymbol))
|
||||
return null;
|
||||
|
||||
var namespaceName = typeSymbol.ContainingNamespace.IsGlobalNamespace
|
||||
? string.Empty
|
||||
: typeSymbol.ContainingNamespace.ToDisplayString();
|
||||
|
||||
var properties = new List<PropInfo>();
|
||||
|
||||
// Read feature flags from [AcBinarySerializable] — disabled features eliminate
|
||||
// corresponding code blocks from generated ScanObject/WriteProperties.
|
||||
var enableIdTracking = true;
|
||||
var enableRefHandling = true;
|
||||
var enableInternString = true;
|
||||
var enableMetadata = true;
|
||||
var enablePropertyFilter = true;
|
||||
var enablePolymorphDetect = true;
|
||||
var binarySerializableAttr = typeSymbol.GetAttributes().FirstOrDefault(a =>
|
||||
a.AttributeClass?.ToDisplayString() == AttributeName);
|
||||
if (binarySerializableAttr != null)
|
||||
{
|
||||
if (binarySerializableAttr.ConstructorArguments.Length == 1)
|
||||
{
|
||||
// Single bool ctor: AcBinarySerializable(enableAllFeatures)
|
||||
var all = (bool)binarySerializableAttr.ConstructorArguments[0].Value!;
|
||||
enableIdTracking = all;
|
||||
enableRefHandling = all;
|
||||
enableInternString = all;
|
||||
enableMetadata = all;
|
||||
enablePropertyFilter = all;
|
||||
enablePolymorphDetect = all;
|
||||
}
|
||||
else if (binarySerializableAttr.ConstructorArguments.Length == 4)
|
||||
{
|
||||
// Four bool ctor: (metadata, idTracking, refHandling, internString) — filter + polymorph default to true
|
||||
enableMetadata = (bool)binarySerializableAttr.ConstructorArguments[0].Value!;
|
||||
enableIdTracking = (bool)binarySerializableAttr.ConstructorArguments[1].Value!;
|
||||
enableRefHandling = (bool)binarySerializableAttr.ConstructorArguments[2].Value!;
|
||||
enableInternString = (bool)binarySerializableAttr.ConstructorArguments[3].Value!;
|
||||
}
|
||||
else if (binarySerializableAttr.ConstructorArguments.Length == 5)
|
||||
{
|
||||
// Five bool ctor: (metadata, idTracking, refHandling, internString, propertyFilter) — polymorph defaults to true
|
||||
enableMetadata = (bool)binarySerializableAttr.ConstructorArguments[0].Value!;
|
||||
enableIdTracking = (bool)binarySerializableAttr.ConstructorArguments[1].Value!;
|
||||
enableRefHandling = (bool)binarySerializableAttr.ConstructorArguments[2].Value!;
|
||||
enableInternString = (bool)binarySerializableAttr.ConstructorArguments[3].Value!;
|
||||
enablePropertyFilter = (bool)binarySerializableAttr.ConstructorArguments[4].Value!;
|
||||
}
|
||||
else if (binarySerializableAttr.ConstructorArguments.Length == 6)
|
||||
{
|
||||
// Six bool ctor: (metadata, idTracking, refHandling, internString, propertyFilter, polymorphDetect)
|
||||
enableMetadata = (bool)binarySerializableAttr.ConstructorArguments[0].Value!;
|
||||
enableIdTracking = (bool)binarySerializableAttr.ConstructorArguments[1].Value!;
|
||||
enableRefHandling = (bool)binarySerializableAttr.ConstructorArguments[2].Value!;
|
||||
enableInternString = (bool)binarySerializableAttr.ConstructorArguments[3].Value!;
|
||||
enablePropertyFilter = (bool)binarySerializableAttr.ConstructorArguments[4].Value!;
|
||||
enablePolymorphDetect = (bool)binarySerializableAttr.ConstructorArguments[5].Value!;
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var p in GetAllSerializablePropertySymbols(typeSymbol))
|
||||
{
|
||||
// String interning attribútum detektálás (null = no attr, true/false = explicit)
|
||||
bool? stringInternAttr = null;
|
||||
if (!enableInternString)
|
||||
{
|
||||
stringInternAttr = false;
|
||||
}
|
||||
else if (GetKind(p.Type) == PropertyTypeKind.String)
|
||||
{
|
||||
var attr = p.GetAttributes().FirstOrDefault(a => a.AttributeClass?.ToDisplayString() == "AyCode.Core.Serializers.Binaries.AcStringInternAttribute");
|
||||
if (attr != null && attr.ConstructorArguments.Length == 1 && attr.ConstructorArguments[0].Kind == TypedConstantKind.Primitive)
|
||||
{
|
||||
stringInternAttr = (bool)attr.ConstructorArguments[0].Value!;
|
||||
}
|
||||
}
|
||||
|
||||
// For typeof(): strip trailing '?' from nullable reference types (typeof(T?) is invalid for ref types)
|
||||
// Nullable value types (int?, Guid?) keep '?' because typeof(int?) == typeof(Nullable<int>) is valid
|
||||
var typeDisplayName = p.Type.ToDisplayString();
|
||||
var typeNameForTypeof = (p.Type.NullableAnnotation == NullableAnnotation.Annotated && !p.Type.IsValueType)
|
||||
? typeDisplayName.TrimEnd('?')
|
||||
: typeDisplayName;
|
||||
|
||||
// Direct object write detection for Complex property types:
|
||||
// Check if the property type has [AcBinarySerializable] (→ has generated writer)
|
||||
// and if it implements IId<T> (→ needs ref tracking in generated code)
|
||||
var kind = GetKind(p.Type);
|
||||
bool hasGenWriter = false;
|
||||
bool propTypeIsIId = false;
|
||||
bool propEnableMetadata = true;
|
||||
bool childNeedsIdScan = true;
|
||||
bool childNeedsAllRefScan = true;
|
||||
bool childNeedsInternScan = true;
|
||||
string? writerClassName = null;
|
||||
string? propIdTypeName = null;
|
||||
int childTypeNameHash = 0;
|
||||
int[]? childPropertyHashes = null;
|
||||
if (kind == PropertyTypeKind.Complex)
|
||||
{
|
||||
// Resolve to the actual type symbol (strip nullable annotation for ref types)
|
||||
// For SharedTag? → SharedTag. OriginalDefinition handles generic types.
|
||||
var resolvedType = p.Type is INamedTypeSymbol namedPropType
|
||||
? namedPropType.OriginalDefinition
|
||||
: p.Type;
|
||||
|
||||
hasGenWriter = resolvedType.Locations.Any(l => l.IsInSource)
|
||||
&& resolvedType.GetAttributes().Any(a =>
|
||||
a.AttributeClass?.ToDisplayString() == AttributeName);
|
||||
|
||||
if (hasGenWriter)
|
||||
{
|
||||
// Read child type's EnableMetadataFeature
|
||||
propEnableMetadata = ReadEnableMetadata(resolvedType);
|
||||
var childScanFlags = ComputeNeedsScan(resolvedType);
|
||||
childNeedsIdScan = childScanFlags.needsIdScan;
|
||||
childNeedsAllRefScan = childScanFlags.needsAllRefScan;
|
||||
childNeedsInternScan = childScanFlags.needsInternScan;
|
||||
var iidIface = resolvedType.AllInterfaces.FirstOrDefault(i =>
|
||||
i.IsGenericType &&
|
||||
i.OriginalDefinition.ToDisplayString() == "AyCode.Core.Interfaces.IId<T>");
|
||||
propTypeIsIId = iidIface != null;
|
||||
if (iidIface != null)
|
||||
propIdTypeName = iidIface.TypeArguments[0].ToDisplayString();
|
||||
|
||||
// Writer class: {Namespace}.{FlatName}_GeneratedWriter
|
||||
var flatName = BuildFlatName((INamedTypeSymbol)resolvedType);
|
||||
var ns = resolvedType.ContainingNamespace.IsGlobalNamespace
|
||||
? string.Empty
|
||||
: resolvedType.ContainingNamespace.ToDisplayString();
|
||||
writerClassName = string.IsNullOrEmpty(ns)
|
||||
? $"{flatName}_GeneratedWriter"
|
||||
: $"{ns}.{flatName}_GeneratedWriter";
|
||||
|
||||
// UseMetadata: compute child type hash-es for inline metadata
|
||||
childTypeNameHash = ComputeFnvHash(resolvedType.Name);
|
||||
childPropertyHashes = ComputeChildPropertyHashes(resolvedType);
|
||||
}
|
||||
}
|
||||
|
||||
// Collection element type analysis for inline collection write
|
||||
PropertyTypeKind elemKind = PropertyTypeKind.Unknown;
|
||||
bool elemHasGenWriter = false;
|
||||
bool elemIsIId = false;
|
||||
bool elemEnableMetadata = true;
|
||||
bool elemNeedsIdScan = true;
|
||||
bool elemNeedsAllRefScan = true;
|
||||
bool elemNeedsInternScan = true;
|
||||
string? elemWriterClassName = null;
|
||||
string? elemIdTypeName = null;
|
||||
string? collKind = null;
|
||||
string? collAddMethod = null;
|
||||
bool collHasCapacityCtor = false;
|
||||
string? elemFullTypeName = null;
|
||||
int elementTypeNameHash = 0;
|
||||
int[]? elementPropertyHashes = null;
|
||||
if (kind == PropertyTypeKind.Collection)
|
||||
{
|
||||
var elemType = GetCollectionElementType(p.Type);
|
||||
if (elemType != null)
|
||||
{
|
||||
elemKind = GetKind(elemType);
|
||||
elemFullTypeName = elemType.ToDisplayString();
|
||||
|
||||
// Detect collection shape for inline write
|
||||
if (p.Type is IArrayTypeSymbol)
|
||||
collKind = "Array";
|
||||
else if (p.Type is INamedTypeSymbol collNamedType)
|
||||
{
|
||||
var origDef = collNamedType.OriginalDefinition.ToDisplayString();
|
||||
collKind = origDef switch
|
||||
{
|
||||
"System.Collections.Generic.List<T>" => "List",
|
||||
"System.Collections.Generic.IList<T>" => "IndexedCollection",
|
||||
"System.Collections.Generic.IReadOnlyList<T>" => "IndexedCollection",
|
||||
"System.Collections.Generic.HashSet<T>" => "Counted", // has Count, no indexer
|
||||
"System.Collections.Generic.Queue<T>" => "Counted",
|
||||
"System.Collections.Generic.ICollection<T>" => "Counted",
|
||||
"System.Collections.Generic.IReadOnlyCollection<T>" => "Counted",
|
||||
"System.Collections.Generic.SortedSet<T>" => "Counted",
|
||||
"System.Collections.Generic.LinkedList<T>" => "Counted",
|
||||
_ => null
|
||||
};
|
||||
|
||||
// Determine add method + capacity ctor for Counted concrete types
|
||||
if (collKind == "Counted")
|
||||
{
|
||||
collAddMethod = origDef switch
|
||||
{
|
||||
"System.Collections.Generic.HashSet<T>" => "Add",
|
||||
"System.Collections.Generic.SortedSet<T>" => "Add",
|
||||
"System.Collections.Generic.Queue<T>" => "Enqueue",
|
||||
"System.Collections.Generic.LinkedList<T>" => "AddLast",
|
||||
_ => null // ICollection<T>, IReadOnlyCollection<T> → backed by List<T>
|
||||
};
|
||||
collHasCapacityCtor = origDef is
|
||||
"System.Collections.Generic.HashSet<T>" or
|
||||
"System.Collections.Generic.Queue<T>";
|
||||
}
|
||||
}
|
||||
|
||||
// For Complex element types, check for generated writer
|
||||
if (elemKind == PropertyTypeKind.Complex)
|
||||
{
|
||||
var resolvedElem = elemType is INamedTypeSymbol namedElem
|
||||
? namedElem.OriginalDefinition : elemType;
|
||||
elemHasGenWriter = resolvedElem.Locations.Any(l => l.IsInSource)
|
||||
&& resolvedElem.GetAttributes().Any(a =>
|
||||
a.AttributeClass?.ToDisplayString() == AttributeName);
|
||||
if (elemHasGenWriter)
|
||||
{
|
||||
// Read element type's EnableMetadataFeature
|
||||
elemEnableMetadata = ReadEnableMetadata(resolvedElem);
|
||||
var elemScanFlags = ComputeNeedsScan(resolvedElem);
|
||||
elemNeedsIdScan = elemScanFlags.needsIdScan;
|
||||
elemNeedsAllRefScan = elemScanFlags.needsAllRefScan;
|
||||
elemNeedsInternScan = elemScanFlags.needsInternScan;
|
||||
var elemIidIface = resolvedElem.AllInterfaces.FirstOrDefault(ifc =>
|
||||
ifc.IsGenericType &&
|
||||
ifc.OriginalDefinition.ToDisplayString() == "AyCode.Core.Interfaces.IId<T>");
|
||||
elemIsIId = elemIidIface != null;
|
||||
if (elemIidIface != null)
|
||||
elemIdTypeName = elemIidIface.TypeArguments[0].ToDisplayString();
|
||||
|
||||
var elemFlatName = BuildFlatName((INamedTypeSymbol)resolvedElem);
|
||||
var ens = resolvedElem.ContainingNamespace.IsGlobalNamespace
|
||||
? string.Empty : resolvedElem.ContainingNamespace.ToDisplayString();
|
||||
elemWriterClassName = string.IsNullOrEmpty(ens)
|
||||
? $"{elemFlatName}_GeneratedWriter"
|
||||
: $"{ens}.{elemFlatName}_GeneratedWriter";
|
||||
|
||||
// UseMetadata: compute element type hash-es for inline metadata
|
||||
elementTypeNameHash = ComputeFnvHash(resolvedElem.Name);
|
||||
elementPropertyHashes = ComputeChildPropertyHashes(resolvedElem);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Dictionary key/value type analysis for inline dictionary read
|
||||
PropertyTypeKind dictKeyKind = PropertyTypeKind.Unknown;
|
||||
PropertyTypeKind dictValueKind = PropertyTypeKind.Unknown;
|
||||
string? dictKeyTypeName = null;
|
||||
string? dictValueTypeName = null;
|
||||
bool dictValueHasGenWriter = false;
|
||||
string? dictValueWriterClassName = null;
|
||||
bool dictValueIsIId = false;
|
||||
bool dictValueEnableMetadata = true;
|
||||
bool dictValueNeedsIdScan = true;
|
||||
bool dictValueNeedsAllRefScan = true;
|
||||
bool dictValueNeedsInternScan = true;
|
||||
int dictValueTypeNameHash = 0;
|
||||
int[]? dictValuePropertyHashes = null;
|
||||
if (kind == PropertyTypeKind.Dictionary)
|
||||
{
|
||||
var (keyType, valueType) = GetDictionaryKeyValueTypes(p.Type);
|
||||
if (keyType != null)
|
||||
{
|
||||
dictKeyKind = GetKind(keyType);
|
||||
dictKeyTypeName = keyType.ToDisplayString();
|
||||
}
|
||||
if (valueType != null)
|
||||
{
|
||||
dictValueKind = GetKind(valueType);
|
||||
dictValueTypeName = valueType.ToDisplayString();
|
||||
if (dictValueKind == PropertyTypeKind.Complex)
|
||||
{
|
||||
var resolvedValue = valueType is INamedTypeSymbol nvt ? nvt.OriginalDefinition : valueType;
|
||||
dictValueHasGenWriter = resolvedValue.Locations.Any(l => l.IsInSource)
|
||||
&& resolvedValue.GetAttributes().Any(a =>
|
||||
a.AttributeClass?.ToDisplayString() == AttributeName);
|
||||
if (dictValueHasGenWriter)
|
||||
{
|
||||
var vfn = BuildFlatName((INamedTypeSymbol)resolvedValue);
|
||||
var vns = resolvedValue.ContainingNamespace.IsGlobalNamespace
|
||||
? string.Empty : resolvedValue.ContainingNamespace.ToDisplayString();
|
||||
dictValueWriterClassName = string.IsNullOrEmpty(vns)
|
||||
? $"{vfn}_GeneratedWriter"
|
||||
: $"{vns}.{vfn}_GeneratedWriter";
|
||||
|
||||
dictValueEnableMetadata = ReadEnableMetadata(resolvedValue);
|
||||
var dvScanFlags = ComputeNeedsScan(resolvedValue);
|
||||
dictValueNeedsIdScan = dvScanFlags.needsIdScan;
|
||||
dictValueNeedsAllRefScan = dvScanFlags.needsAllRefScan;
|
||||
dictValueNeedsInternScan = dvScanFlags.needsInternScan;
|
||||
var dvIidIface = resolvedValue.AllInterfaces.FirstOrDefault(ifc =>
|
||||
ifc.IsGenericType &&
|
||||
ifc.OriginalDefinition.ToDisplayString() == "AyCode.Core.Interfaces.IId<T>");
|
||||
dictValueIsIId = dvIidIface != null;
|
||||
dictValueTypeNameHash = ComputeFnvHash(resolvedValue.Name);
|
||||
dictValuePropertyHashes = ComputeChildPropertyHashes(resolvedValue);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
properties.Add(new PropInfo(
|
||||
p.Name,
|
||||
typeDisplayName,
|
||||
typeNameForTypeof,
|
||||
kind,
|
||||
p.Type.NullableAnnotation == NullableAnnotation.Annotated || IsNullableVT(p.Type),
|
||||
p.Type.SpecialType == SpecialType.System_Object,
|
||||
stringInternAttr, hasGenWriter, propTypeIsIId, writerClassName, propIdTypeName,
|
||||
elemKind, elemHasGenWriter, elemIsIId, elemWriterClassName, elemIdTypeName, collKind, elemFullTypeName,
|
||||
collAddMethod, collHasCapacityCtor,
|
||||
dictKeyKind, dictValueKind, dictKeyTypeName, dictValueTypeName, dictValueHasGenWriter, dictValueWriterClassName,
|
||||
dictValueIsIId, dictValueEnableMetadata, dictValueTypeNameHash, dictValuePropertyHashes,
|
||||
dictValueNeedsIdScan, dictValueNeedsAllRefScan, dictValueNeedsInternScan,
|
||||
childTypeNameHash, childPropertyHashes,
|
||||
elementTypeNameHash, elementPropertyHashes,
|
||||
propEnableMetadata, elemEnableMetadata,
|
||||
childNeedsIdScan, childNeedsAllRefScan, childNeedsInternScan,
|
||||
elemNeedsIdScan, elemNeedsAllRefScan, elemNeedsInternScan));
|
||||
}
|
||||
|
||||
// IId<T>: Id first (index 0), then alphabetical — matches runtime TypeMetadataBase ordering
|
||||
// If EnableIdTrackingFeature == false, skip IId detection entirely → isIId = false
|
||||
var isIId = false;
|
||||
string? idTypeName = null;
|
||||
if (enableIdTracking)
|
||||
{
|
||||
var iidInterface = typeSymbol.AllInterfaces.FirstOrDefault(i =>
|
||||
i.IsGenericType &&
|
||||
i.OriginalDefinition.ToDisplayString() == "AyCode.Core.Interfaces.IId<T>");
|
||||
if (iidInterface != null)
|
||||
{
|
||||
isIId = true;
|
||||
idTypeName = iidInterface.TypeArguments[0].ToDisplayString();
|
||||
}
|
||||
}
|
||||
|
||||
// Properties are already in runtime-matching order from GetAllSerializablePropertySymbols:
|
||||
// derived → base, each level sorted alphabetically (matches TypeMetadataBase.GetUnfilteredProperties).
|
||||
|
||||
var className = BuildFlatName(typeSymbol);
|
||||
var typeNameHash = ComputeFnvHash(typeSymbol.Name);
|
||||
var propertyNameHashes = properties.Select(prop => ComputeFnvHash(prop.Name)).ToArray();
|
||||
var selfScanFlags = ComputeNeedsScan(typeSymbol);
|
||||
return new SerializableClassInfo(namespaceName, className, typeSymbol.ToDisplayString(), properties, isIId, idTypeName, enableRefHandling, typeNameHash, propertyNameHashes, enableMetadata, enablePropertyFilter, enablePolymorphDetect, enableInternString, selfScanFlags.needsIdScan, selfScanFlags.needsAllRefScan, selfScanFlags.needsInternScan);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,309 @@
|
|||
using System.Collections.Generic;
|
||||
|
||||
namespace AyCode.Core.Serializers.SourceGenerator;
|
||||
|
||||
// Source-generator model types — pure POCO data carriers describing a `[AcBinarySerializable]` type
|
||||
// and its serializable properties. Consumed by all emit / diagnostics / analysis passes in the partial
|
||||
// `AcBinarySourceGenerator` class (see siblings `*.GenWriter.cs`, `*.GenReader.cs`, etc.).
|
||||
|
||||
internal sealed class SerializableClassInfo
|
||||
{
|
||||
public string Namespace { get; }
|
||||
public string ClassName { get; }
|
||||
public string FullTypeName { get; }
|
||||
public List<PropInfo> Properties { get; }
|
||||
/// <summary>True if this type implements IId<T></summary>
|
||||
public bool IsIId { get; }
|
||||
/// <summary>The Id type name ("int", "long", "System.Guid") if IsIId, null otherwise</summary>
|
||||
public string? IdTypeName { get; }
|
||||
/// <summary>True if EnableRefHandlingFeature is enabled — controls non-IId All mode tracking code emission.</summary>
|
||||
public bool EnableRefHandling { get; }
|
||||
/// <summary>FNV-1a hash of ClassName (matches runtime SourceType.Name hash)</summary>
|
||||
public int TypeNameHash { get; }
|
||||
/// <summary>FNV-1a hash of each property name, in property order</summary>
|
||||
public int[] PropertyNameHashes { get; }
|
||||
/// <summary>When false, skip inline metadata and use markerless property write for this type.</summary>
|
||||
public bool EnableMetadata { get; }
|
||||
/// <summary>True if EnablePropertyFilterFeature is enabled — controls per-property HasPropertyFilter
|
||||
/// guard emission in WriteProperties / ScanObject. When false, the filter check is omitted entirely
|
||||
/// → leaner generated code on the hot path (typical for high-throughput types that never use a filter).</summary>
|
||||
public bool EnablePropertyFilter { get; }
|
||||
/// <summary>True if EnablePolymorphDetectFeature is enabled — controls <c>ObjectWithTypeName</c> + AQN
|
||||
/// prefix emit on <c>System.Object</c>-declared properties. When false, the prefix is suppressed
|
||||
/// AND ACBIN002 fires at build time if such a property exists on this type (guarding against silent
|
||||
/// wire corruption). Opt-out is intentional: dev guarantees no polymorphic <c>object</c> property
|
||||
/// will be serialized on this type, or all such properties are excluded via <c>[AcBinaryIgnore]</c>.</summary>
|
||||
public bool EnablePolymorphDetect { get; }
|
||||
/// <summary>True if EnableInternStringFeature is enabled — controls whether the SGen-emitted reader
|
||||
/// contains <c>StringInterned</c>, <c>StringInternFirstSmall</c>, <c>StringInternFirstMedium</c> case-ágakat.
|
||||
/// When false, those cases are omitted (the writer doesn't emit those markers when intern is off,
|
||||
/// so the reader doesn't need to handle them). Leaner switch dispatch (~30% fewer string cases) +
|
||||
/// smaller IL → faster cold-start JIT + smaller AOT publish.</summary>
|
||||
public bool EnableInternString { get; }
|
||||
/// <summary>When true, type subtree has IId types needing scan (active in OnlyId + All).</summary>
|
||||
public bool NeedsIdScan { get; }
|
||||
/// <summary>When true, type subtree has non-IId ref tracking (active only in All mode).</summary>
|
||||
public bool NeedsAllRefScan { get; }
|
||||
/// <summary>When true, type subtree needs string interning scan.</summary>
|
||||
public bool NeedsInternScan { get; }
|
||||
/// <summary>Derived: NeedsIdScan || NeedsAllRefScan.</summary>
|
||||
public bool NeedsRefScan => NeedsIdScan || NeedsAllRefScan;
|
||||
/// <summary>Derived: any scan axis active.</summary>
|
||||
public bool NeedsScan => NeedsIdScan || NeedsAllRefScan || NeedsInternScan;
|
||||
public SerializableClassInfo(string ns, string cn, string ftn, List<PropInfo> p, bool isIId, string? idTypeName, bool enableRefHandling, int typeNameHash, int[] propertyNameHashes, bool enableMetadata, bool enablePropertyFilter, bool enablePolymorphDetect, bool enableInternString, bool needsIdScan, bool needsAllRefScan, bool needsInternScan)
|
||||
{ Namespace = ns; ClassName = cn; FullTypeName = ftn; Properties = p; IsIId = isIId; IdTypeName = idTypeName; EnableRefHandling = enableRefHandling; TypeNameHash = typeNameHash; PropertyNameHashes = propertyNameHashes; EnableMetadata = enableMetadata; EnablePropertyFilter = enablePropertyFilter; EnablePolymorphDetect = enablePolymorphDetect; EnableInternString = enableInternString; NeedsIdScan = needsIdScan; NeedsAllRefScan = needsAllRefScan; NeedsInternScan = needsInternScan; }
|
||||
}
|
||||
|
||||
internal sealed class PropInfo
|
||||
{
|
||||
public string Name { get; }
|
||||
public string TypeName { get; }
|
||||
/// <summary>
|
||||
/// Type name safe for typeof() — nullable ref type annotation stripped (typeof(T?) invalid for ref types).
|
||||
/// </summary>
|
||||
public string TypeNameForTypeof { get; }
|
||||
public PropertyTypeKind TypeKind { get; }
|
||||
public bool IsNullable { get; }
|
||||
/// <summary>
|
||||
/// Pre-computed interning flags matching runtime BinaryPropertyAccessorBase._interningFlags.
|
||||
/// Bit layout: bit N = eligible when StringInterningMode == N.
|
||||
/// None=0 → bit 0 never set. Attribute=1 → bit 1. All=2 → bit 2.
|
||||
/// No attr: 0b100 (4), [AcStringIntern(true)]: 0b110 (6), [AcStringIntern(false)]: 0b000 (0).
|
||||
/// </summary>
|
||||
public int InterningFlags { get; }
|
||||
|
||||
/// <summary>True when declared property type is System.Object. Runtime type dispatch needed.</summary>
|
||||
public bool IsObjectDeclaredType { get; }
|
||||
/// <summary>True if the Complex property type has [AcBinarySerializable] → has a generated writer.</summary>
|
||||
public bool HasGeneratedWriter { get; }
|
||||
/// <summary>True if the Complex property type implements IId<T> → needs ref tracking in write pass.</summary>
|
||||
public bool IsIId { get; }
|
||||
/// <summary>Generated writer class name, e.g. "SharedTag_GeneratedWriter". Only set when HasGeneratedWriter.</summary>
|
||||
public string? WriterClassName { get; }
|
||||
/// <summary>Id type name ("int", "long", "System.Guid") for IId child types. Null if not IId.</summary>
|
||||
public string? IdTypeName { get; }
|
||||
|
||||
// Collection element metadata — set when TypeKind == Collection and element type is Complex with generated writer
|
||||
/// <summary>Element type kind for collection properties. Only meaningful when TypeKind == Collection.</summary>
|
||||
public PropertyTypeKind ElementKind { get; }
|
||||
/// <summary>True if collection element type has [AcBinarySerializable].</summary>
|
||||
public bool ElementHasGeneratedWriter { get; }
|
||||
/// <summary>True if collection element type implements IId<T>.</summary>
|
||||
public bool ElementIsIId { get; }
|
||||
/// <summary>Generated writer class name for collection element type.</summary>
|
||||
public string? ElementWriterClassName { get; }
|
||||
/// <summary>Id type name for collection element IId types. Null if not IId.</summary>
|
||||
public string? ElementIdTypeName { get; }
|
||||
/// <summary>Collection type: "List", "Array", "IndexedCollection", "Counted", or null (unknown — fallback to runtime).</summary>
|
||||
public string? CollectionKind { get; }
|
||||
/// <summary>Full element type name for generated code (e.g. "SharedTag").</summary>
|
||||
public string? ElementFullTypeName { get; }
|
||||
/// <summary>Add method for Counted concrete collections. null → List<T>.Add(), "Add" → HashSet/SortedSet, "Enqueue" → Queue, "AddLast" → LinkedList.</summary>
|
||||
public string? CollectionAddMethod { get; }
|
||||
/// <summary>True if the concrete Counted collection has a capacity constructor (HashSet, Queue).</summary>
|
||||
public bool CollectionHasCapacityCtor { get; }
|
||||
|
||||
// Dictionary metadata — set when TypeKind == Dictionary
|
||||
/// <summary>Key type kind for dictionary properties.</summary>
|
||||
public PropertyTypeKind DictKeyKind { get; }
|
||||
/// <summary>Value type kind for dictionary properties.</summary>
|
||||
public PropertyTypeKind DictValueKind { get; }
|
||||
/// <summary>Key type name for generated code.</summary>
|
||||
public string? DictKeyTypeName { get; }
|
||||
/// <summary>Value type name for generated code.</summary>
|
||||
public string? DictValueTypeName { get; }
|
||||
/// <summary>True if dictionary value type has [AcBinarySerializable].</summary>
|
||||
public bool DictValueHasGeneratedWriter { get; }
|
||||
/// <summary>Generated writer class name for dictionary value type.</summary>
|
||||
public string? DictValueWriterClassName { get; }
|
||||
/// <summary>True if dictionary value type implements IId<T>.</summary>
|
||||
public bool DictValueIsIId { get; }
|
||||
/// <summary>When false, dict value type skips inline metadata.</summary>
|
||||
public bool DictValueEnableMetadata { get; }
|
||||
/// <summary>FNV-1a hash of dict value type name.</summary>
|
||||
public int DictValueTypeNameHash { get; }
|
||||
/// <summary>FNV-1a hashes of dict value type's properties.</summary>
|
||||
public int[]? DictValuePropertyHashes { get; }
|
||||
/// <summary>When true, dict value subtree has IId types needing scan.</summary>
|
||||
public bool DictValueNeedsIdScan { get; }
|
||||
/// <summary>When true, dict value subtree has non-IId ref tracking.</summary>
|
||||
public bool DictValueNeedsAllRefScan { get; }
|
||||
/// <summary>When true, dict value subtree needs string interning scan.</summary>
|
||||
public bool DictValueNeedsInternScan { get; }
|
||||
/// <summary>Derived: DictValueNeedsIdScan || DictValueNeedsAllRefScan.</summary>
|
||||
public bool DictValueNeedsRefScan => DictValueNeedsIdScan || DictValueNeedsAllRefScan;
|
||||
/// <summary>Derived: any dict value scan axis active.</summary>
|
||||
public bool DictValueNeedsScan => DictValueNeedsIdScan || DictValueNeedsAllRefScan || DictValueNeedsInternScan;
|
||||
|
||||
// UseMetadata inline hash-ek (Complex/Collection child típushoz)
|
||||
/// <summary>FNV-1a hash of child type name (Complex property). Only set when HasGeneratedWriter.</summary>
|
||||
public int ChildTypeNameHash { get; }
|
||||
/// <summary>FNV-1a hashes of child type's properties. Only set when HasGeneratedWriter.</summary>
|
||||
public int[]? ChildPropertyHashes { get; }
|
||||
/// <summary>FNV-1a hash of collection element type name. Only set when ElementHasGeneratedWriter.</summary>
|
||||
public int ElementTypeNameHash { get; }
|
||||
/// <summary>FNV-1a hashes of collection element type's properties. Only set when ElementHasGeneratedWriter.</summary>
|
||||
public int[]? ElementPropertyHashes { get; }
|
||||
/// <summary>When false, child Complex type skips inline metadata in generated code.</summary>
|
||||
public bool ChildEnableMetadata { get; }
|
||||
/// <summary>When false, collection element type skips inline metadata in generated code.</summary>
|
||||
public bool ElementEnableMetadata { get; }
|
||||
/// <summary>When true, child subtree has IId types needing scan (active in OnlyId + All).</summary>
|
||||
public bool ChildNeedsIdScan { get; }
|
||||
/// <summary>When true, child subtree has non-IId ref tracking (active only in All mode).</summary>
|
||||
public bool ChildNeedsAllRefScan { get; }
|
||||
/// <summary>When true, child subtree needs string interning scan.</summary>
|
||||
public bool ChildNeedsInternScan { get; }
|
||||
/// <summary>Derived: ChildNeedsIdScan || ChildNeedsAllRefScan.</summary>
|
||||
public bool ChildNeedsRefScan => ChildNeedsIdScan || ChildNeedsAllRefScan;
|
||||
/// <summary>Derived: any child scan axis active.</summary>
|
||||
public bool ChildNeedsScan => ChildNeedsIdScan || ChildNeedsAllRefScan || ChildNeedsInternScan;
|
||||
/// <summary>When true, element subtree has IId types needing scan (active in OnlyId + All).</summary>
|
||||
public bool ElementNeedsIdScan { get; }
|
||||
/// <summary>When true, element subtree has non-IId ref tracking (active only in All mode).</summary>
|
||||
public bool ElementNeedsAllRefScan { get; }
|
||||
/// <summary>When true, element subtree needs string interning scan.</summary>
|
||||
public bool ElementNeedsInternScan { get; }
|
||||
/// <summary>Derived: ElementNeedsIdScan || ElementNeedsAllRefScan.</summary>
|
||||
public bool ElementNeedsRefScan => ElementNeedsIdScan || ElementNeedsAllRefScan;
|
||||
/// <summary>Derived: any element scan axis active.</summary>
|
||||
public bool ElementNeedsScan => ElementNeedsIdScan || ElementNeedsAllRefScan || ElementNeedsInternScan;
|
||||
|
||||
public PropInfo(string n, string tn, string tnForTypeof, PropertyTypeKind tk, bool nullable,
|
||||
bool isObjectDeclaredType = false,
|
||||
bool? stringInternAttr = null, bool hasGeneratedWriter = false, bool isIId = false, string? writerClassName = null, string? idTypeName = null,
|
||||
PropertyTypeKind elementKind = PropertyTypeKind.Unknown, bool elementHasGenWriter = false, bool elementIsIId = false,
|
||||
string? elementWriterClassName = null, string? elementIdTypeName = null, string? collectionKind = null, string? elementFullTypeName = null,
|
||||
string? collectionAddMethod = null, bool collectionHasCapacityCtor = false,
|
||||
PropertyTypeKind dictKeyKind = PropertyTypeKind.Unknown, PropertyTypeKind dictValueKind = PropertyTypeKind.Unknown,
|
||||
string? dictKeyTypeName = null, string? dictValueTypeName = null,
|
||||
bool dictValueHasGeneratedWriter = false, string? dictValueWriterClassName = null,
|
||||
bool dictValueIsIId = false, bool dictValueEnableMetadata = true,
|
||||
int dictValueTypeNameHash = 0, int[]? dictValuePropertyHashes = null,
|
||||
bool dictValueNeedsIdScan = true, bool dictValueNeedsAllRefScan = true, bool dictValueNeedsInternScan = true,
|
||||
int childTypeNameHash = 0, int[]? childPropertyHashes = null,
|
||||
int elementTypeNameHash = 0, int[]? elementPropertyHashes = null,
|
||||
bool childEnableMetadata = true, bool elementEnableMetadata = true,
|
||||
bool childNeedsIdScan = true, bool childNeedsAllRefScan = true, bool childNeedsInternScan = true,
|
||||
bool elementNeedsIdScan = true, bool elementNeedsAllRefScan = true, bool elementNeedsInternScan = true)
|
||||
{
|
||||
Name = n;
|
||||
TypeName = tn;
|
||||
TypeNameForTypeof = tnForTypeof;
|
||||
TypeKind = tk;
|
||||
IsNullable = nullable;
|
||||
IsObjectDeclaredType = isObjectDeclaredType;
|
||||
HasGeneratedWriter = hasGeneratedWriter;
|
||||
IsIId = isIId;
|
||||
WriterClassName = writerClassName;
|
||||
IdTypeName = idTypeName;
|
||||
ElementKind = elementKind;
|
||||
ElementHasGeneratedWriter = elementHasGenWriter;
|
||||
ElementIsIId = elementIsIId;
|
||||
ElementWriterClassName = elementWriterClassName;
|
||||
ElementIdTypeName = elementIdTypeName;
|
||||
CollectionKind = collectionKind;
|
||||
ElementFullTypeName = elementFullTypeName;
|
||||
CollectionAddMethod = collectionAddMethod;
|
||||
CollectionHasCapacityCtor = collectionHasCapacityCtor;
|
||||
DictKeyKind = dictKeyKind;
|
||||
DictValueKind = dictValueKind;
|
||||
DictKeyTypeName = dictKeyTypeName;
|
||||
DictValueTypeName = dictValueTypeName;
|
||||
DictValueHasGeneratedWriter = dictValueHasGeneratedWriter;
|
||||
DictValueWriterClassName = dictValueWriterClassName;
|
||||
DictValueIsIId = dictValueIsIId;
|
||||
DictValueEnableMetadata = dictValueEnableMetadata;
|
||||
DictValueTypeNameHash = dictValueTypeNameHash;
|
||||
DictValuePropertyHashes = dictValuePropertyHashes;
|
||||
DictValueNeedsIdScan = dictValueNeedsIdScan;
|
||||
DictValueNeedsAllRefScan = dictValueNeedsAllRefScan;
|
||||
DictValueNeedsInternScan = dictValueNeedsInternScan;
|
||||
ChildTypeNameHash = childTypeNameHash;
|
||||
ChildPropertyHashes = childPropertyHashes;
|
||||
ElementTypeNameHash = elementTypeNameHash;
|
||||
ElementPropertyHashes = elementPropertyHashes;
|
||||
ChildEnableMetadata = childEnableMetadata;
|
||||
ElementEnableMetadata = elementEnableMetadata;
|
||||
ChildNeedsIdScan = childNeedsIdScan;
|
||||
ChildNeedsAllRefScan = childNeedsAllRefScan;
|
||||
ChildNeedsInternScan = childNeedsInternScan;
|
||||
ElementNeedsIdScan = elementNeedsIdScan;
|
||||
ElementNeedsAllRefScan = elementNeedsAllRefScan;
|
||||
ElementNeedsInternScan = elementNeedsInternScan;
|
||||
// Mirror runtime _interningFlags computation from BinaryPropertyAccessorBase
|
||||
int flags = 0;
|
||||
if (stringInternAttr == true) flags |= (1 << 1); // Attribute bit
|
||||
if (stringInternAttr != false) flags |= (1 << 2); // All bit
|
||||
InterningFlags = flags;
|
||||
}
|
||||
}
|
||||
|
||||
internal enum PropertyTypeKind
|
||||
{
|
||||
Unknown, String, Int32, Int64, Int16, Byte, UInt16, UInt32, UInt64,
|
||||
Boolean, Single, Double, Decimal, DateTime, DateTimeOffset, TimeSpan, Guid, Enum,
|
||||
Collection, Complex, Dictionary,
|
||||
NullableInt32, NullableInt64, NullableInt16, NullableByte, NullableUInt16, NullableUInt32, NullableUInt64,
|
||||
NullableBoolean, NullableSingle, NullableDouble, NullableDecimal, NullableDateTime,
|
||||
NullableDateTimeOffset, NullableTimeSpan, NullableGuid, NullableEnum
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Single source of truth for the compile-time decision: does the SGen-emit need a full ref-aware
|
||||
/// switch (<c>Object</c> / <c>ObjectRefFirst</c> / <c>Null</c> / <c>ObjectRef</c> / FixObj) for a
|
||||
/// given Complex property or collection element, OR can it use the zero-branch path
|
||||
/// (<c>Object</c> / <c>Null</c> / FixObj only)?
|
||||
///
|
||||
/// <para><b>Predicate semantics</b>: the decision depends EXCLUSIVELY on whether the child
|
||||
/// element subtree may emit ref markers — captured by <c>PropInfo.ChildNeedsRefScan</c> /
|
||||
/// <c>PropInfo.ElementNeedsRefScan</c>. The parent-level <c>EnableRefHandlingFeature</c> flag is
|
||||
/// <b>NOT</b> a factor here — that flag governs only the parent's SELF-tracking emit in the scan
|
||||
/// pass (<c>GenWriter.cs</c> line 140), it does NOT suppress marker dispatch for child element
|
||||
/// properties of THIS type.</para>
|
||||
///
|
||||
/// <para><b>Writer / reader symmetry</b> — invoked from BOTH sides so the compile-time decision is
|
||||
/// identical at every call site:</para>
|
||||
/// <list type="bullet">
|
||||
/// <item><c>GenReader.EmitReadComplex</c> — guards zero-branch vs full ref-aware switch.</item>
|
||||
/// <item><c>GenReader.EmitReadCollectionElement</c> — same guard for collection-element dispatch.</item>
|
||||
/// <item><c>GenReader.EmitReadDictionary</c> — same guard for dictionary-value dispatch.</item>
|
||||
/// <item><c>GenWriter.EmitDirectCollectionWrite</c> — guards <c>Object</c>-only vs
|
||||
/// <c>WriteObjectRefMarker*</c> (runtime decide) emit on the writer side.</item>
|
||||
/// </list>
|
||||
///
|
||||
/// <para><b>Why a generator-only helper, not a runtime helper</b> — the result is inlined into
|
||||
/// the generated code as either the zero-branch ag or the full-switch ag. The predicate runs
|
||||
/// once per emit-site at generation time; the runtime code has zero overhead from this abstraction
|
||||
/// (no method call, no branch on the runtime hot path).</para>
|
||||
///
|
||||
/// <para>Regression target: <c>AcBinarySerializerIIdReferenceTests.Serialize_RefMarkerCollectionElement_ParentRefHandlingFeatureOff_DriftReproduction</c>.</para>
|
||||
/// </summary>
|
||||
internal static class RefAwareEmitPredicate
|
||||
{
|
||||
/// <summary>
|
||||
/// Reader-side decision for a Complex property (<c>EmitReadComplex</c>) — does the
|
||||
/// emit need a full ref-aware switch on <c>p.ChildNeedsRefScan</c>?
|
||||
/// </summary>
|
||||
internal static bool ChildEmitsRefMarker(PropInfo p) => p.ChildNeedsRefScan;
|
||||
|
||||
/// <summary>
|
||||
/// Reader-side decision for a collection element (<c>EmitReadCollectionElement</c>) and
|
||||
/// writer-side decision for the same element (<c>EmitDirectCollectionWrite</c>) — keyed on
|
||||
/// <c>p.ElementNeedsRefScan</c>.
|
||||
/// </summary>
|
||||
internal static bool ElementEmitsRefMarker(PropInfo p) => p.ElementNeedsRefScan;
|
||||
|
||||
/// <summary>
|
||||
/// Reader-side overload for <c>EmitReadCollectionElement</c> when only the bool flag is in
|
||||
/// scope (e.g. when <c>PropInfo</c> is unrolled at the call site). Same semantics — kept as
|
||||
/// a thin overload so EVERY call site routes through this predicate, not the raw field.
|
||||
/// </summary>
|
||||
internal static bool ElementEmitsRefMarker(bool elementNeedsRefScan) => elementNeedsRefScan;
|
||||
|
||||
/// <summary>
|
||||
/// Reader-side decision for a dictionary value (<c>EmitReadDictionary</c>) — keyed on
|
||||
/// <c>p.DictValueNeedsRefScan</c>. Symmetric with the Complex / Collection-element overloads.
|
||||
/// </summary>
|
||||
internal static bool DictValueEmitsRefMarker(PropInfo p) => p.DictValueNeedsRefScan;
|
||||
}
|
||||
|
|
@ -0,0 +1,368 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using Microsoft.CodeAnalysis;
|
||||
|
||||
namespace AyCode.Core.Serializers.SourceGenerator;
|
||||
|
||||
/// <summary>
|
||||
/// Type-analysis utilities for the AcBinary source generator: kind detection, FNV-1a hashing,
|
||||
/// symbol enumeration, name flattening, and recursive scan-need computation. All methods are
|
||||
/// pure functions over Roslyn symbols (no mutable state, safe to call from any emit pass).
|
||||
/// </summary>
|
||||
public partial class AcBinarySourceGenerator
|
||||
{
|
||||
/// <summary>
|
||||
/// Returns true for property types that use markerless serialization in FastMode.
|
||||
/// These types have ExpectedTypeCode at runtime — no type marker byte, no PropertySkip for defaults.
|
||||
/// </summary>
|
||||
private static bool IsMarkerless(PropertyTypeKind k) => k switch
|
||||
{
|
||||
PropertyTypeKind.Int32 or PropertyTypeKind.Int64 or PropertyTypeKind.Int16 or
|
||||
PropertyTypeKind.Byte or PropertyTypeKind.UInt16 or PropertyTypeKind.UInt32 or PropertyTypeKind.UInt64 or
|
||||
PropertyTypeKind.Double or PropertyTypeKind.Single or PropertyTypeKind.Decimal or
|
||||
PropertyTypeKind.DateTime or PropertyTypeKind.Guid or
|
||||
PropertyTypeKind.TimeSpan or PropertyTypeKind.DateTimeOffset or
|
||||
PropertyTypeKind.Boolean or PropertyTypeKind.Enum => true,
|
||||
_ => false
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Builds a flat class name for nested types: Outer_Inner_Leaf.
|
||||
/// For top-level types returns the simple name unchanged.
|
||||
/// </summary>
|
||||
private static string BuildFlatName(INamedTypeSymbol typeSymbol)
|
||||
{
|
||||
if (typeSymbol.ContainingType == null)
|
||||
return typeSymbol.Name;
|
||||
|
||||
var parts = new List<string>();
|
||||
var current = typeSymbol;
|
||||
while (current != null)
|
||||
{
|
||||
parts.Add(current.Name);
|
||||
current = current.ContainingType;
|
||||
}
|
||||
parts.Reverse();
|
||||
return string.Join("_", parts);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reads EnableMetadataFeature from a type's [AcBinarySerializable] attribute.
|
||||
/// Returns true (default) if no attribute or enableAllFeatures=true.
|
||||
/// </summary>
|
||||
private static bool ReadEnableMetadata(ITypeSymbol type)
|
||||
{
|
||||
var attr = type.GetAttributes().FirstOrDefault(a =>
|
||||
a.AttributeClass?.ToDisplayString() == AttributeName);
|
||||
if (attr == null) return true;
|
||||
if (attr.ConstructorArguments.Length == 1)
|
||||
return (bool)attr.ConstructorArguments[0].Value!;
|
||||
if (attr.ConstructorArguments.Length == 4)
|
||||
return (bool)attr.ConstructorArguments[0].Value!;
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Computes whether a type needs scan pass work, split into ref tracking and string interning.
|
||||
/// Uses a per-call HashSet to guard against circular references (no static cache —
|
||||
/// static state is unsafe in incremental generators as it persists across builds).
|
||||
/// Returns (needsRefScan, needsInternScan) — these are independent axes.
|
||||
/// </summary>
|
||||
private static (bool needsIdScan, bool needsAllRefScan, bool needsInternScan) ComputeNeedsScan(ITypeSymbol type)
|
||||
{
|
||||
return ComputeNeedsScanCore(type, new HashSet<string>());
|
||||
}
|
||||
|
||||
private static (bool needsIdScan, bool needsAllRefScan, bool needsInternScan) ComputeNeedsScanCore(ITypeSymbol type, HashSet<string> visiting)
|
||||
{
|
||||
// Circular reference guard: if already visiting this type, assume true (safe fallback)
|
||||
var key = type.ToDisplayString();
|
||||
if (!visiting.Add(key))
|
||||
return (true, true, true);
|
||||
|
||||
// Read [AcBinarySerializable] flags
|
||||
var attr = type.GetAttributes().FirstOrDefault(a =>
|
||||
a.AttributeClass?.ToDisplayString() == AttributeName);
|
||||
|
||||
bool enableIdTracking = true, enableRefHandling = true, enableInternString = true;
|
||||
if (attr != null)
|
||||
{
|
||||
if (attr.ConstructorArguments.Length == 1)
|
||||
{
|
||||
var all = (bool)attr.ConstructorArguments[0].Value!;
|
||||
enableIdTracking = enableRefHandling = enableInternString = all;
|
||||
}
|
||||
else if (attr.ConstructorArguments.Length == 4)
|
||||
{
|
||||
enableIdTracking = (bool)attr.ConstructorArguments[1].Value!;
|
||||
enableRefHandling = (bool)attr.ConstructorArguments[2].Value!;
|
||||
enableInternString = (bool)attr.ConstructorArguments[3].Value!;
|
||||
}
|
||||
}
|
||||
|
||||
// IId tracking: active in OnlyId + All modes
|
||||
var isIId = enableIdTracking && type.AllInterfaces.Any(i =>
|
||||
i.IsGenericType && i.OriginalDefinition.ToDisplayString() == "AyCode.Core.Interfaces.IId<T>");
|
||||
var needsIdScan = isIId;
|
||||
// Non-IId ref tracking: active only in All mode
|
||||
var needsAllRefScan = !isIId && enableRefHandling;
|
||||
var needsInternScan = false;
|
||||
|
||||
// Check properties for string interning or complex children
|
||||
foreach (var p in GetAllSerializablePropertySymbols(type))
|
||||
{
|
||||
// Early exit: if all flags are already true, no need to check more properties
|
||||
if (needsIdScan && needsAllRefScan && needsInternScan) break;
|
||||
|
||||
var kind = GetKind(p.Type);
|
||||
|
||||
// String with interning?
|
||||
if (enableInternString && kind == PropertyTypeKind.String)
|
||||
{
|
||||
var internAttr = p.GetAttributes().FirstOrDefault(a =>
|
||||
a.AttributeClass?.ToDisplayString() == "AyCode.Core.Serializers.Binaries.AcStringInternAttribute");
|
||||
if (internAttr == null || (internAttr.ConstructorArguments.Length == 1 && (bool)internAttr.ConstructorArguments[0].Value!))
|
||||
needsInternScan = true;
|
||||
}
|
||||
|
||||
// Complex child → recurse
|
||||
if (kind == PropertyTypeKind.Complex)
|
||||
{
|
||||
var resolved = p.Type is INamedTypeSymbol nt ? nt.OriginalDefinition : p.Type;
|
||||
var childFlags = ComputeNeedsScanCore(resolved, visiting);
|
||||
needsIdScan |= childFlags.needsIdScan;
|
||||
needsAllRefScan |= childFlags.needsAllRefScan;
|
||||
needsInternScan |= childFlags.needsInternScan;
|
||||
}
|
||||
|
||||
// Collection → check element type
|
||||
if (kind == PropertyTypeKind.Collection)
|
||||
{
|
||||
var elemType = GetCollectionElementType(p.Type);
|
||||
if (elemType != null)
|
||||
{
|
||||
var elemKind = GetKind(elemType);
|
||||
if (enableInternString && elemKind == PropertyTypeKind.String)
|
||||
needsInternScan = true;
|
||||
if (elemKind == PropertyTypeKind.Complex)
|
||||
{
|
||||
var resolvedElem = elemType is INamedTypeSymbol ne ? ne.OriginalDefinition : elemType;
|
||||
var elemFlags = ComputeNeedsScanCore(resolvedElem, visiting);
|
||||
needsIdScan |= elemFlags.needsIdScan;
|
||||
needsAllRefScan |= elemFlags.needsAllRefScan;
|
||||
needsInternScan |= elemFlags.needsInternScan;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Dictionary → check key and value types
|
||||
if (kind == PropertyTypeKind.Dictionary)
|
||||
{
|
||||
var (keyType, valueType) = GetDictionaryKeyValueTypes(p.Type);
|
||||
if (keyType != null && enableInternString && GetKind(keyType) == PropertyTypeKind.String)
|
||||
needsInternScan = true;
|
||||
if (valueType != null)
|
||||
{
|
||||
var valKind = GetKind(valueType);
|
||||
if (enableInternString && valKind == PropertyTypeKind.String)
|
||||
needsInternScan = true;
|
||||
if (valKind == PropertyTypeKind.Complex)
|
||||
{
|
||||
var resolvedVal = valueType is INamedTypeSymbol nv ? nv.OriginalDefinition : valueType;
|
||||
var valFlags = ComputeNeedsScanCore(resolvedVal, visiting);
|
||||
needsIdScan |= valFlags.needsIdScan;
|
||||
needsAllRefScan |= valFlags.needsAllRefScan;
|
||||
needsInternScan |= valFlags.needsInternScan;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (needsIdScan, needsAllRefScan, needsInternScan);
|
||||
}
|
||||
|
||||
#region FNV-1a Hash (compile-time)
|
||||
|
||||
private static int ComputeFnvHash(string value)
|
||||
{
|
||||
uint hash = 2166136261;
|
||||
for (int i = 0; i < value.Length; i++)
|
||||
{
|
||||
hash ^= value[i];
|
||||
hash *= 16777619;
|
||||
}
|
||||
return (int)hash;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Computes FNV-1a hashes for all serializable properties of a child type.
|
||||
/// Property filtering and ordering matches runtime TypeMetadataBase exactly:
|
||||
/// derived → base, each level sorted alphabetically, with ignore attribute filtering.
|
||||
/// </summary>
|
||||
private static int[] ComputeChildPropertyHashes(ITypeSymbol resolvedType)
|
||||
{
|
||||
// Use hierarchy-walking helper — order matches runtime TypeMetadataBase
|
||||
var props = GetAllSerializablePropertySymbols(resolvedType);
|
||||
return props.Select(p => ComputeFnvHash(p.Name)).ToArray();
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
/// <summary>
|
||||
/// Collects all serializable property symbols from the full inheritance hierarchy.
|
||||
/// Order matches runtime TypeMetadataBase.GetUnfilteredProperties exactly:
|
||||
/// derived → base, each level sorted alphabetically by name.
|
||||
/// Filters: public, get+set, non-indexer, non-static, no ignore attributes.
|
||||
/// Deduplicates by name (most-derived override wins).
|
||||
/// </summary>
|
||||
private static List<IPropertySymbol> GetAllSerializablePropertySymbols(ITypeSymbol typeSymbol)
|
||||
{
|
||||
var result = new List<IPropertySymbol>();
|
||||
var seen = new HashSet<string>();
|
||||
|
||||
for (var currentType = typeSymbol as INamedTypeSymbol;
|
||||
currentType != null && currentType.SpecialType != SpecialType.System_Object;
|
||||
currentType = currentType.BaseType)
|
||||
{
|
||||
var levelProps = new List<IPropertySymbol>();
|
||||
|
||||
foreach (var member in currentType.GetMembers())
|
||||
{
|
||||
if (member is IPropertySymbol p &&
|
||||
p.DeclaredAccessibility == Accessibility.Public &&
|
||||
p.GetMethod != null && p.SetMethod != null &&
|
||||
!p.IsIndexer && !p.IsStatic &&
|
||||
seen.Add(p.Name)) // dedup: most-derived wins
|
||||
{
|
||||
var hasIgnore = p.GetAttributes().Any(a =>
|
||||
{
|
||||
var name = a.AttributeClass?.Name ?? "";
|
||||
return name == "JsonIgnoreAttribute" || name == "IgnoreMemberAttribute" || name == "BsonIgnoreAttribute";
|
||||
});
|
||||
if (hasIgnore) continue;
|
||||
|
||||
levelProps.Add(p);
|
||||
}
|
||||
}
|
||||
|
||||
// Sort each level alphabetically — matches runtime OrderBy(p => p.Name, Ordinal)
|
||||
levelProps.Sort((a, b) => string.Compare(a.Name, b.Name, StringComparison.Ordinal));
|
||||
result.AddRange(levelProps);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
#region Type analysis
|
||||
|
||||
private static bool IsNullableVT(ITypeSymbol t) =>
|
||||
t is INamedTypeSymbol n && n.IsGenericType && n.ConstructedFrom.SpecialType == SpecialType.System_Nullable_T;
|
||||
|
||||
private static PropertyTypeKind GetKind(ITypeSymbol type)
|
||||
{
|
||||
if (type is INamedTypeSymbol n && n.IsGenericType && n.ConstructedFrom.SpecialType == SpecialType.System_Nullable_T)
|
||||
return GetKindCore(n.TypeArguments[0], true);
|
||||
return GetKindCore(type, false);
|
||||
}
|
||||
|
||||
private static PropertyTypeKind GetKindCore(ITypeSymbol type, bool nullable)
|
||||
{
|
||||
switch (type.SpecialType)
|
||||
{
|
||||
case SpecialType.System_String: return PropertyTypeKind.String;
|
||||
case SpecialType.System_Int32: return nullable ? PropertyTypeKind.NullableInt32 : PropertyTypeKind.Int32;
|
||||
case SpecialType.System_Int64: return nullable ? PropertyTypeKind.NullableInt64 : PropertyTypeKind.Int64;
|
||||
case SpecialType.System_Int16: return nullable ? PropertyTypeKind.NullableInt16 : PropertyTypeKind.Int16;
|
||||
case SpecialType.System_Byte: return nullable ? PropertyTypeKind.NullableByte : PropertyTypeKind.Byte;
|
||||
case SpecialType.System_UInt16: return nullable ? PropertyTypeKind.NullableUInt16 : PropertyTypeKind.UInt16;
|
||||
case SpecialType.System_UInt32: return nullable ? PropertyTypeKind.NullableUInt32 : PropertyTypeKind.UInt32;
|
||||
case SpecialType.System_UInt64: return nullable ? PropertyTypeKind.NullableUInt64 : PropertyTypeKind.UInt64;
|
||||
case SpecialType.System_Boolean: return nullable ? PropertyTypeKind.NullableBoolean : PropertyTypeKind.Boolean;
|
||||
case SpecialType.System_Single: return nullable ? PropertyTypeKind.NullableSingle : PropertyTypeKind.Single;
|
||||
case SpecialType.System_Double: return nullable ? PropertyTypeKind.NullableDouble : PropertyTypeKind.Double;
|
||||
case SpecialType.System_Decimal: return nullable ? PropertyTypeKind.NullableDecimal : PropertyTypeKind.Decimal;
|
||||
case SpecialType.System_DateTime: return nullable ? PropertyTypeKind.NullableDateTime : PropertyTypeKind.DateTime;
|
||||
default: break;
|
||||
}
|
||||
var fn = type.ToDisplayString();
|
||||
if (fn == "System.Guid") return nullable ? PropertyTypeKind.NullableGuid : PropertyTypeKind.Guid;
|
||||
if (fn == "System.TimeSpan") return nullable ? PropertyTypeKind.NullableTimeSpan : PropertyTypeKind.TimeSpan;
|
||||
if (fn == "System.DateTimeOffset") return nullable ? PropertyTypeKind.NullableDateTimeOffset : PropertyTypeKind.DateTimeOffset;
|
||||
if (type.TypeKind == TypeKind.Enum) return nullable ? PropertyTypeKind.NullableEnum : PropertyTypeKind.Enum;
|
||||
if (type is IArrayTypeSymbol) return PropertyTypeKind.Collection;
|
||||
// Dictionary detection: must come before IEnumerable<T> (Dictionary implements both)
|
||||
if (type is INamedTypeSymbol dictNt && dictNt.IsGenericType)
|
||||
{
|
||||
var orig = dictNt.OriginalDefinition.ToDisplayString();
|
||||
if (orig == "System.Collections.Generic.IDictionary<TKey, TValue>" ||
|
||||
orig == "System.Collections.Generic.Dictionary<TKey, TValue>" ||
|
||||
dictNt.AllInterfaces.Any(ifc => ifc.OriginalDefinition.ToDisplayString() == "System.Collections.Generic.IDictionary<TKey, TValue>"))
|
||||
return PropertyTypeKind.Dictionary;
|
||||
}
|
||||
if (type is INamedTypeSymbol nt && nt.AllInterfaces.Any(iface => iface.OriginalDefinition.SpecialType == SpecialType.System_Collections_Generic_IEnumerable_T))
|
||||
return PropertyTypeKind.Collection;
|
||||
if (type.TypeKind == TypeKind.Class || type.TypeKind == TypeKind.Struct) return PropertyTypeKind.Complex;
|
||||
return PropertyTypeKind.Unknown;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Extracts the element type T from List<T>, T[], IList<T>, IEnumerable<T>.
|
||||
/// Returns null if the element type cannot be determined.
|
||||
/// </summary>
|
||||
private static ITypeSymbol? GetCollectionElementType(ITypeSymbol type)
|
||||
{
|
||||
// T[] → element type
|
||||
if (type is IArrayTypeSymbol arrayType)
|
||||
return arrayType.ElementType;
|
||||
|
||||
// Generic collections: List<T>, IList<T>, ICollection<T>, IEnumerable<T>
|
||||
if (type is INamedTypeSymbol namedType && namedType.IsGenericType)
|
||||
{
|
||||
// Direct: List<T>, HashSet<T>, etc. — first type argument
|
||||
var iface = namedType.AllInterfaces
|
||||
.FirstOrDefault(i => i.OriginalDefinition.SpecialType == SpecialType.System_Collections_Generic_IEnumerable_T);
|
||||
if (iface != null)
|
||||
return iface.TypeArguments[0];
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Extracts key and value types from Dictionary<K,V> or IDictionary<K,V>.
|
||||
/// </summary>
|
||||
private static (ITypeSymbol? keyType, ITypeSymbol? valueType) GetDictionaryKeyValueTypes(ITypeSymbol type)
|
||||
{
|
||||
if (type is INamedTypeSymbol nt && nt.IsGenericType)
|
||||
{
|
||||
var orig = nt.OriginalDefinition.ToDisplayString();
|
||||
if (orig == "System.Collections.Generic.Dictionary<TKey, TValue>" ||
|
||||
orig == "System.Collections.Generic.IDictionary<TKey, TValue>")
|
||||
return (nt.TypeArguments[0], nt.TypeArguments[1]);
|
||||
|
||||
var iface = nt.AllInterfaces.FirstOrDefault(i =>
|
||||
i.OriginalDefinition.ToDisplayString() == "System.Collections.Generic.IDictionary<TKey, TValue>");
|
||||
if (iface != null)
|
||||
return (iface.TypeArguments[0], iface.TypeArguments[1]);
|
||||
}
|
||||
return (null, null);
|
||||
}
|
||||
|
||||
private static bool IsNullableVTKind(PropertyTypeKind k) => k >= PropertyTypeKind.NullableInt32;
|
||||
|
||||
private static PropertyTypeKind Underlying(PropertyTypeKind k) => k switch
|
||||
{
|
||||
PropertyTypeKind.NullableInt32 => PropertyTypeKind.Int32, PropertyTypeKind.NullableInt64 => PropertyTypeKind.Int64,
|
||||
PropertyTypeKind.NullableInt16 => PropertyTypeKind.Int16, PropertyTypeKind.NullableByte => PropertyTypeKind.Byte,
|
||||
PropertyTypeKind.NullableUInt16 => PropertyTypeKind.UInt16, PropertyTypeKind.NullableUInt32 => PropertyTypeKind.UInt32,
|
||||
PropertyTypeKind.NullableUInt64 => PropertyTypeKind.UInt64, PropertyTypeKind.NullableBoolean => PropertyTypeKind.Boolean,
|
||||
PropertyTypeKind.NullableSingle => PropertyTypeKind.Single, PropertyTypeKind.NullableDouble => PropertyTypeKind.Double,
|
||||
PropertyTypeKind.NullableDecimal => PropertyTypeKind.Decimal, PropertyTypeKind.NullableDateTime => PropertyTypeKind.DateTime,
|
||||
PropertyTypeKind.NullableDateTimeOffset => PropertyTypeKind.DateTimeOffset, PropertyTypeKind.NullableTimeSpan => PropertyTypeKind.TimeSpan,
|
||||
PropertyTypeKind.NullableGuid => PropertyTypeKind.Guid, PropertyTypeKind.NullableEnum => PropertyTypeKind.Enum,
|
||||
_ => PropertyTypeKind.Unknown
|
||||
};
|
||||
|
||||
#endregion
|
||||
}
|
||||
|
|
@ -0,0 +1,79 @@
|
|||
using System.Collections.Immutable;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using Microsoft.CodeAnalysis;
|
||||
using Microsoft.CodeAnalysis.CSharp.Syntax;
|
||||
using Microsoft.CodeAnalysis.Text;
|
||||
|
||||
namespace AyCode.Core.Serializers.SourceGenerator;
|
||||
|
||||
/// <summary>
|
||||
/// Incremental source generator for <c>[AcBinarySerializable]</c> types. Emits an
|
||||
/// <c>IGeneratedBinaryWriter</c> + <c>IGeneratedBinaryReader</c> implementation for every annotated
|
||||
/// type, plus a <c>[ModuleInitializer]</c>-based registry hook that wires the generated instances
|
||||
/// into the runtime serializer at startup.
|
||||
///
|
||||
/// <para><b>Source organization</b> — this generator class is split across multiple partial files
|
||||
/// for navigational clarity:</para>
|
||||
/// <list type="bullet">
|
||||
/// <item><c>AcBinarySourceGenerator.cs</c> (this file) — entry point: <c>[Generator]</c> attribute,
|
||||
/// <c>Initialize</c> + <c>Execute</c> orchestration.</item>
|
||||
/// <item><c>AcBinarySourceGenerator.Models.cs</c> — non-partial model types
|
||||
/// (<c>SerializableClassInfo</c>, <c>PropInfo</c>, <c>PropertyTypeKind</c>).</item>
|
||||
/// <item><c>AcBinarySourceGenerator.TypeAnalysis.cs</c> — Roslyn-symbol utility passes
|
||||
/// (kind detection, FNV hashing, name flattening, scan-need recursion).</item>
|
||||
/// <item><c>AcBinarySourceGenerator.Diagnostics.cs</c> — ACBIN001 (cycle warning) + ACBIN002
|
||||
/// (polymorph misuse error) descriptors and detection methods.</item>
|
||||
/// <item><c>AcBinarySourceGenerator.GetClassInfo.cs</c> — class-info extraction pass (attribute
|
||||
/// flags + property metadata building).</item>
|
||||
/// <item><c>AcBinarySourceGenerator.GenWriter.cs</c> — writer-side emit (WriteProperties, ScanObject,
|
||||
/// ScanForDuplicates, per-property emit helpers).</item>
|
||||
/// <item><c>AcBinarySourceGenerator.GenReader.cs</c> — reader-side emit (ReadProperties, ReadObject,
|
||||
/// per-property read helpers).</item>
|
||||
/// <item><c>AcBinarySourceGenerator.GenInit.cs</c> — ModuleInitializer-based registry hook emit.</item>
|
||||
/// </list>
|
||||
/// </summary>
|
||||
[Generator]
|
||||
public partial class AcBinarySourceGenerator : IIncrementalGenerator
|
||||
{
|
||||
private const string AttributeName = "AyCode.Core.Serializers.Attributes.AcBinarySerializableAttribute";
|
||||
|
||||
// Feature gates on the SGen-emitted writer / scan code are driven by `[AcBinarySerializable]`
|
||||
// attribute flags. Two such gates are wired through SerializableClassInfo:
|
||||
// • EnablePropertyFilter → omits the per-property `HasPropertyFilter` branch when false.
|
||||
// • EnablePolymorphDetect → omits the `ObjectWithTypeName + AQN` prefix on `System.Object`-
|
||||
// declared properties when false (then ACBIN002 guards misuse).
|
||||
// • EnableInternString → omits StringInterned* case-emit in the reader switch when false.
|
||||
// All default `true`; opt-out is per type via the attribute ctor parameters.
|
||||
|
||||
public void Initialize(IncrementalGeneratorInitializationContext context)
|
||||
{
|
||||
var classDeclarations = context.SyntaxProvider
|
||||
.ForAttributeWithMetadataName(
|
||||
AttributeName,
|
||||
predicate: static (node, _) => node is ClassDeclarationSyntax || node is StructDeclarationSyntax,
|
||||
transform: static (ctx, _) => GetClassInfo(ctx))
|
||||
.Where(static info => info != null);
|
||||
|
||||
context.RegisterSourceOutput(classDeclarations.Collect(),
|
||||
static (spc, classes) => Execute(classes!, spc));
|
||||
}
|
||||
|
||||
private static void Execute(ImmutableArray<SerializableClassInfo?> classes, SourceProductionContext context)
|
||||
{
|
||||
if (classes.IsDefaultOrEmpty) return;
|
||||
var valid = classes.Where(c => c != null).Cast<SerializableClassInfo>().ToList();
|
||||
if (valid.Count == 0) return;
|
||||
|
||||
DetectAndReportCycles(valid, context);
|
||||
DetectAndReportPolymorphicMisuse(valid, context);
|
||||
|
||||
foreach (var ci in valid)
|
||||
{
|
||||
context.AddSource($"{ci.ClassName}_GeneratedWriter.g.cs", SourceText.From(GenWriter(ci), Encoding.UTF8));
|
||||
context.AddSource($"{ci.ClassName}_GeneratedReader.g.cs", SourceText.From(GenReader(ci), Encoding.UTF8));
|
||||
}
|
||||
|
||||
context.AddSource("AcBinaryGeneratedWriters_Init.g.cs", SourceText.From(GenInit(valid), Encoding.UTF8));
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,15 @@
|
|||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>netstandard2.0</TargetFramework>
|
||||
<LangVersion>latest</LangVersion>
|
||||
<EnforceExtendedAnalyzerRules>true</EnforceExtendedAnalyzerRules>
|
||||
<IsRoslynComponent>true</IsRoslynComponent>
|
||||
</PropertyGroup>
|
||||
|
||||
<Import Project="..\AyCode.Core.targets" />
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.CodeAnalysis.CSharp" Version="4.8.0" PrivateAssets="all" />
|
||||
<PackageReference Include="Microsoft.CodeAnalysis.Analyzers" Version="3.3.4" PrivateAssets="all" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
|
@ -0,0 +1,39 @@
|
|||
# AyCode.Core.Serializers.SourceGenerator
|
||||
|
||||
Roslyn incremental source generator that produces optimized `IGeneratedBinaryWriter` and `IGeneratedBinaryReader` implementations for types marked with `[AcBinarySerializable]`. Eliminates runtime reflection on serialization hot paths.
|
||||
|
||||
Targets **netstandard2.0** (required for Roslyn analyzers/generators).
|
||||
|
||||
## Key Files
|
||||
|
||||
- **`AcBinarySourceGenerator.cs`** — Single-file `IIncrementalGenerator` (~2100 lines). Generates:
|
||||
- `{ClassName}_GeneratedWriter` — Per-type writer with `ScanObject()` + `WriteProperties()` methods. Handles primitives, strings (with interning), collections, dictionaries, complex nested types, and polymorphic objects.
|
||||
- `{ClassName}_GeneratedReader` — Per-type reader with `ReadProperties()` method.
|
||||
- `ModuleInitializer` — Auto-registers all generated writers/readers at startup.
|
||||
- Circular reference detection with `ACBIN001` diagnostic warning.
|
||||
|
||||
## Slot Allocation
|
||||
|
||||
Each generated writer reserves a unique type slot via `AcBinarySerializer.AllocateWrapperSlot()` (static field initializer, `Interlocked.Increment`).
|
||||
|
||||
- **Slots 0–63** — reserved for runtime polymorphic types (assigned dynamically on first encounter)
|
||||
- **Slots 64+** — source-generated types (allocated at `[ModuleInitializer]` registration time)
|
||||
|
||||
**Slot indices are NOT stable across compilations.** The order depends on Roslyn's `ForAttributeWithMetadataName()` enumeration order, which may vary between builds. This is fine because slots are only meaningful within a single serialization/deserialization session — they are never persisted to disk or sent over the wire as slot indices (the wire format uses type names or metadata hashes for cross-session/cross-type compatibility).
|
||||
|
||||
## Feature Flags
|
||||
|
||||
The `[AcBinarySerializable]` attribute supports per-type feature control:
|
||||
- `enableMetadata` — Property hash metadata for cross-type deserialization
|
||||
- `enableIdTracking` — IId-based reference tracking
|
||||
- `enableRefHandling` — General reference tracking
|
||||
- `enableInternString` — String interning/deduplication
|
||||
|
||||
Disabled features eliminate corresponding code blocks from generated output (zero dead code).
|
||||
|
||||
## Dependencies
|
||||
|
||||
| Dependency | Purpose |
|
||||
|---|---|
|
||||
| `Microsoft.CodeAnalysis.CSharp` | Roslyn syntax/semantic APIs |
|
||||
| `Microsoft.CodeAnalysis.Analyzers` | Analyzer best practices |
|
||||
|
|
@ -7,10 +7,17 @@
|
|||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="MessagePack" Version="3.1.4" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="9.0.11" />
|
||||
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\AyCode.Core\AyCode.Core.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<None Include="docs\**\*.md" />
|
||||
<None Include="**\README.md" Exclude="$(DefaultItemExcludes);docs\**" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,9 @@
|
|||
# Loggers
|
||||
|
||||
Server-side singleton logger for static access across the application.
|
||||
|
||||
> For full logging architecture see `docs/LOGGING/README.md`. For core logger and writer abstractions see `AyCode.Core/Loggers/README.md`.
|
||||
|
||||
## Key Files
|
||||
|
||||
- **`GlobalLogger.cs`** — Singleton static wrapper around an internal `AcGlobalLoggerBase` (sealed `AcLoggerBase` subclass). Provides static methods for all log levels (`Detail`, `Debug`, `Info`, `Warning`, `Suggest`, `Error`, `Write`). Default category: `"GLOBAL_LOGGER"`. Reads config from `appsettings.json` like any `AcLoggerBase`. Exposes `GetWriters` and `Writer<T>()` for accessing specific writer instances.
|
||||
|
|
@ -0,0 +1,33 @@
|
|||
# AyCode.Core.Server
|
||||
|
||||
@project {
|
||||
type = "framework"
|
||||
}
|
||||
|
||||
Server-side extension of AyCode.Core. Provides server-specific implementations that build on the shared core library.
|
||||
|
||||
## Documentation
|
||||
|
||||
| Document | Topic |
|
||||
|---|---|
|
||||
| `LOGGING/README.md` | GlobalLogger singleton, server-side logging |
|
||||
|
||||
## Folder Structure
|
||||
|
||||
| Folder | Purpose |
|
||||
|---|---|
|
||||
| `Loggers/` | Server-side global logger singleton |
|
||||
|
||||
## Key Files
|
||||
|
||||
### Loggers/
|
||||
- **`GlobalLogger.cs`** — Static singleton facade for server-side logging. Wraps `AcGlobalLoggerBase` (sealed, derives from `AcLoggerBase`). Provides static methods (`Detail`, `Debug`, `Info`, `Warning`, `Suggest`, `Error`, `Write`) with `[CallerMemberName]` support. Default category: `"GLOBAL_LOGGER"`.
|
||||
|
||||
## Dependencies
|
||||
|
||||
| Dependency | Purpose |
|
||||
|---|---|
|
||||
| `AyCode.Core` | Core library (loggers, enums, serializers) |
|
||||
| `MessagePack` | MessagePack serialization |
|
||||
| `Newtonsoft.Json` | JSON serialization |
|
||||
| `Microsoft.Extensions.Logging.Abstractions` | Logging abstractions |
|
||||
|
|
@ -0,0 +1,23 @@
|
|||
# Server Logging
|
||||
|
||||
Server-side logging extensions. Core framework (base classes, config, LogLevel, ILogger bridge): `AyCode.Core/AyCode.Core/docs/LOGGING/README.md` | Remote writers (HTTP, browser, SignalR): `AyCode.Services/docs/LOGGING/README.md`.
|
||||
|
||||
## GlobalLogger
|
||||
|
||||
Server-side singleton for static access. Wraps an internal `AcGlobalLoggerBase` instance (sealed `AcLoggerBase` subclass):
|
||||
|
||||
```csharp
|
||||
GlobalLogger.Info("Server started");
|
||||
GlobalLogger.Error("Failed to process", ex, "MyCategory");
|
||||
GlobalLogger.Writer<IAcConsoleLogWriter>()?.Suggest("hint");
|
||||
```
|
||||
|
||||
Default category: `"GLOBAL_LOGGER"`. Reads config from `appsettings.json` like any other `AcLoggerBase` instance.
|
||||
|
||||
All static methods mirror the `IAcLogWriterBase` contract: `Detail`, `Debug`, `Info`, `Warning`, `Suggest`, `Error`, `Write`.
|
||||
|
||||
## Key Source Files
|
||||
|
||||
| Component | Path |
|
||||
|-----------|------|
|
||||
| GlobalLogger | `Loggers/GlobalLogger.cs` |
|
||||
|
|
@ -0,0 +1,16 @@
|
|||
# AyCode.Core.Server documentation
|
||||
|
||||
Topic docs for the `AyCode.Core.Server` project (Layer 0, server-side).
|
||||
|
||||
## Topics
|
||||
|
||||
- [`LOGGING/`](LOGGING/README.md) — Server-side logger (variant of AyCode.Core's base logger)
|
||||
|
||||
## Navigation
|
||||
|
||||
Per the folder-navigation rule, start here when browsing `docs/`. Each topic folder has its own `README.md` (main content) + optional `TOPIC_ISSUES.md` and `TOPIC_TODO.md`.
|
||||
|
||||
## See also
|
||||
|
||||
- **Base logger** (framework): `../../AyCode.Core/AyCode.Core/docs/LOGGING/README.md`
|
||||
- **Remote logger** (AyCode.Services variant): `../../AyCode.Services/docs/LOGGING/README.md`
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<IsPackable>false</IsPackable>
|
||||
|
|
@ -8,15 +8,15 @@
|
|||
<Import Project="..//AyCode.Core.targets" />
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="coverlet.collector" Version="6.0.4">
|
||||
<PackageReference Include="coverlet.collector" Version="8.0.1">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.EnvironmentVariables" Version="9.0.8" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="9.0.8" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
|
||||
<PackageReference Include="MSTest.TestAdapter" Version="3.10.3" />
|
||||
<PackageReference Include="MSTest.TestFramework" Version="3.10.3" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.EnvironmentVariables" Version="9.0.11" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="9.0.11" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.3.0" />
|
||||
<PackageReference Include="MSTest.TestAdapter" Version="4.1.0" />
|
||||
<PackageReference Include="MSTest.TestFramework" Version="4.1.0" />
|
||||
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
|
||||
</ItemGroup>
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,18 @@
|
|||
# Entities
|
||||
|
||||
Concrete entity implementations inheriting from AyCode.Entities abstract generics. Used by database integration tests.
|
||||
|
||||
## Key Files
|
||||
|
||||
- **`User.cs`** — `AcUser<Profile, Company, UserToCompany, Address>`.
|
||||
- **`Company.cs`** — `AcCompany<User, UserToCompany, Profile, Address>`.
|
||||
- **`UserToCompany.cs`** — `AcUserToCompany<User, Company>` junction entity.
|
||||
- **`Profile.cs`** — `AcProfile<Address>`.
|
||||
- **`Address.cs`** — `AcAddress` + `IAcAddressDtoBase` with DTO support.
|
||||
- **`UserToken.cs`** — `AcUserTokenBase` authentication token.
|
||||
- **`EmailMessage.cs`** — `AcEmailMessage<EmailRecipient>`.
|
||||
- **`EmailRecipient.cs`** — `AcEmailRecipient<EmailMessage>`.
|
||||
|
||||
## Relationships
|
||||
|
||||
User ↔ Company (many-to-many via UserToCompany), User → Profile → Address (one-to-one chain), EmailMessage → EmailRecipient (one-to-many).
|
||||
|
|
@ -0,0 +1,17 @@
|
|||
# AyCode.Core.Tests.Internal
|
||||
|
||||
Concrete entity implementations for database integration testing. Exposes types to `AyCode.Database.Tests*` via `[InternalsVisibleTo]`.
|
||||
|
||||
## Folder Structure
|
||||
|
||||
| Folder | Purpose |
|
||||
|---|---|
|
||||
| [`Entities/`](Entities/README.md) | Concrete entity implementations (User, Company, Profile, Address, etc.) |
|
||||
|
||||
## Dependencies
|
||||
|
||||
| Dependency | Purpose |
|
||||
|---|---|
|
||||
| `MSTest` | Test framework |
|
||||
| `AyCode.Core.Tests` | Shared test utilities |
|
||||
| `AyCode.Entities` / `AyCode.Entities.Server` | Abstract entity base classes |
|
||||
|
|
@ -1,27 +1,35 @@
|
|||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<IsPackable>false</IsPackable>
|
||||
<IsTestProject>true</IsTestProject>
|
||||
<!-- Enable generated files output for debugging -->
|
||||
<EmitCompilerGeneratedFiles>true</EmitCompilerGeneratedFiles>
|
||||
<CompilerGeneratedFilesOutputPath>$(BaseIntermediateOutputPath)Generated</CompilerGeneratedFilesOutputPath>
|
||||
</PropertyGroup>
|
||||
|
||||
<Import Project="..//AyCode.Core.targets" />
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.EnvironmentVariables" Version="9.0.8" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="9.0.8" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
|
||||
<PackageReference Include="MSTest.TestAdapter" Version="3.10.3" />
|
||||
<PackageReference Include="MSTest.TestFramework" Version="3.10.3" />
|
||||
<PackageReference Include="coverlet.collector" Version="6.0.4">
|
||||
<PackageReference Include="MemoryPack" Version="1.21.4" />
|
||||
<PackageReference Include="MessagePack" Version="3.1.4" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.EnvironmentVariables" Version="9.0.11" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="9.0.11" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.3.0" />
|
||||
<PackageReference Include="MongoDB.Bson" Version="3.5.2" />
|
||||
<PackageReference Include="MSTest.TestAdapter" Version="4.1.0" />
|
||||
<PackageReference Include="MSTest.TestFramework" Version="4.1.0" />
|
||||
<PackageReference Include="coverlet.collector" Version="8.0.1">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\AyCode.Core.Server\AyCode.Core.Server.csproj" />
|
||||
<ProjectReference Include="..\AyCode.Core\AyCode.Core.csproj" />
|
||||
<ProjectReference Include="..\AyCode.Core.Serializers.SourceGenerator\AyCode.Core.Serializers.SourceGenerator.csproj" OutputItemType="Analyzer" ReferenceOutputAssembly="false" />
|
||||
<ProjectReference Include="..\AyCode.Entities.Server\AyCode.Entities.Server.csproj" />
|
||||
<ProjectReference Include="..\AyCode.Entities\AyCode.Entities.csproj" />
|
||||
<ProjectReference Include="..\AyCode.Interfaces.Server\AyCode.Interfaces.Server.csproj" />
|
||||
|
|
|
|||
|
|
@ -0,0 +1,52 @@
|
|||
using System.Buffers;
|
||||
using System.Text;
|
||||
using AyCode.Core.Compression;
|
||||
|
||||
namespace AyCode.Core.Tests.Compression;
|
||||
|
||||
[TestClass]
|
||||
public class GzipHelperTests
|
||||
{
|
||||
[TestMethod]
|
||||
public void CompressAndDecompress_StringRoundTrip_Succeeds()
|
||||
{
|
||||
var original = "SignalR payload for gzip";
|
||||
|
||||
var compressed = GzipHelper.Compress(original);
|
||||
var decompressed = GzipHelper.DecompressToString(compressed);
|
||||
|
||||
Assert.IsNotNull(compressed);
|
||||
Assert.AreNotEqual(0, compressed.Length);
|
||||
Assert.AreEqual(original, decompressed);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void DecompressToRentedBuffer_ReturnsOriginalBytes()
|
||||
{
|
||||
var payload = "{\"message\":\"gzip\"}";
|
||||
var compressed = GzipHelper.Compress(payload);
|
||||
|
||||
var (buffer, length) = GzipHelper.DecompressToRentedBuffer(compressed);
|
||||
try
|
||||
{
|
||||
Assert.IsTrue(length > 0);
|
||||
var text = Encoding.UTF8.GetString(buffer, 0, length);
|
||||
Assert.AreEqual(payload, text);
|
||||
}
|
||||
finally
|
||||
{
|
||||
ArrayPool<byte>.Shared.Return(buffer);
|
||||
}
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void IsGzipCompressed_ReturnsExpectedValues()
|
||||
{
|
||||
var compressed = GzipHelper.Compress("ping");
|
||||
var nonCompressed = Encoding.UTF8.GetBytes("plain text");
|
||||
|
||||
Assert.IsTrue(GzipHelper.IsGzipCompressed(compressed));
|
||||
Assert.IsFalse(GzipHelper.IsGzipCompressed(nonCompressed));
|
||||
Assert.IsFalse(GzipHelper.IsGzipCompressed(Array.Empty<byte>()));
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
# Compression Tests
|
||||
|
||||
GZip compression utility tests.
|
||||
|
||||
## Key Files
|
||||
|
||||
- **`GzipHelperTests.cs`** — Tests GzipHelper.Compress(), DecompressToString(), DecompressToRentedBuffer() (ArrayPool), IsGzipCompressed() (magic byte detection).
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
# GeneratedWriters
|
||||
|
||||
Hand-written examples of the code pattern that the AcBinarySerializable source generator produces.
|
||||
|
||||
## Key Files
|
||||
|
||||
- **`TestOrderWriter.cs`** — Example IGeneratedBinaryWriter: direct property access (no reflection), alphabetical order, value types inline, complex types delegate to runtime. Demonstrates ICache-friendly pattern (~500B vs 27KB runtime).
|
||||
|
|
@ -0,0 +1,114 @@
|
|||
using System.Runtime.CompilerServices;
|
||||
using AyCode.Core.Serializers.Binaries;
|
||||
using AyCode.Core.Tests.TestModels;
|
||||
|
||||
namespace AyCode.Core.Tests.GeneratedWriters;
|
||||
|
||||
/// <summary>
|
||||
/// Hand-written generated binary writer for TestOrder_All_True.
|
||||
/// Demonstrates the pattern that the source generator will produce.
|
||||
///
|
||||
/// Bypasses the runtime switch/delegate property loop:
|
||||
/// - Direct obj.Property access instead of Func<>.Invoke()
|
||||
/// - No switch dispatch per property
|
||||
/// - No boxing for value types
|
||||
/// - Small method (~500B native) vs 27KB WriteObject — better ICache
|
||||
///
|
||||
/// Properties are written in alphabetical order to match the runtime serializer.
|
||||
/// Complex/Collection properties fall back to the runtime serializer via WriteValue.
|
||||
/// </summary>
|
||||
internal sealed class TestOrderWriter : IGeneratedBinaryWriter
|
||||
{
|
||||
internal static readonly TestOrderWriter Instance = new();
|
||||
|
||||
public void WriteProperties<TOutput>(object value,
|
||||
AcBinarySerializer.BinarySerializationContext<TOutput> context)
|
||||
where TOutput : struct, IBinaryOutputBase
|
||||
{
|
||||
var obj = Unsafe.As<TestOrder_All_True>(value);
|
||||
|
||||
// Properties in alphabetical order (matching runtime serializer):
|
||||
|
||||
// AuditMetadata: MetadataInfo_All_True? (complex, nullable)
|
||||
WriteComplexOrNull(obj.AuditMetadata, context);
|
||||
|
||||
// Category: SharedCategory_All_True? (complex, nullable)
|
||||
WriteComplexOrNull(obj.Category, context);
|
||||
|
||||
// CreatedAt: DateTime (markerless)
|
||||
context.WriteDateTimeBits(obj.CreatedAt);
|
||||
|
||||
// Id: int (markerless)
|
||||
context.WriteVarInt(obj.Id);
|
||||
|
||||
// Items: List<TestOrderItem_All_True> (collection)
|
||||
WriteComplexOrNull(obj.Items, context);
|
||||
|
||||
// MetadataList: List<MetadataInfo_All_True> (collection)
|
||||
WriteComplexOrNull(obj.MetadataList, context);
|
||||
|
||||
// NoMergeItems: List<TestOrderItem_All_True> (collection)
|
||||
WriteComplexOrNull(obj.NoMergeItems, context);
|
||||
|
||||
// OrderMetadata: MetadataInfo_All_True? (complex, nullable)
|
||||
WriteComplexOrNull(obj.OrderMetadata, context);
|
||||
|
||||
// OrderNumber: string
|
||||
AcBinarySerializer.WriteStringGenerated(obj.OrderNumber, context);
|
||||
|
||||
// Owner: SharedUser? (complex, nullable)
|
||||
WriteComplexOrNull(obj.Owner, context);
|
||||
|
||||
// PaidDateUtc: DateTime? (nullable)
|
||||
var paidDate = obj.PaidDateUtc;
|
||||
if (paidDate.HasValue)
|
||||
{
|
||||
context.WriteByte(BinaryTypeCode.DateTime);
|
||||
context.WriteDateTimeBits(paidDate.Value);
|
||||
}
|
||||
else
|
||||
{
|
||||
context.WriteByte(BinaryTypeCode.Null);
|
||||
}
|
||||
|
||||
// PrimaryTag: SharedTag_All_True? (complex, nullable)
|
||||
WriteComplexOrNull(obj.PrimaryTag, context);
|
||||
|
||||
// SecondaryTag: SharedTag_All_True? (complex, nullable)
|
||||
WriteComplexOrNull(obj.SecondaryTag, context);
|
||||
|
||||
// Status: TestStatus (enum, markerless)
|
||||
context.WriteVarInt((int)obj.Status);
|
||||
|
||||
// Tags: List<SharedTag_All_True> (collection)
|
||||
WriteComplexOrNull(obj.Tags, context);
|
||||
|
||||
// TotalAmount: decimal (markerless)
|
||||
context.WriteDecimalBits(obj.TotalAmount);
|
||||
}
|
||||
|
||||
public void ScanObject<TOutput>(object value, AcBinarySerializer.BinarySerializationContext<TOutput> context) where TOutput : struct, IBinaryOutputBase
|
||||
{
|
||||
throw new NotImplementedException();
|
||||
}
|
||||
|
||||
public void ScanForDuplicates<TOutput>(object value, AcBinarySerializer.BinarySerializationContext<TOutput> context) where TOutput : struct, IBinaryOutputBase
|
||||
{
|
||||
if (!context.HasCaching) return;
|
||||
ScanObject(value, context);
|
||||
context.SortWritePlan();
|
||||
}
|
||||
|
||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||
private static void WriteComplexOrNull<TOutput>(object? value, AcBinarySerializer.BinarySerializationContext<TOutput> context)
|
||||
where TOutput : struct, IBinaryOutputBase
|
||||
{
|
||||
if (value == null)
|
||||
{
|
||||
context.WriteByte(BinaryTypeCode.PropertySkip);
|
||||
return;
|
||||
}
|
||||
|
||||
AcBinarySerializer.WriteValueGenerated(value, value.GetType(), context);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,27 @@
|
|||
# AyCode.Core.Tests
|
||||
|
||||
MSTest unit tests for AyCode.Core serialization, compression, and utilities. Covers binary/JSON round-trips, reference handling, nullable types, source generator integration, and performance benchmarks.
|
||||
|
||||
## Folder Structure
|
||||
|
||||
| Folder | Purpose |
|
||||
|---|---|
|
||||
| [`Serialization/`](Serialization/README.md) | Binary and JSON serialization tests (20+ test classes) |
|
||||
| [`Compression/`](Compression/README.md) | GZip compression tests |
|
||||
| [`TestModels/`](TestModels/README.md) | Shared test entities, enums, data factories, SignalR infrastructure |
|
||||
| [`GeneratedWriters/`](GeneratedWriters/README.md) | Hand-written source generator output examples |
|
||||
|
||||
## Key Files (Root)
|
||||
|
||||
- **`GlobalUsings.cs`** — Global MSTest using.
|
||||
- **`TestModelBase.cs`** — Abstract base for test models with configuration support.
|
||||
- **`JsonExtensionTests.cs`** — JSON extension method tests.
|
||||
|
||||
## Dependencies
|
||||
|
||||
| Dependency | Purpose |
|
||||
|---|---|
|
||||
| `MSTest` | Test framework |
|
||||
| `MessagePack` | Serialization comparison |
|
||||
| `MemoryPack` | Serialization comparison |
|
||||
| `MongoDB.Bson` | BSON comparison |
|
||||
|
|
@ -0,0 +1,474 @@
|
|||
using AyCode.Core.Extensions;
|
||||
using AyCode.Core.Serializers.Binaries;
|
||||
|
||||
namespace AyCode.Core.Tests.Serialization;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for DateTime serialization in Binary format.
|
||||
/// Covers edge cases like string-stored DateTime values in GenericAttribute-like scenarios.
|
||||
/// </summary>
|
||||
[TestClass]
|
||||
public class AcBinaryDateTimeSerializationTests
|
||||
{
|
||||
#region DateTime Direct Serialization
|
||||
|
||||
[TestMethod]
|
||||
public void DateTime_RoundTrip_PreservesValue()
|
||||
{
|
||||
var original = new DateTime(2025, 10, 24, 0, 27, 0, DateTimeKind.Utc);
|
||||
|
||||
var binary = AcBinarySerializer.Serialize(original);
|
||||
var result = binary.BinaryTo<DateTime>();
|
||||
|
||||
Assert.AreEqual(original, result);
|
||||
Assert.AreEqual(DateTimeKind.Utc, result.Kind);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void NullableDateTime_RoundTrip_PreservesValue()
|
||||
{
|
||||
DateTime? original = new DateTime(2025, 10, 24, 0, 27, 0, DateTimeKind.Utc);
|
||||
|
||||
var binary = AcBinarySerializer.Serialize(original);
|
||||
var result = binary.BinaryTo<DateTime?>();
|
||||
|
||||
Assert.AreEqual(original, result);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void NullableDateTime_Null_RoundTrip_PreservesNull()
|
||||
{
|
||||
DateTime? original = null;
|
||||
|
||||
var binary = AcBinarySerializer.Serialize(original);
|
||||
var result = binary.BinaryTo<DateTime?>();
|
||||
|
||||
Assert.IsNull(result);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Object with DateTime Property
|
||||
|
||||
[TestMethod]
|
||||
public void ObjectWithDateTime_RoundTrip_PreservesValue()
|
||||
{
|
||||
var original = new TestObjectWithDateTime
|
||||
{
|
||||
Id = 1,
|
||||
CreatedAt = new DateTime(2025, 10, 24, 0, 27, 0, DateTimeKind.Utc),
|
||||
UpdatedAt = new DateTime(2025, 12, 31, 23, 59, 59, DateTimeKind.Utc)
|
||||
};
|
||||
|
||||
var binary = original.ToBinary();
|
||||
var result = binary.BinaryTo<TestObjectWithDateTime>();
|
||||
|
||||
Assert.IsNotNull(result);
|
||||
Assert.AreEqual(original.Id, result.Id);
|
||||
Assert.AreEqual(original.CreatedAt, result.CreatedAt);
|
||||
Assert.AreEqual(original.UpdatedAt, result.UpdatedAt);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void ObjectWithNullableDateTime_Null_RoundTrip_PreservesNull()
|
||||
{
|
||||
var original = new TestObjectWithNullableDateTime
|
||||
{
|
||||
Id = 1,
|
||||
DateOfReceipt = null
|
||||
};
|
||||
|
||||
var binary = original.ToBinary();
|
||||
var result = binary.BinaryTo<TestObjectWithNullableDateTime>();
|
||||
|
||||
Assert.IsNotNull(result);
|
||||
Assert.AreEqual(original.Id, result.Id);
|
||||
Assert.IsNull(result.DateOfReceipt);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void ObjectWithNullableDateTime_HasValue_RoundTrip_PreservesValue()
|
||||
{
|
||||
var original = new TestObjectWithNullableDateTime
|
||||
{
|
||||
Id = 1,
|
||||
DateOfReceipt = new DateTime(2025, 10, 24, 0, 27, 0, DateTimeKind.Utc)
|
||||
};
|
||||
|
||||
var binary = original.ToBinary();
|
||||
var result = binary.BinaryTo<TestObjectWithNullableDateTime>();
|
||||
|
||||
Assert.IsNotNull(result);
|
||||
Assert.AreEqual(original.Id, result.Id);
|
||||
Assert.AreEqual(original.DateOfReceipt, result.DateOfReceipt);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region GenericAttribute-like Scenario (String stored DateTime)
|
||||
|
||||
[TestMethod]
|
||||
public void GenericAttributeScenario_DateTimeAsString_PreservesValue()
|
||||
{
|
||||
var original = new TestGenericAttribute
|
||||
{
|
||||
Key = "DateOfReceipt",
|
||||
Value = "10/24/2025 00:27:00"
|
||||
};
|
||||
|
||||
var binary = original.ToBinary();
|
||||
var result = binary.BinaryTo<TestGenericAttribute>();
|
||||
|
||||
Assert.IsNotNull(result);
|
||||
Assert.AreEqual(original.Key, result.Key);
|
||||
Assert.AreEqual(original.Value, result.Value);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void GenericAttributeScenario_ZeroValue_PreservesValue()
|
||||
{
|
||||
var original = new TestGenericAttribute
|
||||
{
|
||||
Key = "DateOfReceipt",
|
||||
Value = "0"
|
||||
};
|
||||
|
||||
var binary = original.ToBinary();
|
||||
var result = binary.BinaryTo<TestGenericAttribute>();
|
||||
|
||||
Assert.IsNotNull(result);
|
||||
Assert.AreEqual(original.Key, result.Key);
|
||||
Assert.AreEqual("0", result.Value);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void GenericAttributeList_RoundTrip_PreservesAllValues()
|
||||
{
|
||||
var original = new List<TestGenericAttribute>
|
||||
{
|
||||
new() { Key = "DateOfReceipt", Value = "10/24/2025 00:27:00" },
|
||||
new() { Key = "SomeNumber", Value = "42" },
|
||||
new() { Key = "EmptyValue", Value = "" },
|
||||
new() { Key = "ZeroValue", Value = "0" }
|
||||
};
|
||||
|
||||
var binary = original.ToBinary();
|
||||
var result = binary.BinaryTo<List<TestGenericAttribute>>();
|
||||
|
||||
Assert.IsNotNull(result);
|
||||
Assert.AreEqual(4, result.Count);
|
||||
|
||||
Assert.AreEqual("DateOfReceipt", result[0].Key);
|
||||
Assert.AreEqual("10/24/2025 00:27:00", result[0].Value);
|
||||
|
||||
Assert.AreEqual("SomeNumber", result[1].Key);
|
||||
Assert.AreEqual("42", result[1].Value);
|
||||
|
||||
Assert.AreEqual("EmptyValue", result[2].Key);
|
||||
Assert.AreEqual("", result[2].Value);
|
||||
|
||||
Assert.AreEqual("ZeroValue", result[3].Key);
|
||||
Assert.AreEqual("0", result[3].Value);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void ObjectWithGenericAttributes_RoundTrip_PreservesAllValues()
|
||||
{
|
||||
var original = new TestDtoWithGenericAttributes
|
||||
{
|
||||
Id = 123,
|
||||
Name = "Test Order",
|
||||
GenericAttributes =
|
||||
[
|
||||
new() { Key = "DateOfReceipt", Value = "10/24/2025 00:27:00" },
|
||||
new() { Key = "Priority", Value = "1" }
|
||||
]
|
||||
};
|
||||
|
||||
var binary = original.ToBinary();
|
||||
var result = binary.BinaryTo<TestDtoWithGenericAttributes>();
|
||||
|
||||
Assert.IsNotNull(result);
|
||||
Assert.AreEqual(123, result.Id);
|
||||
Assert.AreEqual("Test Order", result.Name);
|
||||
Assert.AreEqual(2, result.GenericAttributes.Count);
|
||||
|
||||
var dateAttr = result.GenericAttributes.FirstOrDefault(x => x.Key == "DateOfReceipt");
|
||||
Assert.IsNotNull(dateAttr);
|
||||
Assert.AreEqual("10/24/2025 00:27:00", dateAttr.Value);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region JSON vs Binary Comparison
|
||||
|
||||
[TestMethod]
|
||||
public void GenericAttribute_JsonAndBinary_ProduceSameResult()
|
||||
{
|
||||
var original = new TestGenericAttribute
|
||||
{
|
||||
Key = "DateOfReceipt",
|
||||
Value = "10/24/2025 00:27:00"
|
||||
};
|
||||
|
||||
var json = original.ToJson();
|
||||
var jsonResult = json.JsonTo<TestGenericAttribute>();
|
||||
|
||||
var binary = original.ToBinary();
|
||||
var binaryResult = binary.BinaryTo<TestGenericAttribute>();
|
||||
|
||||
Assert.IsNotNull(jsonResult);
|
||||
Assert.IsNotNull(binaryResult);
|
||||
|
||||
Assert.AreEqual(jsonResult.Key, binaryResult.Key);
|
||||
Assert.AreEqual(jsonResult.Value, binaryResult.Value);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void DtoWithGenericAttributes_JsonAndBinary_ProduceSameResult()
|
||||
{
|
||||
var original = new TestDtoWithGenericAttributes
|
||||
{
|
||||
Id = 123,
|
||||
Name = "Test Order",
|
||||
GenericAttributes =
|
||||
[
|
||||
new() { Key = "DateOfReceipt", Value = "10/24/2025 00:27:00" },
|
||||
new() { Key = "ZeroValue", Value = "0" }
|
||||
]
|
||||
};
|
||||
|
||||
var json = original.ToJson();
|
||||
var jsonResult = json.JsonTo<TestDtoWithGenericAttributes>();
|
||||
|
||||
var binary = original.ToBinary();
|
||||
var binaryResult = binary.BinaryTo<TestDtoWithGenericAttributes>();
|
||||
|
||||
Assert.IsNotNull(jsonResult);
|
||||
Assert.IsNotNull(binaryResult);
|
||||
|
||||
Assert.AreEqual(jsonResult.Id, binaryResult.Id);
|
||||
Assert.AreEqual(jsonResult.Name, binaryResult.Name);
|
||||
Assert.AreEqual(jsonResult.GenericAttributes.Count, binaryResult.GenericAttributes.Count);
|
||||
|
||||
for (int i = 0; i < jsonResult.GenericAttributes.Count; i++)
|
||||
{
|
||||
Assert.AreEqual(jsonResult.GenericAttributes[i].Key, binaryResult.GenericAttributes[i].Key);
|
||||
Assert.AreEqual(jsonResult.GenericAttributes[i].Value, binaryResult.GenericAttributes[i].Value);
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Test Models
|
||||
|
||||
public class TestObjectWithDateTime
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public DateTime CreatedAt { get; set; }
|
||||
public DateTime UpdatedAt { get; set; }
|
||||
}
|
||||
|
||||
public class TestObjectWithNullableDateTime
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public DateTime? DateOfReceipt { get; set; }
|
||||
}
|
||||
|
||||
public class TestGenericAttribute
|
||||
{
|
||||
public string Key { get; set; } = "";
|
||||
public string Value { get; set; } = "";
|
||||
}
|
||||
|
||||
public class TestDtoWithGenericAttributes
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public string Name { get; set; } = "";
|
||||
public List<TestGenericAttribute> GenericAttributes { get; set; } = [];
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Exact Production Scenario Test
|
||||
|
||||
/// <summary>
|
||||
/// This test reproduces the exact production bug scenario:
|
||||
/// 1. Server sends Binary serialized response with GenericAttributes
|
||||
/// 2. Client deserializes the Binary response
|
||||
/// 3. Client accesses DateOfReceipt property which reads from GenericAttributes
|
||||
/// 4. CommonHelper2.To<DateTime> fails to parse the string value
|
||||
/// </summary>
|
||||
[TestMethod]
|
||||
public void ProductionScenario_GenericAttributeWithDateString_PreservesExactFormat()
|
||||
{
|
||||
// Arrange: Create DTO with GenericAttributes like in production
|
||||
var original = new TestDtoWithGenericAttributes
|
||||
{
|
||||
Id = 123,
|
||||
Name = "Test Order",
|
||||
GenericAttributes =
|
||||
[
|
||||
new() { Key = "DateOfReceipt", Value = "10/24/2025 00:27:00" },
|
||||
new() { Key = "Priority", Value = "1" },
|
||||
new() { Key = "SomeFlag", Value = "true" }
|
||||
]
|
||||
};
|
||||
|
||||
// Act: Binary round-trip (simulates server->client communication)
|
||||
var binary = original.ToBinary();
|
||||
var result = binary.BinaryTo<TestDtoWithGenericAttributes>();
|
||||
|
||||
// Assert: The exact string value must be preserved
|
||||
Assert.IsNotNull(result);
|
||||
var dateAttr = result.GenericAttributes.FirstOrDefault(x => x.Key == "DateOfReceipt");
|
||||
Assert.IsNotNull(dateAttr, "DateOfReceipt attribute should exist");
|
||||
|
||||
// This is the critical assertion - the EXACT string must be preserved
|
||||
Assert.AreEqual("10/24/2025 00:27:00", dateAttr.Value,
|
||||
$"Expected '10/24/2025 00:27:00' but got '{dateAttr.Value}'");
|
||||
|
||||
// Verify it can be parsed with US culture (which is how it was stored)
|
||||
Assert.IsTrue(DateTime.TryParse(dateAttr.Value, new System.Globalization.CultureInfo("en-US"),
|
||||
System.Globalization.DateTimeStyles.None, out var parsedDate),
|
||||
$"Value '{dateAttr.Value}' should be parseable as US date format");
|
||||
|
||||
Assert.AreEqual(new DateTime(2025, 10, 24, 0, 27, 0), parsedDate);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Test that verifies the exact bytes of the string are preserved.
|
||||
/// </summary>
|
||||
[TestMethod]
|
||||
public void ProductionScenario_StringWithSlashes_BytesArePreserved()
|
||||
{
|
||||
var original = "10/24/2025 00:27:00";
|
||||
var originalBytes = System.Text.Encoding.UTF8.GetBytes(original);
|
||||
|
||||
// Serialize and deserialize
|
||||
var binary = AcBinarySerializer.Serialize(original);
|
||||
var result = binary.BinaryTo<string>();
|
||||
|
||||
Assert.IsNotNull(result);
|
||||
var resultBytes = System.Text.Encoding.UTF8.GetBytes(result);
|
||||
|
||||
// Compare byte-by-byte
|
||||
Assert.AreEqual(originalBytes.Length, resultBytes.Length, "String length changed after serialization");
|
||||
for (int i = 0; i < originalBytes.Length; i++)
|
||||
{
|
||||
Assert.AreEqual(originalBytes[i], resultBytes[i],
|
||||
$"Byte at position {i} differs: expected {originalBytes[i]:X2} ('{(char)originalBytes[i]}'), got {resultBytes[i]:X2} ('{(char)resultBytes[i]}')");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Test with large list of GenericAttributes to catch any edge cases.
|
||||
/// </summary>
|
||||
[TestMethod]
|
||||
public void ProductionScenario_ManyGenericAttributes_AllPreserved()
|
||||
{
|
||||
var original = new TestDtoWithGenericAttributes
|
||||
{
|
||||
Id = 999,
|
||||
Name = "Large Order",
|
||||
GenericAttributes = Enumerable.Range(0, 50).Select(i => new TestGenericAttribute
|
||||
{
|
||||
Key = $"Attribute_{i}",
|
||||
Value = i % 5 == 0 ? $"{i}/24/2025 00:00:00" : i.ToString()
|
||||
}).ToList()
|
||||
};
|
||||
|
||||
var binary = original.ToBinary();
|
||||
var result = binary.BinaryTo<TestDtoWithGenericAttributes>();
|
||||
|
||||
Assert.IsNotNull(result);
|
||||
Assert.AreEqual(50, result.GenericAttributes.Count);
|
||||
|
||||
for (int i = 0; i < 50; i++)
|
||||
{
|
||||
var expectedValue = i % 5 == 0 ? $"{i}/24/2025 00:00:00" : i.ToString();
|
||||
Assert.AreEqual($"Attribute_{i}", result.GenericAttributes[i].Key);
|
||||
Assert.AreEqual(expectedValue, result.GenericAttributes[i].Value,
|
||||
$"Attribute_{i} value mismatch: expected '{expectedValue}', got '{result.GenericAttributes[i].Value}'");
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region CommonHelper2.To<DateTime> Simulation Tests
|
||||
|
||||
/// <summary>
|
||||
/// This test simulates what CommonHelper2.To<DateTime> does with various string values.
|
||||
/// It helps identify which values will cause the FormatException.
|
||||
/// </summary>
|
||||
[TestMethod]
|
||||
public void CommonHelperSimulation_ValidDateString_ParsesSuccessfully()
|
||||
{
|
||||
var dateString = "10/24/2025 00:27:00";
|
||||
|
||||
// This is what CommonHelper2.To<DateTime> does internally
|
||||
var converter = System.ComponentModel.TypeDescriptor.GetConverter(typeof(DateTime));
|
||||
Assert.IsTrue(converter.CanConvertFrom(typeof(string)));
|
||||
|
||||
// With InvariantCulture
|
||||
var result = converter.ConvertFrom(null, System.Globalization.CultureInfo.InvariantCulture, dateString);
|
||||
Assert.IsNotNull(result);
|
||||
Assert.IsInstanceOfType(result, typeof(DateTime));
|
||||
var dt = (DateTime)result;
|
||||
// InvariantCulture interprets 10/24/2025 as October 24, 2025
|
||||
Assert.AreEqual(2025, dt.Year);
|
||||
Assert.AreEqual(10, dt.Month);
|
||||
Assert.AreEqual(24, dt.Day);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// This test shows that "0" cannot be parsed as DateTime - this is the actual bug!
|
||||
/// </summary>
|
||||
[TestMethod]
|
||||
public void CommonHelperSimulation_ZeroString_ThrowsFormatException()
|
||||
{
|
||||
var invalidValue = "0";
|
||||
|
||||
var converter = System.ComponentModel.TypeDescriptor.GetConverter(typeof(DateTime));
|
||||
|
||||
// This should throw FormatException - exactly what we see in production
|
||||
var threw = false;
|
||||
try
|
||||
{
|
||||
converter.ConvertFrom(null, System.Globalization.CultureInfo.InvariantCulture, invalidValue);
|
||||
}
|
||||
catch (FormatException)
|
||||
{
|
||||
threw = true;
|
||||
}
|
||||
|
||||
Assert.IsTrue(threw, "Converting '0' to DateTime should throw FormatException");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Test various invalid DateTime strings that might be stored in GenericAttributes.
|
||||
/// </summary>
|
||||
[TestMethod]
|
||||
[DataRow("0")]
|
||||
[DataRow("null")]
|
||||
[DataRow("undefined")]
|
||||
[DataRow("N/A")]
|
||||
public void CommonHelperSimulation_InvalidDateStrings_ThrowFormatException(string invalidValue)
|
||||
{
|
||||
var converter = System.ComponentModel.TypeDescriptor.GetConverter(typeof(DateTime));
|
||||
|
||||
var threw = false;
|
||||
try
|
||||
{
|
||||
converter.ConvertFrom(null, System.Globalization.CultureInfo.InvariantCulture, invalidValue);
|
||||
}
|
||||
catch (FormatException)
|
||||
{
|
||||
threw = true;
|
||||
}
|
||||
|
||||
Assert.IsTrue(threw, $"Converting '{invalidValue}' to DateTime should throw FormatException");
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
|
|
@ -0,0 +1,124 @@
|
|||
using AyCode.Core.Serializers.Binaries;
|
||||
|
||||
namespace AyCode.Core.Tests.Serialization;
|
||||
|
||||
/// <summary>
|
||||
/// Basic serialization tests for primitive types.
|
||||
/// </summary>
|
||||
[TestClass]
|
||||
public class AcBinarySerializerBasicTests
|
||||
{
|
||||
[TestMethod]
|
||||
public void Serialize_Null_ReturnsSingleNullByte()
|
||||
{
|
||||
var result = AcBinarySerializer.Serialize<object?>(null);
|
||||
Assert.AreEqual(1, result.Length);
|
||||
Assert.AreEqual(BinaryTypeCode.Null, result[0]);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void Serialize_Int32_RoundTrip()
|
||||
{
|
||||
var value = 12345;
|
||||
var binary = AcBinarySerializer.Serialize(value);
|
||||
var result = AcBinaryDeserializer.Deserialize<int>(binary);
|
||||
Assert.AreEqual(value, result);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void Serialize_Int64_RoundTrip()
|
||||
{
|
||||
var value = 123456789012345L;
|
||||
var binary = AcBinarySerializer.Serialize(value);
|
||||
var result = AcBinaryDeserializer.Deserialize<long>(binary);
|
||||
Assert.AreEqual(value, result);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void Serialize_Double_RoundTrip()
|
||||
{
|
||||
var value = 3.14159265358979;
|
||||
var binary = AcBinarySerializer.Serialize(value);
|
||||
var result = AcBinaryDeserializer.Deserialize<double>(binary);
|
||||
Assert.AreEqual(value, result);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void Serialize_String_RoundTrip()
|
||||
{
|
||||
var value = "Hello, Binary World!";
|
||||
var binary = AcBinarySerializer.Serialize(value);
|
||||
var result = AcBinaryDeserializer.Deserialize<string>(binary);
|
||||
Assert.AreEqual(value, result);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void Serialize_Boolean_RoundTrip()
|
||||
{
|
||||
var trueResult = AcBinaryDeserializer.Deserialize<bool>(AcBinarySerializer.Serialize(true));
|
||||
var falseResult = AcBinaryDeserializer.Deserialize<bool>(AcBinarySerializer.Serialize(false));
|
||||
Assert.IsTrue(trueResult);
|
||||
Assert.IsFalse(falseResult);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void Serialize_DateTime_RoundTrip()
|
||||
{
|
||||
var value = new DateTime(2024, 12, 25, 10, 30, 45, DateTimeKind.Utc);
|
||||
var binary = AcBinarySerializer.Serialize(value);
|
||||
var result = AcBinaryDeserializer.Deserialize<DateTime>(binary);
|
||||
Assert.AreEqual(value, result);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
[DataRow(DateTimeKind.Unspecified)]
|
||||
[DataRow(DateTimeKind.Utc)]
|
||||
[DataRow(DateTimeKind.Local)]
|
||||
public void Serialize_DateTime_PreservesKind(DateTimeKind kind)
|
||||
{
|
||||
var value = new DateTime(2024, 12, 25, 10, 30, 45, kind);
|
||||
var binary = AcBinarySerializer.Serialize(value);
|
||||
var result = AcBinaryDeserializer.Deserialize<DateTime>(binary);
|
||||
|
||||
Assert.AreEqual(value.Ticks, result.Ticks);
|
||||
Assert.AreEqual(value.Kind, result.Kind);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void Serialize_Guid_RoundTrip()
|
||||
{
|
||||
var value = Guid.NewGuid();
|
||||
var binary = AcBinarySerializer.Serialize(value);
|
||||
var result = AcBinaryDeserializer.Deserialize<Guid>(binary);
|
||||
Assert.AreEqual(value, result);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void Serialize_Decimal_RoundTrip()
|
||||
{
|
||||
var value = 123456.789012m;
|
||||
var binary = AcBinarySerializer.Serialize(value);
|
||||
var result = AcBinaryDeserializer.Deserialize<decimal>(binary);
|
||||
Assert.AreEqual(value, result);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void Serialize_TimeSpan_RoundTrip()
|
||||
{
|
||||
var value = TimeSpan.FromHours(2.5);
|
||||
var binary = AcBinarySerializer.Serialize(value);
|
||||
var result = AcBinaryDeserializer.Deserialize<TimeSpan>(binary);
|
||||
Assert.AreEqual(value, result);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void Serialize_DateTimeOffset_RoundTrip()
|
||||
{
|
||||
var value = new DateTimeOffset(2024, 12, 25, 10, 30, 45, TimeSpan.FromHours(2));
|
||||
var binary = AcBinarySerializer.Serialize(value);
|
||||
var result = AcBinaryDeserializer.Deserialize<DateTimeOffset>(binary);
|
||||
|
||||
Assert.AreEqual(value.UtcTicks, result.UtcTicks);
|
||||
Assert.AreEqual(value.Offset, result.Offset);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,82 @@
|
|||
using AyCode.Core.Serializers.Binaries;
|
||||
using AyCode.Core.Tests.TestModels;
|
||||
|
||||
namespace AyCode.Core.Tests.Serialization;
|
||||
|
||||
[TestClass]
|
||||
public class AcBinarySerializerBenchmarkTests
|
||||
{
|
||||
[TestMethod]
|
||||
public void Serialize_BenchmarkOrder_RoundTrip()
|
||||
{
|
||||
var order = TestDataFactory.CreateBenchmarkOrder(itemCount: 3, palletsPerItem: 2, measurementsPerPallet: 2, pointsPerMeasurement: 5);
|
||||
|
||||
var binary = AcBinarySerializer.Serialize(order);
|
||||
Assert.IsTrue(binary.Length > 0, "Binary data should not be empty");
|
||||
|
||||
var result = AcBinaryDeserializer.Deserialize<TestOrder_All_True>(binary);
|
||||
Assert.IsNotNull(result);
|
||||
Assert.AreEqual(order.Id, result.Id);
|
||||
Assert.AreEqual(order.OrderNumber, result.OrderNumber);
|
||||
Assert.AreEqual(order.Items.Count, result.Items.Count);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void Serialize_BenchmarkOrder_SmallData_RoundTrip()
|
||||
{
|
||||
var order = TestDataFactory.CreateBenchmarkOrder(itemCount: 1, palletsPerItem: 1, measurementsPerPallet: 1, pointsPerMeasurement: 1);
|
||||
|
||||
var binary = AcBinarySerializer.Serialize(order);
|
||||
Assert.IsTrue(binary.Length > 0);
|
||||
|
||||
var result = AcBinaryDeserializer.Deserialize<TestOrder_All_True>(binary);
|
||||
Assert.IsNotNull(result);
|
||||
Assert.AreEqual(order.Id, result.Id);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void Serialize_BenchmarkOrder_LargeData_RoundTrip()
|
||||
{
|
||||
var order = TestDataFactory.CreateBenchmarkOrder(itemCount: 10, palletsPerItem: 5, measurementsPerPallet: 3, pointsPerMeasurement: 10);
|
||||
|
||||
var binary = AcBinarySerializer.Serialize(order);
|
||||
Assert.IsTrue(binary.Length > 0, "Binary data should not be empty");
|
||||
|
||||
var result = AcBinaryDeserializer.Deserialize<TestOrder_All_True>(binary);
|
||||
Assert.IsNotNull(result);
|
||||
Assert.AreEqual(order.Id, result.Id);
|
||||
Assert.AreEqual(order.OrderNumber, result.OrderNumber);
|
||||
Assert.AreEqual(order.Items.Count, result.Items.Count);
|
||||
|
||||
// Verify nested structure
|
||||
for (int i = 0; i < order.Items.Count; i++)
|
||||
{
|
||||
Assert.AreEqual(order.Items[i].Id, result.Items[i].Id);
|
||||
Assert.AreEqual(order.Items[i].Pallets.Count, result.Items[i].Pallets.Count);
|
||||
}
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void Serialize_BenchmarkOrder_WithStringInterning_SmallerThanWithout()
|
||||
{
|
||||
var order = TestDataFactory.CreateBenchmarkOrder(itemCount: 5, palletsPerItem: 3, measurementsPerPallet: 2, pointsPerMeasurement: 5);
|
||||
|
||||
var binaryWithInterning = AcBinarySerializer.Serialize(order, AcBinarySerializerOptions.Default);
|
||||
var binaryWithoutInterning = AcBinarySerializer.Serialize(order, new AcBinarySerializerOptions { UseStringInterning = StringInterningMode.None });
|
||||
|
||||
// Note: String interning may not always result in smaller size due to header overhead
|
||||
// The primary benefit is for larger datasets with many repeated strings
|
||||
Console.WriteLine($"With interning: {binaryWithInterning.Length}, Without: {binaryWithoutInterning.Length}");
|
||||
|
||||
// Both should deserialize correctly regardless of size
|
||||
var result1 = AcBinaryDeserializer.Deserialize<TestOrder_All_True>(binaryWithInterning);
|
||||
var result2 = AcBinaryDeserializer.Deserialize<TestOrder_All_True>(binaryWithoutInterning);
|
||||
|
||||
Assert.IsNotNull(result1);
|
||||
Assert.IsNotNull(result2);
|
||||
Assert.AreEqual(order.Id, result1.Id);
|
||||
Assert.AreEqual(order.Id, result2.Id);
|
||||
Assert.AreEqual(order.Items.Count, result1.Items.Count);
|
||||
Assert.AreEqual(order.Items.Count, result2.Items.Count);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,233 @@
|
|||
using AyCode.Core.Extensions;
|
||||
using AyCode.Core.Tests.TestModels;
|
||||
|
||||
namespace AyCode.Core.Tests.Serialization;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for Chain API reference preservation with IId objects.
|
||||
/// This is the critical feature for DevExpress DXGrid GridCustomDataSource scenario.
|
||||
/// </summary>
|
||||
[TestClass]
|
||||
public class AcBinarySerializerChainReferenceTests
|
||||
{
|
||||
/// <summary>
|
||||
/// CRITICAL TEST: DevExpress DXGrid scenario with Chain API.
|
||||
/// Server returns List<Item> for grid display, but we also have internal cache List<Item>.
|
||||
/// When using ThenPopulate, the grid's visible items MUST be the same object references
|
||||
/// from the cache to ensure Blazor binding works correctly.
|
||||
/// </summary>
|
||||
[TestMethod]
|
||||
public void ChainPopulate_IIdObjects_PreservesReferences()
|
||||
{
|
||||
// Setup: Create internal cache with 5 categories
|
||||
var internalCache = new List<SharedCategory_All_True>
|
||||
{
|
||||
new() { Id = 1, Name = "Category1", SortOrder = 1 },
|
||||
new() { Id = 2, Name = "Category2", SortOrder = 2 },
|
||||
new() { Id = 3, Name = "Category3", SortOrder = 3 },
|
||||
new() { Id = 4, Name = "Category4", SortOrder = 4 },
|
||||
new() { Id = 5, Name = "Category5", SortOrder = 5 }
|
||||
};
|
||||
|
||||
// Server returns subset of categories (like grid pagination - page 2: items 3-5)
|
||||
var serverData = new List<SharedCategory_All_True>
|
||||
{
|
||||
new() { Id = 3, Name = "Category3_Updated", SortOrder = 33 },
|
||||
new() { Id = 4, Name = "Category4_Updated", SortOrder = 44 },
|
||||
new() { Id = 5, Name = "Category5_Updated", SortOrder = 55 }
|
||||
};
|
||||
|
||||
// Serialize server response
|
||||
var binary = serverData.ToBinary();
|
||||
|
||||
// Grid's visible list (empty initially)
|
||||
var gridVisibleList = new List<SharedCategory_All_True>();
|
||||
|
||||
// CRITICAL: Use Chain API to parse once, populate both cache and grid
|
||||
using var chain = binary.BinaryToChain<List<SharedCategory_All_True>>();
|
||||
|
||||
// First: Update internal cache (will become 3 items: 3-5 updated)
|
||||
chain.ThenPopulate(internalCache);
|
||||
|
||||
// Second: Populate grid's visible list
|
||||
chain.ThenPopulate(gridVisibleList);
|
||||
|
||||
// VERIFICATION: After ThenPopulate, internalCache contains the 3 items from server
|
||||
Assert.AreEqual(3, gridVisibleList.Count);
|
||||
Assert.AreEqual(3, internalCache.Count, "ThenPopulate replaces list contents with server data");
|
||||
|
||||
// CRITICAL ASSERTION: Grid items MUST be same object references as cache items!
|
||||
Assert.AreSame(internalCache[0], gridVisibleList[0],
|
||||
"Grid item MUST be same reference as cache item for Blazor binding!");
|
||||
Assert.AreSame(internalCache[1], gridVisibleList[1],
|
||||
"Grid item MUST be same reference as cache item for Blazor binding!");
|
||||
Assert.AreSame(internalCache[2], gridVisibleList[2],
|
||||
"Grid item MUST be same reference as cache item for Blazor binding!");
|
||||
|
||||
// Verify data was updated correctly
|
||||
Assert.AreEqual(3, internalCache[0].Id);
|
||||
Assert.AreEqual("Category3_Updated", internalCache[0].Name);
|
||||
Assert.AreEqual(33, internalCache[0].SortOrder);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Test JSON Chain API reference preservation.
|
||||
/// </summary>
|
||||
[TestMethod]
|
||||
public void JsonChainPopulate_IIdObjects_PreservesReferences()
|
||||
{
|
||||
// Setup: Create internal cache
|
||||
var internalCache = new List<SharedCategory_All_True>
|
||||
{
|
||||
new() { Id = 1, Name = "Category1", SortOrder = 1 },
|
||||
new() { Id = 2, Name = "Category2", SortOrder = 2 },
|
||||
new() { Id = 3, Name = "Category3", SortOrder = 3 }
|
||||
};
|
||||
|
||||
// Server returns subset
|
||||
var serverData = new List<SharedCategory_All_True>
|
||||
{
|
||||
new() { Id = 2, Name = "Category2_Updated", SortOrder = 22 },
|
||||
new() { Id = 3, Name = "Category3_Updated", SortOrder = 33 }
|
||||
};
|
||||
|
||||
// Serialize server response
|
||||
var json = serverData.ToJson();
|
||||
|
||||
// Grid's visible list
|
||||
var gridVisibleList = new List<SharedCategory_All_True>();
|
||||
|
||||
// Use JSON Chain API
|
||||
using var chain = json.JsonToChain<List<SharedCategory_All_True>>();
|
||||
|
||||
// Update internal cache (will replace with 2 items)
|
||||
chain.ThenPopulate(internalCache);
|
||||
|
||||
// Populate grid's visible list
|
||||
chain.ThenPopulate(gridVisibleList);
|
||||
|
||||
// VERIFICATION
|
||||
Assert.AreEqual(2, gridVisibleList.Count);
|
||||
Assert.AreEqual(2, internalCache.Count, "ThenPopulate replaces list contents");
|
||||
|
||||
// CRITICAL: Same references!
|
||||
Assert.AreSame(internalCache[0], gridVisibleList[0]);
|
||||
Assert.AreSame(internalCache[1], gridVisibleList[1]);
|
||||
|
||||
// Verify updates
|
||||
Assert.AreEqual(2, internalCache[0].Id);
|
||||
Assert.AreEqual("Category2_Updated", internalCache[0].Name);
|
||||
Assert.AreEqual(22, internalCache[0].SortOrder);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Test with Guid-based IId implementation.
|
||||
/// </summary>
|
||||
[TestMethod]
|
||||
public void ChainPopulate_GuidIId_PreservesReferences()
|
||||
{
|
||||
var cache = new List<TestGuidOrder>
|
||||
{
|
||||
new() { Id = Guid.NewGuid(), Code = "ORD-001", Count = 10 },
|
||||
new() { Id = Guid.NewGuid(), Code = "ORD-002", Count = 20 }
|
||||
};
|
||||
|
||||
var id1 = cache[0].Id;
|
||||
var id2 = cache[1].Id;
|
||||
|
||||
var serverData = new List<TestGuidOrder>
|
||||
{
|
||||
new() { Id = id1, Code = "ORD-001-UPDATED", Count = 11 },
|
||||
new() { Id = id2, Code = "ORD-002-UPDATED", Count = 22 }
|
||||
};
|
||||
|
||||
var binary = serverData.ToBinary();
|
||||
var gridList = new List<TestGuidOrder>();
|
||||
|
||||
using var chain = binary.BinaryToChain<List<TestGuidOrder>>();
|
||||
|
||||
chain.ThenPopulate(cache);
|
||||
chain.ThenPopulate(gridList);
|
||||
|
||||
Assert.AreEqual(2, gridList.Count);
|
||||
Assert.AreSame(cache[0], gridList[0], "Guid-based IId should also preserve references");
|
||||
Assert.AreSame(cache[1], gridList[1]);
|
||||
Assert.AreEqual("ORD-001-UPDATED", cache[0].Code);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Test multiple chain operations with different subsets.
|
||||
/// </summary>
|
||||
[TestMethod]
|
||||
public void ChainPopulate_MultipleSubsets_PreservesReferencesAcrossAll()
|
||||
{
|
||||
// Large internal cache
|
||||
var internalCache = Enumerable.Range(1, 10)
|
||||
.Select(i => new SharedCategory_All_True { Id = i, Name = $"Category{i}", SortOrder = i * 10 })
|
||||
.ToList();
|
||||
|
||||
// Server returns items 3-7
|
||||
var serverData = Enumerable.Range(3, 5)
|
||||
.Select(i => new SharedCategory_All_True { Id = i, Name = $"Category{i}_Updated", SortOrder = i * 11 })
|
||||
.ToList();
|
||||
|
||||
var binary = serverData.ToBinary();
|
||||
|
||||
// Three different grid pages/views
|
||||
var gridPage1 = new List<SharedCategory_All_True>();
|
||||
var gridPage2 = new List<SharedCategory_All_True>();
|
||||
var gridPage3 = new List<SharedCategory_All_True>();
|
||||
|
||||
using var chain = binary.BinaryToChain<List<SharedCategory_All_True>>();
|
||||
|
||||
// Update cache first
|
||||
chain.ThenPopulate(internalCache);
|
||||
|
||||
// Populate different grid pages
|
||||
chain.ThenPopulate(gridPage1);
|
||||
chain.ThenPopulate(gridPage2);
|
||||
chain.ThenPopulate(gridPage3);
|
||||
|
||||
// All pages should have same references
|
||||
Assert.AreEqual(5, gridPage1.Count);
|
||||
Assert.AreEqual(5, gridPage2.Count);
|
||||
Assert.AreEqual(5, gridPage3.Count);
|
||||
|
||||
// All three pages point to the SAME objects
|
||||
for (int i = 0; i < 5; i++)
|
||||
{
|
||||
Assert.AreSame(gridPage1[i], gridPage2[i], $"Page1 and Page2 item {i} must be same reference");
|
||||
Assert.AreSame(gridPage2[i], gridPage3[i], $"Page2 and Page3 item {i} must be same reference");
|
||||
Assert.AreSame(internalCache[i], gridPage1[i], $"Cache and Page1 item {i} must be same reference");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Simple debug test to verify chain reference tracking works.
|
||||
/// </summary>
|
||||
[TestMethod]
|
||||
public void ChainPopulate_SimpleCase_Works()
|
||||
{
|
||||
var list1 = new List<SharedCategory_All_True>();
|
||||
var list2 = new List<SharedCategory_All_True>();
|
||||
|
||||
var serverData = new List<SharedCategory_All_True>
|
||||
{
|
||||
new() { Id = 1, Name = "Cat1", SortOrder = 10 }
|
||||
};
|
||||
|
||||
var binary = serverData.ToBinary();
|
||||
|
||||
using var chain = binary.BinaryToChain<List<SharedCategory_All_True>>();
|
||||
|
||||
// First populate
|
||||
chain.ThenPopulate(list1);
|
||||
Assert.AreEqual(1, list1.Count);
|
||||
Assert.AreEqual(1, list1[0].Id);
|
||||
|
||||
// Second populate - should reuse same object
|
||||
chain.ThenPopulate(list2);
|
||||
Assert.AreEqual(1, list2.Count);
|
||||
Assert.AreSame(list1[0], list2[0], "Should be same object reference!");
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,345 @@
|
|||
using AyCode.Core.Extensions;
|
||||
using AyCode.Core.Serializers.Binaries;
|
||||
using AyCode.Core.Tests.TestModels;
|
||||
using static AyCode.Core.Tests.TestModels.AcSerializerModels;
|
||||
|
||||
namespace AyCode.Core.Tests.Serialization;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for Binary Chain API (CreateDeserializeChain and CreatePopulateChain).
|
||||
/// </summary>
|
||||
[TestClass]
|
||||
public class AcBinarySerializerChainTests
|
||||
{
|
||||
[TestMethod]
|
||||
public void DeserializeChain_SingleDeserialization_WorksCorrectly()
|
||||
{
|
||||
// Arrange
|
||||
var original = new TestSimpleClass { Id = 42, Name = "John", Value = 3.14, IsActive = true };
|
||||
var binary = original.ToBinary();
|
||||
|
||||
// Act
|
||||
using var chain = binary.BinaryToChain<TestSimpleClass>();
|
||||
var result = chain.Value;
|
||||
|
||||
// Assert
|
||||
Assert.IsNotNull(result);
|
||||
Assert.AreEqual(42, result.Id);
|
||||
Assert.AreEqual("John", result.Name);
|
||||
Assert.AreEqual(3.14, result.Value);
|
||||
Assert.AreEqual(true, result.IsActive);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void DeserializeChain_MultipleDeserializations_ParsesOnlyOnce()
|
||||
{
|
||||
// Arrange
|
||||
var original = new TestSimpleClass { Id = 100, Name = "Test", Value = 99.9, IsActive = false };
|
||||
var binary = original.ToBinary();
|
||||
|
||||
// Act
|
||||
using var chain = binary.BinaryToChain<TestSimpleClass>();
|
||||
var result1 = chain.Value;
|
||||
var result2 = chain.ThenDeserialize<TestSimpleClass>();
|
||||
var result3 = chain.ThenDeserialize<TestSimpleClass>();
|
||||
|
||||
// Assert - All three deserializations should work
|
||||
Assert.IsNotNull(result1);
|
||||
Assert.AreEqual(100, result1.Id);
|
||||
|
||||
Assert.IsNotNull(result2);
|
||||
Assert.AreEqual(100, result2.Id);
|
||||
Assert.AreEqual("Test", result2.Name);
|
||||
|
||||
Assert.IsNotNull(result3);
|
||||
Assert.AreEqual(99.9, result3.Value);
|
||||
Assert.AreEqual(false, result3.IsActive);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void DeserializeChain_NestedObjects_WorksCorrectly()
|
||||
{
|
||||
// Arrange
|
||||
var original = new TestNestedClass
|
||||
{
|
||||
Id = 1,
|
||||
Name = "Parent",
|
||||
Child = new TestSimpleClass { Id = 2, Name = "Child", Value = 10.5 }
|
||||
};
|
||||
var binary = original.ToBinary();
|
||||
|
||||
// Act
|
||||
using var chain = binary.BinaryToChain<TestNestedClass>();
|
||||
var result1 = chain.Value;
|
||||
var result2 = chain.ThenDeserialize<TestNestedClass>();
|
||||
|
||||
// Assert
|
||||
Assert.IsNotNull(result1);
|
||||
Assert.AreEqual("Parent", result1.Name);
|
||||
Assert.IsNotNull(result1.Child);
|
||||
Assert.AreEqual("Child", result1.Child.Name);
|
||||
|
||||
Assert.IsNotNull(result2);
|
||||
Assert.AreEqual(1, result2.Id);
|
||||
Assert.IsNotNull(result2.Child);
|
||||
Assert.AreEqual(10.5, result2.Child.Value);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void DeserializeChain_WithList_WorksCorrectly()
|
||||
{
|
||||
// Arrange
|
||||
var original = new TestClassWithList
|
||||
{
|
||||
Id = 5,
|
||||
Items = new List<string> { "Apple", "Banana", "Cherry" }
|
||||
};
|
||||
var binary = original.ToBinary();
|
||||
|
||||
// Act
|
||||
using var chain = binary.BinaryToChain<TestClassWithList>();
|
||||
var result = chain.Value;
|
||||
|
||||
// Assert
|
||||
Assert.IsNotNull(result);
|
||||
Assert.AreEqual(5, result.Id);
|
||||
Assert.AreEqual(3, result.Items.Count);
|
||||
Assert.AreEqual("Apple", result.Items[0]);
|
||||
Assert.AreEqual("Banana", result.Items[1]);
|
||||
Assert.AreEqual("Cherry", result.Items[2]);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void PopulateChain_SinglePopulate_UpdatesObject()
|
||||
{
|
||||
// Arrange
|
||||
var original = new TestSimpleClass { Id = 99, Name = "Updated", Value = 123.45, IsActive = true };
|
||||
var binary = original.ToBinary();
|
||||
var target = new TestSimpleClass { Id = 1, Name = "Old", Value = 0, IsActive = false };
|
||||
|
||||
// Act
|
||||
using var chain = binary.BinaryToChain(target);
|
||||
|
||||
// Assert
|
||||
Assert.AreEqual(99, target.Id);
|
||||
Assert.AreEqual("Updated", target.Name);
|
||||
Assert.AreEqual(123.45, target.Value);
|
||||
Assert.AreEqual(true, target.IsActive);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void PopulateChain_MultiplePopulates_UpdatesAllObjects()
|
||||
{
|
||||
// Arrange
|
||||
var original = new TestSimpleClass { Id = 100, Name = "Shared", Value = 50.0 };
|
||||
var binary = original.ToBinary();
|
||||
var target1 = new TestSimpleClass { Id = 1, Name = "Old1" };
|
||||
var target2 = new TestSimpleClass { Id = 2, Name = "Old2" };
|
||||
var target3 = new TestSimpleClass { Id = 3, Name = "Old3" };
|
||||
|
||||
// Act
|
||||
using var chain = binary.BinaryToChain(target1);
|
||||
chain.ThenPopulate(target2);
|
||||
chain.ThenPopulate(target3);
|
||||
|
||||
// Assert
|
||||
Assert.AreEqual(100, target1.Id);
|
||||
Assert.AreEqual("Shared", target1.Name);
|
||||
Assert.AreEqual(50.0, target1.Value);
|
||||
|
||||
Assert.AreEqual(100, target2.Id);
|
||||
Assert.AreEqual("Shared", target2.Name);
|
||||
Assert.AreEqual(50.0, target2.Value);
|
||||
|
||||
Assert.AreEqual(100, target3.Id);
|
||||
Assert.AreEqual("Shared", target3.Name);
|
||||
Assert.AreEqual(50.0, target3.Value);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void PopulateChain_NestedObjects_MergesCorrectly()
|
||||
{
|
||||
// Arrange
|
||||
var original = new TestNestedClass
|
||||
{
|
||||
Id = 10,
|
||||
Name = "UpdatedParent",
|
||||
Child = new TestSimpleClass { Id = 20, Name = "UpdatedChild", Value = 99.9 }
|
||||
};
|
||||
var binary = original.ToBinary();
|
||||
var target = new TestNestedClass
|
||||
{
|
||||
Id = 1,
|
||||
Name = "OldParent",
|
||||
Child = new TestSimpleClass { Id = 2, Name = "OldChild", Value = 1.0 }
|
||||
};
|
||||
|
||||
// Act
|
||||
using var chain = binary.BinaryToChain(target);
|
||||
|
||||
// Assert
|
||||
Assert.AreEqual(10, target.Id);
|
||||
Assert.AreEqual("UpdatedParent", target.Name);
|
||||
Assert.IsNotNull(target.Child);
|
||||
Assert.AreEqual(20, target.Child.Id);
|
||||
Assert.AreEqual("UpdatedChild", target.Child.Name);
|
||||
Assert.AreEqual(99.9, target.Child.Value);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void PopulateChain_WithList_UpdatesCollection()
|
||||
{
|
||||
// Arrange
|
||||
var original = new TestClassWithList
|
||||
{
|
||||
Id = 7,
|
||||
Items = new List<string> { "New1", "New2", "New3" }
|
||||
};
|
||||
var binary = original.ToBinary();
|
||||
var target = new TestClassWithList
|
||||
{
|
||||
Id = 1,
|
||||
Items = new List<string> { "Old1" }
|
||||
};
|
||||
|
||||
// Act
|
||||
using var chain = binary.BinaryToChain(target);
|
||||
|
||||
// Assert
|
||||
Assert.AreEqual(7, target.Id);
|
||||
Assert.AreEqual(3, target.Items.Count);
|
||||
Assert.AreEqual("New1", target.Items[0]);
|
||||
Assert.AreEqual("New2", target.Items[1]);
|
||||
Assert.AreEqual("New3", target.Items[2]);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void DeserializeChain_EmptyBinary_ReturnsEmpty()
|
||||
{
|
||||
// Arrange
|
||||
var binary = Array.Empty<byte>();
|
||||
|
||||
// Act
|
||||
using var chain = binary.BinaryToChain<TestSimpleClass>();
|
||||
|
||||
// Assert
|
||||
Assert.IsNull(chain.Value);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void PopulateChain_EmptyBinary_DoesNothing()
|
||||
{
|
||||
// Arrange
|
||||
var binary = Array.Empty<byte>();
|
||||
var target = new TestSimpleClass { Id = 42, Name = "Original" };
|
||||
|
||||
// Act
|
||||
using var chain = binary.BinaryToChain(target);
|
||||
|
||||
// Assert - Should remain unchanged
|
||||
Assert.AreEqual(42, target.Id);
|
||||
Assert.AreEqual("Original", target.Name);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void DeserializeChain_Dispose_CannotReuseAfterDispose()
|
||||
{
|
||||
// Arrange
|
||||
var original = new TestSimpleClass { Id = 1, Name = "Test" };
|
||||
var binary = original.ToBinary();
|
||||
var chain = binary.BinaryToChain<TestSimpleClass>();
|
||||
var value = chain.Value;
|
||||
|
||||
// Act
|
||||
chain.Dispose();
|
||||
|
||||
// Assert
|
||||
Assert.IsNotNull(value); // Value from before dispose should still exist
|
||||
_ = Assert.ThrowsExactly<ObjectDisposedException>(() => chain.ThenDeserialize<TestSimpleClass>());
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void PopulateChain_Dispose_CannotReuseAfterDispose()
|
||||
{
|
||||
// Arrange
|
||||
var original = new TestSimpleClass { Id = 1, Name = "Test" };
|
||||
var binary = original.ToBinary();
|
||||
var target1 = new TestSimpleClass();
|
||||
var chain = binary.BinaryToChain(target1);
|
||||
|
||||
// Act
|
||||
chain.Dispose();
|
||||
|
||||
// Assert
|
||||
var target2 = new TestSimpleClass();
|
||||
_ = Assert.ThrowsExactly<ObjectDisposedException>(() => chain.ThenPopulate(target2));
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void DeserializeChain_WithOptions_UsesCorrectOptions()
|
||||
{
|
||||
// Arrange
|
||||
var original = new TestSimpleClass { Id = 1, Name = "Test", Value = 10.5 };
|
||||
var binary = original.ToBinary();
|
||||
var options = new AcBinarySerializerOptions();
|
||||
|
||||
// Act
|
||||
using var chain = binary.BinaryToChain<TestSimpleClass>(options);
|
||||
var result = chain.Value;
|
||||
|
||||
// Assert
|
||||
Assert.IsNotNull(result);
|
||||
Assert.AreEqual(1, result.Id);
|
||||
Assert.AreEqual("Test", result.Name);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void PopulateChain_WithOptions_UsesCorrectOptions()
|
||||
{
|
||||
// Arrange
|
||||
var original = new TestSimpleClass { Id = 99, Name = "Updated" };
|
||||
var binary = original.ToBinary();
|
||||
var target = new TestSimpleClass { Id = 1, Name = "Old" };
|
||||
var options = new AcBinarySerializerOptions();
|
||||
|
||||
// Act
|
||||
using var chain = binary.BinaryToChain(target, options);
|
||||
|
||||
// Assert
|
||||
Assert.AreEqual(99, target.Id);
|
||||
Assert.AreEqual("Updated", target.Name);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void DeserializeChain_ByteArray_WorksCorrectly()
|
||||
{
|
||||
// Arrange
|
||||
var original = new TestSimpleClass { Id = 42, Name = "Memory Test" };
|
||||
var binary = original.ToBinary();
|
||||
|
||||
// Act
|
||||
using var chain = binary.BinaryToChain<TestSimpleClass>();
|
||||
var result = chain.Value;
|
||||
|
||||
// Assert
|
||||
Assert.IsNotNull(result);
|
||||
Assert.AreEqual(42, result.Id);
|
||||
Assert.AreEqual("Memory Test", result.Name);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void PopulateChain_ByteArray_WorksCorrectly()
|
||||
{
|
||||
// Arrange
|
||||
var original = new TestSimpleClass { Id = 99, Name = "Memory Update" };
|
||||
var binary = original.ToBinary();
|
||||
var target = new TestSimpleClass { Id = 1, Name = "Old" };
|
||||
|
||||
// Act
|
||||
using var chain = binary.BinaryToChain(target);
|
||||
|
||||
// Assert
|
||||
Assert.AreEqual(99, target.Id);
|
||||
Assert.AreEqual("Memory Update", target.Name);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,184 @@
|
|||
using AyCode.Core.Extensions;
|
||||
using AyCode.Core.Serializers;
|
||||
using AyCode.Core.Serializers.Binaries;
|
||||
using static AyCode.Core.Tests.TestModels.AcSerializerModels;
|
||||
|
||||
namespace AyCode.Core.Tests.Serialization;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for circular reference handling with back-navigation properties.
|
||||
/// </summary>
|
||||
[TestClass]
|
||||
public class AcBinarySerializerCircularReferenceTests
|
||||
{
|
||||
/// <summary>
|
||||
/// CRITICAL TEST: Circular references with back-navigation properties.
|
||||
/// This simulates the exact production scenario where:
|
||||
/// - StockTaking has StockTakingItems collection
|
||||
/// - StockTakingItem has StockTaking back-reference (circular!)
|
||||
/// - StockTakingItem has Product navigation property
|
||||
/// </summary>
|
||||
[TestMethod]
|
||||
[DataRow(true, true)]
|
||||
[DataRow(false, true)]
|
||||
[DataRow(true, false)]
|
||||
[DataRow(false, false)]
|
||||
public void Deserialize_CircularReference_ParentChildBackReference(bool useSgen, bool useMeta)
|
||||
{
|
||||
var parent = new CircularParent
|
||||
{
|
||||
Id = 1,
|
||||
Name = "Parent",
|
||||
Created = new DateTime(2025, 1, 24, 15, 25, 0, DateTimeKind.Utc),
|
||||
Modified = DateTime.UtcNow,
|
||||
Creator = 6,
|
||||
Children = new List<CircularChild>()
|
||||
};
|
||||
|
||||
var child = new CircularChild
|
||||
{
|
||||
Id = 10,
|
||||
ParentId = 1,
|
||||
Name = "Child",
|
||||
Created = DateTime.UtcNow.AddHours(-1),
|
||||
Modified = DateTime.UtcNow,
|
||||
Parent = parent,
|
||||
GrandChildren = new List<CircularGrandChild>()
|
||||
};
|
||||
|
||||
var grandChild = new CircularGrandChild
|
||||
{
|
||||
Id = 100,
|
||||
ChildId = 10,
|
||||
CreatorId = 6,
|
||||
ModifierId = null,
|
||||
Created = DateTime.UtcNow,
|
||||
Modified = DateTime.UtcNow,
|
||||
Child = child
|
||||
};
|
||||
|
||||
child.GrandChildren.Add(grandChild);
|
||||
parent.Children.Add(child);
|
||||
|
||||
var option = AcBinarySerializerOptions.Default;
|
||||
option.ReferenceHandling = ReferenceHandlingMode.All;
|
||||
option.UseGeneratedCode = useSgen;
|
||||
option.UseMetadata = useMeta;
|
||||
|
||||
Console.WriteLine($"\n========== ReferenceHandling: {option.ReferenceHandling}, UseSgen: {option.UseGeneratedCode}, UseMeta: {option.UseMetadata} ==========");
|
||||
|
||||
var binary = parent.ToBinary(option);
|
||||
var result = binary.BinaryTo<CircularParent>();
|
||||
|
||||
Assert.IsNotNull(result);
|
||||
Assert.AreEqual(1, result.Id);
|
||||
Assert.AreEqual(6, result.Creator, "Creator should be 6");
|
||||
Assert.AreEqual(parent.Created.Ticks, result.Created.Ticks,
|
||||
$"Created mismatch. Expected: {parent.Created}, Got: {result.Created}");
|
||||
|
||||
Assert.IsNotNull(result.Children);
|
||||
Assert.AreEqual(1, result.Children.Count);
|
||||
|
||||
var resultChild = result.Children[0];
|
||||
Assert.AreEqual(10, resultChild.Id);
|
||||
Assert.AreEqual(resultChild.Created.Ticks, child.Created.Ticks, "Child.Created should match");
|
||||
|
||||
Assert.IsNotNull(resultChild.Parent, "Child.Parent back-reference should be resolved");
|
||||
Assert.AreEqual(1, resultChild.Parent.Id, "Back-reference should point to same parent");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Test list of parents with circular references.
|
||||
/// </summary>
|
||||
[TestMethod]
|
||||
[DataRow(true, true)]
|
||||
[DataRow(false, true)]
|
||||
[DataRow(true, false)]
|
||||
[DataRow(false, false)]
|
||||
public void Deserialize_ListOfCircularReferences_AllItemsCorrect(bool useSgen, bool useMeta)
|
||||
{
|
||||
var parents = Enumerable.Range(1, 5).Select(p =>
|
||||
{
|
||||
var parent = new CircularParent
|
||||
{
|
||||
Id = p,
|
||||
Name = $"Parent_{p}",
|
||||
Created = DateTime.UtcNow.AddDays(-p),
|
||||
Modified = DateTime.UtcNow,
|
||||
Creator = p,
|
||||
Children = new List<CircularChild>()
|
||||
};
|
||||
|
||||
for (int c = 1; c <= 2; c++)
|
||||
{
|
||||
var child = new CircularChild
|
||||
{
|
||||
Id = p * 100 + c,
|
||||
ParentId = p,
|
||||
Name = $"Child_{p}_{c}",
|
||||
Created = DateTime.UtcNow.AddHours(-c),
|
||||
Modified = DateTime.UtcNow,
|
||||
Parent = parent,
|
||||
GrandChildren = new List<CircularGrandChild>()
|
||||
};
|
||||
|
||||
for (int g = 1; g <= 2; g++)
|
||||
{
|
||||
child.GrandChildren.Add(new CircularGrandChild
|
||||
{
|
||||
Id = p * 1000 + c * 100 + g,
|
||||
ChildId = child.Id,
|
||||
CreatorId = g % 2 == 0 ? p : null,
|
||||
ModifierId = g % 2 == 1 ? p * 2 : null,
|
||||
Created = DateTime.UtcNow,
|
||||
Modified = DateTime.UtcNow,
|
||||
Child = child
|
||||
});
|
||||
}
|
||||
|
||||
parent.Children.Add(child);
|
||||
}
|
||||
|
||||
return parent;
|
||||
}).ToList();
|
||||
|
||||
var option = AcBinarySerializerOptions.Default;
|
||||
option.ReferenceHandling = ReferenceHandlingMode.All;
|
||||
option.UseGeneratedCode = useSgen;
|
||||
option.UseMetadata = useMeta;
|
||||
|
||||
Console.WriteLine($"\n========== ReferenceHandling: {option.ReferenceHandling}, UseSgen: {option.UseGeneratedCode}, UseMeta: {option.UseMetadata} ==========");
|
||||
|
||||
var binary = parents.ToBinary(option);
|
||||
var result = binary.BinaryTo<List<CircularParent>>();
|
||||
|
||||
Assert.IsNotNull(result);
|
||||
Assert.AreEqual(5, result.Count);
|
||||
|
||||
for (int p = 0; p < 5; p++)
|
||||
{
|
||||
var original = parents[p];
|
||||
var deserialized = result[p];
|
||||
|
||||
Assert.AreEqual(original.Id, deserialized.Id, $"Parent[{p}].Id mismatch");
|
||||
Assert.AreEqual(original.Creator, deserialized.Creator, $"Parent[{p}].Creator mismatch");
|
||||
Assert.AreEqual(original.Created.Ticks, deserialized.Created.Ticks,
|
||||
$"Parent[{p}].Created mismatch. Expected: {original.Created}, Got: {deserialized.Created}");
|
||||
|
||||
Assert.IsNotNull(deserialized.Children, $"Parent[{p}].Children is null");
|
||||
Assert.AreEqual(2, deserialized.Children.Count, $"Parent[{p}].Children.Count mismatch");
|
||||
|
||||
for (int c = 0; c < 2; c++)
|
||||
{
|
||||
var origChild = original.Children![c];
|
||||
var deserChild = deserialized.Children[c];
|
||||
|
||||
Assert.AreEqual(origChild.Id, deserChild.Id, $"Parent[{p}].Children[{c}].Id mismatch");
|
||||
Assert.AreEqual(origChild.Created.Ticks, deserChild.Created.Ticks,
|
||||
$"Parent[{p}].Children[{c}].Created mismatch");
|
||||
|
||||
Assert.IsNotNull(deserChild.Parent, $"Parent[{p}].Children[{c}].Parent should not be null");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,188 @@
|
|||
using AyCode.Core.Extensions;
|
||||
using static AyCode.Core.Tests.TestModels.AcSerializerModels;
|
||||
using static AyCode.Core.Tests.Serialization.AcSerializerTestHelper;
|
||||
|
||||
namespace AyCode.Core.Tests.Serialization;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for DateTime type handling and potential type mismatch issues.
|
||||
/// </summary>
|
||||
[TestClass]
|
||||
public class AcBinarySerializerDateTimeTests
|
||||
{
|
||||
[TestMethod]
|
||||
public void Deserialize_DateTimeProperty_FromDifferentPropertyOrder_RoundTrip()
|
||||
{
|
||||
var entity = new TestEntityWithDateTimeAndInt
|
||||
{
|
||||
Id = 42,
|
||||
IntValue = 100,
|
||||
Created = new DateTime(2024, 12, 25, 10, 30, 45, DateTimeKind.Utc),
|
||||
Modified = new DateTime(2024, 12, 26, 11, 45, 30, DateTimeKind.Utc),
|
||||
StatusCode = 5,
|
||||
Name = "TestEntity"
|
||||
};
|
||||
|
||||
var binary = entity.ToBinary();
|
||||
var result = binary.BinaryTo<TestEntityWithDateTimeAndInt>();
|
||||
|
||||
Assert.IsNotNull(result);
|
||||
Assert.AreEqual(42, result.Id);
|
||||
Assert.AreEqual(100, result.IntValue);
|
||||
Assert.AreEqual(entity.Created, result.Created, "Created DateTime should match");
|
||||
Assert.AreEqual(entity.Modified, result.Modified, "Modified DateTime should match");
|
||||
Assert.AreEqual(5, result.StatusCode);
|
||||
Assert.AreEqual("TestEntity", result.Name);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void Deserialize_ListOfEntitiesWithDateTimeProperties_RoundTrip()
|
||||
{
|
||||
var entities = CreateDateTimeEntities(10);
|
||||
var binary = entities.ToBinary();
|
||||
var result = binary.BinaryTo<List<TestEntityWithDateTimeAndInt>>();
|
||||
|
||||
Assert.IsNotNull(result);
|
||||
Assert.AreEqual(10, result.Count);
|
||||
|
||||
for (int i = 0; i < 10; i++)
|
||||
{
|
||||
var original = entities[i];
|
||||
var deserialized = result[i];
|
||||
|
||||
Assert.AreEqual(original.Id, deserialized.Id, $"Id mismatch at index {i}");
|
||||
Assert.AreEqual(original.IntValue, deserialized.IntValue, $"IntValue mismatch at index {i}");
|
||||
Assert.AreEqual(original.Created.Ticks, deserialized.Created.Ticks, $"Created mismatch at index {i}");
|
||||
Assert.AreEqual(original.Modified.Ticks, deserialized.Modified.Ticks, $"Modified mismatch at index {i}");
|
||||
Assert.AreEqual(original.StatusCode, deserialized.StatusCode, $"StatusCode mismatch at index {i}");
|
||||
Assert.AreEqual(original.Name, deserialized.Name, $"Name mismatch at index {i}");
|
||||
}
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void Deserialize_EntityWithManyIntPropertiesBeforeDateTime_RoundTrip()
|
||||
{
|
||||
var entity = new TestEntityWithManyIntsBeforeDateTime
|
||||
{
|
||||
Id = 1,
|
||||
Value1 = 10,
|
||||
Value2 = 20,
|
||||
Value3 = 30,
|
||||
Value4 = 40,
|
||||
Value5 = 50,
|
||||
FirstDateTime = new DateTime(2024, 1, 15, 10, 0, 0, DateTimeKind.Utc),
|
||||
SecondDateTime = new DateTime(2024, 6, 20, 15, 30, 0, DateTimeKind.Utc),
|
||||
FinalValue = 999
|
||||
};
|
||||
|
||||
var binary = entity.ToBinary();
|
||||
var result = binary.BinaryTo<TestEntityWithManyIntsBeforeDateTime>();
|
||||
|
||||
Assert.IsNotNull(result);
|
||||
Assert.AreEqual(1, result.Id);
|
||||
Assert.AreEqual(10, result.Value1);
|
||||
Assert.AreEqual(20, result.Value2);
|
||||
Assert.AreEqual(30, result.Value3);
|
||||
Assert.AreEqual(40, result.Value4);
|
||||
Assert.AreEqual(50, result.Value5);
|
||||
Assert.AreEqual(entity.FirstDateTime, result.FirstDateTime, "FirstDateTime should match");
|
||||
Assert.AreEqual(entity.SecondDateTime, result.SecondDateTime, "SecondDateTime should match");
|
||||
Assert.AreEqual(999, result.FinalValue);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void Deserialize_NestedEntityWithDateTimeInChild_RoundTrip()
|
||||
{
|
||||
var parent = new TestParentEntityWithDateTimeChild
|
||||
{
|
||||
ParentId = 1,
|
||||
ParentName = "Parent",
|
||||
Child = new TestEntityWithDateTimeAndInt
|
||||
{
|
||||
Id = 100,
|
||||
IntValue = 200,
|
||||
Created = DateTime.UtcNow.AddDays(-5),
|
||||
Modified = DateTime.UtcNow,
|
||||
StatusCode = 3,
|
||||
Name = "Child"
|
||||
}
|
||||
};
|
||||
|
||||
var binary = parent.ToBinary();
|
||||
var result = binary.BinaryTo<TestParentEntityWithDateTimeChild>();
|
||||
|
||||
Assert.IsNotNull(result);
|
||||
Assert.AreEqual(1, result.ParentId);
|
||||
Assert.AreEqual("Parent", result.ParentName);
|
||||
Assert.IsNotNull(result.Child);
|
||||
Assert.AreEqual(100, result.Child.Id);
|
||||
Assert.AreEqual(200, result.Child.IntValue);
|
||||
Assert.AreEqual(parent.Child.Created.Ticks, result.Child.Created.Ticks, "Child.Created should match");
|
||||
Assert.AreEqual(parent.Child.Modified.Ticks, result.Child.Modified.Ticks, "Child.Modified should match");
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void Deserialize_EntityWithCollectionContainingDateTimeItems_RoundTrip()
|
||||
{
|
||||
var parent = new TestParentWithDateTimeItemCollection
|
||||
{
|
||||
Id = 1,
|
||||
Name = "Parent",
|
||||
Created = DateTime.UtcNow.AddDays(-10),
|
||||
Items = CreateDateTimeEntities(5)
|
||||
};
|
||||
|
||||
var binary = parent.ToBinary();
|
||||
var result = binary.BinaryTo<TestParentWithDateTimeItemCollection>();
|
||||
|
||||
Assert.IsNotNull(result);
|
||||
Assert.AreEqual(1, result.Id);
|
||||
Assert.AreEqual("Parent", result.Name);
|
||||
Assert.AreEqual(parent.Created.Ticks, result.Created.Ticks, "Parent.Created should match");
|
||||
Assert.IsNotNull(result.Items);
|
||||
Assert.AreEqual(5, result.Items.Count);
|
||||
|
||||
for (int i = 0; i < 5; i++)
|
||||
{
|
||||
var originalItem = parent.Items[i];
|
||||
var deserializedItem = result.Items[i];
|
||||
|
||||
Assert.AreEqual(originalItem.Id, deserializedItem.Id, $"Items[{i}].Id should match");
|
||||
Assert.AreEqual(originalItem.IntValue, deserializedItem.IntValue, $"Items[{i}].IntValue should match");
|
||||
Assert.AreEqual(originalItem.Created.Ticks, deserializedItem.Created.Ticks, $"Items[{i}].Created should match");
|
||||
Assert.AreEqual(originalItem.Modified.Ticks, deserializedItem.Modified.Ticks, $"Items[{i}].Modified should match");
|
||||
}
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void Deserialize_ListOfParentEntitiesWithDateTimeChildCollections_RoundTrip()
|
||||
{
|
||||
var parents = CreateParentWithDateTimeItems(3, 3);
|
||||
var binary = parents.ToBinary();
|
||||
var result = binary.BinaryTo<List<TestParentWithDateTimeItemCollection>>();
|
||||
|
||||
Assert.IsNotNull(result);
|
||||
Assert.AreEqual(3, result.Count);
|
||||
|
||||
for (int p = 0; p < 3; p++)
|
||||
{
|
||||
var originalParent = parents[p];
|
||||
var deserializedParent = result[p];
|
||||
|
||||
Assert.AreEqual(originalParent.Id, deserializedParent.Id, $"Parent[{p}].Id should match");
|
||||
Assert.AreEqual(originalParent.Name, deserializedParent.Name, $"Parent[{p}].Name should match");
|
||||
Assert.AreEqual(originalParent.Created.Ticks, deserializedParent.Created.Ticks, $"Parent[{p}].Created should match");
|
||||
Assert.IsNotNull(deserializedParent.Items);
|
||||
Assert.AreEqual(3, deserializedParent.Items.Count, $"Parent[{p}].Items.Count should match");
|
||||
|
||||
for (int i = 0; i < 3; i++)
|
||||
{
|
||||
var originalItem = originalParent.Items![i];
|
||||
var deserializedItem = deserializedParent.Items[i];
|
||||
|
||||
Assert.AreEqual(originalItem.Id, deserializedItem.Id, $"Parent[{p}].Items[{i}].Id should match");
|
||||
Assert.AreEqual(originalItem.Created.Ticks, deserializedItem.Created.Ticks, $"Parent[{p}].Items[{i}].Created should match. Expected: {originalItem.Created}, Got: {deserializedItem.Created}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,486 @@
|
|||
using AyCode.Core.Extensions;
|
||||
using AyCode.Core.Serializers.Binaries;
|
||||
using System.Reflection;
|
||||
using static AyCode.Core.Tests.TestModels.AcSerializerModels;
|
||||
|
||||
namespace AyCode.Core.Tests.Serialization;
|
||||
|
||||
/// <summary>
|
||||
/// Diagnostic tests to help debug serialization issues.
|
||||
/// </summary>
|
||||
[TestClass]
|
||||
public class AcBinarySerializerDiagnosticTests
|
||||
{
|
||||
/// <summary>
|
||||
/// Diagnostic test to understand the exact binary structure.
|
||||
/// This test outputs the binary bytes to help debug production issues.
|
||||
/// </summary>
|
||||
[TestMethod]
|
||||
public void Diagnostic_StockTaking_BinaryStructure()
|
||||
{
|
||||
var stockTaking = new TestStockTakingWithInheritance
|
||||
{
|
||||
Id = 1,
|
||||
StartDateTime = new DateTime(2025, 1, 24, 10, 0, 0, DateTimeKind.Utc),
|
||||
IsClosed = false,
|
||||
Creator = 6,
|
||||
Created = new DateTime(2025, 1, 24, 15, 25, 0, DateTimeKind.Utc),
|
||||
Modified = new DateTime(2025, 1, 24, 16, 0, 0, DateTimeKind.Utc),
|
||||
StockTakingItems = null
|
||||
};
|
||||
|
||||
var binary = stockTaking.ToBinary();
|
||||
|
||||
var hexDump = string.Join(" ", binary.Select(b => b.ToString("X2")));
|
||||
Console.WriteLine($"Binary length: {binary.Length}");
|
||||
Console.WriteLine($"Binary hex: {hexDump}");
|
||||
|
||||
for (int i = 0; i < binary.Length; i++)
|
||||
{
|
||||
if (binary[i] == 214)
|
||||
{
|
||||
Console.WriteLine($"Found 0xD6 at position {i}");
|
||||
}
|
||||
}
|
||||
|
||||
var result = binary.BinaryTo<TestStockTakingWithInheritance>();
|
||||
|
||||
Assert.IsNotNull(result);
|
||||
Assert.AreEqual(6, result.Creator);
|
||||
Assert.AreEqual(stockTaking.Created.Ticks, result.Created.Ticks);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Test with nested list to ensure proper stream positioning.
|
||||
/// </summary>
|
||||
[TestMethod]
|
||||
public void Diagnostic_StockTaking_WithNestedItems()
|
||||
{
|
||||
var stockTaking = new TestStockTakingWithInheritance
|
||||
{
|
||||
Id = 1,
|
||||
StartDateTime = new DateTime(2025, 1, 24, 10, 0, 0, DateTimeKind.Utc),
|
||||
IsClosed = false,
|
||||
Creator = 6,
|
||||
Created = new DateTime(2025, 1, 24, 15, 25, 0, DateTimeKind.Utc),
|
||||
Modified = new DateTime(2025, 1, 24, 16, 0, 0, DateTimeKind.Utc),
|
||||
StockTakingItems = new List<TestStockTakingItemWithInheritance>
|
||||
{
|
||||
new()
|
||||
{
|
||||
Id = 10,
|
||||
StockTakingId = 1,
|
||||
ProductId = 100,
|
||||
IsMeasured = true,
|
||||
OriginalStockQuantity = 50,
|
||||
MeasuredStockQuantity = 48,
|
||||
Created = new DateTime(2025, 1, 24, 14, 0, 0, DateTimeKind.Utc),
|
||||
Modified = new DateTime(2025, 1, 24, 14, 30, 0, DateTimeKind.Utc),
|
||||
StockTakingItemPallets = null
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
var binary = stockTaking.ToBinary();
|
||||
Console.WriteLine($"Binary length with 1 item: {binary.Length}");
|
||||
|
||||
var result = binary.BinaryTo<TestStockTakingWithInheritance>();
|
||||
|
||||
Assert.IsNotNull(result);
|
||||
Assert.AreEqual(6, result.Creator, "Creator should be 6");
|
||||
Assert.AreEqual(stockTaking.Created.Ticks, result.Created.Ticks,
|
||||
$"Created mismatch. Expected: {stockTaking.Created}, Got: {result.Created}");
|
||||
Assert.IsNotNull(result.StockTakingItems);
|
||||
Assert.AreEqual(1, result.StockTakingItems.Count);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// CRITICAL TEST: Verify property order is consistent.
|
||||
/// This test checks that the reflection-based property order matches
|
||||
/// what's expected for serialization/deserialization.
|
||||
/// </summary>
|
||||
[TestMethod]
|
||||
public void Diagnostic_PropertyOrder_InheritanceHierarchy()
|
||||
{
|
||||
var type = typeof(SimStockTaking);
|
||||
var props = type.GetProperties(BindingFlags.Public | BindingFlags.Instance);
|
||||
|
||||
Console.WriteLine($"Properties of {type.Name} (count: {props.Length}):");
|
||||
for (int i = 0; i < props.Length; i++)
|
||||
{
|
||||
var prop = props[i];
|
||||
Console.WriteLine($" [{i}] {prop.Name} : {prop.PropertyType.Name} (declared in: {prop.DeclaringType?.Name})");
|
||||
}
|
||||
|
||||
// The exact order may vary by platform!
|
||||
// Log it so we can compare server vs client
|
||||
Assert.IsTrue(props.Length >= 7, "Should have at least 7 properties");
|
||||
|
||||
// Check that all expected properties exist
|
||||
var propNames = props.Select(p => p.Name).ToHashSet();
|
||||
Assert.IsTrue(propNames.Contains("Id"), "Should have Id");
|
||||
Assert.IsTrue(propNames.Contains("StartDateTime"), "Should have StartDateTime");
|
||||
Assert.IsTrue(propNames.Contains("IsClosed"), "Should have IsClosed");
|
||||
Assert.IsTrue(propNames.Contains("Creator"), "Should have Creator");
|
||||
Assert.IsTrue(propNames.Contains("Created"), "Should have Created");
|
||||
Assert.IsTrue(propNames.Contains("Modified"), "Should have Modified");
|
||||
Assert.IsTrue(propNames.Contains("StockTakingItems"), "Should have StockTakingItems");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// CRITICAL REGRESSION TEST: Simulates exact production hierarchy.
|
||||
/// StockTaking : MgStockTaking<StockTakingItem> : MgEntityBase : BaseEntity
|
||||
/// </summary>
|
||||
[TestMethod]
|
||||
public void Diagnostic_SimStockTaking_RoundTrip()
|
||||
{
|
||||
var stockTaking = new SimStockTaking
|
||||
{
|
||||
Id = 1,
|
||||
StartDateTime = new DateTime(2025, 1, 24, 10, 0, 0, DateTimeKind.Utc),
|
||||
IsClosed = false,
|
||||
Creator = 6, // The exact value from production error
|
||||
Created = new DateTime(2025, 1, 24, 15, 25, 0, DateTimeKind.Utc),
|
||||
Modified = new DateTime(2025, 1, 24, 16, 0, 0, DateTimeKind.Utc),
|
||||
StockTakingItems = null // loadRelations = false means no items
|
||||
};
|
||||
|
||||
var binary = stockTaking.ToBinary();
|
||||
|
||||
// Log the property names in the header
|
||||
Console.WriteLine($"Binary length: {binary.Length}");
|
||||
|
||||
var result = binary.BinaryTo<SimStockTaking>();
|
||||
|
||||
Assert.IsNotNull(result);
|
||||
Assert.AreEqual(1, result.Id, "Id should be 1");
|
||||
Assert.AreEqual(6, result.Creator, "Creator should be 6 - this is where the bug occurs!");
|
||||
Assert.AreEqual(stockTaking.Created.Ticks, result.Created.Ticks,
|
||||
$"Created mismatch. Expected: {stockTaking.Created}, Got: {result.Created}");
|
||||
Assert.AreEqual(stockTaking.StartDateTime, result.StartDateTime);
|
||||
Assert.IsFalse(result.IsClosed);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Test List of SimStockTaking - exact production scenario.
|
||||
/// </summary>
|
||||
[TestMethod]
|
||||
public void Diagnostic_ListOfSimStockTaking_RoundTrip()
|
||||
{
|
||||
var stockTakings = Enumerable.Range(1, 3).Select(i => new SimStockTaking
|
||||
{
|
||||
Id = i,
|
||||
StartDateTime = DateTime.UtcNow.AddDays(-i),
|
||||
IsClosed = i % 2 == 0,
|
||||
Creator = i,
|
||||
Created = DateTime.UtcNow.AddDays(-i),
|
||||
Modified = DateTime.UtcNow,
|
||||
StockTakingItems = null
|
||||
}).ToList();
|
||||
|
||||
var binary = stockTakings.ToBinary();
|
||||
var result = binary.BinaryTo<List<SimStockTaking>>();
|
||||
|
||||
Assert.IsNotNull(result);
|
||||
Assert.AreEqual(3, result.Count);
|
||||
|
||||
for (int i = 0; i < 3; i++)
|
||||
{
|
||||
var original = stockTakings[i];
|
||||
var deserialized = result[i];
|
||||
|
||||
Assert.AreEqual(original.Id, deserialized.Id, $"[{i}] Id mismatch");
|
||||
Assert.AreEqual(original.Creator, deserialized.Creator, $"[{i}] Creator mismatch");
|
||||
Assert.AreEqual(original.Created.Ticks, deserialized.Created.Ticks,
|
||||
$"[{i}] Created mismatch. Expected: {original.Created}, Got: {deserialized.Created}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Diagnostic: Check what PropertyType the reflection returns for generic type parameter.
|
||||
/// </summary>
|
||||
[TestMethod]
|
||||
public void Diagnostic_GenericProperty_ReflectionType()
|
||||
{
|
||||
var parentType = typeof(ConcreteParent);
|
||||
var itemsProp = parentType.GetProperty("Items");
|
||||
|
||||
Assert.IsNotNull(itemsProp);
|
||||
|
||||
var propType = itemsProp.PropertyType;
|
||||
Console.WriteLine($"PropertyType: {propType}");
|
||||
Console.WriteLine($"PropertyType.FullName: {propType.FullName}");
|
||||
Console.WriteLine($"IsGenericType: {propType.IsGenericType}");
|
||||
|
||||
if (propType.IsGenericType)
|
||||
{
|
||||
var args = propType.GetGenericArguments();
|
||||
Console.WriteLine($"GenericArguments.Length: {args.Length}");
|
||||
foreach (var arg in args)
|
||||
{
|
||||
Console.WriteLine($" GenericArgument: {arg.FullName}");
|
||||
}
|
||||
}
|
||||
|
||||
Assert.IsTrue(propType.IsGenericType);
|
||||
var elementType = propType.GetGenericArguments()[0];
|
||||
Assert.AreEqual(typeof(GenericItemImpl), elementType,
|
||||
"Element type should be GenericItemImpl, not IGenericItem");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// CRITICAL BUG REPRODUCTION: StockTakingItems is null (loadRelations = false)
|
||||
/// This test verifies that property-index-based serialization correctly handles null properties.
|
||||
/// </summary>
|
||||
[TestMethod]
|
||||
public void Diagnostic_NullStockTakingItems_VerifyPropertyIndices()
|
||||
{
|
||||
var stockTaking = new SimStockTaking
|
||||
{
|
||||
Id = 1,
|
||||
StartDateTime = new DateTime(2025, 1, 24, 10, 0, 0, DateTimeKind.Utc),
|
||||
IsClosed = false,
|
||||
Creator = 6, // The exact value from production error (becomes TinyInt 0xD6)
|
||||
Created = new DateTime(2025, 1, 24, 15, 25, 0, DateTimeKind.Utc),
|
||||
Modified = new DateTime(2025, 1, 24, 16, 0, 0, DateTimeKind.Utc),
|
||||
StockTakingItems = null // THIS IS THE KEY - loadRelations = false
|
||||
};
|
||||
|
||||
var binary = stockTaking.ToBinary();
|
||||
|
||||
// Log the binary structure
|
||||
Console.WriteLine($"Binary length: {binary.Length}");
|
||||
Console.WriteLine($"Binary hex: {string.Join(" ", binary.Select(b => b.ToString("X2")))}");
|
||||
|
||||
// === HEADER PARSING (using BinaryTypeCode constants) ===
|
||||
var pos = 0;
|
||||
var version = binary[pos++];
|
||||
Console.WriteLine($"Version: {version}");
|
||||
|
||||
var headerFlags = binary[pos++];
|
||||
Console.WriteLine($"Header flags: 0x{headerFlags:X2}");
|
||||
|
||||
bool hasMetadata = (headerFlags & BinaryTypeCode.HeaderFlag_Metadata) != 0;
|
||||
bool hasRefOnlyId = (headerFlags & BinaryTypeCode.HeaderFlag_RefHandling_OnlyId) != 0;
|
||||
bool hasRefAll = (headerFlags & BinaryTypeCode.HeaderFlag_RefHandling_All) != 0;
|
||||
bool hasCacheCount = (headerFlags & BinaryTypeCode.HeaderFlag_HasCacheCount) != 0;
|
||||
Console.WriteLine($" Metadata={hasMetadata}, RefOnlyId={hasRefOnlyId}, RefAll={hasRefAll}, HasCacheCount={hasCacheCount}");
|
||||
|
||||
if (hasCacheCount)
|
||||
{
|
||||
var ccByte = binary[pos];
|
||||
int cacheCount = (ccByte & 0x80) == 0 ? ccByte : (ccByte & 0x7F) | (binary[pos + 1] << 7);
|
||||
pos += (ccByte & 0x80) == 0 ? 1 : 2;
|
||||
Console.WriteLine($"Cache count: {cacheCount}");
|
||||
}
|
||||
|
||||
Console.WriteLine($"\n=== BODY (starts at position {pos}) ===");
|
||||
|
||||
// Read the object marker — can be FixObj slot (0..SlotCount-1) or explicit marker
|
||||
var objectMarker = binary[pos++];
|
||||
bool isFixObj = objectMarker < BinaryTypeCode.SlotCount;
|
||||
Console.WriteLine($"Object marker: 0x{objectMarker:X2} (FixObj={isFixObj}, " +
|
||||
$"Object=0x{BinaryTypeCode.Object:X2}, ObjectRefFirst=0x{BinaryTypeCode.ObjectRefFirst:X2}, " +
|
||||
$"ObjectWithMetadata=0x{BinaryTypeCode.ObjectWithMetadata:X2})");
|
||||
|
||||
Assert.IsTrue(
|
||||
isFixObj
|
||||
|| objectMarker == BinaryTypeCode.Object
|
||||
|| objectMarker == BinaryTypeCode.ObjectWithMetadata
|
||||
|| objectMarker == BinaryTypeCode.ObjectRefFirst
|
||||
|| objectMarker == BinaryTypeCode.ObjectWithMetadataRefFirst,
|
||||
$"Expected an object marker, got 0x{objectMarker:X2}");
|
||||
|
||||
// If ObjectWithMetadata, skip inline metadata
|
||||
if (objectMarker is BinaryTypeCode.ObjectWithMetadata or BinaryTypeCode.ObjectWithMetadataRefFirst)
|
||||
{
|
||||
var propNameHash = BitConverter.ToInt32(binary, pos);
|
||||
pos += 4;
|
||||
Console.WriteLine($"PropNameHash: 0x{propNameHash:X8}");
|
||||
|
||||
var pcByte = binary[pos];
|
||||
int inlinePropCount = (pcByte & 0x80) == 0 ? pcByte : (pcByte & 0x7F) | (binary[pos + 1] << 7);
|
||||
pos += (pcByte & 0x80) == 0 ? 1 : 2;
|
||||
Console.WriteLine($"Inline metadata propCount: {inlinePropCount}");
|
||||
|
||||
for (int h = 0; h < inlinePropCount; h++)
|
||||
{
|
||||
var hash = BitConverter.ToInt32(binary, pos);
|
||||
Console.WriteLine($" Property hash [{h}]: 0x{hash:X8}");
|
||||
pos += 4;
|
||||
}
|
||||
}
|
||||
|
||||
// If RefFirst marker, read VarUInt cache index
|
||||
if (objectMarker is BinaryTypeCode.ObjectRefFirst or BinaryTypeCode.ObjectWithMetadataRefFirst)
|
||||
{
|
||||
var rByte = binary[pos];
|
||||
int refCacheIndex = (rByte & 0x80) == 0 ? rByte : (rByte & 0x7F) | (binary[pos + 1] << 7);
|
||||
pos += (rByte & 0x80) == 0 ? 1 : 2;
|
||||
Console.WriteLine($"RefCacheIndex: {refCacheIndex}");
|
||||
}
|
||||
|
||||
// Markerless format: properties are written in order, no property count header
|
||||
Console.WriteLine($"\n=== BODY PROPERTIES (remaining {binary.Length - pos} bytes) ===");
|
||||
int propIdx = 0;
|
||||
while (pos < binary.Length)
|
||||
{
|
||||
var b = binary[pos];
|
||||
if (b == BinaryTypeCode.DateTime)
|
||||
{
|
||||
Console.WriteLine($" Property [{propIdx}]: DateTime (1+8 bytes)");
|
||||
pos += 9; // marker + 8 bytes ticks
|
||||
}
|
||||
else if (BinaryTypeCode.IsTinyInt(b))
|
||||
{
|
||||
Console.WriteLine($" Property [{propIdx}]: TinyInt value={BinaryTypeCode.DecodeTinyInt(b)} (0x{b:X2})");
|
||||
pos += 1;
|
||||
}
|
||||
else if (b == BinaryTypeCode.False)
|
||||
{
|
||||
Console.WriteLine($" Property [{propIdx}]: Boolean: false");
|
||||
pos += 1;
|
||||
}
|
||||
else if (b == BinaryTypeCode.True)
|
||||
{
|
||||
Console.WriteLine($" Property [{propIdx}]: Boolean: true");
|
||||
pos += 1;
|
||||
}
|
||||
else if (b == BinaryTypeCode.Null)
|
||||
{
|
||||
Console.WriteLine($" Property [{propIdx}]: Null");
|
||||
pos += 1;
|
||||
}
|
||||
else if (b == BinaryTypeCode.PropertySkip)
|
||||
{
|
||||
Console.WriteLine($" Property [{propIdx}]: PropertySkip (default/null)");
|
||||
pos += 1;
|
||||
}
|
||||
else
|
||||
{
|
||||
Console.WriteLine($" Property [{propIdx}]: Unknown type: 0x{b:X2}");
|
||||
break;
|
||||
}
|
||||
propIdx++;
|
||||
}
|
||||
|
||||
// Deserialize and verify
|
||||
var result = binary.BinaryTo<SimStockTaking>();
|
||||
|
||||
Assert.IsNotNull(result);
|
||||
Assert.AreEqual(1, result.Id, "Id should be 1");
|
||||
Assert.AreEqual(6, result.Creator,
|
||||
$"Creator should be 6. Got: {result.Creator}. " +
|
||||
$"If this fails with a very large number, it means DateTime bytes were interpreted as int!");
|
||||
Assert.AreEqual(stockTaking.Created, result.Created,
|
||||
$"Created mismatch. Expected: {stockTaking.Created}, Got: {result.Created}. " +
|
||||
$"If Created has wrong value, deserializer read wrong bytes!");
|
||||
Assert.AreEqual(stockTaking.StartDateTime, result.StartDateTime);
|
||||
Assert.IsFalse(result.IsClosed);
|
||||
Assert.IsNull(result.StockTakingItems, "StockTakingItems should remain null");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Test to verify property order consistency between serializer and deserializer.
|
||||
/// </summary>
|
||||
[TestMethod]
|
||||
public void Diagnostic_VerifyPropertyOrderConsistency()
|
||||
{
|
||||
// Get serializer's property order
|
||||
var serializerType = typeof(AcBinarySerializer);
|
||||
var metadataCacheField = serializerType.GetField("TypeMetadataCache",
|
||||
BindingFlags.NonPublic | BindingFlags.Static);
|
||||
|
||||
// Clear cache to force fresh metadata creation
|
||||
// (This helps ensure we're testing the actual order)
|
||||
|
||||
var type = typeof(SimStockTaking);
|
||||
var props = type.GetProperties(BindingFlags.Public | BindingFlags.Instance)
|
||||
.Where(p => p.CanRead && p.GetIndexParameters().Length == 0)
|
||||
.ToArray();
|
||||
|
||||
Console.WriteLine($"Properties of {type.Name} (reflection order):");
|
||||
for (int i = 0; i < props.Length; i++)
|
||||
{
|
||||
var prop = props[i];
|
||||
Console.WriteLine($" [{i}] {prop.Name} : {prop.PropertyType.Name}");
|
||||
}
|
||||
|
||||
// Verify Creator comes BEFORE Created in the reflection order
|
||||
var creatorIndex = Array.FindIndex(props, p => p.Name == "Creator");
|
||||
var createdIndex = Array.FindIndex(props, p => p.Name == "Created");
|
||||
var stockTakingItemsIndex = Array.FindIndex(props, p => p.Name == "StockTakingItems");
|
||||
|
||||
Console.WriteLine($"\nKey indices:");
|
||||
Console.WriteLine($" StockTakingItems: {stockTakingItemsIndex}");
|
||||
Console.WriteLine($" Creator: {creatorIndex}");
|
||||
Console.WriteLine($" Created: {createdIndex}");
|
||||
|
||||
// The bug scenario: if StockTakingItems is skipped during serialization,
|
||||
// but the deserializer still expects it at the original index position,
|
||||
// then Creator (index 3) would be read when expecting StockTakingItems (index 2)
|
||||
// and Created (index 4) would be read when expecting Creator (index 3)
|
||||
|
||||
Assert.IsTrue(stockTakingItemsIndex >= 0, "StockTakingItems should exist");
|
||||
Assert.IsTrue(creatorIndex >= 0, "Creator should exist");
|
||||
Assert.IsTrue(createdIndex >= 0, "Created should exist");
|
||||
|
||||
// In the class definition order:
|
||||
// StockTakingItems comes BEFORE Creator and Created
|
||||
Assert.IsTrue(stockTakingItemsIndex < creatorIndex,
|
||||
"StockTakingItems should come before Creator");
|
||||
Assert.IsTrue(creatorIndex < createdIndex,
|
||||
"Creator should come before Created");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Test multiple StockTakings with null StockTakingItems - exact production scenario.
|
||||
/// </summary>
|
||||
[TestMethod]
|
||||
public void Diagnostic_MultipleStockTakings_NullItems()
|
||||
{
|
||||
var stockTakings = new List<SimStockTaking>
|
||||
{
|
||||
new()
|
||||
{
|
||||
Id = 1,
|
||||
StartDateTime = new DateTime(2025, 1, 24, 10, 0, 0, DateTimeKind.Utc),
|
||||
IsClosed = false,
|
||||
Creator = 6,
|
||||
Created = new DateTime(2025, 1, 24, 15, 25, 0, DateTimeKind.Utc),
|
||||
Modified = new DateTime(2025, 1, 24, 16, 0, 0, DateTimeKind.Utc),
|
||||
StockTakingItems = null
|
||||
},
|
||||
new()
|
||||
{
|
||||
Id = 2,
|
||||
StartDateTime = new DateTime(2025, 1, 23, 9, 0, 0, DateTimeKind.Utc),
|
||||
IsClosed = true,
|
||||
Creator = 12,
|
||||
Created = new DateTime(2025, 1, 23, 14, 0, 0, DateTimeKind.Utc),
|
||||
Modified = new DateTime(2025, 1, 23, 15, 30, 0, DateTimeKind.Utc),
|
||||
StockTakingItems = null
|
||||
}
|
||||
};
|
||||
|
||||
var binary = stockTakings.ToBinary();
|
||||
Console.WriteLine($"Binary length for 2 StockTakings: {binary.Length}");
|
||||
|
||||
var result = binary.BinaryTo<List<SimStockTaking>>();
|
||||
|
||||
Assert.IsNotNull(result);
|
||||
Assert.AreEqual(2, result.Count);
|
||||
|
||||
// First item
|
||||
Assert.AreEqual(1, result[0].Id);
|
||||
Assert.AreEqual(6, result[0].Creator, "First item Creator should be 6");
|
||||
Assert.AreEqual(stockTakings[0].Created, result[0].Created,
|
||||
$"First item Created mismatch. Expected: {stockTakings[0].Created}, Got: {result[0].Created}");
|
||||
|
||||
// Second item
|
||||
Assert.AreEqual(2, result[1].Id);
|
||||
Assert.AreEqual(12, result[1].Creator, "Second item Creator should be 12");
|
||||
Assert.AreEqual(stockTakings[1].Created, result[1].Created,
|
||||
$"Second item Created mismatch. Expected: {stockTakings[1].Created}, Got: {result[1].Created}");
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,161 @@
|
|||
using AyCode.Core.Extensions;
|
||||
using static AyCode.Core.Tests.TestModels.AcSerializerModels;
|
||||
|
||||
namespace AyCode.Core.Tests.Serialization;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for generic type parameter handling in serialization.
|
||||
/// </summary>
|
||||
[TestClass]
|
||||
public class AcBinarySerializerGenericTypeTests
|
||||
{
|
||||
/// <summary>
|
||||
/// CRITICAL REGRESSION TEST: Generic type parameter causing metadata mismatch.
|
||||
///
|
||||
/// The bug pattern:
|
||||
/// 1. Parent class uses generic type parameter: GenericParent<TItem> where TItem : IGenericItem
|
||||
/// 2. RegisterMetadataForType uses GetCollectionElementType which returns TItem (the interface/constraint)
|
||||
/// 3. But serialization uses runtime type (GenericItemImpl) which has MORE properties
|
||||
/// 4. Property indices in metadata table don't match what's being serialized
|
||||
/// 5. Deserialization reads wrong property indices ? type mismatch!
|
||||
/// </summary>
|
||||
[TestMethod]
|
||||
public void Deserialize_GenericTypeParameter_RuntimeTypeHasMoreProperties()
|
||||
{
|
||||
var parent = new ConcreteParent
|
||||
{
|
||||
Id = 1,
|
||||
Name = "Parent",
|
||||
Creator = 6,
|
||||
Created = new DateTime(2025, 1, 24, 15, 25, 0, DateTimeKind.Utc),
|
||||
Modified = DateTime.UtcNow,
|
||||
Items = new List<GenericItemImpl>
|
||||
{
|
||||
new()
|
||||
{
|
||||
Id = 10,
|
||||
Name = "Item1",
|
||||
ExtraInt = 100,
|
||||
Created = DateTime.UtcNow.AddHours(-1),
|
||||
Modified = DateTime.UtcNow,
|
||||
Description = "Description1"
|
||||
},
|
||||
new()
|
||||
{
|
||||
Id = 20,
|
||||
Name = "Item2",
|
||||
ExtraInt = 200,
|
||||
Created = DateTime.UtcNow.AddHours(-2),
|
||||
Modified = DateTime.UtcNow,
|
||||
Description = "Description2"
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
var binary = parent.ToBinary();
|
||||
var result = binary.BinaryTo<ConcreteParent>();
|
||||
|
||||
Assert.IsNotNull(result);
|
||||
Assert.AreEqual(1, result.Id);
|
||||
Assert.AreEqual(6, result.Creator, "Creator should be 6");
|
||||
Assert.AreEqual(parent.Created.Ticks, result.Created.Ticks,
|
||||
$"Created mismatch. Expected: {parent.Created}, Got: {result.Created}");
|
||||
|
||||
Assert.IsNotNull(result.Items);
|
||||
Assert.AreEqual(2, result.Items.Count);
|
||||
|
||||
Assert.AreEqual(10, result.Items[0].Id);
|
||||
Assert.AreEqual(100, result.Items[0].ExtraInt, "ExtraInt should be preserved");
|
||||
Assert.AreEqual(parent.Items[0].Created.Ticks, result.Items[0].Created.Ticks,
|
||||
"Item Created should match");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Test with list of generic parents.
|
||||
/// </summary>
|
||||
[TestMethod]
|
||||
public void Deserialize_ListOfGenericParents_AllItemsCorrect()
|
||||
{
|
||||
var parents = Enumerable.Range(1, 5).Select(p => new ConcreteParent
|
||||
{
|
||||
Id = p,
|
||||
Name = $"Parent_{p}",
|
||||
Creator = p,
|
||||
Created = DateTime.UtcNow.AddDays(-p),
|
||||
Modified = DateTime.UtcNow,
|
||||
Items = Enumerable.Range(1, 3).Select(i => new GenericItemImpl
|
||||
{
|
||||
Id = p * 100 + i,
|
||||
Name = $"Item_{p}_{i}",
|
||||
ExtraInt = p * 10 + i,
|
||||
Created = DateTime.UtcNow.AddHours(-i),
|
||||
Modified = DateTime.UtcNow,
|
||||
Description = $"Desc_{p}_{i}"
|
||||
}).ToList()
|
||||
}).ToList();
|
||||
|
||||
var binary = parents.ToBinary();
|
||||
var result = binary.BinaryTo<List<ConcreteParent>>();
|
||||
|
||||
Assert.IsNotNull(result);
|
||||
Assert.AreEqual(5, result.Count);
|
||||
|
||||
for (int p = 0; p < 5; p++)
|
||||
{
|
||||
var original = parents[p];
|
||||
var deserialized = result[p];
|
||||
|
||||
Assert.AreEqual(original.Id, deserialized.Id, $"Parent[{p}].Id mismatch");
|
||||
Assert.AreEqual(original.Creator, deserialized.Creator, $"Parent[{p}].Creator mismatch");
|
||||
Assert.AreEqual(original.Created.Ticks, deserialized.Created.Ticks,
|
||||
$"Parent[{p}].Created mismatch. Expected: {original.Created}, Got: {deserialized.Created}");
|
||||
|
||||
Assert.IsNotNull(deserialized.Items, $"Parent[{p}].Items is null");
|
||||
Assert.AreEqual(3, deserialized.Items.Count);
|
||||
|
||||
for (int i = 0; i < 3; i++)
|
||||
{
|
||||
var origItem = original.Items![i];
|
||||
var deserItem = deserialized.Items[i];
|
||||
|
||||
Assert.AreEqual(origItem.Id, deserItem.Id, $"Parent[{p}].Items[{i}].Id mismatch");
|
||||
Assert.AreEqual(origItem.ExtraInt, deserItem.ExtraInt,
|
||||
$"Parent[{p}].Items[{i}].ExtraInt mismatch");
|
||||
Assert.AreEqual(origItem.Created.Ticks, deserItem.Created.Ticks,
|
||||
$"Parent[{p}].Items[{i}].Created mismatch");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Diagnostic: Check what PropertyType the reflection returns for generic type parameter.
|
||||
/// </summary>
|
||||
[TestMethod]
|
||||
public void Diagnostic_GenericProperty_ReflectionType()
|
||||
{
|
||||
var parentType = typeof(ConcreteParent);
|
||||
var itemsProp = parentType.GetProperty("Items");
|
||||
|
||||
Assert.IsNotNull(itemsProp);
|
||||
|
||||
var propType = itemsProp.PropertyType;
|
||||
Console.WriteLine($"PropertyType: {propType}");
|
||||
Console.WriteLine($"PropertyType.FullName: {propType.FullName}");
|
||||
Console.WriteLine($"IsGenericType: {propType.IsGenericType}");
|
||||
|
||||
if (propType.IsGenericType)
|
||||
{
|
||||
var args = propType.GetGenericArguments();
|
||||
Console.WriteLine($"GenericArguments.Length: {args.Length}");
|
||||
foreach (var arg in args)
|
||||
{
|
||||
Console.WriteLine($" GenericArgument: {arg.FullName}");
|
||||
}
|
||||
}
|
||||
|
||||
Assert.IsTrue(propType.IsGenericType);
|
||||
var elementType = propType.GetGenericArguments()[0];
|
||||
Assert.AreEqual(typeof(GenericItemImpl), elementType,
|
||||
"Element type should be GenericItemImpl, not IGenericItem");
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,653 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using AyCode.Core.Extensions;
|
||||
using AyCode.Core.Serializers;
|
||||
using AyCode.Core.Serializers.Binaries;
|
||||
using AyCode.Core.Tests.TestModels;
|
||||
using Microsoft.VisualStudio.TestTools.UnitTesting;
|
||||
|
||||
namespace AyCode.Core.Tests.Serialization;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for IId-based reference handling in Binary serializer.
|
||||
/// Two scenarios:
|
||||
/// 1. Same instance referenced multiple times (object identity)
|
||||
/// 2. Different instances with same IId.Id (IId-based deduplication)
|
||||
///
|
||||
/// Tests verify BOTH:
|
||||
/// - Serialized output uses ObjectRef (not redundant full objects)
|
||||
/// - Deserialized result maintains reference identity
|
||||
/// </summary>
|
||||
[TestClass]
|
||||
public class AcBinarySerializerIIdReferenceTests
|
||||
{
|
||||
#region Helper Methods
|
||||
|
||||
/// <summary>
|
||||
/// Counts occurrences of ObjectRef in binary data.
|
||||
/// Uses BinaryTypeCode.ObjectRef constant to stay in sync with format changes.
|
||||
/// </summary>
|
||||
private static int CountObjectRefs(byte[] binary, bool writeBinaryToConsole = true)
|
||||
{
|
||||
if (writeBinaryToConsole) WriteBinaryToConsole(binary);
|
||||
|
||||
var count = 0;
|
||||
for (var i = 0; i < binary.Length; i++)
|
||||
{
|
||||
if (binary[i] == BinaryTypeCode.ObjectRef)
|
||||
count++;
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
private static void WriteBinaryToConsole(byte[] binary)
|
||||
{
|
||||
Console.WriteLine();
|
||||
Console.WriteLine(BitConverter.ToString(binary));
|
||||
Console.WriteLine();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Counts occurrences of a string in binary data (UTF8).
|
||||
/// </summary>
|
||||
private static int CountStringOccurrences(byte[] binary, string searchString)
|
||||
{
|
||||
var searchBytes = System.Text.Encoding.UTF8.GetBytes(searchString);
|
||||
var count = 0;
|
||||
for (var i = 0; i <= binary.Length - searchBytes.Length; i++)
|
||||
{
|
||||
var match = true;
|
||||
for (var j = 0; j < searchBytes.Length; j++)
|
||||
{
|
||||
if (binary[i + j] != searchBytes[j])
|
||||
{
|
||||
match = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (match) count++;
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Scenario 1: Same Instance (Object Identity)
|
||||
|
||||
/// <summary>
|
||||
/// SCENARIO 1: Same instance referenced multiple times.
|
||||
/// Tests all ReferenceHandling modes: None, OnlyId, All
|
||||
/// </summary>
|
||||
[TestMethod]
|
||||
[DataRow(true, true)]
|
||||
[DataRow(false, true)]
|
||||
[DataRow(true, false)]
|
||||
[DataRow(false, false)]
|
||||
public void SameInstance_SerializeAndDeserialize(bool useSgen, bool useMeta)
|
||||
{
|
||||
var modes = new[]
|
||||
{
|
||||
ReferenceHandlingMode.None,
|
||||
ReferenceHandlingMode.OnlyId,
|
||||
ReferenceHandlingMode.All
|
||||
};
|
||||
|
||||
foreach (var mode in modes)
|
||||
{
|
||||
// Arrange: SAME instance used multiple times
|
||||
var userPreferences = new UserPreferences_All_True();
|
||||
var sharedTag = new SharedTag_All_True { Id = 1, Name = "ImportantTag", Color = "#FF0000" };
|
||||
var sharedUser = new SharedUser_All_True { Id = 1, Preferences = userPreferences };
|
||||
|
||||
var order = new TestOrder_Circ_Ref
|
||||
{
|
||||
Id = 1,
|
||||
OrderNumber = "ORD-001",
|
||||
PrimaryTag = sharedTag,
|
||||
Owner = sharedUser,
|
||||
Items =
|
||||
[
|
||||
new TestOrderItem_Circ_Ref { Id = 1, ProductName = "Product-A", Tag = sharedTag, Assignee = sharedUser },
|
||||
new TestOrderItem_Circ_Ref { Id = 2, ProductName = "Product-B", Tag = sharedTag, Assignee = new SharedUser_All_True { Id = 2, Preferences = userPreferences }},
|
||||
new TestOrderItem_Circ_Ref { Id = 3, ProductName = "Product-C", Tag = sharedTag, Assignee = new SharedUser_All_True { Id = 3, Preferences = userPreferences } }
|
||||
]
|
||||
};
|
||||
|
||||
//order.Parent = order.Items[1];
|
||||
|
||||
if (mode != ReferenceHandlingMode.None) order.Parent = order.Items[1];
|
||||
else order.Parent = userPreferences;
|
||||
|
||||
order.Items[1].ParentOrder = order;
|
||||
|
||||
var options = new AcBinarySerializerOptions
|
||||
{
|
||||
ReferenceHandling = mode,
|
||||
UseGeneratedCode = useSgen,
|
||||
UseMetadata = useMeta,
|
||||
MaxDepth = 10,
|
||||
// None mode has no ref tracking → the cycle (Items[1].ParentOrder = order) is unprotected.
|
||||
// Use Truncate so the recursion silently bottoms out with Null at the depth limit instead of throwing.
|
||||
MaxDepthBehavior = mode == ReferenceHandlingMode.None
|
||||
? MaxDepthBehavior.Truncate
|
||||
: MaxDepthBehavior.Throw
|
||||
};
|
||||
|
||||
Console.WriteLine($"\n========== ReferenceHandling: {options.ReferenceHandling}, UseSgen: {options.UseGeneratedCode}, UseMeta: {options.UseMetadata} ==========");
|
||||
|
||||
// Act
|
||||
var binary = AcBinarySerializer.Serialize(order, options);
|
||||
if (mode == ReferenceHandlingMode.None) WriteBinaryToConsole(binary);
|
||||
var result = binary.BinaryTo<TestOrder_Circ_Ref>(); // Options from header
|
||||
|
||||
var objectRefCount = CountObjectRefs(binary, false);
|
||||
Console.WriteLine($"Binary size: {binary.Length} bytes");
|
||||
Console.WriteLine($"ObjectRef count: {objectRefCount}");
|
||||
|
||||
Assert.IsNotNull(result, $"[{mode}] Deserialized result is null");
|
||||
//Assert.IsNotNull(result.Parent);
|
||||
Assert.IsNotNull(result.Owner);
|
||||
|
||||
// Assert based on mode
|
||||
switch (mode)
|
||||
{
|
||||
case ReferenceHandlingMode.None:
|
||||
// Truncate semantic: cycle bottoms out with Null at MaxDepth=10 → serialize succeeds, deserialize
|
||||
// produces a partial graph where deep cyclic references read as null. Data integrity at root +
|
||||
// first few levels still holds (verified below after the switch). CountObjectRefs raw byte scan
|
||||
// is unreliable in None mode — byte 65 (ObjectRef) == ASCII 'A', so "Product-A" produces false
|
||||
// positives. Skip count assertion; rely on data integrity checks instead.
|
||||
break;
|
||||
|
||||
case ReferenceHandlingMode.OnlyId:
|
||||
// sharedTag (Id=1) 4x → 3 ObjectRefs, sharedUser (Id=1) 2x → 1 ObjectRef = 4 total
|
||||
Assert.IsTrue(objectRefCount >= 4, $"[{mode}] Expected at least 4 ObjectRefs, found {objectRefCount}");
|
||||
// IId types should have reference identity
|
||||
Assert.AreSame(result.PrimaryTag, result.Items[0].Tag, $"[{mode}] Tag reference identity failed");
|
||||
Assert.AreSame(result.Owner, result.Items[0].Assignee, $"[{mode}] User reference identity failed");
|
||||
Assert.AreSame(result, result.Items[1].ParentOrder);
|
||||
Assert.AreSame(result.Parent, result.Items[1]);
|
||||
break;
|
||||
|
||||
case ReferenceHandlingMode.All:
|
||||
// IId types + Non-IId (UserPreferences_All_True) should have ObjectRefs
|
||||
Assert.IsTrue(objectRefCount >= 4, $"[{mode}] Expected at least 4 ObjectRefs, found {objectRefCount}");
|
||||
Assert.AreSame(result.PrimaryTag, result.Items[0].Tag, $"[{mode}] Tag reference identity failed");
|
||||
Assert.AreSame(result.Owner, result.Items[0].Assignee, $"[{mode}] User reference identity failed");
|
||||
|
||||
Assert.AreSame(result, result.Items[1].ParentOrder);
|
||||
Assert.AreSame(result.Parent, result.Items[1]);
|
||||
|
||||
// Non-IId should also have reference identity in All mode
|
||||
Assert.AreSame(result.Owner.Preferences, result.Items[0].Assignee.Preferences, $"[{mode}] UserPreferences_All_True reference identity failed - Non-IId should work in All mode!");
|
||||
break;
|
||||
}
|
||||
|
||||
// Data integrity - always check
|
||||
Assert.IsNotNull(result.PrimaryTag, $"[{mode}] PrimaryTag is null");
|
||||
Assert.AreEqual(1, result.PrimaryTag.Id, $"[{mode}] PrimaryTag.Id incorrect");
|
||||
Assert.AreEqual("ImportantTag", result.PrimaryTag.Name, $"[{mode}] PrimaryTag.Name incorrect");
|
||||
Assert.AreEqual(3, result.Items.Count, $"[{mode}] Items count incorrect");
|
||||
|
||||
Console.WriteLine($"[{mode}] PASSED ✓");
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Scenario 2: Different Instances with Same IId (IId-Based Deduplication)
|
||||
|
||||
/// <summary>
|
||||
/// SCENARIO 2: DIFFERENT instances with SAME IId.Id value.
|
||||
/// CRITICAL test - if IId-based deduplication works:
|
||||
/// - ObjectRef should be used in binary
|
||||
/// - Data should be complete after deserialize
|
||||
/// - References should be identical (AreSame)
|
||||
/// - Different TYPES with same int Id should NOT be confused!
|
||||
/// </summary>
|
||||
[TestMethod]
|
||||
public void DifferentInstances_SameIId_SerializeAndDeserialize()
|
||||
{
|
||||
// Arrange: DIFFERENT instances but SAME IId.Id
|
||||
// CRITICAL: Multiple DIFFERENT TYPES all have Id=1 - must not be confused!
|
||||
var sharedTag = new SharedTag_All_True { Id = 55, Name = "ImportantTag_55", Color = "#FF0000" };
|
||||
var order = new TestOrder_All_True
|
||||
{
|
||||
Id = 1,
|
||||
OrderNumber = "ORD-001",
|
||||
// All three types have Id=1 - tests (Type, Id) keying, not just Id
|
||||
PrimaryTag = new SharedTag_All_True { Id = 1, Name = "Tag_Id1", Color = "#FF0000" },
|
||||
Owner = new SharedUser_All_True { Id = 1, Username = "User_Id1", Email = "user1@test.com" },
|
||||
Category = new SharedCategory_All_True { Id = 1, Name = "Category_Id1", SortOrder = 10 },
|
||||
Items =
|
||||
[
|
||||
new TestOrderItem_All_True
|
||||
{
|
||||
Id = 1,
|
||||
ProductName = "Product-A",
|
||||
Tag = new SharedTag_All_True { Id = 1, Name = "Tag_Id1", Color = "#FF0000" },
|
||||
Assignee = new SharedUser_All_True { Id = 1, Username = "User_Id1", Email = "user1@test.com" }
|
||||
},
|
||||
new TestOrderItem_All_True
|
||||
{
|
||||
Id = 2,
|
||||
ProductName = "Product-B",
|
||||
Tag = new SharedTag_All_True { Id = 1, Name = "Tag_Id1", Color = "#FF0000" },
|
||||
Assignee = new SharedUser_All_True { Id = 1, Username = "User_Id1", Email = "user1@test.com" }
|
||||
},
|
||||
new TestOrderItem_All_True
|
||||
{
|
||||
Id = 3,
|
||||
ProductName = "Product-C",
|
||||
Tag = new SharedTag_All_True { Id = 1, Name = "Tag_Id1", Color = "#FF0000" },
|
||||
Assignee = new SharedUser_All_True { Id = 1, Username = "User_Id1", Email = "user1@test.com" }
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
// Act
|
||||
var binary = order.ToBinary();
|
||||
var result = binary.BinaryTo<TestOrder_All_True>();
|
||||
|
||||
// Assert 1: Check if ObjectRef is used (IId-based deduplication active)
|
||||
var objectRefCount = CountObjectRefs(binary);
|
||||
Console.WriteLine($"\nBinary size: {binary.Length} bytes (WithRef)");
|
||||
Console.WriteLine($"ObjectRef count: {objectRefCount}");
|
||||
|
||||
// Assert 3: Reference identity - same TYPE with same Id should be same reference
|
||||
// Tags with Id=1 should all be same reference
|
||||
Assert.AreSame(result.PrimaryTag, result.Items[0].Tag,
|
||||
"CRITICAL: Item[0].Tag should be same reference as PrimaryTag (same SharedTag_All_True.Id=1)");
|
||||
Assert.AreSame(result.PrimaryTag, result.Items[1].Tag,
|
||||
"CRITICAL: Item[1].Tag should be same reference as PrimaryTag (same SharedTag_All_True.Id=1)");
|
||||
Assert.AreSame(result.PrimaryTag, result.Items[2].Tag,
|
||||
"CRITICAL: Item[2].Tag should be same reference as PrimaryTag (same SharedTag_All_True.Id=1)");
|
||||
|
||||
// Users with Id=1 should all be same reference
|
||||
Assert.AreSame(result.Owner, result.Items[0].Assignee,
|
||||
"CRITICAL: Item[0].Assignee should be same reference as Owner (same SharedUser.Id=1)");
|
||||
Assert.AreSame(result.Owner, result.Items[1].Assignee,
|
||||
"CRITICAL: Item[1].Assignee should be same reference as Owner (same SharedUser.Id=1)");
|
||||
Assert.AreSame(result.Owner, result.Items[2].Assignee,
|
||||
"CRITICAL: Item[2].Assignee should be same reference as Owner (same SharedUser.Id=1)");
|
||||
|
||||
// Assert 4: Different TYPES with same Id should NOT be same reference!
|
||||
Assert.AreNotSame<object>(result.PrimaryTag, result.Owner,
|
||||
"CRITICAL BUG: Tag and User are same reference! Types with same int Id were confused!");
|
||||
Assert.AreNotSame<object>(result.PrimaryTag, result.Category,
|
||||
"CRITICAL BUG: Tag and Category are same reference! Types with same int Id were confused!");
|
||||
Assert.AreNotSame<object>(result.Owner, result.Category,
|
||||
"CRITICAL BUG: User and Category are same reference! Types with same int Id were confused!");
|
||||
// 4 Tags, 4 Users - each should have 3 ObjectRefs = 6 total minimum
|
||||
Assert.IsTrue(objectRefCount >= 6,
|
||||
$"CRITICAL: Expected at least 6 ObjectRef entries (3 per type for Tag and User), found {objectRefCount}. " +
|
||||
"IId-based reference deduplication is NOT working!");
|
||||
|
||||
// Assert 2: Data integrity - ALL data present and correct
|
||||
Assert.IsNotNull(result, "Deserialized result is null");
|
||||
|
||||
// Tag data
|
||||
Assert.IsNotNull(result.PrimaryTag, "PrimaryTag is null - data lost!");
|
||||
Assert.AreEqual(1, result.PrimaryTag.Id, "PrimaryTag.Id incorrect");
|
||||
Assert.AreEqual("Tag_Id1", result.PrimaryTag.Name, "PrimaryTag.Name incorrect - might be confused with User!");
|
||||
Assert.AreEqual("#FF0000", result.PrimaryTag.Color, "PrimaryTag.Color incorrect");
|
||||
|
||||
// User data - MUST NOT be confused with Tag (both have Id=1)
|
||||
Assert.IsNotNull(result.Owner, "Owner is null - data lost!");
|
||||
Assert.AreEqual(1, result.Owner.Id, "Owner.Id incorrect");
|
||||
Assert.AreEqual("User_Id1", result.Owner.Username, "Owner.Username incorrect - might be confused with Tag!");
|
||||
Assert.AreEqual("user1@test.com", result.Owner.Email, "Owner.Email incorrect");
|
||||
|
||||
// Category data - MUST NOT be confused with Tag or User (all have Id=1)
|
||||
Assert.IsNotNull(result.Category, "Category is null - data lost!");
|
||||
Assert.AreEqual(1, result.Category.Id, "Category.Id incorrect");
|
||||
Assert.AreEqual("Category_Id1", result.Category.Name, "Category.Name incorrect - might be confused with Tag!");
|
||||
Assert.AreEqual(10, result.Category.SortOrder, "Category.SortOrder incorrect");
|
||||
|
||||
Assert.IsNotNull(result.Items, "Items is null");
|
||||
Assert.AreEqual(3, result.Items.Count, "Items count incorrect");
|
||||
|
||||
for (var i = 0; i < 3; i++)
|
||||
{
|
||||
// Tag in items
|
||||
Assert.IsNotNull(result.Items[i].Tag, $"Items[{i}].Tag is null - data lost!");
|
||||
Assert.AreEqual(1, result.Items[i].Tag!.Id, $"Items[{i}].Tag.Id incorrect");
|
||||
Assert.AreEqual("Tag_Id1", result.Items[i].Tag.Name, $"Items[{i}].Tag.Name incorrect - confused with User?");
|
||||
|
||||
// User in items - MUST NOT be confused with Tag
|
||||
Assert.IsNotNull(result.Items[i].Assignee, $"Items[{i}].Assignee is null - data lost!");
|
||||
Assert.AreEqual(1, result.Items[i].Assignee!.Id, $"Items[{i}].Assignee.Id incorrect");
|
||||
Assert.AreEqual("User_Id1", result.Items[i].Assignee.Username, $"Items[{i}].Assignee.Username incorrect - confused with Tag?");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Size comparison: Same IId should result in smaller binary + verify data integrity.
|
||||
/// </summary>
|
||||
[TestMethod]
|
||||
public void DifferentInstances_SameIId_SmallerBinaryWithDataIntegrity()
|
||||
{
|
||||
// Arrange: 10 different instances with SAME IId
|
||||
var orderWithSameIId = new TestOrder_All_True
|
||||
{
|
||||
Id = 1,
|
||||
OrderNumber = "SAME-IID",
|
||||
Items = Enumerable.Range(1, 10).Select(i => new TestOrderItem_All_True
|
||||
{
|
||||
Id = i,
|
||||
ProductName = $"Product-{i}",
|
||||
// All have SAME IId.Id = 1, but DIFFERENT instances
|
||||
Assignee = new SharedUser_All_True { Id = 1, Username = "shared_user_name", Email = "shared@test.com" }
|
||||
}).ToList()
|
||||
};
|
||||
|
||||
// Arrange: 10 different instances with DIFFERENT IIds
|
||||
var orderWithDifferentIIds = new TestOrder_All_True
|
||||
{
|
||||
Id = 1,
|
||||
OrderNumber = "DIFF-IID",
|
||||
Items = Enumerable.Range(1, 10).Select(i => new TestOrderItem_All_True
|
||||
{
|
||||
Id = i,
|
||||
ProductName = $"Product-{i}",
|
||||
// All have DIFFERENT IId.Id
|
||||
Assignee = new SharedUser_All_True { Id = i * 100, Username = "unique_user_name", Email = "unique@test.com" }
|
||||
}).ToList()
|
||||
};
|
||||
|
||||
// Act
|
||||
var sameIIdBinary = orderWithSameIId.ToBinary();
|
||||
var diffIIdBinary = orderWithDifferentIIds.ToBinary();
|
||||
var sameIIdResult = sameIIdBinary.BinaryTo<TestOrder_All_True>();
|
||||
var diffIIdResult = diffIIdBinary.BinaryTo<TestOrder_All_True>();
|
||||
|
||||
// Assert 1: Size comparison
|
||||
Console.WriteLine($"Same IId binary size: {sameIIdBinary.Length} bytes");
|
||||
Console.WriteLine($"Different IId binary size: {diffIIdBinary.Length} bytes");
|
||||
Console.WriteLine($"Size difference: {diffIIdBinary.Length - sameIIdBinary.Length} bytes");
|
||||
|
||||
Assert.IsTrue(sameIIdBinary.Length < diffIIdBinary.Length,
|
||||
$"Same IId ({sameIIdBinary.Length}b) should be smaller than different IIds ({diffIIdBinary.Length}b). " +
|
||||
"IId-based deduplication NOT working!");
|
||||
|
||||
// Assert 2: Data integrity for sameIId result
|
||||
Assert.IsNotNull(sameIIdResult, "sameIIdResult is null");
|
||||
Assert.IsNotNull(sameIIdResult.Items, "sameIIdResult.Items is null");
|
||||
Assert.AreEqual(10, sameIIdResult.Items.Count, "sameIIdResult should have 10 items");
|
||||
|
||||
for (var i = 0; i < 10; i++)
|
||||
{
|
||||
Assert.IsNotNull(sameIIdResult.Items[i].Assignee,
|
||||
$"sameIIdResult.Items[{i}].Assignee is null - data lost!");
|
||||
Assert.AreEqual(1, sameIIdResult.Items[i].Assignee!.Id,
|
||||
$"sameIIdResult.Items[{i}].Assignee.Id should be 1");
|
||||
Assert.AreEqual("shared_user_name", sameIIdResult.Items[i].Assignee.Username,
|
||||
$"sameIIdResult.Items[{i}].Assignee.Username incorrect");
|
||||
}
|
||||
|
||||
// Assert 3: Reference identity for sameIId
|
||||
var firstAssignee = sameIIdResult.Items[0].Assignee;
|
||||
for (var i = 1; i < 10; i++)
|
||||
{
|
||||
Assert.AreSame(firstAssignee, sameIIdResult.Items[i].Assignee,
|
||||
$"CRITICAL: Items[{i}].Assignee should be same reference as Items[0].Assignee (same IId.Id=1)");
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Guid-Based IId Tests
|
||||
|
||||
/// <summary>
|
||||
/// Guid IId with same instance - validates ObjectRef + data integrity + reference identity.
|
||||
/// </summary>
|
||||
[TestMethod]
|
||||
public void GuidIId_SameInstance_SerializeAndDeserialize()
|
||||
{
|
||||
// Arrange: SAME instance
|
||||
var sharedGuid = Guid.NewGuid();
|
||||
var sharedItem = new TestGuidItem { Id = sharedGuid, Name = "SharedGuidItem" };
|
||||
|
||||
var order = new TestGuidOrder
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Code = "GUID-001",
|
||||
Count = 3,
|
||||
Items = [sharedItem, sharedItem, sharedItem]
|
||||
};
|
||||
|
||||
// Act
|
||||
var binary = order.ToBinary();
|
||||
var result = binary.BinaryTo<TestGuidOrder>();
|
||||
|
||||
// Assert 1: ObjectRef should be present
|
||||
var objectRefCount = CountObjectRefs(binary);
|
||||
Console.WriteLine($"Binary size: {binary.Length} bytes");
|
||||
Console.WriteLine($"ObjectRef count: {objectRefCount}");
|
||||
|
||||
Assert.IsTrue(objectRefCount >= 2, $"Expected at least 2 ObjectRefs, found {objectRefCount}");
|
||||
|
||||
// Assert 2: Data integrity - all items present and correct
|
||||
Assert.IsNotNull(result, "Result is null");
|
||||
Assert.IsNotNull(result.Items, "Items is null");
|
||||
Assert.AreEqual(3, result.Items.Count, "Items count incorrect");
|
||||
|
||||
for (var i = 0; i < 3; i++)
|
||||
{
|
||||
Assert.IsNotNull(result.Items[i], $"Items[{i}] is null");
|
||||
Assert.AreEqual(sharedGuid, result.Items[i].Id, $"Items[{i}].Id incorrect");
|
||||
Assert.AreEqual("SharedGuidItem", result.Items[i].Name, $"Items[{i}].Name incorrect");
|
||||
}
|
||||
|
||||
// Assert 3: Reference identity
|
||||
Assert.AreSame(result.Items[0], result.Items[1], "Items[0] and Items[1] should be same reference");
|
||||
Assert.AreSame(result.Items[1], result.Items[2], "Items[1] and Items[2] should be same reference");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// CRITICAL: Guid IId with different instances but same Id - tests IId-based deduplication.
|
||||
/// </summary>
|
||||
[TestMethod]
|
||||
public void GuidIId_DifferentInstances_SameId_SerializeAndDeserialize()
|
||||
{
|
||||
// Arrange: DIFFERENT instances but SAME Guid
|
||||
var sharedGuid = Guid.NewGuid();
|
||||
|
||||
var order = new TestGuidOrder
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Code = "GUID-001",
|
||||
Count = 3,
|
||||
Items =
|
||||
[
|
||||
new TestGuidItem { Id = sharedGuid, Name = "SharedGuidItem" },
|
||||
new TestGuidItem { Id = sharedGuid, Name = "SharedGuidItem" },
|
||||
new TestGuidItem { Id = sharedGuid, Name = "SharedGuidItem" }
|
||||
]
|
||||
};
|
||||
|
||||
// Act
|
||||
var binary = order.ToBinary();
|
||||
var result = binary.BinaryTo<TestGuidOrder>();
|
||||
|
||||
// Assert 1: ObjectRef should be present if IId-based dedup works
|
||||
var objectRefCount = CountObjectRefs(binary);
|
||||
Console.WriteLine($"Binary size: {binary.Length} bytes");
|
||||
Console.WriteLine($"ObjectRef count: {objectRefCount}");
|
||||
|
||||
Assert.IsTrue(objectRefCount >= 2,
|
||||
$"CRITICAL: Expected at least 2 ObjectRefs for same Guid IId, found {objectRefCount}. " +
|
||||
"Guid-based IId deduplication NOT working!");
|
||||
|
||||
// Assert 2: Data integrity - all items present and correct
|
||||
Assert.IsNotNull(result, "Result is null");
|
||||
Assert.IsNotNull(result.Items, "Items is null");
|
||||
Assert.AreEqual(3, result.Items.Count, "Items count incorrect");
|
||||
|
||||
for (var i = 0; i < 3; i++)
|
||||
{
|
||||
Assert.IsNotNull(result.Items[i], $"Items[{i}] is null - data lost!");
|
||||
Assert.AreEqual(sharedGuid, result.Items[i].Id, $"Items[{i}].Id incorrect");
|
||||
Assert.AreEqual("SharedGuidItem", result.Items[i].Name, $"Items[{i}].Name incorrect");
|
||||
}
|
||||
|
||||
// Assert 3: Reference identity - if IId-based, should be same reference
|
||||
Assert.AreSame(result.Items[0], result.Items[1],
|
||||
"CRITICAL: Items[0] and Items[1] should be same reference (same Guid)");
|
||||
Assert.AreSame(result.Items[1], result.Items[2],
|
||||
"CRITICAL: Items[1] and Items[2] should be same reference (same Guid)");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Data Integrity Tests
|
||||
|
||||
/// <summary>
|
||||
/// Diagnostic test to verify IId detection works correctly.
|
||||
/// </summary>
|
||||
[TestMethod]
|
||||
public void IIdDetection_Diagnostic()
|
||||
{
|
||||
// Test GetIdInfo directly
|
||||
var sharedTagType = typeof(SharedTag_All_True);
|
||||
var idInfo = AyCode.Core.Helpers.JsonUtilities.GetIdInfo(sharedTagType);
|
||||
|
||||
Console.WriteLine($"SharedTag_All_True GetIdInfo: IsId={idInfo.IsId}, IdType={idInfo.IdType?.Name}");
|
||||
Assert.IsTrue(idInfo.IsId, "SharedTag_All_True should be detected as IId<int>");
|
||||
Assert.AreEqual(typeof(int), idInfo.IdType, "SharedTag_All_True Id type should be int");
|
||||
|
||||
// Test SharedUser
|
||||
var sharedUserType = typeof(SharedUser_All_True);
|
||||
var userIdInfo = AyCode.Core.Helpers.JsonUtilities.GetIdInfo(sharedUserType);
|
||||
Console.WriteLine($"SharedUser GetIdInfo: IsId={userIdInfo.IsId}, IdType={userIdInfo.IdType?.Name}");
|
||||
Assert.IsTrue(userIdInfo.IsId, "SharedUser should be detected as IId<int>");
|
||||
|
||||
// Test TestGuidItem
|
||||
var guidItemType = typeof(TestGuidItem);
|
||||
var guidIdInfo = AyCode.Core.Helpers.JsonUtilities.GetIdInfo(guidItemType);
|
||||
Console.WriteLine($"TestGuidItem GetIdInfo: IsId={guidIdInfo.IsId}, IdType={guidIdInfo.IdType?.Name}");
|
||||
Assert.IsTrue(guidIdInfo.IsId, "TestGuidItem should be detected as IId<Guid>");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify data is correct regardless of reference handling.
|
||||
/// </summary>
|
||||
[TestMethod]
|
||||
public void SharedCategory_DataIntegrity()
|
||||
{
|
||||
var categories = new List<SharedCategory_All_True>
|
||||
{
|
||||
new() { Id = 1, Name = "Category1", SortOrder = 1, IsDefault = true },
|
||||
new() { Id = 2, Name = "Category2", SortOrder = 2, ParentCategoryId = 1 },
|
||||
new() { Id = 3, Name = "Category3", SortOrder = 3, ParentCategoryId = 1 }
|
||||
};
|
||||
|
||||
var binary = categories.ToBinary();
|
||||
var result = binary.BinaryTo<List<SharedCategory_All_True>>();
|
||||
|
||||
Assert.IsNotNull(result);
|
||||
Assert.AreEqual(3, result.Count);
|
||||
|
||||
Assert.AreEqual(1, result[0].Id);
|
||||
Assert.AreEqual("Category1", result[0].Name);
|
||||
Assert.IsTrue(result[0].IsDefault);
|
||||
|
||||
Assert.AreEqual(2, result[1].Id);
|
||||
Assert.AreEqual(1, result[1].ParentCategoryId);
|
||||
|
||||
Assert.AreEqual(3, result[2].Id);
|
||||
Assert.AreEqual(1, result[2].ParentCategoryId);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region SGen-emit writer/reader ref-handling asymmetry — regression target
|
||||
|
||||
/// <summary>
|
||||
/// Target test for the SGen-emit writer/reader asymmetry hypothesis — covers BOTH
|
||||
/// collection-element AND dictionary-value ref-marker paths in a single graph.
|
||||
/// <para>
|
||||
/// Setup:
|
||||
/// <list type="bullet">
|
||||
/// <item><c>TestRefAsymParent</c> [AcBinarySerializable(false)] — parent EnableRefHandlingFeature=false.</item>
|
||||
/// <item><c>TestRefAsymChild</c> [AcBinarySerializable(true)] — child IId<int>, all features ON.</item>
|
||||
/// <item>Same child instance referenced twice in the parent's <c>Children</c> list
|
||||
/// AND twice as VALUES in the parent's <c>ChildrenMap</c> dictionary.</item>
|
||||
/// <item>Runtime <c>ReferenceHandling=All</c> + <c>Interning=All</c> (via Default options).</item>
|
||||
/// <item><c>MarkerDecimal</c> property AFTER the list — drift detection slot (decimal = 16 fixed bytes).</item>
|
||||
/// <item><c>MarkerDecimal2</c> property AFTER the dictionary — second drift detection slot,
|
||||
/// catches the symmetric dict-value emit asymmetry (EmitReadDictionary:482).</item>
|
||||
/// </list>
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Expected if the asymmetry-hypothesis holds: the writer (runtime via
|
||||
/// WriteObjectGenerated bridge) emits ObjectRefFirst+ObjectRef for the duplicates; the SGen
|
||||
/// reader-emit's zero-branch path (parent flag false guarding out the ref-aware switch)
|
||||
/// misreads the VarUInt cacheIdx as a property-marker byte → DECIMAL_DRIFT exception or
|
||||
/// value-mismatch on MarkerDecimal / MarkerDecimal2.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Expected if the hypothesis is WRONG: the test passes — different fix direction needed.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
[TestMethod]
|
||||
public void Serialize_RefMarkerCollectionElement_ParentRefHandlingFeatureOff_DriftReproduction()
|
||||
{
|
||||
var sharedChild = new TestRefAsymChild { Id = 1, Name = "Shared" };
|
||||
var parent = new TestRefAsymParent
|
||||
{
|
||||
Id = 100,
|
||||
Children = new List<TestRefAsymChild> { sharedChild, sharedChild },
|
||||
MarkerDecimal = 999.99m,
|
||||
ChildrenMap = new Dictionary<int, TestRefAsymChild>
|
||||
{
|
||||
{ 10, sharedChild },
|
||||
{ 20, sharedChild },
|
||||
},
|
||||
MarkerDecimal2 = 888.88m,
|
||||
};
|
||||
|
||||
var options = AcBinarySerializerOptions.Default; // RefHandling=All, Interning=All
|
||||
options.UseGeneratedCode = true;
|
||||
|
||||
var bytes = AcBinarySerializer.Serialize(parent, options);
|
||||
|
||||
// Sanity check: did the writer actually emit an ObjectRef marker for the duplicates?
|
||||
var objectRefCount = CountObjectRefs(bytes, writeBinaryToConsole: false);
|
||||
Console.WriteLine($"Wire size: {bytes.Length}, ObjectRef occurrences: {objectRefCount}");
|
||||
|
||||
var result = AcBinaryDeserializer.Deserialize<TestRefAsymParent>(bytes, options);
|
||||
|
||||
Assert.IsNotNull(result, "Deserialize returned null — wire corruption");
|
||||
Assert.AreEqual(parent.Id, result.Id, "Parent.Id mismatch — possible drift before the list");
|
||||
|
||||
// --- Collection-element path (EmitReadCollectionElement) ---
|
||||
Assert.IsNotNull(result.Children, "Children list was null after round-trip");
|
||||
Assert.AreEqual(2, result.Children.Count, "Children count mismatch");
|
||||
Assert.IsNotNull(result.Children[0]);
|
||||
Assert.IsNotNull(result.Children[1]);
|
||||
Assert.AreEqual(sharedChild.Id, result.Children[0].Id, "Children[0].Id mismatch");
|
||||
Assert.AreEqual(sharedChild.Name, result.Children[0].Name, "Children[0].Name mismatch");
|
||||
Assert.AreEqual(sharedChild.Id, result.Children[1].Id, "Children[1].Id mismatch — drift on the duplicate");
|
||||
Assert.AreEqual(sharedChild.Name, result.Children[1].Name, "Children[1].Name mismatch — drift on the duplicate");
|
||||
Assert.AreEqual(parent.MarkerDecimal, result.MarkerDecimal,
|
||||
"MarkerDecimal drift — wire-position desync after the Children list (smoking gun for collection-element SGen-emit asymmetry)");
|
||||
|
||||
// --- Dictionary-value path (EmitReadDictionary) ---
|
||||
Assert.IsNotNull(result.ChildrenMap, "ChildrenMap was null after round-trip");
|
||||
Assert.AreEqual(2, result.ChildrenMap.Count, "ChildrenMap count mismatch");
|
||||
Assert.IsTrue(result.ChildrenMap.ContainsKey(10), "ChildrenMap missing key=10");
|
||||
Assert.IsTrue(result.ChildrenMap.ContainsKey(20), "ChildrenMap missing key=20");
|
||||
Assert.IsNotNull(result.ChildrenMap[10]);
|
||||
Assert.IsNotNull(result.ChildrenMap[20]);
|
||||
Assert.AreEqual(sharedChild.Id, result.ChildrenMap[10].Id, "ChildrenMap[10].Id mismatch");
|
||||
Assert.AreEqual(sharedChild.Name, result.ChildrenMap[10].Name, "ChildrenMap[10].Name mismatch");
|
||||
Assert.AreEqual(sharedChild.Id, result.ChildrenMap[20].Id, "ChildrenMap[20].Id mismatch — drift on the dict-value duplicate");
|
||||
Assert.AreEqual(sharedChild.Name, result.ChildrenMap[20].Name, "ChildrenMap[20].Name mismatch — drift on the dict-value duplicate");
|
||||
Assert.AreEqual(parent.MarkerDecimal2, result.MarkerDecimal2,
|
||||
"MarkerDecimal2 drift — wire-position desync after the ChildrenMap dictionary (smoking gun for dict-value SGen-emit asymmetry)");
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
|
|
@ -0,0 +1,314 @@
|
|||
using System;
|
||||
using AyCode.Core.Extensions;
|
||||
using AyCode.Core.Serializers;
|
||||
using AyCode.Core.Serializers.Binaries;
|
||||
using AyCode.Core.Tests.TestModels;
|
||||
using Microsoft.VisualStudio.TestTools.UnitTesting;
|
||||
|
||||
namespace AyCode.Core.Tests.Serialization;
|
||||
|
||||
/// <summary>
|
||||
/// Focused repro tests for <c>MaxDepthBehavior.Truncate</c> on cyclic graphs.
|
||||
///
|
||||
/// Tracks <c>BINARY_ISSUES.md#accore-bin-i-t7k3</c>: SGen path produces wire-misalignment
|
||||
/// (<c>DECIMAL_DRIFT</c> on round-trip) when Truncate fires inside a cycle. Runtime path
|
||||
/// works correctly with the same data — that's the control test for diagnosis.
|
||||
///
|
||||
/// Designed for interactive debugger sessions: minimal graph, small <c>MaxDepth=5</c>,
|
||||
/// single cycle property. Step through `WriteObjectFullMarkerIId` to compare runtime
|
||||
/// vs SGen call sequences at the truncation boundary.
|
||||
/// </summary>
|
||||
[TestClass]
|
||||
public class AcBinarySerializerMaxDepthTruncateTests
|
||||
{
|
||||
private const int MaxDepthForTest = 5;
|
||||
|
||||
/// <summary>
|
||||
/// Builds the minimal cyclic graph used by most tests below.
|
||||
/// Cycle: <c>order → Items[0] → ParentOrder → order → …</c>.
|
||||
/// Only primitive properties on the leaf entities so the body is short and the wire is easy to diff.
|
||||
/// </summary>
|
||||
private static TestOrder_Circ_Ref BuildMinimalCycle()
|
||||
{
|
||||
var order = new TestOrder_Circ_Ref
|
||||
{
|
||||
Id = 1,
|
||||
OrderNumber = "TEST-001",
|
||||
Items =
|
||||
[
|
||||
new TestOrderItem_Circ_Ref
|
||||
{
|
||||
Id = 10,
|
||||
ProductName = "Product-A",
|
||||
Quantity = 5
|
||||
}
|
||||
]
|
||||
};
|
||||
order.Items[0].ParentOrder = order; // ← closes the cycle
|
||||
return order;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds the cyclic graph PLUS sets the polymorphic <c>Parent</c> property (declared <c>object?</c>)
|
||||
/// to a non-IId concrete instance — mirroring the failing SameInstance test's setup for None mode.
|
||||
/// This routes through <see cref="WriteValueNonPrimitiveWithWrapperPoly"/> →
|
||||
/// <see cref="WriteObjectPolymorphic"/> on every cycle level (Parent is written at every TestOrder body).
|
||||
/// </summary>
|
||||
private static TestOrder_Circ_Ref BuildCycleWithPolymorphicParent()
|
||||
{
|
||||
var order = BuildMinimalCycle();
|
||||
// Parent is `object?` on TestOrder_Circ_Ref — polymorphic write path.
|
||||
// UserPreferences_All_True is non-IId, leaf-like (Language, LightTheme strings + scalars), no further refs.
|
||||
order.Parent = new UserPreferences_All_True { Language = "en-US", Theme = "light" };
|
||||
return order;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Diagnostic helper: dump the wire bytes as hex for visual comparison.
|
||||
/// Useful in the debugger to spot the runtime-vs-SGen wire diff.
|
||||
/// </summary>
|
||||
private static void DumpWire(string label, byte[] wire)
|
||||
{
|
||||
Console.WriteLine($"=== {label} | {wire.Length} bytes ===");
|
||||
Console.WriteLine(BitConverter.ToString(wire));
|
||||
Console.WriteLine();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// CONTROL TEST — runtime path with Truncate. Should pass.
|
||||
/// If this fails, the bug is broader than the SGen path; fix here first.
|
||||
/// </summary>
|
||||
[TestMethod]
|
||||
public void Runtime_None_Truncate_CyclicGraph_RoundTrips()
|
||||
{
|
||||
var order = BuildMinimalCycle();
|
||||
|
||||
var options = new AcBinarySerializerOptions
|
||||
{
|
||||
ReferenceHandling = ReferenceHandlingMode.None,
|
||||
UseGeneratedCode = false, // ← runtime path
|
||||
UseMetadata = false,
|
||||
MaxDepth = MaxDepthForTest,
|
||||
MaxDepthBehavior = MaxDepthBehavior.Truncate
|
||||
};
|
||||
|
||||
// Act
|
||||
var binary = AcBinarySerializer.Serialize(order, options);
|
||||
DumpWire("Runtime + Truncate", binary);
|
||||
var result = binary.BinaryTo<TestOrder_Circ_Ref>();
|
||||
|
||||
// Assert: serialize+deserialize succeeds; root + first-level data intact;
|
||||
// ParentOrder is null at the truncation boundary (instead of a full cycle round-trip).
|
||||
Assert.IsNotNull(result, "Deserialize result should not be null");
|
||||
Assert.AreEqual(1, result.Id, "root Id");
|
||||
Assert.AreEqual("TEST-001", result.OrderNumber, "root OrderNumber");
|
||||
Assert.IsNotNull(result.Items, "Items list should be materialized");
|
||||
Assert.AreEqual(1, result.Items.Count, "Items count");
|
||||
Assert.AreEqual(10, result.Items[0].Id, "Item Id");
|
||||
Assert.AreEqual("Product-A", result.Items[0].ProductName, "Item ProductName");
|
||||
Assert.AreEqual(5, result.Items[0].Quantity, "Item Quantity");
|
||||
// ParentOrder may or may not be set depending on where truncation fires —
|
||||
// the contract is "the deserialize must not throw and root-level data must be intact".
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// BUG REPRO — SGen path with Truncate. Currently fails with <c>DECIMAL_DRIFT</c>
|
||||
/// on round-trip. Same input as the runtime control above; only <c>UseGeneratedCode</c> differs.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Step through with the VS debugger:
|
||||
/// 1. Break in <c>WriteObjectFullMarkerIId</c> for both runs (runtime test above + this).
|
||||
/// 2. Compare <c>_position</c>, <c>_recursionDepth</c>, and the wire-bytes-just-written at each
|
||||
/// call-site between the two paths.
|
||||
/// 3. Identify the byte position where the SGen wire diverges from the runtime wire.
|
||||
/// 4. Likely culprits to inspect:
|
||||
/// - <c>TryEnterRecursion</c> inc/dec balance on the SGen-emit code path
|
||||
/// - <c>WriteObjectFullMarkerIId</c> ref-handling branches (2nd-occurrence ExitRecursion undo)
|
||||
/// - SGen-emitted property-loop ordering vs runtime <c>WritePropertiesMarkerless</c>
|
||||
/// </remarks>
|
||||
[TestMethod]
|
||||
public void Sgen_None_Truncate_CyclicGraph_RoundTrips()
|
||||
{
|
||||
var order = BuildMinimalCycle();
|
||||
|
||||
var options = new AcBinarySerializerOptions
|
||||
{
|
||||
ReferenceHandling = ReferenceHandlingMode.None,
|
||||
UseGeneratedCode = true, // ← SGen path (triggers the bug)
|
||||
UseMetadata = false,
|
||||
MaxDepth = MaxDepthForTest,
|
||||
MaxDepthBehavior = MaxDepthBehavior.Truncate
|
||||
};
|
||||
|
||||
// Act
|
||||
var binary = AcBinarySerializer.Serialize(order, options);
|
||||
DumpWire("SGen + Truncate", binary);
|
||||
var result = binary.BinaryTo<TestOrder_Circ_Ref>();
|
||||
|
||||
// Assert: same as runtime control. Currently throws DECIMAL_DRIFT.
|
||||
Assert.IsNotNull(result, "Deserialize result should not be null");
|
||||
Assert.AreEqual(1, result.Id, "root Id");
|
||||
Assert.AreEqual("TEST-001", result.OrderNumber, "root OrderNumber");
|
||||
Assert.IsNotNull(result.Items, "Items list should be materialized");
|
||||
Assert.AreEqual(1, result.Items.Count, "Items count");
|
||||
Assert.AreEqual(10, result.Items[0].Id, "Item Id");
|
||||
Assert.AreEqual("Product-A", result.Items[0].ProductName, "Item ProductName");
|
||||
Assert.AreEqual(5, result.Items[0].Quantity, "Item Quantity");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// SGen + Truncate + useMetadata=true variant. Also currently fails (multi-byte marker variant
|
||||
/// of the same underlying issue). Useful for the debug session to confirm whether the fix
|
||||
/// also covers the metadata code path or just the simple Object-marker path.
|
||||
/// </summary>
|
||||
[TestMethod]
|
||||
public void Sgen_None_Truncate_UseMetadata_CyclicGraph_RoundTrips()
|
||||
{
|
||||
var order = BuildMinimalCycle();
|
||||
|
||||
var options = new AcBinarySerializerOptions
|
||||
{
|
||||
ReferenceHandling = ReferenceHandlingMode.None,
|
||||
UseGeneratedCode = true,
|
||||
UseMetadata = true, // ← multi-byte marker (ObjectWithMetadata + inline meta)
|
||||
MaxDepth = MaxDepthForTest,
|
||||
MaxDepthBehavior = MaxDepthBehavior.Truncate
|
||||
};
|
||||
|
||||
// Act
|
||||
var binary = AcBinarySerializer.Serialize(order, options);
|
||||
DumpWire("SGen + Truncate + useMetadata", binary);
|
||||
var result = binary.BinaryTo<TestOrder_Circ_Ref>();
|
||||
|
||||
// Assert: same root-level integrity expectation.
|
||||
Assert.IsNotNull(result);
|
||||
Assert.AreEqual(1, result.Id);
|
||||
Assert.AreEqual("TEST-001", result.OrderNumber);
|
||||
Assert.IsNotNull(result.Items);
|
||||
Assert.AreEqual(1, result.Items.Count);
|
||||
Assert.AreEqual("Product-A", result.Items[0].ProductName);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// CONTROL — runtime + polymorphic Parent. Should pass.
|
||||
/// Compared to <see cref="Sgen_None_Truncate_PolymorphicCycle_RoundTrips"/> below: same data, different code path.
|
||||
/// </summary>
|
||||
[TestMethod]
|
||||
public void Runtime_None_Truncate_PolymorphicCycle_RoundTrips()
|
||||
{
|
||||
var order = BuildCycleWithPolymorphicParent();
|
||||
|
||||
var options = new AcBinarySerializerOptions
|
||||
{
|
||||
ReferenceHandling = ReferenceHandlingMode.None,
|
||||
UseGeneratedCode = false,
|
||||
UseMetadata = false,
|
||||
MaxDepth = MaxDepthForTest,
|
||||
MaxDepthBehavior = MaxDepthBehavior.Truncate
|
||||
};
|
||||
|
||||
var binary = AcBinarySerializer.Serialize(order, options);
|
||||
DumpWire("Runtime + Truncate + PolymorphicParent", binary);
|
||||
var result = binary.BinaryTo<TestOrder_Circ_Ref>();
|
||||
|
||||
Assert.IsNotNull(result);
|
||||
Assert.AreEqual(1, result.Id);
|
||||
Assert.AreEqual("TEST-001", result.OrderNumber);
|
||||
Assert.IsNotNull(result.Items);
|
||||
Assert.AreEqual(1, result.Items.Count);
|
||||
// Root-level Parent should round-trip (depth 1 — well below MaxDepth).
|
||||
Assert.IsNotNull(result.Parent, "Root order.Parent should round-trip — depth 1 < MaxDepth");
|
||||
Assert.IsInstanceOfType(result.Parent, typeof(UserPreferences_All_True));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// REPRO — SGen + polymorphic Parent inside a cycle. This is the failing case in the
|
||||
/// original SameInstance test for (useSgen=true, useMeta=false) None mode.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The cycle (Items[0].ParentOrder = order) makes <c>TestOrder.WriteProperties</c> recurse.
|
||||
/// At each cycle level, the body writes its <c>Parent</c> property polymorphically via
|
||||
/// <c>WriteValueNonPrimitiveWithWrapperPoly</c> → <c>WriteObjectPolymorphic</c>. When the
|
||||
/// cycle reaches <c>MaxDepth</c>, the SGen path produces wire bytes that the SGen reader
|
||||
/// later mis-interprets (<c>DECIMAL_DRIFT</c> on TotalAmount at the deepest unwind frame).
|
||||
/// Runtime control above with the same data works correctly — diff the two wires to find
|
||||
/// where SGen diverges.
|
||||
///
|
||||
/// Focus debug-watch targets at the truncation boundary:
|
||||
/// - <see cref="AcBinarySerializer.WriteObjectPolymorphic"/> Truncate path (Null written)
|
||||
/// - <see cref="AcBinarySerializer.BinarySerializationContext{TOutput}.TryEnterRecursion"/> inc/dec balance
|
||||
/// - The polymorphic-prefix wire bytes (FixObj-slot vs ObjectWithTypeName) immediately before/after the truncate
|
||||
/// </remarks>
|
||||
[TestMethod]
|
||||
public void Sgen_None_Truncate_PolymorphicCycle_RoundTrips()
|
||||
{
|
||||
var order = BuildCycleWithPolymorphicParent();
|
||||
|
||||
var options = new AcBinarySerializerOptions
|
||||
{
|
||||
ReferenceHandling = ReferenceHandlingMode.None,
|
||||
UseGeneratedCode = true, // ← SGen path (triggers the bug)
|
||||
UseMetadata = false,
|
||||
MaxDepth = MaxDepthForTest,
|
||||
MaxDepthBehavior = MaxDepthBehavior.Truncate
|
||||
};
|
||||
|
||||
var binary = AcBinarySerializer.Serialize(order, options);
|
||||
DumpWire("SGen + Truncate + PolymorphicParent", binary);
|
||||
var result = binary.BinaryTo<TestOrder_Circ_Ref>();
|
||||
|
||||
Assert.IsNotNull(result);
|
||||
Assert.AreEqual(1, result.Id);
|
||||
Assert.AreEqual("TEST-001", result.OrderNumber);
|
||||
Assert.IsNotNull(result.Items);
|
||||
Assert.AreEqual(1, result.Items.Count);
|
||||
Assert.IsNotNull(result.Parent, "Root order.Parent should round-trip — depth 1 < MaxDepth");
|
||||
Assert.IsInstanceOfType(result.Parent, typeof(UserPreferences_All_True));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Non-cyclic shallow case — the primary delta-update use case.
|
||||
/// Serialize an entity with intentionally truncated nested collections (MaxDepth=1),
|
||||
/// verify root + first-level scalar properties round-trip while nested complex ones become null.
|
||||
/// Both runtime and SGen paths should pass this. If SGen fails here too, the bug isn't
|
||||
/// cycle-specific — it's pure Truncate-emission corruption.
|
||||
/// </summary>
|
||||
[TestMethod]
|
||||
[DataRow(false, DisplayName = "Runtime")]
|
||||
[DataRow(true, DisplayName = "SGen")]
|
||||
public void Sgen_Or_Runtime_None_Truncate_NoCycle_ShallowRoundTrip(bool useSgen)
|
||||
{
|
||||
var order = new TestOrder_Circ_Ref
|
||||
{
|
||||
Id = 42,
|
||||
OrderNumber = "DELTA-UPDATE-001",
|
||||
Items =
|
||||
[
|
||||
new TestOrderItem_Circ_Ref { Id = 1, ProductName = "P1" },
|
||||
new TestOrderItem_Circ_Ref { Id = 2, ProductName = "P2" }
|
||||
]
|
||||
// No cycle. Items array elements truncate at MaxDepth=1.
|
||||
};
|
||||
|
||||
var options = new AcBinarySerializerOptions
|
||||
{
|
||||
ReferenceHandling = ReferenceHandlingMode.None,
|
||||
UseGeneratedCode = useSgen,
|
||||
UseMetadata = false,
|
||||
MaxDepth = 1, // root + 1 level
|
||||
MaxDepthBehavior = MaxDepthBehavior.Truncate
|
||||
};
|
||||
|
||||
var binary = AcBinarySerializer.Serialize(order, options);
|
||||
DumpWire($"NoCycle Truncate ({(useSgen ? "SGen" : "Runtime")})", binary);
|
||||
var result = binary.BinaryTo<TestOrder_Circ_Ref>();
|
||||
|
||||
Assert.IsNotNull(result);
|
||||
Assert.AreEqual(42, result.Id);
|
||||
Assert.AreEqual("DELTA-UPDATE-001", result.OrderNumber);
|
||||
// Items elements are at depth 2 — get truncated to null at MaxDepth=1.
|
||||
// Result.Items list itself should exist (it's at depth 1), but elements should be null.
|
||||
// The exact element-or-null result is depth-implementation-dependent — the strict invariant
|
||||
// is "deserialize doesn't throw and root scalars are intact".
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,226 @@
|
|||
using System.IO.Pipelines;
|
||||
using System.IO.Pipes;
|
||||
using AyCode.Core.Serializers.Binaries;
|
||||
using AyCode.Core.Tests.TestModels;
|
||||
using static AyCode.Core.Tests.TestModels.AcSerializerModels;
|
||||
|
||||
namespace AyCode.Core.Tests.Serialization;
|
||||
|
||||
/// <summary>
|
||||
/// Cross-platform NamedPipe IPC roundtrip tests proving AcBinarySerializer's streaming framework
|
||||
/// works on arbitrary <c>PipeWriter</c>/<c>PipeReader</c> sources without per-transport adapters.
|
||||
///
|
||||
/// <para>The serializer/deserializer surface intentionally has NO NamedPipe-specific helpers —
|
||||
/// the tests own the <see cref="NamedPipeServerStream"/> / <see cref="NamedPipeClientStream"/>
|
||||
/// lifecycle directly and call the generic
|
||||
/// <see cref="AcBinarySerializer.SerializeChunked{T}(T, PipeWriter, AcBinarySerializerOptions)"/> +
|
||||
/// <see cref="AcBinaryDeserializer.Deserialize{T}(AsyncPipeReaderInput, AcBinarySerializerOptions)"/>
|
||||
/// primitives, with the receive-side drain implemented via the test-only
|
||||
/// <see cref="AsyncPipeReaderInputExtensions.DrainFromAsync"/> extension. The same generic
|
||||
/// primitives apply to FileStream / NetworkStream / custom transports — consumers own the
|
||||
/// transport lifecycle, framework stays transport-agnostic.</para>
|
||||
///
|
||||
/// <para>With <c>BufferWriterChunkSize = 256</c>, even small test payloads cross multiple chunk
|
||||
/// boundaries on the wire — exercises the real chunking + sliding-window cycling behavior.</para>
|
||||
/// </summary>
|
||||
[TestClass]
|
||||
public class AcBinarySerializerNamedPipeTests
|
||||
{
|
||||
[TestMethod]
|
||||
public async Task RoundTrip_SmallChunkSize_PayloadEquals()
|
||||
{
|
||||
var pipeName = $"AcBinaryTest-{Guid.NewGuid():N}";
|
||||
|
||||
// 256-byte chunk size = Kestrel slab default; small enough to force multi-chunk framing
|
||||
// for our 50-item payload, exercises the AsyncSegment chunked wire format end-to-end.
|
||||
var opts = new AcBinarySerializerOptions { BufferWriterChunkSize = 256 };
|
||||
var original = CreatePayload(50);
|
||||
|
||||
var result = await RunNamedPipeRoundTripAsync(pipeName, original, opts);
|
||||
|
||||
Assert.IsNotNull(result);
|
||||
AssertPayloadEquals(original, result);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public async Task RoundTrip_LargeScalePayload_ChunkSize256_StructuralEquality()
|
||||
{
|
||||
// Production-scale payload via TestDataFactory: 100 root items × 3 pallets × 3 measurements × 4 points
|
||||
// = ~3700 deeply-nested objects with shared references (50 tags, 20 users, metadata, 10 categories).
|
||||
// Serialized size ~few hundred KB → many chunks at chunkSize=256 → real backpressure-driven streaming
|
||||
// (sequential per-chunk flush on StreamPipeWriter, bytes flow incrementally as consumer drains).
|
||||
|
||||
#if DEBUG
|
||||
// Capture BOTH receiver and sender state to diagnose StreamPipeWriter interaction if needed.
|
||||
var diagLogs = new List<string>();
|
||||
|
||||
AsyncPipeReaderInput.DiagnosticLog = msg => diagLogs.Add($"[R] {msg}");
|
||||
AsyncPipeWriterOutput.DiagnosticLog = msg => diagLogs.Add($"[S] {msg}");
|
||||
#endif
|
||||
try
|
||||
{
|
||||
var pipeName = $"AcBinaryTest-{Guid.NewGuid():N}";
|
||||
var opts = new AcBinarySerializerOptions { BufferWriterChunkSize = 256 };
|
||||
var original = TestDataFactory.CreateLargeScaleBenchmarkOrder(rootItemCount: 100);
|
||||
|
||||
var result = await RunNamedPipeRoundTripAsync(pipeName, original, opts);
|
||||
|
||||
Assert.IsNotNull(result);
|
||||
Assert.AreEqual(original.Id, result.Id);
|
||||
Assert.AreEqual(original.OrderNumber, result.OrderNumber);
|
||||
Assert.AreEqual(original.Status, result.Status);
|
||||
Assert.AreEqual(original.TotalAmount, result.TotalAmount);
|
||||
|
||||
// Deep structure: count items + pallets + measurements + points must match exactly
|
||||
var origCounts = CountTestOrderHierarchy(original);
|
||||
var resultCounts = CountTestOrderHierarchy(result);
|
||||
|
||||
Assert.AreEqual(origCounts.items, resultCounts.items, "Items count mismatch");
|
||||
Assert.AreEqual(origCounts.pallets, resultCounts.pallets, "Pallets count mismatch");
|
||||
Assert.AreEqual(origCounts.measurements, resultCounts.measurements, "Measurements count mismatch");
|
||||
Assert.AreEqual(origCounts.points, resultCounts.points, "Points count mismatch");
|
||||
}
|
||||
finally
|
||||
{
|
||||
#if DEBUG
|
||||
AsyncPipeReaderInput.DiagnosticLog = null;
|
||||
AsyncPipeWriterOutput.DiagnosticLog = null;
|
||||
|
||||
if (diagLogs.Count > 0)
|
||||
{
|
||||
Console.WriteLine($"=== Sender [S] + Receiver [R] DiagnosticLog trail ({diagLogs.Count} entries) ===");
|
||||
|
||||
// Print last 60 entries (most relevant to failure point)
|
||||
var startIdx = Math.Max(0, diagLogs.Count - 60);
|
||||
if (startIdx > 0) Console.WriteLine($" ... ({startIdx} earlier entries elided)");
|
||||
|
||||
for (var i = startIdx; i < diagLogs.Count; i++) Console.WriteLine($" [{i}] {diagLogs[i]}");
|
||||
|
||||
Console.WriteLine($"=== End DiagnosticLog ===");
|
||||
}
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Owns the full NamedPipe lifecycle: binds server, accepts connect, drives the generic
|
||||
/// <see cref="AcBinarySerializer.SerializeChunked{T}(T, PipeWriter, AcBinarySerializerOptions)"/> on
|
||||
/// the client side, and on the server side runs the canonical drain+deserialize pair
|
||||
/// (test-only <see cref="AsyncPipeReaderInputExtensions.DrainFromAsync"/> on the calling thread,
|
||||
/// <see cref="AcBinaryDeserializer.Deserialize{T}(AsyncPipeReaderInput, AcBinarySerializerOptions)"/>
|
||||
/// on a Task.Run BG thread). The framework helpers know nothing about NamedPipe — only PipeWriter /
|
||||
/// PipeReader.
|
||||
/// </summary>
|
||||
private static async Task<T?> RunNamedPipeRoundTripAsync<T>(string pipeName, T original, AcBinarySerializerOptions opts)
|
||||
{
|
||||
// Server-side bind is synchronous (NamedPipeServerStream ctor registers the pipe with
|
||||
// the OS), so the client can immediately attempt connect once we hand off to async.
|
||||
await using var pipeServer = new NamedPipeServerStream(pipeName, PipeDirection.In, 1, PipeTransmissionMode.Message, System.IO.Pipes.PipeOptions.Asynchronous);
|
||||
|
||||
var receiveTask = Task.Run(async () =>
|
||||
{
|
||||
await pipeServer.WaitForConnectionAsync().ConfigureAwait(false);
|
||||
var pipeReader = PipeReader.Create(pipeServer);
|
||||
|
||||
// Inlined version of what the removed DeserializeFromPipeReaderAsync used to do:
|
||||
// single-message mode + drain on calling thread + deserialize on Task.Run BG.
|
||||
using var input = new AsyncPipeReaderInput(initialCapacity: opts.BufferWriterChunkSize * 2, multiMessage: false);
|
||||
var deserTask = Task.Run(() => AcBinaryDeserializer.Deserialize<T>(input, opts));
|
||||
await input.DrainFromAsync(pipeReader).ConfigureAwait(false);
|
||||
return await deserTask.ConfigureAwait(false);
|
||||
});
|
||||
|
||||
await using var pipeClient = new NamedPipeClientStream(".", pipeName, PipeDirection.Out, System.IO.Pipes.PipeOptions.Asynchronous);
|
||||
await pipeClient.ConnectAsync().ConfigureAwait(false);
|
||||
|
||||
var pipeWriter = PipeWriter.Create(pipeClient);
|
||||
try
|
||||
{
|
||||
// Public PipeWriter overload (raw chunked stream — no per-chunk frame headers,
|
||||
// bit-compatible with Serialize(v, opts) byte[] output). Auto-selects sequential
|
||||
// flush strategy because PipeWriter.Create(stream) returns StreamPipeWriter
|
||||
// (race-incompatible with parallel send).
|
||||
AcBinarySerializer.SerializeChunked(original, pipeWriter, opts);
|
||||
}
|
||||
finally
|
||||
{
|
||||
await pipeWriter.CompleteAsync().ConfigureAwait(false);
|
||||
}
|
||||
|
||||
return await receiveTask.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private static (int items, int pallets, int measurements, int points) CountTestOrderHierarchy(TestOrder_All_True order)
|
||||
{
|
||||
var items = order.Items.Count;
|
||||
int pallets = 0, measurements = 0, points = 0;
|
||||
|
||||
foreach (var item in order.Items)
|
||||
{
|
||||
pallets += item.Pallets.Count;
|
||||
foreach (var p in item.Pallets)
|
||||
{
|
||||
measurements += p.Measurements.Count;
|
||||
points += p.Measurements.Sum(m => m.Points.Count);
|
||||
}
|
||||
}
|
||||
|
||||
return (items, pallets, measurements, points);
|
||||
}
|
||||
|
||||
// Note: a "default chunk size" test was deliberately omitted. The default
|
||||
// AcBinarySerializerOptions.BufferWriterChunkSize used to be 65536, which exceeded the
|
||||
// UINT16 max (256). Fixed in this work to 256. Tests above explicitly set chunk size
|
||||
// for reproducibility regardless of default.
|
||||
|
||||
private static TestParentWithDateTimeItemCollection CreatePayload(int itemCount)
|
||||
{
|
||||
var now = DateTime.UtcNow;
|
||||
var items = new List<TestEntityWithDateTimeAndInt>(itemCount);
|
||||
|
||||
for (var i = 0; i < itemCount; i++)
|
||||
{
|
||||
items.Add(new TestEntityWithDateTimeAndInt
|
||||
{
|
||||
Id = i + 1,
|
||||
IntValue = i * 3,
|
||||
Created = now.AddMinutes(-i),
|
||||
Modified = now.AddMinutes(i),
|
||||
StatusCode = i % 4,
|
||||
Name = $"item-{i}"
|
||||
});
|
||||
}
|
||||
|
||||
return new TestParentWithDateTimeItemCollection
|
||||
{
|
||||
Id = 11,
|
||||
Name = "named-pipe-roundtrip",
|
||||
Created = now,
|
||||
Items = items
|
||||
};
|
||||
}
|
||||
|
||||
private static void AssertPayloadEquals(TestParentWithDateTimeItemCollection expected, TestParentWithDateTimeItemCollection actual)
|
||||
{
|
||||
Assert.AreEqual(expected.Id, actual.Id);
|
||||
Assert.AreEqual(expected.Name, actual.Name);
|
||||
Assert.AreEqual(expected.Created, actual.Created);
|
||||
|
||||
Assert.IsNotNull(expected.Items);
|
||||
Assert.IsNotNull(actual.Items);
|
||||
Assert.AreEqual(expected.Items.Count, actual.Items.Count);
|
||||
|
||||
for (var i = 0; i < expected.Items.Count; i++)
|
||||
{
|
||||
var e = expected.Items[i];
|
||||
var a = actual.Items[i];
|
||||
|
||||
Assert.AreEqual(e.Id, a.Id);
|
||||
Assert.AreEqual(e.IntValue, a.IntValue);
|
||||
Assert.AreEqual(e.Created, a.Created);
|
||||
Assert.AreEqual(e.Modified, a.Modified);
|
||||
Assert.AreEqual(e.StatusCode, a.StatusCode);
|
||||
Assert.AreEqual(e.Name, a.Name);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,370 @@
|
|||
using AyCode.Core.Extensions;
|
||||
using static AyCode.Core.Tests.TestModels.AcSerializerModels;
|
||||
|
||||
namespace AyCode.Core.Tests.Serialization;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for navigation property serialization issues.
|
||||
///
|
||||
/// CRITICAL BUG REPRODUCTION:
|
||||
/// When a navigation property (like StockTakingItem.Product) is populated,
|
||||
/// the serializer writes properties of the navigation target (Product),
|
||||
/// but these property names were NOT registered in the metadata header!
|
||||
///
|
||||
/// The bug pattern:
|
||||
/// 1. RegisterMetadataForType walks List<StockTakingItem> and registers StockTakingItem properties
|
||||
/// 2. StockTakingItem has a "Product" property of type Product - this property NAME is registered
|
||||
/// 3. BUT Product's own properties (Name, Description, Price, CategoryId) are NOT registered!
|
||||
/// 4. When Product is NOT NULL at runtime, WriteObject writes Product's property indices
|
||||
/// 5. GetPropertyNameIndex returns NEW indices that weren't in the header!
|
||||
/// 6. Deserializer reads property indices that don't exist in its table ? crash/type mismatch
|
||||
/// </summary>
|
||||
[TestClass]
|
||||
public class AcBinarySerializerNavigationPropertyTests
|
||||
{
|
||||
/// <summary>
|
||||
/// CRITICAL REGRESSION TEST: Navigation properties causing metadata mismatch.
|
||||
/// This is the EXACT production scenario:
|
||||
/// - StockTakingItem.Product is populated by the database query
|
||||
/// - Product's properties are serialized with wrong indices
|
||||
/// - Deserializer fails with type mismatch
|
||||
/// </summary>
|
||||
[TestMethod]
|
||||
public void Deserialize_NavigationPropertyPopulated_MetadataIncludesNestedType()
|
||||
{
|
||||
var parent = new ParentWithNavigatingItems
|
||||
{
|
||||
Id = 1,
|
||||
Name = "Parent",
|
||||
Creator = 6, // The exact value from production error
|
||||
Created = new DateTime(2025, 1, 24, 15, 25, 0, DateTimeKind.Utc),
|
||||
Modified = DateTime.UtcNow,
|
||||
Items = new List<ItemWithNavigationProperty>
|
||||
{
|
||||
new()
|
||||
{
|
||||
Id = 10,
|
||||
ParentId = 1,
|
||||
ProductId = 100,
|
||||
IsMeasured = true,
|
||||
Quantity = 50,
|
||||
Created = DateTime.UtcNow.AddHours(-1),
|
||||
Modified = DateTime.UtcNow,
|
||||
// Navigation property IS populated - this is the key!
|
||||
Product = new ProductEntity
|
||||
{
|
||||
Id = 100,
|
||||
Name = "TestProduct",
|
||||
Description = "Product description with long text",
|
||||
Price = 99.99,
|
||||
CategoryId = 5,
|
||||
Created = DateTime.UtcNow.AddDays(-30)
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
var binary = parent.ToBinary();
|
||||
var result = binary.BinaryTo<ParentWithNavigatingItems>();
|
||||
|
||||
Assert.IsNotNull(result);
|
||||
Assert.AreEqual(1, result.Id);
|
||||
Assert.AreEqual(6, result.Creator, "Creator should be 6");
|
||||
Assert.AreEqual(parent.Created.Ticks, result.Created.Ticks,
|
||||
$"Created mismatch. Expected: {parent.Created}, Got: {result.Created}");
|
||||
|
||||
Assert.IsNotNull(result.Items);
|
||||
Assert.AreEqual(1, result.Items.Count);
|
||||
|
||||
var item = result.Items[0];
|
||||
Assert.AreEqual(10, item.Id);
|
||||
Assert.AreEqual(100, item.ProductId);
|
||||
|
||||
// Navigation property should be deserialized correctly
|
||||
Assert.IsNotNull(item.Product, "Product navigation property should not be null");
|
||||
Assert.AreEqual(100, item.Product.Id);
|
||||
Assert.AreEqual("TestProduct", item.Product.Name);
|
||||
Assert.AreEqual(5, item.Product.CategoryId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Test with multiple items, some with Product populated, some without.
|
||||
/// This creates a mixed scenario where some items have navigation properties.
|
||||
/// </summary>
|
||||
[TestMethod]
|
||||
public void Deserialize_MixedNavigationProperties_AllItemsCorrect()
|
||||
{
|
||||
var parent = new ParentWithNavigatingItems
|
||||
{
|
||||
Id = 1,
|
||||
Name = "Parent",
|
||||
Creator = 6,
|
||||
Created = DateTime.UtcNow,
|
||||
Modified = DateTime.UtcNow,
|
||||
Items = Enumerable.Range(1, 5).Select(i => new ItemWithNavigationProperty
|
||||
{
|
||||
Id = i * 10,
|
||||
ParentId = 1,
|
||||
ProductId = 100 + i,
|
||||
IsMeasured = i % 2 == 0,
|
||||
Quantity = i * 10,
|
||||
Created = DateTime.UtcNow.AddHours(-i),
|
||||
Modified = DateTime.UtcNow,
|
||||
// Only populate Product for even items
|
||||
Product = i % 2 == 0 ? new ProductEntity
|
||||
{
|
||||
Id = 100 + i,
|
||||
Name = $"Product_{i}",
|
||||
Description = $"Description for product {i}",
|
||||
Price = i * 10.5,
|
||||
CategoryId = i % 3,
|
||||
Created = DateTime.UtcNow.AddDays(-i)
|
||||
} : null
|
||||
}).ToList()
|
||||
};
|
||||
|
||||
var binary = parent.ToBinary();
|
||||
var result = binary.BinaryTo<ParentWithNavigatingItems>();
|
||||
|
||||
Assert.IsNotNull(result);
|
||||
Assert.AreEqual(1, result.Id);
|
||||
Assert.AreEqual(6, result.Creator);
|
||||
|
||||
Assert.IsNotNull(result.Items);
|
||||
Assert.AreEqual(5, result.Items.Count);
|
||||
|
||||
for (int i = 1; i <= 5; i++)
|
||||
{
|
||||
var item = result.Items[i - 1];
|
||||
Assert.AreEqual(i * 10, item.Id, $"Item {i} Id mismatch");
|
||||
|
||||
if (i % 2 == 0)
|
||||
{
|
||||
Assert.IsNotNull(item.Product, $"Item {i} should have Product");
|
||||
Assert.AreEqual($"Product_{i}", item.Product.Name);
|
||||
}
|
||||
else
|
||||
{
|
||||
Assert.IsNull(item.Product, $"Item {i} should not have Product");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Test with list of parents, each with items with navigation properties.
|
||||
/// This is the exact production scenario - multiple StockTaking entities
|
||||
/// each with StockTakingItems that have Product navigation properties.
|
||||
/// </summary>
|
||||
[TestMethod]
|
||||
public void Deserialize_ListOfParentsWithNavigationProperties_AllCorrect()
|
||||
{
|
||||
var parents = Enumerable.Range(1, 3).Select(p => new ParentWithNavigatingItems
|
||||
{
|
||||
Id = p,
|
||||
Name = $"Parent_{p}",
|
||||
Creator = p,
|
||||
Created = DateTime.UtcNow.AddDays(-p),
|
||||
Modified = DateTime.UtcNow,
|
||||
Items = Enumerable.Range(1, 2).Select(i => new ItemWithNavigationProperty
|
||||
{
|
||||
Id = p * 100 + i,
|
||||
ParentId = p,
|
||||
ProductId = 1000 + i,
|
||||
IsMeasured = true,
|
||||
Quantity = 10 * i,
|
||||
Created = DateTime.UtcNow.AddHours(-i),
|
||||
Modified = DateTime.UtcNow,
|
||||
Product = new ProductEntity
|
||||
{
|
||||
Id = 1000 + i,
|
||||
Name = $"Product_{p}_{i}",
|
||||
Description = $"Description {p}_{i}",
|
||||
Price = (p * 10) + (i * 1.5),
|
||||
CategoryId = i % 3,
|
||||
Created = DateTime.UtcNow.AddDays(-10)
|
||||
}
|
||||
}).ToList()
|
||||
}).ToList();
|
||||
|
||||
var binary = parents.ToBinary();
|
||||
var result = binary.BinaryTo<List<ParentWithNavigatingItems>>();
|
||||
|
||||
Assert.IsNotNull(result);
|
||||
Assert.AreEqual(3, result.Count);
|
||||
|
||||
for (int p = 0; p < 3; p++)
|
||||
{
|
||||
var parent = result[p];
|
||||
Assert.AreEqual(p + 1, parent.Id, $"Parent[{p}].Id mismatch");
|
||||
Assert.AreEqual(p + 1, parent.Creator, $"Parent[{p}].Creator mismatch");
|
||||
|
||||
Assert.IsNotNull(parent.Items);
|
||||
Assert.AreEqual(2, parent.Items.Count);
|
||||
|
||||
for (int i = 0; i < 2; i++)
|
||||
{
|
||||
var item = parent.Items[i];
|
||||
Assert.IsNotNull(item.Product, $"Parent[{p}].Items[{i}].Product should not be null");
|
||||
Assert.AreEqual($"Product_{p + 1}_{i + 1}", item.Product.Name);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Test deeply nested navigation properties.
|
||||
/// Product has a Category, Category has a Parent, etc.
|
||||
/// </summary>
|
||||
[TestMethod]
|
||||
public void Deserialize_DeeplyNestedNavigationProperties_AllCorrect()
|
||||
{
|
||||
// This tests that the serializer correctly handles navigation properties
|
||||
// even when they are deeply nested (Product -> Category -> Parent)
|
||||
var parent = new ParentWithNavigatingItems
|
||||
{
|
||||
Id = 1,
|
||||
Name = "Parent",
|
||||
Creator = 6,
|
||||
Created = DateTime.UtcNow,
|
||||
Modified = DateTime.UtcNow,
|
||||
Items = new List<ItemWithNavigationProperty>
|
||||
{
|
||||
new()
|
||||
{
|
||||
Id = 10,
|
||||
ParentId = 1,
|
||||
ProductId = 100,
|
||||
IsMeasured = true,
|
||||
Quantity = 50,
|
||||
Created = DateTime.UtcNow,
|
||||
Modified = DateTime.UtcNow,
|
||||
Product = new ProductEntity
|
||||
{
|
||||
Id = 100,
|
||||
Name = "ProductWithDetails",
|
||||
Description = "Very long description that should be interned",
|
||||
Price = 123.45,
|
||||
CategoryId = 10,
|
||||
Created = DateTime.UtcNow.AddMonths(-6)
|
||||
}
|
||||
},
|
||||
new()
|
||||
{
|
||||
Id = 20,
|
||||
ParentId = 1,
|
||||
ProductId = 200,
|
||||
IsMeasured = false,
|
||||
Quantity = 25,
|
||||
Created = DateTime.UtcNow,
|
||||
Modified = DateTime.UtcNow,
|
||||
Product = new ProductEntity
|
||||
{
|
||||
Id = 200,
|
||||
Name = "AnotherProduct",
|
||||
Description = "Another description",
|
||||
Price = 67.89,
|
||||
CategoryId = 20,
|
||||
Created = DateTime.UtcNow.AddMonths(-3)
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
var binary = parent.ToBinary();
|
||||
|
||||
// Log binary size for debugging
|
||||
Console.WriteLine($"Binary size: {binary.Length} bytes");
|
||||
|
||||
var result = binary.BinaryTo<ParentWithNavigatingItems>();
|
||||
|
||||
Assert.IsNotNull(result);
|
||||
Assert.AreEqual(1, result.Id);
|
||||
Assert.AreEqual(6, result.Creator);
|
||||
|
||||
Assert.IsNotNull(result.Items);
|
||||
Assert.AreEqual(2, result.Items.Count);
|
||||
|
||||
// First item
|
||||
Assert.AreEqual(10, result.Items[0].Id);
|
||||
Assert.IsNotNull(result.Items[0].Product);
|
||||
Assert.AreEqual("ProductWithDetails", result.Items[0].Product.Name);
|
||||
Assert.AreEqual(123.45, result.Items[0].Product.Price);
|
||||
|
||||
// Second item
|
||||
Assert.AreEqual(20, result.Items[1].Id);
|
||||
Assert.IsNotNull(result.Items[1].Product);
|
||||
Assert.AreEqual("AnotherProduct", result.Items[1].Product.Name);
|
||||
Assert.AreEqual(67.89, result.Items[1].Product.Price);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Test with same Product instance referenced multiple times.
|
||||
/// This tests the reference handling with navigation properties.
|
||||
/// </summary>
|
||||
[TestMethod]
|
||||
public void Deserialize_SharedNavigationProperty_ReferencesPreserved()
|
||||
{
|
||||
// Create a shared Product that is referenced by multiple items
|
||||
var sharedProduct = new ProductEntity
|
||||
{
|
||||
Id = 999,
|
||||
Name = "SharedProduct",
|
||||
Description = "This product is shared across items",
|
||||
Price = 50.00,
|
||||
CategoryId = 1,
|
||||
Created = DateTime.UtcNow.AddYears(-1)
|
||||
};
|
||||
|
||||
var parent = new ParentWithNavigatingItems
|
||||
{
|
||||
Id = 1,
|
||||
Name = "Parent",
|
||||
Creator = 6,
|
||||
Created = DateTime.UtcNow,
|
||||
Modified = DateTime.UtcNow,
|
||||
Items = new List<ItemWithNavigationProperty>
|
||||
{
|
||||
new()
|
||||
{
|
||||
Id = 10,
|
||||
ParentId = 1,
|
||||
ProductId = 999,
|
||||
IsMeasured = true,
|
||||
Quantity = 50,
|
||||
Created = DateTime.UtcNow,
|
||||
Modified = DateTime.UtcNow,
|
||||
Product = sharedProduct // Same reference
|
||||
},
|
||||
new()
|
||||
{
|
||||
Id = 20,
|
||||
ParentId = 1,
|
||||
ProductId = 999,
|
||||
IsMeasured = false,
|
||||
Quantity = 75,
|
||||
Created = DateTime.UtcNow,
|
||||
Modified = DateTime.UtcNow,
|
||||
Product = sharedProduct // Same reference
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
var binary = parent.ToBinary();
|
||||
var result = binary.BinaryTo<ParentWithNavigatingItems>();
|
||||
|
||||
Assert.IsNotNull(result);
|
||||
Assert.IsNotNull(result.Items);
|
||||
Assert.AreEqual(2, result.Items.Count);
|
||||
|
||||
// Both items should have the same Product values
|
||||
Assert.IsNotNull(result.Items[0].Product);
|
||||
Assert.IsNotNull(result.Items[1].Product);
|
||||
Assert.AreEqual(999, result.Items[0].Product.Id);
|
||||
Assert.AreEqual(999, result.Items[1].Product.Id);
|
||||
Assert.AreEqual("SharedProduct", result.Items[0].Product.Name);
|
||||
Assert.AreEqual("SharedProduct", result.Items[1].Product.Name);
|
||||
|
||||
// With reference handling, they should be the same instance
|
||||
// (This depends on ReferenceHandling being enabled)
|
||||
Console.WriteLine($"Same instance: {ReferenceEquals(result.Items[0].Product, result.Items[1].Product)}");
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,237 @@
|
|||
using AyCode.Core.Extensions;
|
||||
using static AyCode.Core.Tests.TestModels.AcSerializerModels;
|
||||
using static AyCode.Core.Tests.Serialization.AcSerializerTestHelper;
|
||||
|
||||
namespace AyCode.Core.Tests.Serialization;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for nullable value type serialization.
|
||||
/// </summary>
|
||||
[TestClass]
|
||||
public class AcBinarySerializerNullableTests
|
||||
{
|
||||
[TestMethod]
|
||||
public void Deserialize_NullableIntProperty_WithValue_RoundTrip()
|
||||
{
|
||||
var obj = new TestClassWithNullableProperties { Id = 42, NullableInt = 123, NullableIntNull = null };
|
||||
|
||||
var binary = obj.ToBinary();
|
||||
var result = binary.BinaryTo<TestClassWithNullableProperties>();
|
||||
|
||||
Assert.IsNotNull(result);
|
||||
Assert.AreEqual(42, result.Id);
|
||||
Assert.AreEqual(123, result.NullableInt);
|
||||
Assert.IsNull(result.NullableIntNull);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void Deserialize_NullableDoubleProperty_WithValue_RoundTrip()
|
||||
{
|
||||
var obj = new TestClassWithNullableProperties { Id = 1, NullableDouble = 3.14159, NullableDoubleNull = null };
|
||||
|
||||
var binary = obj.ToBinary();
|
||||
var result = binary.BinaryTo<TestClassWithNullableProperties>();
|
||||
|
||||
Assert.IsNotNull(result);
|
||||
Assert.AreEqual(3.14159, result.NullableDouble);
|
||||
Assert.IsNull(result.NullableDoubleNull);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void Deserialize_NullableDateTimeProperty_WithValue_RoundTrip()
|
||||
{
|
||||
var testDate = new DateTime(2024, 12, 25, 10, 30, 45, DateTimeKind.Utc);
|
||||
var obj = new TestClassWithNullableProperties { Id = 1, NullableDateTime = testDate, NullableDateTimeNull = null };
|
||||
|
||||
var binary = obj.ToBinary();
|
||||
var result = binary.BinaryTo<TestClassWithNullableProperties>();
|
||||
|
||||
Assert.IsNotNull(result);
|
||||
Assert.AreEqual(testDate, result.NullableDateTime);
|
||||
Assert.IsNull(result.NullableDateTimeNull);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void Deserialize_NullableGuidProperty_WithValue_RoundTrip()
|
||||
{
|
||||
var testGuid = Guid.NewGuid();
|
||||
var obj = new TestClassWithNullableProperties { Id = 1, NullableGuid = testGuid, NullableGuidNull = null };
|
||||
|
||||
var binary = obj.ToBinary();
|
||||
var result = binary.BinaryTo<TestClassWithNullableProperties>();
|
||||
|
||||
Assert.IsNotNull(result);
|
||||
Assert.AreEqual(testGuid, result.NullableGuid);
|
||||
Assert.IsNull(result.NullableGuidNull);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void Deserialize_NullableDecimalProperty_WithValue_RoundTrip()
|
||||
{
|
||||
var obj = new TestClassWithNullableProperties { Id = 1, NullableDecimal = 123456.789m, NullableDecimalNull = null };
|
||||
|
||||
var binary = obj.ToBinary();
|
||||
var result = binary.BinaryTo<TestClassWithNullableProperties>();
|
||||
|
||||
Assert.IsNotNull(result);
|
||||
Assert.AreEqual(123456.789m, result.NullableDecimal);
|
||||
Assert.IsNull(result.NullableDecimalNull);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void Deserialize_NullableBoolProperty_WithValue_RoundTrip()
|
||||
{
|
||||
var obj = new TestClassWithNullableProperties { Id = 1, NullableBool = true, NullableBoolNull = null };
|
||||
|
||||
var binary = obj.ToBinary();
|
||||
var result = binary.BinaryTo<TestClassWithNullableProperties>();
|
||||
|
||||
Assert.IsNotNull(result);
|
||||
Assert.AreEqual(true, result.NullableBool);
|
||||
Assert.IsNull(result.NullableBoolNull);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void Deserialize_NullableLongProperty_WithValue_RoundTrip()
|
||||
{
|
||||
var obj = new TestClassWithNullableProperties { Id = 1, NullableLong = 9876543210L, NullableLongNull = null };
|
||||
|
||||
var binary = obj.ToBinary();
|
||||
var result = binary.BinaryTo<TestClassWithNullableProperties>();
|
||||
|
||||
Assert.IsNotNull(result);
|
||||
Assert.AreEqual(9876543210L, result.NullableLong);
|
||||
Assert.IsNull(result.NullableLongNull);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void Deserialize_AllNullablePropertiesWithValues_RoundTrip()
|
||||
{
|
||||
var testDate = new DateTime(2024, 6, 15, 14, 30, 0, DateTimeKind.Utc);
|
||||
var testGuid = Guid.Parse("12345678-1234-1234-1234-123456789abc");
|
||||
|
||||
var obj = new TestClassWithNullableProperties
|
||||
{
|
||||
Id = 999,
|
||||
NullableInt = int.MaxValue,
|
||||
NullableIntNull = 42,
|
||||
NullableLong = long.MaxValue,
|
||||
NullableLongNull = 100L,
|
||||
NullableDouble = double.MaxValue,
|
||||
NullableDoubleNull = 2.5,
|
||||
NullableDecimal = decimal.MaxValue,
|
||||
NullableDecimalNull = 1.1m,
|
||||
NullableDateTime = testDate,
|
||||
NullableDateTimeNull = DateTime.UtcNow,
|
||||
NullableGuid = testGuid,
|
||||
NullableGuidNull = Guid.NewGuid(),
|
||||
NullableBool = false,
|
||||
NullableBoolNull = true
|
||||
};
|
||||
|
||||
var binary = obj.ToBinary();
|
||||
var result = binary.BinaryTo<TestClassWithNullableProperties>();
|
||||
|
||||
Assert.IsNotNull(result);
|
||||
Assert.AreEqual(obj.Id, result.Id);
|
||||
Assert.AreEqual(obj.NullableInt, result.NullableInt);
|
||||
Assert.AreEqual(obj.NullableIntNull, result.NullableIntNull);
|
||||
Assert.AreEqual(obj.NullableLong, result.NullableLong);
|
||||
Assert.AreEqual(obj.NullableLongNull, result.NullableLongNull);
|
||||
Assert.AreEqual(obj.NullableDouble, result.NullableDouble);
|
||||
Assert.AreEqual(obj.NullableDecimalNull, result.NullableDecimalNull);
|
||||
Assert.AreEqual(obj.NullableDateTime, result.NullableDateTime);
|
||||
Assert.AreEqual(obj.NullableGuid, result.NullableGuid);
|
||||
Assert.AreEqual(obj.NullableBool, result.NullableBool);
|
||||
Assert.AreEqual(obj.NullableBoolNull, result.NullableBoolNull);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void Deserialize_ObjectWithNestedNullableProperties_RoundTrip()
|
||||
{
|
||||
var obj = new TestParentWithNullableChild
|
||||
{
|
||||
Id = 1,
|
||||
Name = "Parent",
|
||||
Child = new TestClassWithNullableProperties
|
||||
{
|
||||
Id = 2,
|
||||
NullableInt = 100,
|
||||
NullableDouble = 5.5,
|
||||
NullableDateTime = DateTime.UtcNow,
|
||||
NullableGuid = Guid.NewGuid()
|
||||
}
|
||||
};
|
||||
|
||||
var binary = obj.ToBinary();
|
||||
var result = binary.BinaryTo<TestParentWithNullableChild>();
|
||||
|
||||
Assert.IsNotNull(result);
|
||||
Assert.AreEqual(obj.Id, result.Id);
|
||||
Assert.AreEqual(obj.Name, result.Name);
|
||||
Assert.IsNotNull(result.Child);
|
||||
Assert.AreEqual(obj.Child.NullableInt, result.Child.NullableInt);
|
||||
Assert.AreEqual(obj.Child.NullableDouble, result.Child.NullableDouble);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void Deserialize_ListOfObjectsWithNullableProperties_RoundTrip()
|
||||
{
|
||||
var items = CreateNullablePropertyItems(10);
|
||||
var binary = items.ToBinary();
|
||||
var result = binary.BinaryTo<List<TestClassWithNullableProperties>>();
|
||||
|
||||
Assert.IsNotNull(result);
|
||||
Assert.AreEqual(10, result.Count);
|
||||
|
||||
for (int i = 0; i < 10; i++)
|
||||
{
|
||||
var original = items[i];
|
||||
var deserialized = result[i];
|
||||
|
||||
Assert.AreEqual(original.Id, deserialized.Id, $"Id mismatch at index {i}");
|
||||
Assert.AreEqual(original.NullableInt, deserialized.NullableInt, $"NullableInt mismatch at index {i}");
|
||||
Assert.AreEqual(original.NullableDouble, deserialized.NullableDouble, $"NullableDouble mismatch at index {i}");
|
||||
Assert.AreEqual(original.NullableGuid, deserialized.NullableGuid, $"NullableGuid mismatch at index {i}");
|
||||
}
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void Deserialize_StockTakingLikeHierarchy_WithNullableProperties_RoundTrip()
|
||||
{
|
||||
var stockTaking = CreateStockTaking(2, 2);
|
||||
|
||||
var binary = stockTaking.ToBinary();
|
||||
var result = binary.BinaryTo<TestStockTaking>();
|
||||
|
||||
Assert.IsNotNull(result);
|
||||
Assert.AreEqual(stockTaking.Id, result.Id);
|
||||
Assert.AreEqual(stockTaking.IsClosed, result.IsClosed);
|
||||
Assert.AreEqual(stockTaking.Creator, result.Creator);
|
||||
|
||||
Assert.IsNotNull(result.StockTakingItems);
|
||||
Assert.AreEqual(2, result.StockTakingItems.Count);
|
||||
|
||||
var item0 = result.StockTakingItems[0];
|
||||
Assert.IsNotNull(item0.StockTakingItemPallets);
|
||||
Assert.AreEqual(2, item0.StockTakingItemPallets.Count);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void Deserialize_ListOfStockTakingLikeEntities_RoundTrip()
|
||||
{
|
||||
var stockTakings = CreateStockTakingList(2, 1, 1);
|
||||
var binary = stockTakings.ToBinary();
|
||||
var result = binary.BinaryTo<List<TestStockTaking>>();
|
||||
|
||||
Assert.IsNotNull(result);
|
||||
Assert.AreEqual(2, result.Count);
|
||||
|
||||
Assert.AreEqual(1, result[0].Id);
|
||||
Assert.IsNotNull(result[0].StockTakingItems);
|
||||
Assert.AreEqual(1, result[0].StockTakingItems.Count);
|
||||
|
||||
Assert.AreEqual(2, result[1].Id);
|
||||
Assert.IsNotNull(result[1].StockTakingItems);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,185 @@
|
|||
using AyCode.Core.Extensions;
|
||||
using static AyCode.Core.Tests.TestModels.AcSerializerModels;
|
||||
|
||||
namespace AyCode.Core.Tests.Serialization;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for object serialization including nested objects, lists, and dictionaries.
|
||||
/// </summary>
|
||||
[TestClass]
|
||||
public class AcBinarySerializerObjectTests
|
||||
{
|
||||
[TestMethod]
|
||||
public void Serialize_SimpleObject_RoundTrip()
|
||||
{
|
||||
var obj = new TestSimpleClass { Id = 42, Name = "Test Object", Value = 3.14, IsActive = true };
|
||||
|
||||
var binary = obj.ToBinary();
|
||||
var result = binary.BinaryTo<TestSimpleClass>();
|
||||
|
||||
Assert.IsNotNull(result);
|
||||
Assert.AreEqual(obj.Id, result.Id);
|
||||
Assert.AreEqual(obj.Name, result.Name);
|
||||
Assert.AreEqual(obj.Value, result.Value);
|
||||
Assert.AreEqual(obj.IsActive, result.IsActive);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void Serialize_NestedObject_RoundTrip()
|
||||
{
|
||||
var obj = new TestNestedClass
|
||||
{
|
||||
Id = 1,
|
||||
Name = "Parent",
|
||||
Child = new TestSimpleClass { Id = 2, Name = "Child", Value = 2.5, IsActive = true }
|
||||
};
|
||||
|
||||
var binary = obj.ToBinary();
|
||||
var result = binary.BinaryTo<TestNestedClass>();
|
||||
|
||||
Assert.IsNotNull(result);
|
||||
Assert.AreEqual(obj.Id, result.Id);
|
||||
Assert.AreEqual(obj.Name, result.Name);
|
||||
Assert.IsNotNull(result.Child);
|
||||
Assert.AreEqual(obj.Child.Id, result.Child.Id);
|
||||
Assert.AreEqual(obj.Child.Name, result.Child.Name);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void Serialize_List_RoundTrip()
|
||||
{
|
||||
var list = new List<int> { 1, 2, 3, 4, 5 };
|
||||
var binary = list.ToBinary();
|
||||
var result = binary.BinaryTo<List<int>>();
|
||||
|
||||
Assert.IsNotNull(result);
|
||||
CollectionAssert.AreEqual(list, result);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void Serialize_ObjectWithList_RoundTrip()
|
||||
{
|
||||
var obj = new TestClassWithList { Id = 1, Items = ["Item1", "Item2", "Item3"] };
|
||||
|
||||
var binary = obj.ToBinary();
|
||||
var result = binary.BinaryTo<TestClassWithList>();
|
||||
|
||||
Assert.IsNotNull(result);
|
||||
Assert.AreEqual(obj.Id, result.Id);
|
||||
Assert.IsNotNull(result.Items);
|
||||
CollectionAssert.AreEqual(obj.Items, result.Items);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void Serialize_Dictionary_RoundTrip()
|
||||
{
|
||||
var dict = new Dictionary<string, int> { ["one"] = 1, ["two"] = 2, ["three"] = 3 };
|
||||
|
||||
var binary = dict.ToBinary();
|
||||
var result = binary.BinaryTo<Dictionary<string, int>>();
|
||||
|
||||
Assert.IsNotNull(result);
|
||||
Assert.AreEqual(dict.Count, result.Count);
|
||||
foreach (var kvp in dict)
|
||||
{
|
||||
Assert.IsTrue(result.ContainsKey(kvp.Key));
|
||||
Assert.AreEqual(kvp.Value, result[kvp.Key]);
|
||||
}
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void Populate_UpdatesExistingObject()
|
||||
{
|
||||
var target = new TestSimpleClass { Id = 0, Name = "Original" };
|
||||
var source = new TestSimpleClass { Id = 42, Name = "Updated", Value = 3.14 };
|
||||
|
||||
var binary = source.ToBinary();
|
||||
binary.BinaryTo(target);
|
||||
|
||||
Assert.AreEqual(42, target.Id);
|
||||
Assert.AreEqual("Updated", target.Name);
|
||||
Assert.AreEqual(3.14, target.Value);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void PopulateMerge_MergesNestedObjects()
|
||||
{
|
||||
var target = new TestNestedClass
|
||||
{
|
||||
Id = 1,
|
||||
Name = "Original",
|
||||
Child = new TestSimpleClass { Id = 10, Name = "OriginalChild", Value = 1.0 }
|
||||
};
|
||||
|
||||
var source = new TestNestedClass
|
||||
{
|
||||
Id = 2,
|
||||
Name = "Updated",
|
||||
Child = new TestSimpleClass { Id = 20, Name = "UpdatedChild", Value = 2.0 }
|
||||
};
|
||||
|
||||
var binary = source.ToBinary();
|
||||
binary.BinaryToMerge(target);
|
||||
|
||||
Assert.AreEqual(2, target.Id);
|
||||
Assert.AreEqual("Updated", target.Name);
|
||||
Assert.IsNotNull(target.Child);
|
||||
Assert.AreEqual(20, target.Child.Id);
|
||||
Assert.AreEqual("UpdatedChild", target.Child.Name);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void Serialize_SimpleId45_RoundTrip()
|
||||
{
|
||||
// Simple test to debug Id=45 serialization issue
|
||||
var item = new TestHighReuseDto { Id = 45, CategoryCode = "test"};
|
||||
|
||||
var binary = item.ToBinary();
|
||||
var result = binary.BinaryTo<TestHighReuseDto>();
|
||||
|
||||
Assert.IsNotNull(result);
|
||||
Assert.AreEqual(45, result.Id, "Id should be 45 after deserialization");
|
||||
Assert.AreEqual("test", result.CategoryCode);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void Serialize_SimpleId0_RoundTrip()
|
||||
{
|
||||
// Test with Id=0 to see if SKIP marker is used correctly
|
||||
var item = new TestHighReuseDto { Id = 0 };
|
||||
|
||||
var binary = item.ToBinary();
|
||||
var result = binary.BinaryTo<TestHighReuseDto>();
|
||||
|
||||
Assert.IsNotNull(result);
|
||||
Assert.AreEqual(0, result.Id, "Id should be 0 after deserialization");
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
[DataRow(-16, DisplayName = "TinyInt min: -16")]
|
||||
[DataRow(-1, DisplayName = "Negative: -1")]
|
||||
[DataRow(0, DisplayName = "Zero: 0")]
|
||||
[DataRow(1, DisplayName = "Small: 1")]
|
||||
[DataRow(45, DisplayName = "Was buggy: 45")]
|
||||
[DataRow(47, DisplayName = "TinyInt max: 47")]
|
||||
[DataRow(48, DisplayName = "Above TinyInt: 48")]
|
||||
[DataRow(66, DisplayName = "Was PropertySkip v2: 66")]
|
||||
[DataRow(100, DisplayName = "Medium: 100")]
|
||||
[DataRow(191, DisplayName = "Current PropertySkip value: 191")]
|
||||
[DataRow(192, DisplayName = "TinyInt code start: 192")]
|
||||
[DataRow(253, DisplayName = "Was PropertySkip v1: 253")]
|
||||
[DataRow(255, DisplayName = "Max byte: 255")]
|
||||
[DataRow(1000, DisplayName = "Large: 1000")]
|
||||
[DataRow(int.MaxValue, DisplayName = "MaxValue")]
|
||||
[DataRow(int.MinValue, DisplayName = "MinValue")]
|
||||
public void Serialize_VariousIntIds_PropertySkipTest(int id)
|
||||
{
|
||||
var item = new TestHighReuseDto { Id = id, CategoryCode = "test" };
|
||||
|
||||
var binary = item.ToBinary();
|
||||
var result = binary.BinaryTo<TestHighReuseDto>();
|
||||
|
||||
Assert.IsNotNull(result);
|
||||
Assert.AreEqual(id, result.Id, $"Id should be {id} after deserialization");
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,729 @@
|
|||
using AyCode.Core.Serializers.Binaries;
|
||||
using AyCode.Core.Tests.TestModels;
|
||||
using System.IO;
|
||||
using System.IO.Pipelines;
|
||||
using static AyCode.Core.Tests.TestModels.AcSerializerModels;
|
||||
|
||||
namespace AyCode.Core.Tests.Serialization;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for <see cref="AsyncPipeReaderInput"/> (Step 1, ACCORE-BIN-T-D6H4) and the
|
||||
/// <see cref="AsyncPipeReaderInputExtensions.DrainFromAsync"/> extension (Step 2, ACCORE-BIN-T-M2K1),
|
||||
/// plus the real parallel pipeline test (Step 3, ACCORE-BIN-T-V7C9), plus runtime type-detect
|
||||
/// sanity pinning (Step 4).
|
||||
///
|
||||
/// <para>Tests run with <see cref="AsyncPipeReaderInput"/>'s default <c>multiMessage = true</c> —
|
||||
/// <see cref="AsyncPipeReaderInput.Feed"/> expects the AsyncSegment chunked wire format
|
||||
/// <c>[201][UINT16 LE size][data]</c> per chunk, tolerates <c>[200]</c> CHUNK_START prefix, and
|
||||
/// signals end-of-stream on <c>[202]</c> CHUNK_END. The <see cref="WrapInChunkFrame"/> helper
|
||||
/// wraps test data into single chunk frames; multi-chunk tests concatenate multiple frames.</para>
|
||||
///
|
||||
/// <para>Wire format identical to <see cref="AsyncPipeWriterOutput"/> framed output and to
|
||||
/// SignalR's <c>AcBinaryHubProtocol.TryParseChunkData</c> input — unified across all transports
|
||||
/// per ADR-0003 §9.</para>
|
||||
/// </summary>
|
||||
[TestClass]
|
||||
public class AcBinarySerializerPipeParallelTests
|
||||
{
|
||||
// ====================================================================
|
||||
// Step 1 — AsyncPipeReaderInput contract (ACCORE-BIN-T-D6H4)
|
||||
// ====================================================================
|
||||
|
||||
[TestMethod]
|
||||
public void Feed_EmptyData_NoOp()
|
||||
{
|
||||
using var input = new AsyncPipeReaderInput(64);
|
||||
|
||||
input.Feed(ReadOnlySpan<byte>.Empty);
|
||||
input.Complete();
|
||||
|
||||
// No data → TryAdvanceSegment returns false immediately
|
||||
input.Initialize(out var buffer, out var position, out var bufferLength);
|
||||
Assert.AreEqual(0, bufferLength);
|
||||
|
||||
var hasMore = input.TryAdvanceSegment(ref buffer, ref position, ref bufferLength, 1);
|
||||
Assert.IsFalse(hasMore);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void Feed_AppendsBytes_AccessibleViaTryAdvanceSegment()
|
||||
{
|
||||
using var input = new AsyncPipeReaderInput(64);
|
||||
var data = new byte[] { 1, 2, 3, 4, 5, 6, 7, 8 };
|
||||
|
||||
input.Feed(WrapInChunkFrame(data)); // [201][UINT16=8][1..8]
|
||||
input.Complete();
|
||||
|
||||
var consumed = ConsumeAll(input);
|
||||
CollectionAssert.AreEqual(data, consumed);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void Initialize_BeforeFeed_ReturnsEmptyBuffer()
|
||||
{
|
||||
using var input = new AsyncPipeReaderInput(64);
|
||||
|
||||
input.Initialize(out var buffer, out var position, out var bufferLength);
|
||||
|
||||
Assert.IsNotNull(buffer);
|
||||
Assert.AreEqual(0, position);
|
||||
Assert.AreEqual(0, bufferLength);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void Initialize_AfterFeed_ReturnsAvailableData()
|
||||
{
|
||||
using var input = new AsyncPipeReaderInput(64);
|
||||
|
||||
var data = new byte[] { 10, 20, 30 };
|
||||
input.Feed(WrapInChunkFrame(data));
|
||||
|
||||
input.Initialize(out var buffer, out var position, out var bufferLength);
|
||||
|
||||
Assert.AreEqual(0, position);
|
||||
Assert.AreEqual(3, bufferLength);
|
||||
Assert.AreEqual((byte)10, buffer[0]);
|
||||
Assert.AreEqual((byte)20, buffer[1]);
|
||||
Assert.AreEqual((byte)30, buffer[2]);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void Complete_AllConsumed_TryAdvanceSegmentReturnsFalse()
|
||||
{
|
||||
using var input = new AsyncPipeReaderInput(64);
|
||||
|
||||
input.Feed(WrapInChunkFrame([1, 2, 3]));
|
||||
input.Complete();
|
||||
|
||||
// Simulate consumer that has read all 3 bytes
|
||||
input.Initialize(out var buffer, out var position, out var bufferLength);
|
||||
position = bufferLength;
|
||||
|
||||
var hasMore = input.TryAdvanceSegment(ref buffer, ref position, ref bufferLength, 1);
|
||||
Assert.IsFalse(hasMore);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void Complete_WithLeftoverData_TryAdvanceSegmentReturnsTrueWithRemainder()
|
||||
{
|
||||
using var input = new AsyncPipeReaderInput(64);
|
||||
|
||||
input.Feed(WrapInChunkFrame([1, 2, 3]));
|
||||
input.Feed(WrapInChunkFrame([4, 5, 6]));
|
||||
input.Complete();
|
||||
|
||||
// Simulate consumer that has read 3 of 6 bytes — advance should expose the rest
|
||||
input.Initialize(out var buffer, out var position, out var bufferLength);
|
||||
Assert.AreEqual(6, bufferLength);
|
||||
|
||||
position = 3;
|
||||
var hasMore = input.TryAdvanceSegment(ref buffer, ref position, ref bufferLength, 1);
|
||||
|
||||
Assert.IsTrue(hasMore);
|
||||
Assert.AreEqual(3, position);
|
||||
Assert.AreEqual(6, bufferLength);
|
||||
Assert.AreEqual((byte)4, buffer[3]);
|
||||
Assert.AreEqual((byte)5, buffer[4]);
|
||||
Assert.AreEqual((byte)6, buffer[5]);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void Grow_PastInitialCapacity_BytesPreservedAcrossGrows()
|
||||
{
|
||||
// Initial capacity = 16, feed > 16 bytes consecutively (no consume between) → forces grow
|
||||
using var input = new AsyncPipeReaderInput(16);
|
||||
|
||||
var data = new byte[64];
|
||||
for (var i = 0; i < data.Length; i++) data[i] = (byte)i;
|
||||
|
||||
// Feed in chunks that overflow the initial buffer (each wrapped in a chunk frame)
|
||||
input.Feed(WrapInChunkFrame(data, 0, 16));
|
||||
input.Feed(WrapInChunkFrame(data, 16, 16)); // grow #1
|
||||
input.Feed(WrapInChunkFrame(data, 32, 32)); // grow #2
|
||||
input.Complete();
|
||||
|
||||
var consumed = ConsumeAll(input);
|
||||
CollectionAssert.AreEqual(data, consumed);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public async Task ProducerConsumer_Concurrency_AllBytesDeliveredInOrder()
|
||||
{
|
||||
const int totalBytes = 8192;
|
||||
const int chunkSize = 17; // intentional: not a power of 2, exercises partial fills
|
||||
|
||||
using var input = new AsyncPipeReaderInput(64);
|
||||
var expected = new byte[totalBytes];
|
||||
for (var i = 0; i < totalBytes; i++) expected[i] = (byte)(i & 0xFF);
|
||||
|
||||
var consumeTask = Task.Run(() => ConsumeAll(input));
|
||||
|
||||
var produceTask = Task.Run(() =>
|
||||
{
|
||||
try
|
||||
{
|
||||
var offset = 0;
|
||||
while (offset < expected.Length)
|
||||
{
|
||||
var take = Math.Min(chunkSize, expected.Length - offset);
|
||||
|
||||
input.Feed(WrapInChunkFrame(expected, offset, take));
|
||||
offset += take;
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
input.Complete();
|
||||
}
|
||||
});
|
||||
|
||||
await Task.WhenAll(consumeTask, produceTask);
|
||||
|
||||
var actual = consumeTask.Result;
|
||||
CollectionAssert.AreEqual(expected, actual);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public async Task ProducerConsumer_SlidingWindowCycle_ManyResetsHandledCorrectly()
|
||||
{
|
||||
// Small initial buffer + slow producer drives many reset-to-0 cycles.
|
||||
const int totalBytes = 32 * 1024;
|
||||
const int chunkSize = 7;
|
||||
|
||||
using var input = new AsyncPipeReaderInput(32);
|
||||
var expected = new byte[totalBytes];
|
||||
|
||||
for (var i = 0; i < totalBytes; i++) expected[i] = (byte)(i & 0xFF);
|
||||
|
||||
var consumeTask = Task.Run(() => ConsumeAll(input));
|
||||
|
||||
var produceTask = Task.Run(async () =>
|
||||
{
|
||||
try
|
||||
{
|
||||
var offset = 0;
|
||||
while (offset < expected.Length)
|
||||
{
|
||||
var take = Math.Min(chunkSize, expected.Length - offset);
|
||||
input.Feed(WrapInChunkFrame(expected, offset, take));
|
||||
offset += take;
|
||||
if ((offset & 0x7F) == 0) await Task.Yield();
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
input.Complete();
|
||||
}
|
||||
});
|
||||
|
||||
await Task.WhenAll(consumeTask, produceTask);
|
||||
|
||||
var actual = consumeTask.Result;
|
||||
|
||||
Assert.AreEqual(expected.Length, actual.Length);
|
||||
CollectionAssert.AreEqual(expected, actual);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void Dispose_DoesNotThrow()
|
||||
{
|
||||
var input = new AsyncPipeReaderInput(64);
|
||||
input.Feed(WrapInChunkFrame([1, 2, 3]));
|
||||
input.Complete();
|
||||
|
||||
input.Dispose();
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void Constructor_InvalidCapacity_ThrowsArgumentOutOfRange()
|
||||
{
|
||||
_ = Assert.ThrowsExactly<ArgumentOutOfRangeException>(() => new AsyncPipeReaderInput(0));
|
||||
_ = Assert.ThrowsExactly<ArgumentOutOfRangeException>(() => new AsyncPipeReaderInput(-1));
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void Feed_PartialFrameAcrossCalls_ParsedCorrectly()
|
||||
{
|
||||
// Verifies the framing state machine survives partial frame headers / sizes / data
|
||||
// split across multiple Feed calls.
|
||||
using var input = new AsyncPipeReaderInput(64);
|
||||
|
||||
var data = new byte[] { 10, 20, 30, 40, 50 };
|
||||
var frame = WrapInChunkFrame(data); // 8 bytes total: [201][05][00][10][20][30][40][50]
|
||||
|
||||
// Feed byte-by-byte to stress the state machine
|
||||
for (var i = 0; i < frame.Length; i++) input.Feed(frame.AsSpan(i, 1));
|
||||
input.Complete();
|
||||
|
||||
var consumed = ConsumeAll(input);
|
||||
CollectionAssert.AreEqual(data, consumed);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void Feed_ChunkEndMarker_AutoResetsForNextMessage()
|
||||
{
|
||||
// [202] CHUNK_END is end-of-MESSAGE on the WIRE — NOT end-of-session and NOT, by itself,
|
||||
// a buffer-cursor recycle. On [202], the framing-state machine resets to AwaitingHeader so
|
||||
// the next [201] header is parsed correctly; buffer-cursor recycling is armed separately by
|
||||
// the consumer via MessageDone() (typically from the AcBinaryDeserializer.Deserialize<T>(
|
||||
// AsyncPipeReaderInput, opts) finally block, AFTER the deserialiser has finished reading
|
||||
// the structurally-complete graph). See BINARY_ISSUES.md#accore-bin-i-q4t8 / R5K2 fix.
|
||||
// Session end is signalled separately by an external Complete() / stream-EOF.
|
||||
using var input = new AsyncPipeReaderInput(64);
|
||||
|
||||
// Message 1
|
||||
input.Feed(WrapInChunkFrame([1, 2, 3]));
|
||||
input.Feed([202]); // CHUNK_END — framing reset only (no buffer-cursor recycle, no completion)
|
||||
|
||||
// First message is consumable
|
||||
input.Initialize(out var buf1, out var pos1, out var bufLen1);
|
||||
Assert.AreEqual(3, bufLen1);
|
||||
Assert.AreEqual(1, buf1[0]);
|
||||
Assert.AreEqual(2, buf1[1]);
|
||||
Assert.AreEqual(3, buf1[2]);
|
||||
|
||||
// Simulate the AcBinaryDeserializer.Deserialize<T>(input, opts) finally block: the consumer
|
||||
// calls MessageDone() AFTER it has finished reading the graph. This arms the
|
||||
// _readPos = -1 sentinel; the next AppendToBuffer for message 2 sees rp < 0 and recycles
|
||||
// the buffer to 0 (sliding-window cycling).
|
||||
input.MessageDone();
|
||||
|
||||
// Message 2 — same long-lived input, just keeps going
|
||||
input.Feed(WrapInChunkFrame([10, 20, 30, 40]));
|
||||
input.Feed([202]);
|
||||
|
||||
// Re-initialize for the next deserializer call — the buffer was recycled to 0 by the
|
||||
// sliding-window cycling triggered when AppendToBuffer saw _readPos == -1 (sentinel
|
||||
// armed by the MessageDone() call above).
|
||||
input.Initialize(out var buf2, out var pos2, out var bufLen2);
|
||||
Assert.AreEqual(4, bufLen2);
|
||||
Assert.AreEqual(10, buf2[0]);
|
||||
Assert.AreEqual(20, buf2[1]);
|
||||
Assert.AreEqual(30, buf2[2]);
|
||||
Assert.AreEqual(40, buf2[3]);
|
||||
|
||||
// Now signal end-of-session explicitly
|
||||
input.Complete();
|
||||
|
||||
// After Complete, TryAdvanceSegment returns false on empty — session truly ended
|
||||
var pos3 = bufLen2;
|
||||
var bufLen3 = bufLen2;
|
||||
var buf3 = buf2;
|
||||
var hasMore = input.TryAdvanceSegment(ref buf3, ref pos3, ref bufLen3, 1);
|
||||
Assert.IsFalse(hasMore);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void Feed_ExternalComplete_SignalsEndOfSession()
|
||||
{
|
||||
// Explicit Complete() (or stream-EOF in the DrainFromAsync path) is the session-end signal —
|
||||
// distinct from per-message [202] markers which only auto-reset for the next message.
|
||||
using var input = new AsyncPipeReaderInput(64);
|
||||
|
||||
input.Feed(WrapInChunkFrame([1, 2, 3]));
|
||||
input.Complete(); // external session-end
|
||||
|
||||
input.Initialize(out var buffer, out var position, out var bufferLength);
|
||||
Assert.AreEqual(3, bufferLength);
|
||||
|
||||
position = bufferLength;
|
||||
var hasMore = input.TryAdvanceSegment(ref buffer, ref position, ref bufferLength, 1);
|
||||
Assert.IsFalse(hasMore);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void Feed_UnexpectedMarker_ThrowsInvalidDataException()
|
||||
{
|
||||
using var input = new AsyncPipeReaderInput(64);
|
||||
|
||||
// Byte 0x42 is not 200/201/202 — should throw
|
||||
_ = Assert.ThrowsExactly<InvalidDataException>(() => input.Feed([0x42]));
|
||||
}
|
||||
|
||||
// ====================================================================
|
||||
// Step 2 — DrainFromAsync extension (ACCORE-BIN-T-M2K1)
|
||||
// ====================================================================
|
||||
|
||||
[TestMethod]
|
||||
public async Task DrainFromAsync_NullInput_ThrowsArgumentNullException()
|
||||
{
|
||||
var pipe = new Pipe();
|
||||
await pipe.Writer.CompleteAsync();
|
||||
|
||||
await Assert.ThrowsExactlyAsync<ArgumentNullException>(async () => await AsyncPipeReaderInputExtensions.DrainFromAsync(null!, pipe.Reader));
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public async Task DrainFromAsync_NullReader_ThrowsArgumentNullException()
|
||||
{
|
||||
using var input = new AsyncPipeReaderInput(64);
|
||||
|
||||
await Assert.ThrowsExactlyAsync<ArgumentNullException>(async () => await input.DrainFromAsync(null!));
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public async Task DrainFromAsync_PipeWithData_FeedsAllBytes()
|
||||
{
|
||||
using var input = new AsyncPipeReaderInput(64);
|
||||
var pipe = new Pipe();
|
||||
|
||||
var data = new byte[] { 1, 2, 3, 4, 5, 6, 7, 8 };
|
||||
await pipe.Writer.WriteAsync(WrapInChunkFrame(data));
|
||||
await pipe.Writer.CompleteAsync();
|
||||
|
||||
await input.DrainFromAsync(pipe.Reader);
|
||||
|
||||
var consumed = ConsumeAll(input);
|
||||
CollectionAssert.AreEqual(data, consumed);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public async Task DrainFromAsync_EmptyPipeCompleted_CallsCompleteOnInput()
|
||||
{
|
||||
using var input = new AsyncPipeReaderInput(64);
|
||||
var pipe = new Pipe();
|
||||
await pipe.Writer.CompleteAsync();
|
||||
|
||||
await input.DrainFromAsync(pipe.Reader);
|
||||
|
||||
// After drain, AsyncPipeReaderInput should be completed → TryAdvanceSegment returns false on empty
|
||||
input.Initialize(out var buffer, out var position, out var bufferLength);
|
||||
var hasMore = input.TryAdvanceSegment(ref buffer, ref position, ref bufferLength, 1);
|
||||
Assert.IsFalse(hasMore);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public async Task DrainFromAsync_ConcurrentWriteDrainConsume_OrderPreserved()
|
||||
{
|
||||
// 3-thread pipeline: writer → pipe → drainer → input → consumer
|
||||
using var input = new AsyncPipeReaderInput(64);
|
||||
var pipe = new Pipe();
|
||||
|
||||
const int totalBytes = 4096;
|
||||
var expected = new byte[totalBytes];
|
||||
for (var i = 0; i < totalBytes; i++) expected[i] = (byte)(i & 0xFF);
|
||||
|
||||
var consumeTask = Task.Run(() => ConsumeAll(input));
|
||||
var drainTask = input.DrainFromAsync(pipe.Reader);
|
||||
|
||||
var writeTask = Task.Run(async () =>
|
||||
{
|
||||
try
|
||||
{
|
||||
const int chunkSize = 31;
|
||||
var offset = 0;
|
||||
while (offset < expected.Length)
|
||||
{
|
||||
var take = Math.Min(chunkSize, expected.Length - offset);
|
||||
await pipe.Writer.WriteAsync(WrapInChunkFrame(expected, offset, take));
|
||||
offset += take;
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
await pipe.Writer.CompleteAsync();
|
||||
}
|
||||
});
|
||||
|
||||
await Task.WhenAll(consumeTask, drainTask, writeTask);
|
||||
|
||||
var actual = consumeTask.Result;
|
||||
CollectionAssert.AreEqual(expected, actual);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public async Task DrainFromAsync_Cancellation_PropagatesAndCallsComplete()
|
||||
{
|
||||
using var input = new AsyncPipeReaderInput(64);
|
||||
var pipe = new Pipe();
|
||||
using var cts = new CancellationTokenSource();
|
||||
|
||||
var drainTask = input.DrainFromAsync(pipe.Reader, cts.Token);
|
||||
|
||||
cts.Cancel();
|
||||
|
||||
await Assert.ThrowsExactlyAsync<OperationCanceledException>(async () => await drainTask);
|
||||
|
||||
// Verify Complete was called in the finally block
|
||||
input.Initialize(out var buffer, out var position, out var bufferLength);
|
||||
var hasMore = input.TryAdvanceSegment(ref buffer, ref position, ref bufferLength, 1);
|
||||
Assert.IsFalse(hasMore);
|
||||
}
|
||||
|
||||
// ====================================================================
|
||||
// Step 3 — Real parallel pipeline test (ACCORE-BIN-T-V7C9)
|
||||
//
|
||||
// True 3-task pipeline: AcBinarySerializer writes framed chunks to pipe.Writer via
|
||||
// AsyncPipeWriterOutput (framed mode under the hood) — drainer pulls from pipe.Reader
|
||||
// via DrainFromAsync — deserializer reads from AsyncPipeReaderInput (framing-aware Feed).
|
||||
// All three run concurrently with TRUE serialize↔deserialize overlap (the serializer is
|
||||
// still writing the tail of the message while the deserializer has already consumed the
|
||||
// head, courtesy of per-chunk SyncAwaitFlush in AsyncPipeWriterOutput).
|
||||
//
|
||||
// BufferWriterChunkSize = 256 → small payloads cross multiple [201][UINT16][data] chunk
|
||||
// boundaries on the wire, exercising the framing-aware AsyncPipeReaderInput.Feed state
|
||||
// machine. Wire is uniform AsyncSegment chunked format (per ADR-0003 §9).
|
||||
// ====================================================================
|
||||
|
||||
[TestMethod]
|
||||
public async Task RealParallelPipeline_SerializeViaPipeWriter_DeserializeViaPipeReader_PayloadEquals()
|
||||
{
|
||||
var opts = new AcBinarySerializerOptions { BufferWriterChunkSize = 256 };
|
||||
var original = CreatePayload(50);
|
||||
|
||||
var pipe = new Pipe();
|
||||
using var input = new AsyncPipeReaderInput(initialCapacity: opts.BufferWriterChunkSize * 2);
|
||||
|
||||
var deserTask = Task.Run(() => AcBinaryDeserializer.Deserialize<TestParentWithDateTimeItemCollection>(input, opts));
|
||||
|
||||
var drainTask = input.DrainFromAsync(pipe.Reader);
|
||||
|
||||
var serTask = Task.Run(async () =>
|
||||
{
|
||||
try
|
||||
{
|
||||
// SerializeChunkedFramed — writes [201][UINT16][data] per chunk on the wire.
|
||||
// AsyncPipeReaderInput.Feed strips framing internally on the receive side
|
||||
// (default multiMessage = true).
|
||||
AcBinarySerializer.SerializeChunkedFramed(original, pipe.Writer, opts);
|
||||
}
|
||||
finally
|
||||
{
|
||||
await pipe.Writer.CompleteAsync();
|
||||
}
|
||||
});
|
||||
|
||||
await Task.WhenAll(serTask, drainTask, deserTask);
|
||||
|
||||
var result = deserTask.Result;
|
||||
Assert.IsNotNull(result);
|
||||
AssertPayloadEquals(original, result);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public async Task RealParallelPipeline_LargeScalePayload_ChunkSize4096_StructuralEquality()
|
||||
{
|
||||
// Production-scale payload via TestDataFactory: 100 root items × 3 pallets × 3 measurements × 4 points
|
||||
// = ~3700 deeply-nested objects with shared references. Serialized size ~few hundred KB →
|
||||
// many chunks at chunkSize=4096 → real backpressure-driven streaming (PipeWriter pauseThreshold
|
||||
// ~64KB, bytes flow incrementally as drainer + deserializer task pulls them out).
|
||||
// This is the most-realistic real-parallel-pipeline test: in-memory Pipe + 3-task overlap +
|
||||
// production-scale payload + production-scale chunk size.
|
||||
var opts = new AcBinarySerializerOptions { BufferWriterChunkSize = 4096 };
|
||||
var original = TestDataFactory.CreateLargeScaleBenchmarkOrder(rootItemCount: 100);
|
||||
|
||||
var pipe = new Pipe();
|
||||
using var input = new AsyncPipeReaderInput(initialCapacity: opts.BufferWriterChunkSize * 2);
|
||||
|
||||
var deserTask = Task.Run(() => AcBinaryDeserializer.Deserialize<TestOrder_All_True>(input, opts));
|
||||
var drainTask = input.DrainFromAsync(pipe.Reader);
|
||||
var serTask = Task.Run(async () =>
|
||||
{
|
||||
try { AcBinarySerializer.SerializeChunkedFramed(original, pipe.Writer, opts); }
|
||||
finally { await pipe.Writer.CompleteAsync(); }
|
||||
});
|
||||
|
||||
await Task.WhenAll(serTask, drainTask, deserTask);
|
||||
|
||||
var result = deserTask.Result;
|
||||
Assert.IsNotNull(result);
|
||||
Assert.AreEqual(original.Id, result.Id);
|
||||
Assert.AreEqual(original.OrderNumber, result.OrderNumber);
|
||||
Assert.AreEqual(original.Status, result.Status);
|
||||
Assert.AreEqual(original.TotalAmount, result.TotalAmount);
|
||||
|
||||
var origCounts = CountTestOrderHierarchy(original);
|
||||
var resultCounts = CountTestOrderHierarchy(result);
|
||||
|
||||
Assert.AreEqual(origCounts.items, resultCounts.items, "Items count mismatch");
|
||||
Assert.AreEqual(origCounts.pallets, resultCounts.pallets, "Pallets count mismatch");
|
||||
Assert.AreEqual(origCounts.measurements, resultCounts.measurements, "Measurements count mismatch");
|
||||
Assert.AreEqual(origCounts.points, resultCounts.points, "Points count mismatch");
|
||||
}
|
||||
|
||||
private static (int items, int pallets, int measurements, int points) CountTestOrderHierarchy(TestOrder_All_True order)
|
||||
{
|
||||
var items = order.Items.Count;
|
||||
int pallets = 0, measurements = 0, points = 0;
|
||||
foreach (var item in order.Items)
|
||||
{
|
||||
pallets += item.Pallets.Count;
|
||||
foreach (var p in item.Pallets)
|
||||
{
|
||||
measurements += p.Measurements.Count;
|
||||
points += p.Measurements.Sum(m => m.Points.Count);
|
||||
}
|
||||
}
|
||||
return (items, pallets, measurements, points);
|
||||
}
|
||||
|
||||
// ====================================================================
|
||||
// Step 4 — AsyncPipeWriterOutput runtime type detect — sanity pinning
|
||||
// ====================================================================
|
||||
//
|
||||
// Guards the architectural assumption that PipeWriter.Create(Stream).GetType() resolves to a
|
||||
// different runtime type than new Pipe().Writer.GetType(). This is what makes
|
||||
// AsyncPipeWriterOutput._serializeFlushAndAcquire auto-select between sequential
|
||||
// (Stream-backed) and parallel (Pipe-based) flush strategies safe — without touching internal
|
||||
// BCL type names directly. If a future .NET unifies the two writer impls or renames the
|
||||
// internal type in a way that breaks the detect, these tests fail before prod.
|
||||
|
||||
[TestMethod]
|
||||
public void StreamPipeWriter_AndPipeWriter_AreDistinctTypes()
|
||||
{
|
||||
var pipeBased = new Pipe().Writer.GetType();
|
||||
var streamBased = PipeWriter.Create(Stream.Null).GetType();
|
||||
|
||||
// Cornerstone of the runtime detect — must NEVER unify, else _serializeFlushAndAcquire
|
||||
// would either always-true or always-false, both of which break correctness.
|
||||
Assert.AreNotEqual(pipeBased, streamBased,
|
||||
$"Runtime types unified — pipe-based and stream-backed PipeWriter must remain distinct. " +
|
||||
$"pipeBased={pipeBased.FullName}, streamBased={streamBased.FullName}");
|
||||
|
||||
// Living documentation — typenames printed for debugging on future .NET upgrades.
|
||||
Console.WriteLine($"Pipe.Writer typename: {pipeBased.FullName}");
|
||||
Console.WriteLine($"PipeWriter.Create(Stream) typename: {streamBased.FullName}");
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void StreamPipeWriterTypeField_MatchesFactoryResult()
|
||||
{
|
||||
// The static field caches the StreamPipeWriter type via PipeWriter.Create(Stream.Null).GetType()
|
||||
// at class-load time. A second call to the factory MUST yield the same Type instance —
|
||||
// otherwise the cache is stale and the runtime detect mis-classifies all stream writers.
|
||||
var freshType = PipeWriter.Create(Stream.Null).GetType();
|
||||
|
||||
Assert.AreSame(freshType, AsyncPipeWriterOutput.StreamPipeWriterType,
|
||||
"Cached StreamPipeWriterType differs from a fresh factory result — the BCL is " +
|
||||
"behaving non-deterministically (or the test was loaded before AsyncPipeWriterOutput).");
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void IsAssignableFrom_PipeBasedWriter_ReturnsFalse()
|
||||
{
|
||||
// The Pipe.Writer impl must NOT be a StreamPipeWriter (or subclass thereof) — else
|
||||
// sequential mode would be wrongly selected and we'd lose the parallelism feature.
|
||||
var pipeBasedType = new Pipe().Writer.GetType();
|
||||
|
||||
Assert.IsFalse(AsyncPipeWriterOutput.StreamPipeWriterType.IsAssignableFrom(pipeBasedType),
|
||||
$"Pipe.Writer typename={pipeBasedType.FullName} is unexpectedly a StreamPipeWriter " +
|
||||
$"(or subclass) — runtime detect would mis-classify it as sequential.");
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void IsAssignableFrom_StreamBackedWriters_ReturnsTrue()
|
||||
{
|
||||
// PipeWriter.Create(stream) must always yield a StreamPipeWriter (or subclass) —
|
||||
// even for unusual stream types (file, memory, null).
|
||||
Type[] writerTypes =
|
||||
[
|
||||
PipeWriter.Create(Stream.Null).GetType(),
|
||||
PipeWriter.Create(new MemoryStream()).GetType(),
|
||||
];
|
||||
|
||||
foreach (var t in writerTypes)
|
||||
{
|
||||
Assert.IsTrue(AsyncPipeWriterOutput.StreamPipeWriterType.IsAssignableFrom(t),
|
||||
$"PipeWriter.Create(<stream>) returned typename={t.FullName} which is not " +
|
||||
$"assignable to StreamPipeWriterType — the BCL changed its factory contract.");
|
||||
}
|
||||
}
|
||||
|
||||
// ====================================================================
|
||||
// Test helpers
|
||||
// ====================================================================
|
||||
|
||||
/// <summary>
|
||||
/// Wraps a raw payload in a single AsyncSegment chunk frame: <c>[201][UINT16 LE size][data]</c>.
|
||||
/// Matches the wire format produced by <see cref="AsyncPipeWriterOutput"/> per chunk.
|
||||
/// </summary>
|
||||
private static byte[] WrapInChunkFrame(byte[] data) => WrapInChunkFrame(data, 0, data.Length);
|
||||
|
||||
private static byte[] WrapInChunkFrame(byte[] data, int offset, int length)
|
||||
{
|
||||
var result = new byte[3 + length];
|
||||
|
||||
result[0] = 201; // CHUNK_DATA marker
|
||||
result[1] = (byte)(length & 0xFF); // UINT16 LE size, low byte
|
||||
result[2] = (byte)((length >> 8) & 0xFF); // UINT16 LE size, high byte
|
||||
|
||||
Array.Copy(data, offset, result, 3, length);
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Drains the input fully via the IBinaryInputBase contract, returning all consumed bytes.
|
||||
/// Mimics the consumer pattern that <c>DeserializeSequence<TInput></c> uses internally.
|
||||
/// </summary>
|
||||
private static byte[] ConsumeAll(AsyncPipeReaderInput input)
|
||||
{
|
||||
var consumed = new List<byte>();
|
||||
input.Initialize(out var buffer, out var position, out var bufferLength);
|
||||
|
||||
while (true)
|
||||
{
|
||||
while (position < bufferLength)
|
||||
{
|
||||
consumed.Add(buffer[position]);
|
||||
position++;
|
||||
}
|
||||
|
||||
if (!input.TryAdvanceSegment(ref buffer, ref position, ref bufferLength, 1))
|
||||
break;
|
||||
}
|
||||
|
||||
input.Release();
|
||||
return consumed.ToArray();
|
||||
}
|
||||
|
||||
private static TestParentWithDateTimeItemCollection CreatePayload(int itemCount)
|
||||
{
|
||||
var now = DateTime.UtcNow;
|
||||
var items = new List<TestEntityWithDateTimeAndInt>(itemCount);
|
||||
|
||||
for (var i = 0; i < itemCount; i++)
|
||||
{
|
||||
items.Add(new TestEntityWithDateTimeAndInt
|
||||
{
|
||||
Id = i + 1,
|
||||
IntValue = i * 3,
|
||||
Created = now.AddMinutes(-i),
|
||||
Modified = now.AddMinutes(i),
|
||||
StatusCode = i % 4,
|
||||
Name = $"item-{i}"
|
||||
});
|
||||
}
|
||||
|
||||
return new TestParentWithDateTimeItemCollection
|
||||
{
|
||||
Id = 11,
|
||||
Name = "real-parallel-pipeline",
|
||||
Created = now,
|
||||
Items = items
|
||||
};
|
||||
}
|
||||
|
||||
private static void AssertPayloadEquals(TestParentWithDateTimeItemCollection expected, TestParentWithDateTimeItemCollection actual)
|
||||
{
|
||||
Assert.AreEqual(expected.Id, actual.Id);
|
||||
Assert.AreEqual(expected.Name, actual.Name);
|
||||
Assert.AreEqual(expected.Created, actual.Created);
|
||||
|
||||
Assert.IsNotNull(expected.Items);
|
||||
Assert.IsNotNull(actual.Items);
|
||||
Assert.AreEqual(expected.Items.Count, actual.Items.Count);
|
||||
|
||||
for (var i = 0; i < expected.Items.Count; i++)
|
||||
{
|
||||
var e = expected.Items[i];
|
||||
var a = actual.Items[i];
|
||||
|
||||
Assert.AreEqual(e.Id, a.Id);
|
||||
Assert.AreEqual(e.IntValue, a.IntValue);
|
||||
Assert.AreEqual(e.Created, a.Created);
|
||||
Assert.AreEqual(e.Modified, a.Modified);
|
||||
Assert.AreEqual(e.StatusCode, a.StatusCode);
|
||||
Assert.AreEqual(e.Name, a.Name);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,206 @@
|
|||
using AyCode.Core.Serializers.Binaries;
|
||||
using AyCode.Core.Tests.TestModels;
|
||||
|
||||
namespace AyCode.Core.Tests.Serialization;
|
||||
|
||||
[TestClass]
|
||||
public class AcBinarySerializerSGenNullComplexPropertyTests
|
||||
{
|
||||
[TestMethod]
|
||||
[DataRow(true, true)]
|
||||
[DataRow(true, false)]
|
||||
[DataRow(false, false)]
|
||||
[DataRow(false, true)]
|
||||
public void Serialize_SGenComplexPropertyNull_DoesNotThrow_AndRoundTripsAsNull(bool useSgen, bool fastMode)
|
||||
{
|
||||
var model = new SGenNullComplexParent
|
||||
{
|
||||
Id = 7,
|
||||
Customer = null!,
|
||||
Note = "regression"
|
||||
};
|
||||
|
||||
var options = fastMode ? AcBinarySerializerOptions.FastMode: AcBinarySerializerOptions.Default;
|
||||
options.UseGeneratedCode = useSgen;
|
||||
|
||||
var bytes = AcBinarySerializer.Serialize(model, options);
|
||||
var roundTrip = AcBinaryDeserializer.Deserialize<SGenNullComplexParent>(bytes, options);
|
||||
|
||||
Assert.IsNotNull(roundTrip);
|
||||
Assert.AreEqual(model.Id, roundTrip.Id);
|
||||
Assert.AreEqual(model.Note, roundTrip.Note);
|
||||
Assert.IsNull(roundTrip.Customer,
|
||||
"complex reference property must round-trip as null when source was null " +
|
||||
"(regression for SGen WriteObjectGenerated fallback else-branch null-check)");
|
||||
|
||||
Assert.IsTrue(System.Array.IndexOf(bytes, (byte)BinaryTypeCode.PropertySkip) >= 0,
|
||||
"writer must emit PropertySkip marker on the null Customer slot " +
|
||||
"(deeper verification: confirms the fix took the PropertySkip path, " +
|
||||
"not an unrelated null-safe code path)");
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
[DataRow(true, true)]
|
||||
[DataRow(true, false)]
|
||||
[DataRow(false, false)]
|
||||
[DataRow(false, true)]
|
||||
public void Serialize_SGenComplexPropertyNonNull_RoundTripsCorrectly(bool useSgen, bool fastMode)
|
||||
{
|
||||
var model = new SGenNullComplexParent
|
||||
{
|
||||
Id = 13,
|
||||
Customer = new NonGeneratedComplexCustomer { Id = 42, Name = "child" },
|
||||
Note = "positive"
|
||||
};
|
||||
|
||||
var options = fastMode ? AcBinarySerializerOptions.FastMode: AcBinarySerializerOptions.Default;
|
||||
options.UseGeneratedCode = useSgen;
|
||||
|
||||
var bytes = AcBinarySerializer.Serialize(model, options);
|
||||
var roundTrip = AcBinaryDeserializer.Deserialize<SGenNullComplexParent>(bytes, options);
|
||||
|
||||
Assert.IsNotNull(roundTrip);
|
||||
Assert.AreEqual(model.Id, roundTrip.Id);
|
||||
Assert.AreEqual(model.Note, roundTrip.Note);
|
||||
Assert.IsNotNull(roundTrip.Customer,
|
||||
"non-null complex reference property must round-trip (null-check fix must not break the non-null path)");
|
||||
Assert.AreEqual(model.Customer.Id, roundTrip.Customer.Id);
|
||||
Assert.AreEqual(model.Customer.Name, roundTrip.Customer.Name);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
[DataRow(true, true)]
|
||||
[DataRow(true, false)]
|
||||
[DataRow(false, false)]
|
||||
[DataRow(false, true)]
|
||||
public void Serialize_SGenCollectionPropertyNull_DoesNotThrow_AndRoundTripsAsNull(bool useSgen, bool fastMode)
|
||||
{
|
||||
var model = new SGenNullCollectionParent
|
||||
{
|
||||
Id = 11,
|
||||
Items = null!,
|
||||
Note = "regression-collection"
|
||||
};
|
||||
|
||||
var options = fastMode ? AcBinarySerializerOptions.FastMode: AcBinarySerializerOptions.Default;
|
||||
options.UseGeneratedCode = useSgen;
|
||||
|
||||
var bytes = AcBinarySerializer.Serialize(model, options);
|
||||
var roundTrip = AcBinaryDeserializer.Deserialize<SGenNullCollectionParent>(bytes, options);
|
||||
|
||||
Assert.IsNotNull(roundTrip);
|
||||
Assert.AreEqual(model.Id, roundTrip.Id);
|
||||
Assert.AreEqual(model.Note, roundTrip.Note);
|
||||
Assert.IsNull(roundTrip.Items,
|
||||
"collection property (with non-SGen element type) must round-trip as null when source was null " +
|
||||
"(regression for SGen Collection fallback WriteValueGenerated else-branch null-check)");
|
||||
|
||||
Assert.IsTrue(System.Array.IndexOf(bytes, (byte)BinaryTypeCode.PropertySkip) >= 0,
|
||||
"writer must emit PropertySkip marker on the null Items slot " +
|
||||
"(confirms the fix took the PropertySkip path, not an unrelated null-safe code path)");
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
[DataRow(true, true)]
|
||||
[DataRow(true, false)]
|
||||
[DataRow(false, false)]
|
||||
[DataRow(false, true)]
|
||||
public void Serialize_SGenCollectionPropertyNonNull_RoundTripsCorrectly(bool useSgen, bool fastMode)
|
||||
{
|
||||
var model = new SGenNullCollectionParent
|
||||
{
|
||||
Id = 17,
|
||||
Items = new List<NonGeneratedComplexCustomer>
|
||||
{
|
||||
new() { Id = 1, Name = "first" },
|
||||
new() { Id = 2, Name = "second" }
|
||||
},
|
||||
Note = "positive-collection"
|
||||
};
|
||||
|
||||
var options = fastMode ? AcBinarySerializerOptions.FastMode: AcBinarySerializerOptions.Default;
|
||||
options.UseGeneratedCode = useSgen;
|
||||
|
||||
var bytes = AcBinarySerializer.Serialize(model, options);
|
||||
var roundTrip = AcBinaryDeserializer.Deserialize<SGenNullCollectionParent>(bytes, options);
|
||||
|
||||
Assert.IsNotNull(roundTrip);
|
||||
Assert.AreEqual(model.Id, roundTrip.Id);
|
||||
Assert.AreEqual(model.Note, roundTrip.Note);
|
||||
Assert.IsNotNull(roundTrip.Items,
|
||||
"non-null collection property must round-trip (null-check fix must not break the non-null path)");
|
||||
Assert.AreEqual(model.Items.Count, roundTrip.Items.Count);
|
||||
Assert.AreEqual(model.Items[0].Id, roundTrip.Items[0].Id);
|
||||
Assert.AreEqual(model.Items[0].Name, roundTrip.Items[0].Name);
|
||||
Assert.AreEqual(model.Items[1].Id, roundTrip.Items[1].Id);
|
||||
Assert.AreEqual(model.Items[1].Name, roundTrip.Items[1].Name);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
[DataRow(true, true)]
|
||||
[DataRow(true, false)]
|
||||
[DataRow(false, false)]
|
||||
[DataRow(false, true)]
|
||||
public void Serialize_SGenDictionaryPropertyNull_DoesNotThrow_AndRoundTripsAsNull(bool useSgen, bool fastMode)
|
||||
{
|
||||
var model = new SGenNullDictionaryParent
|
||||
{
|
||||
Id = 23,
|
||||
Mapping = null!,
|
||||
Note = "regression-dictionary"
|
||||
};
|
||||
|
||||
var options = fastMode ? AcBinarySerializerOptions.FastMode: AcBinarySerializerOptions.Default;
|
||||
options.UseGeneratedCode = useSgen;
|
||||
|
||||
var bytes = AcBinarySerializer.Serialize(model, options);
|
||||
var roundTrip = AcBinaryDeserializer.Deserialize<SGenNullDictionaryParent>(bytes, options);
|
||||
|
||||
Assert.IsNotNull(roundTrip);
|
||||
Assert.AreEqual(model.Id, roundTrip.Id);
|
||||
Assert.AreEqual(model.Note, roundTrip.Note);
|
||||
Assert.IsNull(roundTrip.Mapping,
|
||||
"dictionary property must round-trip as null when source was null " +
|
||||
"(pins EmitDirectDictionaryWrite line ~1037 null-check against future regression)");
|
||||
|
||||
Assert.IsTrue(System.Array.IndexOf(bytes, (byte)BinaryTypeCode.PropertySkip) >= 0,
|
||||
"writer must emit PropertySkip marker on the null Mapping slot " +
|
||||
"(confirms the PropertySkip path, not an unrelated null-safe code path)");
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
[DataRow(true, true)]
|
||||
[DataRow(true, false)]
|
||||
[DataRow(false, false)]
|
||||
[DataRow(false, true)]
|
||||
public void Serialize_SGenDictionaryPropertyNonNull_RoundTripsCorrectly(bool useSgen, bool fastMode)
|
||||
{
|
||||
var model = new SGenNullDictionaryParent
|
||||
{
|
||||
Id = 29,
|
||||
Mapping = new Dictionary<string, NonGeneratedComplexCustomer>
|
||||
{
|
||||
["alpha"] = new() { Id = 1, Name = "first" },
|
||||
["beta"] = new() { Id = 2, Name = "second" }
|
||||
},
|
||||
Note = "positive-dictionary"
|
||||
};
|
||||
|
||||
var options = fastMode ? AcBinarySerializerOptions.FastMode: AcBinarySerializerOptions.Default;
|
||||
options.UseGeneratedCode = useSgen;
|
||||
|
||||
var bytes = AcBinarySerializer.Serialize(model, options);
|
||||
var roundTrip = AcBinaryDeserializer.Deserialize<SGenNullDictionaryParent>(bytes, options);
|
||||
|
||||
Assert.IsNotNull(roundTrip);
|
||||
Assert.AreEqual(model.Id, roundTrip.Id);
|
||||
Assert.AreEqual(model.Note, roundTrip.Note);
|
||||
Assert.IsNotNull(roundTrip.Mapping,
|
||||
"non-null dictionary property must round-trip (null-check pin must not break the non-null path)");
|
||||
Assert.AreEqual(model.Mapping.Count, roundTrip.Mapping.Count);
|
||||
Assert.AreEqual(model.Mapping["alpha"].Id, roundTrip.Mapping["alpha"].Id);
|
||||
Assert.AreEqual(model.Mapping["alpha"].Name, roundTrip.Mapping["alpha"].Name);
|
||||
Assert.AreEqual(model.Mapping["beta"].Id, roundTrip.Mapping["beta"].Id);
|
||||
Assert.AreEqual(model.Mapping["beta"].Name, roundTrip.Mapping["beta"].Name);
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue