Skip to content

localenv: populate setup-local warnings and durationMs - #6176

Closed
rugpanov wants to merge 3 commits into
mainfrom
dbconnect/setup-local-warnings-duration
Closed

localenv: populate setup-local warnings and durationMs#6176
rugpanov wants to merge 3 commits into
mainfrom
dbconnect/setup-local-warnings-duration

Conversation

@rugpanov

@rugpanov rugpanov commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

Changes

Populates the two dead fields in the environments setup-local --output json contract (both were declared but never set — warnings always [], durationMs always 0). Scoped to the still-hidden command.

  • durationMs — inject a clock (Pipeline.Now, default time.Now) and stamp wall time on every Run exit path. Tests use a fixed clock; the acceptance JSON goldens normalize the value via a [DURATION_MS] repl so they stay deterministic.
  • warnings — emit a documented, closed set of categorical codes from the merge phase:
    • W_REQUIRES_PYTHON_OVERRIDDEN / W_DBCONNECT_PIN_OVERRIDDEN — the user's pin differed from the env's and was replaced.
    • W_USER_CONSTRAINT_CONFLICT — a [project].dependencies pin is provably disjoint from the env's constraint-dependencies for that package (conservative PEP 440: == / ~= / bounds; ambiguous ranges never fire, so no false positives — uv stays the real resolver).

Detection is a read-only compare of the pre-merge pyproject.toml against the fetched constraints (detectMergeWarnings); MergeManaged still owns the byte edits. Warnings fire for both --dry-run and real runs, and only for existing projects (greenfield has nothing to override).

Why

These back the ERD's "setup result" metric group (merge-quality warning count + the ~3-min duration claim). The extension's setup-result telemetry (DECO-27787) had to omit warningsCount because the CLI never emitted real warnings. DECO-27875; unblocks the reopened DECO-27787 follow-up.

Tests

  • Unit: detectMergeWarnings (override + conflict + greenfield/constraints-only/unparseable cases) and a rangesDisjoint table (exact/compat-release/bounds/!=/multi-clause/wildcard). Pipeline tests: warnings surface in the result, greenfield stays empty, and durationMs is deterministic under an injected clock.
  • Acceptance: new merge-warnings-json scenario asserts all three codes through --output json; 5 existing JSON goldens updated for the [DURATION_MS] repl.
  • go test ./libs/localenv/... ./cmd/environments/... + localenv/help acceptance pass; golangci-lint and deadcode clean; darwin + GOOS=windows build.

This PR was written by Claude Code.

@github-actions

github-actions Bot commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

Approval status: pending

/acceptance/localenv/ - needs approval

9 files changed
Eligible: @rclarey, @anton-107, @misha-db

/libs/localenv/ - needs approval

5 files changed
Eligible: @rclarey, @anton-107, @misha-db

General files (require maintainer)

Files: acceptance/test.toml
Based on git history:

  • @denik -- recent work in acceptance/

Any maintainer (@andrewnester, @anton-107, @denik, @pietern, @shreyas-goenka, @simonfaltum, @renaudhartert-db, @janniklasrose, @lennartkats-db) can approve all areas.
See OWNERS for ownership rules.

@eng-dev-ecosystem-bot

eng-dev-ecosystem-bot commented Aug 5, 2026

Copy link
Copy Markdown
Collaborator

Integration test report

Commit: 9d6bd19

Run: 31098157394

Env 💚​RECOVERED 🙈​SKIP ✅​pass 🙈​skip Time
💚​ aws linux 4 4 291 1111 4:45
💚​ aws windows 4 4 293 1109 3:55
💚​ azure linux 4 4 290 1111 4:55
💚​ azure windows 4 4 292 1109 4:02
💚​ gcp linux 1 5 291 1111 5:24
💚​ gcp windows 1 5 293 1109 5:04
8 interesting tests: 4 RECOVERED, 4 SKIP
Test Name aws linux aws windows azure linux azure windows gcp linux gcp windows
💚​ TestAccept 💚​R 💚​R 💚​R 💚​R 💚​R 💚​R
🙈​ TestAccept/bundle/invariant/no_drift 🙈​S 🙈​S 🙈​S 🙈​S 🙈​S 🙈​S
🙈​ TestAccept/bundle/resources/vector_search_endpoints/drift/recreated_same_name 🙈​S 🙈​S 🙈​S 🙈​S 🙈​S 🙈​S
🙈​ TestAccept/bundle/resources/vector_search_indexes/recreate/embedding_dimension 🙈​S 🙈​S 🙈​S 🙈​S 🙈​S 🙈​S
🙈​ TestAccept/ssh/connection 🙈​S 🙈​S 🙈​S 🙈​S 🙈​S 🙈​S
💚​ TestFetchRepositoryInfoAPI_FromRepo 💚​R 💚​R 💚​R 💚​R 🙈​S 🙈​S
💚​ TestFetchRepositoryInfoAPI_FromRepo/root 💚​R 💚​R 💚​R 💚​R
💚​ TestFetchRepositoryInfoAPI_FromRepo/subdir 💚​R 💚​R 💚​R 💚​R
Top 3 slowest tests (at least 2 minutes):
duration env testname
3:11 aws windows TestAccept
2:59 azure windows TestAccept
2:57 gcp windows TestAccept

