Skip to content

Config server cache backend - #471

Open
steiler wants to merge 44 commits into
sensitivefrom
config-server-cache-backend
Open

Config server cache backend#471
steiler wants to merge 44 commits into
sensitivefrom
config-server-cache-backend

Conversation

@steiler

@steiler steiler commented Aug 11, 2026

Copy link
Copy Markdown
Collaborator

Summary

  • Adds a config-server-backed cache.Client implementation that reads last-applied config via a new config_read.ConfigReadService gRPC client instead of the local/dragonfly cache.

Pairs-with: sdcio/config-server#476
Pairs-with: sdcio/integration-tests#115

Note on integration coverage

The integration-tests job now runs tests/05-cache-backend/ (CRUD round-trip + restart-recovery) against a Cache.Type: config-server deployment via sdcio/integration-tests PR #115, which extends single.yml with cache_type/suites_to_run inputs and adds the suite itself.

Pairs-with: sdcio/integration-tests#115 supersedes the previous #113 reference: #115's branch bundles #113's config-keyring Secret fix directly (a hard, cache-backend-independent prerequisite -- without it data-server-controller hangs in ContainerCreating for any deploy) alongside the actual cache-backend suite/workflow changes, so a single paired ref now covers both concerns.

steiler and others added 12 commits August 7, 2026 14:17
Wayfinder-driven design pass for a config-server-backed pkg/cache.Client
backend: resolves interface-shape, running-vs-real-intent routing, and
config-server-local-API questions, consolidated into ADR
pkg/cache/docs/adr/0001-config-server-backed-cache-client.md. Also breaks
the ADR down into a numbered implementation ticket index
(.scratch/config-server-cache-backend/issues/) for a follow-up
implementation effort.

Co-authored-by: Cursor <cursoragent@cursor.com>
CacheConfig.validateSetDefaults silently rewrote any Cache.Type other
than "remote" back to "local", which would have clobbered the new
"config-server" backend type before it ever reached the backend-selection
switch. Switch explicitly on known types and reject anything else with a
clear error instead of coercing it.

Closes ticket 02 of the config-server-backed cache client effort
(.scratch/config-server-cache-backend/issues/02-fix-cache-type-validation.md).

Co-authored-by: Cursor <cursoragent@cursor.com>
…GetAll

Add GetOrphan()/GetSensitivePaths() to ImportConfigAdapter so it is the
full intent-shaped adapter surface, implemented from real fields on
ProtoTreeImporter and stubbed on Json/XmlTreeImporter (never used for
real intents). Widen cache.Client's and CacheClientBound's real-intent
Get/GetAll methods to return importer.ImportConfigAdapter instead of
the concrete *tree_persist.Intent, so a future config-server-backed
cache.Client can satisfy the same surface without a shared persisted
representation. LocalCache's behavior is unchanged — it now just wraps
its existing proto payload in ProtoTreeImporter before returning it.

Ticket 03 of the config-server-backed cache client plan; running still
flows through the same Get/Modify methods unchanged (ticket 04 splits
it out).

Co-authored-by: Cursor <cursoragent@cursor.com>
running (the synced device-state pseudo-intent) is structurally
different from a real intent and only shared the generic
Intent surface because LocalCache happened to store everything the
same way. Give it its own InstanceRunningGet/InstanceRunningModify
(cache.Client) and RunningGet/RunningModify (CacheClientBound), so a
future config-server-backed Client never has to special-case running
inside the real-intent Get/List/Modify/Delete path.

replaceIntent and writeBackSyncTree switch to the dedicated methods
instead of passing consts.RunningIntentName through IntentGet/
IntentModify. LocalCache implements the new methods as thin
passthroughs to its existing disk-backed store, so on-disk behavior
for running is unchanged. populateSensitivePathIndex now explicitly
excludes running from its IntentGetAll scan, replacing the previous
incidental exclusion (running never set SensitivePaths).

Co-authored-by: Cursor <cursoragent@cursor.com>
sdc-protos now has ConfigReadService (Get/List) + ConfigEntry/ConfigBlob
messages covering the ADR's field-mapping table, at commit bca11ef on
sdc-protos' config-server-cache-backend branch. Contract-only, per ticket
scope.

