RenderJob.java

package org.fresnel.backend.jobs;

import org.fresnel.optics.RenderResult;

import java.time.Instant;
import java.util.concurrent.CopyOnWriteArrayList;
import java.util.concurrent.atomic.AtomicReference;
import java.util.function.Consumer;

/**
 * A long-running render job. Threadsafe; a single producer reports progress and
 * eventually completes or fails, while many consumers (SSE subscribers) read state.
 *
 * <p>Listeners are notified on every progress update and on terminal state changes.
 */
public final class RenderJob {

    /** Job lifecycle. */
    public enum State { QUEUED, RUNNING, COMPLETED, FAILED }

    private final String id;
    private final String label;
    private final long createdAtEpochMs;
    private final AtomicReference<State> state = new AtomicReference<>(State.QUEUED);
    private volatile double progress = 0.0;
    private volatile String message = "queued";
    private volatile RenderResult result;
    private volatile Throwable error;

    private final CopyOnWriteArrayList<Consumer<RenderJob>> listeners = new CopyOnWriteArrayList<>();

    public RenderJob(String id, String label) {
        this.id = id;
        this.label = label;
        this.createdAtEpochMs = Instant.now().toEpochMilli();
    }

    public String id() { return id; }
    public String label() { return label; }
    public long createdAtEpochMs() { return createdAtEpochMs; }
    public State state() { return state.get(); }
    public double progress() { return progress; }
    public String message() { return message; }
    public RenderResult result() { return result; }
    public Throwable error() { return error; }

    /** Called by the worker to update progress. Triggers listeners. */
    public void reportProgress(double frac, String msg) {
        if (state.get() == State.QUEUED) state.compareAndSet(State.QUEUED, State.RUNNING);
        this.progress = Math.max(0.0, Math.min(1.0, frac));
        if (msg != null) this.message = msg;
        notifyListeners();
    }

    void complete(RenderResult r) {
        this.result = r;
        this.progress = 1.0;
        this.message = "completed";
        state.set(State.COMPLETED);
        notifyListeners();
    }

    void fail(Throwable t) {
        this.error = t;
        this.message = "failed: " + t.getMessage();
        state.set(State.FAILED);
        notifyListeners();
    }

    public void addListener(Consumer<RenderJob> listener) { listeners.add(listener); }
    public void removeListener(Consumer<RenderJob> listener) { listeners.remove(listener); }

    public boolean isTerminal() {
        State s = state.get();
        return s == State.COMPLETED || s == State.FAILED;
    }

    /**
     * Used by {@link org.fresnel.backend.jobs.RenderJobService} to rehydrate a
     * read-only snapshot from a persisted {@code COMPLETED} record. The result
     * payload itself is not stored on the snapshot — clients fetch the PNG via
     * {@code RenderJobService.resultPng(id)}.
     */
    public void markCompletedExternally(double progress, String message) {
        this.progress = Math.max(0.0, Math.min(1.0, progress));
        if (message != null) this.message = message;
        state.set(State.COMPLETED);
    }

    /** Counterpart of {@link #markCompletedExternally} for failed persisted jobs. */
    public void markFailedExternally(String message, String errorMessage) {
        if (message != null) this.message = message;
        if (errorMessage != null) this.error = new RuntimeException(errorMessage);
        state.set(State.FAILED);
    }

    private void notifyListeners() {
        for (Consumer<RenderJob> l : listeners) {
            try { l.accept(this); } catch (RuntimeException ignored) { /* never let a listener crash the producer */ }
        }
    }
}