Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ the rule it protects.
| `task e2e-wire` | Real Go SSE bytes consumed by the TypeScript client over HTTP. |
| `task e2e-browser` | Native `EventSource` and unbundled ESM in Chromium. |
| `task cluster-facts` | Record observed Kubernetes behavior for the supported cluster version. |
| `task test-real-api` | Compose the save path against a real API server: gateway, host endpoint and browser store. |
| `task test-cluster` | Exercise the Kubernetes backend against a real API server. |

The cluster tasks need Docker and take longer; the fixture suites are the per-pull-request baseline.
Expand Down
11 changes: 11 additions & 0 deletions Taskfile.yml
Original file line number Diff line number Diff line change
Expand Up @@ -437,6 +437,17 @@ tasks:
# API server, on BOTH paths: the streaming list against kube-apiserver, and list-then-watch against
# the aggregated API that refuses it (F6). It does not run in per-PR CI: it needs Docker-in-Docker
# and it takes minutes, while the fixtures gate every PR in milliseconds.
# The save path, composed against a real API server: the real gateway, a real host endpoint and the
# real browser store (run by node).
test-real-api:
desc: "Real-API save composition: status churn, overlapping recovery and the UID race (proposal 0006)."
deps: [cluster-up]
dir: gateway/kube
env:
KUBECONFIG: '{{.KUBECONFIG_PATH}}'
cmds:
- go test -tags e2e -v -count=1 -run '^TestRealAPI' .