Co-authored-by: Cursor <cursoragent@cursor.com>
Defines LocalConfigReader (Get-by-name / List-by-target), the Target,
Document, and ConfigBlob types per the ADR's field-mapping table, and
an in-memory FakeLocalConfigReader seedable with fixtures. Lets the
config-server-backed cache.Client (ticket 06) be built and unit-tested
now, decoupled from the real cross-repo gRPC transport (ticket 01/07/08).

Ticket 05 of docs/adr/0001-config-server-backed-cache-client.md; no
existing file touched.

Co-authored-by: Cursor <cursoragent@cursor.com>
…fake

Builds ConfigServerCache (pkg/cache/configserver.go), the new cache.Client
for Cache.Type: config-server, behind ticket 05's LocalConfigReader seam:
real-intent reads map onto the seam's Get/List and wrap results as
importer.ImportConfigAdapter via a new configserver.NewImportAdapter
(pkg/cache/configserver/importer.go, merge.go) per the ADR's field-mapping
table; writes on real intents are unconditional no-ops; "running" is served
from its own independent in-memory, per-instance store, entirely separate
from the seam. Ticket 06 done.

Co-authored-by: Cursor <cursoragent@cursor.com>
Implements ticket 08: GRPCConfigReader (pkg/cache/configserver/grpc.go)
implements the local-read seam by calling the generated
config_read.ConfigReadServiceClient (sdc-protos ticket 01), replacing
ticket 05's fake behind ticket 06's backend. Connection settings are
caller-supplied (grpc.ClientConnInterface / Dial(address)), never
hardcoded. Unit-tested against a new generated mock
(mocks/mockconfigread) of the real client.

Pins sdc-protos to the sibling local checkout (untagged commit a8f3da0,
which adds config_read) until that branch is pushed/tagged upstream.

No wiring into Server.createCacheClient/CacheConfig — that's ticket 09.

Co-authored-by: Cursor <cursoragent@cursor.com>
Add the case "config-server" branch to createCacheClient, dialing the
colocated config-server controller and constructing ConfigServerCache
against it. Extend CacheConfig with Namespace and require both Address
and Namespace for Cache.Type: config-server; local/remote behavior is
unchanged.

Closes ticket 09.

Co-authored-by: Cursor <cursoragent@cursor.com>
…landed

Implemented in the config-server checkout (pkg/sdc/configread); see the
ticket's Comments section for what landed. All tickets 01-09 are now done.

Co-authored-by: Cursor <cursoragent@cursor.com>
…igAdapter

running is domain-distinct from Intent, but Client's read-side return
type is a mechanical "ready for Tree.ImportConfig" contract, not an
intent-specific one. Move the *tree_persist.Intent -> ImportConfigAdapter
wrap into each Client implementation (LocalCache, ConfigServerCache),
matching InstanceIntentGet, so transaction_rpc.go no longer needs to know
running is proto-backed. Amends ADR 0001, which originally specified the
now-reversed *tree_persist.Intent signature.

Co-authored-by: Cursor <cursoragent@cursor.com>
Decompose cache.Client and cache.CacheClientBound into IntentReader,
IntentWriter, RunningStore, and InstanceLifecycle capability
interfaces (recomposed via embedding), regenerate mocks per
capability, and narrow read-only consumers (forEachIntent,
populateSensitivePathIndex) to the bound reader interface.

Make config-server's no-op writes explicit via a standalone
noopIntentWriter composed with *ConfigServerCache in
Server.createCacheClient, instead of burying no-ops inside
ConfigServerCache's own methods.

Also extract LocalCache.decodeIntent to dedup repeated decode logic,
and relocate config-server's ImportConfigAdapter implementation
(documentImporter/mergeConfigBlobs) to pkg/tree/importer/configserver
alongside its sibling tree importers.

Co-authored-by: Cursor <cursoragent@cursor.com>
@steiler
steiler requested a review from a team as a code owner August 11, 2026 06:21
@steiler
steiler force-pushed the config-server-cache-backend branch from a570265 to ccff783 Compare August 11, 2026 06:47
steiler and others added 4 commits August 11, 2026 13:22
…rver backend

Adds splitDatastoreName + ErrMalformedDatastoreName so ConfigServerCache can
later derive per-call target namespace/name from the datastore name instead
of a fixed, deployment-wide namespace (ticket 01).

