-
[OPIK-6187] [BE] feat: optimization project migration job (V1 → V2 auto-inference) (#6828)
发布于
2026-06-01 07:39:33 +00:00 - [OPIK-6186] [BE] feat: add experiment and dataset migration skip columns to workspaces
-
Migration 000073: add experiment_project_migration_skipped_at /
experiment_project_migration_skip_reason alongside the existing
migration_skipped_at / migration_skipped_reason columns; copy any
existing data into the new columns; keep old columns alive for
backward compat during rolling deployment. -
Migration 000074: add dataset_project_migration_skipped_at /
dataset_project_migration_skip_reason columns (no legacy columns to
mirror; these are net-new for the upcoming dataset migration job). -
WorkspacesDAO / WorkspacesService: rename markMigrationSkipped /
findMigrationSkippedWorkspaceIds / countMigrationSkipped to their
experiment_project_migration_* equivalents; all UPDATE and INSERT
statements dual-write to both old and new columns so old pods still
reading migration_skipped_at see current data during the deployment
window; add parallel dataset_project_migration_* methods. -
Workspace record: replace generic migrationSkippedAt/Reason with
experimentProjectMigrationSkippedAt/Reason +
datasetProjectMigrationSkippedAt/Reason. -
ExperimentProjectMigrationService + ExperimentProjectMigrationJobTest:
update two call sites each to use the renamed service methods.
-
fix: read experiment migration skip status from legacy column during rolling deployment
-
fix: guard UPDATE on legacy column; add legacy fields to Workspace record
-
[OPIK-6186] [BE] feat: dataset project migration job (V1 → V2 auto-inference)
Implements the dataset analogue of the experiment project migration (D1).
Setsdatasets.project_idfrom NULL by inferring the project from each
dataset's experiments → experiment_items → traces graph in ClickHouse.Architecture (matches D1 sibling and the ticket's locked-in design):
- Eligibility: pure MySQL on
datasets(orphans excluding demo names and
the env-var exclusion list, ordered smallest-first). - Inference: ClickHouse, joins experiments → experiment_items → traces,
groups by dataset_id, exposescount(DISTINCT t.project_id)so the
service does the four-bucket classification. - Four buckets — certain / certain-deleted / ambiguous / no-inference —
classified in the service, not the SQL. No-inference orphans fall back
to the workspace's "Default Project". - Three trap reasons persisted in
workspaces.dataset_project_migration_*:
deleted_project,all_ambiguous,default_project_missing. Also
surfaced as labels on thecycle.trapped_workspacesgauge so dashboards
can break down by reason. - Write: MySQL UPDATE with
WHERE project_id IS NULLidempotency guard. - Post-migration: only
workspaceVersionService.evictCache(workspaceId).
Critical fix: when a workspace has both certain orphans and no-inference
orphans without a Default Project, the certain mappings migrate BEFORE
the workspace is trapped. Earlier draft trapped first and silently
dropped the validated certain mappings.Query optimizations (validated against prod read replicas):
- CH inference: per-alias
workspace_idpredicate on every joined alias.
Without it the optimizer doesn't push the workspace filter through
INNER JOIN, leading to a full traces-table scan that would exceed the
cluster's row-read cap. With it, the primary key prunes most granules. - MySQL eligibility with exclusions:
FORCE INDEX (datasets_workspace_id_name_uk)flips a full table scan into a range
scan — about 4× faster end-to-end. - Secondary
ORDER BY workspace_id ASCfor deterministic tie-breaking
(verified zero cost impact).
Reactive flow:
- All blocking JDBI calls wrapped in
Mono.fromCallable(...).subscribeOn(migrationScheduler). - Dedicated bounded-elastic scheduler isolates JDBC work from the shared
boundedElastic()and from the reactive client pools, mirroring D1.
Metrics under
opik.migration.dataset_project.*:cycle.eligible_workspaces,cycle.trapped_workspaces{reason},
cycle.env_excluded_workspaces,cycle.duration{result}(on Job),
workspace.duration{result},datasets.skipped{reason},
datasets.assigned_to_default,batch.size.
Tests: 10 integration tests in
DatasetProjectMigrationJobTestcovering
every bucket, every trap reason, idempotency, env-excluded, and the
critical-fix regression (certain mappings preserved when Default Project
is missing). Container reuse disabled on this test class because the
MySQL eligibility query is sensitive to stale orphan rows across runs.Collateral fixes (column-rename leftovers caught while running tests):
ExperimentProjectMigrationService.markMigrationSkipped(...)→
markExperimentProjectMigrationSkipped(...).ExperimentProjectMigrationServiceTest: same call-site renames plus
migrationSkippedReason()→experimentProjectMigrationSkipReason().
- fix(migration): address PR review feedback
-
WorkspacesDAO: change NULL guard on
updateExperimentProjectMigrationSkippedIfNull
frommigration_skipped_at IS NULLtoexperiment_project_migration_skipped_at IS NULL.
The legacy-column guard prevented new pods from backfilling rows written by old pods
with only the legacy column set; the new column would have stayed NULL forever and
the skip state would be lost when the legacy column gets dropped. -
DatasetProjectMigrationJobTest: extend the 4 trap-path tests to assert the persisted
datasetProjectMigrationSkipReasonmatches the expected catalog value, not just the
presence of the workspace in the trapped list. Catches regressions where the trap
fires but writes the wrong reason. Factored the assertion into a shared helper.
- refactor(migration): align D2 with D1 policy (auto-create Default Project)
Mirror the experiment-migration (D1) policy on the dataset job:
- certain-deleted datasets reroute to Default Project instead of trapping
the workspace; the lookup uses ProjectService.getOrCreate so a missing
Default Project is auto-provisioned in-line. - no-inference datasets also flow through getOrCreate, eliminating the
default_project_missing trap. - only all_ambiguous remains as an active trap reason — the CLI tool
will cover those later.
Tag the datasets.assigned_to_default counter with reason=deleted_project
or no_inference so the dashboard can split the two contributing buckets.Drop dead code introduced by the removed trap paths: defaultProjectMissing
parameter and unreachable branch, RESULT_DEFAULT_PROJECT_MISSING and
RESULT_ALL_SKIPPED_DELETED attribute constants. Keep the trap-reason
string constants in KNOWN_TRAP_REASONS so the gauge still buckets legacy
workspaces persisted before the policy change.Fix a latent bug in resolveDefaultProjectAndMigrate: Mono.fromCallable
returning null produces an empty Mono in Reactor, which short-circuited
the happy path. Use an early return when no datasets need Default.Update affected tests: certain-deleted now migrates, default-missing now
auto-creates, mixed-bucket workspace keeps only ambiguous as V1.- refactor(migration): address PR review feedback (OPIK-6186)
Address all 13 unresolved review comments on PR #6799:
Nits & cleanups:
- MigrationSkipReasonCount: convert comment to javadoc, add @Builder and
@NonNull (#1, #2). - DatasetDAO.batchSetProjectId: use text block instead of string
concatenation (#7). - DatasetDAO.findEligibleDatasetMigrationWorkspaces: drop the default
dispatch method and the no-exclusion SQL variant; collapse to one
SqlQuery driven by<if(excludedWorkspaceIds)>so FORCE INDEX and
workspace_id NOT IN (...)are emitted together exactly when needed
(#4, #6). EXPLAIN against prod confirms: no exclusions → planner picks
the right index on its own; with exclusions → FORCE INDEX is still
required (otherwise type=ALL full scan, 220k rows). - DatasetProjectMigrationService: extract
findEligibleWorkspaces,
findOrphanDatasetIds,writeBatchprivate helpers around the
transactionTemplate calls (#8). - config-test.yml: datasetProjectMigration.startupDelay 0s → 5s so the
job test's seed completes before the first cycle fires (#12). - WorkspacesDAO/Service: remove unused
countDatasetProjectMigrationSkipped
(#3, the only DAO method that test scenarios needed but production
doesn't).
Substantive changes:
- ExperimentDAO.computeDatasetProjectMapping: accept the orphan ID set
and adde.dataset_id IN :dataset_idsso the join only walks the
graph for V1 datasets (#10). - Same query rewritten to derive project_id from
experiments.project_id
(which D1 sets) instead of the experiments→experiment_items→traces
join, withargMax(project_id, last_updated_at) GROUP BY idto dedup
across ReplacingMergeTree row versions andHAVING != ''to filter
experiments D1 left unmigrated (#11). Prod measurement on the
worst-case workspace (120k experiments, 50-dataset sample):
read_rows 1.15M → 130k (~8.8× fewer), read_bytes 127MB → 18MB
(~7× less), latency 533ms → 111ms (~4.8× faster), and inference rate
48% → 84% because experiment rows exist independently of trace links.
Test split (#13):
- New DatasetProjectMigrationServiceTest covers all classification and
policy cases (9 tests) by calling runMigrationCycle().block()
directly, mirroring ExperimentProjectMigrationServiceTest. - DatasetProjectMigrationJobTest trimmed to a single happy-path E2E
driven by the scheduler. Combined suite: 10 tests in ~36s (was ~120s).
Audit (no code change, will reply on the threads):
- #5 datasets indexes: existing (workspace_id, name) UK and
(workspace_id, project_id) idx cover both query patterns; orphan
query plan = type=ref on workspace_project_idx. - #9 scheduler placement: every blocking JDBI / projectService /
workspacesService call already has .subscribeOn(migrationScheduler);
the R2DBC Flux from computeDatasetProjectMapping is non-blocking and
the post-collect work is shifted to migrationScheduler via
.publishOn().
- [OPIK-6186] [BE] address PR review follow-ups on COMPUTE_DATASET_PROJECT_MAPPING
Address the 4 unresolved threads on PR #6799:
- ExperimentDAO COMPUTE_DATASET_PROJECT_MAPPING: drop redundant
AND dataset_id != ''predicate (overlaps withdataset_id IN :dataset_idsand dataset_id is part of the PK). - ExperimentDAO.computeDatasetProjectMapping: switch the parameter from
@NonNull Set<UUID>+isEmpty()to plainSet<UUID>+
CollectionUtils.isEmpty()per reviewer preference. - ExperimentDAO row mapper: defensively wrap the
project_idread in
Optional.ofNullable(...).filter(StringUtils::isNotBlank)and emit
viaMono::justOrEmpty, mirroring the experiment-side mapper on the
same DAO. The SQLHAVING experiment_project_id != ''already filters
blanks; this is belt-and-suspenders so a future schema change can't
cause anUUID.fromString("")IAE. The service-level no-inference
bucket already routes filtered-out datasets to the workspace's
Default Project (or auto-provisions viaprojectService.getOrCreate).
Tests:
- DatasetProjectMigrationServiceTest: add two dedicated-workspace
scenarios for the reviewer's edge case where every experiment for a
dataset hasproject_id = ''(D1-pending or D1-ambiguous):- …ToExistingDefaultProject: pre-seeded Default Project; dataset
migrates there. - …AutoCreatingDefaultProject: no Default Project; service
auto-provisions one and migrates the dataset there.
Both prove the empty-project-id rows don't slip through as
certain/ambiguous and that the no-inference fallback covers them.
- …ToExistingDefaultProject: pre-seeded Default Project; dataset
- [OPIK-6187] [BE] feat: optimization project migration job (V1 → V2 auto-inference)
D3 of the V1 → V2 workspace migration. Backfills
optimizations.project_idin
ClickHouse from''(orphan) to a real project via experiments-primary (Path A)
with a cross-DB dataset fallback (Path B). Mirrors D1 (experiments) and D2
(datasets): Quartz job + Managed service + dedicated reactor scheduler +
persisted skip-state. Stacks on top of OPIK-6186 (#6799).- Five-bucket classification: certain-via-experiments, certain-via-dataset,
certain-but-deleted-project → Default, no-inference → Default, ambiguous →
skip. Path A wins on disagreement. - Per-workspace D1/D2 readiness guard, overridable via
optimizationProjectMigration.allowBeforeDependencies=truefor tests. - Trap reasons persisted:
all_ambiguous(active).deleted_project/
default_project_missingkept as defensive constants — after the policy
alignment with D1/D2, neither is emitted by the active code paths. - New diagnostic counter
opik.migration.optimization_project.inference.path {path=experiments|dataset}to size Path B's contribution in production. - Liquibase migration
000076adds
workspaces.optimization_project_migration_skipped_at/_skip_reason+ index. OptimizationDAOqueries: eligibility (CH,argMaxHAVING), Path A
inference (joins experiments), batch INSERT withSELECT * REPLACE.DatasetServiceexposes bulkfindProjectIdsByDatasetIdsfor Path B and
hasVersion1Datasetsfor the readiness probe.- Happy-path E2E test exercising Path A end-to-end through the scheduler.
- refactor(migration): address PR review feedback (OPIK-6187)
Address all 3 unresolved review comments on PR #6828:
-
000078 (renumbered from 000076 after merge): add nearby comment
documenting the workspaces_opt_proj_migration_skipped_idx index
purpose per .agents/skills/opik-backend/migrations.md (#1). -
Extract AbstractProjectMigrationJob: pulls the doJob/interrupt
skeleton — enabled check, interrupt latch, best-effort lock, cycle
timeout, result-tagged histogram, error resume — out of the four
duplicate V1 → V2 jobs (experiment, dataset, optimization, prompt).
Each subclass now provides entity label, metric namespace, config
getters, and the service's runMigrationCycle() delegate. Saves ~360
lines and keeps the lock/metrics/error flow in one place (#2). -
Extract markMigrationSkipped helper in WorkspacesServiceImpl: the
UPDATE-if-null → INSERT → retry-UPDATE flow used by the experiment,
prompt, optimization (and previously dataset) skip-state writers is
now a single private method parameterised by two DAO method refs.
Removes ~85 lines and ensures any future locking fix lands in one
place (#3).
Also drops the orphaned dataset migration skip code from
WorkspacesDAO / WorkspacesService / Workspace because migration 000077
on main dropped the underlying columns when the dataset migration
switched to dominant-project assignment.Tests:
- All 38 migration tests still pass (experiment / dataset /
optimization / prompt × service + job). - Workspaces resource and version tests still pass (75/75).
- refactor(migration): address follow-up PR review feedback (OPIK-6187)
Address the 2 new unresolved review comments on PR #6828:
-
AbstractProjectMigrationJob logging: convert the new "{} migration
job" placeholders to the project's quoted structured style
("entity='{}'"). Reads consistently with the rest of the backend
and removes the lower-cased label helper that only existed to
shoehorn the entity name into a sentence. -
Add AbstractProjectMigrationJobTest covering the three branches
introduced by this refactor that the integration suites don't
isolate today:- isEnabled() == false: bestEffortLock is never invoked and the
cycle stays at zero. - Lock-skipped: bestEffortLock picks the no-lock branch and
runMigrationCycle is never invoked. - Interrupt before processing: interrupting before doJob still
short-circuits before bestEffortLock is called.
Uses a small TestProjectMigrationJob subclass with controllable
AtomicBoolean / AtomicInteger state and Mockito on LockService.
- isEnabled() == false: bestEffortLock is never invoked and the
- test(datasets): lock DatasetType to DATASET in CreateFromTracesTest
The seven assertions in DatasetsResourceCreateFromTracesTest expect the
enriched dataset item's data to contain the wrapped "input" and
"expected_output" keys produced by TraceEnrichmentMapper. PODAM was
manufacturing the helper's Dataset with a random DatasetType, so when it
rolled TEST_SUITE the data was unwrapped to top-level keys by
DatasetItemService.filterDataForDatasetType (introduced in OPIK-5649)
and "containsKey('input')" failed — observed locally as a 2-fail/1-pass
flake and intermittently in CI.Override the type to DATASET in this test's local buildDataset() helper
so the shape stays consistent. The public DatasetResourceClient helper
is left untouched because other suites do rely on the random type.- refactor(migration): extract ProjectMigrationJobConfig interface
Replace the four config getter abstract methods on AbstractProjectMigrationJob
(isEnabled / lockTimeout / lockWaitTime / jobTimeout) with a single
ProjectMigrationJobConfig config()hook. Each entity-specific config record
(Experiment / Dataset / Optimization / Prompt) already had matching method
signatures, so they implement the interface with no boilerplate beyond the
implementsclause. Subclass jobs collapse from five overrides to one.The interface lives in
infrastructure/alongside the config records and
exposes only what the abstract base needs to drive a cycle (enabled flag,
lock timeouts, job timeout). Scheduling-specific fields (interval,
startupDelay,schedulerThreadCap, …) stay on the concrete records and
are read independently byOpikGuiceyLifecycleEventListenerwhen wiring
the Quartz schedule.Test:
- AbstractProjectMigrationJobTest now uses a tiny private record implementing
ProjectMigrationJobConfig backed by an AtomicBoolean for the enabled flag. - Job tests run unchanged — 7/7 (3 abstract + 4 entity-specific job tests).
- refactor(migration): collapse listener scheduler methods via interface
Add interval() and startupDelay() to ProjectMigrationJobConfig and have
all six V1 → V2 migration configs (experiment, dataset, optimization,
prompt, automation rule, alert) implement it. The latter two already had
the matching method signatures, so they just gain theimplements
clause.OpikGuiceyLifecycleEventListener: replace six near-identical
schedule*ProjectMigrationJobIfEnabledmethods (~60 lines) with one
scheduleProjectMigrationJobIfEnabled(label, jobClass, config)helper
that drives every call site through the interface. The body is the same
disabled-log / enabled-schedule check that lived in each method.Net diff: −50 lines in the listener for the same runtime behaviour.
- refactor(migration): extract AbstractProjectMigrationService Managed lifecycle
The four V1 → V2 project-migration services
(Experiment / Dataset / Optimization / Prompt) each held a private
volatile Scheduler migrationSchedulerfield and identical
start()/stop()implementations that built and disposed a bounded-elastic
reactor scheduler with config-driven sizing. Only the scheduler thread name
differed.Extract the scheduler field and the Managed
start()/stop()into a small
AbstractProjectMigrationServicebase class. Subclasses now declare:schedulerName()— the thread-name string (e.g.,
"optimization-project-migration-service")jobConfig()— typically just returns the entity-specific config record,
which now implements ProjectMigrationJobConfig
…and access the live scheduler via
migrationScheduler(). The cycle and
classification logic remains entirely on each subclass.Add the three scheduler knobs (
schedulerThreadCap,
schedulerQueuedTaskCap,schedulerThreadTtl) to
ProjectMigrationJobConfig. All six configs already had matching method
signatures so no records change.Net: −85 lines across the four services for the same runtime behaviour;
42 migration tests still pass.- test: complete TestProjectMigrationJobConfig after interface expansion
Commit 9ce5007 expanded ProjectMigrationJobConfig with interval(),
startupDelay(), schedulerThreadCap(), schedulerQueuedTaskCap(), and
schedulerThreadTtl(), but the test-only TestProjectMigrationJobConfig
record in AbstractProjectMigrationJobTest still only implemented the
original four. CI test-compile failed:TestProjectMigrationJobConfig is not abstract and does not override
abstract method schedulerThreadTtl() in ProjectMigrationJobConfigAdd the missing five overrides with sensible test defaults. The base
class doesn't read interval()/startupDelay()/scheduler* on the cycle
path, so the values are unused by the existing test branches; they
exist purely to satisfy the interface contract.3 abstract-job tests still pass.
- Revert cross-cutting migration abstractions per PR review (OPIK-6187)
Per @andrescrz's review feedback (PR #6828, 2026-05-29 CHANGES_REQUESTED):
the migration jobs should stay independent. These migrations are
short-lived one-time utilities scheduled for removal once installations
move to Opik 2.0, the trapped-state path is being removed in the next PR
(dominant-project resolution everywhere), and the meaningful abstraction
already lives inbestEffortLock. Coupling jobs through an abstract base
violates SRP and introduces merge-conflict risk for changes coming days
later.Reverted:
AbstractProjectMigrationJobandAbstractProjectMigrationJobTest
removed. Each of Experiment / Dataset / Optimization / Prompt jobs
carries its owndoJob/interruptagain, restored to their main
state (Optimization restored to its pre-abstraction shape).AbstractProjectMigrationServiceremoved. Each service owns its
volatile Scheduler migrationSchedulerfield, builds the bounded-elastic
scheduler instart(), and disposes it instop()independently.ProjectMigrationJobConfiginterface removed. The 6 config records
(Experiment / Dataset / Optimization / Prompt / AutomationRule / Alert)
are plain records again, noimplementsclause.WorkspacesServiceImpl.markMigrationSkippedhelper removed. The
mark* methods (experiment, prompt, optimization) each inline the
UPDATE-if-null → INSERT → retry-UPDATE flow as on main.OpikGuiceyLifecycleEventListener.scheduleProjectMigrationJobIfEnabled
helper removed. The 6schedule*ProjectMigrationJobIfEnabledmethods
are independent again.- Restored
OptimizationProjectMigrationJobTest(the original integration
test from this PR).
Kept (still valid, separate from andrescrz's revert ask):
- Migration
000078_add_optimization_migration_columns_to_workspaces.sql
with its index-purpose comment. - The renumbering from 000076 → 000078 and
AFTER prompt_project_migration_skip_reason. Workspacerecord /WorkspacesDAO/WorkspacesServicekeep their
optimization-trap columns and mark/find/count methods (the trap path
itself is going away in andrescrz's next PR; this PR just adds the
columns so the cycle compiles and tests pass).DatasetDAO.findProjectIdsByDatasetIdsfor Path B inference.- The
DatasetsResourceCreateFromTracesTeststability fix.
Tests:
- All 39 migration tests pass (4 job + 4 service + the integration).
mvn compile -DskipTestsclean.mvn spotless:checkclean.
- feat(migration): switch optimization migration to dominant-project (Option A)
Per the "Optimization Project Migration — Options Review" Notion doc, the
ambiguous (Path A ≥ 2 distinct projects) bucket is replaced with
deterministic dominant-project assignment, mirroring the dataset
migration (OPIK-6701). Prod analysis (2026-05-29) shows ambiguity is
small (95 / 4,793 real orphans, 2.0%), decisive (86% clear majority,
avg 6.9 vs 1.1 experiments), and reference-safe (0 deleted dominant
projects). All four buckets now actionable: certain via experiments
(dominant), certain via dataset (Path B), certain-deleted → Default
Project, no-inference → Default Project.The job no longer traps workspaces and the per-workspace
optimization_project_migration_skipped_at/_reason state is removed.Backend changes
- COMPUTE_OPTIMIZATION_PROJECT_MAPPING_VIA_EXPERIMENTS rewritten with
the arraySort(proj -> (-count, -last_activity, project_id)) ranking
used by COMPUTE_DATASET_PROJECT_MAPPING, and now emits a
project_breakdown column ("p1=5,p2=3,…") for log diagnostics. - OptimizationProjectMapping gains projectBreakdown (mirrors
DatasetProjectMapping). - OptimizationProjectMigrationService:
· classifyWithPathAResults: Path A returning any non-zero row is
treated as certain — the SQL picks the dominant project; the
ambiguous branch is gone.
· New optimizations.assigned_to_dominant_project counter
(distinct_project_count label, capped at 50) + recordDominantAssignments
log line per multi-project assignment.
· Removed optimizations.skipped{ambiguous}, cycle.trapped_workspaces
gauge + recordTrappedWorkspacesByReason, RESULT_ALL_SKIPPED_AMBIGUOUS,
TRAPPED_REASON_* constants, KNOWN_TRAP_REASONS, resolveTrapReason,
TrapDecision record, WorkspacesService dependency on the skip
methods.
· finalizeWorkspace no longer marks the workspace skipped on
all-ambiguous remainder (there is no remainder by construction). - Deleted migration 000078_add_optimization_migration_columns_to_workspaces.sql
(trap columns + index removed before they shipped to prod). - Workspace record: removed optimizationProjectMigrationSkippedAt /
optimizationProjectMigrationSkipReason. - WorkspacesService / WorkspacesDAO: removed
markOptimizationProjectMigrationSkipped,
findOptimizationProjectMigrationSkippedWorkspaceIds,
countOptimizationProjectMigrationSkippedByReason
(interface + impl + DAO).
Kept (per the doc)
- Hard D1/D2 readiness guard + allowBeforeDependencies override.
- Demo exclusion (DemoData.OPTIMIZATIONS) + env-excluded workspaces
(MIGRATION_EXCLUDED_WORKSPACE_IDS). - Path B (dataset fallback) via DatasetDAO.findProjectIdsByDatasetIds.
- inference.path counter, optimizations.assigned_to_default counter,
cycle.duration / cycle.eligible_workspaces / cycle.env_excluded_workspaces /
workspace.duration / batch.size. - The integration test (Path A end-to-end via the scheduler) still
passes unchanged.
Tests
- 29 migration tests (optimization + experiment + dataset + prompt
jobs/services) pass. - 85/85 workspace + workspace-version resource tests pass (2 pre-existing
skips). - mvn compile + spotless:apply clean.
Follow-up (not in this PR, per doc decisions §2–§3)
- FE: drop projectId from the v2 optimization trials and Trial-Page
experiment queries so multi-project optimization detail pages list
the complete trial set. - FE polish: align the cross-project dataset URL in OptimizationHeader,
DatasetNameCell, OptimizationConfiguration with the optimization's
project (cosmetic; dataset content already loads correctly).
- test(migration): add OptimizationProjectMigrationServiceTest covering all classification buckets
The optimization migration only had one happy-path integration test
(scheduler-driven, Path A single-project). The dominant-project switch
in 2cdd78e introduced new classification logic with zero direct
coverage, so port the dataset-migration service-test pattern to
optimizations.Twelve tests, all driving
migrationService.runMigrationCycle().block()
directly:- migrateEligibleOptimizationsAcrossWorkspaces — Path A happy path
across two workspaces, one and two orphan optimizations - secondCycleIsNoopAfterSuccessfulMigration — idempotency (BATCH_SET_PROJECT_ID
WHERE project_id = '' guard) - migrateMultiProjectOptimizationToDominantProjectByCount — the core
dominant-project assignment (3 vs 1 experiments, dominant wins) - migrateMultiProjectOptimizationToMostRecentProjectWhenCountTies —
recency tiebreaker (Decision §1 in the Notion doc) - multiProjectOptimizationIgnoresExperimentsWithEmptyProjectId — the
HAVING experiment_project_id != '' filter drops V1 experiments before
the dominant tally - migrateMultiProjectOptimizationToDefaultProjectWhenDominantProjectWasDeleted
— validation drops the deleted dominant; routes to Default Project
(not the surviving lower-count one) - migrateOptimizationWhenInferredProjectWasDeletedToDefaultProject —
single-project Path A whose project is deleted post-seed - migrateOptimizationViaPathBWhenNoExperimentsReferenceIt — Path B
fallback (no experiments → datasets.project_id) - migrateNoInferenceOptimizationToDefaultProject — Path A=∅, Path B=null
→ pre-seeded Default Project - migrateNoInferenceOptimizationWhenDefaultProjectMissingByAutoCreating
— service auto-provisions Default Project via getOrCreate - mixedWorkspaceMigratesAllBucketsIncludingMultiProjectAsDominant —
certain / multi-project dominant / certain-deleted / Path B /
no-inference all in one workspace - skipExcludedWorkspaces — env-excluded (MIGRATION_EXCLUDED_WORKSPACE_IDS)
Workspace-version flip is deliberately not asserted in these tests:
V2 promotion only fires once every entity type (datasets, experiments,
prompts, optimizations) is V2 in the workspace, which is a multi-job
concern this test does not own. The integration test already documented
the same constraint.Tests pass 12/12 (~40s); full migration suite up from 39 → 51 tests
passing.- fix(migration): address baz logical bugs on optimization migration (OPIK-6187)
Two real bugs flagged in the baz review of the dominant-project commit
(2cdd78e):1. Demo experiments not excluded from Path A inference
COMPUTE_OPTIMIZATION_PROJECT_MAPPING_VIA_EXPERIMENTS counted every
experiment row referencing the orphan optimization, including the
seeded demo experiments listed in DemoData.EXPERIMENTS. The dataset
counterpart already excludes them vianame NOT IN :demo_experiment_names; mirror that here so an orphan optimization
whose only referencing experiment is a demo doesn't get pinned to the
demo's project.2. recordDominantAssignments fired before the deleted-project filter
The counteroptimizations.assigned_to_dominant_project{distinct_project_count}
ran in classifyWithPathAResults — before validateAndMigrate dropped
Path A entries whose project was deleted between the CH query and the
MySQL existence check. Move the call into the validated-certain branch,
matching DatasetProjectMigrationService.recordDominantAssignments. The
counter now reflects only the rows that actually keep the dominant
project; rerouted-deleted rows are counted by
optimizations.assigned_to_default{reason=deleted_project}as
intended.Pass
pathAByOptimizationthrough validateAndMigrate so the post-
validation hook can look up the original distinctProjectCount and
projectBreakdown for the log line.Not in this PR (per discussion with @andrescrz):
The sameinterrupted.set(false)reset is missing from every other
project-migration job (Alert / AutomationRule / Dataset /
DatasetVersionItemsTotal / Experiment / Prompt). The disabled-check +
interrupt-latch + lock/timeout/error block is duplicated across all
six. Per the prior review decision to keep migration jobs independent,
the fix here belongs in a dedicated follow-up that consolidates the
shareddoJob/interruptbody rather than patching one job at a time.
Reverting the local optimization-onlyinterrupted.set(false)until
that follow-up lands so the pattern stays consistent across the six.New test:
pathAExcludesDemoExperimentsFromInference— orphan optimization
referenced by a demo-named experiment in projectA + orphan dataset- pre-seeded Default Project. With the fix the optimization routes
to Default Project; without it Path A would pick up projectA.
- pre-seeded Default Project. With the fix the optimization routes
Full optimization suite: 14/14 passing (12 service + 1 demo-exclusion +
1 integration). Spotless clean.- refactor(migration): address andrescrz review on optimization migration (OPIK-6187)
Removes the D1/D2 dependency guard (ordering is enforced by the deployment
runbook, not in code) and tightens the new DAO methods to use the shared
context-aware binders so SYSTEM_USER + workspaceId flow from the service
context rather than being threaded through DAO arguments.- Drop allowBeforeDependencies config flag + every reference (config, tests, javadoc)
- Drop workspaceId/userName parameters from new OptimizationDAO methods; use
makeFluxContextAware(bindWorkspaceIdToFlux(...) / bindUserNameAndWorkspaceContextToStream(...)) - Service callers set the reactor context via setRequestContext(SYSTEM_USER, workspaceId)
- Drop workspace_id from GROUP BY in COMPUTE_OPTIMIZATION_PROJECT_MAPPING_VIA_EXPERIMENTS
- Drop @NonNull on Set params + use CollectionUtils.isEmpty for null-safety
- Delete unused MigrationSkipReasonCount + DatasetService.hasVersion1Datasets
- Rename (a, b) -> a merge function args to (existing, duplicate)
- Add javadoc on the in-flight ReplacingMergeTree dedup case
- refactor(migration): apply CollectionUtils null-safety in findEligibleOptimizationWorkspaces (OPIK-6187)
Drops @NonNull on excludedWorkspaceIds and switches the size + isEmpty
checks to CollectionUtils — matches the pattern andrescrz asked for on
the other new OptimizationDAO methods.下载附件