Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
74 changes: 74 additions & 0 deletions src/progress-estimation.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import type { TaskProgressSample, TaskSnapshot } from "./task-model";

const ETA_SAMPLE_WINDOW_MILLIS = 30_000;
const ETA_SAMPLE_MAX_LENGTH = 1_000;

/**
* Returns a new progress sample deque with the latest processed count appended.
*
* Samples are retained for the ETA rolling window and capped by count so very chatty tasks do not
* grow memory without bound.
*/
export const appendProgressSample = (
samples: ReadonlyArray<TaskProgressSample> | undefined,
now: number,
processed: number,
): ReadonlyArray<TaskProgressSample> => {
const previousSamples = samples ?? [];
const lastSample = previousSamples.at(-1);
if (lastSample?.processed === processed) {
return previousSamples;
}

// Keep one sample immediately before the rolling window when possible. That gives the ETA
// calculation a usable delta even just after old samples age out of the 30s window.
const windowStart = now - ETA_SAMPLE_WINDOW_MILLIS;
const appendedLength = previousSamples.length + 1;
let firstRetainedIndex = Math.max(0, appendedLength - ETA_SAMPLE_MAX_LENGTH);
while (
firstRetainedIndex + 1 < previousSamples.length &&
previousSamples[firstRetainedIndex + 1]!.timestamp < windowStart
) {
firstRetainedIndex++;
}

return [...previousSamples.slice(firstRetainedIndex), { timestamp: now, processed }];
};

/**
* Estimates remaining time from the task's retained progress sample deque.
*
* Returns undefined for inactive or unknown-total tasks, no remaining work, or insufficient
* observations with positive processed and time deltas.
*/
export const estimateRemainingMillis = (task: TaskSnapshot): number | undefined => {
const { processed, total } = task.units;
if (task.status !== "running" || total === undefined) {
return undefined;
}

const remaining = total - processed;
if (processed <= 0 || remaining <= 0) {
return undefined;
}

const samples = task.progressSamples;
const lastSample = samples.at(-1);
if (lastSample === undefined) {
return undefined;
}

const firstSample = samples[0];
if (firstSample === undefined || firstSample === lastSample) {
return undefined;
}

// appendProgressSample retains the rolling window, so these endpoints measure recent throughput.
const deltaProcessed = lastSample.processed - firstSample.processed;
const deltaMillis = lastSample.timestamp - firstSample.timestamp;
if (deltaProcessed <= 0 || deltaMillis <= 0) {
return undefined;
}

return Math.max(0, Math.floor((remaining * deltaMillis) / deltaProcessed));
};
50 changes: 2 additions & 48 deletions src/renderer/shared/format.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import type { TaskSnapshot } from "../../task-model";
import { isDeterminate } from "./determinate";
import { estimateRemainingMillis } from "../../progress-estimation";
import { getAmountParts } from "./amount-parts";