test-cluster:
desc: "The kube backend against a real API server on both watch paths."
deps: [cluster-up, cluster-aggregated-api]
Expand Down
9 changes: 8 additions & 1 deletion docs/proposals/0006-stream-and-save-implementation-plan.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ Use the [adoption guide](../adopting.md), [saving guide](../saving.md) and
|---|---|---|
| 1 | Define convergence precisely — complete | [Evidence](../../conformance/README.md#convergence-evidence) |
| 2 | Publish tested deletion-recovery and keep-local recipes — complete | [Recipes](../../examples/editor-recipes/README.md) and [tests](../../packages/krm-stream/test/recipes.test.ts) |
| 3 | Harden real-API save composition and identity races | Exact-commit API evidence, separate from fake-client CI. |
| 3 | Harden real-API save composition and identity races — implemented | [`TestRealAPI` cases](../../gateway/kube/composition_e2e_test.go), run by `task test-real-api` |
| 4 | Measure and implement upstream continuation | Same-workload comparison proves continuity, bounded recovery and authorization. |

Review the remaining priorities as separate changes. Baseline measurement can run alongside
Expand Down Expand Up @@ -81,6 +81,13 @@ only if repeated consumer code warrants it; do not create another conflict regis

## 3. Real-API composition and identity races

Implemented as the `TestRealAPI` cases, run by `task test-real-api`. The browser side runs the real store and conditional editor under node. A
real status subresource comes from a test-installed Widget CRD, and a test-only transport barrier
recreates the ConfigMap between the host's preflight GET and its PATCH. The race showed that
Kubernetes checks the captured `resourceVersion` before the UID, so a replacement arrives as a plain
409. The host now classifies it with one GET and answers with structured Kubernetes `Status`
responses. The criteria below are what those cases assert.

The [existing real-API stale-RV test](../../gateway/kube/e2e_test.go) proves ordinary rejection, not
these compositions. Extend it alongside [store/example tests](../../packages/krm-stream/test/saving.test.ts)
and [host handler tests](../../gateway/kube/examples/conditionalsave/handler_test.go).
Expand Down
8 changes: 8 additions & 0 deletions docs/saving.md
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,14 @@ current draft and any conflicts, and let the person review before capturing a ne
retry the old patch with a newer version, and do not apply a response for a replacement UID to the
old editor.

A 409 does not always mean a newer version of the same object. Kubernetes checks the captured
`resourceVersion` before the UID, so an object deleted and recreated under the same name since the
host's preflight read also fails as a plain conflict. The guarded read above refuses the replacement's
UID, so the editor reports it unavailable. A host can say so directly: the
[conditional-save host](../gateway/kube/examples/conditionalsave/handler.go) answers with the
Kubernetes `Status`, and after a 409 reads the object once, naming a replacement in `details.uid`.
It never turns a 422 validation error into a conflict.

## Why a quiet stream can still reject a save

The gateway sends the fields your view needs. It suppresses updates that change only ignored fields
Expand Down
21 changes: 16 additions & 5 deletions examples/conditional-save/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,11 @@ algorithm or shared watch implementation.
- [saving tests](../../packages/krm-stream/test/saving.test.ts) exercise the response races.
- `TestConditionalSaveConflict` in the Kubernetes e2e suite exercises this handler against a real API
server: a competing update makes a captured save return 409 without overwriting the winner.
- The `TestRealAPI` cases (`task test-real-api`) compose the editor, a real gateway stream and a host
endpoint against a real API server, with the browser store run by node. They cover status churn
behind `krm-spec/v1`, a bookkeeping-only write behind `krm-full/v1`, a reconciliation read that lands
during snapshot recovery, a deletion and recreation between this handler's preflight GET and its
PATCH, and a real validation error.

```ts
const store = new LiveResourceStore();
Expand All @@ -34,8 +39,8 @@ connection.close();
Use the same scope and `krm-full/v1` projection on the stream and this example endpoint. The GET
returns a complete projected object with redacted paths; it must use a most-recent Kubernetes read, with
no HTTP or application cache. A recreated name has a different UID and must be opened as a new editor.
Keep an external draft archive if users need to recover edits after deletion: the store intentionally
removes drafts of deleted objects.
The store intentionally removes drafts of deleted objects; if users need to recover edits after a
deletion, keep a copy with the [recovery-copy recipe](../editor-recipes/README.md#recover-work-after-a-deletion).

A narrow merge patch is **not concurrency protection**. JSON merge patch replaces arrays as a whole,
even when the client merges keyed list items intelligently. This endpoint puts the captured UID and
Expand All @@ -60,9 +65,15 @@ that array when `redactedPaths` is absent. If both are supplied, `redactedPaths`
Omitted redaction metadata never clears protection.

The example distinguishes `draft-conflict` (show conflicting paths), `version-stale` (base refreshed,
o field disagreement), `recovering` (a usable base is not established), and `unavailable` (missing or
replacement UID), alongside `unchanged`, `busy` and `saved`. Transport and validation failures throw
host errors. The live-state callback is checked before saving and after reconciliation.
no field disagreement), `recovering` (a usable base is not established), and `unavailable` (missing,
deleted or replacement UID), alongside `unchanged`, `busy` and `saved`. Transport and validation
failures throw host errors. The live-state callback is checked before saving and after reconciliation.

The endpoint answers every Kubernetes error with the Kubernetes `Status` as JSON, so a 422 keeps its
reason and field causes for the form to show. Because Kubernetes checks the captured
`resourceVersion` before the UID, an object deleted and recreated after the preflight GET fails as a
plain 409; the endpoint then reads it once and answers 409 "object was replaced" with the new UID in
`details.uid`, or 404 if it is simply gone. It never turns any other 422 into a conflict.

After a refused read, `recovering` preserves the draft. Wait for stream recovery or authoritative
metadata, then the next Save performs only a guarded GET. An accepted read returns `version-stale`
Expand Down
2 changes: 2 additions & 0 deletions examples/conditional-save/editor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,8 @@ export function conditionalEditor(
headers: { "Content-Type": "application/json" },
body: JSON.stringify(intent),
});
// Deleted after the preflight read: the watch will remove it too, so it is not a host error.
if (result.status === 404) return "unavailable";
if (result.status === 409) {
if (!store.ids().includes(uid)) return "unavailable";
return await refresh();
Expand Down
Loading
Loading