@grepr/cli 1.14.13-15e1f25 → 1.15.0-e33385d

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/build/dist/commands/job-draft-command.d.ts.map +1 -1
  2. package/build/dist/commands/job-draft-command.js +6 -71
  3. package/build/dist/commands/job-draft-command.js.map +1 -1
  4. package/build/dist/docs-index/index.json +1 -1
  5. package/build/dist/grepr.d.ts.map +1 -1
  6. package/build/dist/grepr.js +0 -2
  7. package/build/dist/grepr.js.map +1 -1
  8. package/build/dist/lib/backfill.js +4 -8
  9. package/build/dist/lib/backfill.js.map +1 -1
  10. package/build/dist/lib/job-patch.d.ts +15 -31
  11. package/build/dist/lib/job-patch.d.ts.map +1 -1
  12. package/build/dist/lib/job-patch.js +39 -709
  13. package/build/dist/lib/job-patch.js.map +1 -1
  14. package/build/dist/lib/job-plan.d.ts +3 -6
  15. package/build/dist/lib/job-plan.d.ts.map +1 -1
  16. package/build/dist/lib/job-plan.js +5 -55
  17. package/build/dist/lib/job-plan.js.map +1 -1
  18. package/build/dist/lib/option-parsers.d.ts +0 -5
  19. package/build/dist/lib/option-parsers.d.ts.map +1 -1
  20. package/build/dist/lib/option-parsers.js +0 -7
  21. package/build/dist/lib/option-parsers.js.map +1 -1
  22. package/build/dist/lib/signal-source.d.ts +1 -1
  23. package/build/dist/lib/signal-source.d.ts.map +1 -1
  24. package/build/dist/lib/signal-source.js +10 -69
  25. package/build/dist/lib/signal-source.js.map +1 -1
  26. package/build/dist/types.d.ts +8 -37
  27. package/build/dist/types.d.ts.map +1 -1
  28. package/build/dist/types.js +0 -69
  29. package/build/dist/types.js.map +1 -1
  30. package/package.json +1 -1
  31. package/build/dist/commands/job-to-test-command.d.ts +0 -74
  32. package/build/dist/commands/job-to-test-command.d.ts.map +0 -1
  33. package/build/dist/commands/job-to-test-command.js +0 -161
  34. package/build/dist/commands/job-to-test-command.js.map +0 -1
  35. package/build/dist/lib/job-graph-log-pipeline-constants.d.ts +0 -12
  36. package/build/dist/lib/job-graph-log-pipeline-constants.d.ts.map +0 -1
  37. package/build/dist/lib/job-graph-log-pipeline-constants.js +0 -16
  38. package/build/dist/lib/job-graph-log-pipeline-constants.js.map +0 -1
  39. package/build/dist/lib/job-graph-transformer.d.ts +0 -119
  40. package/build/dist/lib/job-graph-transformer.d.ts.map +0 -1
  41. package/build/dist/lib/job-graph-transformer.js +0 -767
  42. package/build/dist/lib/job-graph-transformer.js.map +0 -1
  43. package/build/dist/lib/job-graph-utils.d.ts +0 -50
  44. package/build/dist/lib/job-graph-utils.d.ts.map +0 -1
  45. package/build/dist/lib/job-graph-utils.js +0 -90
  46. package/build/dist/lib/job-graph-utils.js.map +0 -1