Both fields were declared in the --output json contract but never populated:
warnings was always [] and durationMs always 0. They back the ERD's "setup
result" metrics (merge-quality warning count + the ~3min duration claim), so
the extension had nothing real to report. DECO-27875.

- durationMs: add an injectable clock (Pipeline.Now, default time.Now) and
  stamp wall time across every Run exit path. Tests inject a fixed clock; the
  acceptance JSON goldens normalize the value via a [DURATION_MS] repl so they
  stay deterministic.
- warnings: emit a documented, closed set of categorical codes from the merge
  phase — W_REQUIRES_PYTHON_OVERRIDDEN and W_DBCONNECT_PIN_OVERRIDDEN when the
  user's pins differ from the env's and are replaced, and
  W_USER_CONSTRAINT_CONFLICT when a [project].dependencies pin is provably
  disjoint from the env's constraint for that package. Detection is a read-only
  compare of the pre-merge pyproject against the fetched constraints
  (detectMergeWarnings); MergeManaged still owns the byte edits. The conflict
  check is conservative PEP 440 (== / ~= / bounds) and only fires on a provable
  disjoint, so an ambiguous range is never a false positive — uv remains the
  real resolver.

Warnings fire for both --dry-run and real runs, and only for existing
projects (greenfield has nothing of the user's to override). Contract-additive
and scoped to libs/localenv; the command is still hidden.

DECO-27875

Co-authored-by: Isaac
… input

Follow-up to the review of the setup-local warnings work. Four defects, all in
the advisory/telemetry path (no effect on the merged file or the run's outcome):

- A PEP 735 {include-group = ...} entry in [dependency-groups].dev made the
  strict decode fail, and detectMergeWarnings returned no warnings at all — even
  though [project] had already parsed and MergeManaged still rewrote all three
  managed regions. The user's pins were replaced with no advisory, and the
  warning histogram under-counted exactly the projects using a standard uv
  feature. The user's file now decodes through userPyprojectTOML, which models
  dev as []any and skips non-string entries; the artifact keeps the strict struct
  since we control its shape.

- parseClause accepted a single-segment "~=" (e.g. "~=2"), which PEP 440 does
  not define. compatibleReleaseContains returns "not contained" for such a base,
  and the disjointness callers read that as proof of disjointness — so
  overlapping ranges were reported as conflicts ("requests~=2" vs
  "requests==2.31.0"). Such a clause is now refused so it stays an unknown range,
  restoring the "only provably disjoint" invariant the code documents.

- Duplicate constraint-dependencies entries for one package let the last one win,
  making the outcome depend on artifact ordering (["pyarrow<21","pyarrow>=20"]
  reported a conflict; the reverse order did not). Entries for a package compose
  as a conjunction, so they are now joined and treated as an unknown range.

- Extras were left in the specifier, so "pyarrow[compute]==17.0.0" never parsed
  and a real conflict went unreported. Extras select optional features and never
  narrow the version range, so they are stripped. Requirements carrying an
  environment marker are now skipped instead of half-parsed, since we do not
  evaluate markers and the pin may not apply to the resolving interpreter.

Also drops the [project].dependencies field added to pyprojectTOML, which the
new user-side struct supersedes.

Verified by brute-forcing 5184 ordered specifier pairs (including the malformed
single-segment "~=" forms) against a dense version grid: every "disjoint" verdict
is backed by the absence of a witness version satisfying both sides, and ~=
semantics match PEP 440's worked examples via an independent interval model.

Co-authored-by: Isaac
…s-connect pin

Review follow-up. PEP 735 {include-group = "..."} is an indirection, so the
user's pin may live in a group the dev group merely references:

  [dependency-groups]
  dev = [{include-group = "spark"}]
  spark = ["databricks-connect==16.1.0"]

The previous scan only walked dev's own string entries, so it found no pin and
emitted no W_DBCONNECT_PIN_OVERRIDDEN — while MergeManaged still injects the
env's pin into dev, leaving the file with two pins for the same package and no
advisory that one replaced the other. groupRequirements now resolves the
reference chain, normalizing group names per PEP 503 (which PEP 735 also
specifies) and skipping already-visited groups so an include-group cycle
terminates. Decoding dependency-groups as a map is what makes the other groups
reachable; the walk is deterministic despite the map because warning order is
fixed downstream.

Also scopes the environment-marker check to the post-extras remainder rather
than the raw requirement, so it tests only where a marker can appear. Behavior
is unchanged for every input either form decides — a ";" in a url requirement is
still skipped, which costs nothing since a url has no comparable version range.

This closes a false negative that predates the warnings work rather than a
regression from it: the parent commit reported nothing for the same input too.

Co-authored-by: Isaac
@rugpanov
rugpanov force-pushed the dbconnect/setup-local-warnings-duration branch from 54ccf7a to 9d6bd19 Compare August 6, 2026 11:38

@anton-107 anton-107 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed the branch locally and verified each point by running it rather than reading. The durationMs half looks correct and self-contained — the defer covers every exit path including the E_CANCELED reclassification. My concerns are all in the warnings half.

Blocking: #1, #2, #5 (inline below). The short version: as shipped, W_USER_CONSTRAINT_CONFLICT will rarely fire on the constraint shape our artifacts actually publish, and W_DBCONNECT_PIN_OVERRIDDEN makes a factually wrong claim in the exact case commit 9d6bd19 was added to handle. Since both back a metric, the failure mode is silent — bad numbers, nothing visibly broken.

Non-blocking: #3, #4, #6, #7 — dead code and comments that describe behavior the code doesn't have. Cheap to fix, worth doing before merge.

None of the fixes are large or architectural; the helpers needed (groupRequirements, compareRelease) are already in the file.

One suggestion: consider splitting. durationMs + the repl + the 5 golden updates is correct and could land now; the warnings work gets its own PR with the blocking items addressed. That also matches the one-change-per-PR rule, since these are two independent contract fields.

Verification: go test ./libs/localenv/ ./cmd/environments/ passes on the branch. For #3 and #4 I confirmed by deleting the code and re-running the suite.

Comment thread libs/localenv/warnings.go
if strings.TrimSpace(entry) != strings.TrimSpace(c.DatabricksConnect) {
warnings = append(warnings, Warning{
Code: WarnDBConnectPinOverridden,
Message: fmt.Sprintf("databricks-connect %q was replaced by the environment's %q", strings.TrimSpace(entry), c.DatabricksConnect),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

BLOCKING (#1) — this message is factually wrong in the case it was written for.

I ran MergeManaged on the exact input from TestDetectMergeWarningsIncludeGroupIndirection, and the merged output is:

dev = ["databricks-connect==17.0.0", {include-group = "spark"}]
spark = ["databricks-connect==16.1.0"]

The user's 16.1.0 pin is still there. Nothing was replaced — a second, contradicting pin was inserted alongside it, and uv sync will fail to resolve. 9d6bd19's own commit message concedes this ("the merged file would carry two databricks-connect pins"), but the warning still tells the user their pin was cleanly overridden.

So in this scenario the user gets a reassuring advisory and then a hard resolution failure, while the actual hazard — two pins for one package — has no signal at all. TestDetectMergeWarningsIncludeGroupIndirection asserts the inaccurate wording, so it locks the bug in.

Walking the indirection is the right instinct; the conclusion drawn from it is wrong. This case needs its own code (a duplicate-pin warning), not the override code — they're different conditions with different user actions.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed and fixed in #6191. The merged output is exactly as you show — the merge inserts a second pin and leaves the users 16.1.0 in place, so "was replaced" was untrue and hid the resolution failure.

Split into its own condition rather than reworded: W_DBCONNECT_PIN_DUPLICATED, emitted when the pin is reachable only through an include-group. A pin sitting directly in devs own array still reports W_DBCONNECT_PIN_OVERRIDDEN, since that one really is rewritten in place. The distinction is directRequirements vs groupRequirements. The test now asserts the message does not contain "was replaced", so the wording cannot regress.

Comment thread libs/localenv/warnings.go
}
}

warnings = append(warnings, constraintConflicts(p.Project.Dependencies, c.ConstraintDeps)...)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

BLOCKING (#2) — conflict detection skips dependency groups entirely.

Only [project].dependencies is scanned. I confirmed that pyarrow==17.0.0 in [dependency-groups].dev against an env constraint of pyarrow~=21.0.0 produces zero warnings.

uv applies constraint-dependencies to the whole resolution, so a group pin breaks uv sync exactly like one in [project].dependencies. Given that this command's whole audience is projects with a dev group (that's where the dbconnect pin lives), that's likely where conflicting pins land in practice.

The fix is nearly free: groupRequirements already exists a few lines up for the dbconnect check and just isn't reused here.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed and fixed in #6191. constraintConflicts now receives slices.Concat(p.Project.Dependencies, groupRequirements(p.DependencyGroups, devGroup)), so the dev group is scanned including pins behind an include-group. Tests cover a conflicting dev-group pin and one reached by indirection.

Comment thread libs/localenv/warnings.go
// pair it cannot decide — a "!=", a multi-clause range, an unparseable release —
// returns false, so an uncertain case is never reported as a conflict. uv remains
// the real resolver; this only surfaces the obvious clashes as an advisory.
func rangesDisjoint(userSpec, envSpec string) bool {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

BLOCKING (#5) — can't decide the constraint shape our own artifacts use.

Verified against the current implementation:

user env provably disjoint? reported
>=20 <19 yes false
>=4 <3 yes false
~=17.0 ~=21.0 yes false

Every other localenv fixture publishes bound-style constraints — pyarrow<19, pandas<3 in serverless-json/test.toml. The tell is that the new fixture had to use pyarrow~=21.0.0 for the warning to fire at all: the test was fit to the detector rather than to what the artifacts contain.

Being conservative on genuinely ambiguous ranges is the right call, and I'm not asking for a full PEP 440 solver. But opposite-direction bounds and ~= vs ~= are trivially decidable, and compareRelease is already right here. Since the stated purpose is a merge-quality metric, a detector that misses the common shape under-reports invisibly — the dashboard just shows a low conflict count and nobody can tell it's wrong.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed and fixed in #6191. The fixture point is the part that convinced me: every pre-existing fixture publishes pyarrow<19 / pandas<3, and the only ~= entry in the tree is the one I added — so the test was fitted to the detector, exactly as you said.

Rather than adding the two missing operator pairs, the core is now interval arithmetic: each clause converts to a release range with explicit endpoint inclusivity, and disjointness is one endpoint comparison. That decides all three rows in your table plus ~= vs a bound, and it replaced clausesDisjoint, satisfies, and compatibleReleaseContains outright.

One subtlety worth flagging since it is easy to get wrong: I first normalized to half-open ranges by synthesizing a successor, which is unsound — ">3.12" becoming ">=3.12.1" excludes 3.12.0.5, which does satisfy ">3.12" (compareRelease zero-pads). Shrinking an interval turns a real overlap into a false conflict, so inclusivity is tracked per endpoint instead. TestRangesDisjoint now covers the touching-endpoint cases (>=2.0 vs <=2.0 overlaps; >2.0 vs <=2.0 does not).

Comment thread libs/localenv/warnings.go
case "==":
// a pins exactly a.rel; disjoint iff that version does not satisfy b.
return !satisfies(b, a.rel)
case "~=":

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Non-blocking (#3) — this branch is dead.

It only fires when b.op == "==", and the swapped call clausesDisjoint(b, a) already decides that pair through satisfies's ~= case, reaching the same compatibleReleaseContains.

I brute-forced all 6400 pairs over 8 operators × 10 releases against a copy with the branch removed: zero divergence, and the package suite passes without it.

Separately: the single-segment ~= guard in parseClause (~line 269) is load-bearing — but it protects the satisfies path, not this branch. Its comment credits "the disjointness callers" via compatibleReleaseContains, which will mislead the next reader once this branch is gone. Worth correcting while you're here.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed and fixed in #6191. Verified the same way before changing anything: the swapped clausesDisjoint(b, a) call reaches case "=="satisfies → the ~= case → the same compatibleReleaseContains, so the branch could never be the deciding path.

Moot now — the interval rewrite for #5 removed clausesDisjoint, satisfies, and compatibleReleaseContains entirely. The parseClause single-segment ~= guard is kept and its comment now explains the real reason: clause.interval builds the ceiling from rel[:len(rel)-1], so a one-segment base would yield an empty prefix and a nonsense bound.

Comment thread libs/localenv/warnings.go
}
// Sort by package name (embedded in Message after "dependency ") for stable
// output; the count and code are what the contract carries, order is cosmetic.
sortWarningsByMessage(warnings)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Non-blocking (#4) — unnecessary, and both comments describe behavior it doesn't have.

The comment says "map iteration and slice order upstream are unstable." They aren't: constraintConflicts iterates userDeps, a []string straight from the TOML decode, in declaration order. envByName is only ever used for lookup, never iterated. Tests pass with the sort removed.

It also doesn't do what it claims. Both this comment and the one above constraintConflicts say "sorted by package name," but it sorts the whole Message string in ASCII order — so "Zeta==1.0.0" sorts before "alpha==1.0.0" (verified). Net effect is discarding the user's declaration order, which is the more useful order for reading warnings, for no determinism gain.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed and removed in #6191. Both halves of your point check out: envByName is only ever indexed, never ranged over, and userDeps arrives in declaration order from the TOML decode — so there was no instability to protect against. And it sorted the whole Message, so "Zeta==1.0.0" really did sort before "alpha==1.0.0".

Warnings now come out in the users declaration order, and both comments were rewritten to describe that instead. I did double-check that removing the sort does not expose the map iteration in groupRequirements — that one only indexes its map and walks arrays in order, so it is deterministic on its own.

Comment thread libs/localenv/pipeline.go
// Now returns the current time; it exists so tests can inject a deterministic
// clock (acceptance goldens would otherwise carry a real, changing durationMs).
// nil means time.Now — see now().
Now func() time.Time

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Non-blocking (#6) — the stated rationale isn't the real one.

The comment says acceptance goldens "would otherwise carry a real, changing durationMs," but the [DURATION_MS] repl is what makes the goldens deterministic — this field isn't involved. Now is never set anywhere outside pipeline_test.go:276.

So an injectable field on the exported Pipeline struct exists so one test can assert 250, which largely verifies that Sub().Milliseconds() works. Given the repo's stance on speculative flexibility, I'd either drop the field and assert the duration is sane, or keep it and reword the comment to say what it's actually for.

The timing logic itself is right — measuring in Run via defer correctly covers the failure and cancellation paths.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed. Now was set only at pipeline_test.go:276, and you are right that the repl — not the clock — is what makes the goldens deterministic, so the stated rationale was wrong.

Took the drop-it option in #6190, given the repo stance on speculative flexibility: an exported field on Pipeline existing for one test did not earn its keep. Timing is now plain time.Now() / time.Since. The replacement test delays the constraint fetch by 25ms and asserts the duration exceeds that and stays within the observed wall time — a >= 0 assertion would have passed against the old hardcoded 0 and proved nothing. A second test covers the preflight-error path.

Comment thread acceptance/test.toml
[[Repls]]
# environments setup-local emits a real wall-time durationMs; normalize it so the
# JSON goldens are deterministic. Runs before the generic numeric repls (Order 10).
Old = '"durationMs": \d+'

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Non-blocking (#7)localenv is the only thing emitting this key, so this fits better in acceptance/localenv/test.toml. Global scope means a future command that emits durationMs gets it silently normalized in its goldens without anyone opting in.

(The Order = 8 choice is correct — it has to beat the Order = 10 numeric repls. Worth keeping that note wherever it lands.)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed and moved in #6190 — it now lives in a new acceptance/localenv/test.toml, and the Order = 8 note moved with it, including why it has to beat the root Order = 10 numeric repls.

@rugpanov

rugpanov commented Aug 6, 2026

Copy link
Copy Markdown
Contributor Author

Closing in favor of the split you suggested — the two contract fields are independent and only one of them needed rework:

Each of your seven points reproduced exactly as described. Two were on me in ways worth naming: I had reported #1 as fixed in 9d6bd1953 — walking the include-group was the right instinct but I attached the override warning to it, and my own commit message conceded the file ends up with two pins while the message claimed a clean replacement. And I twice classified #5 as an intentional non-goal; the fixture evidence (every existing fixture publishes pyarrow<19 / pandas<3, and the only ~= in the tree is the one I added) shows that was wrong for something backing a metric.

One limitation to flag on both PRs: the SDK bump on main is undownloadable in my environment, so neither branch was compiled locally — CI is the first real build. #6190 is already green through the first matrix jobs. #6191 also omits the merge-warnings-json acceptance scenario, because its golden carries a byte-exact diff field that has to be generated by running the suite; it needs adding once someone can regenerate goldens.

@rugpanov rugpanov closed this Aug 6, 2026
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.

3 participants