Co-authored-by: Cursor <cursoragent@cursor.com>
target() now decodes each call's cacheInstanceName via splitDatastoreName
instead of trusting a single, deployment-wide namespace field, so one
data-server process correctly serves datastores across multiple Kubernetes
namespaces and config-server's ConfigReadService receives the bare target
name. Every target() caller, plus InstanceCreate, propagates/surfaces
ErrMalformedDatastoreName on a malformed datastore name. Constructors drop
their namespace parameter (ticket 02).

Co-authored-by: Cursor <cursoragent@cursor.com>
Namespace was never meaningful once ConfigServerCache derives it per-call
from the datastore name (ticket 02); config-server cache type now requires
only Address (ticket 03).

Co-authored-by: Cursor <cursoragent@cursor.com>
CI can't resolve `replace github.com/sdcio/sdc-protos => ../sdc-protos`
since no sibling checkout exists on the runner. Push the
config-server-cache-backend branch to sdcio/sdc-protos and pin go.mod
to its commit via a pseudo-version instead.

Also silence two staticcheck QF1011 findings on intentional interface
assertions in new tests, uncovered now that lint can actually resolve
the module.

Co-authored-by: Cursor <cursoragent@cursor.com>
steiler and others added 4 commits August 21, 2026 14:51
Config-server stores intents as a single JSON object at the YANG root path,
which mergeConfigBlobs rejected during LoadAllButRunningIntents and broke
performRevert after sync.

Co-authored-by: Cursor <cursoragent@cursor.com>
Document that per-intent SensitivePaths markers are scoped separately from
schema-defined YANG sensitivity, which ShouldRedact still applies unconditionally.

Co-authored-by: Cursor <cursoragent@cursor.com>
Config-server keys TransactionSet intents as namespace.name, but the
config-server cache was returning bare Config names. That mismatch made
DeviationWatcher split intent owners incorrectly and panic the controller.

Co-authored-by: Cursor <cursoragent@cursor.com>
Second single.yml invocation (cache_type: config-server) lands with the cache backend, not on main ahead of it. Existing integration-tests job is unchanged.

Co-authored-by: Cursor <cursoragent@cursor.com>
steiler and others added 5 commits August 25, 2026 13:03
Use the integration-tests feature-branch workflow so cache_type=config-server can include 02-crud and 03-deviations alongside 05-cache-backend.

Co-authored-by: Cursor <cursoragent@cursor.com>
IntentModify/IntentDelete now write through to config-server's
ConfigSnapshotService synchronously at apply time, replacing the
noopIntentWriter composition ADR 0001 used. Last-applied tracking under
this backend now matches Cache.Type: local's write-at-apply timing,
closing the ghost-intent race where a deleted intent stayed
last-applied until the next best-effort snapshot refresh.

- ConfigServerCache gains a LocalConfigClient seam (read+write) and
  implements IntentWriter directly; NewConfigServerClient no longer
  composes noopIntentWriter.
- New tree-to-JSON flattener (document_export.go) converts an applied
  tree_persist.Intent into the flat Document shape the read path
  already understands, for GRPCConfigClient.Modify's wire payload.
- Fix the log-only IntentDelete failure in lowlevelTransactionSet: a
  failed delete-write now hard-fails the transaction, matching
  IntentModify's existing behavior (ADR 0003's asymmetry fix).
- GRPCConfigReader/FakeLocalConfigReader renamed to
  GRPCConfigClient/FakeLocalConfigClient; mocks/mockconfigread
  regenerated against the renamed ConfigSnapshotServiceClient.
- Tests: seam-level modify/delete round trips, malformed-datastore-name
  coverage for the new write callers, and datastore-level regression
  tests for delete-failure hard-fail and rollback-restores-a-deleted-
  intent.
- Commit the previously-drafted ADR 0003 and CONTEXT.md Last-applied
  term update.
Spec review of the real-IntentWriter commit found the delete-no-rehydration
checklist item was backed by a weaker test than described, and the
rollback-restore test only checked the intent's name, not its content.

- Add TestConfigServerBackend_DeleteApply_NoRehydration: wires a real
  *cache.ConfigServerCache over configserver.FakeLocalConfigClient (not a
  generic mock) through two TransactionSet calls and asserts the exact
  regression signature named in the ticket — LoadAllButRunningIntents does
  not rehydrate a deleted intent.
- Strengthen TestTransactionRollback_RestoresDeletedIntent to assert the
  restored leaf content, not just the intent name.
- Clarify ConfigServerCache's ignoreNonExisting doc comment: unused by
  today's LocalConfigWriter implementations, not an inherent design
  guarantee.
