发布

  • [OPIK-6187] [BE] feat: optimization project migration job (V1 → V2 auto-inference) (#6828)

    frostbyte_neo 发布于 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).
    Sets datasets.project_id from 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, exposes count(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 the cycle.trapped_workspaces gauge so dashboards
      can break down by reason.
    • Write: MySQL UPDATE with WHERE project_id IS NULL idempotency 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_id predicate 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 ASC for 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 DatasetProjectMigrationJobTest covering
    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
      from migration_skipped_at IS NULL to experiment_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
      datasetProjectMigrationSkipReason matches 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, writeBatch private 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 add e.dataset_id IN :dataset_ids so 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, with argMax(project_id, last_updated_at) GROUP BY id to dedup
      across ReplacingMergeTree row versions and HAVING != '' 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 with dataset_id IN :dataset_ids and dataset_id is part of the PK).
    • ExperimentDAO.computeDatasetProjectMapping: switch the parameter from
      @NonNull Set<UUID> + isEmpty() to plain Set<UUID> +
      CollectionUtils.isEmpty() per reviewer preference.
    • ExperimentDAO row mapper: defensively wrap the project_id read in
      Optional.ofNullable(...).filter(StringUtils::isNotBlank) and emit
      via Mono::justOrEmpty, mirroring the experiment-side mapper on the
      same DAO. The SQL HAVING experiment_project_id != '' already filters
      blanks; this is belt-and-suspenders so a future schema change can't
      cause an UUID.fromString("") IAE. The service-level no-inference
      bucket already routes filtered-out datasets to the workspace's
      Default Project (or auto-provisions via projectService.getOrCreate).

    Tests:

    • DatasetProjectMigrationServiceTest: add two dedicated-workspace
      scenarios for the reviewer's edge case where every experiment for a
      dataset has project_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.
    • [OPIK-6187] [BE] feat: optimization project migration job (V1 → V2 auto-inference)

    D3 of the V1 → V2 workspace migration. Backfills optimizations.project_id in
    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=true for tests.
    • Trap reasons persisted: all_ambiguous (active). deleted_project /
      default_project_missing kept 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 000076 adds
      workspaces.optimization_project_migration_skipped_at/_skip_reason + index.
    • OptimizationDAO queries: eligibility (CH, argMax HAVING), Path A
      inference (joins experiments), batch INSERT with SELECT * REPLACE.
    • DatasetService exposes bulk findProjectIdsByDatasetIds for Path B and
      hasVersion1Datasets for 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.
    • 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
    implements clause. 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 by OpikGuiceyLifecycleEventListener when 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 the implements
    clause.

    OpikGuiceyLifecycleEventListener: replace six near-identical
    schedule*ProjectMigrationJobIfEnabled methods (~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 migrationScheduler field 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
    AbstractProjectMigrationService base 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 ProjectMigrationJobConfig

    Add 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 in bestEffortLock. Coupling jobs through an abstract base
    violates SRP and introduces merge-conflict risk for changes coming days
    later.

    Reverted:

    • AbstractProjectMigrationJob and AbstractProjectMigrationJobTest
      removed. Each of Experiment / Dataset / Optimization / Prompt jobs
      carries its own doJob / interrupt again, restored to their main
      state (Optimization restored to its pre-abstraction shape).
    • AbstractProjectMigrationService removed. Each service owns its
      volatile Scheduler migrationScheduler field, builds the bounded-elastic
      scheduler in start(), and disposes it in stop() independently.
    • ProjectMigrationJobConfig interface removed. The 6 config records
      (Experiment / Dataset / Optimization / Prompt / AutomationRule / Alert)
      are plain records again, no implements clause.
    • WorkspacesServiceImpl.markMigrationSkipped helper 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 6 schedule*ProjectMigrationJobIfEnabled methods
      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.
    • Workspace record / WorkspacesDAO / WorkspacesService keep 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.findProjectIdsByDatasetIds for Path B inference.
    • The DatasetsResourceCreateFromTracesTest stability fix.

    Tests:

    • All 39 migration tests pass (4 job + 4 service + the integration).
    • mvn compile -DskipTests clean.
    • mvn spotless:check clean.
    • 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 via name 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 counter optimizations.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 pathAByOptimization through 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 same interrupted.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
    shared doJob/interrupt body rather than patching one job at a time.
    Reverting the local optimization-only interrupted.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.

    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.

    下载附件