diff --git a/agent-service/src/agent/prompts.test.ts b/agent-service/src/agent/prompts.test.ts new file mode 100644 index 00000000000..14df9ac69d5 --- /dev/null +++ b/agent-service/src/agent/prompts.test.ts @@ -0,0 +1,206 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { describe, expect, test } from "bun:test"; +import { buildSystemPrompt } from "./prompts"; +import { WorkflowSystemMetadata } from "./util/workflow-system-metadata"; + +/** + * Construct a fresh, empty metadata store. `buildAllowedOperatorSchemas` will + * yield "No operators available." which is fine — we are only asserting on the + * envelope sections (profiler guide, key principles, UDF guides), not on the + * operator-schema block. + */ +function emptyMetadata(): WorkflowSystemMetadata { + return new (WorkflowSystemMetadata as any)(); +} + +describe("buildSystemPrompt — base content (regression guard)", () => { + test("includes the dataflow primer and key principles", () => { + const prompt = buildSystemPrompt(emptyMetadata()); + expect(prompt).toContain("What is Dataflow?"); + expect(prompt).toContain("Key Principles"); + expect(prompt).toContain("Available Operators"); + }); + + test("renders 'No operators available.' when metadata is empty", () => { + const prompt = buildSystemPrompt(emptyMetadata()); + expect(prompt).toContain("No operators available."); + }); +}); + +describe("buildSystemPrompt — UDF gating", () => { + test("includes Python UDF guide when Python is allowed (default = all)", () => { + const prompt = buildSystemPrompt(emptyMetadata()); + expect(prompt).toContain("## Python UDF Guide"); + }); + + test("includes R UDF guide when R is allowed (default = all)", () => { + const prompt = buildSystemPrompt(emptyMetadata()); + expect(prompt).toContain("## R UDF Guide"); + }); + + test("omits Python UDF guide when the allowed list excludes PythonUDFV2", () => { + const prompt = buildSystemPrompt(emptyMetadata(), ["CSVFileScan"]); + expect(prompt).not.toContain("## Python UDF Guide"); + }); + + test("omits R UDF guide when the allowed list excludes RUDF", () => { + const prompt = buildSystemPrompt(emptyMetadata(), ["CSVFileScan"]); + expect(prompt).not.toContain("## R UDF Guide"); + }); +}); + +describe("buildSystemPrompt — Profiler Guide (Phase 2)", () => { + test("always includes the Profiler Guide section", () => { + const prompt = buildSystemPrompt(emptyMetadata()); + expect(prompt).toContain("## Profiler Guide"); + }); + + test("Profiler Guide is included even when the allowed-operator list is restrictive", () => { + // Profiler tools are always registered regardless of which builder operators + // are exposed, so the guide must not be gated on the allow-list. + const prompt = buildSystemPrompt(emptyMetadata(), ["CSVFileScan"]); + expect(prompt).toContain("## Profiler Guide"); + }); + + test("names every Phase-1 read-only profiler tool", () => { + const prompt = buildSystemPrompt(emptyMetadata()); + expect(prompt).toContain("getProfilerSummary"); + expect(prompt).toContain("listHotOperators"); + expect(prompt).toContain("getOperatorMetrics"); + expect(prompt).toContain("getOptimizationHints"); + expect(prompt).toContain("compareToBaseline"); + }); + + test("names every rule id surfaced by the rule engine", () => { + const prompt = buildSystemPrompt(emptyMetadata()); + expect(prompt).toContain("SCAN_FULL_TABLE_NO_FILTER"); + expect(prompt).toContain("UPSTREAM_OVERPRODUCTION"); + expect(prompt).toContain("JOIN_HIGH_FANIN_LOW_FANOUT"); + expect(prompt).toContain("RUNTIME_OUTLIER"); + expect(prompt).toContain("IDLE_HEAVY"); + expect(prompt).toContain("LOW_PARALLELISM_HOT_OP"); + }); + + test("instructs the agent to use the profiler proactively for performance questions", () => { + const prompt = buildSystemPrompt(emptyMetadata()); + expect(prompt).toContain("Proactively"); + // Heuristic anchor: the guide must talk about slowness / bottlenecks + expect(prompt.toLowerCase()).toContain("slowness"); + expect(prompt.toLowerCase()).toContain("bottleneck"); + }); + + test("instructs the agent to surface proposals via proposeOperatorChange, not modifyOperator", () => { + const prompt = buildSystemPrompt(emptyMetadata()); + // The load-bearing rule of Phase 3 — proposals go through the structured tool, + // not direct mutation. + expect(prompt).toContain("proposeOperatorChange"); + expect(prompt).toContain("Never call `modifyOperator`"); + expect(prompt).toContain("Apply / Reject card"); + }); + + test("documents the required arguments for proposeOperatorChange", () => { + const prompt = buildSystemPrompt(emptyMetadata()); + expect(prompt).toContain("proposeOperatorChange — required arguments"); + expect(prompt).toContain("operatorId"); + expect(prompt).toContain("propertyChanges"); + expect(prompt).toContain("reasoning"); + expect(prompt).toContain("expectedImpact"); + expect(prompt).toContain("firingHints"); + }); + + test("teaches the proactive-call-out example using proposeOperatorChange", () => { + const prompt = buildSystemPrompt(emptyMetadata()); + expect(prompt).toContain("Example — proactive bottleneck call-out (Phase 3 flow)"); + expect(prompt).toContain("Apply or Reject"); + }); + + test("permits multiple independent proposals in a single turn", () => { + const prompt = buildSystemPrompt(emptyMetadata()); + expect(prompt).toContain("Example — multiple independent suggestions"); + expect(prompt).toContain("more than once"); + expect(prompt).toContain("Do NOT bundle"); + }); + + test("teaches proposeOptimizationPlan for related multi-step optimizations (Phase 4)", () => { + const prompt = buildSystemPrompt(emptyMetadata()); + expect(prompt).toContain("Multi-step optimization plans"); + expect(prompt).toContain("proposeOptimizationPlan"); + expect(prompt).toContain("Apply All"); + }); + + test("explains when to use a plan vs single proposals vs multiple proposals", () => { + const prompt = buildSystemPrompt(emptyMetadata()); + expect(prompt).toContain("When to use a plan vs single proposals"); + // The three branches must each be named so the model can choose correctly. + expect(prompt).toContain("RELATED and ORDERED"); + expect(prompt).toContain("INDEPENDENT"); + expect(prompt).toContain("exactly one change"); + }); + + test("documents the required arguments for proposeOptimizationPlan", () => { + const prompt = buildSystemPrompt(emptyMetadata()); + expect(prompt).toContain("proposeOptimizationPlan — required arguments"); + expect(prompt).toContain("planTitle"); + expect(prompt).toContain("planRationale"); + expect(prompt).toContain("steps"); + expect(prompt).toContain("2–10"); + }); + + test("includes a Phase 4 multi-step plan example", () => { + const prompt = buildSystemPrompt(emptyMetadata()); + expect(prompt).toContain("Example — multi-step plan (Phase 4 flow)"); + }); + + test("distinguishes direct user requests (use modifyOperator) from agent-initiated proposals", () => { + const prompt = buildSystemPrompt(emptyMetadata()); + expect(prompt).toContain("Direct user requests are different"); + expect(prompt).toContain("Example — direct user request"); + }); + + test("teaches the no-bottleneck case via a final example", () => { + const prompt = buildSystemPrompt(emptyMetadata()); + expect(prompt).toContain("Example — no bottleneck found"); + }); + + test("tells the agent how to react to NO_DATA from the snapshot tools", () => { + const prompt = buildSystemPrompt(emptyMetadata()); + expect(prompt).toContain("No profiler data available"); + }); +}); + +describe("buildSystemPrompt — ordering invariants", () => { + test("operator schemas appear before the appended guide sections", () => { + const prompt = buildSystemPrompt(emptyMetadata()); + const operatorsIdx = prompt.indexOf("## Available Operators"); + const profilerIdx = prompt.indexOf("## Profiler Guide"); + expect(operatorsIdx).toBeGreaterThanOrEqual(0); + expect(profilerIdx).toBeGreaterThan(operatorsIdx); + }); + + test("Profiler Guide comes after the UDF guides when both are present", () => { + const prompt = buildSystemPrompt(emptyMetadata()); + const pyIdx = prompt.indexOf("## Python UDF Guide"); + const rIdx = prompt.indexOf("## R UDF Guide"); + const profilerIdx = prompt.indexOf("## Profiler Guide"); + expect(profilerIdx).toBeGreaterThan(pyIdx); + expect(profilerIdx).toBeGreaterThan(rIdx); + }); +}); diff --git a/agent-service/src/agent/prompts.ts b/agent-service/src/agent/prompts.ts index 064eed2e3e5..1e43a3b5f4d 100644 --- a/agent-service/src/agent/prompts.ts +++ b/agent-service/src/agent/prompts.ts @@ -22,6 +22,103 @@ import { WorkflowSystemMetadata } from "./util/workflow-system-metadata"; const PYTHON_UDF_OPERATOR_TYPES = ["PythonUDFV2"]; const R_UDF_OPERATOR_TYPES = ["RUDF"]; +const PROFILER_INSTRUCTIONS = `## Profiler Guide + +When the user has run their workflow with the profiler heatmap enabled, a per-message profiler snapshot is attached and the read-only profiler tools become useful for diagnosing performance. + +### Read-only profiler tools + +- \`getProfilerSummary\` — top-level snapshot: view, hottest operator, hint count, baseline status. Always call this first when investigating performance. +- \`listHotOperators\` — top-N hottest operators with full per-operator metrics. +- \`getOperatorMetrics\` — full metrics for a single operator by id. +- \`getOptimizationHints\` — hints fired by the rule engine. Rule ids include: SCAN_FULL_TABLE_NO_FILTER, UPSTREAM_OVERPRODUCTION, JOIN_HIGH_FANIN_LOW_FANOUT, RUNTIME_OUTLIER, IDLE_HEAVY, LOW_PARALLELISM_HOT_OP. +- \`compareToBaseline\` — per-operator deltas (current run vs an uploaded baseline run). + +If a tool returns a "No profiler data available" message, tell the user how to enable profiling (turn on the gauge icon in the run-bar and re-run the workflow) — do not guess about performance without data. + +### When to use the profiler + +- **Proactively**: whenever the user asks about slowness, performance, bottlenecks, optimization, why a workflow is taking long, or anything similar — start by calling \`getProfilerSummary\`. +- **On request**: questions about specific operator runtimes, fired hints, or run-vs-run comparisons. +- **Do NOT use**: for questions about building workflows, schema, data semantics, or operator behavior unrelated to performance. + +### Canonical flow for performance questions + +1. Call \`getProfilerSummary\` to confirm data exists and identify the hottest operator. +2. Call \`getOptimizationHints\` to see what the rule engine flagged. +3. Optionally call \`listHotOperators\` or \`getOperatorMetrics\` for more detail. +4. Summarize findings — cite the operator id, heat score, and the specific hint(s) that fired. +5. If a hint directly supports a mechanical change, call \`proposeOperatorChange\` to surface a structured proposal. The frontend will render an Apply / Reject card next to your message. +6. **Never call \`modifyOperator\` (or any other mutating tool) for profiler-driven suggestions.** \`proposeOperatorChange\` is the only correct channel — the UI's Apply button is the confirmation gate, and it invokes the mutation directly on the user's side without re-asking you. + +### proposeOperatorChange — required arguments + +Every call must provide: +- \`operatorId\`: the exact operator id (not the display name). +- \`propertyChanges\`: a sparse object containing ONLY the keys to change (merge-style; do not echo unchanged properties). +- \`reasoning\`: cites the firing hint(s) by ruleId (e.g. "RUNTIME_OUTLIER and LOW_PARALLELISM_HOT_OP on python-udf-1"). +- \`expectedImpact\`: what the user should see after applying. +- \`firingHints\` (optional but recommended): the ruleIds (e.g. \`["RUNTIME_OUTLIER", "LOW_PARALLELISM_HOT_OP"]\`). + +Only propose changes that fired hints directly support. Do not invent optimizations the rule engine didn't surface. + +### Direct user requests are different + +If the user explicitly says "set workers to 4 on python-udf-1" or similar imperative instruction, call \`modifyOperator\` directly — they have already approved the change in the request itself. \`proposeOperatorChange\` is for agent-initiated suggestions where the user has NOT yet expressed an intent to change anything. + +### Example — proactive bottleneck call-out (Phase 3 flow) + +User: "My workflow feels slow." + +(You call \`getProfilerSummary\` → hottest is \`python-udf-1\` score 0.97; then \`getOptimizationHints\` → \`RUNTIME_OUTLIER\` and \`LOW_PARALLELISM_HOT_OP\` both fire on \`python-udf-1\`. Then you call \`proposeOperatorChange\` with operatorId=\`python-udf-1\`, propertyChanges=\`{ workers: 4 }\`, reasoning="RUNTIME_OUTLIER and LOW_PARALLELISM_HOT_OP both fired on python-udf-1.", expectedImpact="Should cut runtime via more parallelism.", firingHints=\`["RUNTIME_OUTLIER","LOW_PARALLELISM_HOT_OP"]\`.) + +Response: "The Python UDF (\`python-udf-1\`) is your bottleneck — its runtime is far above the workflow median (RUNTIME_OUTLIER), and it's running with only 1 worker while being hot (LOW_PARALLELISM_HOT_OP). I've proposed increasing \`workers\` from 1 to 4 — you can Apply or Reject the proposal below." + +### Example — multiple independent suggestions + +You may call \`proposeOperatorChange\` more than once in a single turn when several distinct operators have independent hints. Each proposal renders its own Apply / Reject card. Do NOT bundle unrelated changes into a single proposal. + +### Multi-step optimization plans (proposeOptimizationPlan) + +When the optimization is a SEQUENCE of related changes that build on each other (e.g. "first push the Filter upstream, then bump the UDF workers, then re-shard"), use \`proposeOptimizationPlan\` instead of multiple \`proposeOperatorChange\` calls. The frontend renders the plan as one card with per-step Apply / Reject buttons plus an "Apply All" button. + +### When to use a plan vs single proposals + +- **Use \`proposeOptimizationPlan\`** when the steps are RELATED and ORDERED — fixing one issue depends on or interacts with another (e.g. push a Filter upstream, then retune the downstream operator's parameters). Minimum 2 steps; maximum 10. +- **Use multiple \`proposeOperatorChange\`** when the suggestions are INDEPENDENT — each operator has its own unrelated hint and the user might want to accept some and reject others without ordering implications. +- **Use a single \`proposeOperatorChange\`** when there is exactly one change to propose. + +### proposeOptimizationPlan — required arguments + +- \`planTitle\`: short title (e.g. "Optimize the Python UDF bottleneck"). +- \`planRationale\`: why these steps together, citing the firing hints. +- \`firingHints\` (optional): ruleIds justifying the plan as a whole. +- \`steps\`: ordered array (length 2–10). Each step has \`operatorId\`, sparse \`propertyChanges\`, \`description\`, \`reasoning\`, \`expectedImpact\`. + +### Example — multi-step plan (Phase 4 flow) + +User: "What can we do to make this faster?" + +(You investigate via \`getProfilerSummary\` and \`getOptimizationHints\` → \`SCAN_FULL_TABLE_NO_FILTER\` on \`csv-scan-1\` and \`LOW_PARALLELISM_HOT_OP\` on \`python-udf-1\`. The fixes are ordered — filtering upstream changes the data volume the UDF sees. You call \`proposeOptimizationPlan\` with planTitle="Reduce Python UDF load", planRationale="SCAN_FULL_TABLE_NO_FILTER and LOW_PARALLELISM_HOT_OP both feed into the same hot path; filtering upstream lowers the working set before we add parallelism.", steps=[{ operatorId: "filter-1", propertyChanges: { predicate: "is not null" }, description: "Add a Filter between csv-scan-1 and python-udf-1", reasoning: "SCAN_FULL_TABLE_NO_FILTER", expectedImpact: "Drops rows before the UDF" }, { operatorId: "python-udf-1", propertyChanges: { workers: 4 }, description: "Bump UDF workers to 4", reasoning: "LOW_PARALLELISM_HOT_OP", expectedImpact: "Parallelizes the remaining work" }].) + +Response: "Two changes will compound here. I've proposed a 2-step plan — apply them in order via the card below." + +### Example — direct user request (use modifyOperator, not propose) + +User: "Set workers to 4 on python-udf-1." + +(You call \`modifyOperator\` directly. No proposal — the user already approved by asking.) + +Response: "Done — \`python-udf-1\` now has 4 workers." + +### Example — no bottleneck found + +User: "Anything slow?" + +(You call \`getProfilerSummary\` → max score 0.34, 0 hints. You do NOT call \`proposeOperatorChange\` — there's nothing to propose.) + +Response: "I checked the profiler — nothing's bottlenecked. The hottest operator (\`csv-scan-1\`) is at a heat score of only 0.34 and no optimization hints fired. The workflow looks healthy."`; + const PYTHON_UDF_INSTRUCTIONS = `## Python UDF Guide Python UDF operators run user-defined Python code. There are 2 APIs to process data: @@ -290,6 +387,10 @@ export function buildSystemPrompt(metadataStore: WorkflowSystemMetadata, allowed const extraSections: string[] = []; if (pythonAllowed) extraSections.push(PYTHON_UDF_INSTRUCTIONS); if (rAllowed) extraSections.push(R_UDF_INSTRUCTIONS); + // Profiler instructions are not gated on allowed operator types — the read-only + // profiler tools are always available regardless of which builder operators + // are exposed to the agent. + extraSections.push(PROFILER_INSTRUCTIONS); const base = SYSTEM_PROMPT_TEMPLATE.replace("{{OPERATOR_SCHEMA}}", operatorSchemas); return extraSections.length > 0 ? `${base}\n${extraSections.join("\n\n")}\n` : base; diff --git a/agent-service/src/agent/proposals/proposal-generators.test.ts b/agent-service/src/agent/proposals/proposal-generators.test.ts new file mode 100644 index 00000000000..fbb7bafe383 --- /dev/null +++ b/agent-service/src/agent/proposals/proposal-generators.test.ts @@ -0,0 +1,240 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { describe, expect, test } from "bun:test"; +import { + FilterPredicatesResponseSchema, + WorkerCountResponseSchema, + proposeFilterPredicate, + proposeWorkerCount, + type FilterPredicatesResponse, + type WorkerCountResponse, +} from "./proposal-generators"; + +/** + * Minimal hand-rolled LanguageModelV2 stub — avoids importing `ai/test`, + * which transitively requires `msw` (a dev-only HTTP mock not installed + * in this project). `generateObject` only ever calls `doGenerate` for + * non-streaming output, so we only need to implement that. + */ +type DoGenerate = (options: any) => Promise<{ + finishReason: string; + usage: { inputTokens: number; outputTokens: number; totalTokens: number }; + content: { type: string; text: string }[]; + warnings: any[]; +}>; + +class StubModel { + readonly specificationVersion = "v2"; + readonly provider = "stub-provider"; + readonly modelId = "stub-model"; + readonly supportedUrls = {}; + readonly doGenerateCalls: any[] = []; + constructor(private readonly _doGenerate: DoGenerate) {} + doGenerate = async (options: any) => { + this.doGenerateCalls.push(options); + return this._doGenerate(options); + }; + doStream = async () => { + throw new Error("doStream not implemented in StubModel"); + }; +} + +function mockJsonModel(payload: unknown) { + const text = JSON.stringify(payload); + return new StubModel(async () => ({ + finishReason: "stop", + usage: { inputTokens: 1, outputTokens: 1, totalTokens: 2 }, + content: [{ type: "text", text }], + warnings: [], + })); +} + +function mockThrowModel() { + return new StubModel(async () => { + throw new Error("boom"); + }); +} + +function captureUserPrompt(model: StubModel): string { + const call = model.doGenerateCalls[0]; + const userMsg = call?.prompt?.find?.((m: any) => m.role === "user"); + if (!userMsg) return ""; + return Array.isArray(userMsg.content) + ? userMsg.content.map((p: any) => p.text ?? "").join("\n") + : String(userMsg.content ?? ""); +} + +describe("proposeFilterPredicate", () => { + test("returns the parsed schema-shaped response on a well-formed model output", async () => { + const payload: FilterPredicatesResponse = { + predicates: [ + { attribute: "country", condition: "=", value: "US" }, + { attribute: "popularity", condition: ">", value: "0.5" }, + ], + reasoning: + "Aggregate downstream groups by country; filtering popularity > 0.5 is a sensible volume reduction.", + }; + const model = mockJsonModel(payload); + const out = await proposeFilterPredicate(model as any, { + upstreamOpId: "csv-scan-1", + downstreamOpId: "agg-1", + upstreamSchema: [ + { attributeName: "country", attributeType: "string" }, + { attributeName: "popularity", attributeType: "double" }, + ], + downstreamType: "Aggregate", + downstreamProperties: { groupByKeys: ["country"] }, + }); + expect(out).toEqual(payload); + }); + + test("the schema accepts the 'is not null' fallback the prompt allows", () => { + const parsed = FilterPredicatesResponseSchema.safeParse({ + predicates: [{ attribute: "id", condition: "is not null", value: "" }], + reasoning: "Could not pick a useful predicate; falling back to is-not-null on a primary-id-like column.", + }); + expect(parsed.success).toBe(true); + }); + + test("the schema rejects an empty predicates array", () => { + const parsed = FilterPredicatesResponseSchema.safeParse({ + predicates: [], + reasoning: "no idea", + }); + expect(parsed.success).toBe(false); + }); + + test("the schema rejects more than 5 predicates", () => { + const five: FilterPredicatesResponse = { + predicates: Array.from({ length: 6 }, (_, i) => ({ + attribute: `col${i}`, + condition: "is not null" as const, + value: "", + })), + reasoning: "too many", + }; + const parsed = FilterPredicatesResponseSchema.safeParse(five); + expect(parsed.success).toBe(false); + }); + + test("the schema rejects an unknown condition value", () => { + const parsed = FilterPredicatesResponseSchema.safeParse({ + predicates: [{ attribute: "x", condition: "starts with", value: "foo" }], + reasoning: "r", + }); + expect(parsed.success).toBe(false); + }); + + test("propagates errors from the model (caller treats any error as a 'miss')", async () => { + await expect( + proposeFilterPredicate(mockThrowModel() as any, { + upstreamOpId: "u", + downstreamOpId: "d", + upstreamSchema: [{ attributeName: "a", attributeType: "string" }], + }) + ).rejects.toThrow(); + }); + + test("includes schema lines + downstream context + sample-row block in the prompt", async () => { + const model = mockJsonModel({ + predicates: [{ attribute: "a", condition: "is not null", value: "" }], + reasoning: "r", + }); + + await proposeFilterPredicate(model as any, { + upstreamOpId: "csv-1", + downstreamOpId: "agg-1", + upstreamSchema: [ + { attributeName: "country", attributeType: "string" }, + { attributeName: "year", attributeType: "int" }, + ], + downstreamType: "Aggregate", + downstreamProperties: { groupByKeys: ["country"] }, + upstreamSamples: [{ country: "US", year: 2020 }], + }); + + const prompt = captureUserPrompt(model); + expect(prompt).toContain("csv-1"); + expect(prompt).toContain("country (string)"); + expect(prompt).toContain("year (int)"); + expect(prompt).toContain("Aggregate"); + expect(prompt).toContain('"groupByKeys":["country"]'); + expect(prompt).toContain('"country":"US"'); + }); +}); + +describe("proposeWorkerCount", () => { + test("returns the parsed worker count on a well-formed model output", async () => { + const payload: WorkerCountResponse = { + workers: 6, + reasoning: "Long runtime + low idle ratio on a Python UDF.", + }; + const model = mockJsonModel(payload); + const out = await proposeWorkerCount(model as any, { + operatorId: "python-udf-1", + operatorType: "PythonUDFV2", + currentWorkers: 1, + runtimeMs: 25_000, + idleRatio: 0.1, + inputRows: 5_000_000, + }); + expect(out).toEqual(payload); + }); + + test("the schema enforces an integer in [1, 64]", () => { + expect(WorkerCountResponseSchema.safeParse({ workers: 0, reasoning: "x" }).success).toBe(false); + expect(WorkerCountResponseSchema.safeParse({ workers: 65, reasoning: "x" }).success).toBe(false); + expect(WorkerCountResponseSchema.safeParse({ workers: 4.5, reasoning: "x" }).success).toBe(false); + expect(WorkerCountResponseSchema.safeParse({ workers: 4, reasoning: "x" }).success).toBe(true); + }); + + test("the schema rejects missing reasoning", () => { + expect(WorkerCountResponseSchema.safeParse({ workers: 4 }).success).toBe(false); + }); + + test("includes operator type + runtime + idle ratio in the prompt", async () => { + const model = mockJsonModel({ workers: 4, reasoning: "r" }); + await proposeWorkerCount(model as any, { + operatorId: "udf-1", + operatorType: "PythonUDFV2", + currentWorkers: 1, + runtimeMs: 12_000, + idleRatio: 0.05, + }); + const prompt = captureUserPrompt(model); + expect(prompt).toContain("udf-1"); + expect(prompt).toContain("PythonUDFV2"); + expect(prompt).toContain("12000 ms"); + expect(prompt).toContain("Idle ratio: 0.05"); + }); + + test("handles missing optional metrics with 'unknown' placeholders in the prompt", async () => { + const model = mockJsonModel({ workers: 4, reasoning: "r" }); + await proposeWorkerCount(model as any, { + operatorId: "x", + operatorType: "Sort", + currentWorkers: 1, + }); + const prompt = captureUserPrompt(model); + expect(prompt).toContain("Runtime: unknown"); + expect(prompt).toContain("Idle ratio: unknown"); + expect(prompt).toContain("Input rows: unknown"); + }); +}); diff --git a/agent-service/src/agent/proposals/proposal-generators.ts b/agent-service/src/agent/proposals/proposal-generators.ts new file mode 100644 index 00000000000..f5a1cb88a79 --- /dev/null +++ b/agent-service/src/agent/proposals/proposal-generators.ts @@ -0,0 +1,204 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { generateObject, type LanguageModel } from "ai"; +import { z } from "zod"; + +/** + * Deferred items from profiler-agent-tool-plan.md: smarter ghost-suggestion + * materialization. The frontend's static rule-based defaults (first column, + * 4 workers) are safe but uninformed. These generators ask the LLM for an + * informed value via schema-constrained structured output (no ReAct loop, + * no tools — one LLM call, returns JSON matching the Zod schema). + * + * Caller policy: any thrown error is a "miss" — the frontend keeps its + * rule-based default. We do not retry; the agent is enhancement, not + * load-bearing. + */ + +const FILTER_CONDITION_VALUES = [ + "=", + "!=", + ">", + ">=", + "<", + "<=", + "is null", + "is not null", + "contains", + "does not contain", + "regex", +] as const; + +/** The frontend's filter-predicate row shape. */ +export const FilterPredicateSchema = z.object({ + attribute: z.string().min(1).describe("Column name from the upstream schema."), + condition: z.enum(FILTER_CONDITION_VALUES).describe("Filter condition."), + value: z + .string() + .describe( + "Filter value. Empty string is allowed and required for 'is null' / 'is not null' conditions." + ), +}); + +export const FilterPredicatesResponseSchema = z.object({ + predicates: z + .array(FilterPredicateSchema) + .min(1) + .max(5) + .describe("Ordered list of predicate rows the user will see pre-filled."), + reasoning: z + .string() + .min(1) + .describe("One-line rationale citing why these columns / conditions are likely useful."), +}); + +export type FilterPredicate = z.infer; +export type FilterPredicatesResponse = z.infer; + +export interface ProposeFilterPredicateInput { + upstreamOpId: string; + downstreamOpId: string; + /** Output columns of the upstream operator: name + type. Required. */ + upstreamSchema: { attributeName: string; attributeType: string }[]; + /** Operator type of the downstream operator (e.g. "Aggregate"). Optional. */ + downstreamType?: string; + /** Downstream operator properties for context (e.g. groupBy keys). Optional. */ + downstreamProperties?: Record; + /** Optional sample rows for grounding (omit if expensive to fetch). */ + upstreamSamples?: Record[]; +} + +const FILTER_PREDICATE_SYSTEM_PROMPT = `You are a data-pipeline assistant that suggests useful Filter predicates for an Apache Texera dataflow. + +Given an upstream operator's output schema (and optionally sample rows + the downstream operator's context), propose 1 to 5 filter predicate rows that would plausibly be useful to the user. + +Rules: +- Each predicate has: attribute (column name from the upstream schema), condition (one of the enum values), value. +- Use empty string for value when condition is 'is null' or 'is not null'. +- Prefer predicates that reduce data volume meaningfully without being too aggressive. +- If the downstream is an Aggregate/groupBy, prefer predicates on columns that are NOT in the groupBy keys (filtering by group key is usually a no-op for the downstream). +- If sample values are provided, ground your value choices in them (avoid hallucinating unseen values). +- If you cannot pick a useful predicate, return a single "is not null" predicate on the most semantically meaningful non-null-looking column — that's still better than the rule-based default of picking the first column blindly. +- Return AT MOST 5 predicates, ordered by likely usefulness.`; + +export async function proposeFilterPredicate( + model: LanguageModel, + input: ProposeFilterPredicateInput +): Promise { + const schemaLines = input.upstreamSchema + .map(c => `- ${c.attributeName} (${c.attributeType})`) + .join("\n"); + const samplesBlock = + input.upstreamSamples && input.upstreamSamples.length > 0 + ? `Sample rows (up to 5):\n${input.upstreamSamples + .slice(0, 5) + .map(r => JSON.stringify(r)) + .join("\n")}` + : "Sample rows: not available."; + const downstreamBlock = input.downstreamType + ? `Downstream operator: ${input.downstreamType}${ + input.downstreamProperties + ? ` with properties ${JSON.stringify(input.downstreamProperties)}` + : "" + }` + : "Downstream operator: unspecified."; + + const userPrompt = `Upstream operator: ${input.upstreamOpId} +Upstream output schema: +${schemaLines} + +${downstreamBlock} + +${samplesBlock} + +Propose 1 to 5 useful Filter predicates.`; + + const result = await generateObject({ + model, + system: FILTER_PREDICATE_SYSTEM_PROMPT, + prompt: userPrompt, + schema: FilterPredicatesResponseSchema, + temperature: 0.1, + }); + return result.object; +} + +/** Response shape for proposeWorkerCount. */ +export const WorkerCountResponseSchema = z.object({ + workers: z + .number() + .int() + .min(1) + .max(64) + .describe("Proposed number of parallel workers for the operator (1..64)."), + reasoning: z + .string() + .min(1) + .describe( + "One-line rationale citing runtime, idle ratio, operator type, or data size that informed the choice." + ), +}); + +export type WorkerCountResponse = z.infer; + +export interface ProposeWorkerCountInput { + operatorId: string; + operatorType: string; + currentWorkers: number; + runtimeMs?: number | null; + idleRatio?: number | null; + inputRows?: number | null; + outputRows?: number | null; +} + +const WORKER_COUNT_SYSTEM_PROMPT = `You are a performance-tuning assistant for Apache Texera dataflow operators. + +Given an operator's current metrics, propose a worker count (parallelism) that would likely reduce its runtime. Output JSON matching the provided schema. + +Rules: +- For Python UDF or other CPU-bound operators with low idle ratio, parallelism scales well — propose 4 to 8 workers depending on runtime. +- For high idle ratio (>0.5), the operator is upstream-bound; parallelism won't help much — propose at most 2. +- For Sort, Aggregate, or other inherently serial / coordinator-heavy operators, conservative (1 to 2). +- Never propose more than 8 unless runtime is extreme (> 30s) AND idle ratio is low (< 0.3). +- The current rule-based default is 4. Beat it when you have signal; otherwise return 4.`; + +export async function proposeWorkerCount( + model: LanguageModel, + input: ProposeWorkerCountInput +): Promise { + const metricsLines = [ + `Operator id: ${input.operatorId}`, + `Operator type: ${input.operatorType}`, + `Current workers: ${input.currentWorkers}`, + input.runtimeMs != null ? `Runtime: ${input.runtimeMs} ms` : "Runtime: unknown", + input.idleRatio != null ? `Idle ratio: ${input.idleRatio.toFixed(2)}` : "Idle ratio: unknown", + input.inputRows != null ? `Input rows: ${input.inputRows}` : "Input rows: unknown", + input.outputRows != null ? `Output rows: ${input.outputRows}` : "Output rows: unknown", + ].join("\n"); + + const result = await generateObject({ + model, + system: WORKER_COUNT_SYSTEM_PROMPT, + prompt: `Propose a worker count for the operator below.\n\n${metricsLines}`, + schema: WorkerCountResponseSchema, + temperature: 0.1, + }); + return result.object; +} diff --git a/agent-service/src/agent/texera-agent.ts b/agent-service/src/agent/texera-agent.ts index 37eb12d8688..a72a5eb1290 100644 --- a/agent-service/src/agent/texera-agent.ts +++ b/agent-service/src/agent/texera-agent.ts @@ -47,6 +47,8 @@ import { TOOL_NAME_EXECUTE_OPERATOR, type ExecutionConfig, } from "./tools/workflow-execution-tools"; +import { createProfilerTools } from "./tools/profiler-tools"; +import { createProposalTools } from "./tools/proposal-tools"; import { assembleContext } from "./util/context-utils"; import { compileWorkflowAsync, type WorkflowCompilationResponse } from "../api/compile-api"; import { createLogger } from "../logger"; @@ -123,6 +125,14 @@ export class TexeraAgent { private workflowChangeSubscription: Subscription | null = null; + /** + * Per-message profiler snapshot, set at the start of each `sendMessage` from the + * frontend's WebSocket payload. Profiler tools read it lazily via a getter passed + * to `createProfilerTools` so they always see the *current* snapshot, even though + * the tool instances are created once at agent construction. + */ + private currentProfilerSnapshot: unknown = undefined; + private log: Logger; constructor(config: TexeraAgentConfig) { @@ -228,6 +238,9 @@ export class TexeraAgent { ); } + Object.assign(tools, createProfilerTools(() => this.currentProfilerSnapshot)); + Object.assign(tools, createProposalTools()); + return tools; } @@ -485,10 +498,18 @@ export class TexeraAgent { this.workflowState.addSubscription(subscription); } - async sendMessage(userMessage: string, messageSource?: "chat" | "feedback"): Promise { + async sendMessage( + userMessage: string, + messageSource?: "chat" | "feedback", + profilerSnapshot?: unknown + ): Promise { const messageId = `msg-${this.agentId}-${++this.messageCounter}-${Date.now()}`; let stepIndex = 0; + // Store the per-message profiler snapshot so profiler tools can read it via + // their getter callback. Cleared at end of message to avoid stale reuse. + this.currentProfilerSnapshot = profilerSnapshot; + await this.refreshWorkflowFromBackend(); this.abortController = new AbortController(); diff --git a/agent-service/src/agent/tools/profiler-tools.test.ts b/agent-service/src/agent/tools/profiler-tools.test.ts new file mode 100644 index 00000000000..1a536e5683f --- /dev/null +++ b/agent-service/src/agent/tools/profiler-tools.test.ts @@ -0,0 +1,315 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { describe, expect, test } from "bun:test"; +import { + createCompareToBaselineTool, + createGetOperatorMetricsTool, + createGetOptimizationHintsTool, + createGetProfilerSummaryTool, + createListHotOperatorsTool, + createProfilerTools, + parseSnapshot, +} from "./profiler-tools"; + +/** + * Minimal snapshot matching the shape produced by the frontend's `buildProfilerSnapshot`. + * Real frontend snapshots include more fields; tests pass the minimum needed per case. + */ +function makeSnapshot(overrides: any = {}) { + return { + header: { + enabled: true, + view: "runtime", + hotThresholdPercentile: 80, + operatorCount: 2, + generatedAt: "2026-05-15T12:00:00.000Z", + ...overrides.header, + }, + operators: overrides.operators ?? [ + { + operatorId: "op-1", + displayName: "Python UDF", + operatorType: "PythonUDFV2", + score: 0.97, + runtimeMs: 2710, + throughputRowsPerSec: 1714, + inputRows: 4645, + outputRows: 4645, + inputSize: 21000000, + outputSize: 23000000, + workers: 1, + idleRatio: 0.64, + }, + { + operatorId: "op-2", + displayName: "Aggregate", + operatorType: "Aggregate", + score: 0.12, + runtimeMs: 80, + throughputRowsPerSec: 12000, + inputRows: 4645, + outputRows: 11, + inputSize: null, + outputSize: null, + workers: 1, + idleRatio: null, + }, + ], + hintsByOperator: overrides.hintsByOperator ?? [ + { + operatorId: "op-1", + displayName: "Python UDF", + hints: [ + { + ruleId: "RUNTIME_OUTLIER", + severity: "warning", + message: "Runtime is 7.8× the median across operators — likely the workflow bottleneck.", + }, + ], + }, + ], + baseline: overrides.baseline, + }; +} + +describe("parseSnapshot", () => { + test("returns undefined for non-object inputs", () => { + expect(parseSnapshot(undefined)).toBeUndefined(); + expect(parseSnapshot(null)).toBeUndefined(); + expect(parseSnapshot("string")).toBeUndefined(); + expect(parseSnapshot(42)).toBeUndefined(); + }); + + test("returns undefined when header or operators are missing", () => { + expect(parseSnapshot({ operators: [] })).toBeUndefined(); + expect(parseSnapshot({ header: {} })).toBeUndefined(); + }); + + test("returns the snapshot when shape is recognized", () => { + const snap = makeSnapshot(); + expect(parseSnapshot(snap)).toBe(snap); + }); +}); + +describe("getProfilerSummary", () => { + test("returns NO_DATA_MSG when no snapshot is available", async () => { + const t = createGetProfilerSummaryTool(() => undefined); + const result = (await t.execute!({} as any, {} as any)) as string; + expect(result).toContain("No profiler data available"); + }); + + test("returns a structured summary including the hottest operator", async () => { + const snap = makeSnapshot(); + const t = createGetProfilerSummaryTool(() => snap); + const result = (await t.execute!({} as any, {} as any)) as string; + const parsed = JSON.parse(result); + expect(parsed.view).toBe("runtime"); + expect(parsed.operatorCount).toBe(2); + expect(parsed.hintsCount).toBe(1); + expect(parsed.baselineLoaded).toBe(false); + expect(parsed.hottestOperator.operatorId).toBe("op-1"); + expect(parsed.hottestOperator.score).toBe(0.97); + expect(parsed.totalRuntimeMs).toBe(2790); + }); + + test("reflects baseline-loaded state in the summary", async () => { + const snap = makeSnapshot({ + baseline: { + header: { workflowName: "Prev Run", executionName: null, generatedAt: "x" }, + deltas: [], + }, + }); + const t = createGetProfilerSummaryTool(() => snap); + const result = (await t.execute!({} as any, {} as any)) as string; + const parsed = JSON.parse(result); + expect(parsed.baselineLoaded).toBe(true); + expect(parsed.baselineWorkflow).toBe("Prev Run"); + }); +}); + +describe("listHotOperators", () => { + test("returns top-N sorted (snapshot is pre-sorted by score desc)", async () => { + const snap = makeSnapshot(); + const t = createListHotOperatorsTool(() => snap); + const result = (await t.execute!({ limit: 1 } as any, {} as any)) as string; + const parsed = JSON.parse(result); + expect(parsed).toHaveLength(1); + expect(parsed[0].operatorId).toBe("op-1"); + }); + + test("defaults to limit=5 when no limit is provided", async () => { + const snap = makeSnapshot(); + const t = createListHotOperatorsTool(() => snap); + const result = (await t.execute!({} as any, {} as any)) as string; + const parsed = JSON.parse(result); + // snapshot only has 2; the limit just caps the slice + expect(parsed).toHaveLength(2); + }); + + test("returns NO_DATA_MSG when no snapshot", async () => { + const t = createListHotOperatorsTool(() => undefined); + const result = (await t.execute!({} as any, {} as any)) as string; + expect(result).toContain("No profiler data available"); + }); +}); + +describe("getOperatorMetrics", () => { + test("returns the operator when found", async () => { + const snap = makeSnapshot(); + const t = createGetOperatorMetricsTool(() => snap); + const result = (await t.execute!({ operatorId: "op-2" } as any, {} as any)) as string; + const parsed = JSON.parse(result); + expect(parsed.operatorId).toBe("op-2"); + expect(parsed.runtimeMs).toBe(80); + }); + + test("returns an error result when the operator is not found", async () => { + const snap = makeSnapshot(); + const t = createGetOperatorMetricsTool(() => snap); + const result = (await t.execute!({ operatorId: "missing" } as any, {} as any)) as string; + expect(result).toContain("[ERROR]"); + expect(result).toContain("missing"); + }); + + test("returns NO_DATA_MSG when no snapshot", async () => { + const t = createGetOperatorMetricsTool(() => undefined); + const result = (await t.execute!({ operatorId: "op-1" } as any, {} as any)) as string; + expect(result).toContain("No profiler data available"); + }); +}); + +describe("getOptimizationHints", () => { + test("returns all hints when no operatorId is given", async () => { + const snap = makeSnapshot(); + const t = createGetOptimizationHintsTool(() => snap); + const result = (await t.execute!({} as any, {} as any)) as string; + const parsed = JSON.parse(result); + expect(parsed).toHaveLength(1); + expect(parsed[0].operatorId).toBe("op-1"); + }); + + test("filters to a single operator when operatorId is given", async () => { + const snap = makeSnapshot(); + const t = createGetOptimizationHintsTool(() => snap); + const result = (await t.execute!({ operatorId: "op-1" } as any, {} as any)) as string; + const parsed = JSON.parse(result); + expect(parsed).toHaveLength(1); + expect(parsed[0].hints[0].ruleId).toBe("RUNTIME_OUTLIER"); + }); + + test("returns friendly text when no hints fired for the requested operator", async () => { + const snap = makeSnapshot(); + const t = createGetOptimizationHintsTool(() => snap); + const result = (await t.execute!({ operatorId: "op-2" } as any, {} as any)) as string; + expect(result).toContain("No optimization hints fired for operator 'op-2'"); + }); + + test("returns friendly text when nothing fired across the workflow", async () => { + const snap = makeSnapshot({ hintsByOperator: [] }); + const t = createGetOptimizationHintsTool(() => snap); + const result = (await t.execute!({} as any, {} as any)) as string; + expect(result).toContain("No optimization hints fired across the workflow"); + }); +}); + +describe("compareToBaseline", () => { + test("returns a friendly message when no baseline is loaded", async () => { + const snap = makeSnapshot(); + const t = createCompareToBaselineTool(() => snap); + const result = (await t.execute!({} as any, {} as any)) as string; + expect(result).toContain("No baseline loaded"); + }); + + test("returns all deltas + baseline header when baseline is loaded", async () => { + const snap = makeSnapshot({ + baseline: { + header: { + workflowName: "Prev Run", + executionName: "run-1", + generatedAt: "2026-05-14T00:00:00Z", + }, + deltas: [ + { + operatorId: "op-1", + displayName: "Python UDF", + matchStatus: "matched", + direction: "improved", + runtimeMsDelta: -55, + throughputRowsPerSecDelta: 400, + outputRowsDelta: 0, + inputRowsDelta: 0, + scoreDelta: 0.29, + }, + ], + }, + }); + const t = createCompareToBaselineTool(() => snap); + const result = (await t.execute!({} as any, {} as any)) as string; + const parsed = JSON.parse(result); + expect(parsed.baselineWorkflow).toBe("Prev Run"); + expect(parsed.baselineExecution).toBe("run-1"); + expect(parsed.deltas).toHaveLength(1); + expect(parsed.deltas[0].direction).toBe("improved"); + }); + + test("filters deltas by operatorId when provided", async () => { + const snap = makeSnapshot({ + baseline: { + header: { workflowName: "Prev", executionName: null, generatedAt: "x" }, + deltas: [ + { operatorId: "op-1", direction: "improved" } as any, + { operatorId: "op-2", direction: "regressed" } as any, + ], + }, + }); + const t = createCompareToBaselineTool(() => snap); + const result = (await t.execute!({ operatorId: "op-2" } as any, {} as any)) as string; + const parsed = JSON.parse(result); + expect(parsed.deltas).toHaveLength(1); + expect(parsed.deltas[0].operatorId).toBe("op-2"); + }); +}); + +describe("createProfilerTools (factory)", () => { + test("returns all 5 expected tools keyed by their TOOL_NAME constants", () => { + const tools = createProfilerTools(() => undefined); + expect(Object.keys(tools).sort()).toEqual( + [ + "compareToBaseline", + "getOperatorMetrics", + "getOptimizationHints", + "getProfilerSummary", + "listHotOperators", + ].sort() + ); + }); + + test("getter is called lazily — different calls see different snapshots", async () => { + let currentSnap: any = makeSnapshot(); + const tools = createProfilerTools(() => currentSnap); + const summaryBefore = (await tools.getProfilerSummary.execute!({} as any, {} as any)) as string; + expect(JSON.parse(summaryBefore).operatorCount).toBe(2); + + // Mutate underlying value — same tool instance, fresh result. + currentSnap = makeSnapshot({ header: { operatorCount: 99 } }); + const summaryAfter = (await tools.getProfilerSummary.execute!({} as any, {} as any)) as string; + expect(JSON.parse(summaryAfter).operatorCount).toBe(99); + }); +}); diff --git a/agent-service/src/agent/tools/profiler-tools.ts b/agent-service/src/agent/tools/profiler-tools.ts new file mode 100644 index 00000000000..a84af45fe54 --- /dev/null +++ b/agent-service/src/agent/tools/profiler-tools.ts @@ -0,0 +1,299 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { tool } from "ai"; +import { z } from "zod"; +import { createErrorResult, createToolResult } from "./tools-utility"; + +/** + * Phase 1 of the profiler-agent-tool plan: five read-only tools that let the agent + * answer questions like "why is my workflow slow?" by inspecting the per-message + * profiler snapshot the frontend ships alongside each chat message. + * + * All snapshot math (scores, hints, baseline deltas) is pre-computed on the + * frontend (`profiler-snapshot.ts`). These tools just slice / filter / sort. + * + * The snapshot is passed in via a getter callback rather than a direct value so + * the tool instances created once at agent boot always read the *current* + * snapshot — `TexeraAgent` updates the underlying field per-message in + * `sendMessage`. + */ + +export const TOOL_NAME_GET_PROFILER_SUMMARY = "getProfilerSummary"; +export const TOOL_NAME_LIST_HOT_OPERATORS = "listHotOperators"; +export const TOOL_NAME_GET_OPERATOR_METRICS = "getOperatorMetrics"; +export const TOOL_NAME_GET_OPTIMIZATION_HINTS = "getOptimizationHints"; +export const TOOL_NAME_COMPARE_TO_BASELINE = "compareToBaseline"; + +const NO_DATA_MSG = + "No profiler data available. Ask the user to turn on the Profiler heatmap (gauge icon in the run-bar) and re-run the workflow, then try again."; + +/** + * Snapshot shape — defensively-typed mirror of the frontend's `ProfilerSnapshot`. + * Fields that may be missing are `unknown` here so we can validate at the read site. + */ +interface ParsedSnapshot { + header: { + enabled: boolean; + view: string; + hotThresholdPercentile: number; + operatorCount: number; + generatedAt: string; + }; + operators: ParsedOperator[]; + hintsByOperator: ParsedHintEntry[]; + baseline?: { + header: { + workflowName: string; + executionName: string | null; + generatedAt: string; + }; + deltas: ParsedDelta[]; + }; +} + +interface ParsedOperator { + operatorId: string; + displayName: string; + operatorType: string | null; + score: number; + runtimeMs: number | null; + throughputRowsPerSec: number | null; + inputRows: number; + outputRows: number; + inputSize: number | null; + outputSize: number | null; + workers: number | null; + idleRatio: number | null; +} + +interface ParsedHintEntry { + operatorId: string; + displayName: string; + hints: { ruleId: string; severity: string; message: string }[]; +} + +interface ParsedDelta { + operatorId: string; + displayName: string; + matchStatus: string; + direction: string; + runtimeMsDelta: number | null; + throughputRowsPerSecDelta: number | null; + outputRowsDelta: number | null; + inputRowsDelta: number | null; + scoreDelta: number | null; +} + +/** + * Defensive parse of the raw snapshot blob. Returns `undefined` for any shape + * we don't recognize — caller surfaces NO_DATA_MSG. + */ +export function parseSnapshot(raw: unknown): ParsedSnapshot | undefined { + if (!raw || typeof raw !== "object") return undefined; + const obj = raw as Record; + const header = obj.header as Record | undefined; + const operators = obj.operators; + if (!header || !Array.isArray(operators)) return undefined; + return raw as ParsedSnapshot; +} + +export function createGetProfilerSummaryTool(getSnapshot: () => unknown) { + return tool({ + description: + "Returns a one-shot overview of the profiler state for the current workflow run: " + + "current view (runtime/throughput/io-imbalance/delta), hot-threshold percentile, " + + "operator count, the single hottest operator (id + display name + score), " + + "the total number of fired optimization hints, and whether a baseline is loaded for comparison. " + + "Call this first to know whether profiler data is even available before drilling into specifics.", + inputSchema: z.object({}), + execute: async () => { + const snap = parseSnapshot(getSnapshot()); + if (!snap) return createToolResult(NO_DATA_MSG); + const hottest = snap.operators[0]; + const totalRuntimeMs = snap.operators.reduce( + (sum, op) => sum + (op.runtimeMs ?? 0), + 0 + ); + const totalHints = snap.hintsByOperator.reduce((sum, h) => sum + h.hints.length, 0); + return createToolResult( + JSON.stringify({ + enabled: snap.header.enabled, + view: snap.header.view, + hotThresholdPercentile: snap.header.hotThresholdPercentile, + operatorCount: snap.header.operatorCount, + totalRuntimeMs, + hintsCount: totalHints, + baselineLoaded: !!snap.baseline, + baselineWorkflow: snap.baseline?.header.workflowName ?? null, + hottestOperator: hottest + ? { + operatorId: hottest.operatorId, + displayName: hottest.displayName, + operatorType: hottest.operatorType, + score: hottest.score, + runtimeMs: hottest.runtimeMs, + } + : null, + generatedAt: snap.header.generatedAt, + }) + ); + }, + }); +} + +export function createListHotOperatorsTool(getSnapshot: () => unknown) { + return tool({ + description: + "Returns the top-N hottest operators (sorted by heat score descending). " + + "Default N = 5. Each entry includes full per-operator metrics: " + + "score, runtimeMs, throughputRowsPerSec, inputRows, outputRows, inputSize, outputSize, " + + "workers, idleRatio. Use this when the user asks 'what's slow' or 'which operators are the bottleneck'.", + inputSchema: z.object({ + limit: z + .number() + .int() + .min(1) + .max(50) + .optional() + .describe("Max operators to return (default 5, max 50)."), + }), + execute: async args => { + const snap = parseSnapshot(getSnapshot()); + if (!snap) return createToolResult(NO_DATA_MSG); + const n = args.limit ?? 5; + const top = snap.operators.slice(0, n); + return createToolResult(JSON.stringify(top)); + }, + }); +} + +export function createGetOperatorMetricsTool(getSnapshot: () => unknown) { + return tool({ + description: + "Returns the full per-operator metrics for a single operator id. " + + "Use this when the user asks about a specific operator by id or display name shown in a prior tool result. " + + "Returns an error message if the operator is not in the snapshot.", + inputSchema: z.object({ + operatorId: z.string().describe("The exact operatorId (not the display name)."), + }), + execute: async args => { + const snap = parseSnapshot(getSnapshot()); + if (!snap) return createToolResult(NO_DATA_MSG); + const op = snap.operators.find(o => o.operatorId === args.operatorId); + if (!op) { + return createErrorResult( + `Operator '${args.operatorId}' is not in the profiler snapshot. Use listHotOperators to see available operator ids.` + ); + } + return createToolResult(JSON.stringify(op)); + }, + }); +} + +export function createGetOptimizationHintsTool(getSnapshot: () => unknown) { + return tool({ + description: + "Returns the optimization hints fired by the profiler rule engine. " + + "When 'operatorId' is provided, returns only hints for that operator; otherwise returns all hints across the workflow. " + + "Each hint has a ruleId (SCAN_FULL_TABLE_NO_FILTER, UPSTREAM_OVERPRODUCTION, " + + "JOIN_HIGH_FANIN_LOW_FANOUT, RUNTIME_OUTLIER, IDLE_HEAVY, LOW_PARALLELISM_HOT_OP), " + + "a severity (warning/info), and a human-readable message. " + + "Use this to explain *why* an operator is hot and what the engine recommends.", + inputSchema: z.object({ + operatorId: z + .string() + .optional() + .describe("If set, return only hints for this operator. Otherwise return all hints."), + }), + execute: async args => { + const snap = parseSnapshot(getSnapshot()); + if (!snap) return createToolResult(NO_DATA_MSG); + const filtered = args.operatorId + ? snap.hintsByOperator.filter(h => h.operatorId === args.operatorId) + : snap.hintsByOperator; + if (filtered.length === 0) { + return createToolResult( + args.operatorId + ? `No optimization hints fired for operator '${args.operatorId}'.` + : "No optimization hints fired across the workflow." + ); + } + return createToolResult(JSON.stringify(filtered)); + }, + }); +} + +export function createCompareToBaselineTool(getSnapshot: () => unknown) { + return tool({ + description: + "Returns per-operator deltas (current run vs the user's uploaded baseline run). " + + "Each delta includes matchStatus (matched/new-in-current/removed-since-baseline), " + + "direction (improved/regressed/unchanged/n/a), and signed numeric deltas for " + + "runtimeMs, throughputRowsPerSec, outputRows, inputRows, scoreDelta. " + + "Returns a no-data message if the user hasn't uploaded a baseline. " + + "When 'operatorId' is set, returns only that operator's delta; otherwise returns all.", + inputSchema: z.object({ + operatorId: z + .string() + .optional() + .describe("If set, return only this operator's delta. Otherwise return all deltas."), + }), + execute: async args => { + const snap = parseSnapshot(getSnapshot()); + if (!snap) return createToolResult(NO_DATA_MSG); + if (!snap.baseline) { + return createToolResult( + "No baseline loaded. Ask the user to upload a previously-downloaded JSON profiler report via the profiler popover's 'Compare to previous run' section to enable run-vs-run comparison." + ); + } + const deltas = args.operatorId + ? snap.baseline.deltas.filter(d => d.operatorId === args.operatorId) + : snap.baseline.deltas; + if (deltas.length === 0 && args.operatorId) { + return createErrorResult( + `Operator '${args.operatorId}' is not in the baseline or current snapshot.` + ); + } + return createToolResult( + JSON.stringify({ + baselineWorkflow: snap.baseline.header.workflowName, + baselineExecution: snap.baseline.header.executionName, + baselineGeneratedAt: snap.baseline.header.generatedAt, + deltas, + }) + ); + }, + }); +} + +/** + * Convenience factory — builds all five profiler tools given a single getter that + * always returns the current snapshot. Mirrors the create-X-tool pattern used by + * the workflow CRUD tools. + */ +export function createProfilerTools(getSnapshot: () => unknown): Record { + return { + [TOOL_NAME_GET_PROFILER_SUMMARY]: createGetProfilerSummaryTool(getSnapshot), + [TOOL_NAME_LIST_HOT_OPERATORS]: createListHotOperatorsTool(getSnapshot), + [TOOL_NAME_GET_OPERATOR_METRICS]: createGetOperatorMetricsTool(getSnapshot), + [TOOL_NAME_GET_OPTIMIZATION_HINTS]: createGetOptimizationHintsTool(getSnapshot), + [TOOL_NAME_COMPARE_TO_BASELINE]: createCompareToBaselineTool(getSnapshot), + }; +} diff --git a/agent-service/src/agent/tools/proposal-tools.test.ts b/agent-service/src/agent/tools/proposal-tools.test.ts new file mode 100644 index 00000000000..a8571c3a84c --- /dev/null +++ b/agent-service/src/agent/tools/proposal-tools.test.ts @@ -0,0 +1,177 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { describe, expect, test } from "bun:test"; +import { + createProposalTools, + createProposeOperatorChangeTool, + createProposeOptimizationPlanTool, + TOOL_NAME_PROPOSE_OPERATOR_CHANGE, + TOOL_NAME_PROPOSE_OPTIMIZATION_PLAN, + type OperatorChangeProposal, + type OptimizationPlanProposal, +} from "./proposal-tools"; + +describe("proposeOperatorChange", () => { + test("returns a structured JSON proposal with all fields preserved", async () => { + const t = createProposeOperatorChangeTool(); + const raw = (await t.execute!( + { + operatorId: "python-udf-1", + propertyChanges: { workers: 4 }, + reasoning: "RUNTIME_OUTLIER and LOW_PARALLELISM_HOT_OP both fired on python-udf-1.", + expectedImpact: "Should cut runtime via more parallelism.", + firingHints: ["RUNTIME_OUTLIER", "LOW_PARALLELISM_HOT_OP"], + } as any, + {} as any + )) as string; + const parsed: OperatorChangeProposal = JSON.parse(raw); + expect(parsed.kind).toBe("operator_change_proposal"); + expect(parsed.operatorId).toBe("python-udf-1"); + expect(parsed.propertyChanges).toEqual({ workers: 4 }); + expect(parsed.reasoning).toContain("RUNTIME_OUTLIER"); + expect(parsed.expectedImpact).toContain("parallelism"); + expect(parsed.firingHints).toEqual(["RUNTIME_OUTLIER", "LOW_PARALLELISM_HOT_OP"]); + }); + + test("omits firingHints from the JSON when not provided", async () => { + const t = createProposeOperatorChangeTool(); + const raw = (await t.execute!( + { + operatorId: "agg-1", + propertyChanges: { groupByKeys: ["k"] }, + reasoning: "UPSTREAM_OVERPRODUCTION on csv-scan-1 → agg-1.", + expectedImpact: "Reduces shuffle.", + } as any, + {} as any + )) as string; + const parsed: OperatorChangeProposal = JSON.parse(raw); + expect(parsed.firingHints).toBeUndefined(); + }); + + test("accepts arbitrarily-shaped propertyChanges values (string / number / boolean / object / array)", async () => { + const t = createProposeOperatorChangeTool(); + const raw = (await t.execute!( + { + operatorId: "op-x", + propertyChanges: { + workers: 4, + enabled: true, + mode: "stream", + predicates: [{ attribute: "x", condition: "is not null" }], + options: { batchSize: 100 }, + }, + reasoning: "r", + expectedImpact: "i", + } as any, + {} as any + )) as string; + const parsed: OperatorChangeProposal = JSON.parse(raw); + expect(parsed.propertyChanges).toMatchObject({ + workers: 4, + enabled: true, + mode: "stream", + }); + expect(Array.isArray((parsed.propertyChanges as any).predicates)).toBe(true); + expect((parsed.propertyChanges as any).options).toEqual({ batchSize: 100 }); + }); +}); + +describe("proposeOptimizationPlan", () => { + function validPlanArgs(overrides: Record = {}) { + return { + planTitle: "Optimize the Python UDF bottleneck", + planRationale: + "RUNTIME_OUTLIER and LOW_PARALLELISM_HOT_OP both fire on python-udf-1; the upstream scan also has SCAN_FULL_TABLE_NO_FILTER.", + firingHints: ["RUNTIME_OUTLIER", "LOW_PARALLELISM_HOT_OP", "SCAN_FULL_TABLE_NO_FILTER"], + steps: [ + { + operatorId: "filter-1", + propertyChanges: { predicate: "col > 0" }, + description: "Push a Filter upstream of the UDF", + reasoning: "SCAN_FULL_TABLE_NO_FILTER → smaller working set", + expectedImpact: "Reduces rows entering the UDF", + }, + { + operatorId: "python-udf-1", + propertyChanges: { workers: 4 }, + description: "Increase UDF workers to 4", + reasoning: "LOW_PARALLELISM_HOT_OP", + expectedImpact: "Parallelizes the remaining UDF work", + }, + ], + ...overrides, + }; + } + + test("returns a structured JSON plan with all fields preserved", async () => { + const t = createProposeOptimizationPlanTool(); + const raw = (await t.execute!(validPlanArgs() as any, {} as any)) as string; + const parsed: OptimizationPlanProposal = JSON.parse(raw); + expect(parsed.kind).toBe("optimization_plan_proposal"); + expect(parsed.planTitle).toContain("Python UDF bottleneck"); + expect(parsed.firingHints).toEqual([ + "RUNTIME_OUTLIER", + "LOW_PARALLELISM_HOT_OP", + "SCAN_FULL_TABLE_NO_FILTER", + ]); + expect(parsed.steps).toHaveLength(2); + expect(parsed.steps[0].operatorId).toBe("filter-1"); + expect(parsed.steps[1].operatorId).toBe("python-udf-1"); + expect(parsed.steps[1].propertyChanges).toEqual({ workers: 4 }); + }); + + test("preserves step order (steps are ordered, not a set)", async () => { + const t = createProposeOptimizationPlanTool(); + const steps = [ + { operatorId: "a", propertyChanges: {}, description: "A", reasoning: "a", expectedImpact: "a" }, + { operatorId: "b", propertyChanges: {}, description: "B", reasoning: "b", expectedImpact: "b" }, + { operatorId: "c", propertyChanges: {}, description: "C", reasoning: "c", expectedImpact: "c" }, + ]; + const raw = (await t.execute!(validPlanArgs({ steps }) as any, {} as any)) as string; + const parsed: OptimizationPlanProposal = JSON.parse(raw); + expect(parsed.steps.map(s => s.operatorId)).toEqual(["a", "b", "c"]); + }); + + test("omits firingHints from the JSON when not provided", async () => { + const t = createProposeOptimizationPlanTool(); + const args = validPlanArgs({ firingHints: undefined }); + const raw = (await t.execute!(args as any, {} as any)) as string; + const parsed: OptimizationPlanProposal = JSON.parse(raw); + expect(parsed.firingHints).toBeUndefined(); + }); +}); + +describe("createProposalTools (factory)", () => { + test("registers both proposal tools under their TOOL_NAME constants", () => { + const tools = createProposalTools(); + expect(Object.keys(tools).sort()).toEqual( + [TOOL_NAME_PROPOSE_OPERATOR_CHANGE, TOOL_NAME_PROPOSE_OPTIMIZATION_PLAN].sort() + ); + expect(tools[TOOL_NAME_PROPOSE_OPERATOR_CHANGE]).toBeDefined(); + expect(tools[TOOL_NAME_PROPOSE_OPTIMIZATION_PLAN]).toBeDefined(); + }); + + test("tool name constants match the agreed contract", () => { + // Cross-project contract — frontend reads tool calls by name. Changing + // either requires updating frontend/src/.../service/agent/agent-proposal.ts. + expect(TOOL_NAME_PROPOSE_OPERATOR_CHANGE).toBe("proposeOperatorChange"); + expect(TOOL_NAME_PROPOSE_OPTIMIZATION_PLAN).toBe("proposeOptimizationPlan"); + }); +}); diff --git a/agent-service/src/agent/tools/proposal-tools.ts b/agent-service/src/agent/tools/proposal-tools.ts new file mode 100644 index 00000000000..fccfaf883c8 --- /dev/null +++ b/agent-service/src/agent/tools/proposal-tools.ts @@ -0,0 +1,190 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { tool } from "ai"; +import { z } from "zod"; +import { createToolResult } from "./tools-utility"; + +/** + * Phase 3 of the profiler-agent-tool plan: structured-proposal channel. + * + * `proposeOperatorChange` is a NON-MUTATING tool. It records a proposed + * operator-property change as a tool call in the chat transcript so the + * frontend can render an Apply/Reject card next to the agent's message. + * + * The actual mutation runs on the frontend when the user clicks Apply — via + * the existing `WorkflowActionService.setOperatorProperty` — bypassing a + * round-trip back to the agent. This keeps the confirmation gate UI-side and + * makes it harder for a hallucinating agent to silently rewrite the workflow. + */ + +export const TOOL_NAME_PROPOSE_OPERATOR_CHANGE = "proposeOperatorChange"; +export const TOOL_NAME_PROPOSE_OPTIMIZATION_PLAN = "proposeOptimizationPlan"; + +export interface OperatorChangeProposal { + kind: "operator_change_proposal"; + operatorId: string; + propertyChanges: Record; + reasoning: string; + expectedImpact: string; + firingHints?: string[]; +} + +/** + * Phase 4: a multi-step optimization plan — an ordered sequence of related + * operator-property changes presented as one card with per-step Apply/Reject. + * Use this when the steps build on each other (e.g. "first restructure, then + * tune workers"); for independent single-operator suggestions, prefer + * proposeOperatorChange instead — one card per change is clearer. + */ +export interface OptimizationPlanStep { + operatorId: string; + propertyChanges: Record; + description: string; + reasoning: string; + expectedImpact: string; +} + +export interface OptimizationPlanProposal { + kind: "optimization_plan_proposal"; + planTitle: string; + planRationale: string; + firingHints?: string[]; + steps: OptimizationPlanStep[]; +} + +export function createProposeOperatorChangeTool() { + return tool({ + description: + "Surface a structured proposal to change an operator's properties — does NOT apply the change. " + + "Use this (instead of asking for text confirmation) whenever a profiler hint suggests a concrete, mechanical edit " + + "(e.g. LOW_PARALLELISM_HOT_OP → increase 'workers'). The frontend will render an Apply / Reject card next to your " + + "message; do not also call 'modifyOperator' for the same change — the UI handles the mutation. " + + "Each call must include: the exact operatorId, a propertyChanges object listing ONLY the keys to change " + + "(merge-style, not full replacement), the reasoning citing the firing hint(s), and the expected impact.", + inputSchema: z.object({ + operatorId: z + .string() + .describe("The exact operatorId targeted by the proposal (not the display name)."), + propertyChanges: z + .record(z.string(), z.unknown()) + .describe( + "Sparse object of operator-property keys → new values. Frontend will merge into the existing properties. " + + "Do NOT include unchanged keys." + ), + reasoning: z + .string() + .describe( + "Why this change — must cite the firing profiler hint(s) (e.g. 'RUNTIME_OUTLIER and LOW_PARALLELISM_HOT_OP on python-udf-1')." + ), + expectedImpact: z + .string() + .describe( + "What the user should see after applying (e.g. 'cuts python-udf-1 runtime via more parallelism')." + ), + firingHints: z + .array(z.string()) + .optional() + .describe("Optional list of ruleIds (e.g. ['RUNTIME_OUTLIER']) that justify this proposal."), + }), + execute: async args => { + const proposal: OperatorChangeProposal = { + kind: "operator_change_proposal", + operatorId: args.operatorId, + propertyChanges: args.propertyChanges, + reasoning: args.reasoning, + expectedImpact: args.expectedImpact, + firingHints: args.firingHints, + }; + return createToolResult(JSON.stringify(proposal)); + }, + }); +} + +export function createProposeOptimizationPlanTool() { + return tool({ + description: + "Surface a structured multi-step optimization plan — does NOT apply any of the steps. " + + "Use this (instead of multiple proposeOperatorChange calls) when the suggested changes are RELATED and ORDERED " + + "(e.g. 'first push the Filter upstream, then bump the UDF workers'). For independent single-operator suggestions, " + + "prefer proposeOperatorChange — one card per change is clearer. The frontend renders the plan as one card with " + + "per-step Apply / Reject buttons plus an 'Apply All' button.", + inputSchema: z.object({ + planTitle: z + .string() + .min(1) + .describe("Short title for the plan (e.g. 'Optimize the Python UDF bottleneck')."), + planRationale: z + .string() + .min(1) + .describe( + "Plan-level rationale — why these steps together. Cite firing hint(s) (e.g. 'RUNTIME_OUTLIER and LOW_PARALLELISM_HOT_OP on python-udf-1')." + ), + firingHints: z + .array(z.string()) + .optional() + .describe("Optional list of ruleIds justifying the plan as a whole."), + steps: z + .array( + z.object({ + operatorId: z.string().describe("Exact operatorId targeted by this step."), + propertyChanges: z + .record(z.string(), z.unknown()) + .describe( + "Sparse property update for this step (merge-style; only changed keys)." + ), + description: z + .string() + .min(1) + .describe("One-line description of what this step does."), + reasoning: z + .string() + .min(1) + .describe("Why this specific step is in the plan."), + expectedImpact: z + .string() + .min(1) + .describe("What the user should see after applying this step."), + }) + ) + .min(2) + .max(10) + .describe( + "Ordered list of steps. Must contain at least 2 entries — a single-step 'plan' should be a proposeOperatorChange call instead. Maximum 10 steps to avoid overwhelming the user." + ), + }), + execute: async args => { + const plan: OptimizationPlanProposal = { + kind: "optimization_plan_proposal", + planTitle: args.planTitle, + planRationale: args.planRationale, + firingHints: args.firingHints, + steps: args.steps, + }; + return createToolResult(JSON.stringify(plan)); + }, + }); +} + +export function createProposalTools(): Record { + return { + [TOOL_NAME_PROPOSE_OPERATOR_CHANGE]: createProposeOperatorChangeTool(), + [TOOL_NAME_PROPOSE_OPTIMIZATION_PLAN]: createProposeOptimizationPlanTool(), + }; +} diff --git a/agent-service/src/agent/tools/workflow-crud-tools.ts b/agent-service/src/agent/tools/workflow-crud-tools.ts index 54a0b29db99..4865304a557 100644 --- a/agent-service/src/agent/tools/workflow-crud-tools.ts +++ b/agent-service/src/agent/tools/workflow-crud-tools.ts @@ -43,6 +43,16 @@ export interface ToolContext { toolTimeoutMs?: number; executionTimeoutMs?: number; }; + /** + * Per-message profiler snapshot from the frontend. Populated when the user has + * profiling enabled and ProfilerService has scores; undefined otherwise. Read-only + * — profiler tools (`createProfilerTools`) inspect this to answer questions like + * "why is my workflow slow?". The shape mirrors `ProfilerSnapshot` in the frontend + * (see `frontend/src/app/workspace/service/profiler/profiler-snapshot.ts`); kept + * `unknown` here so we don't need to share types across project boundaries — + * profiler tools parse defensively at the read site. + */ + profilerSnapshot?: unknown; } export const TOOL_NAME_ADD_OPERATOR = "addOperator"; diff --git a/agent-service/src/agent/util/context-utils.ts b/agent-service/src/agent/util/context-utils.ts index 195692cbf50..6f8a63f6a8e 100644 --- a/agent-service/src/agent/util/context-utils.ts +++ b/agent-service/src/agent/util/context-utils.ts @@ -28,9 +28,41 @@ import type { ReActStep } from "../../types/agent"; import type { WorkflowCompilationResponse, WorkflowFatalError } from "../../api/compile-api"; import { extractOperatorInputPortSchemaMap } from "./workflow-utils"; import { createLogger } from "../../logger"; +import { + TOOL_NAME_GET_PROFILER_SUMMARY, + TOOL_NAME_LIST_HOT_OPERATORS, + TOOL_NAME_GET_OPERATOR_METRICS, + TOOL_NAME_GET_OPTIMIZATION_HINTS, + TOOL_NAME_COMPARE_TO_BASELINE, +} from "../tools/profiler-tools"; +import { + TOOL_NAME_PROPOSE_OPERATOR_CHANGE, + TOOL_NAME_PROPOSE_OPTIMIZATION_PLAN, +} from "../tools/proposal-tools"; const log = createLogger("ContextAssembler"); +// Read-only tools whose JSON output IS the answer the model needs to see. +// Mutating tools (addOperator, modifyOperator, etc.) leave their effects in the +// regenerated "# Current Dataflow" section, so their result text is redundant +// and intentionally stripped to keep context compact. +// +// proposeOperatorChange is included so that, if the agent makes multiple proposals +// in a single turn, it can recall the prior proposal's text when generating +// its summary message. The frontend uses these tool calls (by name) to render +// Apply/Reject UI — that's an independent consumer of the same channel. +const TOOLS_REQUIRING_RESULT_IN_CONTEXT = new Set([ + TOOL_NAME_GET_PROFILER_SUMMARY, + TOOL_NAME_LIST_HOT_OPERATORS, + TOOL_NAME_GET_OPERATOR_METRICS, + TOOL_NAME_GET_OPTIMIZATION_HINTS, + TOOL_NAME_COMPARE_TO_BASELINE, + TOOL_NAME_PROPOSE_OPERATOR_CHANGE, + TOOL_NAME_PROPOSE_OPTIMIZATION_PLAN, +]); + +const TOOL_RESULT_MAX_CHARS = 4000; + export function assembleContext( visibleSteps: ReActStep[], workflowState: WorkflowState, @@ -127,6 +159,14 @@ function serializeTask(steps: ReActStep[], status: "completed" | "ongoing"): str const tr = step.toolResults?.[i]; const statusAttr = tr?.isError ? "failed" : "succeeded"; lines.push(`- ${tc.toolName} (${statusAttr})`); + if (tr && TOOLS_REQUIRING_RESULT_IN_CONTEXT.has(tc.toolName)) { + const output = typeof tr.output === "string" ? tr.output : JSON.stringify(tr.output); + const truncated = + output.length > TOOL_RESULT_MAX_CHARS + ? output.slice(0, TOOL_RESULT_MAX_CHARS) + "…(truncated)" + : output; + lines.push(` Result: ${truncated}`); + } } } lines.push(""); diff --git a/agent-service/src/server.ts b/agent-service/src/server.ts index a31f9ede115..ee8100376ca 100644 --- a/agent-service/src/server.ts +++ b/agent-service/src/server.ts @@ -23,6 +23,10 @@ import { createOpenAI } from "@ai-sdk/openai"; import { TexeraAgent } from "./agent/texera-agent"; import { getBackendConfig } from "./api/backend-api"; import { extractUserFromToken, validateToken } from "./api/auth-api"; +import { + proposeFilterPredicate, + proposeWorkerCount, +} from "./agent/proposals/proposal-generators"; import { retrieveWorkflow } from "./api/workflow-api"; import { WorkflowSystemMetadata } from "./agent/util/workflow-system-metadata"; import { env } from "./config/env"; @@ -52,7 +56,7 @@ async function createAgentInstance( const config = getBackendConfig(); const openai = createOpenAI({ - baseURL: `${config.modelsEndpoint}/api`, + baseURL: config.modelsEndpoint, apiKey: env.LLM_API_KEY, }); @@ -137,6 +141,142 @@ function getAgent(agentId: string): TexeraAgent { return agent; } +/** + * Stateless one-shot LLM model used by the /proposals/* endpoints. We build it + * lazily because the LiteLLM endpoint + key come from env. Each proposal call + * picks the requested modelType (a string already registered in the LiteLLM + * gateway). No agent state, no ReAct loop — these are just schema-constrained + * LLM calls in service of smarter ghost-suggestion materialization. + */ +function buildOneShotModel(modelType: string) { + const config = getBackendConfig(); + const openai = createOpenAI({ + baseURL: config.modelsEndpoint, + apiKey: env.LLM_API_KEY, + }); + return openai.chat(modelType); +} + +const DEFAULT_PROPOSAL_MODEL = "claude-haiku-4.5"; +const PROPOSAL_TIMEOUT_MS = 15_000; + +async function withTimeout(p: Promise, ms: number, label: string): Promise { + let timeoutHandle: any; + const timeout = new Promise((_, reject) => { + timeoutHandle = setTimeout(() => reject(new Error(`${label} timed out after ${ms}ms`)), ms); + }); + try { + return await Promise.race([p, timeout]); + } finally { + clearTimeout(timeoutHandle); + } +} + +const proposalsRouter = new Elysia({ prefix: "/proposals" }) + .onError(({ error, set }) => { + log.warn({ err: error }, "proposal request failed"); + set.status = 502; + return { error: error instanceof Error ? error.message : String(error) }; + }) + .post( + "/filter-predicate", + async ({ body }) => { + const { + upstreamOpId, + downstreamOpId, + upstreamSchema, + downstreamType, + downstreamProperties, + upstreamSamples, + modelType, + } = body as { + upstreamOpId: string; + downstreamOpId: string; + upstreamSchema: { attributeName: string; attributeType: string }[]; + downstreamType?: string; + downstreamProperties?: Record; + upstreamSamples?: Record[]; + modelType?: string; + }; + const model = buildOneShotModel(modelType || DEFAULT_PROPOSAL_MODEL); + const result = await withTimeout( + proposeFilterPredicate(model, { + upstreamOpId, + downstreamOpId, + upstreamSchema, + downstreamType, + downstreamProperties, + upstreamSamples, + }), + PROPOSAL_TIMEOUT_MS, + "proposeFilterPredicate" + ); + return result; + }, + { + body: t.Object({ + upstreamOpId: t.String(), + downstreamOpId: t.String(), + upstreamSchema: t.Array(t.Object({ attributeName: t.String(), attributeType: t.String() })), + downstreamType: t.Optional(t.String()), + downstreamProperties: t.Optional(t.Record(t.String(), t.Any())), + upstreamSamples: t.Optional(t.Array(t.Record(t.String(), t.Any()))), + modelType: t.Optional(t.String()), + }), + } + ) + .post( + "/worker-count", + async ({ body }) => { + const { + operatorId, + operatorType, + currentWorkers, + runtimeMs, + idleRatio, + inputRows, + outputRows, + modelType, + } = body as { + operatorId: string; + operatorType: string; + currentWorkers: number; + runtimeMs?: number | null; + idleRatio?: number | null; + inputRows?: number | null; + outputRows?: number | null; + modelType?: string; + }; + const model = buildOneShotModel(modelType || DEFAULT_PROPOSAL_MODEL); + const result = await withTimeout( + proposeWorkerCount(model, { + operatorId, + operatorType, + currentWorkers, + runtimeMs, + idleRatio, + inputRows, + outputRows, + }), + PROPOSAL_TIMEOUT_MS, + "proposeWorkerCount" + ); + return result; + }, + { + body: t.Object({ + operatorId: t.String(), + operatorType: t.String(), + currentWorkers: t.Number(), + runtimeMs: t.Optional(t.Nullable(t.Number())), + idleRatio: t.Optional(t.Nullable(t.Number())), + inputRows: t.Optional(t.Nullable(t.Number())), + outputRows: t.Optional(t.Nullable(t.Number())), + modelType: t.Optional(t.String()), + }), + } + ); + const agentsRouter = new Elysia({ prefix: "/agents" }) // Error handler must live on the same Elysia instance whose routes throw, or // its scope will not see the errors. Elysia 1.x defaults to local scoping for @@ -406,6 +546,7 @@ interface WsMessage { type: "message" | "stop"; content?: string; messageSource?: "chat" | "feedback"; + profilerSnapshot?: unknown; } interface OperatorResultSummaryWs { @@ -484,6 +625,7 @@ export function buildApp() { timestamp: new Date().toISOString(), })) .use(agentsRouter) + .use(proposalsRouter) ) .ws(`${env.API_PREFIX}/agents/:id/react`, { open(ws) { @@ -552,7 +694,14 @@ export function buildApp() { broadcastToAgent(agentId, { type: "state", state: "GENERATING" }); try { - const result = await agent.sendMessage(msg.content, msg.messageSource); + // Pass through optional profilerSnapshot from the frontend so the + // agent's read-only profiler tools have current per-operator metrics. + // Frontend builds this via `buildProfilerSnapshot` in profiler-snapshot.ts. + const result = await agent.sendMessage( + msg.content, + msg.messageSource, + msg.profilerSnapshot + ); agent.setStepCallback(null); diff --git a/amber/src/main/scala/org/apache/texera/amber/engine/architecture/scheduling/ProfilerScoring.scala b/amber/src/main/scala/org/apache/texera/amber/engine/architecture/scheduling/ProfilerScoring.scala new file mode 100644 index 00000000000..b3adf055571 --- /dev/null +++ b/amber/src/main/scala/org/apache/texera/amber/engine/architecture/scheduling/ProfilerScoring.scala @@ -0,0 +1,108 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.apache.texera.amber.engine.architecture.scheduling + +import org.apache.texera.web.model.websocket.event.OperatorAggregatedMetrics + +/** + * Single source of truth for the live per-operator profiler "heat" score used + * by the frontend profiler heatmap. Mirrors the formulas in the TypeScript + * `ProfilerService.computeScores` / `rawCostFor` so that if we ever need to + * compute scores server-side (e.g., to embed into persisted runtime stats, or + * to drive scheduler decisions), both sides agree. + * + * No call site in the engine yet — purely a utility for future use, per + * profiler-m3-implementation-plan P5. + */ +object ProfilerScoring { + + sealed trait ProfilerView extends Product with Serializable + object ProfilerView { + case object Runtime extends ProfilerView + case object Throughput extends ProfilerView + case object IoImbalance extends ProfilerView + + /** Lookup by lowercase / kebab string form (matches the frontend's view ids). */ + def fromString(s: String): Option[ProfilerView] = s.toLowerCase match { + case "runtime" => Some(Runtime) + case "throughput" => Some(Throughput) + case "io-imbalance" => Some(IoImbalance) + case _ => None + } + } + + /** + * Per-operator raw cost for the given view. NOT normalized — see [[liveScore]] + * for the [0, 1] score derived by dividing by the peer max. + * + * Runtime view: rawCost = aggregatedDataProcessingTime (ns) — higher = hotter. + * Throughput view: rawCost = 1 / aggregatedOutputRowCount — slower producers are hotter. + * IoImbalance view: rawCost = clamp(1 - out/in, 0, 1) — operators dropping most rows are hotter. + * + * Returns 0 for any non-finite / non-positive case (operator hasn't started, + * counts are missing, etc.) — matches the frontend's defensive defaults. + */ + def liveRawCost(metrics: OperatorAggregatedMetrics, view: ProfilerView): Double = { + view match { + case ProfilerView.Runtime => + val t = metrics.aggregatedDataProcessingTime.toDouble + if (t.isFinite && t > 0) t else 0.0 + + case ProfilerView.Throughput => + val out = metrics.aggregatedOutputRowCount + if (out > 0) 1.0 / out.toDouble else 0.0 + + case ProfilerView.IoImbalance => + val inp = metrics.aggregatedInputRowCount + val out = metrics.aggregatedOutputRowCount + if (inp <= 0) 0.0 + else clamp(1.0 - out.toDouble / inp.toDouble, 0.0, 1.0) + } + } + + /** + * Normalize a single operator's raw cost against the peer maximum across the + * workflow. Returns 0 when the peer max is 0 (the workflow hasn't produced + * measurable work yet) and clamps the result into [0, 1]. + */ + def liveScore(rawCost: Double, peerMaxRawCost: Double): Double = { + if (peerMaxRawCost <= 0.0 || !rawCost.isFinite || !peerMaxRawCost.isFinite) 0.0 + else clamp(rawCost / peerMaxRawCost, 0.0, 1.0) + } + + /** + * Convenience: compute scores for a whole map of operator id -> metrics in + * one pass. Mirrors `ProfilerService.computeScores` exactly so both sides + * stay apples-to-apples. + */ + def liveScores( + perOperator: Map[String, OperatorAggregatedMetrics], + view: ProfilerView + ): Map[String, Double] = { + if (perOperator.isEmpty) return Map.empty + val rawCosts: Map[String, Double] = + perOperator.view.mapValues(m => liveRawCost(m, view)).toMap + val peerMax: Double = rawCosts.values.maxOption.getOrElse(0.0) + rawCosts.view.mapValues(rc => liveScore(rc, peerMax)).toMap + } + + private def clamp(value: Double, min: Double, max: Double): Double = + math.max(min, math.min(max, value)) +} diff --git a/amber/src/test/scala/org/apache/texera/amber/engine/architecture/scheduling/ProfilerScoringSpec.scala b/amber/src/test/scala/org/apache/texera/amber/engine/architecture/scheduling/ProfilerScoringSpec.scala new file mode 100644 index 00000000000..f5881073635 --- /dev/null +++ b/amber/src/test/scala/org/apache/texera/amber/engine/architecture/scheduling/ProfilerScoringSpec.scala @@ -0,0 +1,184 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.apache.texera.amber.engine.architecture.scheduling + +import org.apache.texera.amber.engine.architecture.scheduling.ProfilerScoring.ProfilerView +import org.apache.texera.web.model.websocket.event.OperatorAggregatedMetrics +import org.scalatest.flatspec.AnyFlatSpec +import org.scalatest.matchers.should.Matchers + +/** + * Mirrors the frontend `profiler.service.spec.ts` formula assertions. If a + * test fails here AND the corresponding frontend test still passes, the two + * implementations have drifted — fix them together. + */ +class ProfilerScoringSpec extends AnyFlatSpec with Matchers { + + private def metrics( + dataNs: Long = 0L, + inRows: Long = 0L, + outRows: Long = 0L + ): OperatorAggregatedMetrics = + OperatorAggregatedMetrics( + operatorState = "Running", + aggregatedInputRowCount = inRows, + aggregatedInputSize = 0L, + inputPortMetrics = Map.empty, + aggregatedOutputRowCount = outRows, + aggregatedOutputSize = 0L, + outputPortMetrics = Map.empty, + numWorkers = 1L, + aggregatedDataProcessingTime = dataNs, + aggregatedControlProcessingTime = 0L, + aggregatedIdleTime = 0L + ) + + // ---- ProfilerView.fromString --------------------------------------------- + + "ProfilerView.fromString" should "round-trip every canonical id" in { + ProfilerView.fromString("runtime") shouldBe Some(ProfilerView.Runtime) + ProfilerView.fromString("throughput") shouldBe Some(ProfilerView.Throughput) + ProfilerView.fromString("io-imbalance") shouldBe Some(ProfilerView.IoImbalance) + } + + it should "be case-insensitive" in { + ProfilerView.fromString("Runtime") shouldBe Some(ProfilerView.Runtime) + ProfilerView.fromString("IO-IMBALANCE") shouldBe Some(ProfilerView.IoImbalance) + } + + it should "return None for unknown views (delta is frontend-only paint, not a scoring formula)" in { + ProfilerView.fromString("delta") shouldBe None + ProfilerView.fromString("") shouldBe None + ProfilerView.fromString("nope") shouldBe None + } + + // ---- liveRawCost: runtime view ------------------------------------------- + + "liveRawCost (runtime)" should "return the data-processing time when positive" in { + ProfilerScoring.liveRawCost(metrics(dataNs = 12345L), ProfilerView.Runtime) shouldBe 12345.0 + } + + it should "return 0 for zero / negative data-processing time" in { + ProfilerScoring.liveRawCost(metrics(dataNs = 0L), ProfilerView.Runtime) shouldBe 0.0 + ProfilerScoring.liveRawCost(metrics(dataNs = -5L), ProfilerView.Runtime) shouldBe 0.0 + } + + // ---- liveRawCost: throughput view ---------------------------------------- + + "liveRawCost (throughput)" should "invert output row count — slow producers are hotter" in { + ProfilerScoring.liveRawCost(metrics(outRows = 100L), ProfilerView.Throughput) shouldBe 0.01 + ProfilerScoring.liveRawCost(metrics(outRows = 10L), ProfilerView.Throughput) shouldBe 0.1 + } + + it should "return 0 when no output rows have been produced" in { + ProfilerScoring.liveRawCost(metrics(outRows = 0L), ProfilerView.Throughput) shouldBe 0.0 + } + + // ---- liveRawCost: io-imbalance view -------------------------------------- + + "liveRawCost (io-imbalance)" should "be 1.0 when 100% of rows are dropped" in { + ProfilerScoring.liveRawCost( + metrics(inRows = 1000L, outRows = 0L), + ProfilerView.IoImbalance + ) shouldBe 1.0 + } + + it should "be 0.0 when output >= input (passthrough or fan-out)" in { + ProfilerScoring.liveRawCost( + metrics(inRows = 100L, outRows = 100L), + ProfilerView.IoImbalance + ) shouldBe 0.0 + ProfilerScoring.liveRawCost( + metrics(inRows = 100L, outRows = 200L), + ProfilerView.IoImbalance + ) shouldBe 0.0 + } + + it should "interpolate linearly for partial drops" in { + ProfilerScoring.liveRawCost( + metrics(inRows = 100L, outRows = 30L), + ProfilerView.IoImbalance + ) shouldBe (0.7 +- 1e-9) + } + + it should "return 0 when no input rows have arrived" in { + ProfilerScoring.liveRawCost(metrics(inRows = 0L), ProfilerView.IoImbalance) shouldBe 0.0 + } + + // ---- liveScore (normalization) ------------------------------------------- + + "liveScore" should "return rawCost / peerMaxRawCost when peerMax > 0" in { + ProfilerScoring.liveScore(50.0, 100.0) shouldBe 0.5 + ProfilerScoring.liveScore(100.0, 100.0) shouldBe 1.0 + ProfilerScoring.liveScore(0.0, 100.0) shouldBe 0.0 + } + + it should "clamp into [0, 1]" in { + // rawCost > peerMax shouldn't happen in practice, but guard it anyway. + ProfilerScoring.liveScore(200.0, 100.0) shouldBe 1.0 + } + + it should "return 0 when peerMax is non-positive or non-finite" in { + ProfilerScoring.liveScore(50.0, 0.0) shouldBe 0.0 + ProfilerScoring.liveScore(50.0, -1.0) shouldBe 0.0 + ProfilerScoring.liveScore(50.0, Double.NaN) shouldBe 0.0 + ProfilerScoring.liveScore(50.0, Double.PositiveInfinity) shouldBe 0.0 + } + + it should "return 0 for non-finite rawCost (defensive)" in { + ProfilerScoring.liveScore(Double.NaN, 100.0) shouldBe 0.0 + ProfilerScoring.liveScore(Double.PositiveInfinity, 100.0) shouldBe 0.0 + } + + // ---- liveScores: end-to-end map computation ------------------------------ + + "liveScores" should "normalize across the whole workflow in one pass (runtime view)" in { + val perOp = Map( + "fast" -> metrics(dataNs = 200L), + "hot" -> metrics(dataNs = 2000L), + "mid" -> metrics(dataNs = 1000L) + ) + val scores = ProfilerScoring.liveScores(perOp, ProfilerView.Runtime) + scores("fast") shouldBe 0.1 + scores("mid") shouldBe 0.5 + scores("hot") shouldBe 1.0 + } + + it should "produce 0 for every operator when no work has been done" in { + val perOp = Map("a" -> metrics(), "b" -> metrics()) + val scores = ProfilerScoring.liveScores(perOp, ProfilerView.Runtime) + scores("a") shouldBe 0.0 + scores("b") shouldBe 0.0 + } + + it should "return an empty map for an empty input (no NaN from max-of-empty)" in { + ProfilerScoring.liveScores(Map.empty, ProfilerView.Runtime) shouldBe empty + } + + it should "score the slowest producer highest under the throughput view" in { + val perOp = Map( + "fast-producer" -> metrics(outRows = 1_000_000L), + "slow-producer" -> metrics(outRows = 10L) + ) + val scores = ProfilerScoring.liveScores(perOp, ProfilerView.Throughput) + scores("slow-producer") shouldBe 1.0 + scores("fast-producer") should be < scores("slow-producer") + } +} diff --git a/frontend/src/app/common/type/workflow.ts b/frontend/src/app/common/type/workflow.ts index 8e1c1c7e85b..47aa65e15fd 100644 --- a/frontend/src/app/common/type/workflow.ts +++ b/frontend/src/app/common/type/workflow.ts @@ -42,6 +42,19 @@ export interface WorkflowSettings { * */ +/** + * Per-workflow override for the runtime performance profiler (heatmap + hints). + * When present, takes precedence over the per-user `localStorage` defaults. + * Defined inline (not imported from the profiler service) to keep this common-layer + * type free of workspace-service dependencies. + */ +export interface WorkflowProfilerConfig + extends Readonly<{ + enabled: boolean; + view: "runtime" | "throughput" | "io-imbalance" | "delta"; + hotThresholdPercentile: number; + }> {} + export interface WorkflowContent extends Readonly<{ operators: OperatorPredicate[]; @@ -49,6 +62,8 @@ export interface WorkflowContent links: OperatorLink[]; commentBoxes: CommentBox[]; settings: WorkflowSettings; + /** Optional per-workflow profiler config. Older workflows simply omit this key. */ + profilerConfig?: WorkflowProfilerConfig; }> {} export type Workflow = { content: WorkflowContent } & WorkflowMetadata; diff --git a/frontend/src/app/workspace/component/agent/agent-panel/agent-chat/agent-chat.component.html b/frontend/src/app/workspace/component/agent/agent-panel/agent-chat/agent-chat.component.html index d650a0a146b..3b6aa577a4e 100644 --- a/frontend/src/app/workspace/component/agent/agent-panel/agent-chat/agent-chat.component.html +++ b/frontend/src/app/workspace/component/agent/agent-panel/agent-chat/agent-chat.component.html @@ -118,6 +118,204 @@ class="message-content"> + + +
+
+ + {{ plan.planTitle }} + + {{ getPlanAppliedCount(plan) }}/{{ plan.steps.length }} applied + +
+
{{ plan.planRationale }}
+
+ {{ hint }} +
+
    +
  1. +
    + {{ step.description }} + {{ step.operatorId }} +
    +
    + {{ formatProposalSummary(step) }} +
    +
    + Why: {{ step.reasoning }} + Impact: {{ step.expectedImpact }} +
    +
    + + + + + + + + Applied + + + + Rejected + + + + Operator missing + + + + Failed + + +
    +
  2. +
+ +
+ + +
+
+ + Suggested change + {{ proposal.operatorId }} +
+
+ {{ formatProposalSummary(proposal) }} +
+
Why: {{ proposal.reasoning }}
+
Expected impact: {{ proposal.expectedImpact }}
+
+ {{ hint }} +
+
+ + + + + + + + Applied + + + + Rejected + + + + Operator no longer exists + + + + Failed to apply + + +
+
+
diff --git a/frontend/src/app/workspace/component/agent/agent-panel/agent-chat/agent-chat.component.scss b/frontend/src/app/workspace/component/agent/agent-panel/agent-chat/agent-chat.component.scss index 9098f1e243f..ec31d1d953a 100644 --- a/frontend/src/app/workspace/component/agent/agent-panel/agent-chat/agent-chat.component.scss +++ b/frontend/src/app/workspace/component/agent/agent-panel/agent-chat/agent-chat.component.scss @@ -314,3 +314,243 @@ } } } + +// Phase 3: structured-proposal card under agent messages. +.agent-proposal-card { + margin: 8px 0; + padding: 10px 12px; + border: 1px solid #ffd591; + background: #fff7e6; + border-radius: 6px; + font-size: 13px; + line-height: 1.4; + color: #262626; + + // Dim the card once the user has acted on it so pending cards stand out. + &[data-state="applied"], + &[data-state="rejected"], + &[data-state="missing-operator"], + &[data-state="failed"] { + opacity: 0.7; + } + + .agent-proposal-header { + display: flex; + align-items: center; + gap: 6px; + margin-bottom: 6px; + color: #d46b08; + + .agent-proposal-title { + font-weight: 600; + } + + .agent-proposal-operator { + margin-left: auto; + font-family: monospace; + font-size: 12px; + color: #595959; + background: #fafafa; + padding: 1px 6px; + border-radius: 3px; + border: 1px solid #e8e8e8; + } + } + + .agent-proposal-changes { + margin-bottom: 6px; + + code { + display: inline-block; + padding: 2px 6px; + background: #fafafa; + border: 1px solid #e8e8e8; + border-radius: 3px; + font-size: 12px; + overflow-wrap: anywhere; + } + } + + .agent-proposal-reasoning, + .agent-proposal-impact { + margin-bottom: 4px; + overflow-wrap: anywhere; + + strong { + color: #595959; + margin-right: 4px; + } + } + + .agent-proposal-hints { + margin: 6px 0; + display: flex; + flex-wrap: wrap; + gap: 4px; + } + + .agent-proposal-actions { + margin-top: 8px; + display: flex; + gap: 8px; + align-items: center; + } + + // Override the global `[nz-button] { width: 40px }` rule from menu.component + // bleeding in — these buttons need to be label-sized. + .agent-proposal-btn[nz-button] { + width: auto; + padding: 0 12px; + } + + .agent-proposal-status { + display: inline-flex; + align-items: center; + gap: 4px; + font-size: 12px; + font-weight: 500; + + &.agent-proposal-status-applied { + color: #389e0d; + } + + &.agent-proposal-status-rejected { + color: #8c8c8c; + } + + &.agent-proposal-status-error { + color: #cf1322; + } + } +} + +// Phase 4: multi-step optimization-plan card under agent messages. +.agent-plan-card { + margin: 8px 0; + padding: 10px 12px; + border: 1px solid #91d5ff; + background: #e6f7ff; + border-radius: 6px; + font-size: 13px; + line-height: 1.4; + color: #262626; + + .agent-plan-header { + display: flex; + align-items: center; + gap: 6px; + margin-bottom: 6px; + color: #096dd9; + + .agent-plan-title { + font-weight: 600; + } + + .agent-plan-progress { + margin-left: auto; + font-size: 12px; + font-weight: 500; + color: #595959; + background: #fafafa; + padding: 1px 6px; + border-radius: 3px; + border: 1px solid #e8e8e8; + } + } + + .agent-plan-rationale { + margin-bottom: 6px; + overflow-wrap: anywhere; + } + + .agent-plan-hints { + margin: 6px 0 8px; + display: flex; + flex-wrap: wrap; + gap: 4px; + } + + .agent-plan-steps { + margin: 0; + padding-left: 20px; + } + + .agent-plan-step { + margin-bottom: 10px; + padding: 8px 10px; + background: #ffffff; + border: 1px solid #d9d9d9; + border-radius: 4px; + + &[data-state="applied"], + &[data-state="rejected"], + &[data-state="missing-operator"], + &[data-state="failed"] { + opacity: 0.75; + } + + .agent-plan-step-header { + display: flex; + align-items: center; + gap: 6px; + margin-bottom: 4px; + + .agent-plan-step-desc { + font-weight: 500; + flex: 1; + overflow-wrap: anywhere; + } + + .agent-plan-step-operator { + font-family: monospace; + font-size: 11px; + color: #595959; + background: #fafafa; + padding: 1px 6px; + border-radius: 3px; + border: 1px solid #e8e8e8; + } + } + + .agent-plan-step-changes { + margin-bottom: 4px; + + code { + display: inline-block; + padding: 2px 6px; + background: #fafafa; + border: 1px solid #e8e8e8; + border-radius: 3px; + font-size: 11px; + overflow-wrap: anywhere; + } + } + + .agent-plan-step-meta { + display: flex; + flex-direction: column; + gap: 2px; + font-size: 12px; + color: #595959; + margin-bottom: 6px; + overflow-wrap: anywhere; + + strong { + color: #262626; + margin-right: 4px; + } + } + + .agent-plan-step-actions { + display: flex; + gap: 8px; + align-items: center; + } + } + + .agent-plan-footer { + margin-top: 8px; + display: flex; + justify-content: flex-end; + } +} diff --git a/frontend/src/app/workspace/component/agent/agent-panel/agent-chat/agent-chat.component.ts b/frontend/src/app/workspace/component/agent/agent-panel/agent-chat/agent-chat.component.ts index 55b6c6a3f66..37ab0d3b72b 100644 --- a/frontend/src/app/workspace/component/agent/agent-panel/agent-chat/agent-chat.component.ts +++ b/frontend/src/app/workspace/component/agent/agent-panel/agent-chat/agent-chat.component.ts @@ -37,12 +37,24 @@ import { AgentInfo, AgentService } from "../../../../service/agent/agent.service import { WorkflowActionService } from "../../../../service/workflow-graph/model/workflow-action.service"; import { NotificationService } from "../../../../../common/service/notification/notification.service"; import { WorkflowPersistService } from "../../../../../common/service/workflow-persist/workflow-persist.service"; +import { ProfilerService } from "../../../../service/profiler/profiler.service"; +import { buildProfilerSnapshot } from "../../../../service/profiler/profiler-snapshot"; +import { + extractPlans, + extractProposals, + mergeProposalIntoProperties, + summarizePropertyChanges, + OperatorChangeProposal, + OptimizationPlanProposal, + OptimizationPlanStep, + ProposalState, +} from "../../../../service/agent/agent-proposal"; import { ɵNzTransitionPatchDirective } from "ng-zorro-antd/core/transition-patch"; import { NzIconDirective } from "ng-zorro-antd/icon"; import { NzTooltipDirective } from "ng-zorro-antd/tooltip"; import { NzSpaceCompactItemDirective } from "ng-zorro-antd/space"; import { NzButtonComponent } from "ng-zorro-antd/button"; -import { NgIf, NgFor } from "@angular/common"; +import { NgIf, NgFor, NgSwitch, NgSwitchCase } from "@angular/common"; import { MarkdownComponent } from "ngx-markdown"; import { NzSpinComponent } from "ng-zorro-antd/spin"; import { @@ -73,6 +85,8 @@ import { NzSwitchComponent } from "ng-zorro-antd/switch"; NzButtonComponent, NgIf, NgFor, + NgSwitch, + NgSwitchCase, MarkdownComponent, NzSpinComponent, NzInputDirective, @@ -135,7 +149,8 @@ export class AgentChatComponent implements OnInit, AfterViewChecked, OnDestroy, private workflowActionService: WorkflowActionService, private notificationService: NotificationService, private cdr: ChangeDetectorRef, - private workflowPersistService: WorkflowPersistService + private workflowPersistService: WorkflowPersistService, + private profilerService: ProfilerService ) {} ngOnInit(): void { @@ -399,6 +414,128 @@ export class AgentChatComponent implements OnInit, AfterViewChecked, OnDestroy, return !!response.operatorAccess && response.operatorAccess.size > 0; } + // Phase 3 / 4 (profiler-agent-tool-plan): structured-proposal channel. + // - `proposeOperatorChange` → single Apply/Reject card under the agent message. + // - `proposeOptimizationPlan` → multi-step plan card with per-step Apply/Reject + // plus an Apply-All button. + // State is tracked per stable id (toolCallId for single proposals, synthetic + // `${toolCallId}::${index}` for plan steps) so re-renders remember user actions. + private proposalCache = new WeakMap(); + private planCache = new WeakMap(); + private proposalStateByCallId = new Map(); + + public getProposals(step: ReActStep): OperatorChangeProposal[] { + const cached = this.proposalCache.get(step); + if (cached) return cached; + const proposals = extractProposals(step); + this.proposalCache.set(step, proposals); + return proposals; + } + + public getPlans(step: ReActStep): OptimizationPlanProposal[] { + const cached = this.planCache.get(step); + if (cached) return cached; + const plans = extractPlans(step); + this.planCache.set(step, plans); + return plans; + } + + public getProposalState(stateKey: string): ProposalState { + return this.proposalStateByCallId.get(stateKey) ?? "pending"; + } + + public formatProposalSummary(p: { propertyChanges: Record }): string { + return summarizePropertyChanges(p.propertyChanges); + } + + /** + * Shared application logic used by single proposals AND individual plan steps. + * `stateKey` is what we track UI state under: toolCallId for proposals, + * `${toolCallId}::${index}` for plan steps. + */ + private applyOperatorChange( + operatorId: string, + propertyChanges: Record, + stateKey: string + ): void { + if (this.getProposalState(stateKey) !== "pending") return; + const graph = this.workflowActionService.getTexeraGraph(); + if (!graph.hasOperator(operatorId)) { + this.proposalStateByCallId.set(stateKey, "missing-operator"); + this.notificationService.warning(`Operator '${operatorId}' no longer exists in the workflow.`); + return; + } + try { + const existing = graph.getOperator(operatorId).operatorProperties; + const merged = mergeProposalIntoProperties(existing, propertyChanges); + this.workflowActionService.setOperatorProperty(operatorId, merged); + this.proposalStateByCallId.set(stateKey, "applied"); + this.notificationService.success( + `Applied: ${operatorId} ${summarizePropertyChanges(propertyChanges)}` + ); + } catch (err) { + this.proposalStateByCallId.set(stateKey, "failed"); + this.notificationService.error( + `Failed to apply change to '${operatorId}': ${err instanceof Error ? err.message : String(err)}` + ); + } + } + + public applyProposal(proposal: OperatorChangeProposal): void { + this.applyOperatorChange(proposal.operatorId, proposal.propertyChanges, proposal.toolCallId); + } + + public rejectProposal(proposal: OperatorChangeProposal): void { + if (this.getProposalState(proposal.toolCallId) !== "pending") return; + this.proposalStateByCallId.set(proposal.toolCallId, "rejected"); + } + + public applyPlanStep(step: OptimizationPlanStep): void { + this.applyOperatorChange(step.operatorId, step.propertyChanges, step.stepId); + } + + public rejectPlanStep(step: OptimizationPlanStep): void { + if (this.getProposalState(step.stepId) !== "pending") return; + this.proposalStateByCallId.set(step.stepId, "rejected"); + } + + /** + * Apply every pending step of a plan, in order. Stops on the first failure + * so downstream steps that depend on the failed one aren't applied to a + * broken state. + */ + public applyAllPlanSteps(plan: OptimizationPlanProposal): void { + for (const step of plan.steps) { + const state = this.getProposalState(step.stepId); + if (state !== "pending") continue; + this.applyPlanStep(step); + if (this.getProposalState(step.stepId) !== "applied") { + // Bail on first failure / missing-operator so later steps don't compound the error. + return; + } + } + } + + public getPlanAppliedCount(plan: OptimizationPlanProposal): number { + return plan.steps.filter(s => this.getProposalState(s.stepId) === "applied").length; + } + + public hasPendingPlanSteps(plan: OptimizationPlanProposal): boolean { + return plan.steps.some(s => this.getProposalState(s.stepId) === "pending"); + } + + public trackProposalById(_index: number, proposal: OperatorChangeProposal): string { + return proposal.toolCallId; + } + + public trackPlanById(_index: number, plan: OptimizationPlanProposal): string { + return plan.toolCallId; + } + + public trackPlanStepById(_index: number, step: OptimizationPlanStep): string { + return step.stepId; + } + public sendMessage(): void { if (!this.currentMessage.trim() || !this.canSendMessage()) { return; @@ -407,8 +544,49 @@ export class AgentChatComponent implements OnInit, AfterViewChecked, OnDestroy, const userMessage = this.currentMessage.trim(); this.currentMessage = ""; + // Attach a fresh profiler snapshot so the agent's read-only profiler tools have + // current per-operator metrics + hints to answer questions. Snapshot is omitted + // when profiling is disabled — the tools will then surface a "no data" message. + const snapshot = this.buildProfilerSnapshotForAgent(); + // Fire-and-forget; responses stream in via the WebSocket subscription. - this.agentService.sendMessage(this.agentInfo.id, userMessage); + this.agentService.sendMessage(this.agentInfo.id, userMessage, "chat", snapshot); + } + + private buildProfilerSnapshotForAgent(): unknown { + const graph = this.workflowActionService.getTexeraGraph(); + return buildProfilerSnapshot({ + state: this.profilerService.getState(), + operatorType: id => { + try { + return graph.getOperator(id)?.operatorType; + } catch { + return undefined; + } + }, + displayName: id => { + try { + const op = graph.getOperator(id); + return op?.customDisplayName?.trim() || op?.operatorType || id; + } catch { + return id; + } + }, + upstreamOps: id => { + try { + return graph.getInputLinksByOperatorId(id).map(l => l.source.operatorID); + } catch { + return []; + } + }, + downstreamOps: id => { + try { + return graph.getOutputLinksByOperatorId(id).map(l => l.target.operatorID); + } catch { + return []; + } + }, + }); } /** diff --git a/frontend/src/app/workspace/component/menu/menu.component.html b/frontend/src/app/workspace/component/menu/menu.component.html index a21e4d56429..829c2c06945 100644 --- a/frontend/src/app/workspace/component/menu/menu.component.html +++ b/frontend/src/app/workspace/component/menu/menu.component.html @@ -372,6 +372,24 @@ nzTheme="outline"> Share + + +
+ + + +
+ + Compare to previous run + + +
+
+ +
+ +
+ …or pick a past run + + + +
+
+ +
+
+
{{ profilerState.baseline?.header?.workflowName }}
+
+ Execution: {{ profilerState.baseline?.header?.executionName }} +
+
Generated: {{ profilerState.baseline?.header?.generatedAt }}
+
{{ profilerState.baseline?.operators?.length }} operators
+
+
+ + +
+ + + + +
+
+ +
+
+ Download report +
+ + +
+
+
+ +
+ + + + +
+ Turns on the bottleneck heatmap on the canvas: operators get colored by + cost (under the current View), and the side panel shows + detailed per-operator metrics and optimization hints. +
+
+ + +
+ Pick what the heatmap colors operators by: +
    +
  • Runtime — CPU time spent in the operator. Hottest = slowest to compute.
  • +
  • Throughput — inverse of output rows. Hottest = slowest producer.
  • +
  • I/O imbalance — fraction of input rows dropped. Hottest = drops the most input.
  • +
  • Δ vs baseline — green/red by runtime change against an uploaded baseline. Available once a baseline is loaded (see Compare to previous run below).
  • +
+
+
+ + +
+ An operator counts as "hot" when its normalized heat score is at or above this + percentile (0 – 100). Drives the LOW_PARALLELISM_HOT_OP hint, + which flags single-worker operators that are doing most of the workflow's work. +

+ Default: 80. Note: at 100, only the literal hottest + operator (score == 1.0) ever qualifies — practical max is around 95. +
+
+ + +
+ Upload a previously-downloaded JSON profiler report to compare the current run + against it. +

+ Once a baseline is loaded: +
    +
  • Each operator's property panel shows a "Compared to baseline" section with runtime / throughput / row deltas and an improved/regressed/unchanged tag.
  • +
  • The Δ vs baseline view in the dropdown unlocks, coloring the canvas green (improved) / red (regressed) instead of by absolute cost.
  • +
+ Use Download report → JSON on a previous run to produce the file. +
+
+ + +
+ All actionable suggestions for the current run. Mirrors the dashed "ghost" + elements on the canvas — useful when ghosts are off-screen on a large workflow. +

+ Apply performs the same action as clicking a ghost (inserts a + Filter on the edge, or bumps the operator's worker count). × + dismisses the suggestion for this session. +
+
+
{ + if (!this.runDisable) { + this.onClickRunHandler(); + } + }); + // set the map of operatorStatusMap this.validationWorkflowService .getWorkflowValidationErrorStream() @@ -257,6 +290,239 @@ export class MenuComponent implements OnInit, OnDestroy { this.computingUnitStatusSubscription.unsubscribe(); } + public profilerPopoverVisible = false; + + public toggleProfilerPopover(): void { + this.profilerPopoverVisible = !this.profilerPopoverVisible; + } + + public closeProfilerPopover(): void { + this.profilerPopoverVisible = false; + } + + public toggleProfiling(enabled: boolean): void { + this.profilerService.setEnabled(enabled); + } + + public setProfilerView(view: ProfilerView): void { + this.profilerService.setView(view); + } + + /** Apply a suggestion from the popover list — routes through the service so the + * workflow-editor component performs the actual canvas mutation. */ + public applySuggestion(s: Suggestion): void { + this.profilerSuggestionsService.requestMaterialize(s); + } + + /** Dismiss a suggestion from the popover list. */ + public dismissSuggestionFromList(s: Suggestion): void { + this.profilerSuggestionsService.dismiss(s.id); + } + + /** Short label for one suggestion row — used in the popover list. */ + public suggestionShortLabel(s: Suggestion): string { + if (s.type === "INSERT_FILTER") { + return `Insert Filter on edge`; + } + return `Bump workers → ${s.proposedWorkers}`; + } + + public trackSuggestionById(_index: number, s: Suggestion): string { + return s.id; + } + + public setProfilerHotThreshold(percentile: number): void { + this.profilerService.setHotThresholdPercentile(percentile); + } + + /** + * Returns true when there's enough data to produce a meaningful profiler report — + * profiling must be enabled AND at least one operator has stats. The download buttons + * disable themselves otherwise so users never get an empty file. + */ + public canDownloadProfilerReport(): boolean { + const state = this.profilerService.getState(); + return state.enabled && Object.keys(state.scores).length > 0; + } + + public downloadProfilerReport(format: "markdown" | "json"): void { + if (!this.canDownloadProfilerReport()) return; + + const state = this.profilerService.getState(); + const graph = this.workflowActionService.getTexeraGraph(); + const generatedAt = new Date(); + + const report = buildReport({ + workflowName: this.currentWorkflowName || DEFAULT_WORKFLOW_NAME, + executionName: this.currentExecutionName?.trim() || undefined, + generatedAt, + view: state.view, + hotThresholdPercentile: state.hotThresholdPercentile, + scores: state.scores, + operatorType: id => { + try { + return graph.getOperator(id)?.operatorType; + } catch { + return undefined; + } + }, + displayName: id => { + try { + const op = graph.getOperator(id); + return op?.customDisplayName?.trim() || op?.operatorType || id; + } catch { + return id; + } + }, + upstreamOps: id => { + try { + return graph.getInputLinksByOperatorId(id).map(l => l.source.operatorID); + } catch { + return []; + } + }, + downstreamOps: id => { + try { + return graph.getOutputLinksByOperatorId(id).map(l => l.target.operatorID); + } catch { + return []; + } + }, + }); + + const slug = slugifyForFilename(this.currentWorkflowName || DEFAULT_WORKFLOW_NAME); + const stamp = formatFilenameTimestamp(generatedAt); + if (format === "markdown") { + saveAs( + new Blob([report.markdown], { type: "text/markdown;charset=utf-8" }), + `profiler-report-${slug}-${stamp}.md` + ); + } else { + saveAs( + new Blob([JSON.stringify(report.json, null, 2)], { type: "application/json" }), + `profiler-report-${slug}-${stamp}.json` + ); + } + } + + /** + * Handles the hidden file input's change event for the "Upload baseline" + * button. Parses the chosen file as a profiler JSON report and, on success, + * registers it as the comparison baseline on ProfilerService. + */ + public onBaselineFileSelected(event: Event): void { + const input = event.target as HTMLInputElement; + const file = input?.files?.[0]; + // Reset the input value so selecting the same file twice still triggers change. + if (input) input.value = ""; + if (!file) return; + + const reader = new FileReader(); + reader.onload = () => { + try { + const parsed = parseBaselineReport(JSON.parse(reader.result as string)); + if (!parsed) { + this.notificationService.error( + "That JSON doesn't look like a profiler report. Use a file downloaded via 'Download report → JSON'." + ); + return; + } + this.profilerService.setBaseline(parsed); + this.notificationService.success( + `Baseline loaded: ${parsed.operators.length} operator${parsed.operators.length === 1 ? "" : "s"} from ${parsed.header.workflowName}.` + ); + } catch { + this.notificationService.error("Could not parse the selected file as JSON."); + } + }; + reader.onerror = () => { + this.notificationService.error("Failed to read the selected file."); + }; + reader.readAsText(file); + } + + public clearProfilerBaseline(): void { + this.profilerService.clearBaseline(); + } + + // --------------------------------------------------------------------------- + // P6 — Compare across runs (server-side baseline). + // The user picks a past execution from a dropdown; we fetch its persisted + // runtime stats, convert to BaselineReport, and hand to ProfilerService — + // reusing the exact same delta heatmap + side-panel UI as the upload flow. + // --------------------------------------------------------------------------- + + /** Past executions of the current workflow. Populated lazily on popover open. */ + public profilerHistoryExecutions: WorkflowExecutionsEntry[] = []; + public profilerHistoryLoading: boolean = false; + public profilerHistorySelectedEid: number | null = null; + + /** + * Fetches the list of completed executions for the current workflow. Idempotent: + * call from the popover's open-handler so the dropdown is populated when shown. + */ + public loadProfilerHistoryList(): void { + if (this.workflowId == null) { + this.profilerHistoryExecutions = []; + return; + } + this.profilerHistoryLoading = true; + this.profilerHistoryService + .listCompletedExecutions(this.workflowId) + .pipe(untilDestroyed(this)) + .subscribe({ + next: rows => { + this.profilerHistoryExecutions = rows; + this.profilerHistoryLoading = false; + }, + error: () => { + this.profilerHistoryExecutions = []; + this.profilerHistoryLoading = false; + }, + }); + } + + /** Human-readable label for a past execution shown in the dropdown options. */ + public profilerHistoryLabel(entry: WorkflowExecutionsEntry): string { + const name = entry.name && entry.name.trim().length > 0 ? entry.name : `Execution #${entry.eId}`; + const when = entry.completionTime + ? new Date(entry.completionTime).toLocaleString() + : entry.startingTime + ? new Date(entry.startingTime).toLocaleString() + : ""; + return when ? `${name} — ${when}` : name; + } + + /** + * Selects a past execution as the comparison baseline. Fetches the persisted + * stats, converts via the pure helper, and hands the result to ProfilerService. + */ + public onProfilerHistorySelected(eid: number | null): void { + this.profilerHistorySelectedEid = eid; + if (eid == null || this.workflowId == null) return; + const execution = this.profilerHistoryExecutions.find(e => e.eId === eid); + if (!execution) { + this.notificationService.error(`Execution #${eid} is not in the loaded list.`); + return; + } + const workflowName = this.currentWorkflowName ?? `Workflow ${this.workflowId}`; + this.profilerHistoryService + .loadBaselineForExecution({ workflowId: this.workflowId, execution, workflowName }) + .pipe(untilDestroyed(this)) + .subscribe(baseline => { + if (!baseline) { + this.notificationService.error( + "No baseline data available for that run (the engine may not have persisted stats)." + ); + return; + } + this.profilerService.setBaseline(baseline); + this.notificationService.success( + `Baseline loaded from ${baseline.header.executionName ?? "run"} (${baseline.operators.length} operators).` + ); + }); + } + private subscribeToComputingUnitSelection(): void { this.computingUnitStatusService .getSelectedComputingUnit() diff --git a/frontend/src/app/workspace/component/property-editor/operator-property-edit-frame/operator-property-edit-frame.component.html b/frontend/src/app/workspace/component/property-editor/operator-property-edit-frame/operator-property-edit-frame.component.html index 1f2c2963f29..e950fe83214 100644 --- a/frontend/src/app/workspace/component/property-editor/operator-property-edit-frame/operator-property-edit-frame.component.html +++ b/frontend/src/app/workspace/component/property-editor/operator-property-edit-frame/operator-property-edit-frame.component.html @@ -146,6 +146,145 @@ Confirm Change + + +
+
+ Heat score + {{ profilerScore | number: '1.0-2' }} +
+
+ Runtime + {{ ms | number: '1.0-1' }} ms +
+
+ Throughput + {{ tput | number: '1.0-0' }} rows/s +
+
+ Input rows + {{ currentOperatorStatus.aggregatedInputRowCount | number }} +
+
+ Output rows + {{ currentOperatorStatus.aggregatedOutputRowCount | number }} +
+
+ Input size (bytes) + {{ inSize | number }} +
+
+ Output size (bytes) + {{ outSize | number }} +
+
+ Workers + {{ workers }} +
+
+ Idle ratio + {{ idleRatio | number: '1.0-2' }} +
+
+
+
Optimization hints
+
+ {{ hint.ruleId }} + {{ hint.message }} +
+
+
+ No optimization hints for this operator. +
+
+
Compared to baseline
+ +
+ New operator in this run — not present in the baseline. +
+
+ Removed since baseline — was present in the previous run only. +
+ +
+ + {{ profilerBaselineDelta.direction }} + + Direction +
+
+ Runtime Δ + + {{ profilerBaselineDelta.runtimeMsDelta | number: '1.0-1' }} ms + +
+
+ Throughput Δ + + {{ profilerBaselineDelta.throughputRowsPerSecDelta | number: '1.0-0' }} rows/s + +
+
+ Output rows Δ + {{ profilerBaselineDelta.outputRowsDelta | number }} +
+
+ Input rows Δ + {{ profilerBaselineDelta.inputRowsDelta | number }} +
+
+ Heat score Δ + {{ profilerBaselineDelta.scoreDelta | number: '1.0-2' }} +
+
+
+ +
No comparable data for this operator.
+
+
+
+
+
Operator Version: {{ operatorVersion }}
diff --git a/frontend/src/app/workspace/component/property-editor/operator-property-edit-frame/operator-property-edit-frame.component.scss b/frontend/src/app/workspace/component/property-editor/operator-property-edit-frame/operator-property-edit-frame.component.scss index 4126a9ee1ce..74d64bcbc95 100644 --- a/frontend/src/app/workspace/component/property-editor/operator-property-edit-frame/operator-property-edit-frame.component.scss +++ b/frontend/src/app/workspace/component/property-editor/operator-property-edit-frame/operator-property-edit-frame.component.scss @@ -73,3 +73,89 @@ margin-bottom: 0; } } + +.profiler-section { + margin: 8px 16px; +} + +.profiler-metrics { + display: flex; + flex-direction: column; + gap: 4px; +} + +.profiler-metric-row { + display: flex; + justify-content: space-between; + font-size: 12px; +} + +.profiler-metric-label { + color: rgba(0, 0, 0, 0.65); +} + +.profiler-metric-value { + font-family: ui-monospace, SFMono-Regular, monospace; +} + +.profiler-hints { + margin-top: 10px; + display: flex; + flex-direction: column; + gap: 6px; +} + +.profiler-hints-title { + font-weight: 500; + font-size: 12px; + color: rgba(0, 0, 0, 0.75); +} + +.profiler-hint { + display: flex; + align-items: flex-start; + gap: 6px; + font-size: 12px; + line-height: 1.4; +} + +.profiler-hint-message { + flex: 1; +} + +.profiler-hints-empty { + margin-top: 8px; + font-size: 12px; + color: rgba(0, 0, 0, 0.45); +} + +.profiler-baseline { + margin-top: 10px; + display: flex; + flex-direction: column; + gap: 4px; + border-top: 1px solid #f0f0f0; + padding-top: 8px; +} + +.profiler-baseline-title { + font-weight: 500; + font-size: 12px; + color: rgba(0, 0, 0, 0.75); +} + +.profiler-baseline-status { + font-size: 12px; + color: rgba(0, 0, 0, 0.55); +} + +.profiler-baseline-row { + display: flex; + align-items: center; + gap: 8px; + font-size: 12px; +} + +.profiler-baseline-row-label { + color: rgba(0, 0, 0, 0.55); +} diff --git a/frontend/src/app/workspace/component/property-editor/operator-property-edit-frame/operator-property-edit-frame.component.ts b/frontend/src/app/workspace/component/property-editor/operator-property-edit-frame/operator-property-edit-frame.component.ts index c7ab561f403..141fbb1ffb7 100644 --- a/frontend/src/app/workspace/component/property-editor/operator-property-edit-frame/operator-property-edit-frame.component.ts +++ b/frontend/src/app/workspace/component/property-editor/operator-property-edit-frame/operator-property-edit-frame.component.ts @@ -61,7 +61,7 @@ import * as Y from "yjs"; import { OperatorSchema } from "src/app/workspace/types/operator-schema.interface"; import { AttributeType, PortSchema } from "../../../types/workflow-compiling.interface"; import { GuiConfigService } from "../../../../common/service/gui-config.service"; -import { NgIf } from "@angular/common"; +import { NgIf, NgFor, DecimalPipe } from "@angular/common"; import { NzSpaceCompactItemDirective } from "ng-zorro-antd/space"; import { NzButtonComponent } from "ng-zorro-antd/button"; import { ɵNzTransitionPatchDirective } from "ng-zorro-antd/core/transition-patch"; @@ -70,6 +70,16 @@ import { NzIconDirective } from "ng-zorro-antd/icon"; import { NzPopoverDirective } from "ng-zorro-antd/popover"; import { NzFormDirective } from "ng-zorro-antd/form"; import { NzWaveDirective } from "ng-zorro-antd/core/wave"; +import { NzCollapseComponent, NzCollapsePanelComponent } from "ng-zorro-antd/collapse"; +import { NzTagComponent } from "ng-zorro-antd/tag"; +import { ProfilerService } from "../../../service/profiler/profiler.service"; +import { Hint, computeHintsForOperator, HintContext } from "../../../service/profiler/profiler-hints"; +import { + computeOperatorDelta, + indexBaseline, + OperatorDelta, + statsToComparable, +} from "../../../service/profiler/profiler-delta"; Quill.register("modules/cursors", QuillCursors); @@ -96,6 +106,8 @@ Quill.register("modules/cursors", QuillCursors); styleUrls: ["./operator-property-edit-frame.component.scss"], imports: [ NgIf, + NgFor, + DecimalPipe, NzSpaceCompactItemDirective, NzButtonComponent, ɵNzTransitionPatchDirective, @@ -108,6 +120,9 @@ Quill.register("modules/cursors", QuillCursors); FormlyModule, TypeCastingDisplayComponent, NzWaveDirective, + NzCollapseComponent, + NzCollapsePanelComponent, + NzTagComponent, ], }) export class OperatorPropertyEditFrameComponent implements OnInit, OnChanges, OnDestroy { @@ -163,6 +178,15 @@ export class OperatorPropertyEditFrameComponent implements OnInit, OnChanges, On // used to tear down subscriptions that takeUntil(teardownObservable) private teardownObservable: Subject = new Subject(); + // Profiler M2 fields + public profilerEnabled: boolean = false; + public profilerScore: number | undefined; + public profilerHints: readonly Hint[] = []; + + // Profiler P6 — baseline comparison + public profilerBaselineLoaded: boolean = false; + public profilerBaselineDelta: OperatorDelta | undefined; + constructor( private formlyJsonschema: FormlyJsonschema, private workflowActionService: WorkflowActionService, @@ -173,14 +197,19 @@ export class OperatorPropertyEditFrameComponent implements OnInit, OnChanges, On private changeDetectorRef: ChangeDetectorRef, private workflowVersionService: WorkflowVersionService, private workflowStatusSerivce: WorkflowStatusService, - private config: GuiConfigService + private config: GuiConfigService, + private profilerService: ProfilerService ) {} ngOnChanges(changes: SimpleChanges): void { this.currentOperatorId = changes.currentOperatorId?.currentValue; if (!this.currentOperatorId) { + this.currentOperatorStatus = undefined; + this.refreshProfilerView(); return; } + this.currentOperatorStatus = this.workflowStatusSerivce.getCurrentStatus()[this.currentOperatorId]; + this.refreshProfilerView(); this.rerenderEditorForm(); } @@ -205,8 +234,137 @@ export class OperatorPropertyEditFrameComponent implements OnInit, OnChanges, On .subscribe(update => { if (this.currentOperatorId) { this.currentOperatorStatus = update[this.currentOperatorId]; + this.refreshProfilerView(); } }); + + this.profilerService + .getStateStream() + .pipe(untilDestroyed(this)) + .subscribe(() => this.refreshProfilerView()); + } + + private refreshProfilerView(): void { + const opId = this.currentOperatorId; + const state = this.profilerService.getState(); + this.profilerEnabled = state.enabled; + this.profilerBaselineLoaded = state.baseline !== undefined; + if (!opId) { + this.profilerScore = undefined; + this.profilerHints = []; + this.profilerBaselineDelta = undefined; + return; + } + const entry = state.scores[opId]; + this.profilerScore = entry?.score; + this.profilerBaselineDelta = this.computeBaselineDeltaFor(opId, entry); + + if (!state.enabled) { + this.profilerHints = []; + return; + } + const graph = this.workflowActionService.getTexeraGraph(); + const stats: Record = {}; + const scores: Record = {}; + for (const id of Object.keys(state.scores)) { + stats[id] = state.scores[id].stats; + scores[id] = state.scores[id].score; + } + const ctx: HintContext = { + stats, + scores, + hotThreshold: state.hotThresholdPercentile / 100, + operatorType: id => { + try { + return graph.getOperator(id)?.operatorType; + } catch { + return undefined; + } + }, + displayName: id => { + try { + const op = graph.getOperator(id); + return op?.customDisplayName?.trim() || op?.operatorType || id; + } catch { + return id; + } + }, + upstreamOps: id => { + try { + return graph.getInputLinksByOperatorId(id).map(l => l.source.operatorID); + } catch { + return []; + } + }, + downstreamOps: id => { + try { + return graph.getOutputLinksByOperatorId(id).map(l => l.target.operatorID); + } catch { + return []; + } + }, + }; + this.profilerHints = computeHintsForOperator(opId, ctx); + } + + /** + * Computes the per-operator delta against the loaded baseline (if any). + * Returns undefined when no baseline is loaded so the template can omit + * the section entirely. Handles the three match cases: + * - operator in current + baseline -> matched delta + * - operator in current but not baseline -> "new-in-current" + * - operator in baseline but not current -> caller never selects these, + * but the math handles it for completeness. + */ + private computeBaselineDeltaFor( + opId: string, + entry: { score: number; stats: OperatorStatistics } | undefined + ): OperatorDelta | undefined { + const baseline = this.profilerService.getBaseline(); + if (!baseline) return undefined; + const baselineIndex = indexBaseline(baseline); + const baselineOp = baselineIndex[opId]; + + let current = undefined; + if (entry) { + const op = this.workflowActionService.getTexeraGraph().hasOperator(opId) + ? this.workflowActionService.getTexeraGraph().getOperator(opId) + : undefined; + const displayName = op?.customDisplayName?.trim() || op?.operatorType || opId; + current = statsToComparable({ + operatorId: opId, + displayName, + operatorType: op?.operatorType, + score: entry.score, + stats: entry.stats, + }); + } + + if (!current && !baselineOp) return undefined; + return computeOperatorDelta(opId, current, baselineOp); + } + + // Template helpers — kept here to avoid template-side arithmetic. + + public getProfilerRuntimeMs(s: OperatorStatistics | undefined): number | undefined { + const t = s?.aggregatedDataProcessingTime; + return t && t > 0 ? t / 1_000_000 : undefined; + } + + public getProfilerThroughputRowsPerSec(s: OperatorStatistics | undefined): number | undefined { + const t = s?.aggregatedDataProcessingTime; + const out = s?.aggregatedOutputRowCount ?? 0; + if (!t || t <= 0 || out <= 0) return undefined; + return out / (t / 1_000_000_000); + } + + public getProfilerIdleRatio(s: OperatorStatistics | undefined): number | undefined { + if (!s) return undefined; + const data = s.aggregatedDataProcessingTime ?? 0; + const ctrl = s.aggregatedControlProcessingTime ?? 0; + const idle = s.aggregatedIdleTime ?? 0; + const total = data + ctrl + idle; + return total > 0 ? idle / total : undefined; } async ngOnDestroy() { diff --git a/frontend/src/app/workspace/component/workflow-editor/workflow-editor.component.html b/frontend/src/app/workspace/component/workflow-editor/workflow-editor.component.html index ed0b7cc748b..1d0bfd2623e 100644 --- a/frontend/src/app/workspace/component/workflow-editor/workflow-editor.component.html +++ b/frontend/src/app/workspace/component/workflow-editor/workflow-editor.component.html @@ -27,6 +27,130 @@ +
+
+ Profiler — + + Runtime + Throughput + I/O imbalance + Δ vs baseline + +
+ +
+
+ cold + hot +
+
+ +
+
+ improved + regressed +
+ +
No baseline loaded
+
+ +
+
+ +
+ + {{ profilerRunPrompt.message }} + + +
+ +
+
+ + + + Filter + + + + + workers ({{ ghost.proposedWorkers }}) + +
+ +
+ +
+
{{ profilerHover.displayName }}
+
+ {{ profilerHover.viewLabel }} + {{ profilerHover.headline }} +
+
+ Heat score + {{ profilerHover.score | number: '1.0-2' }} +
+
+
= []; + private lastSuggestions: readonly Suggestion[] = []; + + /** + * After materializing a suggestion, this floating prompt at the top of the canvas + * invites the user to re-run the workflow (the natural next step after a structural + * change). Null when no prompt is showing. Auto-dismisses after a few seconds. + */ + public profilerRunPrompt: { message: string } | null = null; + private profilerRunPromptTimeoutId?: number; + // Cached agent result summaries for port label display constructor( @@ -128,7 +168,11 @@ export class WorkflowEditorComponent implements OnInit, AfterViewInit, OnDestroy public nzContextMenu: NzContextMenuService, private elementRef: ElementRef, private config: GuiConfigService, - private agentService: AgentService + private agentService: AgentService, + public profilerService: ProfilerService, + private profilerSuggestionsService: ProfilerSuggestionsService, + private workflowUtilService: WorkflowUtilService, + private workflowCompilingService: WorkflowCompilingService ) { this.wrapper = this.workflowActionService.getJointGraphWrapper(); } @@ -188,6 +232,9 @@ export class WorkflowEditorComponent implements OnInit, AfterViewInit, OnDestroy this.handlePortHighlightEvent(); this.registerPortDisplayNameChangeHandler(); this.handleOperatorStatisticsUpdate(); + this.handleProfilerHeatmap(); + this.handleProfilerHover(); + this.handleProfilerSuggestions(); this.handleRegionEvents(); this.handleOperatorSuggestionHighlightEvent(); this.handleAgentHoverHighlight(); @@ -360,6 +407,545 @@ export class WorkflowEditorComponent implements OnInit, AfterViewInit, OnDestroy }); } + /** + * Subscribes to the profiler state stream and repaints operator bodies according to the + * computed heat scores. When profiling is disabled, restores each operator's default fill. + */ + private handleProfilerHeatmap(): void { + let lastEnabled = false; + this.profilerService + .getStateStream() + .pipe(untilDestroyed(this)) + .subscribe(state => { + const operators = this.workflowActionService.getTexeraGraph().getAllOperators(); + if (!state.enabled) { + if (lastEnabled) { + operators.forEach(op => this.jointUIService.resetOperatorHeatmap(this.paper, op)); + } + lastEnabled = false; + return; + } + lastEnabled = true; + + if (state.view === "delta") { + this.applyDeltaHeatmap(state, operators); + return; + } + + operators.forEach(op => { + const entry = state.scores[op.operatorID]; + if (!entry) { + this.jointUIService.resetOperatorHeatmap(this.paper, op); + return; + } + const measurableCost = + (entry.stats.aggregatedDataProcessingTime ?? 0) > 0 || + (entry.stats.aggregatedOutputRowCount ?? 0) > 0; + this.jointUIService.changeOperatorHeatmap( + this.paper, + op.operatorID, + entry.score, + entry.state, + measurableCost + ); + }); + }); + } + + /** + * Paints the canvas with green/red intensities based on per-operator runtime + * deltas against the loaded baseline. Falls back to default fills when no + * baseline is loaded. + */ + private applyDeltaHeatmap(state: ProfilerState, operators: readonly OperatorPredicate[]): void { + if (!state.baseline) { + operators.forEach(op => this.jointUIService.resetOperatorHeatmap(this.paper, op)); + return; + } + const baselineIndex = indexBaseline(state.baseline); + const currentMap: Record = {}; + for (const op of operators) { + const entry = state.scores[op.operatorID]; + if (!entry) continue; + const displayName = op.customDisplayName?.trim() || op.operatorType || op.operatorID; + currentMap[op.operatorID] = statsToComparable({ + operatorId: op.operatorID, + displayName, + operatorType: op.operatorType, + score: entry.score, + stats: entry.stats, + }); + } + const deltas = computeAllDeltas(currentMap, baselineIndex); + const maxAbs = maxAbsRuntimeDelta(deltas); + + operators.forEach(op => { + const delta = deltas[op.operatorID]; + if (!delta) { + // operator has no entry on either side (not yet seen this run, no baseline) + this.jointUIService.resetOperatorHeatmap(this.paper, op); + return; + } + const intensity = computeDeltaIntensity(delta, maxAbs); + const model = this.paper.getModelById(op.operatorID); + if (!model) return; + model.attr("rect.body/fill", JointUIService.getDeltaHeatmapColor(intensity)); + }); + } + + /** + * Subscribes to JointJS element hover events and surfaces a small floating card + * with the operator's display name, current-view headline metric, and heat score. + * + * Visibility rules: + * - Card only appears when profiling is enabled AND the operator has an entry in scores. + * - Card is non-interactive (pointer-events: none) so it never steals clicks. + * - Card hides on mouseleave, on canvas pan/click, or when profiling is toggled off. + */ + private handleProfilerHover(): void { + fromJointPaperEvent(this.paper, "element:mouseenter") + .pipe(untilDestroyed(this)) + .subscribe(([cellView, evt]: any) => { + const opId = cellView?.model?.id?.toString(); + if (!opId) return; + const state = this.profilerService.getState(); + if (!state.enabled) return; + const entry = state.scores[opId]; + if (!entry) return; + + const wrapperRect = this.editorWrapper?.getBoundingClientRect(); + if (!wrapperRect) return; + const clientX = evt?.clientX ?? 0; + const clientY = evt?.clientY ?? 0; + const op = this.workflowActionService.getTexeraGraph().hasOperator(opId) + ? this.workflowActionService.getTexeraGraph().getOperator(opId) + : undefined; + const displayName = op?.customDisplayName?.trim() || op?.operatorType || opId; + + // In delta view, headline shows the current-vs-baseline runtime gap (if any). + // In all other views, headline shows the view's primary metric. + let headline: string | undefined; + if (state.view === "delta" && state.baseline) { + const baselineOp = indexBaseline(state.baseline)[opId]; + if (baselineOp) { + const currentRuntimeMs = + entry.stats.aggregatedDataProcessingTime && entry.stats.aggregatedDataProcessingTime > 0 + ? entry.stats.aggregatedDataProcessingTime / 1_000_000 + : null; + const runtimeMsDelta = + currentRuntimeMs !== null && baselineOp.runtimeMs !== null + ? currentRuntimeMs - baselineOp.runtimeMs + : null; + headline = formatDeltaHoverHeadline(runtimeMsDelta); + } + } else { + headline = formatHoverHeadline(state.view, entry.stats); + } + + this.profilerHover = { + displayName, + viewLabel: formatViewLabel(state.view), + headline, + score: entry.score, + x: clientX - wrapperRect.left + 12, + y: clientY - wrapperRect.top + 12, + }; + this.changeDetectorRef.detectChanges(); + }); + + fromJointPaperEvent(this.paper, "element:mouseleave") + .pipe(untilDestroyed(this)) + .subscribe(() => this.clearProfilerHover()); + + fromJointPaperEvent(this.paper, "blank:pointerdown") + .pipe(untilDestroyed(this)) + .subscribe(() => this.clearProfilerHover()); + + // Hide when profiling is toggled off so the stale card doesn't linger. + this.profilerService + .getStateStream() + .pipe(untilDestroyed(this)) + .subscribe(state => { + if (!state.enabled && this.profilerHover) { + this.clearProfilerHover(); + } + }); + } + + private clearProfilerHover(): void { + if (this.profilerHover) { + this.profilerHover = null; + this.changeDetectorRef.detectChanges(); + } + } + + /** + * Subscribes to the profiler suggestions stream and renders one HTML "ghost" + * overlay per suggestion. Ghosts are positioned at the midpoint of the edge they + * represent, in wrapper-relative pixel coordinates. Repositions on canvas pan/zoom + * so the ghosts stay anchored. + */ + private handleProfilerSuggestions(): void { + this.profilerSuggestionsService + .getSuggestionsStream() + .pipe(untilDestroyed(this)) + .subscribe(suggestions => { + this.lastSuggestions = suggestions; + this.recomputeGhostPositions(); + }); + + // The menu popover's "Apply" buttons fire materialize requests through the service + // (instead of depending on this editor component directly). Subscribe and route them + // through the same materialize logic the canvas ghost click uses. + this.profilerSuggestionsService + .getMaterializeRequestStream() + .pipe(untilDestroyed(this)) + .subscribe(s => this.materializeSuggestion(s)); + + // JointJS emits `translate` / `scale` events when the user pans/zooms the canvas. + // Reposition ghosts on each so they remain anchored to their edges. + this.paper.on("translate", () => this.recomputeGhostPositions()); + this.paper.on("scale", () => this.recomputeGhostPositions()); + } + + private recomputeGhostPositions(): void { + if (!this.editorWrapper) { + this.profilerGhosts = []; + return; + } + const wrapperRect = this.editorWrapper.getBoundingClientRect(); + const positioned: Array = []; + // Track per-anchor occupancy so overlapping ghosts get a small vertical offset. + const anchorBucket = new Map(); + for (const s of this.lastSuggestions) { + const placed = this.computeGhostPosition(s, wrapperRect, anchorBucket); + if (placed) positioned.push(placed); + } + this.profilerGhosts = positioned; + this.changeDetectorRef.detectChanges(); + } + + /** + * Computes wrapper-relative pixel coordinates for a single suggestion. Branches + * on suggestion type so edge ghosts (INSERT_FILTER) sit at edge midpoints and + * operator-attached ghosts (BUMP_WORKERS) sit above their operator. Returns + * undefined when the referenced operator(s) are no longer on the canvas. + */ + private computeGhostPosition( + s: Suggestion, + wrapperRect: DOMRect, + anchorBucket: Map + ): (Suggestion & { x: number; y: number }) | undefined { + if (s.type === "INSERT_FILTER") { + const upElem = this.paper.getModelById(s.upstreamOpId); + const downElem = this.paper.getModelById(s.downstreamOpId); + if (!upElem || !downElem) return undefined; + const upBBox = (upElem as joint.dia.Element).getBBox(); + const downBBox = (downElem as joint.dia.Element).getBBox(); + const midPaperX = (upBBox.x + upBBox.width + downBBox.x) / 2; + const midPaperY = (upBBox.y + upBBox.height / 2 + downBBox.y + downBBox.height / 2) / 2; + const pagePoint = this.paper.localToPagePoint(midPaperX, midPaperY); + const anchorKey = `edge:${s.upstreamOpId}->${s.downstreamOpId}`; + const stackIdx = anchorBucket.get(anchorKey) ?? 0; + anchorBucket.set(anchorKey, stackIdx + 1); + return { + ...s, + x: pagePoint.x - wrapperRect.left, + y: pagePoint.y - wrapperRect.top + stackIdx * 32, + }; + } + // BUMP_WORKERS: anchor above the operator. + const opElem = this.paper.getModelById(s.operatorId); + if (!opElem) return undefined; + const bbox = (opElem as joint.dia.Element).getBBox(); + const paperX = bbox.x + bbox.width / 2; + const paperY = bbox.y - 24; // 24px above the operator's top edge + const pagePoint = this.paper.localToPagePoint(paperX, paperY); + const anchorKey = `op:${s.operatorId}`; + const stackIdx = anchorBucket.get(anchorKey) ?? 0; + anchorBucket.set(anchorKey, stackIdx + 1); + return { + ...s, + x: pagePoint.x - wrapperRect.left, + y: pagePoint.y - wrapperRect.top - stackIdx * 32, + }; + } + + /** + * Applies a ghost suggestion to the canvas. Branches on the suggestion type: + * - INSERT_FILTER → inserts a Filter operator on the upstream→downstream edge. + * - BUMP_WORKERS → sets the operator's `workers` property to the proposed value. + * In both cases, after the change we highlight the affected operator so the + * property panel opens for the user to fine-tune. + */ + public materializeSuggestion(ghost: Suggestion): void { + if (ghost.type === "INSERT_FILTER") { + void this.materializeInsertFilter(ghost); + return; + } + if (ghost.type === "BUMP_WORKERS") { + void this.materializeBumpWorkers(ghost); + return; + } + } + + /** Agent timeout used by materialize handlers — short enough to keep UX snappy. */ + private static readonly AGENT_PROPOSAL_TIMEOUT_MS = 4000; + + private async materializeInsertFilter( + ghost: Extract + ): Promise { + const upElem = this.paper.getModelById(ghost.upstreamOpId); + const downElem = this.paper.getModelById(ghost.downstreamOpId); + if (!upElem || !downElem) { + this.profilerSuggestionsService.dismiss(ghost.id); + return; + } + const upBBox = (upElem as joint.dia.Element).getBBox(); + const downBBox = (downElem as joint.dia.Element).getBBox(); + const newPos = { + x: (upBBox.x + upBBox.width + downBBox.x) / 2 - 60, + y: (upBBox.y + upBBox.height / 2 + downBBox.y + downBBox.height / 2) / 2 - 30, + }; + + // Try the agent for a smarter set of predicate rows; fall back to a single + // `is not null` row on the first upstream column if the agent isn't available + // or returns nothing useful (deferred items from profiler-agent-tool-plan). + const predicates = await this.proposeFilterPredicates(ghost); + + const newFilterBase = this.workflowUtilService.getNewOperatorPredicate("Filter"); + const newFilter: OperatorPredicate = + predicates.length > 0 + ? { + ...newFilterBase, + operatorProperties: { + ...newFilterBase.operatorProperties, + predicates, + }, + } + : newFilterBase; + + const graph = this.workflowActionService.getTexeraGraph(); + const existingLinks = graph + .getOutputLinksByOperatorId(ghost.upstreamOpId) + .filter(l => l.target.operatorID === ghost.downstreamOpId); + + graph.bundleActions(() => { + this.workflowActionService.addOperator(newFilter, newPos); + if (existingLinks.length > 0) { + const linkToBreak = existingLinks[0]; + const newInputPortId = newFilter.inputPorts[0]?.portID; + const newOutputPortId = newFilter.outputPorts[0]?.portID; + if (newInputPortId && newOutputPortId) { + this.workflowActionService.deleteLink(linkToBreak.source, linkToBreak.target); + this.workflowActionService.addLink({ + linkID: this.workflowUtilService.getLinkRandomUUID(), + source: linkToBreak.source, + target: { operatorID: newFilter.operatorID, portID: newInputPortId }, + }); + this.workflowActionService.addLink({ + linkID: this.workflowUtilService.getLinkRandomUUID(), + source: { operatorID: newFilter.operatorID, portID: newOutputPortId }, + target: linkToBreak.target, + }); + } + } + }); + + // Open the property panel on the new Filter so the user can configure its predicate. + this.workflowActionService.getJointGraphWrapper().setMultiSelectMode(false); + this.workflowActionService.getJointGraphWrapper().highlightOperators(newFilter.operatorID); + + this.profilerSuggestionsService.dismiss(ghost.id); + this.showRunPrompt(ghost); + } + + /** + * Picks the first column name from the upstream operator's output schema, or + * `undefined` if the schema isn't available (e.g. workflow hasn't been compiled + * yet). Used as the rule-based fallback when the agent isn't available. + */ + private firstUpstreamColumnName(upstreamOpId: string): string | undefined { + const outputMap = this.workflowCompilingService.getOperatorOutputSchemaMap(upstreamOpId); + if (!outputMap) return undefined; + for (const portKey of Object.keys(outputMap)) { + const portSchema = outputMap[portKey]; + if (portSchema && portSchema.length > 0) { + return portSchema[0].attributeName; + } + } + return undefined; + } + + /** + * Collects the upstream operator's output schema as a flat attribute list (first + * non-empty port wins). Used to build the request body for the agent's + * proposeFilterPredicate endpoint. + */ + private upstreamSchemaForAgent( + upstreamOpId: string + ): { attributeName: string; attributeType: string }[] { + const outputMap = this.workflowCompilingService.getOperatorOutputSchemaMap(upstreamOpId); + if (!outputMap) return []; + for (const portKey of Object.keys(outputMap)) { + const portSchema = outputMap[portKey]; + if (portSchema && portSchema.length > 0) { + return portSchema.map(a => ({ attributeName: a.attributeName, attributeType: a.attributeType })); + } + } + return []; + } + + /** + * Returns the predicate rows to pre-populate a newly-inserted Filter operator + * with. Calls the agent for context-aware predicates and falls back to a single + * `is not null` row on the first upstream column if the agent is unavailable, + * times out, or returns nothing useful. Returns [] only when the schema isn't + * available either (workflow hasn't been compiled yet). + */ + private async proposeFilterPredicates( + ghost: Extract + ): Promise<{ attribute: string; condition: string; value: string }[]> { + const upstreamSchema = this.upstreamSchemaForAgent(ghost.upstreamOpId); + if (upstreamSchema.length > 0) { + const graph = this.workflowActionService.getTexeraGraph(); + let downstreamType: string | undefined; + let downstreamProperties: Record | undefined; + if (graph.hasOperator(ghost.downstreamOpId)) { + const down = graph.getOperator(ghost.downstreamOpId); + downstreamType = down.operatorType; + downstreamProperties = { ...(down.operatorProperties ?? {}) }; + } + const proposal = await this.agentService.tryProposeFilterPredicate( + { + upstreamOpId: ghost.upstreamOpId, + downstreamOpId: ghost.downstreamOpId, + upstreamSchema, + downstreamType, + downstreamProperties, + }, + WorkflowEditorComponent.AGENT_PROPOSAL_TIMEOUT_MS + ); + if (proposal && proposal.predicates.length > 0) return proposal.predicates; + } + const firstColumn = this.firstUpstreamColumnName(ghost.upstreamOpId); + return firstColumn + ? [{ attribute: firstColumn, condition: "is not null", value: "" }] + : []; + } + + private async materializeBumpWorkers( + ghost: Extract + ): Promise { + const graph = this.workflowActionService.getTexeraGraph(); + if (!graph.hasOperator(ghost.operatorId)) { + this.profilerSuggestionsService.dismiss(ghost.id); + return; + } + const op = graph.getOperator(ghost.operatorId); + + // Try the agent for a runtime/idle-ratio-aware worker count; fall back to + // ghost.proposedWorkers (the static rule-based default) on any miss. + const targetWorkers = await this.proposeWorkerCount(ghost, op); + + // Merge: preserve all other properties, override `workers`. + const newProperties = { + ...(op.operatorProperties ?? {}), + workers: targetWorkers, + }; + this.workflowActionService.setOperatorProperty(ghost.operatorId, newProperties); + + // Open the property panel on the bumped operator so the user can verify / fine-tune. + this.workflowActionService.getJointGraphWrapper().setMultiSelectMode(false); + this.workflowActionService.getJointGraphWrapper().highlightOperators(ghost.operatorId); + + this.profilerSuggestionsService.dismiss(ghost.id); + this.showRunPrompt(ghost); + } + + /** + * Returns the worker count to bump to. Reads the latest profiler entry to + * give the agent runtime + idle-ratio context, then falls back to the + * static rule-based default (`ghost.proposedWorkers`) on any miss. + */ + private async proposeWorkerCount( + ghost: Extract, + op: OperatorPredicate + ): Promise { + const currentWorkers = Number((op.operatorProperties as any)?.workers ?? 1) || 1; + const profilerEntry = this.profilerService.getState().scores[ghost.operatorId]; + // Use the same derivation as the profiler snapshot so the agent sees the same + // runtime/idle numbers it would see in chat — keeps suggestions consistent. + const derived = profilerEntry + ? statsToComparable({ + operatorId: ghost.operatorId, + displayName: op.customDisplayName ?? op.operatorType, + operatorType: op.operatorType, + score: profilerEntry.score, + stats: profilerEntry.stats, + }) + : null; + const proposal = await this.agentService.tryProposeWorkerCount( + { + operatorId: ghost.operatorId, + operatorType: op.operatorType, + currentWorkers, + runtimeMs: derived?.runtimeMs ?? null, + idleRatio: derived?.idleRatio ?? null, + inputRows: derived?.inputRows ?? null, + outputRows: derived?.outputRows ?? null, + }, + WorkflowEditorComponent.AGENT_PROPOSAL_TIMEOUT_MS + ); + return proposal?.workers ?? ghost.proposedWorkers; + } + + public dismissSuggestion(ghost: Suggestion, event: MouseEvent): void { + event.stopPropagation(); + this.profilerSuggestionsService.dismiss(ghost.id); + } + + public trackGhostById(_index: number, ghost: Suggestion): string { + return ghost.id; + } + + /** + * Briefly surface a "Run now" prompt at the top of the canvas after the user + * materializes a suggestion. Auto-dismisses after 10 seconds; the user can also + * click ×. The Run button itself stays the canonical way to execute — this is just + * a one-click shortcut so the user doesn't have to chase the toolbar after applying. + */ + private showRunPrompt(suggestion: Suggestion): void { + const message = + suggestion.type === "INSERT_FILTER" + ? "Filter inserted — re-run to see how the workflow performs with the change?" + : "Worker count updated — re-run to see how the workflow performs with the change?"; + this.profilerRunPrompt = { message }; + if (this.profilerRunPromptTimeoutId !== undefined) { + window.clearTimeout(this.profilerRunPromptTimeoutId); + } + this.profilerRunPromptTimeoutId = window.setTimeout(() => { + this.profilerRunPrompt = null; + this.profilerRunPromptTimeoutId = undefined; + this.changeDetectorRef.detectChanges(); + }, 10000); + this.changeDetectorRef.detectChanges(); + } + + public triggerRunFromPrompt(): void { + this.profilerSuggestionsService.requestWorkflowRun(); + this.dismissRunPrompt(); + } + + public dismissRunPrompt(): void { + this.profilerRunPrompt = null; + if (this.profilerRunPromptTimeoutId !== undefined) { + window.clearTimeout(this.profilerRunPromptTimeoutId); + this.profilerRunPromptTimeoutId = undefined; + } + this.changeDetectorRef.detectChanges(); + } + private handleRegionEvents(): void { this.editor.classList.add("hide-region"); const Region = joint.dia.Element.define( diff --git a/frontend/src/app/workspace/service/agent/agent-proposal-requests.spec.ts b/frontend/src/app/workspace/service/agent/agent-proposal-requests.spec.ts new file mode 100644 index 00000000000..c81e730a204 --- /dev/null +++ b/frontend/src/app/workspace/service/agent/agent-proposal-requests.spec.ts @@ -0,0 +1,125 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { describe, it, expect } from "vitest"; +import { + FILTER_CONDITIONS, + parseFilterPredicatesResponse, + parseWorkerCountResponse, +} from "./agent-proposal-requests"; + +describe("parseFilterPredicatesResponse", () => { + it("accepts a single well-formed predicate", () => { + const out = parseFilterPredicatesResponse({ + predicates: [{ attribute: "country", condition: "=", value: "US" }], + reasoning: "downstream groups by country", + }); + expect(out).toEqual({ + predicates: [{ attribute: "country", condition: "=", value: "US" }], + reasoning: "downstream groups by country", + }); + }); + + it("accepts up to 5 predicates and preserves order", () => { + const predicates = [ + { attribute: "a", condition: "is not null", value: "" }, + { attribute: "b", condition: ">", value: "0" }, + { attribute: "c", condition: "=", value: "x" }, + { attribute: "d", condition: "contains", value: "foo" }, + { attribute: "e", condition: "regex", value: "^x" }, + ]; + const out = parseFilterPredicatesResponse({ predicates, reasoning: "r" }); + expect(out?.predicates.map(p => p.attribute)).toEqual(["a", "b", "c", "d", "e"]); + }); + + it("accepts every condition in the FILTER_CONDITIONS enum", () => { + for (const c of FILTER_CONDITIONS) { + const out = parseFilterPredicatesResponse({ + predicates: [{ attribute: "x", condition: c, value: c === "is null" || c === "is not null" ? "" : "v" }], + reasoning: "r", + }); + expect(out, `condition ${c} should parse`).toBeDefined(); + } + }); + + it.each([ + ["null body", null], + ["string body", "oops"], + ["empty object", {}], + ["missing predicates", { reasoning: "r" }], + ["missing reasoning", { predicates: [{ attribute: "a", condition: "=", value: "v" }] }], + ["empty predicates array", { predicates: [], reasoning: "r" }], + [ + "more than 5 predicates", + { + predicates: Array.from({ length: 6 }, () => ({ attribute: "a", condition: "=", value: "v" })), + reasoning: "r", + }, + ], + ["empty reasoning", { predicates: [{ attribute: "a", condition: "=", value: "v" }], reasoning: "" }], + [ + "unknown condition", + { predicates: [{ attribute: "a", condition: "starts with", value: "v" }], reasoning: "r" }, + ], + [ + "missing attribute on a row", + { predicates: [{ condition: "=", value: "v" }], reasoning: "r" }, + ], + [ + "non-string value on a row", + { predicates: [{ attribute: "a", condition: "=", value: 42 }], reasoning: "r" }, + ], + ])("returns undefined for malformed response (%s)", (_label, raw) => { + expect(parseFilterPredicatesResponse(raw)).toBeUndefined(); + }); + + it("rejects the whole proposal if any row is malformed (all-or-nothing)", () => { + const out = parseFilterPredicatesResponse({ + predicates: [ + { attribute: "ok", condition: "=", value: "v" }, + { attribute: "", condition: "=", value: "v" }, // bad + ], + reasoning: "r", + }); + expect(out).toBeUndefined(); + }); +}); + +describe("parseWorkerCountResponse", () => { + it("accepts a well-formed integer in [1, 64]", () => { + expect(parseWorkerCountResponse({ workers: 4, reasoning: "r" })).toEqual({ workers: 4, reasoning: "r" }); + expect(parseWorkerCountResponse({ workers: 1, reasoning: "r" })?.workers).toBe(1); + expect(parseWorkerCountResponse({ workers: 64, reasoning: "r" })?.workers).toBe(64); + }); + + it.each([ + ["null body", null], + ["empty object", {}], + ["zero workers", { workers: 0, reasoning: "r" }], + ["negative workers", { workers: -1, reasoning: "r" }], + ["fractional workers", { workers: 4.5, reasoning: "r" }], + ["above max", { workers: 65, reasoning: "r" }], + ["string workers", { workers: "4", reasoning: "r" }], + ["missing reasoning", { workers: 4 }], + ["empty reasoning", { workers: 4, reasoning: "" }], + ["non-string reasoning", { workers: 4, reasoning: 1 }], + ])("returns undefined for malformed response (%s)", (_label, raw) => { + expect(parseWorkerCountResponse(raw)).toBeUndefined(); + }); +}); diff --git a/frontend/src/app/workspace/service/agent/agent-proposal-requests.ts b/frontend/src/app/workspace/service/agent/agent-proposal-requests.ts new file mode 100644 index 00000000000..432d1234c10 --- /dev/null +++ b/frontend/src/app/workspace/service/agent/agent-proposal-requests.ts @@ -0,0 +1,130 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +/** + * Pure helpers for the on-demand proposal HTTP endpoints — deferred items from + * the profiler-agent-tool plan. Used to make smarter defaults when the user + * materializes a ghost suggestion (Filter predicate, bump-workers target). + * + * Architecture: AgentService POSTs to /api/proposals/* and the response is + * validated here. Any malformed shape → return undefined so the caller's + * rule-based fallback kicks in (the agent is enhancement, not load-bearing). + */ + +/** Conditions accepted by the Filter operator's predicate rows. Mirrors the backend Zod enum. */ +export const FILTER_CONDITIONS = [ + "=", + "!=", + ">", + ">=", + "<", + "<=", + "is null", + "is not null", + "contains", + "does not contain", + "regex", +] as const; + +export type FilterCondition = (typeof FILTER_CONDITIONS)[number]; + +export interface FilterPredicateRow { + attribute: string; + condition: FilterCondition; + value: string; +} + +export interface FilterPredicatesProposal { + predicates: FilterPredicateRow[]; + reasoning: string; +} + +export interface WorkerCountProposal { + workers: number; + reasoning: string; +} + +/** + * Validate a raw response body from /api/proposals/filter-predicate. Returns + * undefined on any malformed shape (missing fields, wrong types, empty array, + * unknown condition, etc.) so the caller falls back to the rule-based default. + */ +export function parseFilterPredicatesResponse(raw: unknown): FilterPredicatesProposal | undefined { + if (!raw || typeof raw !== "object") return undefined; + const { predicates, reasoning } = raw as Record; + if (!Array.isArray(predicates) || predicates.length === 0 || predicates.length > 5) return undefined; + if (typeof reasoning !== "string" || reasoning.length === 0) return undefined; + const valid: FilterPredicateRow[] = []; + for (const p of predicates) { + const row = parsePredicateRow(p); + if (!row) return undefined; // any bad row poisons the whole proposal + valid.push(row); + } + return { predicates: valid, reasoning }; +} + +function parsePredicateRow(raw: unknown): FilterPredicateRow | undefined { + if (!raw || typeof raw !== "object") return undefined; + const { attribute, condition, value } = raw as Record; + if (typeof attribute !== "string" || attribute.length === 0) return undefined; + if (typeof condition !== "string") return undefined; + if (!(FILTER_CONDITIONS as readonly string[]).includes(condition)) return undefined; + // The backend allows empty value only when condition is is null / is not null. + // We don't enforce that on parse — the user can edit it, and being strict + // here would discard otherwise-usable proposals. + if (typeof value !== "string") return undefined; + return { attribute, condition: condition as FilterCondition, value }; +} + +/** + * Validate a raw response body from /api/proposals/worker-count. Returns + * undefined on any malformed shape. + */ +export function parseWorkerCountResponse(raw: unknown): WorkerCountProposal | undefined { + if (!raw || typeof raw !== "object") return undefined; + const { workers, reasoning } = raw as Record; + if (typeof workers !== "number" || !Number.isInteger(workers) || workers < 1 || workers > 64) { + return undefined; + } + if (typeof reasoning !== "string" || reasoning.length === 0) return undefined; + return { workers, reasoning }; +} + +/** Request body for POST /api/proposals/filter-predicate. */ +export interface FilterPredicateRequest { + upstreamOpId: string; + downstreamOpId: string; + upstreamSchema: { attributeName: string; attributeType: string }[]; + downstreamType?: string; + downstreamProperties?: Record; + upstreamSamples?: Record[]; + modelType?: string; +} + +/** Request body for POST /api/proposals/worker-count. */ +export interface WorkerCountRequest { + operatorId: string; + operatorType: string; + currentWorkers: number; + runtimeMs?: number | null; + idleRatio?: number | null; + inputRows?: number | null; + outputRows?: number | null; + modelType?: string; +} diff --git a/frontend/src/app/workspace/service/agent/agent-proposal.spec.ts b/frontend/src/app/workspace/service/agent/agent-proposal.spec.ts new file mode 100644 index 00000000000..6126d731363 --- /dev/null +++ b/frontend/src/app/workspace/service/agent/agent-proposal.spec.ts @@ -0,0 +1,315 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { describe, it, expect } from "vitest"; +import { + PROPOSE_OPERATOR_CHANGE_TOOL_NAME, + PROPOSE_OPTIMIZATION_PLAN_TOOL_NAME, + extractPlans, + extractProposals, + mergeProposalIntoProperties, + summarizePropertyChanges, +} from "./agent-proposal"; +import { ReActStep } from "./agent-types"; + +function makeStep(overrides: Partial = {}): ReActStep { + return { + id: "step-1", + messageId: "msg-1", + stepId: 1, + timestamp: new Date(), + role: "agent", + content: "", + isBegin: true, + isEnd: false, + toolCalls: [], + ...overrides, + } as ReActStep; +} + +function makeProposalCall(overrides: Record = {}) { + return { + toolName: PROPOSE_OPERATOR_CHANGE_TOOL_NAME, + toolCallId: "tc-1", + input: { + operatorId: "python-udf-1", + propertyChanges: { workers: 4 }, + reasoning: "RUNTIME_OUTLIER and LOW_PARALLELISM_HOT_OP fired.", + expectedImpact: "Cuts runtime via parallelism.", + firingHints: ["RUNTIME_OUTLIER", "LOW_PARALLELISM_HOT_OP"], + ...overrides, + }, + }; +} + +describe("extractProposals", () => { + it("returns [] for non-agent steps", () => { + const step = makeStep({ role: "user", toolCalls: [makeProposalCall()] }); + expect(extractProposals(step)).toEqual([]); + }); + + it("returns [] when toolCalls is missing or empty", () => { + expect(extractProposals(makeStep({ toolCalls: undefined }))).toEqual([]); + expect(extractProposals(makeStep({ toolCalls: [] }))).toEqual([]); + }); + + it("ignores tool calls whose name does not match", () => { + const step = makeStep({ + toolCalls: [ + { toolName: "getProfilerSummary", toolCallId: "tc-x", input: {} }, + { toolName: "modifyOperator", toolCallId: "tc-y", input: { operatorId: "op" } }, + ], + }); + expect(extractProposals(step)).toEqual([]); + }); + + it("extracts a single well-formed proposal with all fields", () => { + const step = makeStep({ toolCalls: [makeProposalCall()] }); + const out = extractProposals(step); + expect(out).toHaveLength(1); + expect(out[0]).toEqual({ + toolCallId: "tc-1", + operatorId: "python-udf-1", + propertyChanges: { workers: 4 }, + reasoning: "RUNTIME_OUTLIER and LOW_PARALLELISM_HOT_OP fired.", + expectedImpact: "Cuts runtime via parallelism.", + firingHints: ["RUNTIME_OUTLIER", "LOW_PARALLELISM_HOT_OP"], + }); + }); + + it("extracts multiple independent proposals from one step", () => { + const step = makeStep({ + toolCalls: [ + makeProposalCall({ operatorId: "op-a" }), + { ...makeProposalCall({ operatorId: "op-b" }), toolCallId: "tc-2" }, + ], + }); + const out = extractProposals(step); + expect(out.map(p => p.operatorId)).toEqual(["op-a", "op-b"]); + expect(out.map(p => p.toolCallId)).toEqual(["tc-1", "tc-2"]); + }); + + it("makes firingHints undefined when omitted", () => { + const call = makeProposalCall({ firingHints: undefined }); + const out = extractProposals(makeStep({ toolCalls: [call] })); + expect(out[0].firingHints).toBeUndefined(); + }); + + it("makes firingHints undefined when shape is invalid (non-array or mixed types)", () => { + const a = extractProposals(makeStep({ toolCalls: [makeProposalCall({ firingHints: "RUNTIME_OUTLIER" })] })); + expect(a[0].firingHints).toBeUndefined(); + const b = extractProposals(makeStep({ toolCalls: [makeProposalCall({ firingHints: ["ok", 42] })] })); + expect(b[0].firingHints).toBeUndefined(); + }); + + it.each([ + ["missing operatorId", { operatorId: undefined }], + ["empty operatorId", { operatorId: "" }], + ["non-string operatorId", { operatorId: 42 }], + ["missing propertyChanges", { propertyChanges: undefined }], + ["null propertyChanges", { propertyChanges: null }], + ["array propertyChanges", { propertyChanges: [1, 2] }], + ["missing reasoning", { reasoning: undefined }], + ["non-string reasoning", { reasoning: 1 }], + ["missing expectedImpact", { expectedImpact: undefined }], + ])("silently drops a malformed proposal (%s)", (_label, override) => { + const call = makeProposalCall(); + Object.assign(call.input, override); + expect(extractProposals(makeStep({ toolCalls: [call] }))).toEqual([]); + }); + + it("skips a tool call with no toolCallId (cannot track UI state without one)", () => { + const call = makeProposalCall(); + delete (call as any).toolCallId; + expect(extractProposals(makeStep({ toolCalls: [call] }))).toEqual([]); + }); +}); + +describe("mergeProposalIntoProperties", () => { + it("preserves unchanged keys", () => { + const merged = mergeProposalIntoProperties({ workers: 1, mode: "stream", enabled: true }, { workers: 4 }); + expect(merged).toEqual({ workers: 4, mode: "stream", enabled: true }); + }); + + it("handles undefined existing as empty object", () => { + expect(mergeProposalIntoProperties(undefined, { x: 1 })).toEqual({ x: 1 }); + }); + + it("changes take precedence over existing values", () => { + expect(mergeProposalIntoProperties({ a: 1 }, { a: 99 })).toEqual({ a: 99 }); + }); + + it("does not mutate inputs", () => { + const existing = { a: 1, b: 2 }; + const changes = { b: 99 }; + mergeProposalIntoProperties(existing, changes); + expect(existing).toEqual({ a: 1, b: 2 }); + expect(changes).toEqual({ b: 99 }); + }); +}); + +describe("extractPlans", () => { + function makePlanCall(overrides: Record = {}) { + return { + toolName: PROPOSE_OPTIMIZATION_PLAN_TOOL_NAME, + toolCallId: "plan-tc-1", + input: { + planTitle: "Reduce Python UDF load", + planRationale: "SCAN_FULL_TABLE_NO_FILTER and LOW_PARALLELISM_HOT_OP feed the same hot path.", + firingHints: ["SCAN_FULL_TABLE_NO_FILTER", "LOW_PARALLELISM_HOT_OP"], + steps: [ + { + operatorId: "filter-1", + propertyChanges: { predicate: "is not null" }, + description: "Add a Filter upstream of the UDF", + reasoning: "SCAN_FULL_TABLE_NO_FILTER", + expectedImpact: "Drops rows before the UDF", + }, + { + operatorId: "python-udf-1", + propertyChanges: { workers: 4 }, + description: "Bump UDF workers to 4", + reasoning: "LOW_PARALLELISM_HOT_OP", + expectedImpact: "Parallelizes the remaining work", + }, + ], + ...overrides, + }, + }; + } + + it("returns [] for non-agent steps", () => { + const step = makeStep({ role: "user", toolCalls: [makePlanCall()] }); + expect(extractPlans(step)).toEqual([]); + }); + + it("returns [] when toolCalls is missing or empty", () => { + expect(extractPlans(makeStep({ toolCalls: undefined }))).toEqual([]); + expect(extractPlans(makeStep({ toolCalls: [] }))).toEqual([]); + }); + + it("ignores tool calls whose name does not match", () => { + const step = makeStep({ + toolCalls: [ + { toolName: PROPOSE_OPERATOR_CHANGE_TOOL_NAME, toolCallId: "x", input: {} }, + { toolName: "getProfilerSummary", toolCallId: "y", input: {} }, + ], + }); + expect(extractPlans(step)).toEqual([]); + }); + + it("extracts a well-formed plan with all fields preserved + step ids", () => { + const step = makeStep({ toolCalls: [makePlanCall()] }); + const out = extractPlans(step); + expect(out).toHaveLength(1); + const plan = out[0]; + expect(plan.toolCallId).toBe("plan-tc-1"); + expect(plan.planTitle).toBe("Reduce Python UDF load"); + expect(plan.firingHints).toEqual(["SCAN_FULL_TABLE_NO_FILTER", "LOW_PARALLELISM_HOT_OP"]); + expect(plan.steps).toHaveLength(2); + expect(plan.steps[0].stepId).toBe("plan-tc-1::0"); + expect(plan.steps[1].stepId).toBe("plan-tc-1::1"); + expect(plan.steps[0].operatorId).toBe("filter-1"); + expect(plan.steps[1].propertyChanges).toEqual({ workers: 4 }); + }); + + it("preserves step order (steps are ordered, not a set)", () => { + const call = makePlanCall({ + steps: [ + { operatorId: "a", propertyChanges: {}, description: "A", reasoning: "r", expectedImpact: "i" }, + { operatorId: "b", propertyChanges: {}, description: "B", reasoning: "r", expectedImpact: "i" }, + { operatorId: "c", propertyChanges: {}, description: "C", reasoning: "r", expectedImpact: "i" }, + ], + }); + const plan = extractPlans(makeStep({ toolCalls: [call] }))[0]; + expect(plan.steps.map(s => s.operatorId)).toEqual(["a", "b", "c"]); + expect(plan.steps.map(s => s.stepId)).toEqual(["plan-tc-1::0", "plan-tc-1::1", "plan-tc-1::2"]); + }); + + it("drops the plan when fewer than 2 steps are well-formed (after per-step validation)", () => { + const call = makePlanCall({ + steps: [ + { operatorId: "a", propertyChanges: {}, description: "A", reasoning: "r", expectedImpact: "i" }, + // malformed: missing description + { operatorId: "b", propertyChanges: {}, description: "", reasoning: "r", expectedImpact: "i" }, + ], + }); + expect(extractPlans(makeStep({ toolCalls: [call] }))).toEqual([]); + }); + + it.each([ + ["missing planTitle", { planTitle: undefined }], + ["empty planTitle", { planTitle: "" }], + ["missing planRationale", { planRationale: undefined }], + ["missing steps", { steps: undefined }], + ["non-array steps", { steps: { length: 0 } }], + ["empty steps", { steps: [] }], + ])("silently drops a malformed plan (%s)", (_label, override) => { + const call = makePlanCall(); + Object.assign(call.input, override); + expect(extractPlans(makeStep({ toolCalls: [call] }))).toEqual([]); + }); + + it("makes firingHints undefined when omitted or mis-typed", () => { + expect(extractPlans(makeStep({ toolCalls: [makePlanCall({ firingHints: undefined })] }))[0].firingHints).toBeUndefined(); + expect(extractPlans(makeStep({ toolCalls: [makePlanCall({ firingHints: "RUNTIME_OUTLIER" })] }))[0].firingHints).toBeUndefined(); + expect(extractPlans(makeStep({ toolCalls: [makePlanCall({ firingHints: ["ok", 1] })] }))[0].firingHints).toBeUndefined(); + }); + + it("plan and standalone proposals from the same step are returned by their respective extractors", () => { + const proposalCall = { + toolName: PROPOSE_OPERATOR_CHANGE_TOOL_NAME, + toolCallId: "tc-prop", + input: { + operatorId: "agg-1", + propertyChanges: { x: 1 }, + reasoning: "r", + expectedImpact: "i", + }, + }; + const step = makeStep({ toolCalls: [proposalCall, makePlanCall()] }); + expect(extractProposals(step)).toHaveLength(1); + expect(extractPlans(step)).toHaveLength(1); + }); +}); + +describe("summarizePropertyChanges", () => { + it("formats a single numeric change", () => { + expect(summarizePropertyChanges({ workers: 4 })).toBe("workers → 4"); + }); + + it("formats a single string change with quotes", () => { + expect(summarizePropertyChanges({ mode: "stream" })).toBe('mode → "stream"'); + }); + + it("formats multiple changes joined with commas", () => { + expect(summarizePropertyChanges({ workers: 4, enabled: true })).toBe("workers → 4, enabled → true"); + }); + + it("returns the empty-changes sentinel for {}", () => { + expect(summarizePropertyChanges({})).toBe("(no changes)"); + }); + + it("handles null and object/array values via JSON stringify", () => { + const s = summarizePropertyChanges({ a: null, b: { x: 1 }, c: [1, 2] }); + expect(s).toContain("a → null"); + expect(s).toContain('b → {"x":1}'); + expect(s).toContain("c → [1,2]"); + }); +}); diff --git a/frontend/src/app/workspace/service/agent/agent-proposal.ts b/frontend/src/app/workspace/service/agent/agent-proposal.ts new file mode 100644 index 00000000000..a94d4d90cf8 --- /dev/null +++ b/frontend/src/app/workspace/service/agent/agent-proposal.ts @@ -0,0 +1,216 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { ReActStep } from "./agent-types"; + +/** + * Phase 3 of the profiler-agent-tool plan: structured-proposal channel. + * + * The agent surfaces proposed operator-property changes via a non-mutating + * `proposeOperatorChange` tool call rather than text patterns. This pure + * module extracts those proposals from a ReActStep so the chat component can + * render Apply / Reject UI, and applies them via shallow merge when the user + * accepts. + * + * The tool name string is the cross-project contract with the agent-service — + * see `agent-service/src/agent/tools/proposal-tools.ts`. + */ + +export const PROPOSE_OPERATOR_CHANGE_TOOL_NAME = "proposeOperatorChange"; +export const PROPOSE_OPTIMIZATION_PLAN_TOOL_NAME = "proposeOptimizationPlan"; + +export interface OperatorChangeProposal { + /** Stable id (mirrors the ai-sdk toolCallId) used to track per-proposal state in the UI. */ + toolCallId: string; + operatorId: string; + propertyChanges: Record; + reasoning: string; + expectedImpact: string; + firingHints?: string[]; +} + +/** + * Phase 4: a multi-step optimization plan surfaced via the `proposeOptimizationPlan` + * tool. Steps are ordered; each step has its own UI state (pending/applied/...) + * tracked under a synthetic stepId of `${toolCallId}::${index}`. The plan as a + * whole is identified by `toolCallId` (mirrors the AI-SDK tool call id). + */ +export interface OptimizationPlanStep { + /** Synthetic stable id: `${planToolCallId}::${index}` — used as state key. */ + stepId: string; + operatorId: string; + propertyChanges: Record; + description: string; + reasoning: string; + expectedImpact: string; +} + +export interface OptimizationPlanProposal { + toolCallId: string; + planTitle: string; + planRationale: string; + firingHints?: string[]; + steps: OptimizationPlanStep[]; +} + +/** + * Pulls every well-formed `proposeOperatorChange` call out of a single agent + * step. Malformed calls (wrong tool name, missing required field, wrong types) + * are silently skipped — the agent occasionally hallucinates partial args, and + * we never want a bad proposal to crash chat rendering. + */ +export function extractProposals(step: ReActStep): OperatorChangeProposal[] { + if (step.role !== "agent" || !step.toolCalls || step.toolCalls.length === 0) return []; + const out: OperatorChangeProposal[] = []; + for (const tc of step.toolCalls) { + const proposal = parseProposalFromToolCall(tc); + if (proposal) out.push(proposal); + } + return out; +} + +/** + * Pulls every well-formed `proposeOptimizationPlan` call out of a single agent + * step. A plan with fewer than 2 valid steps is dropped — that's a malformed + * plan per the agent-side schema, and a single-step plan should have been a + * `proposeOperatorChange` instead. + */ +export function extractPlans(step: ReActStep): OptimizationPlanProposal[] { + if (step.role !== "agent" || !step.toolCalls || step.toolCalls.length === 0) return []; + const out: OptimizationPlanProposal[] = []; + for (const tc of step.toolCalls) { + const plan = parsePlanFromToolCall(tc); + if (plan) out.push(plan); + } + return out; +} + +function parsePlanFromToolCall(tc: any): OptimizationPlanProposal | undefined { + if (!tc || tc.toolName !== PROPOSE_OPTIMIZATION_PLAN_TOOL_NAME) return undefined; + const toolCallId = typeof tc.toolCallId === "string" ? tc.toolCallId : undefined; + const input = tc.input; + if (!toolCallId || !input || typeof input !== "object") return undefined; + const { planTitle, planRationale, firingHints, steps } = input as Record; + if (typeof planTitle !== "string" || planTitle.length === 0) return undefined; + if (typeof planRationale !== "string" || planRationale.length === 0) return undefined; + if (!Array.isArray(steps) || steps.length === 0) return undefined; + + const validSteps: OptimizationPlanStep[] = []; + for (let i = 0; i < steps.length; i++) { + const parsed = parsePlanStep(steps[i], toolCallId, i); + if (parsed) validSteps.push(parsed); + } + // Per the agent-side schema, a plan must have ≥2 valid steps. We re-enforce + // here in case the agent returned fewer well-formed steps than declared. + if (validSteps.length < 2) return undefined; + + return { + toolCallId, + planTitle, + planRationale, + firingHints: + Array.isArray(firingHints) && firingHints.every(h => typeof h === "string") + ? (firingHints as string[]) + : undefined, + steps: validSteps, + }; +} + +function parsePlanStep(raw: unknown, planToolCallId: string, index: number): OptimizationPlanStep | undefined { + if (!raw || typeof raw !== "object") return undefined; + const { operatorId, propertyChanges, description, reasoning, expectedImpact } = raw as Record< + string, + unknown + >; + if (typeof operatorId !== "string" || operatorId.length === 0) return undefined; + if (!propertyChanges || typeof propertyChanges !== "object" || Array.isArray(propertyChanges)) { + return undefined; + } + if (typeof description !== "string" || description.length === 0) return undefined; + if (typeof reasoning !== "string" || reasoning.length === 0) return undefined; + if (typeof expectedImpact !== "string" || expectedImpact.length === 0) return undefined; + return { + stepId: `${planToolCallId}::${index}`, + operatorId, + propertyChanges: propertyChanges as Record, + description, + reasoning, + expectedImpact, + }; +} + +function parseProposalFromToolCall(tc: any): OperatorChangeProposal | undefined { + if (!tc || tc.toolName !== PROPOSE_OPERATOR_CHANGE_TOOL_NAME) return undefined; + const toolCallId = typeof tc.toolCallId === "string" ? tc.toolCallId : undefined; + const input = tc.input; + if (!toolCallId || !input || typeof input !== "object") return undefined; + const { operatorId, propertyChanges, reasoning, expectedImpact, firingHints } = input as Record< + string, + unknown + >; + if (typeof operatorId !== "string" || operatorId.length === 0) return undefined; + if (!propertyChanges || typeof propertyChanges !== "object" || Array.isArray(propertyChanges)) { + return undefined; + } + if (typeof reasoning !== "string" || typeof expectedImpact !== "string") return undefined; + return { + toolCallId, + operatorId, + propertyChanges: propertyChanges as Record, + reasoning, + expectedImpact, + firingHints: + Array.isArray(firingHints) && firingHints.every(h => typeof h === "string") + ? (firingHints as string[]) + : undefined, + }; +} + +/** + * Shallow-merge a proposal's changes into the operator's current properties. + * Used when the user clicks Apply — `WorkflowActionService.setOperatorProperty` + * replaces the whole property object, so we must include all unchanged keys. + */ +export function mergeProposalIntoProperties( + existing: Readonly> | undefined, + changes: Readonly> +): Record { + return { ...(existing ?? {}), ...changes }; +} + +export type ProposalState = "pending" | "applied" | "rejected" | "missing-operator" | "failed"; + +/** + * Helper: produce a one-line summary of the property changes for the proposal + * card header. Keeps things grep-friendly for review. + */ +export function summarizePropertyChanges(changes: Readonly>): string { + const entries = Object.entries(changes); + if (entries.length === 0) return "(no changes)"; + return entries + .map(([k, v]) => `${k} → ${formatValue(v)}`) + .join(", "); +} + +function formatValue(v: unknown): string { + if (v === null) return "null"; + if (typeof v === "string") return JSON.stringify(v); + if (typeof v === "number" || typeof v === "boolean") return String(v); + return JSON.stringify(v); +} diff --git a/frontend/src/app/workspace/service/agent/agent.service.ts b/frontend/src/app/workspace/service/agent/agent.service.ts index 2009734030b..d861b83fade 100644 --- a/frontend/src/app/workspace/service/agent/agent.service.ts +++ b/frontend/src/app/workspace/service/agent/agent.service.ts @@ -41,6 +41,15 @@ import { AuthService } from "../../../common/service/user/auth.service"; import { AgentState, ReActStep, ModelMessage } from "./agent-types"; import { Workflow, WorkflowContent } from "../../../common/type/workflow"; import { ComputingUnitStatusService } from "../../../common/service/computing-unit/computing-unit-status/computing-unit-status.service"; +import { + FilterPredicateRequest, + FilterPredicatesProposal, + WorkerCountProposal, + WorkerCountRequest, + parseFilterPredicatesResponse, + parseWorkerCountResponse, +} from "./agent-proposal-requests"; +import { firstValueFrom, timeout } from "rxjs"; /** * Agent settings for API (serializable format). @@ -900,8 +909,18 @@ export class AgentService { /** * Send a message to an agent via WebSocket. * The message is sent through the WebSocket connection for real-time streaming. + * + * Optionally attaches a `profilerSnapshot` so the agent's read-only profiler tools + * (Phase 1 of the agent-tool plan) have current per-operator metrics + hints to + * answer questions like "why is my workflow slow?". When omitted, those tools + * surface a "no profiler data available" message to the agent. */ - public sendMessage(agentId: string, message: string, messageSource: "chat" | "feedback" = "chat"): void { + public sendMessage( + agentId: string, + message: string, + messageSource: "chat" | "feedback" = "chat", + profilerSnapshot?: unknown + ): void { const agent = this.agents.get(agentId); if (!agent) { this.notificationService.error(`Agent with ID ${agentId} not found`); @@ -914,11 +933,14 @@ export class AgentService { return; } - const wsMessage = { + const wsMessage: Record = { type: "message", content: message, messageSource, }; + if (profilerSnapshot !== undefined) { + wsMessage.profilerSnapshot = profilerSnapshot; + } try { tracking.websocket.send(JSON.stringify(wsMessage)); @@ -1338,4 +1360,54 @@ export class AgentService { public getResultAnnotationsVisible(): boolean { return this.resultAnnotationsVisibleSubject.getValue(); } + + // ----------------------------------------------------------------------------- + // On-demand proposal endpoints (deferred items from profiler-agent-tool-plan). + // + // Both methods are best-effort: if the endpoint isn't reachable, times out, + // or returns a malformed shape, they resolve to `undefined` so the caller's + // rule-based fallback (firstColumn / fixed 4 workers) keeps the feature + // working with or without the agent. + // ----------------------------------------------------------------------------- + + /** + * Ask the agent-service to propose 1-5 useful Filter predicate rows for the + * given upstream → downstream link. Returns `undefined` on any failure. + */ + public async tryProposeFilterPredicate( + request: FilterPredicateRequest, + timeoutMs: number = 15000 + ): Promise { + try { + const raw = await firstValueFrom( + this.http + .post(`${this.AGENT_API_BASE}/proposals/filter-predicate`, request) + .pipe(timeout(timeoutMs)) + ); + return parseFilterPredicatesResponse(raw); + } catch { + return undefined; + } + } + + /** + * Ask the agent-service to propose a parallel worker count for the given + * operator based on its current runtime + idle ratio + type. Returns + * `undefined` on any failure. + */ + public async tryProposeWorkerCount( + request: WorkerCountRequest, + timeoutMs: number = 15000 + ): Promise { + try { + const raw = await firstValueFrom( + this.http + .post(`${this.AGENT_API_BASE}/proposals/worker-count`, request) + .pipe(timeout(timeoutMs)) + ); + return parseWorkerCountResponse(raw); + } catch { + return undefined; + } + } } diff --git a/frontend/src/app/workspace/service/joint-ui/joint-ui.service.ts b/frontend/src/app/workspace/service/joint-ui/joint-ui.service.ts index d5d8f78c584..771c246a0fa 100644 --- a/frontend/src/app/workspace/service/joint-ui/joint-ui.service.ts +++ b/frontend/src/app/workspace/service/joint-ui/joint-ui.service.ts @@ -507,6 +507,67 @@ export class JointUIService { }); } + /** + * Paints the operator body with a heatmap color based on a 0..1 cost score. + * Special states (Uninitialized/Ready, Running-with-no-data, Failed) get fixed colors per the profiler plan. + */ + public changeOperatorHeatmap( + jointPaper: joint.dia.Paper, + operatorID: string, + score: number, + state: OperatorState, + hasMeasurableCost: boolean + ): void { + const model = jointPaper.getModelById(operatorID); + if (!model) return; + const fillColor = JointUIService.getHeatmapColor(score, state, hasMeasurableCost); + model.attr("rect.body/fill", fillColor); + } + + /** + * Restores the operator body fill to its default (white / disabled gray). + */ + public resetOperatorHeatmap(jointPaper: joint.dia.Paper, operator: OperatorPredicate): void { + const model = jointPaper.getModelById(operator.operatorID); + if (!model) return; + model.attr("rect.body/fill", JointUIService.getOperatorFillColor(operator)); + } + + public static getHeatmapColor(score: number, state: OperatorState, hasMeasurableCost: boolean): string { + switch (state) { + case OperatorState.Uninitialized: + case OperatorState.Initializing: + case OperatorState.Ready: + return "#E0E0E0"; + case OperatorState.Recovering: + return "#D6E9F8"; + } + if (state === OperatorState.Running && !hasMeasurableCost) { + return "#D6E9F8"; + } + const clamped = Math.max(0, Math.min(1, Number.isFinite(score) ? score : 0)); + const hue = 120 - clamped * 120; + return `hsl(${hue}, 70%, 60%)`; + } + + /** + * Maps a bipolar delta intensity in [-1, 1] to a fill color. + * intensity < 0 → green (operator improved) + * intensity > 0 → red (operator regressed) + * intensity ≈ 0 → neutral pale gray (no signal) + * + * Saturation scales with magnitude so a small change is a soft tint and a + * large change saturates the color. + */ + public static getDeltaHeatmapColor(intensity: number): string { + if (!Number.isFinite(intensity) || intensity === 0) return "#F0F0F0"; + const clamped = Math.max(-1, Math.min(1, intensity)); + const hue = clamped < 0 ? 120 : 0; + // Saturation scales with magnitude: |0| → 0% (gray), |1| → 70% + const saturation = Math.round(Math.abs(clamped) * 70); + return `hsl(${hue}, ${saturation}%, 60%)`; + } + /** * This method will change the operator's color based on the validation status * valid : default color diff --git a/frontend/src/app/workspace/service/profiler/profiler-config.spec.ts b/frontend/src/app/workspace/service/profiler/profiler-config.spec.ts new file mode 100644 index 00000000000..2152ed8ba10 --- /dev/null +++ b/frontend/src/app/workspace/service/profiler/profiler-config.spec.ts @@ -0,0 +1,131 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { + parseProfilerConfig, + profilerConfigEquals, + serializeProfilerConfig, +} from "./profiler-config"; + +describe("parseProfilerConfig", () => { + it("returns undefined for null / non-object inputs", () => { + expect(parseProfilerConfig(undefined)).toBeUndefined(); + expect(parseProfilerConfig(null)).toBeUndefined(); + expect(parseProfilerConfig("hello")).toBeUndefined(); + expect(parseProfilerConfig(42)).toBeUndefined(); + }); + + it("returns undefined for empty object (no recognized fields)", () => { + expect(parseProfilerConfig({})).toBeUndefined(); + expect(parseProfilerConfig({ unrelated: "stuff" })).toBeUndefined(); + }); + + it("parses a fully-valid config faithfully", () => { + expect(parseProfilerConfig({ enabled: true, view: "throughput", hotThresholdPercentile: 90 })).toEqual({ + enabled: true, + view: "throughput", + hotThresholdPercentile: 90, + }); + }); + + it("falls back to defaults for missing fields when at least one is present", () => { + const result = parseProfilerConfig({ enabled: true }); + expect(result).toEqual({ enabled: true, view: "runtime", hotThresholdPercentile: 80 }); + }); + + it("falls back to default view when the persisted value is not a known view", () => { + const result = parseProfilerConfig({ + enabled: true, + view: "garbage", + hotThresholdPercentile: 75, + }); + expect(result?.view).toBe("runtime"); + // other fields preserved + expect(result?.enabled).toBe(true); + expect(result?.hotThresholdPercentile).toBe(75); + }); + + it("clamps out-of-range percentile to [0, 100]", () => { + expect(parseProfilerConfig({ hotThresholdPercentile: 9999 })?.hotThresholdPercentile).toBe(100); + expect(parseProfilerConfig({ hotThresholdPercentile: -50 })?.hotThresholdPercentile).toBe(0); + }); + + it("ignores non-number percentile (NaN, Infinity, string)", () => { + expect(parseProfilerConfig({ hotThresholdPercentile: Number.NaN })?.hotThresholdPercentile).toBe(80); + expect(parseProfilerConfig({ hotThresholdPercentile: Infinity })?.hotThresholdPercentile).toBe(80); + expect(parseProfilerConfig({ hotThresholdPercentile: "90" as any })?.hotThresholdPercentile).toBe(80); + }); + + it("coerces non-boolean enabled to false", () => { + expect(parseProfilerConfig({ enabled: "true" as any })?.enabled).toBe(false); + expect(parseProfilerConfig({ enabled: 1 as any })?.enabled).toBe(false); + }); +}); + +describe("serializeProfilerConfig", () => { + it("extracts just the persistable fields", () => { + const result = serializeProfilerConfig({ + enabled: true, + view: "io-imbalance", + hotThresholdPercentile: 95, + }); + expect(result).toEqual({ + enabled: true, + view: "io-imbalance", + hotThresholdPercentile: 95, + }); + }); + + it("round-trips through parse → serialize without loss", () => { + const original = serializeProfilerConfig({ + enabled: true, + view: "throughput", + hotThresholdPercentile: 50, + }); + expect(parseProfilerConfig(original)).toEqual(original); + }); +}); + +describe("profilerConfigEquals", () => { + it("returns true for identical configs", () => { + const a = { enabled: true, view: "runtime" as const, hotThresholdPercentile: 80 }; + const b = { enabled: true, view: "runtime" as const, hotThresholdPercentile: 80 }; + expect(profilerConfigEquals(a, b)).toBe(true); + }); + + it("returns false when any field differs", () => { + const a = { enabled: true, view: "runtime" as const, hotThresholdPercentile: 80 }; + expect( + profilerConfigEquals(a, { ...a, enabled: false }) + ).toBe(false); + expect( + profilerConfigEquals(a, { ...a, view: "throughput" }) + ).toBe(false); + expect( + profilerConfigEquals(a, { ...a, hotThresholdPercentile: 90 }) + ).toBe(false); + }); + + it("handles undefineds without throwing", () => { + const a = { enabled: true, view: "runtime" as const, hotThresholdPercentile: 80 }; + expect(profilerConfigEquals(undefined, undefined)).toBe(true); + expect(profilerConfigEquals(undefined, a)).toBe(false); + expect(profilerConfigEquals(a, undefined)).toBe(false); + }); +}); diff --git a/frontend/src/app/workspace/service/profiler/profiler-config.ts b/frontend/src/app/workspace/service/profiler/profiler-config.ts new file mode 100644 index 00000000000..d08c714f815 --- /dev/null +++ b/frontend/src/app/workspace/service/profiler/profiler-config.ts @@ -0,0 +1,103 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +/** + * Pure (de)serialization for the per-workflow profiler config block that lives + * inside WorkflowContent.profilerConfig. + * + * - parseProfilerConfig: defensive cast from `unknown` (workflow JSON loaded from + * the backend is untyped) → ProfilerConfig | undefined. Same validation as + * ProfilerService.restoreConfig: unknown view → fallback; out-of-range percentile + * → clamped; non-boolean enabled → fallback. + * - serializeProfilerConfig: extract the minimal serializable shape from in-memory + * profiler state. + */ + +import type { ProfilerView } from "./profiler.service"; + +export interface ProfilerConfig { + readonly enabled: boolean; + readonly view: ProfilerView; + readonly hotThresholdPercentile: number; +} + +const DEFAULT_VIEW: ProfilerView = "runtime"; +const DEFAULT_HOT_THRESHOLD = 80; +const VALID_VIEWS: ReadonlySet = new Set(["runtime", "throughput", "io-imbalance", "delta"]); + +/** + * Defensive parse of an unknown blob (e.g. a field on a freshly-loaded workflow + * whose schema was authored by an older client). Returns `undefined` when the + * blob isn't a plausible profiler config — callers should treat that as + * "workflow has no per-workflow override" and fall back to user defaults. + */ +export function parseProfilerConfig(raw: unknown): ProfilerConfig | undefined { + if (!raw || typeof raw !== "object") return undefined; + const obj = raw as Record; + + // Require at least one recognized field to be present so we don't manufacture + // a config from an empty object. + const hasAnyField = "enabled" in obj || "view" in obj || "hotThresholdPercentile" in obj; + if (!hasAnyField) return undefined; + + const enabled = typeof obj.enabled === "boolean" ? obj.enabled : false; + + let view: ProfilerView = DEFAULT_VIEW; + if (typeof obj.view === "string" && VALID_VIEWS.has(obj.view)) { + view = obj.view as ProfilerView; + } + + let hotThresholdPercentile = DEFAULT_HOT_THRESHOLD; + const rawPct = obj.hotThresholdPercentile; + if (typeof rawPct === "number" && Number.isFinite(rawPct)) { + hotThresholdPercentile = Math.max(0, Math.min(100, rawPct)); + } + + return { enabled, view, hotThresholdPercentile }; +} + +/** + * Extracts the wire-shaped profiler config from a ProfilerService state-like input. + * Pure: takes only the fields it needs, no service dependency. + */ +export function serializeProfilerConfig(input: { + enabled: boolean; + view: ProfilerView; + hotThresholdPercentile: number; +}): ProfilerConfig { + return { + enabled: input.enabled, + view: input.view, + hotThresholdPercentile: input.hotThresholdPercentile, + }; +} + +/** + * Deep equality check on two configs. Used by the bridge code that syncs + * ProfilerService ↔ WorkflowActionService to break write-loops cheaply. + */ +export function profilerConfigEquals(a: ProfilerConfig | undefined, b: ProfilerConfig | undefined): boolean { + if (a === b) return true; + if (!a || !b) return false; + return ( + a.enabled === b.enabled && + a.view === b.view && + a.hotThresholdPercentile === b.hotThresholdPercentile + ); +} diff --git a/frontend/src/app/workspace/service/profiler/profiler-delta.spec.ts b/frontend/src/app/workspace/service/profiler/profiler-delta.spec.ts new file mode 100644 index 00000000000..7399d7e2250 --- /dev/null +++ b/frontend/src/app/workspace/service/profiler/profiler-delta.spec.ts @@ -0,0 +1,409 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { OperatorState, OperatorStatistics } from "../../types/execute-workflow.interface"; +import { + BaselineReport, + ComparableOperator, + computeAllDeltas, + computeDeltaIntensity, + computeOperatorDelta, + indexBaseline, + maxAbsRuntimeDelta, + parseBaselineReport, + statsToComparable, +} from "./profiler-delta"; + +function op(partial: Partial & { operatorId: string }): ComparableOperator { + return { + displayName: partial.operatorId, + operatorType: null, + score: 0, + runtimeMs: null, + throughputRowsPerSec: null, + inputRows: 0, + outputRows: 0, + inputSize: null, + outputSize: null, + workers: null, + idleRatio: null, + ...partial, + }; +} + +describe("computeOperatorDelta — matched", () => { + it("computes runtime/throughput/row/score deltas as current minus baseline", () => { + const current = op({ + operatorId: "a", + runtimeMs: 1000, + throughputRowsPerSec: 500, + inputRows: 100, + outputRows: 100, + score: 0.5, + }); + const baseline = op({ + operatorId: "a", + runtimeMs: 2000, + throughputRowsPerSec: 250, + inputRows: 80, + outputRows: 80, + score: 0.7, + }); + const d = computeOperatorDelta("a", current, baseline); + expect(d.matchStatus).toBe("matched"); + expect(d.runtimeMsDelta).toBe(-1000); + expect(d.throughputRowsPerSecDelta).toBe(250); + expect(d.outputRowsDelta).toBe(20); + expect(d.inputRowsDelta).toBe(20); + expect(d.scoreDelta).toBeCloseTo(-0.2, 5); + }); + + it('returns direction "improved" when runtime drops by more than 5%', () => { + const d = computeOperatorDelta( + "a", + op({ operatorId: "a", runtimeMs: 500 }), + op({ operatorId: "a", runtimeMs: 1000 }) + ); + expect(d.direction).toBe("improved"); + }); + + it('returns direction "regressed" when runtime rises by more than 5%', () => { + const d = computeOperatorDelta( + "a", + op({ operatorId: "a", runtimeMs: 2000 }), + op({ operatorId: "a", runtimeMs: 1000 }) + ); + expect(d.direction).toBe("regressed"); + }); + + it('returns direction "unchanged" when runtime change is within 5%', () => { + const d = computeOperatorDelta( + "a", + op({ operatorId: "a", runtimeMs: 1020 }), + op({ operatorId: "a", runtimeMs: 1000 }) + ); + expect(d.direction).toBe("unchanged"); + }); + + it('returns direction "unchanged" when absolute delta is under 1ms', () => { + const d = computeOperatorDelta( + "a", + op({ operatorId: "a", runtimeMs: 0.5 }), + op({ operatorId: "a", runtimeMs: 0 }) + ); + expect(d.direction).toBe("unchanged"); + }); + + it("falls back to outputRowsDelta when runtime is missing on one side", () => { + const current = op({ operatorId: "a", runtimeMs: null, outputRows: 200 }); + const baseline = op({ operatorId: "a", runtimeMs: null, outputRows: 100 }); + const d = computeOperatorDelta("a", current, baseline); + expect(d.runtimeMsDelta).toBeNull(); + expect(d.outputRowsDelta).toBe(100); + expect(d.direction).toBe("improved"); // more output flowed -> progress + }); + + it("preserves displayName from the current run when matched", () => { + const current = op({ operatorId: "a", displayName: "New Name" }); + const baseline = op({ operatorId: "a", displayName: "Old Name" }); + expect(computeOperatorDelta("a", current, baseline).displayName).toBe("New Name"); + }); +}); + +describe("computeOperatorDelta — unmatched", () => { + it('marks new-in-current when baseline is missing', () => { + const d = computeOperatorDelta("a", op({ operatorId: "a", runtimeMs: 100 }), undefined); + expect(d.matchStatus).toBe("new-in-current"); + expect(d.runtimeMsDelta).toBeNull(); + expect(d.direction).toBe("n/a"); + expect(d.current).toBeDefined(); + expect(d.baseline).toBeUndefined(); + }); + + it('marks removed-since-baseline when current is missing', () => { + const d = computeOperatorDelta("a", undefined, op({ operatorId: "a", runtimeMs: 100 })); + expect(d.matchStatus).toBe("removed-since-baseline"); + expect(d.direction).toBe("n/a"); + expect(d.current).toBeUndefined(); + expect(d.baseline).toBeDefined(); + }); +}); + +describe("computeAllDeltas", () => { + it("returns one entry per id across both maps", () => { + const current = { a: op({ operatorId: "a" }), b: op({ operatorId: "b" }) }; + const baseline = { b: op({ operatorId: "b" }), c: op({ operatorId: "c" }) }; + const all = computeAllDeltas(current, baseline); + expect(Object.keys(all).sort()).toEqual(["a", "b", "c"]); + expect(all["a"].matchStatus).toBe("new-in-current"); + expect(all["b"].matchStatus).toBe("matched"); + expect(all["c"].matchStatus).toBe("removed-since-baseline"); + }); + + it("returns an empty map when both inputs are empty", () => { + expect(computeAllDeltas({}, {})).toEqual({}); + }); +}); + +describe("parseBaselineReport", () => { + it("returns undefined for null / non-object inputs", () => { + expect(parseBaselineReport(null)).toBeUndefined(); + expect(parseBaselineReport(undefined)).toBeUndefined(); + expect(parseBaselineReport(42)).toBeUndefined(); + expect(parseBaselineReport("string")).toBeUndefined(); + }); + + it("returns undefined when operators array is missing", () => { + expect(parseBaselineReport({ header: {} })).toBeUndefined(); + }); + + it("returns undefined when operators array yields no parseable entries", () => { + expect(parseBaselineReport({ operators: [{ noId: true }, "garbage"] })).toBeUndefined(); + }); + + it("parses a valid report and exposes the operators", () => { + const result = parseBaselineReport({ + header: { + workflowName: "TikTok Pipeline", + executionName: "run-1", + generatedAt: "2026-05-14T12:00:00Z", + view: "runtime", + hotThresholdPercentile: 80, + operatorCount: 1, + }, + operators: [ + { + operatorId: "op-1", + displayName: "Python UDF", + operatorType: "PythonUDFV2", + score: 0.97, + runtimeMs: 2710.8, + throughputRowsPerSec: 1714, + inputRows: 4645, + outputRows: 4645, + inputSize: 21628576, + outputSize: 23790952, + workers: 1, + idleRatio: 0.64, + }, + ], + }); + expect(result?.operators).toHaveLength(1); + expect(result?.operators[0].displayName).toBe("Python UDF"); + expect(result?.header.workflowName).toBe("TikTok Pipeline"); + }); + + it("backfills sane defaults when fields are partially missing on an operator entry", () => { + const result = parseBaselineReport({ + operators: [{ operatorId: "lonely-op" }], + }); + expect(result?.operators[0].displayName).toBe("lonely-op"); // falls back to id + expect(result?.operators[0].score).toBe(0); + expect(result?.operators[0].runtimeMs).toBeNull(); + expect(result?.operators[0].inputRows).toBe(0); + }); + + it("falls back to a synthesized header when the header is missing", () => { + const result = parseBaselineReport({ + operators: [{ operatorId: "x" }], + }); + expect(result?.header.workflowName).toContain("uploaded baseline"); + }); + + it("skips garbage entries and keeps valid ones", () => { + const result = parseBaselineReport({ + operators: [ + { operatorId: "a" }, + { noId: "garbage" }, + null, + 42, + { operatorId: "b" }, + ], + }); + expect(result?.operators.map(o => o.operatorId)).toEqual(["a", "b"]); + }); +}); + +describe("statsToComparable", () => { + function rawStats(partial: Partial = {}): OperatorStatistics { + return { + operatorState: OperatorState.Completed, + aggregatedInputRowCount: 0, + inputPortMetrics: {}, + aggregatedOutputRowCount: 0, + outputPortMetrics: {}, + ...partial, + }; + } + + it("derives runtimeMs and throughput from raw nanosecond fields", () => { + const out = statsToComparable({ + operatorId: "a", + displayName: "Python UDF", + operatorType: "PythonUDFV2", + score: 0.5, + stats: rawStats({ + aggregatedDataProcessingTime: 2_000_000_000, + aggregatedOutputRowCount: 1000, + }), + }); + expect(out.runtimeMs).toBe(2_000); + expect(out.throughputRowsPerSec).toBe(500); + }); + + it("returns null for runtimeMs / throughput when unmeasurable", () => { + const out = statsToComparable({ + operatorId: "a", + displayName: "a", + operatorType: null, + score: 0, + stats: rawStats({}), + }); + expect(out.runtimeMs).toBeNull(); + expect(out.throughputRowsPerSec).toBeNull(); + }); + + it("computes idle ratio from data + control + idle nanos", () => { + const out = statsToComparable({ + operatorId: "a", + displayName: "a", + operatorType: null, + score: 0, + stats: rawStats({ + aggregatedDataProcessingTime: 100, + aggregatedControlProcessingTime: 100, + aggregatedIdleTime: 300, + }), + }); + expect(out.idleRatio).toBeCloseTo(0.6, 5); + }); + + it("normalizes undefined operatorType to null", () => { + const out = statsToComparable({ + operatorId: "a", + displayName: "a", + operatorType: undefined, + score: 0, + stats: rawStats(), + }); + expect(out.operatorType).toBeNull(); + }); +}); + +describe("maxAbsRuntimeDelta", () => { + it("returns 0 for an empty map", () => { + expect(maxAbsRuntimeDelta({})).toBe(0); + }); + + it("returns the largest absolute runtime delta, ignoring sign", () => { + const a = computeOperatorDelta("a", op({ operatorId: "a", runtimeMs: 100 }), op({ operatorId: "a", runtimeMs: 500 })); + const b = computeOperatorDelta("b", op({ operatorId: "b", runtimeMs: 700 }), op({ operatorId: "b", runtimeMs: 200 })); + expect(maxAbsRuntimeDelta({ a, b })).toBe(500); // both -400 and +500, max abs is 500 + }); + + it("skips operators with null runtime delta", () => { + const a = computeOperatorDelta("a", op({ operatorId: "a" }), op({ operatorId: "a" })); + expect(maxAbsRuntimeDelta({ a })).toBe(0); + }); +}); + +describe("computeDeltaIntensity", () => { + it("returns negative intensity for improved operators (lower runtime)", () => { + const d = computeOperatorDelta( + "a", + op({ operatorId: "a", runtimeMs: 500 }), + op({ operatorId: "a", runtimeMs: 1000 }) + ); + expect(computeDeltaIntensity(d, 500)).toBeLessThan(0); + }); + + it("returns positive intensity for regressed operators (higher runtime)", () => { + const d = computeOperatorDelta( + "a", + op({ operatorId: "a", runtimeMs: 1500 }), + op({ operatorId: "a", runtimeMs: 1000 }) + ); + expect(computeDeltaIntensity(d, 500)).toBeGreaterThan(0); + }); + + it("returns 0 for unchanged operators (within the 5% / 1ms band)", () => { + const d = computeOperatorDelta( + "a", + op({ operatorId: "a", runtimeMs: 1020 }), + op({ operatorId: "a", runtimeMs: 1000 }) + ); + // direction is "unchanged" for this 2% change + expect(computeDeltaIntensity(d, 100)).toBe(0); + }); + + it('returns 0 for "new-in-current" and "removed-since-baseline" operators', () => { + const newOp = computeOperatorDelta("a", op({ operatorId: "a", runtimeMs: 500 }), undefined); + const removedOp = computeOperatorDelta("a", undefined, op({ operatorId: "a", runtimeMs: 500 })); + expect(computeDeltaIntensity(newOp, 500)).toBe(0); + expect(computeDeltaIntensity(removedOp, 500)).toBe(0); + }); + + it("clamps intensity to [-1, 1]", () => { + const huge = computeOperatorDelta( + "a", + op({ operatorId: "a", runtimeMs: 10_000 }), + op({ operatorId: "a", runtimeMs: 1_000 }) + ); + // delta is +9000 ms; with maxAbs=1 we'd otherwise overshoot. + expect(computeDeltaIntensity(huge, 1)).toBe(1); + }); + + it("returns 0 when maxAbsDeltaMs is 0", () => { + const d = computeOperatorDelta( + "a", + op({ operatorId: "a", runtimeMs: 500 }), + op({ operatorId: "a", runtimeMs: 1000 }) + ); + expect(computeDeltaIntensity(d, 0)).toBe(0); + }); + + it("returns 0 when runtimeMsDelta is null (e.g. one side missing runtime)", () => { + const d = computeOperatorDelta( + "a", + op({ operatorId: "a", runtimeMs: null }), + op({ operatorId: "a", runtimeMs: 1000 }) + ); + expect(computeDeltaIntensity(d, 100)).toBe(0); + }); +}); + +describe("indexBaseline", () => { + it("indexes operators by operatorId", () => { + const baseline: BaselineReport = { + header: { + workflowName: "x", + executionName: null, + generatedAt: "", + view: "runtime", + hotThresholdPercentile: 80, + operatorCount: 2, + }, + operators: [op({ operatorId: "a" }), op({ operatorId: "b" })], + }; + const idx = indexBaseline(baseline); + expect(idx["a"].operatorId).toBe("a"); + expect(idx["b"].operatorId).toBe("b"); + expect(idx["nope"]).toBeUndefined(); + }); +}); diff --git a/frontend/src/app/workspace/service/profiler/profiler-delta.ts b/frontend/src/app/workspace/service/profiler/profiler-delta.ts new file mode 100644 index 00000000000..065a2927f54 --- /dev/null +++ b/frontend/src/app/workspace/service/profiler/profiler-delta.ts @@ -0,0 +1,350 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +/** + * Pure helpers for the "compare across runs" feature. + * + * Treats the JSON report produced by `profiler-report.ts` as the canonical + * baseline snapshot format. Users upload a previously-downloaded report, we + * parse it defensively, and produce per-operator deltas vs. the current run. + * + * Dependency-free (no Angular, no RxJS) so the math is unit-testable. + */ + +import type { OperatorStatistics } from "../../types/execute-workflow.interface"; + +/** + * Minimum operator shape needed for comparison. Matches a subset of + * `ReportTopOperator` from `profiler-report.ts` — keeping it separate here + * lets this module stand alone for testing and avoids tight coupling. + */ +export interface ComparableOperator { + readonly operatorId: string; + readonly displayName: string; + readonly operatorType: string | null; + readonly score: number; + readonly runtimeMs: number | null; + readonly throughputRowsPerSec: number | null; + readonly inputRows: number; + readonly outputRows: number; + readonly inputSize: number | null; + readonly outputSize: number | null; + readonly workers: number | null; + readonly idleRatio: number | null; +} + +export type MatchStatus = "matched" | "new-in-current" | "removed-since-baseline"; +export type DeltaDirection = "improved" | "regressed" | "unchanged" | "n/a"; + +export interface OperatorDelta { + readonly operatorId: string; + readonly displayName: string; + readonly matchStatus: MatchStatus; + readonly current?: ComparableOperator; + readonly baseline?: ComparableOperator; + /** Current minus baseline. `null` when either side is missing the metric. */ + readonly runtimeMsDelta: number | null; + readonly throughputRowsPerSecDelta: number | null; + readonly outputRowsDelta: number | null; + readonly inputRowsDelta: number | null; + readonly scoreDelta: number | null; + /** Derived from runtimeMsDelta primarily, falling back to outputRowsDelta. */ + readonly direction: DeltaDirection; +} + +export interface BaselineReport { + readonly header: { + readonly workflowName: string; + readonly executionName: string | null; + readonly generatedAt: string; + readonly view: string; + readonly hotThresholdPercentile: number; + readonly operatorCount: number; + }; + readonly operators: readonly ComparableOperator[]; +} + +const RUNTIME_UNCHANGED_ABS_MS = 1; // below this is rounding noise +const RUNTIME_UNCHANGED_RELATIVE = 0.05; // ±5% counts as unchanged + +/** + * Compute deltas for every operator id in either map. Operators missing on + * one side surface as `new-in-current` or `removed-since-baseline`. + */ +export function computeAllDeltas( + currentByOpId: Readonly>, + baselineByOpId: Readonly> +): Readonly> { + const out: Record = {}; + const allIds = new Set([...Object.keys(currentByOpId), ...Object.keys(baselineByOpId)]); + for (const id of allIds) { + out[id] = computeOperatorDelta(id, currentByOpId[id], baselineByOpId[id]); + } + return out; +} + +/** + * Compute a single operator's delta. Either side may be undefined — match + * status and `direction` reflect that. + */ +export function computeOperatorDelta( + opId: string, + current: ComparableOperator | undefined, + baseline: ComparableOperator | undefined +): OperatorDelta { + if (current && !baseline) { + return { + operatorId: opId, + displayName: current.displayName, + matchStatus: "new-in-current", + current, + runtimeMsDelta: null, + throughputRowsPerSecDelta: null, + outputRowsDelta: null, + inputRowsDelta: null, + scoreDelta: null, + direction: "n/a", + }; + } + if (!current && baseline) { + return { + operatorId: opId, + displayName: baseline.displayName, + matchStatus: "removed-since-baseline", + baseline, + runtimeMsDelta: null, + throughputRowsPerSecDelta: null, + outputRowsDelta: null, + inputRowsDelta: null, + scoreDelta: null, + direction: "n/a", + }; + } + // matched + const c = current!; + const b = baseline!; + const runtimeMsDelta = nullableDelta(c.runtimeMs, b.runtimeMs); + const throughputRowsPerSecDelta = nullableDelta(c.throughputRowsPerSec, b.throughputRowsPerSec); + const outputRowsDelta = c.outputRows - b.outputRows; + const inputRowsDelta = c.inputRows - b.inputRows; + const scoreDelta = c.score - b.score; + return { + operatorId: opId, + displayName: c.displayName, + matchStatus: "matched", + current: c, + baseline: b, + runtimeMsDelta, + throughputRowsPerSecDelta, + outputRowsDelta, + inputRowsDelta, + scoreDelta, + direction: deriveDirection(runtimeMsDelta, outputRowsDelta, b.runtimeMs), + }; +} + +/** + * Defensive parse of an uploaded JSON file purporting to be a P3 profiler + * report. Returns `undefined` if the shape is not recognizable so callers + * can show a friendly "unrecognized file" error instead of crashing. + */ +export function parseBaselineReport(raw: unknown): BaselineReport | undefined { + if (!raw || typeof raw !== "object") return undefined; + const obj = raw as Record; + const operatorsRaw = obj.operators; + if (!Array.isArray(operatorsRaw)) return undefined; + + const operators: ComparableOperator[] = []; + for (const op of operatorsRaw) { + const parsed = parseOperator(op); + if (parsed) operators.push(parsed); + } + if (operators.length === 0) return undefined; + + const header = parseHeader(obj.header) ?? defaultHeader(operators.length); + return { header, operators }; +} + +function parseOperator(raw: unknown): ComparableOperator | undefined { + if (!raw || typeof raw !== "object") return undefined; + const o = raw as Record; + const operatorId = typeof o.operatorId === "string" ? o.operatorId : undefined; + if (!operatorId) return undefined; + return { + operatorId, + displayName: typeof o.displayName === "string" ? o.displayName : operatorId, + operatorType: typeof o.operatorType === "string" ? o.operatorType : null, + score: typeof o.score === "number" && Number.isFinite(o.score) ? o.score : 0, + runtimeMs: numOrNull(o.runtimeMs), + throughputRowsPerSec: numOrNull(o.throughputRowsPerSec), + inputRows: typeof o.inputRows === "number" && Number.isFinite(o.inputRows) ? o.inputRows : 0, + outputRows: typeof o.outputRows === "number" && Number.isFinite(o.outputRows) ? o.outputRows : 0, + inputSize: numOrNull(o.inputSize), + outputSize: numOrNull(o.outputSize), + workers: numOrNull(o.workers), + idleRatio: numOrNull(o.idleRatio), + }; +} + +function parseHeader(raw: unknown): BaselineReport["header"] | undefined { + if (!raw || typeof raw !== "object") return undefined; + const o = raw as Record; + return { + workflowName: typeof o.workflowName === "string" ? o.workflowName : "(unknown)", + executionName: typeof o.executionName === "string" ? o.executionName : null, + generatedAt: typeof o.generatedAt === "string" ? o.generatedAt : "(unknown)", + view: typeof o.view === "string" ? o.view : "runtime", + hotThresholdPercentile: + typeof o.hotThresholdPercentile === "number" && Number.isFinite(o.hotThresholdPercentile) + ? o.hotThresholdPercentile + : 80, + operatorCount: + typeof o.operatorCount === "number" && Number.isFinite(o.operatorCount) ? o.operatorCount : 0, + }; +} + +function defaultHeader(operatorCount: number): BaselineReport["header"] { + return { + workflowName: "(uploaded baseline)", + executionName: null, + generatedAt: "(unknown)", + view: "runtime", + hotThresholdPercentile: 80, + operatorCount, + }; +} + +/** Index a baseline by operator id for cheap delta lookup. */ +export function indexBaseline(baseline: BaselineReport): Readonly> { + const out: Record = {}; + for (const op of baseline.operators) { + out[op.operatorId] = op; + } + return out; +} + +/** + * Converts the live profiler state (raw `OperatorStatistics` + a normalized score + * and identity metadata) into the comparable shape used for delta computation. + * + * Mirrors the metric derivations done by the report builder so the two are + * apples-to-apples when diffing. + */ +export function statsToComparable(input: { + readonly operatorId: string; + readonly displayName: string; + readonly operatorType: string | null | undefined; + readonly score: number; + readonly stats: OperatorStatistics; +}): ComparableOperator { + const s = input.stats; + const runtimeNs = s.aggregatedDataProcessingTime; + const runtimeMs = runtimeNs && runtimeNs > 0 ? runtimeNs / 1_000_000 : null; + const outRows = s.aggregatedOutputRowCount ?? 0; + const throughputRowsPerSec = + runtimeNs && runtimeNs > 0 && outRows > 0 ? outRows / (runtimeNs / 1_000_000_000) : null; + + const dataNs = s.aggregatedDataProcessingTime ?? 0; + const ctrlNs = s.aggregatedControlProcessingTime ?? 0; + const idleNs = s.aggregatedIdleTime ?? 0; + const totalNs = dataNs + ctrlNs + idleNs; + const idleRatio = totalNs > 0 ? idleNs / totalNs : null; + + return { + operatorId: input.operatorId, + displayName: input.displayName, + operatorType: input.operatorType ?? null, + score: input.score, + runtimeMs, + throughputRowsPerSec, + inputRows: s.aggregatedInputRowCount ?? 0, + outputRows: outRows, + inputSize: s.aggregatedInputSize ?? null, + outputSize: s.aggregatedOutputSize ?? null, + workers: s.numWorkers ?? null, + idleRatio, + }; +} + +function numOrNull(v: unknown): number | null { + return typeof v === "number" && Number.isFinite(v) ? v : null; +} + +function nullableDelta(a: number | null, b: number | null): number | null { + if (a === null || b === null) return null; + return a - b; +} + +/** + * Returns a bipolar intensity in [-1, 1] for the delta heatmap mode. + * < 0 → operator improved (will be rendered green) + * > 0 → operator regressed (will be rendered red) + * 0 → unchanged, missing baseline, or no comparable runtime + * + * Uses runtime as the comparison axis (most actionable metric). Magnitude is + * normalized by `maxAbsDeltaMs` so the hottest deltas saturate the gradient. + */ +export function computeDeltaIntensity(delta: OperatorDelta, maxAbsDeltaMs: number): number { + if (delta.matchStatus !== "matched") return 0; + const d = delta.runtimeMsDelta; + if (d === null || !Number.isFinite(d) || maxAbsDeltaMs <= 0) return 0; + // Treat "unchanged" (per direction heuristic) as zero so the canvas matches the side panel. + if (delta.direction === "unchanged") return 0; + const clamped = Math.max(-1, Math.min(1, d / maxAbsDeltaMs)); + return clamped; +} + +/** + * Computes the maximum absolute runtime delta across a set of operator deltas. + * Used to normalize per-operator intensities into [-1, 1] so the hottest change + * pegs the gradient and the rest scale proportionally. + */ +export function maxAbsRuntimeDelta(deltas: Readonly>): number { + let max = 0; + for (const id of Object.keys(deltas)) { + const d = deltas[id].runtimeMsDelta; + if (d !== null && Number.isFinite(d)) { + const abs = Math.abs(d); + if (abs > max) max = abs; + } + } + return max; +} + +function deriveDirection( + runtimeDelta: number | null, + outputRowsDelta: number, + baselineRuntime: number | null +): DeltaDirection { + if (runtimeDelta === null) { + // No runtime change is computable. Fall back to output-row change: more + // output is generally a sign the operator is running further along, so + // treat sign as a weak indicator of progress (improved) only when the + // runtime side is uncomputable. + if (outputRowsDelta === 0) return "unchanged"; + return outputRowsDelta > 0 ? "improved" : "regressed"; + } + const absUnchanged = Math.abs(runtimeDelta) < RUNTIME_UNCHANGED_ABS_MS; + const relUnchanged = + baselineRuntime !== null && baselineRuntime > 0 + ? Math.abs(runtimeDelta) / baselineRuntime < RUNTIME_UNCHANGED_RELATIVE + : false; + if (absUnchanged || relUnchanged) return "unchanged"; + return runtimeDelta < 0 ? "improved" : "regressed"; +} diff --git a/frontend/src/app/workspace/service/profiler/profiler-hints.spec.ts b/frontend/src/app/workspace/service/profiler/profiler-hints.spec.ts new file mode 100644 index 00000000000..a2823616fcf --- /dev/null +++ b/frontend/src/app/workspace/service/profiler/profiler-hints.spec.ts @@ -0,0 +1,352 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { OperatorState, OperatorStatistics } from "../../types/execute-workflow.interface"; +import { computeHintsForOperator, HintContext, HintRuleId } from "./profiler-hints"; + +function stat(partial: Partial = {}): OperatorStatistics { + return { + operatorState: OperatorState.Completed, + aggregatedInputRowCount: 0, + inputPortMetrics: {}, + aggregatedOutputRowCount: 0, + outputPortMetrics: {}, + ...partial, + }; +} + +interface BuildCtxInput { + stats?: Record; + scores?: Record; + hotThreshold?: number; + types?: Record; + displayNames?: Record; + upstreams?: Record; + downstreams?: Record; +} + +function buildCtx(input: BuildCtxInput): HintContext { + return { + stats: input.stats ?? {}, + scores: input.scores ?? {}, + hotThreshold: input.hotThreshold ?? 0.8, + operatorType: id => input.types?.[id], + displayName: id => input.displayNames?.[id] ?? id, + upstreamOps: id => input.upstreams?.[id] ?? [], + downstreamOps: id => input.downstreams?.[id] ?? [], + }; +} + +function ruleIds(opId: string, ctx: HintContext): HintRuleId[] { + return computeHintsForOperator(opId, ctx).map(h => h.ruleId); +} + +describe("computeHintsForOperator", () => { + it("returns empty list when operator has no stats entry", () => { + expect(computeHintsForOperator("missing", buildCtx({}))).toEqual([]); + }); + + it("returns hints in deterministic (alphabetical-by-ruleId) order", () => { + // Construct a context where multiple rules fire simultaneously. + const ctx = buildCtx({ + stats: { + scan: stat({ aggregatedOutputRowCount: 2_000_000, operatorState: OperatorState.Running }), + join: stat({ + aggregatedInputRowCount: 2_000_000, + aggregatedOutputRowCount: 100, + aggregatedDataProcessingTime: 10_000, + numWorkers: 1, + operatorState: OperatorState.Running, + }), + peer: stat({ aggregatedDataProcessingTime: 1000 }), + }, + scores: { join: 1.0 }, + types: { scan: "CSVScan", join: "HashJoin" }, + upstreams: { join: ["scan"] }, + }); + const ids = ruleIds("join", ctx); + const sorted = [...ids].sort((a, b) => a.localeCompare(b)); + expect(ids).toEqual(sorted); + }); + + describe("JOIN_HIGH_FANIN_LOW_FANOUT", () => { + it("fires when a Join keeps <5% of its input", () => { + const ctx = buildCtx({ + stats: { j: stat({ aggregatedInputRowCount: 1000, aggregatedOutputRowCount: 10 }) }, + types: { j: "HashJoin" }, + }); + expect(ruleIds("j", ctx)).toContain("JOIN_HIGH_FANIN_LOW_FANOUT"); + }); + + it("does not fire when output is >=5% of input", () => { + const ctx = buildCtx({ + stats: { j: stat({ aggregatedInputRowCount: 1000, aggregatedOutputRowCount: 100 }) }, + types: { j: "HashJoin" }, + }); + expect(ruleIds("j", ctx)).not.toContain("JOIN_HIGH_FANIN_LOW_FANOUT"); + }); + + it("does not fire for non-join operator types", () => { + const ctx = buildCtx({ + stats: { f: stat({ aggregatedInputRowCount: 1000, aggregatedOutputRowCount: 10 }) }, + types: { f: "Filter" }, + }); + expect(ruleIds("f", ctx)).not.toContain("JOIN_HIGH_FANIN_LOW_FANOUT"); + }); + + it("does not fire when the join has not consumed any rows yet", () => { + const ctx = buildCtx({ + stats: { j: stat({ aggregatedInputRowCount: 0, aggregatedOutputRowCount: 0 }) }, + types: { j: "HashJoin" }, + }); + expect(ruleIds("j", ctx)).not.toContain("JOIN_HIGH_FANIN_LOW_FANOUT"); + }); + }); + + describe("UPSTREAM_OVERPRODUCTION", () => { + it("fires when an upstream emits >10× what this op consumes", () => { + const ctx = buildCtx({ + stats: { + src: stat({ aggregatedOutputRowCount: 100_000 }), + downstream: stat({ aggregatedInputRowCount: 1_000 }), + }, + upstreams: { downstream: ["src"] }, + }); + expect(ruleIds("downstream", ctx)).toContain("UPSTREAM_OVERPRODUCTION"); + }); + + it("does not fire when input/output ratios are reasonable", () => { + const ctx = buildCtx({ + stats: { + src: stat({ aggregatedOutputRowCount: 1_000 }), + downstream: stat({ aggregatedInputRowCount: 1_000 }), + }, + upstreams: { downstream: ["src"] }, + }); + expect(ruleIds("downstream", ctx)).not.toContain("UPSTREAM_OVERPRODUCTION"); + }); + + it("does not fire when there are no upstream operators", () => { + const ctx = buildCtx({ + stats: { onlyOp: stat({ aggregatedInputRowCount: 1_000 }) }, + }); + expect(ruleIds("onlyOp", ctx)).not.toContain("UPSTREAM_OVERPRODUCTION"); + }); + + it("uses displayName (not raw operator id) in the hint message", () => { + const ctx = buildCtx({ + stats: { + "Scan-operator-abc123": stat({ aggregatedOutputRowCount: 100_000 }), + "Filter-operator-xyz789": stat({ aggregatedInputRowCount: 1_000 }), + }, + displayNames: { + "Scan-operator-abc123": "Tweet Source", + "Filter-operator-xyz789": "Recent Filter", + }, + upstreams: { "Filter-operator-xyz789": ["Scan-operator-abc123"] }, + }); + const hints = computeHintsForOperator("Filter-operator-xyz789", ctx); + const overproduce = hints.find(h => h.ruleId === "UPSTREAM_OVERPRODUCTION"); + expect(overproduce).toBeDefined(); + expect(overproduce!.message).toContain("Tweet Source"); + expect(overproduce!.message).toContain("Recent Filter"); + // Negative: the message should NOT contain the raw internal ids. + expect(overproduce!.message).not.toContain("Scan-operator-abc123"); + expect(overproduce!.message).not.toContain("Filter-operator-xyz789"); + }); + + it("falls back to raw id in messages when no displayName is mapped", () => { + const ctx = buildCtx({ + stats: { + src: stat({ aggregatedOutputRowCount: 100_000 }), + downstream: stat({ aggregatedInputRowCount: 1_000 }), + }, + upstreams: { downstream: ["src"] }, + // no displayNames map + }); + const hints = computeHintsForOperator("downstream", ctx); + const overproduce = hints.find(h => h.ruleId === "UPSTREAM_OVERPRODUCTION"); + expect(overproduce!.message).toContain("src"); + expect(overproduce!.message).toContain("downstream"); + }); + }); + + describe("RUNTIME_OUTLIER", () => { + it("fires when an operator runs >3× the median", () => { + const ctx = buildCtx({ + stats: { + a: stat({ aggregatedDataProcessingTime: 100 }), + b: stat({ aggregatedDataProcessingTime: 100 }), + c: stat({ aggregatedDataProcessingTime: 100 }), + bottleneck: stat({ aggregatedDataProcessingTime: 1000 }), + }, + }); + expect(ruleIds("bottleneck", ctx)).toContain("RUNTIME_OUTLIER"); + }); + + it("does not fire for operators within the median band", () => { + const ctx = buildCtx({ + stats: { + a: stat({ aggregatedDataProcessingTime: 100 }), + b: stat({ aggregatedDataProcessingTime: 100 }), + c: stat({ aggregatedDataProcessingTime: 200 }), + }, + }); + expect(ruleIds("c", ctx)).not.toContain("RUNTIME_OUTLIER"); + }); + + it("does not fire when there are fewer than 2 timed peers", () => { + const ctx = buildCtx({ + stats: { only: stat({ aggregatedDataProcessingTime: 1_000_000 }) }, + }); + expect(ruleIds("only", ctx)).not.toContain("RUNTIME_OUTLIER"); + }); + + it("does not fire when this operator has no measured runtime", () => { + const ctx = buildCtx({ + stats: { + slow: stat({ aggregatedDataProcessingTime: 0 }), + a: stat({ aggregatedDataProcessingTime: 100 }), + b: stat({ aggregatedDataProcessingTime: 100 }), + }, + }); + expect(ruleIds("slow", ctx)).not.toContain("RUNTIME_OUTLIER"); + }); + }); + + describe("LOW_PARALLELISM_HOT_OP", () => { + it("fires when a hot operator runs with a single worker", () => { + const ctx = buildCtx({ + stats: { hot: stat({ numWorkers: 1, aggregatedDataProcessingTime: 1000 }) }, + scores: { hot: 0.9 }, + hotThreshold: 0.8, + }); + expect(ruleIds("hot", ctx)).toContain("LOW_PARALLELISM_HOT_OP"); + }); + + it("does not fire when the operator is not hot", () => { + const ctx = buildCtx({ + stats: { warm: stat({ numWorkers: 1 }) }, + scores: { warm: 0.4 }, + hotThreshold: 0.8, + }); + expect(ruleIds("warm", ctx)).not.toContain("LOW_PARALLELISM_HOT_OP"); + }); + + it("does not fire when worker count is >1, even for hot operators", () => { + const ctx = buildCtx({ + stats: { hot: stat({ numWorkers: 4 }) }, + scores: { hot: 1.0 }, + }); + expect(ruleIds("hot", ctx)).not.toContain("LOW_PARALLELISM_HOT_OP"); + }); + }); + + describe("IDLE_HEAVY", () => { + it("fires when a running op is idle >70% of the time", () => { + const ctx = buildCtx({ + stats: { + waiting: stat({ + operatorState: OperatorState.Running, + aggregatedDataProcessingTime: 100, + aggregatedControlProcessingTime: 100, + aggregatedIdleTime: 800, + }), + }, + }); + expect(ruleIds("waiting", ctx)).toContain("IDLE_HEAVY"); + }); + + it("does not fire when the operator is not running", () => { + const ctx = buildCtx({ + stats: { + done: stat({ + operatorState: OperatorState.Completed, + aggregatedIdleTime: 800, + aggregatedDataProcessingTime: 100, + }), + }, + }); + expect(ruleIds("done", ctx)).not.toContain("IDLE_HEAVY"); + }); + + it("does not fire when idle ratio is below threshold", () => { + const ctx = buildCtx({ + stats: { + busy: stat({ + operatorState: OperatorState.Running, + aggregatedDataProcessingTime: 700, + aggregatedControlProcessingTime: 100, + aggregatedIdleTime: 200, + }), + }, + }); + expect(ruleIds("busy", ctx)).not.toContain("IDLE_HEAVY"); + }); + + it("does not fire when all timing fields are missing/zero", () => { + const ctx = buildCtx({ + stats: { fresh: stat({ operatorState: OperatorState.Running }) }, + }); + expect(ruleIds("fresh", ctx)).not.toContain("IDLE_HEAVY"); + }); + }); + + describe("SCAN_FULL_TABLE_NO_FILTER", () => { + it("fires when a large scan has no filter immediately downstream", () => { + const ctx = buildCtx({ + stats: { + scan: stat({ aggregatedOutputRowCount: 2_000_000 }), + downstream: stat({}), + }, + types: { scan: "CSVFileScan", downstream: "Projection" }, + downstreams: { scan: ["downstream"] }, + }); + expect(ruleIds("scan", ctx)).toContain("SCAN_FULL_TABLE_NO_FILTER"); + }); + + it("does not fire when a Filter operator is directly downstream", () => { + const ctx = buildCtx({ + stats: { + scan: stat({ aggregatedOutputRowCount: 2_000_000 }), + flt: stat({}), + }, + types: { scan: "CSVFileScan", flt: "Filter" }, + downstreams: { scan: ["flt"] }, + }); + expect(ruleIds("scan", ctx)).not.toContain("SCAN_FULL_TABLE_NO_FILTER"); + }); + + it("does not fire for small scans", () => { + const ctx = buildCtx({ + stats: { scan: stat({ aggregatedOutputRowCount: 10 }) }, + types: { scan: "CSVFileScan" }, + }); + expect(ruleIds("scan", ctx)).not.toContain("SCAN_FULL_TABLE_NO_FILTER"); + }); + + it("does not fire for non-scan operators", () => { + const ctx = buildCtx({ + stats: { agg: stat({ aggregatedOutputRowCount: 2_000_000 }) }, + types: { agg: "Aggregate" }, + }); + expect(ruleIds("agg", ctx)).not.toContain("SCAN_FULL_TABLE_NO_FILTER"); + }); + }); +}); diff --git a/frontend/src/app/workspace/service/profiler/profiler-hints.ts b/frontend/src/app/workspace/service/profiler/profiler-hints.ts new file mode 100644 index 00000000000..a26b6d5fb10 --- /dev/null +++ b/frontend/src/app/workspace/service/profiler/profiler-hints.ts @@ -0,0 +1,217 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +/** + * Pure rule engine that converts the latest profiler stats snapshot into a list of + * human-readable optimization hints for an individual operator. + * + * Kept dependency-free (no Angular, no RxJS) so that: + * 1. Each rule can be unit-tested with a synthetic `HintContext`. + * 2. Callers (currently the side panel) can invoke it synchronously on demand. + */ + +import { OperatorState, OperatorStatistics } from "../../types/execute-workflow.interface"; + +export type HintRuleId = + | "JOIN_HIGH_FANIN_LOW_FANOUT" + | "UPSTREAM_OVERPRODUCTION" + | "RUNTIME_OUTLIER" + | "LOW_PARALLELISM_HOT_OP" + | "IDLE_HEAVY" + | "SCAN_FULL_TABLE_NO_FILTER"; + +export type HintSeverity = "info" | "warning"; + +export interface Hint { + readonly ruleId: HintRuleId; + readonly severity: HintSeverity; + readonly message: string; +} + +export interface HintContext { + /** Latest per-operator stats snapshot, keyed by operator id. */ + readonly stats: Readonly>; + /** Normalized scores in [0,1] from ProfilerService, keyed by operator id. */ + readonly scores: Readonly>; + /** Threshold (in [0,1]) at or above which an operator counts as "hot". */ + readonly hotThreshold: number; + /** Returns the operator's static type (e.g. "HashJoin", "CSVScan"), or undefined if unknown. */ + readonly operatorType: (opId: string) => string | undefined; + /** + * Returns a human-readable label for the operator (customDisplayName, falling back to + * operatorType, falling back to the raw id). Used in hint messages so end users don't + * see internal ids like "HashJoin-operator-abc123ef". + */ + readonly displayName: (opId: string) => string; + /** Operator ids that feed directly into the given op. */ + readonly upstreamOps: (opId: string) => readonly string[]; + /** Operator ids that the given op feeds directly into. */ + readonly downstreamOps: (opId: string) => readonly string[]; +} + +const JOIN_TYPE_PATTERN = /join/i; +const FILTER_TYPE_PATTERN = /filter/i; +const SCAN_TYPE_PATTERN = /(scan|source)/i; + +const JOIN_LOW_FANOUT_RATIO = 0.05; +const UPSTREAM_OVERPRODUCE_RATIO = 10; +const RUNTIME_OUTLIER_FACTOR = 3; +const IDLE_HEAVY_RATIO = 0.7; +const SCAN_LARGE_OUTPUT = 1_000_000; + +/** + * Computes all applicable hints for a single operator given a context snapshot. + * Returns hints in stable order (rule id alphabetical) so the UI is deterministic. + */ +export function computeHintsForOperator(opId: string, ctx: HintContext): readonly Hint[] { + const stats = ctx.stats[opId]; + if (!stats) return []; + + const hints: Hint[] = []; + const type = ctx.operatorType(opId); + + pushIfDefined(hints, joinHighFaninLowFanoutRule(opId, stats, type)); + pushIfDefined(hints, upstreamOverproductionRule(opId, ctx)); + pushIfDefined(hints, runtimeOutlierRule(opId, ctx)); + pushIfDefined(hints, lowParallelismHotOpRule(opId, stats, ctx)); + pushIfDefined(hints, idleHeavyRule(stats)); + pushIfDefined(hints, scanFullTableNoFilterRule(opId, stats, ctx, type)); + + return hints.sort((a, b) => a.ruleId.localeCompare(b.ruleId)); +} + +function pushIfDefined(hints: Hint[], hint: Hint | undefined): void { + if (hint) hints.push(hint); +} + +function joinHighFaninLowFanoutRule( + _opId: string, + s: OperatorStatistics, + type: string | undefined +): Hint | undefined { + if (!type || !JOIN_TYPE_PATTERN.test(type)) return undefined; + const inp = s.aggregatedInputRowCount ?? 0; + const out = s.aggregatedOutputRowCount ?? 0; + if (inp <= 0) return undefined; + if (out / inp >= JOIN_LOW_FANOUT_RATIO) return undefined; + return { + ruleId: "JOIN_HIGH_FANIN_LOW_FANOUT", + severity: "warning", + message: `Join emits <${Math.round(JOIN_LOW_FANOUT_RATIO * 100)}% of its input (${out.toLocaleString()} of ${inp.toLocaleString()} rows). Consider filtering upstream to reduce shuffle.`, + }; +} + +function upstreamOverproductionRule(opId: string, ctx: HintContext): Hint | undefined { + const myStats = ctx.stats[opId]; + if (!myStats) return undefined; + const myInputs = myStats.aggregatedInputRowCount ?? 0; + if (myInputs <= 0) return undefined; + for (const upstream of ctx.upstreamOps(opId)) { + const upStats = ctx.stats[upstream]; + if (!upStats) continue; + const upOut = upStats.aggregatedOutputRowCount ?? 0; + if (upOut > myInputs * UPSTREAM_OVERPRODUCE_RATIO) { + return { + ruleId: "UPSTREAM_OVERPRODUCTION", + severity: "warning", + message: `Upstream '${ctx.displayName(upstream)}' produces ${upOut.toLocaleString()} rows but '${ctx.displayName(opId)}' keeps only ${myInputs.toLocaleString()}. Push a filter upstream.`, + }; + } + } + return undefined; +} + +function runtimeOutlierRule(opId: string, ctx: HintContext): Hint | undefined { + const myStats = ctx.stats[opId]; + const myTime = myStats?.aggregatedDataProcessingTime ?? 0; + if (myTime <= 0) return undefined; + + const peerTimes: number[] = []; + for (const id of Object.keys(ctx.stats)) { + const t = ctx.stats[id].aggregatedDataProcessingTime ?? 0; + if (t > 0) peerTimes.push(t); + } + if (peerTimes.length < 2) return undefined; + const median = computeMedian(peerTimes); + if (median <= 0) return undefined; + if (myTime <= RUNTIME_OUTLIER_FACTOR * median) return undefined; + + return { + ruleId: "RUNTIME_OUTLIER", + severity: "warning", + message: `Runtime is ${(myTime / median).toFixed(1)}× the median across operators — likely the workflow bottleneck.`, + }; +} + +function lowParallelismHotOpRule(opId: string, s: OperatorStatistics, ctx: HintContext): Hint | undefined { + const score = ctx.scores[opId] ?? 0; + if (score < ctx.hotThreshold) return undefined; + const workers = s.numWorkers ?? 1; + if (workers > 1) return undefined; + return { + ruleId: "LOW_PARALLELISM_HOT_OP", + severity: "info", + message: `Hot operator is running with ${workers} worker. Increasing parallelism may improve runtime.`, + }; +} + +function idleHeavyRule(s: OperatorStatistics): Hint | undefined { + if (s.operatorState !== OperatorState.Running) return undefined; + const data = s.aggregatedDataProcessingTime ?? 0; + const ctrl = s.aggregatedControlProcessingTime ?? 0; + const idle = s.aggregatedIdleTime ?? 0; + const total = data + ctrl + idle; + if (total <= 0) return undefined; + const ratio = idle / total; + if (ratio <= IDLE_HEAVY_RATIO) return undefined; + return { + ruleId: "IDLE_HEAVY", + severity: "info", + message: `Operator is idle ${Math.round(ratio * 100)}% of the time — the bottleneck is likely upstream.`, + }; +} + +function scanFullTableNoFilterRule( + opId: string, + s: OperatorStatistics, + ctx: HintContext, + type: string | undefined +): Hint | undefined { + if (!type || !SCAN_TYPE_PATTERN.test(type)) return undefined; + const out = s.aggregatedOutputRowCount ?? 0; + if (out <= SCAN_LARGE_OUTPUT) return undefined; + const downstream = ctx.downstreamOps(opId); + const hasFilterChild = downstream.some(id => { + const childType = ctx.operatorType(id); + return !!childType && FILTER_TYPE_PATTERN.test(childType); + }); + if (hasFilterChild) return undefined; + return { + ruleId: "SCAN_FULL_TABLE_NO_FILTER", + severity: "warning", + message: `Scan emits ${out.toLocaleString()} rows with no immediate filter downstream. Apply a filter to reduce data volume early.`, + }; +} + +function computeMedian(values: readonly number[]): number { + if (values.length === 0) return 0; + const sorted = [...values].sort((a, b) => a - b); + const mid = sorted.length >> 1; + return sorted.length % 2 === 0 ? (sorted[mid - 1] + sorted[mid]) / 2 : sorted[mid]; +} diff --git a/frontend/src/app/workspace/service/profiler/profiler-history.service.ts b/frontend/src/app/workspace/service/profiler/profiler-history.service.ts new file mode 100644 index 00000000000..13c63f46643 --- /dev/null +++ b/frontend/src/app/workspace/service/profiler/profiler-history.service.ts @@ -0,0 +1,119 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { Injectable } from "@angular/core"; +import { Observable, defer, of, shareReplay, map, catchError } from "rxjs"; +import { + EXECUTION_STATUS_CODE, + WorkflowExecutionsEntry, +} from "../../../dashboard/type/workflow-executions-entry"; +import { WorkflowExecutionsService } from "../../../dashboard/service/user/workflow-executions/workflow-executions.service"; +import { ExecutionState } from "../../types/execute-workflow.interface"; +import { BaselineReport } from "./profiler-delta"; +import { + WorkflowRuntimeStatsRow, + convertStatsRowsToBaseline, +} from "./profiler-history"; + +/** + * P6 (compare across runs): turns the persisted per-execution stats already + * exposed by `/executions/{wid}` + `/executions/{wid}/stats/{eid}` into the + * existing `BaselineReport` shape so the side-panel delta UI works + * end-to-end with zero new rendering code. + * + * Caching policy: we memoize per (wid, eid) because the same baseline tends to + * be re-selected as users compare against different historical runs. Cache + * never invalidates within a session — historical stats are immutable once an + * execution completes. + */ +@Injectable({ providedIn: "root" }) +export class ProfilerHistoryService { + private readonly baselineCache = new Map>(); + + constructor(private readonly workflowExecutionsService: WorkflowExecutionsService) {} + + /** + * List the historical executions of a workflow that have completed and + * therefore have a baseline worth comparing against. Filters out in-flight + * / failed runs since their stats are incomplete or absent. + */ + public listCompletedExecutions(workflowId: number): Observable { + return this.workflowExecutionsService + .retrieveWorkflowExecutions(workflowId, [ExecutionState.Completed]) + .pipe( + map(rows => + rows.filter(r => EXECUTION_STATUS_CODE[r.status] === ExecutionState.Completed) + ), + catchError(() => of([] as WorkflowExecutionsEntry[])) + ); + } + + /** + * Fetch the runtime stats for the given execution and convert them into a + * `BaselineReport` ready to feed into `ProfilerService.setBaseline`. Returns + * `undefined` on a network failure or when the persisted stats yield zero + * valid operators — callers should fall back to no-baseline state. + * + * `cuid` is forwarded to the backend's query string for API-shape parity but + * the current server impl ignores it (the URI lookup is keyed solely on eId). + */ + public loadBaselineForExecution(input: { + workflowId: number; + execution: WorkflowExecutionsEntry; + workflowName: string; + }): Observable { + const key = `${input.workflowId}::${input.execution.eId}`; + const cached = this.baselineCache.get(key); + if (cached) return cached; + + const stream = defer(() => + this.workflowExecutionsService.retrieveWorkflowRuntimeStatistics( + input.workflowId, + input.execution.eId, + input.execution.cuId + ) + ).pipe( + map(rows => { + const rowsAsRecords = rows as unknown as WorkflowRuntimeStatsRow[]; + return convertStatsRowsToBaseline({ + rows: rowsAsRecords, + workflowName: input.workflowName, + executionName: input.execution.name || `Execution #${input.execution.eId}`, + generatedAt: completionTimestampToIso(input.execution.completionTime), + }); + }), + catchError(() => of(undefined)), + shareReplay(1) + ); + this.baselineCache.set(key, stream); + return stream; + } + + /** Clears all cached baselines. Mainly for tests + workflow switches. */ + public clearCache(): void { + this.baselineCache.clear(); + } +} + +function completionTimestampToIso(ts: number | undefined): string { + if (typeof ts !== "number" || !Number.isFinite(ts) || ts <= 0) { + return new Date().toISOString(); + } + return new Date(ts).toISOString(); +} diff --git a/frontend/src/app/workspace/service/profiler/profiler-history.spec.ts b/frontend/src/app/workspace/service/profiler/profiler-history.spec.ts new file mode 100644 index 00000000000..ceeecae133b --- /dev/null +++ b/frontend/src/app/workspace/service/profiler/profiler-history.spec.ts @@ -0,0 +1,256 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { describe, it, expect } from "vitest"; +import { + convertStatsRowsToBaseline, + latestRowPerOperator, + WorkflowRuntimeStatsRow, +} from "./profiler-history"; + +function makeRow(overrides: Partial = {}): WorkflowRuntimeStatsRow { + return { + operatorId: "op-1", + timestamp: "2026-05-15T12:00:00.000Z", + inputTupleCount: 0, + inputTupleSize: 0, + outputTupleCount: 0, + outputTupleSize: 0, + dataProcessingTime: 0, + controlProcessingTime: 0, + idleTime: 0, + numWorkers: 1, + status: 0, + ...overrides, + }; +} + +describe("latestRowPerOperator", () => { + it("returns [] for empty input", () => { + expect(latestRowPerOperator([])).toEqual([]); + }); + + it("keeps the single row when only one is present", () => { + const r = makeRow({ operatorId: "x" }); + expect(latestRowPerOperator([r])).toEqual([r]); + }); + + it("keeps the highest-timestamp row per operator (string timestamps)", () => { + const early = makeRow({ operatorId: "x", timestamp: "2026-05-15T12:00:00.000Z", outputTupleCount: 1 }); + const late = makeRow({ operatorId: "x", timestamp: "2026-05-15T12:05:00.000Z", outputTupleCount: 9 }); + const out = latestRowPerOperator([early, late]); + expect(out).toHaveLength(1); + expect(out[0].outputTupleCount).toBe(9); + }); + + it("keeps the highest-timestamp row per operator (numeric ms timestamps)", () => { + const early = makeRow({ operatorId: "x", timestamp: 1000, outputTupleCount: 1 }); + const late = makeRow({ operatorId: "x", timestamp: 5000, outputTupleCount: 9 }); + const out = latestRowPerOperator([late, early]); + expect(out[0].outputTupleCount).toBe(9); + }); + + it("preserves the LAST row when timestamps tie (stable upsert)", () => { + const a = makeRow({ operatorId: "x", timestamp: 100, outputTupleCount: 1 }); + const b = makeRow({ operatorId: "x", timestamp: 100, outputTupleCount: 2 }); + const out = latestRowPerOperator([a, b]); + expect(out[0].outputTupleCount).toBe(2); + }); + + it("returns one row per operator even when interleaved", () => { + const rows: WorkflowRuntimeStatsRow[] = [ + makeRow({ operatorId: "x", timestamp: 100, outputTupleCount: 1 }), + makeRow({ operatorId: "y", timestamp: 200, outputTupleCount: 10 }), + makeRow({ operatorId: "x", timestamp: 300, outputTupleCount: 3 }), + makeRow({ operatorId: "y", timestamp: 400, outputTupleCount: 30 }), + ]; + const out = latestRowPerOperator(rows); + expect(out).toHaveLength(2); + const byId = Object.fromEntries(out.map(r => [r.operatorId, r.outputTupleCount])); + expect(byId).toEqual({ x: 3, y: 30 }); + }); + + it("drops rows with non-parseable timestamps (defensive)", () => { + const ok = makeRow({ operatorId: "x", timestamp: 200, outputTupleCount: 9 }); + const bad = makeRow({ operatorId: "y", timestamp: "not a date", outputTupleCount: 999 }); + const out = latestRowPerOperator([ok, bad]); + expect(out).toHaveLength(1); + expect(out[0].operatorId).toBe("x"); + }); +}); + +describe("convertStatsRowsToBaseline", () => { + it("returns undefined when no rows yield a valid latest entry", () => { + expect( + convertStatsRowsToBaseline({ + rows: [], + workflowName: "wf", + executionName: null, + generatedAt: "2026-05-15T12:00:00.000Z", + }) + ).toBeUndefined(); + // Single row with an unparseable timestamp also yields nothing. + expect( + convertStatsRowsToBaseline({ + rows: [makeRow({ timestamp: "garbage" })], + workflowName: "wf", + executionName: null, + generatedAt: "now", + }) + ).toBeUndefined(); + }); + + it("populates the header with workflowName / executionName / generatedAt / operatorCount", () => { + const out = convertStatsRowsToBaseline({ + rows: [ + makeRow({ operatorId: "a" }), + makeRow({ operatorId: "b", timestamp: "2026-05-15T12:01:00.000Z" }), + ], + workflowName: "My Workflow", + executionName: "run-12", + generatedAt: "2026-05-15T12:30:00.000Z", + }); + expect(out).toBeDefined(); + expect(out!.header.workflowName).toBe("My Workflow"); + expect(out!.header.executionName).toBe("run-12"); + expect(out!.header.generatedAt).toBe("2026-05-15T12:30:00.000Z"); + expect(out!.header.operatorCount).toBe(2); + // Defaults: view "runtime", hot threshold 80. + expect(out!.header.view).toBe("runtime"); + expect(out!.header.hotThresholdPercentile).toBe(80); + }); + + it("honors explicit view + hotThresholdPercentile when supplied", () => { + const out = convertStatsRowsToBaseline({ + rows: [makeRow({ operatorId: "a" })], + workflowName: "w", + executionName: null, + generatedAt: "x", + view: "throughput", + hotThresholdPercentile: 95, + }); + expect(out!.header.view).toBe("throughput"); + expect(out!.header.hotThresholdPercentile).toBe(95); + }); + + it("converts ns data-processing-time to runtimeMs", () => { + const out = convertStatsRowsToBaseline({ + rows: [makeRow({ operatorId: "a", dataProcessingTime: 2_000_000_000 })], // 2 seconds + workflowName: "w", + executionName: null, + generatedAt: "x", + }); + expect(out!.operators[0].runtimeMs).toBe(2000); + }); + + it("derives throughput as outputRows / runtimeSeconds when both > 0", () => { + const out = convertStatsRowsToBaseline({ + rows: [ + makeRow({ operatorId: "a", dataProcessingTime: 1_000_000_000, outputTupleCount: 5000 }), + ], + workflowName: "w", + executionName: null, + generatedAt: "x", + }); + expect(out!.operators[0].throughputRowsPerSec).toBe(5000); + }); + + it("derives idleRatio as idle / (data + control + idle)", () => { + const out = convertStatsRowsToBaseline({ + rows: [ + makeRow({ + operatorId: "a", + dataProcessingTime: 1, + controlProcessingTime: 1, + idleTime: 2, + }), + ], + workflowName: "w", + executionName: null, + generatedAt: "x", + }); + expect(out!.operators[0].idleRatio).toBe(0.5); + }); + + it("emits null for runtime / throughput / idleRatio when there is no measurable work", () => { + const out = convertStatsRowsToBaseline({ + rows: [makeRow({ operatorId: "a" })], // all zero + workflowName: "w", + executionName: null, + generatedAt: "x", + }); + const op = out!.operators[0]; + expect(op.runtimeMs).toBeNull(); + expect(op.throughputRowsPerSec).toBeNull(); + expect(op.idleRatio).toBeNull(); + }); + + it("emits null for inputSize / outputSize when 0 (treated as unmeasured)", () => { + const out = convertStatsRowsToBaseline({ + rows: [makeRow({ operatorId: "a", inputTupleSize: 0, outputTupleSize: 0 })], + workflowName: "w", + executionName: null, + generatedAt: "x", + }); + expect(out!.operators[0].inputSize).toBeNull(); + expect(out!.operators[0].outputSize).toBeNull(); + }); + + it("displayName mirrors the operatorId (backend persists no friendly name)", () => { + const out = convertStatsRowsToBaseline({ + rows: [makeRow({ operatorId: "scan-1" })], + workflowName: "w", + executionName: null, + generatedAt: "x", + }); + expect(out!.operators[0].displayName).toBe("scan-1"); + }); + + it("zero / negative metric values are clamped to 0 (defensive against wire glitches)", () => { + const out = convertStatsRowsToBaseline({ + rows: [ + makeRow({ + operatorId: "a", + inputTupleCount: -5 as any, + outputTupleCount: -1 as any, + }), + ], + workflowName: "w", + executionName: null, + generatedAt: "x", + }); + expect(out!.operators[0].inputRows).toBe(0); + expect(out!.operators[0].outputRows).toBe(0); + }); + + it("deduplicates multiple snapshot rows per operator end-to-end (keeps the latest cumulative totals)", () => { + const out = convertStatsRowsToBaseline({ + rows: [ + makeRow({ operatorId: "a", timestamp: 1000, outputTupleCount: 100 }), + makeRow({ operatorId: "a", timestamp: 2000, outputTupleCount: 500 }), + makeRow({ operatorId: "a", timestamp: 3000, outputTupleCount: 999 }), + ], + workflowName: "w", + executionName: null, + generatedAt: "x", + }); + expect(out!.operators).toHaveLength(1); + expect(out!.operators[0].outputRows).toBe(999); + }); +}); diff --git a/frontend/src/app/workspace/service/profiler/profiler-history.ts b/frontend/src/app/workspace/service/profiler/profiler-history.ts new file mode 100644 index 00000000000..cb1115d3c63 --- /dev/null +++ b/frontend/src/app/workspace/service/profiler/profiler-history.ts @@ -0,0 +1,180 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { BaselineReport, ComparableOperator } from "./profiler-delta"; + +/** + * P6 (compare across runs): pure helpers that convert the backend's persisted + * per-execution stats into the same `BaselineReport` shape used by the existing + * upload-baseline flow. Lets us reuse all the downstream delta math + UI for + * free — the only new path is "fetch a previous execution → set baseline." + * + * Wire format mirrors `WorkflowExecutionsResource.WorkflowRuntimeStatistics`: + * each row is one cumulative snapshot of one operator at one timestamp. + * Multiple rows per operator are normal (polled at the engine's update interval); + * the LATEST row per operator carries the final cumulative totals. + */ + +/** One row from `GET /api/executions/{wid}/stats/{eid}`. */ +export interface WorkflowRuntimeStatsRow { + operatorId: string; + /** ISO-8601 string or millisecond epoch — converted to ms via `Date.parse` / `+ts`. */ + timestamp: string | number; + inputTupleCount: number; + inputTupleSize: number; + outputTupleCount: number; + outputTupleSize: number; + /** Nanoseconds, matching the live stream. */ + dataProcessingTime: number; + /** Nanoseconds. */ + controlProcessingTime: number; + /** Nanoseconds. */ + idleTime: number; + numWorkers: number; + status: number; +} + +/** One row from `GET /api/executions/{wid}` (the executions list). */ +export interface WorkflowExecutionEntry { + eId: number; + vId: number; + cuId: number; + userName: string; + googleAvatar: string; + status: number; + result: string; + /** ISO-8601 string or millisecond epoch. */ + startingTime: string | number; + /** ISO-8601 string or millisecond epoch. */ + completionTime: string | number; + bookmarked: boolean; + name: string; + logLocation: string; +} + +/** + * From a list of cumulative snapshot rows, return the LAST row per operator + * (by timestamp). Defensive: rows with non-parseable timestamps are dropped. + */ +export function latestRowPerOperator( + rows: readonly WorkflowRuntimeStatsRow[] +): WorkflowRuntimeStatsRow[] { + const byOp = new Map(); + for (const row of rows) { + const ts = toEpochMs(row.timestamp); + if (!Number.isFinite(ts)) continue; + const existing = byOp.get(row.operatorId); + if (!existing || ts >= existing.ts) { + byOp.set(row.operatorId, { row, ts }); + } + } + return Array.from(byOp.values()).map(v => v.row); +} + +/** + * Convert a single (already-deduplicated) snapshot row into the comparable + * shape used by the delta engine. Mirrors the live derivations in + * `profiler-delta.statsToComparable` so historical and live runs are + * apples-to-apples. + */ +function rowToComparable(row: WorkflowRuntimeStatsRow): ComparableOperator { + const dataNs = nonNegOrZero(row.dataProcessingTime); + const ctrlNs = nonNegOrZero(row.controlProcessingTime); + const idleNs = nonNegOrZero(row.idleTime); + const totalNs = dataNs + ctrlNs + idleNs; + const runtimeMs = dataNs > 0 ? dataNs / 1_000_000 : null; + const outRows = nonNegOrZero(row.outputTupleCount); + const inRows = nonNegOrZero(row.inputTupleCount); + const throughputRowsPerSec = + dataNs > 0 && outRows > 0 ? outRows / (dataNs / 1_000_000_000) : null; + const idleRatio = totalNs > 0 ? idleNs / totalNs : null; + + return { + operatorId: row.operatorId, + // The backend's persisted stats don't carry a friendly display name, so + // mirror the operator id. The current workflow will provide the real name + // when the delta is rendered (via the live operator-by-id lookup). + displayName: row.operatorId, + operatorType: null, + // Score is recomputed on the live side; baseline scores aren't needed. + score: 0, + runtimeMs, + throughputRowsPerSec, + inputRows: inRows, + outputRows: outRows, + inputSize: row.inputTupleSize > 0 ? row.inputTupleSize : null, + outputSize: row.outputTupleSize > 0 ? row.outputTupleSize : null, + workers: row.numWorkers > 0 ? row.numWorkers : null, + idleRatio, + }; +} + +export interface ConvertRowsToBaselineInput { + rows: readonly WorkflowRuntimeStatsRow[]; + /** Used in the report's header so the UI can show "Comparing to: …". */ + workflowName: string; + executionName: string | null; + /** When the baseline run was generated — typically the execution's completion time. */ + generatedAt: string; + /** Profiler view active at the time the historical run was captured (or now). */ + view?: string; + /** Hot-threshold percentile in effect (default 80 to match the frontend). */ + hotThresholdPercentile?: number; +} + +/** + * Build a `BaselineReport` from raw runtime-statistics rows. Handles the + * "multiple snapshot rows per operator" case by keeping only the latest row + * (highest timestamp) per operatorId. + * + * Returns `undefined` if the input rows yield zero valid operators — caller + * should treat that as "no baseline data available for this execution" and + * skip calling `setBaseline`. + */ +export function convertStatsRowsToBaseline( + input: ConvertRowsToBaselineInput +): BaselineReport | undefined { + const latest = latestRowPerOperator(input.rows); + if (latest.length === 0) return undefined; + const operators = latest.map(rowToComparable); + return { + header: { + workflowName: input.workflowName, + executionName: input.executionName, + generatedAt: input.generatedAt, + view: input.view ?? "runtime", + hotThresholdPercentile: + typeof input.hotThresholdPercentile === "number" ? input.hotThresholdPercentile : 80, + operatorCount: operators.length, + }, + operators, + }; +} + +function toEpochMs(ts: string | number | undefined | null): number { + if (ts == null) return NaN; + if (typeof ts === "number") return ts; + const parsed = Date.parse(ts); + return Number.isFinite(parsed) ? parsed : NaN; +} + +function nonNegOrZero(v: number | null | undefined): number { + if (typeof v !== "number" || !Number.isFinite(v) || v < 0) return 0; + return v; +} diff --git a/frontend/src/app/workspace/service/profiler/profiler-hover.spec.ts b/frontend/src/app/workspace/service/profiler/profiler-hover.spec.ts new file mode 100644 index 00000000000..1e8e9cf6d7f --- /dev/null +++ b/frontend/src/app/workspace/service/profiler/profiler-hover.spec.ts @@ -0,0 +1,113 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { OperatorState, OperatorStatistics } from "../../types/execute-workflow.interface"; +import { formatHoverHeadline, formatViewLabel } from "./profiler-hover"; + +function stat(partial: Partial = {}): OperatorStatistics { + return { + operatorState: OperatorState.Completed, + aggregatedInputRowCount: 0, + inputPortMetrics: {}, + aggregatedOutputRowCount: 0, + outputPortMetrics: {}, + ...partial, + }; +} + +describe("formatHoverHeadline", () => { + describe("runtime view", () => { + it("returns ms with one decimal for sub-100 values", () => { + // 50,000,000 ns = 50 ms + const text = formatHoverHeadline("runtime", stat({ aggregatedDataProcessingTime: 50_000_000 })); + expect(text).toBe("50.0 ms"); + }); + + it("returns ms without decimals for >=100 ms values", () => { + // 2,710,800,000 ns = 2,710.8 ms + const text = formatHoverHeadline("runtime", stat({ aggregatedDataProcessingTime: 2_710_800_000 })); + expect(text).toBe("2,711 ms"); + }); + + it("returns undefined when runtime is missing", () => { + expect(formatHoverHeadline("runtime", stat({}))).toBeUndefined(); + }); + + it("returns undefined when runtime is zero", () => { + expect(formatHoverHeadline("runtime", stat({ aggregatedDataProcessingTime: 0 }))).toBeUndefined(); + }); + }); + + describe("throughput view", () => { + it("returns rows/s rounded to whole rows", () => { + // 1,000 rows over 0.5s -> 2,000 rows/s + const text = formatHoverHeadline( + "throughput", + stat({ aggregatedOutputRowCount: 1_000, aggregatedDataProcessingTime: 500_000_000 }) + ); + expect(text).toBe("2,000 rows/s"); + }); + + it("returns undefined when output rows are zero", () => { + expect( + formatHoverHeadline( + "throughput", + stat({ aggregatedOutputRowCount: 0, aggregatedDataProcessingTime: 1_000_000 }) + ) + ).toBeUndefined(); + }); + + it("returns undefined when runtime is missing", () => { + expect(formatHoverHeadline("throughput", stat({ aggregatedOutputRowCount: 1_000 }))).toBeUndefined(); + }); + }); + + describe("io-imbalance view", () => { + it("returns dropped percentage and absolute counts", () => { + const text = formatHoverHeadline( + "io-imbalance", + stat({ aggregatedInputRowCount: 1_000, aggregatedOutputRowCount: 50 }) + ); + // 95% dropped + expect(text).toBe("95% dropped (50 of 1,000)"); + }); + + it("returns 0% dropped for pass-through operators", () => { + const text = formatHoverHeadline( + "io-imbalance", + stat({ aggregatedInputRowCount: 1_000, aggregatedOutputRowCount: 1_000 }) + ); + expect(text).toBe("0% dropped (1,000 of 1,000)"); + }); + + it("returns undefined when input is zero (source operators)", () => { + expect( + formatHoverHeadline("io-imbalance", stat({ aggregatedInputRowCount: 0, aggregatedOutputRowCount: 1_000 })) + ).toBeUndefined(); + }); + }); +}); + +describe("formatViewLabel", () => { + it("maps each ProfilerView to a human-readable label", () => { + expect(formatViewLabel("runtime")).toBe("Runtime"); + expect(formatViewLabel("throughput")).toBe("Throughput"); + expect(formatViewLabel("io-imbalance")).toBe("I/O imbalance"); + }); +}); diff --git a/frontend/src/app/workspace/service/profiler/profiler-hover.ts b/frontend/src/app/workspace/service/profiler/profiler-hover.ts new file mode 100644 index 00000000000..2b935441d86 --- /dev/null +++ b/frontend/src/app/workspace/service/profiler/profiler-hover.ts @@ -0,0 +1,105 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +/** + * Pure formatting helpers for the per-operator hover card. + * + * Kept dependency-free (no Angular, no JointJS) so the headline text can be + * unit-tested with synthetic stats without standing up a paper or DOM. + */ + +import { OperatorStatistics } from "../../types/execute-workflow.interface"; +import { ProfilerView } from "./profiler.service"; + +/** + * Returns the headline metric to surface on hover for the current profiler view. + * The text mirrors what the side panel shows in its "key field" position, just + * shorter so it fits a single tooltip line. + * + * Returns `undefined` when no meaningful value is available (e.g. an operator + * that hasn't started or has zero of the relevant metric); callers should + * suppress the headline row in that case rather than render an empty string. + */ +export function formatHoverHeadline(view: ProfilerView, stats: OperatorStatistics): string | undefined { + switch (view) { + case "runtime": { + const t = stats.aggregatedDataProcessingTime; + if (!t || t <= 0) return undefined; + const ms = t / 1_000_000; + return `${formatNumber(ms, ms >= 100 ? 0 : 1)} ms`; + } + case "throughput": { + const out = stats.aggregatedOutputRowCount ?? 0; + const t = stats.aggregatedDataProcessingTime; + if (!t || t <= 0 || out <= 0) return undefined; + const rowsPerSec = out / (t / 1_000_000_000); + return `${formatNumber(rowsPerSec, 0)} rows/s`; + } + case "io-imbalance": { + const inp = stats.aggregatedInputRowCount ?? 0; + const out = stats.aggregatedOutputRowCount ?? 0; + if (inp <= 0) return undefined; + const dropped = 1 - out / inp; + return `${(dropped * 100).toFixed(0)}% dropped (${out.toLocaleString()} of ${inp.toLocaleString()})`; + } + case "delta": { + // Delta view's headline is the current-vs-baseline runtime gap. The hover + // card doesn't have direct access to the baseline here, so the caller + // overrides the headline with `formatDeltaHoverHeadline` below. Returning + // undefined keeps the row collapsed by default. + return undefined; + } + } +} + +/** + * Headline for the hover card when the canvas is in delta view. Takes the + * already-computed runtime delta (ms) directly so this helper stays pure and + * doesn't need to re-derive math from baseline+current pairs. + */ +export function formatDeltaHoverHeadline(runtimeMsDelta: number | null): string | undefined { + if (runtimeMsDelta === null || !Number.isFinite(runtimeMsDelta)) return undefined; + const sign = runtimeMsDelta < 0 ? "−" : "+"; + const abs = Math.abs(runtimeMsDelta); + return `${sign}${formatNumber(abs, abs >= 100 ? 0 : 1)} ms vs baseline`; +} + +/** + * Returns a short human-readable label for the current profiler view — + * shown next to the headline metric so the tooltip is self-describing. + */ +export function formatViewLabel(view: ProfilerView): string { + switch (view) { + case "runtime": + return "Runtime"; + case "throughput": + return "Throughput"; + case "io-imbalance": + return "I/O imbalance"; + case "delta": + return "Δ vs baseline"; + } +} + +function formatNumber(n: number, fractionDigits: number): string { + return n.toLocaleString(undefined, { + minimumFractionDigits: fractionDigits, + maximumFractionDigits: fractionDigits, + }); +} diff --git a/frontend/src/app/workspace/service/profiler/profiler-report.spec.ts b/frontend/src/app/workspace/service/profiler/profiler-report.spec.ts new file mode 100644 index 00000000000..4a4cdbfa312 --- /dev/null +++ b/frontend/src/app/workspace/service/profiler/profiler-report.spec.ts @@ -0,0 +1,347 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { OperatorState, OperatorStatistics } from "../../types/execute-workflow.interface"; +import { ProfilerEntry } from "./profiler.service"; +import { + buildReport, + formatFilenameTimestamp, + ReportInput, + slugifyForFilename, +} from "./profiler-report"; + +function stat(partial: Partial = {}): OperatorStatistics { + return { + operatorState: OperatorState.Completed, + aggregatedInputRowCount: 0, + inputPortMetrics: {}, + aggregatedOutputRowCount: 0, + outputPortMetrics: {}, + ...partial, + }; +} + +function entry(score: number, s: OperatorStatistics = stat()): ProfilerEntry { + return { score, state: s.operatorState, stats: s }; +} + +interface BuildInputOverrides { + scores?: Record; + types?: Record; + displayNames?: Record; + upstreams?: Record; + downstreams?: Record; + topN?: number; + workflowName?: string; + executionName?: string; + view?: ReportInput["view"]; + hotThresholdPercentile?: number; + generatedAt?: Date; +} + +function buildInput(o: BuildInputOverrides = {}): ReportInput { + return { + workflowName: o.workflowName ?? "My Workflow", + executionName: o.executionName, + generatedAt: o.generatedAt ?? new Date("2026-05-14T12:00:00Z"), + view: o.view ?? "runtime", + hotThresholdPercentile: o.hotThresholdPercentile ?? 80, + scores: o.scores ?? {}, + topN: o.topN, + operatorType: id => o.types?.[id], + displayName: id => o.displayNames?.[id] ?? id, + upstreamOps: id => o.upstreams?.[id] ?? [], + downstreamOps: id => o.downstreams?.[id] ?? [], + }; +} + +describe("buildReport — header", () => { + it("captures workflowName, view, threshold, operator count, generatedAt", () => { + const report = buildReport( + buildInput({ + workflowName: "TikTok Analysis", + view: "throughput", + hotThresholdPercentile: 90, + scores: { + a: entry(1.0), + b: entry(0.5), + }, + generatedAt: new Date("2026-05-14T17:30:00Z"), + }) + ); + expect(report.json.header.workflowName).toBe("TikTok Analysis"); + expect(report.json.header.view).toBe("throughput"); + expect(report.json.header.hotThresholdPercentile).toBe(90); + expect(report.json.header.operatorCount).toBe(2); + expect(report.json.header.generatedAt).toBe("2026-05-14T17:30:00.000Z"); + }); + + it("represents missing executionName as null in JSON", () => { + const report = buildReport(buildInput({})); + expect(report.json.header.executionName).toBeNull(); + }); + + it("uses '(unnamed)' in markdown when executionName missing", () => { + const report = buildReport(buildInput({})); + expect(report.markdown).toContain("(unnamed)"); + }); +}); + +describe("buildReport — top hot operators", () => { + it("sorts descending by score and assigns ranks 1..N", () => { + const report = buildReport( + buildInput({ + scores: { + slow: entry(0.4), + medium: entry(0.6), + hot: entry(0.9), + }, + }) + ); + const ranks = report.json.topHotOperators.map(o => o.operatorId); + expect(ranks).toEqual(["hot", "medium", "slow"]); + expect(report.json.topHotOperators[0].rank).toBe(1); + expect(report.json.topHotOperators[2].rank).toBe(3); + }); + + it("breaks score ties by displayName for deterministic output", () => { + const report = buildReport( + buildInput({ + scores: { + alpha: entry(0.5), + beta: entry(0.5), + gamma: entry(0.5), + }, + displayNames: { alpha: "Zoo", beta: "Apple", gamma: "Mango" }, + }) + ); + const order = report.json.topHotOperators.map(o => o.displayName); + expect(order).toEqual(["Apple", "Mango", "Zoo"]); + }); + + it("caps the top section at the requested topN (default 5)", () => { + const scores: Record = {}; + for (let i = 0; i < 10; i++) { + scores[`op-${i}`] = entry(i / 10); + } + const defaultReport = buildReport(buildInput({ scores })); + expect(defaultReport.json.topHotOperators).toHaveLength(5); + + const top3 = buildReport(buildInput({ scores, topN: 3 })); + expect(top3.json.topHotOperators).toHaveLength(3); + }); + + it("emits topN=0 as empty top section without crashing", () => { + const report = buildReport( + buildInput({ scores: { a: entry(1.0) }, topN: 0 }) + ); + expect(report.json.topHotOperators).toHaveLength(0); + expect(report.markdown).toContain("Top 0 hottest operators"); + }); + + it("converts runtime ns to ms and computes throughput", () => { + // 2 second runtime, 1000 output rows -> 500 rows/s, 2000 ms + const report = buildReport( + buildInput({ + scores: { + a: entry( + 1.0, + stat({ aggregatedDataProcessingTime: 2_000_000_000, aggregatedOutputRowCount: 1_000 }) + ), + }, + }) + ); + const op = report.json.topHotOperators[0]; + expect(op.runtimeMs).toBe(2_000); + expect(op.throughputRowsPerSec).toBe(500); + }); + + it("leaves runtimeMs / throughput as null when unmeasurable", () => { + const report = buildReport( + buildInput({ scores: { a: entry(0, stat({ aggregatedDataProcessingTime: 0 })) } }) + ); + const op = report.json.topHotOperators[0]; + expect(op.runtimeMs).toBeNull(); + expect(op.throughputRowsPerSec).toBeNull(); + }); + + it("computes idle ratio when timing fields are present", () => { + const report = buildReport( + buildInput({ + scores: { + a: entry( + 0.5, + stat({ + aggregatedDataProcessingTime: 100, + aggregatedControlProcessingTime: 100, + aggregatedIdleTime: 300, + operatorState: OperatorState.Running, + }) + ), + }, + }) + ); + expect(report.json.topHotOperators[0].idleRatio).toBeCloseTo(0.6, 5); + }); + + it("leaves idleRatio as null when no timing data is available", () => { + const report = buildReport(buildInput({ scores: { a: entry(0) } })); + expect(report.json.topHotOperators[0].idleRatio).toBeNull(); + }); +}); + +describe("buildReport — hints", () => { + it("includes only operators that produced hints", () => { + // Scan→Filter where scan emits 100k and filter keeps 1k → UPSTREAM_OVERPRODUCTION on the filter. + const report = buildReport( + buildInput({ + scores: { + scan: entry(0.3, stat({ aggregatedOutputRowCount: 100_000 })), + filter: entry(0.5, stat({ aggregatedInputRowCount: 1_000 })), + }, + types: { scan: "CSVScan", filter: "Filter" }, + displayNames: { scan: "Tweet Source", filter: "Recent Filter" }, + upstreams: { filter: ["scan"] }, + }) + ); + const ops = report.json.hintsByOperator.map(h => h.operatorId); + expect(ops).toContain("filter"); + expect(ops).not.toContain("scan"); + }); + + it("uses displayName (not operatorID) when rendering hint messages", () => { + const report = buildReport( + buildInput({ + scores: { + "Scan-op-abc": entry(0.3, stat({ aggregatedOutputRowCount: 100_000 })), + "Filter-op-xyz": entry(0.5, stat({ aggregatedInputRowCount: 1_000 })), + }, + displayNames: { "Scan-op-abc": "Tweet Source", "Filter-op-xyz": "Recent Filter" }, + upstreams: { "Filter-op-xyz": ["Scan-op-abc"] }, + }) + ); + const filterHints = report.json.hintsByOperator.find(h => h.operatorId === "Filter-op-xyz"); + expect(filterHints?.hints[0].message).toContain("Tweet Source"); + expect(filterHints?.hints[0].message).not.toContain("Scan-op-abc"); + }); + + it("emits an empty hintsByOperator + helpful markdown when nothing fires", () => { + const report = buildReport( + buildInput({ scores: { a: entry(0.1, stat({ aggregatedDataProcessingTime: 1 })) } }) + ); + expect(report.json.hintsByOperator).toHaveLength(0); + expect(report.markdown).toContain("No optimization hints fired"); + }); +}); + +describe("buildReport — raw appendix", () => { + it("contains every operator in score-sorted order", () => { + const report = buildReport( + buildInput({ + scores: { + a: entry(0.2), + b: entry(0.8), + c: entry(0.5), + }, + }) + ); + expect(report.json.operators.map(o => o.operatorId)).toEqual(["b", "c", "a"]); + }); + + it("includes operators not in the topN block", () => { + const scores: Record = {}; + for (let i = 0; i < 8; i++) scores[`op-${i}`] = entry(i / 8); + const report = buildReport(buildInput({ scores, topN: 3 })); + expect(report.json.topHotOperators).toHaveLength(3); + expect(report.json.operators).toHaveLength(8); + }); +}); + +describe("buildReport — markdown integrity", () => { + it("includes the expected sections in order", () => { + const report = buildReport( + buildInput({ + scores: { a: entry(1.0, stat({ aggregatedDataProcessingTime: 1 })) }, + workflowName: "Header Check", + }) + ); + const headerIdx = report.markdown.indexOf("# Profiler report — Header Check"); + const topIdx = report.markdown.indexOf("## Top 1 hottest operator"); + const hintsIdx = report.markdown.indexOf("## Optimization hints"); + const rawIdx = report.markdown.indexOf("## All operators (raw appendix)"); + expect(headerIdx).toBeGreaterThanOrEqual(0); + expect(topIdx).toBeGreaterThan(headerIdx); + expect(hintsIdx).toBeGreaterThan(topIdx); + expect(rawIdx).toBeGreaterThan(hintsIdx); + }); + + it("escapes pipe and newline in operator names so the table is well-formed", () => { + const report = buildReport( + buildInput({ + scores: { weird: entry(1.0) }, + displayNames: { weird: "evil|name\nwith\rstuff" }, + }) + ); + // pipe must be escaped; raw newline must be replaced with a space. + expect(report.markdown).toContain("evil\\|name with stuff"); + expect(report.markdown).not.toMatch(/evil\|name\nwith/); + }); + + it("renders all six numeric columns of the table header", () => { + const report = buildReport(buildInput({ scores: { a: entry(1.0) } })); + expect(report.markdown).toContain( + "| # | Operator | Type | Score | Runtime (ms) | Throughput (rows/s) | In rows | Out rows | Workers | Idle ratio |" + ); + }); + + it("uses '—' placeholder when a metric is null", () => { + const report = buildReport( + buildInput({ scores: { a: entry(0, stat({ aggregatedDataProcessingTime: 0 })) } }) + ); + // The table row should contain at least one en-dash for runtime/throughput/idle null cases. + expect(report.markdown).toMatch(/\|\s*—\s*\|/); + }); +}); + +describe("filename helpers", () => { + it("slugifies workflow names into safe filename chunks", () => { + expect(slugifyForFilename("My TikTok Analysis!")).toBe("my-tiktok-analysis"); + expect(slugifyForFilename(" spaces ")).toBe("spaces"); + expect(slugifyForFilename("UPPER-CASE")).toBe("upper-case"); + expect(slugifyForFilename("!!!")).toBe("workflow"); // fallback when nothing usable + expect(slugifyForFilename("中文名前")).toBe("workflow"); + }); + + it("formats date as colon-free ISO chunk for filenames", () => { + expect(formatFilenameTimestamp(new Date("2026-05-14T17:30:42.123Z"))).toBe( + "2026-05-14T17-30-42" + ); + }); +}); + +describe("buildReport — empty state", () => { + it("does not throw with no scores and produces a coherent skeleton", () => { + const report = buildReport(buildInput({})); + expect(report.json.topHotOperators).toEqual([]); + expect(report.json.hintsByOperator).toEqual([]); + expect(report.json.operators).toEqual([]); + expect(report.markdown).toContain("No operators have stats yet"); + expect(report.markdown).toContain("No optimization hints fired"); + }); +}); diff --git a/frontend/src/app/workspace/service/profiler/profiler-report.ts b/frontend/src/app/workspace/service/profiler/profiler-report.ts new file mode 100644 index 00000000000..f3eae69ec3a --- /dev/null +++ b/frontend/src/app/workspace/service/profiler/profiler-report.ts @@ -0,0 +1,302 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +/** + * Pure builder that converts a ProfilerService state snapshot + workflow-graph + * context into a downloadable run report in two formats: Markdown (human-readable) + * and JSON (machine-readable). + * + * Kept dependency-free (no Angular, no RxJS, no DOM) so the entire builder is + * unit-testable with synthetic inputs and so the JSON shape is documented in code. + */ + +import { OperatorStatistics } from "../../types/execute-workflow.interface"; +import { ProfilerEntry, ProfilerView } from "./profiler.service"; +import { computeHintsForOperator, Hint, HintContext } from "./profiler-hints"; +import { formatViewLabel } from "./profiler-hover"; + +export interface ReportInput { + readonly workflowName: string; + readonly executionName: string | undefined; + readonly generatedAt: Date; + readonly view: ProfilerView; + readonly hotThresholdPercentile: number; + readonly scores: Readonly>; + readonly operatorType: (opId: string) => string | undefined; + readonly displayName: (opId: string) => string; + readonly upstreamOps: (opId: string) => readonly string[]; + readonly downstreamOps: (opId: string) => readonly string[]; + /** Number of hot operators to show in the headline section. Defaults to 5. */ + readonly topN?: number; +} + +export interface ReportHeader { + readonly workflowName: string; + readonly executionName: string | null; + readonly generatedAt: string; // ISO 8601 + readonly view: ProfilerView; + readonly hotThresholdPercentile: number; + readonly operatorCount: number; +} + +export interface ReportTopOperator { + readonly rank: number; + readonly operatorId: string; + readonly displayName: string; + readonly operatorType: string | null; + readonly score: number; + readonly runtimeMs: number | null; + readonly throughputRowsPerSec: number | null; + readonly inputRows: number; + readonly outputRows: number; + readonly inputSize: number | null; + readonly outputSize: number | null; + readonly workers: number | null; + readonly idleRatio: number | null; +} + +export interface ReportHintEntry { + readonly operatorId: string; + readonly displayName: string; + readonly hints: readonly Hint[]; +} + +export interface ReportJson { + readonly header: ReportHeader; + readonly topHotOperators: readonly ReportTopOperator[]; + readonly hintsByOperator: readonly ReportHintEntry[]; + readonly operators: readonly ReportTopOperator[]; // full appendix, same shape minus rank semantics +} + +export interface Report { + readonly markdown: string; + readonly json: ReportJson; +} + +const DEFAULT_TOP_N = 5; + +/** + * Build a complete profiler report. Pure: same inputs always yield the same output + * (modulo the `generatedAt` timestamp the caller provides). + */ +export function buildReport(input: ReportInput): Report { + const opIds = Object.keys(input.scores); + + // Sort operators by score descending, break ties by displayName for deterministic output. + const sortedIds = [...opIds].sort((a, b) => { + const sa = input.scores[a].score; + const sb = input.scores[b].score; + if (sb !== sa) return sb - sa; + return input.displayName(a).localeCompare(input.displayName(b)); + }); + + const allOperators: ReportTopOperator[] = sortedIds.map((opId, idx) => + toReportOperator(opId, idx + 1, input) + ); + + const topN = input.topN ?? DEFAULT_TOP_N; + const topHotOperators = allOperators.slice(0, Math.max(0, topN)); + + // Build hint context once, reuse across operators. + const stats: Record = {}; + const scoreMap: Record = {}; + for (const id of opIds) { + stats[id] = input.scores[id].stats; + scoreMap[id] = input.scores[id].score; + } + const hintCtx: HintContext = { + stats, + scores: scoreMap, + hotThreshold: input.hotThresholdPercentile / 100, + operatorType: input.operatorType, + displayName: input.displayName, + upstreamOps: input.upstreamOps, + downstreamOps: input.downstreamOps, + }; + + const hintsByOperator: ReportHintEntry[] = []; + for (const opId of sortedIds) { + const hints = computeHintsForOperator(opId, hintCtx); + if (hints.length === 0) continue; + hintsByOperator.push({ + operatorId: opId, + displayName: input.displayName(opId), + hints, + }); + } + + const header: ReportHeader = { + workflowName: input.workflowName, + executionName: input.executionName ?? null, + generatedAt: input.generatedAt.toISOString(), + view: input.view, + hotThresholdPercentile: input.hotThresholdPercentile, + operatorCount: opIds.length, + }; + + const json: ReportJson = { + header, + topHotOperators, + hintsByOperator, + operators: allOperators, + }; + + const markdown = renderMarkdown(header, topHotOperators, hintsByOperator, allOperators); + + return { markdown, json }; +} + +/** + * Produce a filesystem-safe slug for use in the downloaded filename. + * Example: `"My TikTok Analysis!"` → `"my-tiktok-analysis"`. + */ +export function slugifyForFilename(name: string): string { + const cleaned = name + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, ""); + return cleaned.length > 0 ? cleaned : "workflow"; +} + +/** + * Format the ISO timestamp portion of the filename — `2026-05-14T17-30-00` + * (colons replaced so the name is safe on every OS). + */ +export function formatFilenameTimestamp(date: Date): string { + return date.toISOString().replace(/[:]/g, "-").replace(/\..+$/, ""); +} + +// ---------------- internals ---------------- + +function toReportOperator(opId: string, rank: number, input: ReportInput): ReportTopOperator { + const entry = input.scores[opId]; + const s = entry.stats; + const runtimeNs = s.aggregatedDataProcessingTime; + const runtimeMs = runtimeNs && runtimeNs > 0 ? runtimeNs / 1_000_000 : null; + const outRows = s.aggregatedOutputRowCount ?? 0; + const throughputRowsPerSec = + runtimeNs && runtimeNs > 0 && outRows > 0 ? outRows / (runtimeNs / 1_000_000_000) : null; + + const dataNs = s.aggregatedDataProcessingTime ?? 0; + const ctrlNs = s.aggregatedControlProcessingTime ?? 0; + const idleNs = s.aggregatedIdleTime ?? 0; + const totalNs = dataNs + ctrlNs + idleNs; + const idleRatio = totalNs > 0 ? idleNs / totalNs : null; + + return { + rank, + operatorId: opId, + displayName: input.displayName(opId), + operatorType: input.operatorType(opId) ?? null, + score: entry.score, + runtimeMs, + throughputRowsPerSec, + inputRows: s.aggregatedInputRowCount ?? 0, + outputRows: outRows, + inputSize: s.aggregatedInputSize ?? null, + outputSize: s.aggregatedOutputSize ?? null, + workers: s.numWorkers ?? null, + idleRatio, + }; +} + +function renderMarkdown( + header: ReportHeader, + top: readonly ReportTopOperator[], + hintsByOperator: readonly ReportHintEntry[], + all: readonly ReportTopOperator[] +): string { + const lines: string[] = []; + lines.push(`# Profiler report — ${escapeMd(header.workflowName)}`); + lines.push(""); + lines.push(`- **Execution:** ${escapeMd(header.executionName ?? "(unnamed)")}`); + lines.push(`- **Generated at:** ${header.generatedAt}`); + lines.push(`- **View:** ${formatViewLabel(header.view)}`); + lines.push(`- **Hot threshold:** ${header.hotThresholdPercentile}th percentile`); + lines.push(`- **Operators with stats:** ${header.operatorCount}`); + lines.push(""); + + lines.push(`## Top ${top.length} hottest operator${top.length === 1 ? "" : "s"}`); + lines.push(""); + if (top.length === 0) { + lines.push("_No operators have stats yet._"); + } else { + lines.push(operatorTableHeader()); + for (const op of top) { + lines.push(operatorTableRow(op)); + } + } + lines.push(""); + + lines.push("## Optimization hints"); + lines.push(""); + if (hintsByOperator.length === 0) { + lines.push("_No optimization hints fired across the workflow._"); + } else { + for (const entry of hintsByOperator) { + lines.push(`### ${escapeMd(entry.displayName)}`); + lines.push(""); + for (const hint of entry.hints) { + lines.push(`- **${hint.ruleId}** (${hint.severity}): ${hint.message}`); + } + lines.push(""); + } + } + + lines.push("## All operators (raw appendix)"); + lines.push(""); + if (all.length === 0) { + lines.push("_No operators have stats yet._"); + } else { + lines.push(operatorTableHeader()); + for (const op of all) { + lines.push(operatorTableRow(op)); + } + } + lines.push(""); + + return lines.join("\n"); +} + +function operatorTableHeader(): string { + return [ + "| # | Operator | Type | Score | Runtime (ms) | Throughput (rows/s) | In rows | Out rows | Workers | Idle ratio |", + "|---|---|---|---|---|---|---|---|---|---|", + ].join("\n"); +} + +function operatorTableRow(op: ReportTopOperator): string { + return `| ${op.rank} | ${escapeMd(op.displayName)} | ${op.operatorType ?? "—"} | ${op.score.toFixed(2)} | ${formatNumOrDash(op.runtimeMs, 1)} | ${formatNumOrDash(op.throughputRowsPerSec, 0)} | ${op.inputRows.toLocaleString()} | ${op.outputRows.toLocaleString()} | ${op.workers ?? "—"} | ${formatNumOrDash(op.idleRatio, 2)} |`; +} + +function formatNumOrDash(n: number | null, fractionDigits: number): string { + if (n === null || !Number.isFinite(n)) return "—"; + return n.toLocaleString(undefined, { + minimumFractionDigits: fractionDigits, + maximumFractionDigits: fractionDigits, + }); +} + +/** + * Escape pipe and any kind of newline (LF / CR / CRLF) so a free-text operator name + * doesn't break a markdown table row. + */ +function escapeMd(text: string): string { + return text.replace(/\|/g, "\\|").replace(/[\r\n]+/g, " "); +} diff --git a/frontend/src/app/workspace/service/profiler/profiler-snapshot.spec.ts b/frontend/src/app/workspace/service/profiler/profiler-snapshot.spec.ts new file mode 100644 index 00000000000..26d814a87be --- /dev/null +++ b/frontend/src/app/workspace/service/profiler/profiler-snapshot.spec.ts @@ -0,0 +1,221 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { OperatorState, OperatorStatistics } from "../../types/execute-workflow.interface"; +import { ProfilerEntry, ProfilerState } from "./profiler.service"; +import { buildProfilerSnapshot, BuildSnapshotInput } from "./profiler-snapshot"; + +function stat(partial: Partial = {}): OperatorStatistics { + return { + operatorState: OperatorState.Completed, + aggregatedInputRowCount: 0, + inputPortMetrics: {}, + aggregatedOutputRowCount: 0, + outputPortMetrics: {}, + ...partial, + }; +} + +function entry(score: number, s: OperatorStatistics = stat()): ProfilerEntry { + return { score, state: s.operatorState, stats: s }; +} + +interface BuildInputOverrides { + enabled?: boolean; + view?: ProfilerState["view"]; + hotThresholdPercentile?: number; + scores?: Record; + baseline?: ProfilerState["baseline"]; + types?: Record; + displayNames?: Record; + upstreams?: Record; + downstreams?: Record; + now?: () => Date; +} + +function makeInput(o: BuildInputOverrides = {}): BuildSnapshotInput { + const state: ProfilerState = { + enabled: o.enabled ?? true, + view: o.view ?? "runtime", + hotThresholdPercentile: o.hotThresholdPercentile ?? 80, + scores: o.scores ?? {}, + baseline: o.baseline, + }; + return { + state, + operatorType: id => o.types?.[id], + displayName: id => o.displayNames?.[id] ?? id, + upstreamOps: id => o.upstreams?.[id] ?? [], + downstreamOps: id => o.downstreams?.[id] ?? [], + now: o.now, + }; +} + +describe("buildProfilerSnapshot", () => { + it("returns undefined when profiling is disabled", () => { + expect(buildProfilerSnapshot(makeInput({ enabled: false }))).toBeUndefined(); + }); + + it("returns a snapshot with header reflecting the current state", () => { + const snapshot = buildProfilerSnapshot( + makeInput({ + enabled: true, + view: "throughput", + hotThresholdPercentile: 90, + scores: { a: entry(0.5), b: entry(0.2) }, + now: () => new Date("2026-05-15T17:30:00Z"), + }) + ); + expect(snapshot).toBeDefined(); + expect(snapshot!.header.enabled).toBe(true); + expect(snapshot!.header.view).toBe("throughput"); + expect(snapshot!.header.hotThresholdPercentile).toBe(90); + expect(snapshot!.header.operatorCount).toBe(2); + expect(snapshot!.header.generatedAt).toBe("2026-05-15T17:30:00.000Z"); + }); + + it("sorts operators by score descending with displayName as tiebreaker", () => { + const snapshot = buildProfilerSnapshot( + makeInput({ + scores: { + a: entry(0.5), + b: entry(0.5), + c: entry(0.9), + }, + displayNames: { a: "Zoo", b: "Apple", c: "Bird" }, + }) + ); + expect(snapshot!.operators.map(o => o.displayName)).toEqual(["Bird", "Apple", "Zoo"]); + }); + + it("derives runtime, throughput, idle ratio from raw nanosecond stats", () => { + const snapshot = buildProfilerSnapshot( + makeInput({ + scores: { + a: entry( + 1.0, + stat({ + aggregatedDataProcessingTime: 2_000_000_000, + aggregatedOutputRowCount: 1_000, + aggregatedIdleTime: 1_000_000_000, + aggregatedControlProcessingTime: 0, + }) + ), + }, + }) + ); + const op = snapshot!.operators[0]; + expect(op.runtimeMs).toBe(2_000); + expect(op.throughputRowsPerSec).toBe(500); + // idle / (data + ctrl + idle) = 1e9 / 3e9 = 0.333... + expect(op.idleRatio).toBeCloseTo(0.333, 2); + }); + + it("only includes operators that fired hints in hintsByOperator", () => { + const snapshot = buildProfilerSnapshot( + makeInput({ + scores: { + scan: entry(0.3, stat({ aggregatedOutputRowCount: 100_000 })), + filter: entry(0.5, stat({ aggregatedInputRowCount: 1_000 })), + }, + types: { scan: "CSVScan", filter: "Filter" }, + upstreams: { filter: ["scan"] }, + downstreams: { scan: ["filter"] }, + }) + ); + const opsWithHints = snapshot!.hintsByOperator.map(h => h.operatorId); + expect(opsWithHints).toContain("filter"); // UPSTREAM_OVERPRODUCTION fires + expect(opsWithHints).not.toContain("scan"); + }); + + it("omits baseline section when no baseline is loaded", () => { + const snapshot = buildProfilerSnapshot( + makeInput({ scores: { a: entry(1.0) } }) + ); + expect(snapshot!.baseline).toBeUndefined(); + }); + + it("includes baseline section with deltas when a baseline is loaded", () => { + const baseline = { + header: { + workflowName: "Prev", + executionName: "run-1", + generatedAt: "2026-05-14T12:00:00Z", + view: "runtime", + hotThresholdPercentile: 80, + operatorCount: 1, + }, + operators: [ + { + operatorId: "a", + displayName: "Python UDF", + operatorType: "PythonUDFV2", + score: 0.5, + runtimeMs: 2000, + throughputRowsPerSec: 500, + inputRows: 100, + outputRows: 100, + inputSize: null, + outputSize: null, + workers: 1, + idleRatio: null, + }, + ], + }; + const snapshot = buildProfilerSnapshot( + makeInput({ + scores: { + a: entry( + 1.0, + stat({ aggregatedDataProcessingTime: 1_000_000_000, aggregatedOutputRowCount: 100 }) + ), + }, + baseline, + }) + ); + expect(snapshot!.baseline).toBeDefined(); + expect(snapshot!.baseline!.header.workflowName).toBe("Prev"); + expect(snapshot!.baseline!.deltas).toHaveLength(1); + const delta = snapshot!.baseline!.deltas[0]; + expect(delta.operatorId).toBe("a"); + expect(delta.matchStatus).toBe("matched"); + // current runtime 1000 ms - baseline 2000 ms = -1000 ms (improved) + expect(delta.runtimeMsDelta).toBe(-1000); + expect(delta.direction).toBe("improved"); + }); + + it("produces JSON-serializable output (no Date objects, no functions)", () => { + const snapshot = buildProfilerSnapshot( + makeInput({ + scores: { a: entry(1.0, stat({ aggregatedDataProcessingTime: 100 })) }, + now: () => new Date("2026-05-15T12:00:00Z"), + }) + ); + // Should round-trip cleanly through JSON.stringify / JSON.parse. + const roundTripped = JSON.parse(JSON.stringify(snapshot)); + expect(roundTripped).toEqual(snapshot); + }); + + it("returns an empty operators array when there are no scores", () => { + const snapshot = buildProfilerSnapshot(makeInput({ scores: {} })); + expect(snapshot!.operators).toEqual([]); + expect(snapshot!.hintsByOperator).toEqual([]); + expect(snapshot!.header.operatorCount).toBe(0); + }); +}); diff --git a/frontend/src/app/workspace/service/profiler/profiler-snapshot.ts b/frontend/src/app/workspace/service/profiler/profiler-snapshot.ts new file mode 100644 index 00000000000..0d6bee2a009 --- /dev/null +++ b/frontend/src/app/workspace/service/profiler/profiler-snapshot.ts @@ -0,0 +1,196 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +/** + * Pure builder for the JSON snapshot that the frontend ships to the texera-agent + * on every chat message. The agent uses this as the input to its read-only profiler + * tools (Phase 1 of `profiler-agent-tool-plan.md`) — `getProfilerSummary`, + * `listHotOperators`, `getOperatorMetrics`, `getOptimizationHints`, `compareToBaseline`. + * + * Design notes: + * 1. The agent-service never re-computes profiler math. We pre-compute scores, + * hints, and (when a baseline is loaded) deltas here so the tools just slice / + * filter / sort. + * 2. The snapshot is JSON-serializable end-to-end (no Date objects, no class + * instances, no functions) so it can be sent over the existing WebSocket and + * parsed cleanly on the Bun side. + * 3. Dependency-free pure function — no Angular, no RxJS. Caller is responsible + * for collecting the inputs from ProfilerService / WorkflowActionService. + */ + +import type { ProfilerEntry, ProfilerState, ProfilerView } from "./profiler.service"; +import type { Hint, HintContext } from "./profiler-hints"; +import { computeHintsForOperator } from "./profiler-hints"; +import type { BaselineReport, ComparableOperator, OperatorDelta } from "./profiler-delta"; +import { + computeOperatorDelta, + indexBaseline, + statsToComparable, +} from "./profiler-delta"; + +export interface ProfilerSnapshotHeader { + readonly enabled: boolean; + readonly view: ProfilerView; + readonly hotThresholdPercentile: number; + readonly operatorCount: number; + readonly generatedAt: string; +} + +export interface ProfilerSnapshotHintEntry { + readonly operatorId: string; + readonly displayName: string; + readonly hints: readonly Hint[]; +} + +export interface ProfilerSnapshotBaselineHeader { + readonly workflowName: string; + readonly executionName: string | null; + readonly generatedAt: string; +} + +/** + * The JSON payload shipped to agent-service. Field names are agent-tool oriented + * (e.g. `operators` not `topHotOperators`) so the tools' read sites stay terse. + */ +export interface ProfilerSnapshot { + readonly header: ProfilerSnapshotHeader; + /** All operators that have profiler stats, sorted by heat score descending. */ + readonly operators: readonly ComparableOperator[]; + /** Operators that produced at least one optimization hint. */ + readonly hintsByOperator: readonly ProfilerSnapshotHintEntry[]; + /** When a baseline is loaded, deltas vs that baseline for matched operators. */ + readonly baseline?: { + readonly header: ProfilerSnapshotBaselineHeader; + readonly deltas: readonly OperatorDelta[]; + }; +} + +export interface BuildSnapshotInput { + readonly state: ProfilerState; + /** Same graph adapter shape the side panel uses for `HintContext`. */ + readonly operatorType: (opId: string) => string | undefined; + readonly displayName: (opId: string) => string; + readonly upstreamOps: (opId: string) => readonly string[]; + readonly downstreamOps: (opId: string) => readonly string[]; + /** Optional clock injection — tests pass a fixed Date for deterministic output. */ + readonly now?: () => Date; +} + +/** + * Returns `undefined` when profiling is disabled — callers should send no snapshot + * at all in that case so the agent knows profiler data is unavailable. Otherwise + * returns a fully-resolved ProfilerSnapshot ready to JSON.stringify. + */ +export function buildProfilerSnapshot(input: BuildSnapshotInput): ProfilerSnapshot | undefined { + const { state } = input; + if (!state.enabled) return undefined; + + const ctx: HintContext = { + stats: collectStats(state), + scores: collectScores(state), + hotThreshold: state.hotThresholdPercentile / 100, + operatorType: input.operatorType, + displayName: input.displayName, + upstreamOps: input.upstreamOps, + downstreamOps: input.downstreamOps, + }; + + // Sort operators by score desc (tie-break by displayName for determinism). + const sortedIds = Object.keys(state.scores).sort((a, b) => { + const sa = state.scores[a].score; + const sb = state.scores[b].score; + if (sb !== sa) return sb - sa; + return input.displayName(a).localeCompare(input.displayName(b)); + }); + + const operators: ComparableOperator[] = sortedIds.map(id => + statsToComparable({ + operatorId: id, + displayName: input.displayName(id), + operatorType: input.operatorType(id), + score: state.scores[id].score, + stats: state.scores[id].stats, + }) + ); + + const hintsByOperator: ProfilerSnapshotHintEntry[] = []; + for (const id of sortedIds) { + const hints = computeHintsForOperator(id, ctx); + if (hints.length === 0) continue; + hintsByOperator.push({ + operatorId: id, + displayName: input.displayName(id), + hints, + }); + } + + const baseline = state.baseline ? buildBaselineSection(state.baseline, operators) : undefined; + + const header: ProfilerSnapshotHeader = { + enabled: true, + view: state.view, + hotThresholdPercentile: state.hotThresholdPercentile, + operatorCount: operators.length, + generatedAt: (input.now?.() ?? new Date()).toISOString(), + }; + + return baseline ? { header, operators, hintsByOperator, baseline } : { header, operators, hintsByOperator }; +} + +// ---- internals ---- + +function collectStats(state: ProfilerState): HintContext["stats"] { + const stats: Record = {}; + for (const id of Object.keys(state.scores)) { + stats[id] = state.scores[id].stats; + } + return stats; +} + +function collectScores(state: ProfilerState): HintContext["scores"] { + const scores: Record = {}; + for (const id of Object.keys(state.scores)) { + scores[id] = state.scores[id].score; + } + return scores; +} + +function buildBaselineSection( + baseline: BaselineReport, + currentOps: readonly ComparableOperator[] +): ProfilerSnapshot["baseline"] { + const baselineIndex = indexBaseline(baseline); + const currentIndex: Record = {}; + for (const op of currentOps) currentIndex[op.operatorId] = op; + + const allIds = new Set([...Object.keys(currentIndex), ...Object.keys(baselineIndex)]); + const deltas: OperatorDelta[] = []; + for (const id of allIds) { + deltas.push(computeOperatorDelta(id, currentIndex[id], baselineIndex[id])); + } + + return { + header: { + workflowName: baseline.header.workflowName, + executionName: baseline.header.executionName, + generatedAt: baseline.header.generatedAt, + }, + deltas, + }; +} diff --git a/frontend/src/app/workspace/service/profiler/profiler-suggestions.service.spec.ts b/frontend/src/app/workspace/service/profiler/profiler-suggestions.service.spec.ts new file mode 100644 index 00000000000..07a8f17ef48 --- /dev/null +++ b/frontend/src/app/workspace/service/profiler/profiler-suggestions.service.spec.ts @@ -0,0 +1,340 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import "zone.js/testing"; +import { BehaviorSubject, Subject } from "rxjs"; +import { OperatorState, OperatorStatistics } from "../../types/execute-workflow.interface"; +import { ProfilerEntry, ProfilerState } from "./profiler.service"; +import { ProfilerSuggestionsService } from "./profiler-suggestions.service"; +import { Suggestion, edgeSuggestionId } from "./profiler-suggestions"; + +function stat(partial: Partial = {}): OperatorStatistics { + return { + operatorState: OperatorState.Completed, + aggregatedInputRowCount: 0, + inputPortMetrics: {}, + aggregatedOutputRowCount: 0, + outputPortMetrics: {}, + ...partial, + }; +} + +interface FakeOperator { + operatorID: string; + operatorType: string; + customDisplayName?: string; +} +interface FakeLink { + source: { operatorID: string }; + target: { operatorID: string }; +} + +/** + * Minimal fakes that mirror the parts of ProfilerService / WorkflowActionService + * that ProfilerSuggestionsService touches. + */ +class StubProfilerService { + public state$: BehaviorSubject = new BehaviorSubject({ + enabled: true, + view: "runtime", + hotThresholdPercentile: 80, + scores: {}, + }); + public getState() { return this.state$.value; } + public getStateStream() { return this.state$.asObservable(); } +} + +class StubGraph { + public operators: Record = {}; + public links: FakeLink[] = []; + + public getOperator(id: string): FakeOperator | undefined { + return this.operators[id]; + } + public getInputLinksByOperatorId(id: string): FakeLink[] { + return this.links.filter(l => l.target.operatorID === id); + } + public getOutputLinksByOperatorId(id: string): FakeLink[] { + return this.links.filter(l => l.source.operatorID === id); + } +} +class StubWorkflowActionService { + public graph = new StubGraph(); + public metadata: { wid: number | undefined } = { wid: undefined }; + public metadataChange$ = new Subject<{ wid: number | undefined }>(); + public getTexeraGraph() { return this.graph; } + public getWorkflowMetadata() { return this.metadata; } + public workflowMetaDataChanged() { return this.metadataChange$.asObservable(); } + + /** Test helper to simulate workflow load / switch. */ + public emitWorkflow(wid: number | undefined): void { + this.metadata = { wid }; + this.metadataChange$.next(this.metadata); + } +} + +/** + * Install an in-memory localStorage mock so the service's persistence path is + * exercisable under Vitest (which ships only a partial Storage implementation). + */ +function installLocalStorageMock(): void { + const store: Record = {}; + const mock: Storage = { + get length() { return Object.keys(store).length; }, + clear: () => { for (const k of Object.keys(store)) delete store[k]; }, + getItem: (k: string) => (k in store ? store[k] : null), + key: (i: number) => Object.keys(store)[i] ?? null, + removeItem: (k: string) => { delete store[k]; }, + setItem: (k: string, v: string) => { store[k] = String(v); }, + }; + Object.defineProperty(globalThis, "localStorage", { + value: mock, + configurable: true, + writable: true, + }); +} + +describe("ProfilerSuggestionsService", () => { + let profiler: StubProfilerService; + let action: StubWorkflowActionService; + let service: ProfilerSuggestionsService; + + beforeEach(() => { + installLocalStorageMock(); + profiler = new StubProfilerService(); + action = new StubWorkflowActionService(); + service = new ProfilerSuggestionsService(profiler as any, action as any); + }); + + function setScoresAndGraph( + stats: Record, + ops: Record, + links: FakeLink[] = [] + ): void { + const scores: Record = {}; + for (const id of Object.keys(stats)) { + scores[id] = { score: 0, state: stats[id].operatorState, stats: stats[id] }; + } + profiler.state$.next({ + ...profiler.state$.value, + scores, + }); + action.graph.operators = {}; + for (const id of Object.keys(ops)) { + action.graph.operators[id] = { operatorID: id, operatorType: ops[id] }; + } + action.graph.links = links; + } + + function collect(): Suggestion[][] { + const emissions: Suggestion[][] = []; + service.getSuggestionsStream().subscribe(s => emissions.push([...s])); + return emissions; + } + + it("emits no suggestions when profiling is disabled", () => { + profiler.state$.next({ ...profiler.state$.value, enabled: false }); + setScoresAndGraph( + { scan: stat({ aggregatedOutputRowCount: 5_000_000 }) }, + { scan: "CSVScan", agg: "Aggregate" }, + [{ source: { operatorID: "scan" }, target: { operatorID: "agg" } }] + ); + const emissions = collect(); + expect(emissions[emissions.length - 1]).toHaveLength(0); + }); + + it("emits an INSERT_FILTER suggestion for an unfiltered large scan when enabled", () => { + setScoresAndGraph( + { scan: stat({ aggregatedOutputRowCount: 5_000_000 }) }, + { scan: "CSVScan", agg: "Aggregate" }, + [{ source: { operatorID: "scan" }, target: { operatorID: "agg" } }] + ); + const emissions = collect(); + const last = emissions[emissions.length - 1]; + expect(last).toHaveLength(1); + const first = last[0]; + expect(first.type).toBe("INSERT_FILTER"); + if (first.type === "INSERT_FILTER") { + expect(first.upstreamOpId).toBe("scan"); + expect(first.downstreamOpId).toBe("agg"); + expect(first.reasonRuleId).toBe("SCAN_FULL_TABLE_NO_FILTER"); + expect(first.id).toBe(edgeSuggestionId("scan", "agg")); + } + }); + + it("filters out dismissed suggestions", () => { + setScoresAndGraph( + { scan: stat({ aggregatedOutputRowCount: 5_000_000 }) }, + { scan: "CSVScan", agg: "Aggregate" }, + [{ source: { operatorID: "scan" }, target: { operatorID: "agg" } }] + ); + const emissions = collect(); + // First (post-subscribe + initial graph) emission: 1 suggestion present. + expect(emissions[emissions.length - 1]).toHaveLength(1); + + service.dismiss(edgeSuggestionId("scan", "agg")); + expect(emissions[emissions.length - 1]).toHaveLength(0); + }); + + it("clearDismissed re-emits previously dismissed suggestions", () => { + setScoresAndGraph( + { scan: stat({ aggregatedOutputRowCount: 5_000_000 }) }, + { scan: "CSVScan", agg: "Aggregate" }, + [{ source: { operatorID: "scan" }, target: { operatorID: "agg" } }] + ); + service.dismiss(edgeSuggestionId("scan", "agg")); + const emissions = collect(); + expect(emissions[emissions.length - 1]).toHaveLength(0); + + service.clearDismissed(); + expect(emissions[emissions.length - 1]).toHaveLength(1); + }); + + it("requestWorkflowRun publishes on the run-request stream", () => { + let received = 0; + service.getWorkflowRunRequestStream().subscribe(() => received++); + service.requestWorkflowRun(); + service.requestWorkflowRun(); + expect(received).toBe(2); + }); + + it("requestMaterialize publishes on the materialize-request stream", () => { + setScoresAndGraph( + { scan: stat({ aggregatedOutputRowCount: 5_000_000 }) }, + { scan: "CSVScan", agg: "Aggregate" }, + [{ source: { operatorID: "scan" }, target: { operatorID: "agg" } }] + ); + const received: Suggestion[] = []; + service.getMaterializeRequestStream().subscribe(s => received.push(s)); + + // Construct a synthetic INSERT_FILTER suggestion (same shape pure engine would produce). + const synthetic: Suggestion = { + id: edgeSuggestionId("scan", "agg"), + type: "INSERT_FILTER", + upstreamOpId: "scan", + downstreamOpId: "agg", + reasonRuleId: "SCAN_FULL_TABLE_NO_FILTER", + reasonMessage: "x", + }; + service.requestMaterialize(synthetic); + expect(received).toHaveLength(1); + expect(received[0].id).toBe(edgeSuggestionId("scan", "agg")); + }); + + describe("per-workflow persistence", () => { + it("persists dismissed ids to localStorage under the current workflow id", () => { + action.emitWorkflow(42); + setScoresAndGraph( + { scan: stat({ aggregatedOutputRowCount: 5_000_000 }) }, + { scan: "CSVScan", agg: "Aggregate" }, + [{ source: { operatorID: "scan" }, target: { operatorID: "agg" } }] + ); + service.dismiss(edgeSuggestionId("scan", "agg")); + const raw = localStorage.getItem("texera.profiler.dismissedSuggestions.42"); + expect(raw).toBeTruthy(); + expect(JSON.parse(raw!)).toEqual([edgeSuggestionId("scan", "agg")]); + }); + + it("hydrates dismissed ids from localStorage when a workflow is loaded", () => { + localStorage.setItem( + "texera.profiler.dismissedSuggestions.7", + JSON.stringify([edgeSuggestionId("scan", "agg")]) + ); + setScoresAndGraph( + { scan: stat({ aggregatedOutputRowCount: 5_000_000 }) }, + { scan: "CSVScan", agg: "Aggregate" }, + [{ source: { operatorID: "scan" }, target: { operatorID: "agg" } }] + ); + const emissions = collect(); + // Pre-workflow-load: dismissals are empty, so suggestion shows up. + expect(emissions[emissions.length - 1]).toHaveLength(1); + + action.emitWorkflow(7); + // After hydrating workflow 7's dismissals: suggestion is filtered out. + expect(emissions[emissions.length - 1]).toHaveLength(0); + }); + + it("swaps dismissed-set when the workflow id changes", () => { + localStorage.setItem( + "texera.profiler.dismissedSuggestions.1", + JSON.stringify([edgeSuggestionId("scan", "agg")]) + ); + // Workflow 2 has no stored dismissals. + setScoresAndGraph( + { scan: stat({ aggregatedOutputRowCount: 5_000_000 }) }, + { scan: "CSVScan", agg: "Aggregate" }, + [{ source: { operatorID: "scan" }, target: { operatorID: "agg" } }] + ); + const emissions = collect(); + + action.emitWorkflow(1); + expect(emissions[emissions.length - 1]).toHaveLength(0); // dismissed in wf 1 + + action.emitWorkflow(2); + expect(emissions[emissions.length - 1]).toHaveLength(1); // visible in wf 2 + }); + + it("does NOT persist when no workflow id is loaded (session-only)", () => { + // No emitWorkflow — wid stays undefined. + setScoresAndGraph( + { scan: stat({ aggregatedOutputRowCount: 5_000_000 }) }, + { scan: "CSVScan", agg: "Aggregate" }, + [{ source: { operatorID: "scan" }, target: { operatorID: "agg" } }] + ); + service.dismiss(edgeSuggestionId("scan", "agg")); + // No keys should have been written. + let foundProfilerKey = false; + for (let i = 0; i < localStorage.length; i++) { + const k = localStorage.key(i); + if (k?.startsWith("texera.profiler.dismissedSuggestions.")) { + foundProfilerKey = true; + break; + } + } + expect(foundProfilerKey).toBe(false); + }); + + it("recovers gracefully from corrupt persisted JSON", () => { + localStorage.setItem("texera.profiler.dismissedSuggestions.9", "not valid json {"); + setScoresAndGraph( + { scan: stat({ aggregatedOutputRowCount: 5_000_000 }) }, + { scan: "CSVScan", agg: "Aggregate" }, + [{ source: { operatorID: "scan" }, target: { operatorID: "agg" } }] + ); + const emissions = collect(); + action.emitWorkflow(9); + // Bad JSON → fall back to empty set → suggestion is visible. + expect(emissions[emissions.length - 1]).toHaveLength(1); + }); + + it("clearDismissed wipes the persisted entry too", () => { + action.emitWorkflow(11); + setScoresAndGraph( + { scan: stat({ aggregatedOutputRowCount: 5_000_000 }) }, + { scan: "CSVScan", agg: "Aggregate" }, + [{ source: { operatorID: "scan" }, target: { operatorID: "agg" } }] + ); + service.dismiss(edgeSuggestionId("scan", "agg")); + expect(JSON.parse(localStorage.getItem("texera.profiler.dismissedSuggestions.11")!)).toHaveLength(1); + + service.clearDismissed(); + expect(JSON.parse(localStorage.getItem("texera.profiler.dismissedSuggestions.11")!)).toEqual([]); + }); + }); +}); diff --git a/frontend/src/app/workspace/service/profiler/profiler-suggestions.service.ts b/frontend/src/app/workspace/service/profiler/profiler-suggestions.service.ts new file mode 100644 index 00000000000..d9f4c98845c --- /dev/null +++ b/frontend/src/app/workspace/service/profiler/profiler-suggestions.service.ts @@ -0,0 +1,252 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { Injectable } from "@angular/core"; +import { BehaviorSubject, combineLatest, Observable, Subject } from "rxjs"; +import { distinctUntilChanged, map } from "rxjs/operators"; + +const DISMISSED_STORAGE_PREFIX = "texera.profiler.dismissedSuggestions."; + +/** Build the localStorage key for a workflow's dismissed-suggestion set. */ +function dismissedStorageKey(wid: number): string { + return `${DISMISSED_STORAGE_PREFIX}${wid}`; +} + +import { ProfilerService } from "./profiler.service"; +import { WorkflowActionService } from "../workflow-graph/model/workflow-action.service"; +import { HintContext } from "./profiler-hints"; +import { statsToComparable } from "./profiler-delta"; +import { + computeSuggestions, + Suggestion, + SuggestionId, +} from "./profiler-suggestions"; + +/** + * Holds the dismissed-set + exposes a live stream of ghost suggestions derived from + * ProfilerService state + the workflow graph. Thin: all logic lives in the pure + * `profiler-suggestions.ts` module; this class just wires Angular streams together. + */ +@Injectable({ + providedIn: "root", +}) +export class ProfilerSuggestionsService { + private readonly dismissed = new BehaviorSubject>(new Set()); + + private readonly suggestions$: Observable; + + /** + * Materialization is fired through this subject so multiple consumers can trigger it + * without depending on each other. The workflow-editor component owns the actual + * mutation (it needs JointJS paper coords); the menu component's popover-list "Apply" + * buttons just publish requests here. + */ + private readonly materializeRequest$ = new Subject(); + + /** + * Fired after a suggestion is materialized when the user clicks the "Run now" prompt + * that appears above the canvas. The menu component (which owns the actual run-button + * orchestration: execution name, computing-unit selection, etc.) subscribes and routes + * through the same handler used by clicking the Run button manually. + */ + private readonly workflowRunRequest$ = new Subject(); + + /** Workflow id we last hydrated the dismissed set for. Used to avoid duplicate hydration. */ + private currentWid: number | undefined; + + constructor( + private profilerService: ProfilerService, + private workflowActionService: WorkflowActionService + ) { + this.suggestions$ = combineLatest([ + this.profilerService.getStateStream(), + this.dismissed.asObservable(), + ]).pipe( + map(([state, dismissed]) => { + if (!state.enabled) return [] as readonly Suggestion[]; + return computeSuggestions(this.buildHintContext(), dismissed); + }), + // Cheap structural equality so subscribers don't re-render on identical lists. + distinctUntilChanged((a, b) => sameSuggestions(a, b)) + ); + + // Hydrate the dismissed set per workflow. localStorage is keyed by the workflow id + // so dismissals (a) survive page reload, (b) don't bleed across workflows. Same + // pattern as P4's `WorkflowProfilerConfig` but stored client-side (since dismissals + // are per-user, not a property of the workflow itself). + this.currentWid = this.workflowActionService.getWorkflowMetadata()?.wid; + this.hydrateFromStorage(); + this.workflowActionService + .workflowMetaDataChanged() + .pipe(distinctUntilChanged((a, b) => a?.wid === b?.wid)) + .subscribe(meta => { + const newWid = meta?.wid; + if (newWid === this.currentWid) return; + this.currentWid = newWid; + this.hydrateFromStorage(); + }); + } + + public getSuggestionsStream(): Observable { + return this.suggestions$; + } + + public dismiss(id: SuggestionId): void { + const next = new Set(this.dismissed.value); + next.add(id); + this.dismissed.next(next); + this.persistToStorage(); + } + + public clearDismissed(): void { + if (this.dismissed.value.size === 0) return; + this.dismissed.next(new Set()); + this.persistToStorage(); + } + + /** + * Hydrate the dismissed set from localStorage for the currently-loaded workflow, + * or fall back to an empty set when no workflow is loaded / no record exists. + * Defensive: handles missing localStorage, corrupt JSON, and unexpected shapes. + */ + private hydrateFromStorage(): void { + if (this.currentWid === undefined) { + if (this.dismissed.value.size > 0) this.dismissed.next(new Set()); + return; + } + try { + const raw = localStorage.getItem(dismissedStorageKey(this.currentWid)); + if (!raw) { + if (this.dismissed.value.size > 0) this.dismissed.next(new Set()); + return; + } + const parsed: unknown = JSON.parse(raw); + if (Array.isArray(parsed)) { + const next = new Set( + parsed.filter((x): x is string => typeof x === "string") + ); + this.dismissed.next(next); + } else { + // Bogus shape — start fresh. + this.dismissed.next(new Set()); + } + } catch { + // localStorage unavailable or JSON corrupt — silently fall back to empty. + this.dismissed.next(new Set()); + } + } + + /** + * Persist the current dismissed set to localStorage under the current workflow id. + * No-op when there's no current wid (e.g. a brand-new unsaved workflow); the set + * remains in-memory for the session. + */ + private persistToStorage(): void { + if (this.currentWid === undefined) return; + try { + const arr = Array.from(this.dismissed.value); + localStorage.setItem(dismissedStorageKey(this.currentWid), JSON.stringify(arr)); + } catch { + // Storage full / disabled / private mode — ignore. Behavior degrades to session-only. + } + } + + /** + * Request that the editor materialize the given suggestion. Fires on + * `materializeRequest$`; the workflow-editor component subscribes and performs + * the actual canvas mutation (with access to JointJS paper coordinates). + */ + public requestMaterialize(suggestion: Suggestion): void { + this.materializeRequest$.next(suggestion); + } + + public getMaterializeRequestStream(): Observable { + return this.materializeRequest$.asObservable(); + } + + /** + * Request that the workflow be re-run. Fires on `workflowRunRequest$`; the menu + * component subscribes and triggers the standard Run-button handler. + */ + public requestWorkflowRun(): void { + this.workflowRunRequest$.next(); + } + + public getWorkflowRunRequestStream(): Observable { + return this.workflowRunRequest$.asObservable(); + } + + /** + * Builds the same HintContext shape that `operator-property-edit-frame` builds for + * its hint computation. Pulled into this service so both the side panel and the + * canvas ghosts see identical inputs and produce consistent recommendations. + */ + private buildHintContext(): HintContext { + const state = this.profilerService.getState(); + const graph = this.workflowActionService.getTexeraGraph(); + const stats: Record extends infer R ? any : never> = {}; + const scoreMap: Record = {}; + for (const id of Object.keys(state.scores)) { + stats[id] = state.scores[id].stats; + scoreMap[id] = state.scores[id].score; + } + return { + stats, + scores: scoreMap, + hotThreshold: state.hotThresholdPercentile / 100, + operatorType: id => { + try { + return graph.getOperator(id)?.operatorType; + } catch { + return undefined; + } + }, + displayName: id => { + try { + const op = graph.getOperator(id); + return op?.customDisplayName?.trim() || op?.operatorType || id; + } catch { + return id; + } + }, + upstreamOps: id => { + try { + return graph.getInputLinksByOperatorId(id).map(l => l.source.operatorID); + } catch { + return []; + } + }, + downstreamOps: id => { + try { + return graph.getOutputLinksByOperatorId(id).map(l => l.target.operatorID); + } catch { + return []; + } + }, + }; + } +} + +function sameSuggestions(a: readonly Suggestion[], b: readonly Suggestion[]): boolean { + if (a.length !== b.length) return false; + for (let i = 0; i < a.length; i++) { + if (a[i].id !== b[i].id) return false; + } + return true; +} diff --git a/frontend/src/app/workspace/service/profiler/profiler-suggestions.spec.ts b/frontend/src/app/workspace/service/profiler/profiler-suggestions.spec.ts new file mode 100644 index 00000000000..f8330c0e045 --- /dev/null +++ b/frontend/src/app/workspace/service/profiler/profiler-suggestions.spec.ts @@ -0,0 +1,342 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { OperatorState, OperatorStatistics } from "../../types/execute-workflow.interface"; +import { HintContext } from "./profiler-hints"; +import { + BUMP_WORKERS_TARGET, + bumpWorkersSuggestionId, + computeSuggestions, + edgeSuggestionId, + InsertFilterSuggestion, + Suggestion, +} from "./profiler-suggestions"; + +function inserts(suggestions: readonly Suggestion[]): InsertFilterSuggestion[] { + return suggestions.filter((s): s is InsertFilterSuggestion => s.type === "INSERT_FILTER"); +} + +function stat(partial: Partial = {}): OperatorStatistics { + return { + operatorState: OperatorState.Completed, + aggregatedInputRowCount: 0, + inputPortMetrics: {}, + aggregatedOutputRowCount: 0, + outputPortMetrics: {}, + ...partial, + }; +} + +interface BuildCtxInput { + stats?: Record; + scores?: Record; + hotThreshold?: number; + types?: Record; + displayNames?: Record; + upstreams?: Record; + downstreams?: Record; +} + +function buildCtx(input: BuildCtxInput = {}): HintContext { + return { + stats: input.stats ?? {}, + scores: input.scores ?? {}, + hotThreshold: input.hotThreshold ?? 0.8, + operatorType: id => input.types?.[id], + displayName: id => input.displayNames?.[id] ?? id, + upstreamOps: id => input.upstreams?.[id] ?? [], + downstreamOps: id => input.downstreams?.[id] ?? [], + }; +} + +describe("computeSuggestions — SCAN_FULL_TABLE_NO_FILTER", () => { + it("creates a ghost between a large scan and its first non-filter downstream", () => { + const ctx = buildCtx({ + stats: { + scan: stat({ aggregatedOutputRowCount: 5_000_000 }), + agg: stat({}), + }, + types: { scan: "CSVScan", agg: "Aggregate" }, + downstreams: { scan: ["agg"] }, + }); + const out = inserts(computeSuggestions(ctx)); + expect(out).toHaveLength(1); + expect(out[0].upstreamOpId).toBe("scan"); + expect(out[0].downstreamOpId).toBe("agg"); + expect(out[0].reasonRuleId).toBe("SCAN_FULL_TABLE_NO_FILTER"); + expect(out[0].id).toBe(edgeSuggestionId("scan", "agg")); + }); + + it("does NOT fire when the immediate downstream is already a Filter", () => { + const ctx = buildCtx({ + stats: { + scan: stat({ aggregatedOutputRowCount: 5_000_000 }), + flt: stat({}), + }, + types: { scan: "CSVScan", flt: "Filter" }, + downstreams: { scan: ["flt"] }, + }); + expect(computeSuggestions(ctx)).toHaveLength(0); + }); + + it("does NOT fire for small scans", () => { + const ctx = buildCtx({ + stats: { scan: stat({ aggregatedOutputRowCount: 100 }) }, + types: { scan: "CSVScan" }, + downstreams: { scan: ["downstream"] }, + }); + expect(computeSuggestions(ctx)).toHaveLength(0); + }); + + it("picks the first non-filter downstream when multiple downstreams exist", () => { + const ctx = buildCtx({ + stats: { + scan: stat({ aggregatedOutputRowCount: 5_000_000 }), + flt: stat({}), + agg: stat({}), + }, + types: { scan: "CSVScan", flt: "Filter", agg: "Aggregate" }, + downstreams: { scan: ["flt", "agg"] }, + }); + // First downstream is Filter — skip — pick Aggregate. + const out = inserts(computeSuggestions(ctx)); + expect(out).toHaveLength(1); + expect(out[0].downstreamOpId).toBe("agg"); + }); +}); + +describe("computeSuggestions — UPSTREAM_OVERPRODUCTION", () => { + it("creates a ghost on the edge from over-producer to keeps-little", () => { + const ctx = buildCtx({ + stats: { + src: stat({ aggregatedOutputRowCount: 100_000 }), + downstream: stat({ aggregatedInputRowCount: 1_000 }), + }, + upstreams: { downstream: ["src"] }, + }); + const out = inserts(computeSuggestions(ctx)); + expect(out).toHaveLength(1); + expect(out[0].upstreamOpId).toBe("src"); + expect(out[0].downstreamOpId).toBe("downstream"); + expect(out[0].reasonRuleId).toBe("UPSTREAM_OVERPRODUCTION"); + }); + + it("uses display names in the reason message", () => { + const ctx = buildCtx({ + stats: { + src: stat({ aggregatedOutputRowCount: 100_000 }), + downstream: stat({ aggregatedInputRowCount: 1_000 }), + }, + displayNames: { src: "Tweet Source", downstream: "Recent Filter" }, + upstreams: { downstream: ["src"] }, + }); + const out = computeSuggestions(ctx); + expect(out[0].reasonMessage).toContain("Tweet Source"); + expect(out[0].reasonMessage).toContain("Recent Filter"); + }); + + it("does not fire when the ratio is reasonable", () => { + const ctx = buildCtx({ + stats: { + src: stat({ aggregatedOutputRowCount: 1_000 }), + downstream: stat({ aggregatedInputRowCount: 1_000 }), + }, + upstreams: { downstream: ["src"] }, + }); + expect(computeSuggestions(ctx)).toHaveLength(0); + }); +}); + +describe("computeSuggestions — JOIN_HIGH_FANIN_LOW_FANOUT", () => { + it("creates a ghost on the edge from the fattest input to the join", () => { + const ctx = buildCtx({ + stats: { + leftIn: stat({ aggregatedOutputRowCount: 10_000 }), + rightIn: stat({ aggregatedOutputRowCount: 100 }), + join: stat({ aggregatedInputRowCount: 10_100, aggregatedOutputRowCount: 50 }), + }, + types: { join: "HashJoin", leftIn: "CSVScan", rightIn: "CSVScan" }, + upstreams: { join: ["leftIn", "rightIn"] }, + }); + const out = inserts(computeSuggestions(ctx)); + const joinSug = out.find(s => s.reasonRuleId === "JOIN_HIGH_FANIN_LOW_FANOUT"); + expect(joinSug).toBeDefined(); + // Should target the fattest input (leftIn with 10k > rightIn with 100) + expect(joinSug!.upstreamOpId).toBe("leftIn"); + expect(joinSug!.downstreamOpId).toBe("join"); + }); + + it("does not fire when join keeps >=5% of input", () => { + const ctx = buildCtx({ + stats: { + leftIn: stat({ aggregatedOutputRowCount: 10_000 }), + join: stat({ aggregatedInputRowCount: 10_000, aggregatedOutputRowCount: 1_000 }), + }, + types: { join: "HashJoin", leftIn: "CSVScan" }, + upstreams: { join: ["leftIn"] }, + }); + expect(computeSuggestions(ctx)).toHaveLength(0); + }); +}); + +describe("computeSuggestions — dedup + dismissed + ordering", () => { + it("dedupes a single edge when multiple rules suggest it", () => { + // Build a graph where the same edge (scan → downstream) is BOTH a large-scan + // and an overproducer-consumer pair. + const ctx = buildCtx({ + stats: { + scan: stat({ aggregatedOutputRowCount: 5_000_000 }), + downstream: stat({ aggregatedInputRowCount: 1_000 }), + }, + types: { scan: "CSVScan", downstream: "Aggregate" }, + downstreams: { scan: ["downstream"] }, + upstreams: { downstream: ["scan"] }, + }); + const out = computeSuggestions(ctx); + // Only one ghost per edge, even though two rules apply. + expect(out).toHaveLength(1); + }); + + it("filters out dismissed suggestion ids", () => { + const ctx = buildCtx({ + stats: { + scan: stat({ aggregatedOutputRowCount: 5_000_000 }), + agg: stat({}), + }, + types: { scan: "CSVScan", agg: "Aggregate" }, + downstreams: { scan: ["agg"] }, + }); + const dismissed = new Set([edgeSuggestionId("scan", "agg")]); + expect(computeSuggestions(ctx, dismissed)).toHaveLength(0); + }); + + it("returns suggestions in deterministic id order", () => { + const ctx = buildCtx({ + stats: { + scanA: stat({ aggregatedOutputRowCount: 5_000_000 }), + scanB: stat({ aggregatedOutputRowCount: 5_000_000 }), + agg: stat({}), + }, + types: { scanA: "CSVScan", scanB: "CSVScan", agg: "Aggregate" }, + downstreams: { scanA: ["agg"], scanB: ["agg"] }, + }); + const out = computeSuggestions(ctx); + const ids = out.map(s => s.id); + const sorted = [...ids].sort((a, b) => a.localeCompare(b)); + expect(ids).toEqual(sorted); + }); + + it("returns empty list for empty stats", () => { + expect(computeSuggestions(buildCtx({}))).toHaveLength(0); + }); + + it("ignores operators with missing stats entries", () => { + const ctx = buildCtx({ + stats: { + scan: stat({ aggregatedOutputRowCount: 5_000_000 }), + // 'agg' referenced in downstreams but has no stats — should be skipped gracefully. + }, + types: { scan: "CSVScan" }, + downstreams: { scan: ["agg"] }, + }); + // SCAN_FULL_TABLE_NO_FILTER still fires; the downstream agg doesn't need a stats entry + // to be the target of a ghost — it just needs to exist as an operator. + const out = inserts(computeSuggestions(ctx)); + expect(out).toHaveLength(1); + expect(out[0].downstreamOpId).toBe("agg"); + }); +}); + +describe("computeSuggestions — LOW_PARALLELISM_HOT_OP (BUMP_WORKERS)", () => { + it("creates a BUMP_WORKERS suggestion when a hot operator has 1 worker", () => { + const ctx = buildCtx({ + stats: { hot: stat({ numWorkers: 1 }) }, + scores: { hot: 0.95 }, + hotThreshold: 0.8, + }); + const out = computeSuggestions(ctx); + expect(out).toHaveLength(1); + expect(out[0].type).toBe("BUMP_WORKERS"); + expect(out[0].id).toBe(bumpWorkersSuggestionId("hot")); + if (out[0].type === "BUMP_WORKERS") { + expect(out[0].operatorId).toBe("hot"); + expect(out[0].currentWorkers).toBe(1); + expect(out[0].proposedWorkers).toBe(BUMP_WORKERS_TARGET); + expect(out[0].reasonRuleId).toBe("LOW_PARALLELISM_HOT_OP"); + } + }); + + it("does NOT fire when the operator is not hot enough", () => { + const ctx = buildCtx({ + stats: { warm: stat({ numWorkers: 1 }) }, + scores: { warm: 0.4 }, + hotThreshold: 0.8, + }); + expect(computeSuggestions(ctx)).toHaveLength(0); + }); + + it("does NOT fire when the operator already has >1 workers", () => { + const ctx = buildCtx({ + stats: { hot: stat({ numWorkers: 4 }) }, + scores: { hot: 1.0 }, + hotThreshold: 0.8, + }); + expect(computeSuggestions(ctx)).toHaveLength(0); + }); + + it("uses display name in the reason message", () => { + const ctx = buildCtx({ + stats: { hot: stat({ numWorkers: 1 }) }, + scores: { hot: 1.0 }, + hotThreshold: 0.8, + displayNames: { hot: "Tweet Enricher" }, + }); + const out = computeSuggestions(ctx); + expect(out[0].reasonMessage).toContain("Tweet Enricher"); + }); + + it("can be dismissed by id", () => { + const ctx = buildCtx({ + stats: { hot: stat({ numWorkers: 1 }) }, + scores: { hot: 1.0 }, + hotThreshold: 0.8, + }); + const dismissed = new Set([bumpWorkersSuggestionId("hot")]); + expect(computeSuggestions(ctx, dismissed)).toHaveLength(0); + }); + + it("coexists with an INSERT_FILTER suggestion on the same hot operator", () => { + // A hot single-worker scan with no downstream filter should produce BOTH: + // - INSERT_FILTER ghost on the scan→agg edge + // - BUMP_WORKERS ghost on the scan operator + const ctx = buildCtx({ + stats: { + scan: stat({ aggregatedOutputRowCount: 5_000_000, numWorkers: 1 }), + agg: stat({}), + }, + scores: { scan: 1.0 }, + hotThreshold: 0.8, + types: { scan: "CSVScan", agg: "Aggregate" }, + downstreams: { scan: ["agg"] }, + }); + const out = computeSuggestions(ctx); + const types = out.map(s => s.type).sort(); + expect(types).toEqual(["BUMP_WORKERS", "INSERT_FILTER"]); + }); +}); diff --git a/frontend/src/app/workspace/service/profiler/profiler-suggestions.ts b/frontend/src/app/workspace/service/profiler/profiler-suggestions.ts new file mode 100644 index 00000000000..30938cf6a26 --- /dev/null +++ b/frontend/src/app/workspace/service/profiler/profiler-suggestions.ts @@ -0,0 +1,250 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +/** + * Pure suggestion engine. Converts the same `HintContext` used by `profiler-hints.ts` + * into a list of *structural* canvas suggestions (initially: "insert a Filter on this edge"). + * + * Stays dependency-free (no Angular, no RxJS) so it's fully unit-testable. + * Re-derives the same thresholds as `profiler-hints.ts` rather than parsing hint + * messages — keeps the modules independent and the math co-located with rules. + */ + +import type { HintContext } from "./profiler-hints"; + +export type SuggestionId = string; +export type SuggestionType = "INSERT_FILTER" | "BUMP_WORKERS"; +export type SuggestionReason = + | "SCAN_FULL_TABLE_NO_FILTER" + | "UPSTREAM_OVERPRODUCTION" + | "JOIN_HIGH_FANIN_LOW_FANOUT" + | "LOW_PARALLELISM_HOT_OP"; + +export interface InsertFilterSuggestion { + readonly id: SuggestionId; + readonly type: "INSERT_FILTER"; + readonly upstreamOpId: string; + readonly downstreamOpId: string; + readonly reasonRuleId: SuggestionReason; + /** Human-readable explanation, mirrors the hint message for reuse in tooltips. */ + readonly reasonMessage: string; +} + +/** + * Proposes increasing the worker count on a hot single-worker operator. + * Mirrors the LOW_PARALLELISM_HOT_OP hint rule: fires when an operator's + * normalized score is at/above the hot threshold AND it's running with <= 1 worker. + */ +export interface BumpWorkersSuggestion { + readonly id: SuggestionId; + readonly type: "BUMP_WORKERS"; + readonly operatorId: string; + readonly currentWorkers: number; + readonly proposedWorkers: number; + readonly reasonRuleId: "LOW_PARALLELISM_HOT_OP"; + readonly reasonMessage: string; +} + +export type Suggestion = InsertFilterSuggestion | BumpWorkersSuggestion; + +/** + * Default target worker count for the bump-workers suggestion. Picked to be a + * meaningful jump (4× over the current 1-worker state) but conservative enough + * to not over-allocate. Users can adjust in the property panel after clicking. + */ +export const BUMP_WORKERS_TARGET = 4; + +// Thresholds — kept in sync with `profiler-hints.ts`. Duplicated rather than imported +// so this module remains independent of hint internals; if a threshold changes, +// update it in both places. (Tests guard the parity.) +const JOIN_TYPE_PATTERN = /join/i; +const FILTER_TYPE_PATTERN = /filter/i; +const SCAN_TYPE_PATTERN = /(scan|source)/i; +const JOIN_LOW_FANOUT_RATIO = 0.05; +const UPSTREAM_OVERPRODUCE_RATIO = 10; +const SCAN_LARGE_OUTPUT = 1_000_000; + +/** + * Computes all structural suggestions for the given context, deduplicated by + * `(upstreamOpId, downstreamOpId)` edge and filtered to exclude dismissed ids. + * + * Order is deterministic (sorted by suggestion id) so the canvas rendering is stable. + */ +export function computeSuggestions( + ctx: HintContext, + dismissed: ReadonlySet = new Set() +): readonly Suggestion[] { + const out: Map = new Map(); + const opIds = Object.keys(ctx.stats); + + for (const opId of opIds) { + pushIfDefined(out, scanNoFilterSuggestion(opId, ctx)); + pushIfDefined(out, upstreamOverproductionSuggestion(opId, ctx)); + pushIfDefined(out, joinHighFaninLowFanoutSuggestion(opId, ctx)); + pushIfDefined(out, bumpWorkersSuggestion(opId, ctx)); + } + + const result: Suggestion[] = []; + for (const s of out.values()) { + if (!dismissed.has(s.id)) result.push(s); + } + result.sort((a, b) => a.id.localeCompare(b.id)); + return result; +} + +/** Build a stable suggestion id for an edge. */ +export function edgeSuggestionId(upstreamOpId: string, downstreamOpId: string): SuggestionId { + return `INSERT_FILTER:${upstreamOpId}->${downstreamOpId}`; +} + +/** Build a stable suggestion id for an operator-attached bump-workers ghost. */ +export function bumpWorkersSuggestionId(operatorId: string): SuggestionId { + return `BUMP_WORKERS:${operatorId}`; +} + +function pushIfDefined(out: Map, s: Suggestion | undefined): void { + // First reason wins for a given edge — keeps output deterministic and avoids + // duplicate ghosts on the same edge. + if (s && !out.has(s.id)) out.set(s.id, s); +} + +/** + * SCAN_FULL_TABLE_NO_FILTER → ghost between scan and its first downstream op. + * Only fires when the scan emits a lot of rows AND its immediate downstream is not + * already a Filter (otherwise the hint wouldn't fire either). + */ +function scanNoFilterSuggestion(opId: string, ctx: HintContext): Suggestion | undefined { + const s = ctx.stats[opId]; + if (!s) return undefined; + const type = ctx.operatorType(opId); + if (!type || !SCAN_TYPE_PATTERN.test(type)) return undefined; + const out = s.aggregatedOutputRowCount ?? 0; + if (out <= SCAN_LARGE_OUTPUT) return undefined; + + const downstream = ctx.downstreamOps(opId); + if (downstream.length === 0) return undefined; + // Find a non-filter downstream to insert before. + const target = downstream.find(id => { + const t = ctx.operatorType(id); + return !t || !FILTER_TYPE_PATTERN.test(t); + }); + if (!target) return undefined; + + return { + id: edgeSuggestionId(opId, target), + type: "INSERT_FILTER", + upstreamOpId: opId, + downstreamOpId: target, + reasonRuleId: "SCAN_FULL_TABLE_NO_FILTER", + reasonMessage: `Scan emits ${out.toLocaleString()} rows with no filter downstream. Insert a Filter to reduce data volume early.`, + }; +} + +/** + * UPSTREAM_OVERPRODUCTION → ghost between the over-producing upstream and the + * keeps-little downstream. The hint fires *on* the downstream; we look at its + * upstreams to find the offender. + */ +function upstreamOverproductionSuggestion(opId: string, ctx: HintContext): Suggestion | undefined { + const myStats = ctx.stats[opId]; + if (!myStats) return undefined; + const myInputs = myStats.aggregatedInputRowCount ?? 0; + if (myInputs <= 0) return undefined; + for (const upstream of ctx.upstreamOps(opId)) { + const upStats = ctx.stats[upstream]; + if (!upStats) continue; + const upOut = upStats.aggregatedOutputRowCount ?? 0; + if (upOut > myInputs * UPSTREAM_OVERPRODUCE_RATIO) { + return { + id: edgeSuggestionId(upstream, opId), + type: "INSERT_FILTER", + upstreamOpId: upstream, + downstreamOpId: opId, + reasonRuleId: "UPSTREAM_OVERPRODUCTION", + reasonMessage: `'${ctx.displayName(upstream)}' produces ${upOut.toLocaleString()} rows but '${ctx.displayName(opId)}' keeps only ${myInputs.toLocaleString()}. Insert a Filter on this edge to push the predicate upstream.`, + }; + } + } + return undefined; +} + +/** + * JOIN_HIGH_FANIN_LOW_FANOUT → ghost upstream of the join, on the edge from the + * input that contributed the most rows. The intuition: filter the biggest side + * before the join to reduce shuffle. + */ +/** + * LOW_PARALLELISM_HOT_OP → operator-attached "bump workers" suggestion. + * Fires when the operator's normalized score is at/above the hot threshold + * AND it's running with <= 1 worker. Mirrors the existing hint rule's + * conditions so the canvas suggestion stays consistent with the side panel. + */ +function bumpWorkersSuggestion(opId: string, ctx: HintContext): Suggestion | undefined { + const s = ctx.stats[opId]; + if (!s) return undefined; + const score = ctx.scores[opId] ?? 0; + if (score < ctx.hotThreshold) return undefined; + const currentWorkers = s.numWorkers ?? 1; + if (currentWorkers > 1) return undefined; + return { + id: bumpWorkersSuggestionId(opId), + type: "BUMP_WORKERS", + operatorId: opId, + currentWorkers, + proposedWorkers: BUMP_WORKERS_TARGET, + reasonRuleId: "LOW_PARALLELISM_HOT_OP", + reasonMessage: `Hot operator '${ctx.displayName(opId)}' is running with ${currentWorkers} worker. Increasing to ${BUMP_WORKERS_TARGET} workers may improve runtime proportionally.`, + }; +} + +function joinHighFaninLowFanoutSuggestion(opId: string, ctx: HintContext): Suggestion | undefined { + const s = ctx.stats[opId]; + if (!s) return undefined; + const type = ctx.operatorType(opId); + if (!type || !JOIN_TYPE_PATTERN.test(type)) return undefined; + const inp = s.aggregatedInputRowCount ?? 0; + const out = s.aggregatedOutputRowCount ?? 0; + if (inp <= 0) return undefined; + if (out / inp >= JOIN_LOW_FANOUT_RATIO) return undefined; + + // Pick the upstream with the most output rows — the "fat" side. + const upstreams = ctx.upstreamOps(opId); + if (upstreams.length === 0) return undefined; + let fattest: string | undefined; + let fattestOut = -1; + for (const up of upstreams) { + const upStats = ctx.stats[up]; + if (!upStats) continue; + const upOut = upStats.aggregatedOutputRowCount ?? 0; + if (upOut > fattestOut) { + fattestOut = upOut; + fattest = up; + } + } + if (!fattest) return undefined; + + return { + id: edgeSuggestionId(fattest, opId), + type: "INSERT_FILTER", + upstreamOpId: fattest, + downstreamOpId: opId, + reasonRuleId: "JOIN_HIGH_FANIN_LOW_FANOUT", + reasonMessage: `Join '${ctx.displayName(opId)}' keeps <${Math.round(JOIN_LOW_FANOUT_RATIO * 100)}% of its input. Insert a Filter before '${ctx.displayName(opId)}' to reduce shuffle.`, + }; +} diff --git a/frontend/src/app/workspace/service/profiler/profiler.service.spec.ts b/frontend/src/app/workspace/service/profiler/profiler.service.spec.ts new file mode 100644 index 00000000000..0c00d6f503c --- /dev/null +++ b/frontend/src/app/workspace/service/profiler/profiler.service.spec.ts @@ -0,0 +1,442 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import "zone.js/testing"; + +import { fakeAsync, tick } from "@angular/core/testing"; +import { BehaviorSubject, Subject } from "rxjs"; + +import { ProfilerService, ProfilerState } from "./profiler.service"; +import { ProfilerConfig, profilerConfigEquals } from "./profiler-config"; +import { + ExecutionState, + ExecutionStateInfo, + OperatorState, + OperatorStatistics, +} from "../../types/execute-workflow.interface"; + +class StubWorkflowStatusService { + public statusSubject = new Subject>(); + public currentStatus: Record = {}; + public getStatusUpdateStream() { + return this.statusSubject.asObservable(); + } + public getCurrentStatus() { + return this.currentStatus; + } +} + +class StubExecuteWorkflowService { + public executionStateStream = new Subject<{ previous: ExecutionStateInfo; current: ExecutionStateInfo }>(); + public getExecutionStateStream() { + return this.executionStateStream.asObservable(); + } +} + +class StubWorkflowActionService { + public profilerConfig: ProfilerConfig | undefined = undefined; + public profilerConfigSubject = new BehaviorSubject(undefined); + public writeCalls: (ProfilerConfig | undefined)[] = []; + public getProfilerConfigStream() { + return this.profilerConfigSubject.asObservable(); + } + public setProfilerConfig(cfg: ProfilerConfig | undefined): void { + this.writeCalls.push(cfg); + if (profilerConfigEquals(this.profilerConfig, cfg)) return; + this.profilerConfig = cfg; + this.profilerConfigSubject.next(cfg); + } +} + +function stat(partial: Partial): OperatorStatistics { + return { + operatorState: OperatorState.Completed, + aggregatedInputRowCount: 0, + inputPortMetrics: {}, + aggregatedOutputRowCount: 0, + outputPortMetrics: {}, + ...partial, + }; +} + +function installLocalStorageMock(): void { + const store: Record = {}; + const mock: Storage = { + get length() { + return Object.keys(store).length; + }, + clear: () => { + for (const k of Object.keys(store)) delete store[k]; + }, + getItem: (k: string) => (k in store ? store[k] : null), + key: (i: number) => Object.keys(store)[i] ?? null, + removeItem: (k: string) => { + delete store[k]; + }, + setItem: (k: string, v: string) => { + store[k] = String(v); + }, + }; + Object.defineProperty(globalThis, "localStorage", { + value: mock, + configurable: true, + writable: true, + }); +} + +describe("ProfilerService", () => { + let workflowStatus: StubWorkflowStatusService; + let executeWorkflow: StubExecuteWorkflowService; + let workflowAction: StubWorkflowActionService; + let profiler: ProfilerService; + + beforeEach(() => { + installLocalStorageMock(); + workflowStatus = new StubWorkflowStatusService(); + executeWorkflow = new StubExecuteWorkflowService(); + workflowAction = new StubWorkflowActionService(); + profiler = new ProfilerService(workflowStatus as any, executeWorkflow as any, workflowAction as any); + }); + + describe("computeScores (pure)", () => { + it("returns empty object for empty stats map", () => { + expect(profiler.computeScores({}, "runtime")).toEqual({}); + }); + + it("assigns score 1.0 to the only operator when it has cost", () => { + const scores = profiler.computeScores( + { op1: stat({ aggregatedDataProcessingTime: 1000 }) }, + "runtime" + ); + expect(scores["op1"].score).toBe(1); + }); + + it("normalizes runtime: hottest op gets 1.0, others scaled proportionally", () => { + const scores = profiler.computeScores( + { + scan: stat({ aggregatedDataProcessingTime: 200 }), + filter: stat({ aggregatedDataProcessingTime: 80 }), + join: stat({ aggregatedDataProcessingTime: 2000 }), + viz: stat({ aggregatedDataProcessingTime: 150 }), + }, + "runtime" + ); + expect(scores["join"].score).toBe(1); + expect(scores["scan"].score).toBeCloseTo(0.1, 5); + expect(scores["filter"].score).toBeCloseTo(0.04, 5); + expect(scores["viz"].score).toBeCloseTo(0.075, 5); + }); + + it("returns score 0 for all when all runtimes are zero", () => { + const scores = profiler.computeScores( + { + a: stat({ aggregatedDataProcessingTime: 0 }), + b: stat({ aggregatedDataProcessingTime: 0 }), + }, + "runtime" + ); + expect(scores["a"].score).toBe(0); + expect(scores["b"].score).toBe(0); + }); + + it("guards against NaN / undefined runtime fields", () => { + const scores = profiler.computeScores( + { + a: stat({}), // no runtime field at all + b: stat({ aggregatedDataProcessingTime: 1000 }), + }, + "runtime" + ); + expect(scores["a"].score).toBe(0); + expect(scores["b"].score).toBe(1); + expect(Number.isFinite(scores["a"].score)).toBe(true); + }); + + it("io-imbalance: a filter that drops most input is hot", () => { + const scores = profiler.computeScores( + { + scan: stat({ aggregatedInputRowCount: 0, aggregatedOutputRowCount: 1000 }), + filter: stat({ aggregatedInputRowCount: 1000, aggregatedOutputRowCount: 10 }), + pass: stat({ aggregatedInputRowCount: 10, aggregatedOutputRowCount: 10 }), + }, + "io-imbalance" + ); + expect(scores["filter"].score).toBe(1); + expect(scores["pass"].score).toBe(0); + expect(scores["scan"].score).toBe(0); + }); + + it("preserves the operator state on each entry", () => { + const scores = profiler.computeScores( + { op1: stat({ operatorState: OperatorState.Running, aggregatedDataProcessingTime: 1 }) }, + "runtime" + ); + expect(scores["op1"].state).toBe(OperatorState.Running); + }); + }); + + describe("enabled toggle", () => { + it("starts disabled and emits empty scores when stats arrive", fakeAsync(() => { + const emissions: ProfilerState[] = []; + profiler.getStateStream().subscribe(s => emissions.push(s)); + workflowStatus.statusSubject.next({ op1: stat({ aggregatedDataProcessingTime: 100 }) }); + tick(600); + const last = emissions[emissions.length - 1]; + expect(last.enabled).toBe(false); + expect(last.scores).toEqual({}); + })); + + it("computes scores immediately when toggled on with current stats", () => { + workflowStatus.currentStatus = { op1: stat({ aggregatedDataProcessingTime: 500 }) }; + profiler.setEnabled(true); + const state = profiler.getState(); + expect(state.enabled).toBe(true); + expect(state.scores["op1"].score).toBe(1); + }); + + it("clears scores immediately when toggled off (does not wait for next stats event)", () => { + workflowStatus.currentStatus = { op1: stat({ aggregatedDataProcessingTime: 500 }) }; + profiler.setEnabled(true); + expect(Object.keys(profiler.getState().scores).length).toBe(1); + profiler.setEnabled(false); + // Synchronous: no stats event needed to clear. + expect(profiler.getState().scores).toEqual({}); + expect(profiler.getState().enabled).toBe(false); + }); + }); + + describe("run lifecycle reset", () => { + it("clears scores on transition to Initializing", () => { + workflowStatus.currentStatus = { op1: stat({ aggregatedDataProcessingTime: 100 }) }; + profiler.setEnabled(true); + expect(Object.keys(profiler.getState().scores).length).toBe(1); + executeWorkflow.executionStateStream.next({ + previous: { state: ExecutionState.Completed }, + current: { state: ExecutionState.Initializing }, + }); + expect(profiler.getState().scores).toEqual({}); + }); + }); + + describe("config persistence", () => { + it("persists view selection to localStorage", () => { + profiler.setView("throughput"); + const raw = localStorage.getItem("texera.profiler.state"); + expect(raw).toBeTruthy(); + expect(JSON.parse(raw!).view).toBe("throughput"); + }); + + it("clamps hot-threshold percentile to [0,100]", () => { + profiler.setHotThresholdPercentile(150); + expect(profiler.getState().hotThresholdPercentile).toBe(100); + profiler.setHotThresholdPercentile(-5); + expect(profiler.getState().hotThresholdPercentile).toBe(0); + }); + + it("ignores corrupt persisted view value and falls back to default", () => { + localStorage.setItem( + "texera.profiler.state", + JSON.stringify({ enabled: true, view: "totally-bogus", hotThresholdPercentile: 50 }) + ); + const fresh = new ProfilerService(workflowStatus as any, executeWorkflow as any, workflowAction as any); + expect(fresh.getState().view).toBe("runtime"); + expect(fresh.getState().enabled).toBe(true); + expect(fresh.getState().hotThresholdPercentile).toBe(50); + }); + + it("clamps out-of-range persisted percentile on restore", () => { + localStorage.setItem( + "texera.profiler.state", + JSON.stringify({ enabled: false, view: "runtime", hotThresholdPercentile: 9999 }) + ); + const fresh = new ProfilerService(workflowStatus as any, executeWorkflow as any, workflowAction as any); + expect(fresh.getState().hotThresholdPercentile).toBe(100); + }); + }); + + describe("throttling", () => { + it("emits at most twice per 500ms window of bursts (leading + trailing)", fakeAsync(() => { + profiler.setEnabled(true); + let emissions = 0; + profiler.getStateStream().subscribe(() => emissions++); + const initialCount = emissions; + for (let i = 0; i < 20; i++) { + workflowStatus.statusSubject.next({ + op1: stat({ aggregatedDataProcessingTime: 100 + i }), + }); + tick(10); + } + tick(600); + // Leading + trailing inside 500ms throttle ≈ at most 2 score-driven emissions for the burst. + const burstEmissions = emissions - initialCount; + expect(burstEmissions).toBeLessThanOrEqual(3); + })); + }); + + describe("per-workflow profiler config sync", () => { + it("hydrates state when a workflow with profilerConfig is loaded", () => { + // Initial state: defaults (view=runtime, enabled=false, threshold=80) + expect(profiler.getState().view).toBe("runtime"); + + // Simulate workflow load with a saved profiler config. + workflowAction.setProfilerConfig({ + enabled: true, + view: "throughput", + hotThresholdPercentile: 95, + }); + + expect(profiler.getState().enabled).toBe(true); + expect(profiler.getState().view).toBe("throughput"); + expect(profiler.getState().hotThresholdPercentile).toBe(95); + }); + + it("leaves state untouched when the loaded workflow has no profilerConfig (undefined)", () => { + profiler.setView("io-imbalance"); + const before = { ...profiler.getState() }; + + // Simulate workflow load with no override. + workflowAction.profilerConfigSubject.next(undefined); + + expect(profiler.getState().view).toBe(before.view); + expect(profiler.getState().enabled).toBe(before.enabled); + }); + + it("writes back to the workflow when setEnabled is called", () => { + profiler.setEnabled(true); + expect(workflowAction.profilerConfig).toEqual({ + enabled: true, + view: "runtime", + hotThresholdPercentile: 80, + }); + }); + + it("writes back to the workflow when setView is called", () => { + profiler.setView("io-imbalance"); + expect(workflowAction.profilerConfig?.view).toBe("io-imbalance"); + }); + + it("writes back to the workflow when setHotThresholdPercentile is called", () => { + profiler.setHotThresholdPercentile(42); + expect(workflowAction.profilerConfig?.hotThresholdPercentile).toBe(42); + }); + + it("does not loop: hydrating from a config does not write back the same value", () => { + // Prime the workflow with a config equal to one we'd derive from current state. + // After hydration completes, the stub should have received writeCalls only from + // the explicit setProfilerConfig the test made — none from ProfilerService. + const initialCalls = workflowAction.writeCalls.length; + workflowAction.setProfilerConfig({ + enabled: true, + view: "runtime", + hotThresholdPercentile: 80, + }); + // The setProfilerConfig() call above counts as one write call from us. + // ProfilerService should NOT have written anything back as a side effect. + expect(workflowAction.writeCalls.length).toBe(initialCalls + 1); + }); + + it("clears scores when the workflow config turns profiling off", () => { + // Enable profiling first and seed some scores via a stats event. + profiler.setEnabled(true); + workflowStatus.currentStatus = { op1: stat({ aggregatedDataProcessingTime: 100 }) }; + // The setEnabled(true) call internally recomputes from currentStatus. + profiler.setEnabled(true); + expect(Object.keys(profiler.getState().scores).length).toBe(1); + + // Workflow loads with profiling explicitly off. + workflowAction.setProfilerConfig({ + enabled: false, + view: "runtime", + hotThresholdPercentile: 80, + }); + expect(profiler.getState().enabled).toBe(false); + expect(profiler.getState().scores).toEqual({}); + }); + + it("baseline: setBaseline stores the report and getBaseline returns it", () => { + const baseline = { + header: { + workflowName: "Prev Run", + executionName: null, + generatedAt: "2026-05-14T12:00:00Z", + view: "runtime", + hotThresholdPercentile: 80, + operatorCount: 1, + }, + operators: [ + { + operatorId: "op-1", + displayName: "Python UDF", + operatorType: "PythonUDFV2", + score: 0.5, + runtimeMs: 1000, + throughputRowsPerSec: 500, + inputRows: 100, + outputRows: 100, + inputSize: null, + outputSize: null, + workers: 1, + idleRatio: null, + }, + ], + }; + profiler.setBaseline(baseline); + expect(profiler.getBaseline()).toBe(baseline); + expect(profiler.getState().baseline).toBe(baseline); + }); + + it("baseline: clearBaseline removes a previously loaded baseline", () => { + profiler.setBaseline({ + header: { + workflowName: "Prev", + executionName: null, + generatedAt: "", + view: "runtime", + hotThresholdPercentile: 80, + operatorCount: 0, + }, + operators: [], + }); + profiler.clearBaseline(); + expect(profiler.getBaseline()).toBeUndefined(); + }); + + it("baseline: clearBaseline is a no-op when nothing is loaded (no extra emission)", () => { + let emissions = 0; + profiler.getStateStream().subscribe(() => emissions++); + const initial = emissions; + profiler.clearBaseline(); + expect(emissions).toBe(initial); + }); + + it("recomputes scores when workflow config flips profiling on", () => { + workflowStatus.currentStatus = { op1: stat({ aggregatedDataProcessingTime: 1000 }) }; + // Initially disabled; no scores. + expect(profiler.getState().enabled).toBe(false); + + workflowAction.setProfilerConfig({ + enabled: true, + view: "runtime", + hotThresholdPercentile: 80, + }); + expect(profiler.getState().enabled).toBe(true); + expect(profiler.getState().scores["op1"]?.score).toBe(1); + }); + }); +}); diff --git a/frontend/src/app/workspace/service/profiler/profiler.service.ts b/frontend/src/app/workspace/service/profiler/profiler.service.ts new file mode 100644 index 00000000000..79d603793de --- /dev/null +++ b/frontend/src/app/workspace/service/profiler/profiler.service.ts @@ -0,0 +1,299 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { Injectable } from "@angular/core"; +import { BehaviorSubject, Observable } from "rxjs"; +import { throttleTime } from "rxjs/operators"; +import { + ExecutionState, + OperatorState, + OperatorStatistics, +} from "../../types/execute-workflow.interface"; +import { WorkflowStatusService } from "../workflow-status/workflow-status.service"; +import { ExecuteWorkflowService } from "../execute-workflow/execute-workflow.service"; +import { WorkflowActionService } from "../workflow-graph/model/workflow-action.service"; +import { ProfilerConfig, profilerConfigEquals, serializeProfilerConfig } from "./profiler-config"; +import { BaselineReport } from "./profiler-delta"; + +export type ProfilerView = "runtime" | "throughput" | "io-imbalance" | "delta"; + +export interface ProfilerEntry { + readonly score: number; + readonly state: OperatorState; + readonly stats: OperatorStatistics; +} + +export interface ProfilerState { + readonly enabled: boolean; + readonly view: ProfilerView; + readonly hotThresholdPercentile: number; + readonly scores: Readonly>; + /** + * Optional baseline snapshot loaded by the user (uploaded P3 JSON report). + * When present, the side panel surfaces per-operator deltas against it. + * Not persisted (kept in memory only) — reports can be large and ephemeral. + */ + readonly baseline?: BaselineReport; +} + +const STORAGE_KEY = "texera.profiler.state"; +const THROTTLE_MS = 500; + +const DEFAULT_STATE: ProfilerState = { + enabled: false, + view: "runtime", + hotThresholdPercentile: 80, + scores: {}, +}; + +@Injectable({ + providedIn: "root", +}) +export class ProfilerService { + private readonly state$ = new BehaviorSubject(DEFAULT_STATE); + + constructor( + private workflowStatusService: WorkflowStatusService, + private executeWorkflowService: ExecuteWorkflowService, + private workflowActionService: WorkflowActionService + ) { + this.restoreConfig(); + + this.workflowStatusService + .getStatusUpdateStream() + .pipe(throttleTime(THROTTLE_MS, undefined, { leading: true, trailing: true })) + .subscribe(stats => this.recomputeScores(stats)); + + this.executeWorkflowService.getExecutionStateStream().subscribe(({ previous, current }) => { + const isRestart = + current.state === ExecutionState.Initializing || + (current.state === ExecutionState.Running && previous.state === ExecutionState.Uninitialized); + if (isRestart) { + this.emit({ scores: {} }); + } + }); + + // Per-workflow override: when a workflow with a saved profilerConfig is loaded, + // its values win over the user's localStorage defaults. `undefined` means the + // workflow has no override — keep the current state unchanged. + this.workflowActionService.getProfilerConfigStream().subscribe(cfg => { + if (!cfg) return; + this.hydrateFromConfig(cfg); + }); + } + + public getState(): ProfilerState { + return this.state$.value; + } + + public getStateStream(): Observable { + return this.state$.asObservable(); + } + + public setEnabled(enabled: boolean): void { + if (enabled) { + this.emit({ enabled }); + this.recomputeScores(this.workflowStatusService.getCurrentStatus()); + } else { + // Clear cached scores when disabling so consumers (side panel, etc.) + // do not show stale per-operator heat values. + this.emit({ enabled, scores: {} }); + } + this.persistConfig(); + this.persistToWorkflow(); + } + + public setView(view: ProfilerView): void { + this.emit({ view }); + this.persistConfig(); + this.persistToWorkflow(); + this.recomputeScores(this.workflowStatusService.getCurrentStatus()); + } + + public setHotThresholdPercentile(percentile: number): void { + const clamped = Math.max(0, Math.min(100, percentile)); + this.emit({ hotThresholdPercentile: clamped }); + this.persistConfig(); + this.persistToWorkflow(); + } + + /** + * Loads a baseline snapshot (a parsed P3 JSON report) for comparison against + * the live run. Replaces any previously loaded baseline. + */ + public setBaseline(baseline: BaselineReport): void { + this.emit({ baseline }); + } + + /** Clears the loaded baseline; the side-panel comparison section will disappear. */ + public clearBaseline(): void { + if (this.state$.value.baseline === undefined) return; + this.emit({ baseline: undefined }); + } + + /** Returns the currently-loaded baseline, or `undefined` if none. */ + public getBaseline(): BaselineReport | undefined { + return this.state$.value.baseline; + } + + /** + * Pure score computation. Exposed for unit tests. + */ + public computeScores( + stats: Record, + view: ProfilerView + ): Record { + const opIds = Object.keys(stats); + if (opIds.length === 0) return {}; + + const rawCost: Record = {}; + for (const opId of opIds) { + rawCost[opId] = this.rawCostFor(stats[opId], view); + } + + const maxCost = Math.max(0, ...Object.values(rawCost)); + const result: Record = {}; + for (const opId of opIds) { + const s = stats[opId]; + const score = maxCost > 0 ? clamp(rawCost[opId] / maxCost, 0, 1) : 0; + result[opId] = { + score: Number.isFinite(score) ? score : 0, + state: s.operatorState, + stats: s, + }; + } + return result; + } + + private recomputeScores(stats: Record): void { + if (!this.state$.value.enabled) { + if (Object.keys(this.state$.value.scores).length > 0) { + this.emit({ scores: {} }); + } + return; + } + const scores = this.computeScores(stats, this.state$.value.view); + this.emit({ scores }); + } + + private rawCostFor(s: OperatorStatistics, view: ProfilerView): number { + switch (view) { + case "runtime": { + const t = s.aggregatedDataProcessingTime ?? 0; + return Number.isFinite(t) && t > 0 ? t : 0; + } + case "throughput": { + // Slow producers are "hot": invert output so small output -> high cost. + const out = s.aggregatedOutputRowCount ?? 0; + return out > 0 ? 1 / out : 0; + } + case "io-imbalance": { + const inp = s.aggregatedInputRowCount ?? 0; + const out = s.aggregatedOutputRowCount ?? 0; + if (inp <= 0) return 0; + return clamp(1 - out / inp, 0, 1); + } + case "delta": { + // Delta view paints the canvas using current-vs-baseline deltas (handled + // in the heatmap handler, not here). The side-panel "Heat score" still + // uses runtime so the number stays meaningful when this view is selected. + const t = s.aggregatedDataProcessingTime ?? 0; + return Number.isFinite(t) && t > 0 ? t : 0; + } + } + } + + private emit(patch: Partial): void { + this.state$.next({ ...this.state$.value, ...patch }); + } + + private persistConfig(): void { + try { + const { enabled, view, hotThresholdPercentile } = this.state$.value; + localStorage.setItem(STORAGE_KEY, JSON.stringify({ enabled, view, hotThresholdPercentile })); + } catch { + // localStorage unavailable; ignore. + } + } + + /** + * Writes the current profiler config back into the active workflow content so it + * survives save/load round-trips. WorkflowActionService deep-equal-guards the write + * so this is a no-op when the value hasn't actually changed (avoiding a feedback + * loop with our own getProfilerConfigStream subscription). + */ + private persistToWorkflow(): void { + const { enabled, view, hotThresholdPercentile } = this.state$.value; + const cfg: ProfilerConfig = serializeProfilerConfig({ enabled, view, hotThresholdPercentile }); + this.workflowActionService.setProfilerConfig(cfg); + } + + /** + * Applies a workflow-supplied profiler config to in-memory state. Guards against + * the persistToWorkflow → getProfilerConfigStream feedback loop by early-returning + * when state already matches the incoming config. + */ + private hydrateFromConfig(cfg: ProfilerConfig): void { + const current = serializeProfilerConfig(this.state$.value); + if (profilerConfigEquals(current, cfg)) return; + const wasEnabled = this.state$.value.enabled; + this.emit({ + enabled: cfg.enabled, + view: cfg.view, + hotThresholdPercentile: cfg.hotThresholdPercentile, + }); + // Recompute scores if the workflow's config turned profiling on, or if it + // changed the view while profiling stays on (different formula → different scores). + if (cfg.enabled) { + this.recomputeScores(this.workflowStatusService.getCurrentStatus()); + } else if (wasEnabled) { + // Workflow disabled profiling — clear stale scores in one synchronous emit. + this.emit({ scores: {} }); + } + } + + private restoreConfig(): void { + try { + const raw = localStorage.getItem(STORAGE_KEY); + if (!raw) return; + const parsed = JSON.parse(raw) as Partial; + const view: ProfilerView = isValidView(parsed.view) ? parsed.view : DEFAULT_STATE.view; + const rawPct = parsed.hotThresholdPercentile; + const hotThresholdPercentile = + typeof rawPct === "number" && Number.isFinite(rawPct) + ? Math.max(0, Math.min(100, rawPct)) + : DEFAULT_STATE.hotThresholdPercentile; + this.emit({ + enabled: typeof parsed.enabled === "boolean" ? parsed.enabled : DEFAULT_STATE.enabled, + view, + hotThresholdPercentile, + }); + } catch { + // ignore corrupt config + } + } +} + +function isValidView(v: unknown): v is ProfilerView { + return v === "runtime" || v === "throughput" || v === "io-imbalance"; +} + +function clamp(x: number, min: number, max: number): number { + return Math.max(min, Math.min(max, x)); +} diff --git a/frontend/src/app/workspace/service/workflow-graph/model/workflow-action.service.ts b/frontend/src/app/workspace/service/workflow-graph/model/workflow-action.service.ts index e3ea66c024c..4c2c2fd27d1 100644 --- a/frontend/src/app/workspace/service/workflow-graph/model/workflow-action.service.ts +++ b/frontend/src/app/workspace/service/workflow-graph/model/workflow-action.service.ts @@ -22,6 +22,11 @@ import { Injectable } from "@angular/core"; import * as joint from "jointjs"; import { BehaviorSubject, merge, Observable, Subject } from "rxjs"; import { ExecutionMode, Workflow, WorkflowContent, WorkflowSettings } from "../../../../common/type/workflow"; +import { + parseProfilerConfig, + profilerConfigEquals, + ProfilerConfig, +} from "../../profiler/profiler-config"; import { WorkflowMetadata } from "../../../../dashboard/type/workflow-metadata.interface"; import { Comment, @@ -98,6 +103,12 @@ export class WorkflowActionService { private workflowSettings: WorkflowSettings; private workflowResetSubject = new Subject(); + // Per-workflow profiler config (UI heatmap/view/threshold). `undefined` means the + // current workflow has no override; consumers (ProfilerService) should fall back to + // user defaults stored in localStorage. Mirrors the workflowSettings field above. + private profilerConfig: ProfilerConfig | undefined = undefined; + private profilerConfigSubject = new BehaviorSubject(undefined); + constructor( private operatorMetadataService: OperatorMetadataService, private jointUIService: JointUIService, @@ -647,6 +658,8 @@ export class WorkflowActionService { const workflowContent: WorkflowContent = workflow.content; this.workflowSettings = workflowContent.settings || this.getDefaultSettings(); + const parsedProfilerCfg = parseProfilerConfig(workflowContent.profilerConfig); + this.applyProfilerConfig(parsedProfilerCfg); let operatorsAndPositions: { op: OperatorPredicate; pos: Point }[] = []; workflowContent.operators.forEach(op => { @@ -763,9 +776,43 @@ export class WorkflowActionService { links, commentBoxes, settings, + ...(this.profilerConfig !== undefined ? { profilerConfig: this.profilerConfig } : {}), }; } + /** + * Returns the current per-workflow profiler config, or `undefined` if the + * workflow has no override. Consumers should fall back to per-user defaults + * (e.g. localStorage) when this returns undefined. + */ + public getProfilerConfig(): ProfilerConfig | undefined { + return this.profilerConfig; + } + + /** + * Stream of per-workflow profiler config changes. Late subscribers get the + * current value immediately (BehaviorSubject). Emits `undefined` when there + * is no per-workflow override. + */ + public getProfilerConfigStream(): Observable { + return this.profilerConfigSubject.asObservable(); + } + + /** + * Overwrites the per-workflow profiler config. No-op when the new value is + * deep-equal to the current — this breaks any feedback loop with ProfilerService. + * Pass `undefined` to clear the override (workflow falls back to user defaults). + */ + public setProfilerConfig(cfg: ProfilerConfig | undefined): void { + this.applyProfilerConfig(cfg); + } + + private applyProfilerConfig(cfg: ProfilerConfig | undefined): void { + if (profilerConfigEquals(this.profilerConfig, cfg)) return; + this.profilerConfig = cfg; + this.profilerConfigSubject.next(cfg); + } + public getWorkflow(): Workflow { return { ...this.workflowMetadata, diff --git a/frontend/src/app/workspace/types/execute-workflow.interface.ts b/frontend/src/app/workspace/types/execute-workflow.interface.ts index 9b6edb00b29..eeedf7baa30 100644 --- a/frontend/src/app/workspace/types/execute-workflow.interface.ts +++ b/frontend/src/app/workspace/types/execute-workflow.interface.ts @@ -86,6 +86,11 @@ export interface OperatorStatistics aggregatedOutputRowCount: number; outputPortMetrics: Record; numWorkers?: number; + aggregatedInputSize?: number; + aggregatedOutputSize?: number; + aggregatedDataProcessingTime?: number; + aggregatedControlProcessingTime?: number; + aggregatedIdleTime?: number; }> {} export interface OperatorStatsUpdate