- Note in GRPCConfigClient.Modify why the wire payload is plaintext (ADR
  0003's "encrypted payload" describes the persisted record; config-server
  encrypts on receipt, same as northbound SensitiveConfig).

Standards review separately flagged Document<->ConfigEntry mapping
duplicated across grpc.go/document_export.go; added configEntryFromDocument
as the Modify-side counterpart to the existing documentFromEntry to remove
the wire-mapping half of that duplication.
…d transaction intents (ticket 05)

lowlevelTransactionSet's validation abort check (ValidationResults.HasErrors)
was unscoped: a validation error owned by an intent rehydrated only via
LoadAllButRunningIntents (a ghost left behind by stale last-applied state)
hard-failed the whole transaction, even when the RPC's own intents (e.g. an
unrelated delete) had no errors of their own. This is the safety-net ticket
(05) on top of ticket 03's apply-time last-applied fix, guarding the
02-CRUD-teardown-hang CI signature described in the spec.

Compute the set of intent names loaded only via LoadAllButRunningIntents
(not part of transaction.GetNewIntents()) and use the new
ValidationResults.HasErrorsExcludingOwners to ignore their errors when
deciding whether to abort. Errors owned by an RPC-owned intent still hard-fail
exactly as before, and excluded ghost errors are still reported in the
response for observability.
golangci-lint-action defaults to verify: true, which runs
'golangci-lint config verify' and fetches the JSON schema for
.golangci.yml from https://golangci-lint.run over the network on
every run. That remote fetch is flaky in CI and has caused
non-deterministic lint-job failures (context deadline exceeded)
unrelated to the actual code or lint config.

golangci-lint run still validates the config at runtime, so disable
the network-dependent early check.
…ErrRunningNotFound

InstanceCreate was seeding c.running[name] with nil, so the very first
InstanceRunningGet call (before any writeBackSyncTree had run) returned
ErrRunningNotFound, causing replaceIntent to hard-fail on first-ever
replace transactions.

Fix: seed with &tree_persist.Intent{} so InstanceRunningGet always
returns an empty-but-valid ImportConfigAdapter on first call.
ErrRunningNotFound and the nil-guard in InstanceRunningGet are removed —
the only reachable nil case was the seeding bug this commit fixes.
… constant

Ghost-intent exclusion (layer 1 + layer 2) was conceptually wrong:

Layer 1 — LoadAll-only intent exclusion: if the combined YANG validation
of the highest-precedence intent tree fails, the RPC must not apply —
regardless of which intent's content caused the violation. Excluding
bystander errors could silently suppress a real cross-intent constraint
failure (e.g. intent2 deletes a leaf intent1 has a leafref to; error is
attributed to intent1, layer 1 suppresses it, invalid config is pushed).
The root fix is eliminating ghosts at the source (ticket 03 + config-server
APIReader read-after-write consistency), not masking errors in the abort path.

Layer 2 — UnknownOwner suppression: 'unknown' is an error-attribution
sentinel for validators that cannot identify a single responsible intent
(mandatory child, must on containers). It is never stored in the cache and
never returned by LoadAllButRunningIntents, so adding it to the LoadAll-only
exclusion set was mixing up two unrelated concepts and could suppress genuine
mandatory or must-statement failures.

Changes:
- Remove loadAllOnlyIntentNames function
- Remove the ghost-exclusion block in lowlevelTransactionSet; replace with
  plain validationResult.HasErrors()
- Remove HasErrorsExcludingOwners from ValidationResults; inline loop back
  into HasErrors()
- Replace hardcoded "unknown" string literals in all four validators with
  the types.UnknownOwner constant (no behaviour change — purely removes
  magic strings)
- Remove TestTransactionSet_LoadAllOnlyValidationError_DoesNotBlockUnrelatedIntent
  (tested the now-deleted exclusion behaviour)
- Replace TestValidationResults_HasErrorsExcludingOwners with
  TestValidationResults_HasErrors; add explicit case asserting UnknownOwner
  errors block the transaction rather than being special-cased
Only used by the ghost-intent exclusion test removed in the prior commit.
When a gNMI subscription delivers a JSON-IETF blob at the container
level, ExpandUpdate's top-level IsState() guard is never reached for
the individual fields inside the blob — those are expanded by
ExpandContainerValue, which had no state-leaf filter.

