194 lines
8.6 KiB
Markdown
194 lines
8.6 KiB
Markdown
# Array Write Ergonomics & Default-Fill Partial Writes — Design
|
|
|
|
Date: 2026-06-18
|
|
|
|
## Problem
|
|
|
|
Writing array-typed MXAccess attributes through the gateway has two ergonomic
|
|
shortfalls:
|
|
|
|
1. **Asymmetric addressing.** An array attribute reads fine by its bare name
|
|
(`Obj.Arr`), but writes require the `[]` body suffix (`Obj.Arr[]`). The
|
|
handle registered from the bare name is read-capable but not cleanly
|
|
write-capable.
|
|
2. **Whole-array writes only.** Every write replaces the entire array; to change
|
|
a few elements the client must marshal and send the full array. This is a
|
|
native MXAccess COM constraint (there is no element-wise write API), but it
|
|
pushes avoidable cost onto clients for large arrays.
|
|
|
|
This design removes both frictions without breaking MXAccess parity. The worker
|
|
is not modified — it continues to perform an honest whole-array COM write. All
|
|
new behavior lives in the gateway and the contract.
|
|
|
|
## Why MXAccess forces whole-array writes
|
|
|
|
The native MXAccess COM `Write` takes a complete VARIANT (`SAFEARRAY` for
|
|
arrays). There is no `WriteArrayElement(index, value)`. Confirmed in the worker:
|
|
`VariantConverter.ConvertToComArray` marshals the entire CLR array in one shot,
|
|
and `MxAccessSession.Write` forwards it verbatim to the COM proxy. Any "partial
|
|
write" feature must therefore reconstruct a full array before the COM call.
|
|
|
|
We deliberately do **not** reconstruct it from current state (no
|
|
read-modify-write merge): that would add latency, cache-staleness, and a race
|
|
window against other writers, and would paper over MXAccess semantics. Instead
|
|
partial writes are **stateless default-fill** (see below).
|
|
|
|
## Goals
|
|
|
|
- Writing an array attribute by its bare name works like reading does — the
|
|
gateway appends `[]` automatically when it knows the attribute is an array.
|
|
- A client can send only the indices it wants plus a total length, instead of
|
|
the full array.
|
|
|
|
## Non-goals
|
|
|
|
- **No preserve-unchanged merge.** Unmentioned indices are written as the
|
|
element type's default, **not** kept at their current value.
|
|
- No element-wise COM write — MXAccess has no such API; every write is
|
|
whole-array and we keep it that way.
|
|
- No change to `ReadBulk` string addressing.
|
|
- The gateway does **not** infer total length; the client supplies it.
|
|
|
|
## Decisions (resolved during brainstorming)
|
|
|
|
| Question | Decision |
|
|
|---|---|
|
|
| Scope | Both: suffix ergonomics **and** partial writes |
|
|
| Partial-write semantics | Stateless **default-fill**: unmentioned indices = type default (reset, not preserved) |
|
|
| Total length | **Client specifies** `total_length` explicitly |
|
|
| Time/timestamp default | **Unix epoch** |
|
|
| Suffix fix location/actor | **Gateway**, using in-memory Galaxy `is_array` metadata, at **AddItem** time |
|
|
| Suffix fallback when metadata unavailable | **Pass through unchanged** (no regression) |
|
|
| Partial-write contract shape | New `MxSparseArray` as a `oneof` arm on `MxValue` |
|
|
| Per-client helpers | **Included** in this change |
|
|
|
|
## Contract changes (`mxaccess_gateway.proto`)
|
|
|
|
A write-only sparse representation, added as a `oneof kind` arm on `MxValue` so
|
|
every write command (`Write`, `Write2`, `WriteSecured`, `WriteSecured2`,
|
|
`WriteBulkEntry`) accepts it without new RPCs:
|
|
|
|
```proto
|
|
message MxSparseArray {
|
|
MxDataType element_data_type = 1;
|
|
uint32 total_length = 2;
|
|
repeated MxSparseElement elements = 3;
|
|
}
|
|
|
|
message MxSparseElement {
|
|
uint32 index = 1;
|
|
MxValue value = 2; // scalar
|
|
}
|
|
|
|
// added to MxValue oneof kind:
|
|
// MxSparseArray sparse_array_value = 19;
|
|
```
|
|
|
|
`sparse_array_value` is **write-only**: the worker never produces it, and the
|
|
gateway rejects it on any read/event path. Regenerate `Generated/` and commit
|
|
the generated `.cs` (the net48 worker build needs the checked-in types — see the
|
|
proto-codegen-regen rule).
|
|
|
|
## Suffix normalization — at `AddItem`, in the gateway
|
|
|
|
The item handle binds to the literal address string at `AddItem` and is reused
|
|
for both reads and writes; at write time only the integer handle is available,
|
|
which is too late to change the address. So normalization happens at
|
|
registration.
|
|
|
|
In the gateway's `AddItemCommand` / `AddItem2Command` handling
|
|
(`GatewaySession`), before forwarding to the worker:
|
|
|
|
1. If `item_definition` already ends with `[]` → leave unchanged.
|
|
2. Else look up `item_definition + "[]"` in the in-memory Galaxy hierarchy cache
|
|
(`IGalaxyHierarchyCache` → `GalaxyTagLookup.Attribute.IsArray`). The index is
|
|
keyed by `FullTagReference`, which already carries the `[]` suffix for
|
|
arrays, so the lookup key must include `[]`. If found and `is_array` →
|
|
rewrite `item_definition` to the `[]` form.
|
|
3. **Fallback:** metadata unavailable or address not found as an array →
|
|
forward verbatim (current behavior).
|
|
|
|
Store the **normalized** address in `SessionItemRegistration.TagAddress` so
|
|
write-time constraint checks (`ConstraintEnforcer`) and readback resolve
|
|
consistently against the `[]`-keyed index.
|
|
|
|
This is safe for reads: both the bare and `[]` forms return the array on read,
|
|
so promoting a registration to the `[]` form does not change read behavior — it
|
|
only makes the handle write-capable.
|
|
|
|
`AddItem2Command` (with `item_context`) normalizes `item_definition` the same
|
|
way. `ReadBulk` is unaffected — it uses raw address strings with its own
|
|
ephemeral registration, so bare-name reads continue to work unchanged.
|
|
|
|
## Partial-write expansion — at the gateway, worker untouched
|
|
|
|
In the gateway write path, before forwarding any write command to the worker, if
|
|
`MxValue.KindCase == SparseArrayValue`:
|
|
|
|
1. Allocate a full array of `total_length`, element type `element_data_type`.
|
|
2. Initialize every slot to the type default:
|
|
- `bool` → `false`
|
|
- `int32` / `int64` → `0`
|
|
- `float` / `double` → `0`
|
|
- `string` → `""`
|
|
- `time` / `timestamp` → Unix epoch
|
|
3. For each `MxSparseElement`, set `array[index]` from the scalar `value`.
|
|
4. Replace the `MxValue` with a normal `array_value` (full `MxArray`).
|
|
|
|
The worker then receives an ordinary whole-array `MxValue`;
|
|
`VariantConverter.ConvertToComArray` and the COM `Write` are unchanged. Parity
|
|
preserved — it really is a whole-array write.
|
|
|
|
Expansion is applied uniformly to every write variant by normalizing the
|
|
`MxValue` of each command (`Write`, `Write2`, `WriteSecured`, `WriteSecured2`,
|
|
and each `WriteBulkEntry`) before it leaves the gateway.
|
|
|
|
## Validation & errors (gateway, `InvalidArgument`)
|
|
|
|
- `total_length == 0`, or any `index >= total_length` → reject.
|
|
- Duplicate indices → reject (no silent last-wins).
|
|
- `element_data_type` must be a supported scalar element type (not `Raw` /
|
|
`Unspecified`); each element `value` must match it.
|
|
- Empty `elements` with `total_length = N` → valid: writes an all-defaults array
|
|
of length N (explicit reset).
|
|
- A sparse value arriving on a read/event path → reject (guard; the worker never
|
|
produces one).
|
|
|
|
## Clients (all five) & docs — same change
|
|
|
|
Per the repo rule that docs change with the source:
|
|
|
|
- Regenerate proto types for dotnet, go, python, rust, java. Watch the Java
|
|
generated-file churn — revert spurious protobuf-version diffs when no `.proto`
|
|
semantics changed beyond the new messages; commit the net48-relevant regen.
|
|
- Add a thin per-client helper to build a sparse write, e.g.
|
|
`WriteArrayElements(handle, totalLength, {index → value})`.
|
|
- Update the **"Array writes replace the whole array"** section in all five
|
|
client READMEs: document default-fill semantics (unmentioned = reset to
|
|
default, not preserved), the `total_length` requirement, and that bare-name
|
|
array writes now auto-normalize to the `[]` form.
|
|
- Update `gateway.md` (command/value surface) and the value-conversion doc.
|
|
|
|
## Testing
|
|
|
|
- **Gateway (FakeWorkerHarness):**
|
|
- Sparse → full expansion per element type; default-fill sizing; correct
|
|
placement of specified indices.
|
|
- `total_length == 0`, index-out-of-range, and duplicate-index rejection.
|
|
- Empty-elements all-defaults case.
|
|
- Suffix normalization: bare array → `[]`; bare scalar → unchanged;
|
|
already-`[]` → unchanged; metadata-cold → pass-through.
|
|
- **Clients:** helper + round-trip serialization per language.
|
|
- **Live MXAccess (opt-in, windev):** one default-fill write and one bare-name
|
|
array write against real COM.
|
|
|
|
## Affected components
|
|
|
|
- Contracts: `mxaccess_gateway.proto` + regenerated `Generated/`.
|
|
- Gateway: `GatewaySession` (AddItem normalization, write expansion),
|
|
`SessionItemRegistration` (store normalized address), interaction with
|
|
`IGalaxyHierarchyCache` / `GalaxyTagLookup`.
|
|
- Worker: **unchanged.**
|
|
- Clients: dotnet, go, python, rust, java (regenerated types + helper + README).
|
|
- Docs: `gateway.md`, value-conversion doc, five client READMEs.
|