const formatDurationSeconds = (seconds: number): string => {
Expand Down Expand Up @@ -27,42 +27,6 @@ const formatClockDurationSeconds = (seconds: number): string => {
return hours > 0 ? `${`${hours}`.padStart(2, "0")}:${clock}` : clock;
};

/**
* Estimates remaining time from the task's retained progress sample deque.
*
* Returns undefined until there are at least two samples with positive processed and time deltas.
*/
const getSmoothedEtaMillis = (
task: TaskSnapshot & { readonly units: TaskSnapshot["units"] & { readonly total: number } },
): number | undefined => {
const { processed, total } = task.units;
const remaining = total - processed;
if (processed <= 0 || remaining <= 0) {
return undefined;
}

const samples = task.progressSamples;
const lastSample = samples.at(-1);
if (lastSample === undefined) {
return undefined;
}

const firstSample = samples[0];
if (firstSample === undefined || firstSample === lastSample) {
return undefined;
}

// The store maintains this as a retained rolling deque, so the first and last samples represent
// recent throughput rather than lifetime average throughput.
const deltaProcessed = lastSample.processed - firstSample.processed;
const deltaMillis = lastSample.timestamp - firstSample.timestamp;
if (deltaProcessed <= 0 || deltaMillis <= 0) {
return undefined;
}

return Math.max(0, Math.floor((remaining * deltaMillis) / deltaProcessed));
};

export const formatElapsed = (task: TaskSnapshot, now: number): string => {
const elapsedMillis = Math.max(0, (task.completedAt ?? now) - task.startedAt);
return formatDurationSeconds(elapsedMillis / 1000);
Expand All @@ -76,17 +40,7 @@ const formatElapsedClock = (task: TaskSnapshot, now: number): string => {
export const formatEta = (task: TaskSnapshot): string => formatEtaClock(task) ?? "";

const formatEtaClock = (task: TaskSnapshot): string | undefined => {
if (task.status !== "running" || !isDeterminate(task)) {
return undefined;
}

const { processed, total } = task.units;
const remaining = total - processed;
if (processed <= 0 || remaining <= 0) {
return undefined;
}

const etaMillis = getSmoothedEtaMillis(task);
const etaMillis = estimateRemainingMillis(task);
if (etaMillis === undefined) {
return undefined;
}
Expand Down
2 changes: 1 addition & 1 deletion src/services/store/store.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
import { appendProgressSample } from "../../progress-estimation";
import { Clock, Context, Effect, Layer, Option, Queue } from "effect";
import type { Column } from "../../columns/types";
import type { TaskId } from "../../task-model";
import type { TaskStore } from "./types";
import type { TaskOperations } from "../task-operations";
import { TaskId as makeTaskId, type TaskSnapshot } from "../../task-model";
import {
appendProgressSample,
createTaskSnapshot,
finalizeTaskSnapshot,
normalizeUnits,
Expand Down
37 changes: 1 addition & 36 deletions src/services/store/task-state.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,12 @@
import type { AddTaskOptions, UpdateTaskOptions } from "../../tasks/options";
import type { TaskId, TaskProgressSample, TaskSnapshot } from "../../task-model";
import type { TaskId, TaskSnapshot } from "../../task-model";

interface TaskCounts {
readonly succeeded: number;
readonly failed: number;
readonly total?: number;
}

const ETA_SAMPLE_WINDOW_MILLIS = 30_000;
const ETA_SAMPLE_MAX_LENGTH = 1_000;

const hasExplicitTotal = (options: Pick<AddTaskOptions | UpdateTaskOptions, "total">) =>
Object.prototype.hasOwnProperty.call(options, "total");

Expand Down Expand Up @@ -53,38 +50,6 @@ const completedUnits = (units: TaskSnapshot["units"]): TaskSnapshot["units"] =>
: units;
};

/**
* Returns a new progress sample deque with the latest processed count appended.
*
* Samples are retained for the ETA rolling window and capped by count so very chatty tasks do not
* grow memory without bound.
*/
export const appendProgressSample = (
samples: ReadonlyArray<TaskProgressSample> | undefined,
now: number,
processed: number,
): ReadonlyArray<TaskProgressSample> => {
const previousSamples = samples ?? [];
const lastSample = previousSamples.at(-1);
if (lastSample?.processed === processed) {
return previousSamples;
}

// Keep one sample immediately before the rolling window when possible. That gives the ETA
// calculation a usable delta even just after old samples age out of the 30s window.
const windowStart = now - ETA_SAMPLE_WINDOW_MILLIS;
const appendedLength = previousSamples.length + 1;
let firstRetainedIndex = Math.max(0, appendedLength - ETA_SAMPLE_MAX_LENGTH);
while (
firstRetainedIndex + 1 < previousSamples.length &&
previousSamples[firstRetainedIndex + 1]!.timestamp < windowStart
) {
firstRetainedIndex++;
}

return [...previousSamples.slice(firstRetainedIndex), { timestamp: now, processed }];
};

/** Applies mutable task fields; the mutation boundary records progress samples. */
export const updatedSnapshot = (snapshot: TaskSnapshot, options: UpdateTaskOptions) => {
const currentUnits = snapshot.units;
Expand Down
Loading