This caused read-only operational leaves (e.g. afi-safi/active-routes
on SRL) to be written into the sync tree under the running owner.
When an intent owning the same container was later deleted, the
mandatory-field validator saw the container kept alive by those state
leaves and fired, blocking the delete transaction for the full retry
window (15 min timeout in CI).

Fix: add IsState() checks in ExpandContainerValue for all three branch
types — LeafSchema fields, LeafListSchema, and child containers.  For
fields the redundant duplicate GetSchemaSdcpbPath call (one for state
check, one for value conversion) is collapsed into a single call.

Regression test: TestExpandContainerValueSkipsStateLeaves in
pkg/utils/converter_test.go.
Cache.Type: local is a standalone deployment with no config-server; it
was never designed to run in a cluster alongside one, and doing so
produced confusing failures unrelated to the local-cache code path
(see .scratch/config-server-cache-backend-ci-fix/spec.md).

Only the integration-tests (config-server cache backend) job remains
as the regression gate for this workflow.

Ticket: 02-integration-tests-remove-local-cache-job
ExpandContainerValue already stripped a JSON-IETF module prefix (e.g.
"srl_nokia-common:ipv4-unicast") from an identityref list key when
that key arrived inside the JSON value, but not when the same key
arrived already embedded in the gNMI path (keysInPath) — the common
case for GET/Subscribe sync responses whose list keys are delivered as
path elements rather than nested in the body.

This let the same list entry (e.g. bgp/afi-safi[afi-safi-name=...])
resolve to two different tree nodes depending on whether it was
populated by device sync (module-prefixed, via path) or by an intent
(unprefixed, via JSON): a running-only "ghost" node that was never
tied to the intent's lifecycle, and the intent-owned node that was.

Deleting the intent removed only the intent-owned node. The ghost node
(kept alive by ordinary config leaves like admin-state, not just state
leaves already handled by 767b535) made the ancestor container's
RemainsToExist() stay true forever, so mandatory-field validation kept
firing on the now-orphaned mandatory children (autonomous-system,
router-id) that only the deleted intent had provided — permanently
failing the delete transaction and hanging the ConfigSet teardown for
the full 15-minute CI retry window (see run
https://github.com/sdcio/data-server/actions/runs/34466219951).

Fix: strip the identityref module prefix for path-sourced keys the
same way as JSON-value-sourced keys, and always write the resulting
keySet back onto the path's last element in one place (previously that
write only happened when the key came from the JSON value, which was
harmless before but would have left path-sourced identityref keys
unstripped even with the fix above).

Regression test: TestExpandContainerValueStripsIdentityrefModulePrefixFromPathKey
in pkg/utils/converter_test.go.
JsonTreeImporterElement.GetKeyValue previously returned
fmt.Sprintf("%v", data) verbatim, so JSON_IETF-encoded identityref
values (e.g. "srl_nokia-common:ipv4-unicast") kept their module
prefix while plain-JSON/proto-imported values for the same identity
stored the bare name ("ipv4-unicast"). The two forms were treated as
different tree keys, splitting one logical identityref-keyed list
entry (e.g. bgp/afi-safi) into two sibling tree nodes.

In production this let a running-only remnant of an afi-safi entry
survive a full intent delete, keeping the parent bgp container
'existing' just long enough for mandatory-child validation to run
against it and reject the transaction with a spurious
'mandatory child [autonomous-system] does not exist' error, even
though the intent (and its mandatory leaves) was being deleted in
full.

Fix GetKeyValue to delegate to GetTVValue + tv.ToString(), mirroring
the XML and proto importers, which normalizes identityref values to
the bare identity name regardless of encoding. Falls back to
fmt.Sprintf when slt is nil, since GetTVValue's underlying
ConvertJsonValueToTv would panic on a nil SchemaLeafType.

Also documents the GetKeyValue contract on ImportConfigAdapterElement:
implementations must return the bare identity name for identityref
types.

Implements issue 01 of the must-leafref-identityref-normalization PRD.
…a race

TreeExport and other read paths were reading LeafEntry.Delete/IsNew/IsUpdated/
IsExplicitDelete fields directly, racing with MarkDelete()/MarkUpdate()/
MarkExpliciteDelete() which mutate these fields under LeafEntry.mu (e.g. from
the owner-delete-marker pool task running concurrently with TreeExport during
writeBackSyncTree/LoadAllButRunningIntents).