@@ -1,767 +0,0 @@
1
- /**
2
- * Job graph transformation utilities for converting production jobs to test configurations.
3
- *
4
- * This module provides functions to transform a production job definition into a test job
5
- * by replacing sources, sinks, and adding test-specific tagging. The transformations are
6
- * purely functional - they do not make any API calls or create resources.
7
- *
8
- * Key transformations:
9
- * - Source replacement: Replace vendor sources with dataset queries or keep originals with limits
10
- * - Sink replacement: Replace vendor sinks with synchronous test sinks or test dataset sinks
11
- * - Test tagging: Add test run ID and sink name tags to track test data
12
- *
13
- * The transformed job can then be executed using the standard job:create command.
14
- */
15
- import { DEFAULT_INPUT, DEFAULT_OUTPUT, JobExecution, JobProcessing, Vertex, DEFAULT_LIMIT } from '../types.js';
16
- import { LogsIcebergTableSourceType, LogsSynchronousSinkType, LogsIcebergTableSinkType, LogTransformActionType, DatadogQueryPredicateType, TagActionModification, TagActionType, LogsValuesSourceType, JsonLogProcessorType, GrokParserType, LogAttributesRemapperType, LogsFilterType, LogReducerType, LogsEventSamplerType, GreprUploadedLogFileSourceType, isSinkType, } from '../openapi/openApiTypes.js';
17
- import { canLimit, parseEdge, generateUUID, } from './job-graph-utils.js';
18
- import { RAW_JSON_PROCESSOR, RAW_PRE_EXCEPTIONS_FILTER, RAW_PRE_PARSER_FILTER, RAW_PRE_WAREHOUSE_FILTER, } from './job-graph-log-pipeline-constants.js';
19
- import fs from 'fs-extra';
20
- /**
21
- * Default `maxBurstLimit` for a draft logs-event-sampler. Matches the UI's
22
- * template-draft fallback (`logSampler?.maxBurstLimit ?? 1000`) so CLI raw-job
23
- * drafts sample at the same rate as the UI's template drafts. `maxAllowedRate`
24
- * has no CLI default: when unset we omit it and let the server default apply,
25
- * exactly as the UI does.
26
- */
27
- export const DEFAULT_DRAFT_SAMPLER_BURST = 1000;
28
- /**
29
- * Transforms a production job configuration into a test job configuration.
30
- *
31
- * This is the main entry point for job transformation. It applies the following changes:
32
- * 1. Renames the job (appends "_test" or uses custom name)
33
- * 2. Updates execution and processing types if specified
34
- * 3. Adds test run ID tag to job tags
35
- * 4. Transforms sources (replace with dataset query, sample data, or add limits)
36
- * 5. Transforms sinks (replace with sync sink or test dataset sinks)
37
- * 6. Adds test tagging operations before sinks
38
- *
39
- * The transformation is purely functional and does not make any API calls.
40
- *
41
- * @param originalJob - The production job to transform
42
- * @param options - Transformation options
43
- * @returns A new job configuration suitable for testing
44
- * @throws Error if job graph is missing or invalid
45
- *
46
- * @example
47
- * const testJob = transformJobToTest(prodJob, {
48
- * execution: 'SYNCHRONOUS',
49
- * processing: 'BATCH',
50
- * datasetId: 'my_dataset',
51
- * start: '2025-01-01T00:00:00Z',
52
- * end: '2025-01-01T01:00:00Z',
53
- * limitRecords: 100
54
- * });
55
- */
56
- export function transformJobToTest(originalJob, options) {
57
- const job = { ...originalJob };
58
- // Determine target execution and processing types (use options or keep original)
59
- const targetExecution = options.execution || job.execution;
60
- const targetProcessing = options.processing || job.processing;
61
- // Create base transformed job with updated metadata
62
- const transformedJob = {
63
- ...job,
64
- name: options.testName || `${job.name}_test`,
65
- execution: targetExecution,
66
- processing: targetProcessing,
67
- tags: {
68
- ...(job.tags || {}),
69
- 'grepr.test_run_id': options.testTag || generateUUID()
70
- }
71
- };
72
- if (!transformedJob.jobGraph) {
73
- throw new Error('Job graph is required');
74
- }
75
- const parsedGraph = parseGraph(transformedJob.jobGraph);
76
- // Step 1: Add test tagging operations on all edges BEFORE transforming sources/sinks
77
- // This captures the original source/sink names in the edge tags
78
- addTestTagging(parsedGraph, transformedJob.tags?.['grepr.test_run_id'] || generateUUID());
79
- // Step 2: Transform sources (replace with dataset, sample data, or add limits)
80
- transformSources(parsedGraph, targetProcessing, options);
81
- // Step 3: Transform sinks (replace with sync sink or test dataset sinks)
82
- transformSinks(parsedGraph, targetExecution, options);
83
- // Convert the parsed/modified graph back to SchemaGreprJobGraph format
84
- const transformedVertices = [];
85
- const transformedEdges = [];
86
- for (const vertex of parsedGraph.values()) {
87
- transformedVertices.push(vertex.operation);
88
- for (const [outputPort, nextIos] of vertex.next) {
89
- for (const nextIo of nextIos) {
90
- const edgeStr = `${vertex.name}:${outputPort} -> ${nextIo.vertex.name}:${nextIo.name}`;
91
- transformedEdges.push(edgeStr);
92
- }
93
- }
94
- }
95
- transformedJob.jobGraph = {
96
- vertices: transformedVertices,
97
- edges: transformedEdges
98
- };
99
- return transformedJob;
100
- }
101
- /**
102
- * Parses the job graph into a vertex-based representation, returning all vertices.
103
- */
104
- function parseGraph(jobGraph) {
105
- const verticesMap = new Map();
106
- // Create Vertex objects for each operation
107
- for (const operation of jobGraph.vertices) {
108
- verticesMap.set(operation.name, new Vertex(operation));
109
- }
110
- // Link vertices based on edges
111
- for (const edge of jobGraph.edges) {
112
- const { sourceVertex: sourceVertexName, targetVertex: targetVertexName, sourcePort, targetPort } = parseEdge(edge);
113
- const sourceVertex = verticesMap.get(sourceVertexName);
114
- const targetVertex = verticesMap.get(targetVertexName);
115
- if (sourceVertex && targetVertex) {
116
- sourceVertex.addNext(sourcePort, { name: targetPort, vertex: targetVertex });
117
- }
118
- }
119
- return verticesMap;
120
- }
121
- /**
122
- * Transforms job graph sources based on the specified options.
123
- *
124
- * Three transformation strategies are supported:
125
- * 1. Sample data file: Replace all sources with a LogsValuesSource (not yet implemented)
126
- * 2. Dataset query: Replace all sources with a single IcebergTableSource
127
- * 3. In-place: Keep original sources but add record limits for batch processing
128
- *
129
- * @param jobGraph - The job graph to transform
130
- * @param processing - Target processing type
131
- * @param options - Source transformation options
132
- * @returns Transformed job graph with updated sources
133
- */
134
- function transformSources(jobGraph, processing, options) {
135
- if (options.sampleDataFile) {
136
- transformSourcesToSampleData(jobGraph, options.sampleDataFile);
137
- }
138
- else if (options.datasetId) {
139
- transformSourcesToDatasetQuery(jobGraph, options.datasetId, options.query, options.start, options.end, processing, options.limitRecords);
140
- }
141
- else {
142
- transformSourcesInPlace(jobGraph, processing, options.limitRecords);
143
- }
144
- }
145
- /**
146
- * Transforms sources to use sample data from a file.
147
- *
148
- * This replaces all existing sources with a single LogsValuesSource that
149
- * emits the log events from the sample data file. The file should contain
150
- * a JSON array of log events matching the LogEvent schema.
151
- *
152
- * All edges from the old sources are rewired to connect from the new source
153
- * to the first downstream operation in the pipeline.
154
- *
155
- * @param jobGraph - The job graph to transform
156
- * @param sampleDataFile - Path to sample data file (JSON array of log events)
157
- * @throws Error if the file cannot be read or parsed
158
- */
159
- function transformSourcesToSampleData(jobGraph, sampleDataFile) {
160
- if (!fs.existsSync(sampleDataFile)) {
161
- throw new Error(`Sample data file not found: ${sampleDataFile}`);
162
- }
163
- // Read and parse the sample data file
164
- let sampleLogs;
165
- try {
166
- const fileContent = fs.readFileSync(sampleDataFile, 'utf-8');
167
- sampleLogs = JSON.parse(fileContent);
168
- if (!Array.isArray(sampleLogs)) {
169
- throw new Error('Sample data must be a JSON array of log events');
170
- }
171
- }
172
- catch (error) {
173
- if (error.message.includes('Sample data')) {
174
- throw error;
175
- }
176
- throw new Error(`Failed to parse sample data file: ${error.message}`);
177
- }
178
- // Create a new LogsValuesSource with the sample data
179
- const newSource = {
180
- type: LogsValuesSourceType.logs_values_source,
181
- name: 'test_sample_source',
182
- values: sampleLogs,
183
- indefinite: false
184
- };
185
- const newSourceVertex = new Vertex(newSource);
186
- // Remove all old sources and connect them to the new source
187
- for (const vertex of Array.from(jobGraph.values())) {
188
- if (isSource(vertex)) {
189
- // Rewire edges from old source's next vertices to new source
190
- for (const [port, nextIos] of vertex.next) {
191
- for (const nextIo of nextIos) {
192
- vertex.removeNext(port, nextIo);
193
- newSourceVertex.addNext(DEFAULT_OUTPUT, nextIo);
194
- }
195
- }
196
- jobGraph.delete(vertex.name);
197
- }
198
- }
199
- // Add the new source vertex to the graph
200
- jobGraph.set(newSourceVertex.name, newSourceVertex);
201
- }
202
- /**
203
- * Transforms sources to use an Iceberg dataset as the data source.
204
- *
205
- * This replaces all existing sources with a single IcebergTableSource that:
206
- * - Reads from the specified dataset
207
- * - Optionally filters data using a query
208
- * - Reads data within the specified time range
209
- * - Limits records for batch processing if specified
210
- *
211
- * All edges from the old sources are rewired to connect from the new source
212
- * to the first downstream operation in the pipeline.
213
- *
214
- * @param jobGraph - The job graph to transform
215
- * @param datasetId - ID of the dataset to read from
216
- * @param query - Optional query to filter dataset data
217
- * @param start - Start time for data query
218
- * @param end - End time for data query
219
- * @param processing - Processing type (BATCH or STREAMING)
220
- * @param limitRecords - Optional record limit for batch processing
221
- * @returns Transformed job graph with dataset source
222
- */
223
- function transformSourcesToDatasetQuery(jobGraph, datasetId, query, start, end, processing, limitRecords) {
224
- const newSource = {
225
- type: LogsIcebergTableSourceType.logs_iceberg_table_source,
226
- name: 'test_dataset_source',
227
- datasetId,
228
- query: {
229
- type: DatadogQueryPredicateType.datadog_query,
230
- query: query || ''
231
- },
232
- start: start || '',
233
- end: end || '',
234
- limit: limitRecords ?? DEFAULT_LIMIT
235
- };
236
- const newSourceVertex = new Vertex(newSource);
237
- // Remove all old sources and connect them to the new source
238
- for (const vertex of Array.from(jobGraph.values())) {
239
- if (isSource(vertex)) {
240
- // This is a source vertex since it has no inputs.
241
- // Rewire edges from old source's next vertices to new source
242
- for (const [port, nextIos] of vertex.next) {
243
- for (const nextIo of nextIos) {
244
- vertex.removeNext(port, nextIo);
245
- newSourceVertex.addNext(DEFAULT_OUTPUT, nextIo);
246
- }
247
- }
248
- jobGraph.delete(vertex.name);
249
- }
250
- }
251
- // Add the new source vertex to the graph
252
- jobGraph.set(newSourceVertex.name, newSourceVertex);
253
- }
254
- /**
255
- * Transforms sources in place by adding record limits for batch processing.
256
- *
257
- * This keeps the original sources but adds a 'limit' parameter to sources
258
- * that support it (e.g., IcebergTableSource). This is useful when you want
259
- * to test with the same data sources but limit the amount of data processed.
260
- *
261
- * @param jobGraph - The job graph to transform
262
- * @param processing - Processing type
263
- * @param limitRecords - Optional record limit to apply
264
- * @returns Transformed job graph with limited sources
265
- */
266
- function transformSourcesInPlace(jobGraph, processing, limitRecords) {
267
- for (const vertex of jobGraph.values()) {
268
- if (!isSource(vertex)) {
269
- continue;
270
- }
271
- if (processing === JobProcessing.BATCH && limitRecords && canLimit(vertex.operation)) {
272
- vertex.operation = {
273
- ...vertex.operation,
274
- limit: limitRecords
275
- };
276
- }
277
- }
278
- }
279
- /**
280
- * Transforms job graph sinks based on the target execution type.
281
- *
282
- * Two transformation strategies:
283
- * 1. SYNCHRONOUS: Replace all sinks with a single synchronous sink that returns results
284
- * 2. ASYNCHRONOUS: Replace vendor sinks with test dataset sinks (if testDataset provided)
285
- *
286
- * @param jobGraph - The job graph to transform
287
- * @param execution - Target execution type
288
- * @param options - Sink transformation options
289
- * @returns Transformed job graph with updated sinks
290
- */
291
- function transformSinks(jobGraph, execution, options) {
292
- if (execution === JobExecution.SYNCHRONOUS) {
293
- return transformSinksToSynchronous(jobGraph);
294
- }
295
- else {
296
- if (!options.testDataset) {
297
- throw new Error('Test dataset ID is required for ASYNCHRONOUS test job');
298
- }
299
- return transformSinksToAsynchronous(jobGraph, options.testDataset);
300
- }
301
- }
302
- /**
303
- * Transforms all sinks to a single synchronous sink.
304
- *
305
- * For synchronous testing, we replace all sink operations with a single
306
- * LogsSyncSink that returns the processed data in the API response.
307
- * This allows immediate verification of results.
308
- *
309
- * All operations that were feeding into the original sinks are rewired
310
- * to feed into the new synchronous sink.
311
- *
312
- * @param jobGraph - The job graph to transform
313
- */
314
- function transformSinksToSynchronous(jobGraph) {
315
- // Create a single synchronous sink to replace them all
316
- const syncSink = {
317
- type: LogsSynchronousSinkType.logs_sync_sink,
318
- name: 'test_synchronous_sink'
319
- };
320
- const sinkVertex = new Vertex(syncSink);
321
- wireNewSink(jobGraph, sinkVertex);
322
- }
323
- /**
324
- * Transforms sinks for asynchronous testing with a test dataset.
325
- *
326
- * If a test dataset ID is provided, this replaces vendor sinks, raw data sinks,
327
- * and event dedup sinks with Iceberg table sinks that write to the test dataset.
328
- *
329
- * The original sink names are preserved to maintain the graph structure.
330
- * Test tagging (added separately) allows distinguishing which sink the data came from.
331
- *
332
- * @param jobGraph - The job graph to transform
333
- * @param testDatasetId - Optional test dataset ID for output
334
- */
335
- function transformSinksToAsynchronous(jobGraph, testDatasetId) {
336
- // For each sink (no outputs), replace with test dataset sink.
337
- const sinkOp = {
338
- type: LogsIcebergTableSinkType.logs_iceberg_table_sink,
339
- name: 'test_async_sink',
340
- datasetId: testDatasetId
341
- };
342
- const sinkVertex = new Vertex(sinkOp);
343
- wireNewSink(jobGraph, sinkVertex);
344
- }
345
- function wireNewSink(jobGraph, sinkVertex) {
346
- // Find all vertices that feed into sinks and rewire them to the new sync sink
347
- for (const vertex of Array.from(jobGraph.values())) {
348
- if (isSink(vertex)) {
349
- // This is a sink vertex since it has no outputs.
350
- // Rewire edges from old sink's prev vertices to new sync sink
351
- for (const [port, prevIos] of vertex.prev) {
352
- for (const prevIo of prevIos) {
353
- vertex.removePrev(port, prevIo);
354
- prevIo.vertex.addNext(prevIo.name, { name: DEFAULT_INPUT, vertex: sinkVertex });
355
- }
356
- }
357
- jobGraph.delete(vertex.name);
358
- }
359
- }
360
- // Add the new sink vertex to the graph
361
- jobGraph.set(sinkVertex.name, sinkVertex);
362
- }
363
- /**
364
- * Adds test tagging operations on all edges, with the id of the
365
- * original edge and the test run ID.
366
- *
367
- * @param jobGraph - The job graph to transform
368
- * @param testRunId - Unique test run identifier
369
- */
370
- function addTestTagging(jobGraph, testRunId) {
371
- for (const vertex of Array.from(jobGraph.values())) {
372
- for (const [output, nextIos] of Array.from(vertex.next)) {
373
- for (const nextIo of nextIos) {
374
- // insert a tagging operation before this io
375
- const edge = `${vertex.name}_${output}_${nextIo.vertex.name}_${nextIo.name}`;
376
- const tagOpName = `${edge}_test_tag`;
377
- const tagOp = createTestTagOperation(tagOpName, testRunId, edge);
378
- const tagVertex = new Vertex(tagOp);
379
- // Rewire edges: vertex -> tagOp -> nextIo.vertex
380
- vertex.removeNext(output, nextIo);
381
- vertex.addNext(output, { name: DEFAULT_INPUT, vertex: tagVertex });
382
- tagVertex.addNext(DEFAULT_OUTPUT, nextIo);
383
- // Add the new tagging vertex to the graph
384
- jobGraph.set(tagVertex.name, tagVertex);
385
- }
386
- }
387
- }
388
- }
389
- /**
390
- * Creates a log transform operation that adds test tags.
391
- *
392
- * The operation adds two tags:
393
- * - grepr.test_run_id: Identifies which test run this data belongs to
394
- * - grepr.edge: Identifies edge this data was going through
395
- *
396
- * These tags are added using the ADD modification, which appends to existing tag arrays.
397
- *
398
- * @param name - Name for the tagging operation
399
- * @param testRunId - Unique test run identifier
400
- * @param edge - Name of the edge
401
- * @returns A LogTransformAction operation that adds the tags
402
- */
403
- function createTestTagOperation(name, testRunId, edge) {
404
- return {
405
- type: LogTransformActionType.log_transform,
406
- name,
407
- transforms: [
408
- {
409
- order: 0,
410
- type: TagActionType.tag_action,
411
- modification: TagActionModification.ADD,
412
- tagKey: 'grepr.test_run_id',
413
- values: [testRunId]
414
- },
415
- {
416
- order: 1,
417
- type: TagActionType.tag_action,
418
- modification: TagActionModification.ADD,
419
- tagKey: 'grepr.edge',
420
- values: [edge]
421
- }
422
- ]
423
- };
424
- }
425
- const isSource = (vertex) => vertex.prev.size === 0;
426
- const isSink = (vertex) => vertex.next.size === 0;
427
- /**
428
- * Vertex types tapped by type (the ones `applyPatchToJobGraph` mutates), so
429
- * legacy graphs with noncanonical names still tap the edited vertex. Pass-through
430
- * stages are matched by name instead (see {@link TAP_STAGE_NAME_TYPES}) because
431
- * linearizing parallel per-sink `logs-filter` vertices would change draft semantics.
432
- * Tap-eligible vertices fork their output to the shared sync sink (tagged for demux);
433
- * sources keep streaming live and sinks collapse into that one shared sync sink.
434
- */
435
- const TAP_MUTABLE_TYPES = new Set([
436
- GrokParserType.grok_parser,
437
- LogAttributesRemapperType.log_attributes_remapper,
438
- LogReducerType.log_reducer,
439
- ]);
440
- const TAP_STAGE_NAME_TYPES = new Map([
441
- [RAW_PRE_PARSER_FILTER, LogsFilterType.logs_filter],
442
- [RAW_JSON_PROCESSOR, JsonLogProcessorType.json_log_processor],
443
- [RAW_PRE_WAREHOUSE_FILTER, LogsFilterType.logs_filter],
444
- [RAW_PRE_EXCEPTIONS_FILTER, LogsFilterType.logs_filter],
445
- ]);
446
- function isTapEligible(vertex) {
447
- if (TAP_MUTABLE_TYPES.has(vertex.type)) {
448
- return true;
449
- }
450
- return TAP_STAGE_NAME_TYPES.get(vertex.name) === vertex.type;
451
- }
452
- /**
453
- * Tap-eligible vertices in topological order (Kahn's algorithm). Uses edges,
454
- * not vertex-array order, since the array order isn't guaranteed topological.
455
- */
456
- function collectTapEligibleInOrder(jobGraph) {
457
- const vertices = jobGraph.vertices ?? [];
458
- const edges = jobGraph.edges ?? [];
459
- const verticesByName = new Map();
460
- for (const v of vertices) {
461
- if (v.name)
462
- verticesByName.set(v.name, v);
463
- }
464
- const downstream = new Map();
465
- const inboundCount = new Map();
466
- for (const v of vertices) {
467
- if (v.name) {
468
- downstream.set(v.name, []);
469
- inboundCount.set(v.name, 0);
470
- }
471
- }
472
- for (const edge of edges) {
473
- const { sourceVertex, targetVertex } = parseEdge(edge);
474
- downstream.get(sourceVertex)?.push(targetVertex);
475
- inboundCount.set(targetVertex, (inboundCount.get(targetVertex) ?? 0) + 1);
476
- }
477
- const queue = [];
478
- for (const [name, count] of inboundCount) {
479
- if (count === 0)
480
- queue.push(name);
481
- }
482
- const ordered = [];
483
- const processed = new Set();
484
- while (queue.length > 0) {
485
- const name = queue.shift();
486
- processed.add(name);
487
- const vertex = verticesByName.get(name);
488
- if (vertex && isTapEligible(vertex)) {
489
- ordered.push(vertex);
490
- }
491
- for (const child of downstream.get(name) ?? []) {
492
- const remaining = (inboundCount.get(child) ?? 0) - 1;
493
- inboundCount.set(child, remaining);
494
- if (remaining === 0)
495
- queue.push(child);
496
- }
497
- }
498
- if (processed.size < verticesByName.size) {
499
- throw new Error(`Unsupported raw job graph shape: cycle detected while building tapped draft ` +
500
- `(${processed.size}/${verticesByName.size} vertices were topologically reachable).`);
501
- }
502
- return ordered;
503
- }
504
- /**
505
- * Build a source-preserving draft from a job-graph pipeline: keep the proposed
506
- * source vertices streaming live (so add/remove-source edits are exercised),
507
- * interpose a logs-event-sampler after each live source to bound per-source
508
- * volume, strip production sinks, and fan output into a shared logs-sync-sink.
509
- * Mirrors the UI's template draft mode for raw (non-templated) jobs. Verifies
510
- * the graph runs, not external sink delivery.
511
- */
512
- export function transformJobGraphToSourcePreservingDraft(originalJob, options = {}) {
513
- if (!originalJob.jobGraph) {
514
- throw new Error('Job graph is required');
515
- }
516
- const graph = structuredClone(originalJob.jobGraph);
517
- const existingNames = new Set();
518
- for (const vertex of graph.vertices) {
519
- if (vertex.name)
520
- existingNames.add(vertex.name);
521
- }
522
- const targetProcessing = options.processing ?? originalJob.processing ?? JobProcessing.STREAMING;
523
- const sinkNames = new Set(graph.vertices
524
- .filter(isProductionSinkOperation)
525
- .map(vertex => vertex.name)
526
- .filter((name) => typeof name === 'string' && name.length > 0));
527
- const originalParsedEdges = parseGraphEdges(graph.edges ?? []);
528
- const sinkInboundEdges = originalParsedEdges.filter(edge => sinkNames.has(edge.targetVertex));
529
- const retainedVertices = graph.vertices.filter(vertex => !sinkNames.has(vertex.name));
530
- const retainedEdges = (graph.edges ?? []).filter(edge => !edgeTouchesAny(edge, sinkNames));
531
- // Interpose a logs-event-sampler after each live source (mutates the retained
532
- // vertices/edges in place) so the live stream is rate-limited, mirroring the
533
- // UI's template draft mode. Done before the tap/sink wiring below so the
534
- // sampler vertices are part of the graph the rest of this function reasons over.
535
- insertSourceSamplers(retainedVertices, retainedEdges, existingNames, options.sampler);
536
- const syncSinkName = uniqueDraftName(existingNames, 'draft_source_preserving_sink');
537
- existingNames.add(syncSinkName);
538
- const syncSink = makeOperation(LogsSynchronousSinkType.logs_sync_sink, syncSinkName);
539
- const tapVertices = collectTapEligibleInOrder({ vertices: retainedVertices, edges: retainedEdges });
540
- const tapEligibleNames = new Set(tapVertices
541
- .map(vertex => vertex.name)
542
- .filter((name) => typeof name === 'string' && name.length > 0));
543
- const draftEdges = [...retainedEdges];
544
- for (const inbound of sinkInboundEdges) {
545
- // A sink chained to another sink was itself stripped, so it cannot source a
546
- // draft edge. Its records are already observed one hop upstream.
547
- if (sinkNames.has(inbound.sourceVertex))
548
- continue;
549
- // Tap-eligible sink-predecessors reach the sync sink through their tagger
550
- // below; adding a direct edge here too would deliver their records twice
551
- // (once untagged, once tagged), inflating counts the per-stage demux can't
552
- // attribute. Route them only through the tagger.
553
- if (tapEligibleNames.has(inbound.sourceVertex))
554
- continue;
555
- addDraftEdgeIfMissing(draftEdges, inbound.sourceVertex, inbound.sourcePort, syncSinkName, DEFAULT_INPUT);
556
- }
557
- // Fallback: no production sinks had recorded inbound edges; wire all terminal vertices to the sync sink.
558
- if (sinkInboundEdges.length === 0) {
559
- for (const terminal of terminalVertices(retainedVertices, retainedEdges)) {
560
- if (tapEligibleNames.has(terminal.name))
561
- continue; // same double-delivery reasoning as above
562
- addDraftEdgeIfMissing(draftEdges, terminal.name, DEFAULT_OUTPUT, syncSinkName, DEFAULT_INPUT);
563
- }
564
- }
565
- const taggerVertices = [];
566
- for (const tapVertex of tapVertices) {
567
- if (!tapVertex.name)
568
- continue;
569
- const taggerName = uniqueDraftName(existingNames, `tap_${tapVertex.name}`);
570
- existingNames.add(taggerName);
571
- const tagger = makeSinkSourceTagger(taggerName, tapVertex.name);
572
- taggerVertices.push(tagger);
573
- addDraftEdgeIfMissing(draftEdges, tapVertex.name, DEFAULT_OUTPUT, taggerName, DEFAULT_INPUT);
574
- addDraftEdgeIfMissing(draftEdges, taggerName, DEFAULT_OUTPUT, syncSinkName, DEFAULT_INPUT);
575
- }
576
- return {
577
- ...originalJob,
578
- name: options.testName ?? `${originalJob.name}_draft`,
579
- execution: JobExecution.SYNCHRONOUS,
580
- processing: targetProcessing,
581
- tags: {
582
- ...(originalJob.tags ?? {}),
583
- },
584
- jobGraph: {
585
- vertices: [...retainedVertices, ...taggerVertices, syncSink],
586
- edges: draftEdges,
587
- },
588
- };
589
- }
590
- function parseGraphEdges(edges) {
591
- return edges.map(edge => parseEdge(edge));
592
- }
593
- function edgeTouchesAny(edge, names) {
594
- const parsed = parseEdge(edge);
595
- return names.has(parsed.sourceVertex) || names.has(parsed.targetVertex);
596
- }
597
- function isProductionSinkOperation(operation) {
598
- return typeof operation.type === 'string' && isSinkType(operation.type);
599
- }
600
- function makeOperation(type, name, extra = {}) {
601
- return { type, name, ...extra };
602
- }
603
- /** A `log-transform` vertex that tags records with `sink-source=<sourceName>` for per-stage demux. */
604
- function makeSinkSourceTagger(taggerName, sourceName) {
605
- return makeOperation(LogTransformActionType.log_transform, taggerName, {
606
- transforms: [
607
- { type: TagActionType.tag_action, modification: TagActionModification.ADD, tagKey: 'sink-source', values: [sourceName], order: 0 },
608
- ],
609
- });
610
- }
611
- /** A `logs-event-sampler` vertex tuned for a draft, mirroring the UI's template draft sampler. */
612
- function makeEventSampler(name, sampler) {
613
- const extra = {
614
- maxBurstLimit: sampler?.maxBurstLimit ?? DEFAULT_DRAFT_SAMPLER_BURST,
615
- };
616
- // Omit maxAllowedRate unless set so the server's sampler default applies, exactly as the UI does.
617
- if (sampler?.maxAllowedRate !== undefined) {
618
- extra.maxAllowedRate = sampler.maxAllowedRate;
619
- }
620
- return makeOperation(LogsEventSamplerType.logs_event_sampler, name, extra);
621
- }
622
- /**
623
- * Interpose a logs-event-sampler between each live source and its downstream
624
- * targets, rewiring `source -> target` into `source -> sampler -> target`.
625
- * Mutates `vertices` (appends samplers) and `edges` (rewires) in place.
626
- *
627
- * A source is any vertex with no inbound edge. Uploaded-log-file sources are
628
- * skipped, matching the UI, which doesn't rate-limit uploaded-file drafts.
629
- * Isolated sources (no outbound edge) get no sampler — there's nothing to bound.
630
- */
631
- function insertSourceSamplers(vertices, edges, existingNames, sampler) {
632
- const targets = new Set(parseGraphEdges(edges).map(edge => edge.targetVertex));
633
- const sources = vertices.filter((vertex) => typeof vertex.name === 'string' &&
634
- vertex.name.length > 0 &&
635
- !targets.has(vertex.name) &&
636
- vertex.type !== GreprUploadedLogFileSourceType.grepr_uploaded_log_file_source);
637
- for (const source of sources) {
638
- const outbound = parseGraphEdges(edges).filter(edge => edge.sourceVertex === source.name);
639
- if (outbound.length === 0)
640
- continue;
641
- const samplerName = uniqueDraftName(existingNames, `${source.name}_draft_sampler`);
642
- existingNames.add(samplerName);
643
- vertices.push(makeEventSampler(samplerName, sampler));
644
- // Drop the source's outbound edges, then relink them through the sampler.
645
- for (let i = edges.length - 1; i >= 0; i--) {
646
- if (parseEdge(edges[i]).sourceVertex === source.name)
647
- edges.splice(i, 1);
648
- }
649
- edges.push(formatDraftEdge(source.name, outbound[0]?.sourcePort ?? DEFAULT_OUTPUT, samplerName, DEFAULT_INPUT));
650
- for (const edge of outbound) {
651
- edges.push(formatDraftEdge(samplerName, DEFAULT_OUTPUT, edge.targetVertex, edge.targetPort));
652
- }
653
- }
654
- }
655
- function terminalVertices(vertices, edges) {
656
- const sources = new Set();
657
- const targets = new Set();
658
- for (const edge of parseGraphEdges(edges)) {
659
- sources.add(edge.sourceVertex);
660
- targets.add(edge.targetVertex);
661
- }
662
- return vertices.filter(vertex => typeof vertex.name === 'string' &&
663
- vertex.name.length > 0 &&
664
- !sources.has(vertex.name) &&
665
- targets.has(vertex.name));
666
- }
667
- function uniqueDraftName(existing, base) {
668
- if (!existing.has(base))
669
- return base;
670
- let i = 1;
671
- while (existing.has(`${base}_${i}`)) {
672
- i += 1;
673
- }
674
- return `${base}_${i}`;
675
- }
676
- function formatDraftEdge(sourceVertex, sourcePort, targetVertex, targetPort) {
677
- const source = sourcePort === DEFAULT_OUTPUT ? sourceVertex : `${sourceVertex}:${sourcePort}`;
678
- const target = targetPort === DEFAULT_INPUT ? targetVertex : `${targetVertex}:${targetPort}`;
679
- return `${source} -> ${target}`;
680
- }
681
- function addDraftEdgeIfMissing(edges, sourceVertex, sourcePort, targetVertex, targetPort) {
682
- const exists = parseGraphEdges(edges).some(edge => edge.sourceVertex === sourceVertex &&
683
- edge.sourcePort === sourcePort &&
684
- edge.targetVertex === targetVertex &&
685
- edge.targetPort === targetPort);
686
- if (!exists) {
687
- edges.push(formatDraftEdge(sourceVertex, sourcePort, targetVertex, targetPort));
688
- }
689
- }
690
- /**
691
- * Displays a summary of the transformations applied to a job.
692
- *
693
- * This shows a before/after comparison including:
694
- * - Job name changes
695
- * - Execution type changes
696
- * - Processing type changes
697
- * - operation and edge changes
698
- * - Test run ID
699
- *
700
- * The output is formatted for console display.
701
- *
702
- * @param originalJob - The original production job
703
- * @param transformedJob - The transformed test job
704
- */
705
- export function showDiff(originalJob, transformedJob) {
706
- console.log('\n=== TRANSFORMATION SUMMARY ===\n');
707
- console.log(`Name: ${originalJob.name} → ${transformedJob.name}`);
708
- console.log(`Execution: ${originalJob.execution} → ${transformedJob.execution}`);
709
- console.log(`Processing: ${originalJob.processing} → ${transformedJob.processing}`);
710
- if (originalJob.jobGraph && transformedJob.jobGraph) {
711
- // Show added/removed operations and added/removed edges
712
- // For each operation in original, check if in transformed, and if changed
713
- const originalOps = new Set(originalJob.jobGraph.vertices.map(v => v.name));
714
- const transformedOps = new Set(transformedJob.jobGraph.vertices.map(v => v.name));
715
- const originalOpsMap = new Map(originalJob.jobGraph.vertices.map(v => [v.name, v]));
716
- const transformedOpsMap = new Map(transformedJob.jobGraph.vertices.map(v => [v.name, v]));
717
- const addedOps = Array.from(transformedOps).filter(op => !originalOps.has(op));
718
- const removedOps = Array.from(originalOps).filter(op => !transformedOps.has(op));
719
- const changedOps = Array.from(originalOps).filter(op => transformedOps.has(op) &&
720
- JSON.stringify(originalOpsMap.get(op)) !== JSON.stringify(transformedOpsMap.get(op)));
721
- if (addedOps.length > 0) {
722
- console.log(`Added Operations:`);
723
- for (const opName of addedOps) {
724
- const op = transformedOpsMap.get(opName);
725
- console.log(`${JSON.stringify(op, null, 2)}`);
726
- }
727
- }
728
- if (removedOps.length > 0) {
729
- console.log(`Removed Operations:`);
730
- for (const opName of removedOps) {
731
- const op = originalOpsMap.get(opName);
732
- console.log(`${JSON.stringify(op, null, 2)}`);
733
- }
734
- }
735
- if (changedOps.length > 0) {
736
- console.log(` Changed Operations:`);
737
- for (const opName of changedOps) {
738
- const originalOp = originalOpsMap.get(opName);
739
- const transformedOp = transformedOpsMap.get(opName);
740
- console.log(`- Original: ${JSON.stringify(originalOp, null, 2)}`);
741
- console.log(`+ Transformed: ${JSON.stringify(transformedOp, null, 2)}`);
742
- }
743
- }
744
- // Show edge changes
745
- const originalEdges = new Set(originalJob.jobGraph.edges);
746
- const transformedEdges = new Set(transformedJob.jobGraph.edges);
747
- const addedEdges = Array.from(transformedEdges).filter(edge => !originalEdges.has(edge));
748
- const removedEdges = Array.from(originalEdges).filter(edge => !transformedEdges.has(edge));
749
- if (addedEdges.length > 0) {
750
- console.log(`\n Added Edges:`);
751
- for (const edge of addedEdges) {
752
- console.log(`+ ${edge}`);
753
- }
754
- }
755
- if (removedEdges.length > 0) {
756
- console.log(`\n Removed Edges:`);
757
- for (const edge of removedEdges) {
758
- console.log(`- ${edge}`);
759
- }
760
- }
761
- console.log('\nOperations:');
762
- }
763
- console.log('\nTest Tag:');
764
- console.log(` ${transformedJob.tags?.['grepr.test_run_id']}`);
765
- console.log('\n=== TRANSFORMED JOB JSON ===\n');
766
- }
767
- //# sourceMappingURL=job-graph-transformer.js.map