Switch all direct field reads to the existing lock-guarded accessors
(GetDeleteFlag/GetNewFlag/GetUpdateFlag/GetExplicitDeleteFlag), and add a new
LeafEntry.ResetFlags helper so LeafVariants.ResetFlags no longer mutates the
flags directly while holding only the sibling lesMutex.
CI run 34482168553 (job 102888410883, this PR) failed the config-server
cache backend job's 03-deviations suite: 5 "Partially Revert Deviations by
Filter Path" tests hit code=Aborted "datastore is locked, other action is
ongoing" from kubectl sdc deviation --revert --filter-path.

Root cause: legitimate lock contention on data-server's per-datastore
TryLock (predates this branch), hit by a code path
(config-server's ClearDeviations) that had no retry, unlike the
TargetConfigController reconcile loop which already tolerates the same
condition. Fixed in the two sibling repos:

- sdcio/config-server: executeClearDeviationTx now retries on
  dsclient.IsRecoverableError (codes.Aborted/ResourceExhausted).
- sdcio/integration-tests: completed c69c07e's fix by wrapping the two
  partial-revert keywords it missed.

No data-server code change needed -- this ticket documents the
investigation and cross-repo fix for future reference.
steiler added a commit to sdcio/config-server that referenced this pull request Sep 11, 2026
…d errors

kubectl sdc deviation --revert (including --filter-path partial reverts)
reaches executeClearDeviationTx via the cleardeviation subresource, which
called TransactionSet/TransactionConfirm once and returned whatever error
it got straight through. Under concurrent load (e.g. TargetConfigController
reconciling the same datastore) that error is frequently codes.Aborted from
data-server's dmutex TryLock losing a race — a condition the reconciler's
own TransactionSet calls already tolerate via isRecoverableGRPCError +
500ms RequeueAfter (pkg/reconcilers/targetconfig/reconciler.go), but which
ClearDeviations had no equivalent for.

- Move isRecoverableGRPCError's classification to
  pkg/sdc/dataserver/client.IsRecoverableError, the lowest-layer package
  both apis/config and pkg/sdc/target/manager already depend on (avoids an
  apis/config <-> pkg/sdc/target/manager import cycle).
- Wrap executeClearDeviationTx's TransactionSet/TransactionConfirm calls in
  retryOnRecoverable: up to 5 attempts, 500ms fixed backoff, matching the
  reconciler's own RequeueAfter for the identical error condition.

codes.Aborted is nothing having been applied server-side (TryLock never
acquired), so replaying the same request is always safe.

Evidence: data-server CI run 34482168553
(sdcio/data-server#471), 03-deviations suite, "Partially Revert Deviations
by Filter Path" group. See data-server
.scratch/deviation-partial-revert-lock-contention/spec.md.
steiler added a commit to sdcio/integration-tests that referenced this pull request Sep 11, 2026
Completes the fix started in c69c07e ("retry Delete Deviation on transient
datastore-locked errors"). That commit's own ticket
(.scratch/last-applied-snapshot-write-at-apply/issues/08) named two
originally-failing groups -- "Reject Deviations" and "Partially Revert
Deviations by Filter Path" -- but only wrapped the keyword behind the
first (Delete Deviation). The second group's two keywords were missed and
remained unwrapped, causing 5 test failures in CI run 34482168553
(sdcio/data-server#471, config-server-cache-backend job, 03-deviations
suite):

- Partial Revert Deviations For Intent by Interface
  (22-srl-nonrevertive.robot) -> extracts Run Partial Revert Deviations
  by Interface.
- Partial Revert Deviations For Intent by Admin State
  (21-sros-nonrevertive.robot) -> extracts Run Partial Revert Deviations
  by Admin State.

Both now wrap the kubectl sdc deviation --revert --filter-path call in
Wait Until Keyword Succeeds ${eventual_timeout} ${retry}, same shape as
c69c07e's Run Deviation Revert extraction.

This is a CI-level safety net alongside the actual protocol-level fix in
sdcio/config-server (executeClearDeviationTx now retries recoverable gRPC
errors server-side). See data-server
.scratch/deviation-partial-revert-lock-contention/spec.md.
@codecov

codecov Bot commented Sep 14, 2026

Copy link
Copy Markdown

@steiler
steiler force-pushed the config-server-cache-backend branch from 3916037 to 15b7efd Compare September 14, 2026 13:37
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant