diff --git a/README.md b/README.md index ade3916..f20c743 100644 --- a/README.md +++ b/README.md @@ -223,23 +223,52 @@ dispatched, it must be completed. #### Cancellation accounting -The only supported policy is -`CancellationAccounting.CHARGE_RESERVED_COST`. Cancelling a queued job removes -it from the queue and live-job indexes and releases its payload, but it does -not roll back the job's reserved virtual cost: +`CancellationAccounting.CHARGE_RESERVED_COST` remains the default for every +constructor that does not receive an explicit policy. Cancelling a queued job +removes it from the queue and live-job indexes and releases its payload, but it +does not roll back the job's reserved virtual cost: - the flow's `lastFinish` tag is not reduced; - tags already assigned to later jobs of the flow are not recomputed; - the charge disappears only when the scheduler becomes globally idle and ends the current busy period. -Consequently, completed-work fairness guarantees do not apply to any trace -containing cancellation. Treat frequent deadline or timeout cancellations as -a release blocker for an integration unless the resulting virtual charge and -dispatch delay are acceptable for that workload. +The opt-in alternative is: -For example, consider two equal-weight flows in a new busy period. Keeping a -job from B live prevents an idle reset: +```java +new SchedulerConfig( + issueDepth, + maxFlows, + maxLiveJobs, + CancellationAccounting.REFUND_CANCELLED_COST); +``` + +With `REFUND_CANCELLED_COST`, a successful queued cancellation removes that +job's cost from future virtual debt. Every later queued job of the same flow is +recomputed in enqueue order from the cancelled job's start tag, and the flow's +finish history moves to the end of that recomputed suffix. Other flows do not +change. The recomputation is one atomic transition under the scheduler's common +lock; callers cannot observe a partial suffix. + +Refund admission is intentionally narrower. Before accepting a job, enqueue +checks that the prospective flow queue has one common exact denominator and a +maximum accumulated numerator within the 4096-bit persistent budget. This +reserves enough numeric space for every later subset of queued cancellations. +An otherwise representable enqueue can therefore return `NUMERIC_LIMIT` under +the refund policy while the same enqueue remains accepted under the default +charge-reserved policy. If enqueue attempts a canonical rebase, every affected +queued flow must remain refund-closed or the complete enqueue is an atomic +no-op. An already accepted queued job can always be cancelled without a numeric +failure. + +Refund is prospective: dispatch decisions that linearized before cancellation +are never revised. Consequently, the published completed-work fairness bound is +not claimed for traces containing cancellation under either policy without a +separate proof. + +For example, under the default charge-reserved policy, consider two +equal-weight flows in a new busy period. Keeping a job from B live prevents an +idle reset: ```text A: enqueue cost=1_000_000 @@ -327,7 +356,9 @@ new SchedulerConfig( CancellationAccounting.CHARGE_RESERVED_COST); ``` -No free-cancellation accounting policy is currently implemented. +`REFUND_CANCELLED_COST` must always be selected explicitly. Existing +constructors and configurations using `CHARGE_RESERVED_COST` retain their +previous behavior and admission domain. The three- and four-argument forms preserve the unrestricted positive `long` weight domain. For production configurations with a known common scale, the @@ -370,7 +401,9 @@ The scheduler rejects work rather than allocating without bound: An enqueue may perform one transactional normalization when exact tags approach their numeric budget. If the result still cannot fit, it returns -`NUMERIC_LIMIT` without changing scheduler state. +`NUMERIC_LIMIT` without changing scheduler state. Refund accounting additionally +reserves numeric space for every later queued cancellation, so it may reject a +larger portion of the otherwise valid admission domain. `1_000_000` is a representation and validation limit for `issueDepth`, not a practically tested scale. One `dispatchUpTo(k)` call is atomic, holds the @@ -381,16 +414,21 @@ recorded run is reviewed for the target hardware and workload. ## Complexity -Let `R` be registered flows, `Q` queued jobs, `B` backlogged flows, and `m` the -number of jobs returned by one capacity call. +Let `R` be registered flows, `Q` queued jobs, `B` backlogged flows, `K` jobs in +the affected per-flow queue or suffix, and `m` the number of jobs returned by +one capacity call. | Operation | Expected or worst-case time | | --- | ---: | | register or close flow | expected `O(1)` | -| enqueue to a backlogged flow | expected `O(1)` | -| enqueue that makes a flow backlogged | `O(log B)` | -| cancel a non-head queued job | expected `O(1)` | -| cancel a flow head | `O(log B)` | +| charge-reserved enqueue to a backlogged flow | expected `O(1)` | +| charge-reserved enqueue that makes a flow backlogged | `O(log B)` | +| refund enqueue to a backlogged flow | `O(K)` | +| refund enqueue that makes a flow backlogged | `O(K + log B)` | +| charge-reserved cancel of a non-head queued job | expected `O(1)` | +| charge-reserved cancel of a flow head | `O(log B)` | +| refund cancel without changing the indexed head | `O(K)` | +| refund cancel that changes the indexed head | `O(K + log B)` | | dispatch `m` jobs | `O(m log B + m)` | | ordinary completion | expected `O(1)` | | aggregate or per-flow snapshot | expected `O(1)` | @@ -398,7 +436,9 @@ number of jobs returned by one capacity call. | rare exact-tag normalization | `O(Q + R)` time and temporary space | Retained state is `O(Q + running jobs + R)`. Terminal jobs and payloads are -not retained as tombstones. +not retained as tombstones. These are algorithmic complexity bounds, not +measured performance claims; no throughput or latency claim is made for refund +accounting without a preserved benchmark run. ## Verification and benchmarks diff --git a/docs/FORMAL_SPEC.md b/docs/FORMAL_SPEC.md index 9f11873..b0ad38b 100644 --- a/docs/FORMAL_SPEC.md +++ b/docs/FORMAL_SPEC.md @@ -72,8 +72,10 @@ Fairness is defined in terms of `cost`, not unknown actual execution time. integer in `1..Integer.MAX_VALUE`. - `maxLiveJobs` — the explicit `queued + dispatched` limit, an integer in `D..Integer.MAX_VALUE`. -- `cancellationAccounting` — the fixed policy - `CancellationAccounting.CHARGE_RESERVED_COST`; no alternative policy exists. +- `cancellationAccounting` — either + `CancellationAccounting.CHARGE_RESERVED_COST` or the opt-in + `CancellationAccounting.REFUND_CANCELLED_COST`. Constructors that do not + receive this value explicitly select `CHARGE_RESERVED_COST`. - `weightDomain` — either unrestricted positive `long` weights or the positive divisors of one fixed common scale `W`. @@ -230,6 +232,53 @@ charge-reserved cancellation policy can accumulate finish-tag debt during a continuous busy period. `NUMERIC_LIMIT` therefore remains a permitted enqueue result under a divisor-constrained domain. +### 3.2.2 Refund-closed queued chains + +**Cancellation accounting design decision.** Under +`REFUND_CANCELLED_COST`, successful admission MUST reserve enough of the +existing persistent numeric budget for every future subset of queued-job +cancellations to remain representable. The admission domain of this opt-in +policy is therefore narrower than the admission domain of +`CHARGE_RESERVED_COST`. + +For a non-empty queued chain of one flow, let `baseStart` be the start tag of +its head and let `r_i = cost_i/weight` be each queued job's reduced exact +increment in enqueue order. Define + +```text +L = lcm(denominator(baseStart), + denominator(r_1), ..., denominator(r_n)) + +A_0 = numerator(baseStart) * (L / denominator(baseStart)) +A_k = A_0 + sum(i=1..k, + numerator(r_i) * (L / denominator(r_i))) +``` + +The chain is **refund-closed** exactly when: + +```text +bitLength(L) <= 4096 +bitLength(A_n) <= 4096 +``` + +All increments are positive, so `A_n` is the maximum unreduced accumulated +numerator. Removing any subset of queued increments can only decrease that +numerator, and every resulting reduced denominator divides `L`. Consequently, +every tag required by any later sequence of successful queued cancellations +fits the persistent budget. Arithmetic over two such persistent values also +fits the 8193-bit transient budget. + +For `REFUND_CANCELLED_COST`, `enqueue` MUST test the prospective complete chain, +including the candidate job, before commit. Failure returns `NUMERIC_LIMIT` as +an atomic no-op. A canonical rebase attempted for the ordinary trigger in §3.2 +MUST also leave every transformed queued chain refund-closed, including the +candidate chain; otherwise the complete rebase-and-enqueue transaction returns +`NUMERIC_LIMIT` without mutation. Failure of the additional refund-closure +test alone does not trigger a rebase. + +`CHARGE_RESERVED_COST` does not perform this additional admission test and +retains the pre-existing numeric admission domain. + ### 3.3 Exact rebasing A rebase is a representational substitution of the same semantic state. Let @@ -275,7 +324,8 @@ plain SFQ(D) described under Scheduler state is the tuple: ```text -Config = (D, maxFlows, maxLiveJobs) +Config = (D, maxFlows, maxLiveJobs, cancellationAccounting, + weightDomain) ownerToken = inert identity token of this instance V = virtual-time tag lastJobSequence = last issued job long sequence, initially 0 @@ -389,6 +439,8 @@ In every observable state: runningSuppliedCost = sum(cost(job) for running jobs of this flow) ``` +16. Under `REFUND_CANCELLED_COST`, every non-empty queued flow chain is + refund-closed as defined in §3.2.2. ## 5. Lifecycles and identity @@ -478,14 +530,21 @@ create a registration and does not accept a weight. A dormant registered flow uses its stored `lastFinish`, not zero; an inactive interval within a busy period therefore does not reset fairness history. -Tags are fixed at enqueue. Later cancellation of another queued job MUST NOT -recalculate the tags of remaining jobs or roll back `lastFinish`. +Under `CHARGE_RESERVED_COST`, tags are fixed at enqueue. Later cancellation of +another queued job MUST NOT recalculate the tags of remaining jobs or roll back +`lastFinish`; the cancelled supplied cost remains a virtual charge until the +end of the current global busy period. -This last rule is a **cancellation design decision** absent from Jin04. It -prevents retroactive changes to accepted scheduling decisions. Its cost is that -a cancelled supplied cost remains a virtual charge until the end of the current -global busy period; the published completed-work fairness bound is not claimed -for intervals containing cancellation. +Under `REFUND_CANCELLED_COST`, enqueue assigns the same initial tags, but §7.4 +prospectively recomputes later queued tags of the same flow after a successful +cancellation. The refund changes only future scheduling state at the cancel +linearization point. Earlier dispatch results and virtual-time observations are +irreversible, so the result is not required to equal a history in which the +cancelled job was never accepted. + +Both policies are **cancellation design decisions** absent from Jin04. The +published completed-work fairness bound is not claimed for intervals containing +cancellation under either policy without a separate proof. ### 6.2 Deterministic tie-breaking @@ -575,7 +634,10 @@ Order of processing: 5. If the job sequence is exhausted, return `SEQUENCE_EXHAUSTED`. 6. Using the fixed registered weight and `lastFinish`, calculate exact `S` and `F`; when required, apply §3.3 transactionally to the complete necessary - state copy. If the budget remains violated, return `NUMERIC_LIMIT`. + state copy. Under `REFUND_CANCELLED_COST`, also apply the prospective + refund-closure checks from §3.2.2, including checks for every chain affected + by a planned rebase. If any applicable budget remains violated, return + `NUMERIC_LIMIT`. 7. Create the inert JobHandle and queued record, insert it into all job indexes, increment the registered flow's `acceptedCost` by `cost`, and update the registered flow counts, `lastFinish`, scheduler-wide counters, and job @@ -590,8 +652,11 @@ not consume a sequence. A null handle is an invalid argument. An opaque handle from another scheduler instance is treated as `NOT_LIVE`. -- If the handle is in `Queued`, atomically remove the job from the queue, - priority, and `LiveById`; decrement the flow count, increment the registered +- If the handle is in `Queued`, cancellation always succeeds. Under + `CHARGE_RESERVED_COST`, atomically remove the job from the queue, priority, + and `LiveById` without changing remaining tags or `lastFinish`. Under + `REFUND_CANCELLED_COST`, apply the prospective refund transition below. + Under both policies, decrement the flow count, increment the registered flow's `cancelledCost` by `cost(job)`, increment the scheduler-wide `cancelled` counter, release the payload, and return `CANCELLED`. - If the handle is in `Running`, change nothing and return @@ -608,6 +673,28 @@ scheduler's last live job, the same transition performs the §3.4 reset for all registrations. Cancellation does not return capacity because a queued job did not occupy any. +For `REFUND_CANCELLED_COST`, let `c` be the queued job selected for +cancellation. At the successful cancel LP: + +1. set `nextFinish := max(V, S(c))`; +2. for every later queued job of the same flow in enqueue order, set + `S(job) := nextFinish`, then set + `F(job) := S(job) + cost(job)/weight(flow)`, and advance + `nextFinish := F(job)`; +3. set `lastFinish(flow) := nextFinish`; +4. remove `c` from the per-flow queue, queued/live indexes, and priority index; +5. release its payload and update lifecycle and supplied-cost counters; +6. if the indexed flow head changed, insert the new head into the global + priority index only after its recomputed tag is installed. + +The complete removal and suffix recomputation are one atomic transition under +the scheduler's serialization boundary. No partially recomputed suffix is +observable. The refund-closure invariant established at enqueue makes every +exact cancellation computation fit the existing budgets, so `cancel` gains no +numeric rejection or exception. Other flows and their tags do not change, +intra-flow enqueue order is preserved, every queued `S` remains at least `V`, +and no past `dispatchUpTo` result is reconsidered. + ### 7.5 `dispatchUpTo(k)` / `dispatch(k)` `k` is an integer in `0..D`; a negative value or `k>D` is an invalid argument. @@ -851,9 +938,12 @@ FlowHandle in this busy period uses `max(V,lastFinish)`. While identity. Once `V` reaches `lastFinish`, the registration can be closed safely without changing the start tag of the next possible job. -The virtual charge of a cancelled job persists even after deactivation until -the global idle reset. This is the intentional non-retroactive semantics of -§6.1. +Under `CHARGE_RESERVED_COST`, the virtual charge of a cancelled job persists +even after deactivation until the global idle reset. Under +`REFUND_CANCELLED_COST`, `lastFinish` is instead moved to the exact end of the +remaining queued suffix at cancellation, while debt represented by already +dispatched work is retained through the suffix base. Both behaviors follow +§6.1 and §7.4. ### 9.4 Registered → closed @@ -887,8 +977,11 @@ weights, finite per-flow maximum costs, and a publication-compatible trace: ``` The units are supplied cost. This document does not claim the bound for -intervals containing cancellation because a non-retroactive virtual charge is -not completed work. +intervals containing cancellation under either accounting policy. Reserved +cost is not completed work under `CHARGE_RESERVED_COST`, while prospective +refund changes future tags without revising earlier dispatch decisions under +`REFUND_CANCELLED_COST`; neither extension inherits the published bound without +a separate proof. No-starvation is claimed only under the preconditions in [Starvation and progress](THEORY.md#starvation-and-progress): a bounded diff --git a/docs/THEORY.md b/docs/THEORY.md index eb201d0..f6b5caf 100644 --- a/docs/THEORY.md +++ b/docs/THEORY.md @@ -79,6 +79,8 @@ The Java library makes the unspecified parts explicit: - every public operation is linearizable; - equal start tags use admission order as a stable FIFO tie-break; - cancellation succeeds only before dispatch; +- charge-reserved cancellation is the default, while opt-in refund cancellation + prospectively removes queued virtual cost from later work of the same flow; - handles are opaque and scheduler-specific; - counts, sequences, and exact-number sizes are bounded and fail closed; - rejected operations are atomic no-ops; @@ -89,6 +91,28 @@ The last rule is an equivalent busy-period normalization described for SFQ. It prevents old virtual debt from growing forever across periods with no live work while preserving the ordering within each busy period. +### Cancellation policies and claims + +Cancellation is a library extension rather than a result supplied by the SFQ +or SFQ(D) papers. The default policy keeps the tags assigned at admission, so a +cancelled queued cost remains virtual debt until global idle. The opt-in refund +policy instead recomputes the later queued suffix of that flow at the +cancellation linearization point. It does not undo an earlier dispatch or +rewrite virtual-time history. + +Exact refund must not turn cancellation into a fallible cleanup operation. The +refund policy therefore narrows admission: before accepting a job, the library +proves that all tags reachable by removing any subset of the queued costs fit +the fixed exact-number budget. Positivity makes the full queued sum the largest +candidate numerator, and a common denominator covers every subset. Numeric +failure is reported by enqueue, while every accepted queued job remains safely +cancellable. + +Neither cancellation extension automatically inherits the papers' +completed-work fairness bound. Charge-reserved debt is not completed service, +and prospective refund changes future tags without revising past scheduling +decisions. A claim for cancellation intervals would require a separate proof. + ## Starvation and progress The scheduler cannot make work finish and cannot guarantee progress when the diff --git a/sfqd-benchmarks/README.md b/sfqd-benchmarks/README.md index 9da43e3..19ec483 100644 --- a/sfqd-benchmarks/README.md +++ b/sfqd-benchmarks/README.md @@ -12,8 +12,9 @@ establish production performance by existing in the repository. java -jar sfqd-benchmarks/target/sfqd-benchmarks.jar -l ``` -The Maven `verify` phase also runs a bounded 60-case idle-reset fixture smoke -and three scale-fixture cases. The largest scale fixture uses `B=10_000`, +The Maven `verify` phase also runs a bounded 90-case idle-reset fixture smoke +covering both cancellation-accounting policies, and three scale-fixture cases. +The largest scale fixture uses `B=10_000`, `Q=100_000`, and `depth=1_024`. These checks validate scheduler state and workload restoration; their execution time is not a benchmark result. @@ -74,7 +75,11 @@ metric and the normalization. ### Cancellation cycles `CancellationCycleBenchmark` measures `cancel -> enqueue replacement`. The -score belongs to the combined cycle, not cancellation alone. +score belongs to the combined cycle, not cancellation alone. The +`cancellationAccounting` parameter selects `CHARGE_RESERVED_COST` or +`REFUND_CANCELLED_COST`; the same policy dimension is present in isolated +head/non-head cancellation latency, terminal idle-reset cancellation, and +first-busy-period cancellation-cycle workloads. ### First busy-period cycles diff --git a/sfqd-benchmarks/src/main/java/io/github/pzhin/sfqd/benchmarks/CancellationCycleBenchmark.java b/sfqd-benchmarks/src/main/java/io/github/pzhin/sfqd/benchmarks/CancellationCycleBenchmark.java index 50441cd..3ef95fa 100644 --- a/sfqd-benchmarks/src/main/java/io/github/pzhin/sfqd/benchmarks/CancellationCycleBenchmark.java +++ b/sfqd-benchmarks/src/main/java/io/github/pzhin/sfqd/benchmarks/CancellationCycleBenchmark.java @@ -4,6 +4,7 @@ import static io.github.pzhin.sfqd.benchmarks.SchedulerBenchmarkSupport.requireCancelled; import io.github.pzhin.sfqd.CancelResult; +import io.github.pzhin.sfqd.CancellationAccounting; import io.github.pzhin.sfqd.EnqueueResult; import io.github.pzhin.sfqd.SchedulerSnapshot; import io.github.pzhin.sfqd.benchmarks.SchedulerBenchmarkSupport.Fixture; @@ -54,6 +55,10 @@ public static class CancellationState { @Param private Target target; + /** Cancellation accounting policy under measurement. */ + @Param({"CHARGE_RESERVED_COST", "REFUND_CANCELLED_COST"}) + private CancellationAccounting cancellationAccounting; + private Fixture fixture; private boolean nextHead; private int nextFlow; @@ -67,7 +72,7 @@ public static class CancellationState { /** Creates the bounded caller and scheduler queues outside measurements. */ @Setup(Level.Trial) public void setupTrial() { - fixture = new Fixture(flowCount, depth, scenario, 0, 2); + fixture = new Fixture(flowCount, depth, scenario, 0, 2, cancellationAccounting); } /** Selects and removes caller-side target bookkeeping outside the timed cycle. */ diff --git a/sfqd-benchmarks/src/main/java/io/github/pzhin/sfqd/benchmarks/FirstBusyPeriodCycleBenchmark.java b/sfqd-benchmarks/src/main/java/io/github/pzhin/sfqd/benchmarks/FirstBusyPeriodCycleBenchmark.java index d704c14..c82257b 100644 --- a/sfqd-benchmarks/src/main/java/io/github/pzhin/sfqd/benchmarks/FirstBusyPeriodCycleBenchmark.java +++ b/sfqd-benchmarks/src/main/java/io/github/pzhin/sfqd/benchmarks/FirstBusyPeriodCycleBenchmark.java @@ -1,5 +1,6 @@ package io.github.pzhin.sfqd.benchmarks; +import io.github.pzhin.sfqd.CancellationAccounting; import io.github.pzhin.sfqd.benchmarks.IdleResetBenchmarkSupport.FirstBusyPeriodFixture; import java.util.Objects; import java.util.concurrent.TimeUnit; @@ -30,12 +31,16 @@ public static class CycleState { @Param({"1", "256"}) private int depth; + /** Cancellation accounting policy under measurement. */ + @Param({"CHARGE_RESERVED_COST", "REFUND_CANCELLED_COST"}) + private CancellationAccounting cancellationAccounting; + private FirstBusyPeriodFixture fixture; /** Registers all flows and establishes the initial globally idle boundary. */ @Setup(Level.Trial) public void setupTrial() { - fixture = new FirstBusyPeriodFixture(flowCount, depth); + fixture = new FirstBusyPeriodFixture(flowCount, depth, cancellationAccounting); } /** Verifies that every measured transaction conserved bounded idle state. */ diff --git a/sfqd-benchmarks/src/main/java/io/github/pzhin/sfqd/benchmarks/IdleResetBenchmarkSupport.java b/sfqd-benchmarks/src/main/java/io/github/pzhin/sfqd/benchmarks/IdleResetBenchmarkSupport.java index 0ad1986..8e81ecb 100644 --- a/sfqd-benchmarks/src/main/java/io/github/pzhin/sfqd/benchmarks/IdleResetBenchmarkSupport.java +++ b/sfqd-benchmarks/src/main/java/io/github/pzhin/sfqd/benchmarks/IdleResetBenchmarkSupport.java @@ -7,6 +7,7 @@ import static io.github.pzhin.sfqd.benchmarks.SchedulerBenchmarkSupport.requireRegistered; import io.github.pzhin.sfqd.CancelResult; +import io.github.pzhin.sfqd.CancellationAccounting; import io.github.pzhin.sfqd.CompletionResult; import io.github.pzhin.sfqd.Dispatch; import io.github.pzhin.sfqd.EnqueueResult; @@ -45,6 +46,15 @@ static final class TerminalFixture { private JobHandle terminalHandle; TerminalFixture(int flowCount, int depth, boolean allTagged, TerminalOperation operation) { + this(flowCount, depth, allTagged, operation, CancellationAccounting.CHARGE_RESERVED_COST); + } + + TerminalFixture( + int flowCount, + int depth, + boolean allTagged, + TerminalOperation operation, + CancellationAccounting cancellationAccounting) { if (flowCount < 1 || depth < 1) { throw new IllegalArgumentException("flowCount and depth must be positive"); } @@ -52,7 +62,8 @@ static final class TerminalFixture { this.depth = depth; this.allTagged = allTagged; this.operation = operation; - this.scheduler = new SfqdScheduler<>(new SchedulerConfig(depth, flowCount, Math.max(depth, flowCount))); + this.scheduler = new SfqdScheduler<>(new SchedulerConfig( + depth, flowCount, Math.max(depth, flowCount), cancellationAccounting)); this.flows = new ArrayList<>(flowCount); this.jobs = new ArrayList<>(flowCount); for (int index = 0; index < flowCount; index++) { @@ -187,12 +198,18 @@ static final class FirstBusyPeriodFixture { private final JobKey job = new JobKey(1L); FirstBusyPeriodFixture(int flowCount, int depth) { + this(flowCount, depth, CancellationAccounting.CHARGE_RESERVED_COST); + } + + FirstBusyPeriodFixture( + int flowCount, int depth, CancellationAccounting cancellationAccounting) { if (flowCount < 1 || depth < 1) { throw new IllegalArgumentException("flowCount and depth must be positive"); } this.flowCount = flowCount; this.depth = depth; - this.scheduler = new SfqdScheduler<>(new SchedulerConfig(depth, flowCount, depth)); + this.scheduler = new SfqdScheduler<>(new SchedulerConfig( + depth, flowCount, depth, cancellationAccounting)); FlowHandle first = null; for (int index = 0; index < flowCount; index++) { FlowHandle registered = requireRegistered(scheduler.registerFlow(new FlowKey(index), 1L)); diff --git a/sfqd-benchmarks/src/main/java/io/github/pzhin/sfqd/benchmarks/IdleResetWorkloadSmoke.java b/sfqd-benchmarks/src/main/java/io/github/pzhin/sfqd/benchmarks/IdleResetWorkloadSmoke.java index fb9675f..05902c0 100644 --- a/sfqd-benchmarks/src/main/java/io/github/pzhin/sfqd/benchmarks/IdleResetWorkloadSmoke.java +++ b/sfqd-benchmarks/src/main/java/io/github/pzhin/sfqd/benchmarks/IdleResetWorkloadSmoke.java @@ -1,6 +1,7 @@ package io.github.pzhin.sfqd.benchmarks; import io.github.pzhin.sfqd.CancelResult; +import io.github.pzhin.sfqd.CancellationAccounting; import io.github.pzhin.sfqd.CompletionResult; import io.github.pzhin.sfqd.EnqueueResult; import io.github.pzhin.sfqd.benchmarks.IdleResetBenchmarkSupport.FirstBusyPeriodFixture; @@ -14,6 +15,10 @@ public final class IdleResetWorkloadSmoke { private static final int[] ALL_TAGGED_FLOW_COUNTS = {1, 100, 10_000}; private static final int[] TARGET_DEPTHS = {1, 256}; private static final int[] FIRST_BUSY_FLOW_COUNTS = {1, 10_000}; + private static final CancellationAccounting[] CANCELLATION_POLICIES = { + CancellationAccounting.CHARGE_RESERVED_COST, + CancellationAccounting.REFUND_CANCELLED_COST + }; private IdleResetWorkloadSmoke() { } @@ -31,25 +36,34 @@ public static void main(String[] arguments) { for (int flowCount : FULL_FLOW_COUNTS) { for (int depth : FULL_DEPTHS) { exerciseCompletion(flowCount, depth, false); - exerciseCancellation(flowCount, depth, false); - cases += 2; + cases++; + for (CancellationAccounting policy : CANCELLATION_POLICIES) { + exerciseCancellation(flowCount, depth, false, policy); + cases++; + } } } for (int flowCount : ALL_TAGGED_FLOW_COUNTS) { for (int depth : TARGET_DEPTHS) { exerciseCompletion(flowCount, depth, true); - exerciseCancellation(flowCount, depth, true); - cases += 2; + cases++; + for (CancellationAccounting policy : CANCELLATION_POLICIES) { + exerciseCancellation(flowCount, depth, true, policy); + cases++; + } } } for (int flowCount : FIRST_BUSY_FLOW_COUNTS) { for (int depth : TARGET_DEPTHS) { exerciseFirstAdmission(flowCount, depth); - exerciseCycle(flowCount, depth); - cases += 2; + cases++; + for (CancellationAccounting policy : CANCELLATION_POLICIES) { + exerciseCycle(flowCount, depth, policy); + cases++; + } } } - if (cases != 60) { + if (cases != 90) { throw new IllegalStateException("unexpected idle-reset smoke matrix size: " + cases); } System.out.println("IDLE_RESET_WORKLOAD_SMOKE PASS cases=" + cases); @@ -63,9 +77,10 @@ private static void exerciseCompletion(int flowCount, int depth, boolean allTagg fixture.verifyPrepared(); } - private static void exerciseCancellation(int flowCount, int depth, boolean allTagged) { + private static void exerciseCancellation( + int flowCount, int depth, boolean allTagged, CancellationAccounting policy) { TerminalFixture fixture = new TerminalFixture( - flowCount, depth, allTagged, TerminalOperation.CANCEL); + flowCount, depth, allTagged, TerminalOperation.CANCEL, policy); CancelResult result = fixture.cancelLastQueued(); fixture.restoreAfterCancellation(result); fixture.verifyPrepared(); @@ -78,8 +93,8 @@ private static void exerciseFirstAdmission(int flowCount, int depth) { fixture.verifyIdle(); } - private static void exerciseCycle(int flowCount, int depth) { - FirstBusyPeriodFixture fixture = new FirstBusyPeriodFixture(flowCount, depth); + private static void exerciseCycle(int flowCount, int depth, CancellationAccounting policy) { + FirstBusyPeriodFixture fixture = new FirstBusyPeriodFixture(flowCount, depth, policy); if (fixture.enqueueCancelCycle() != 1) { throw new IllegalStateException("first-busy-period cycle count diverged"); } diff --git a/sfqd-benchmarks/src/main/java/io/github/pzhin/sfqd/benchmarks/OperationLatencyBenchmark.java b/sfqd-benchmarks/src/main/java/io/github/pzhin/sfqd/benchmarks/OperationLatencyBenchmark.java index dfad629..71e62ef 100644 --- a/sfqd-benchmarks/src/main/java/io/github/pzhin/sfqd/benchmarks/OperationLatencyBenchmark.java +++ b/sfqd-benchmarks/src/main/java/io/github/pzhin/sfqd/benchmarks/OperationLatencyBenchmark.java @@ -6,6 +6,7 @@ import static io.github.pzhin.sfqd.benchmarks.SchedulerBenchmarkSupport.requireCompleted; import io.github.pzhin.sfqd.CancelResult; +import io.github.pzhin.sfqd.CancellationAccounting; import io.github.pzhin.sfqd.CompletionResult; import io.github.pzhin.sfqd.Dispatch; import io.github.pzhin.sfqd.EnqueueResult; @@ -248,6 +249,10 @@ public static class CancellationState extends MatrixState { @Param({"HEAD", "NON_HEAD"}) private CancelPosition position; + /** Cancellation accounting policy under measurement. */ + @Param({"CHARGE_RESERVED_COST", "REFUND_CANCELLED_COST"}) + private CancellationAccounting cancellationAccounting; + private Fixture fixture; private JobRecord target; private CancelResult result; @@ -258,7 +263,7 @@ public static class CancellationState extends MatrixState { /** Builds per-flow queues with at least two entries. */ @Setup(Level.Trial) public void setupTrial() { - fixture = new Fixture(flowCount(), depth(), scenario(), 0, 2); + fixture = new Fixture(flowCount(), depth(), scenario(), 0, 2, cancellationAccounting); } /** Selects a stable queued target without charging caller lookup to scheduler latency. */ @@ -429,13 +434,18 @@ public static class OneTaggedCancellationState { @Param({"1", "8", "64", "256"}) private int depth; + /** Cancellation accounting policy under measurement. */ + @Param({"CHARGE_RESERVED_COST", "REFUND_CANCELLED_COST"}) + private CancellationAccounting cancellationAccounting; + private TerminalFixture fixture; private CancelResult result; /** Establishes exactly one queued job while every other registered flow remains untagged. */ @Setup(Level.Trial) public void setupTrial() { - fixture = new TerminalFixture(flowCount, depth, false, TerminalOperation.CANCEL); + fixture = new TerminalFixture( + flowCount, depth, false, TerminalOperation.CANCEL, cancellationAccounting); } /** Validates the idle transition and restores the exact one-tagged boundary. */ @@ -523,13 +533,18 @@ public static class AllTaggedCancellationState { @Param({"1", "256"}) private int depth; + /** Cancellation accounting policy under measurement. */ + @Param({"CHARGE_RESERVED_COST", "REFUND_CANCELLED_COST"}) + private CancellationAccounting cancellationAccounting; + private TerminalFixture fixture; private CancelResult result; /** Tags every flow without ending the busy period and leaves the final job queued. */ @Setup(Level.Trial) public void setupTrial() { - fixture = new TerminalFixture(flowCount, depth, true, TerminalOperation.CANCEL); + fixture = new TerminalFixture( + flowCount, depth, true, TerminalOperation.CANCEL, cancellationAccounting); } /** Validates global idle and re-establishes the all-tagged boundary. */ diff --git a/sfqd-benchmarks/src/main/java/io/github/pzhin/sfqd/benchmarks/SchedulerBenchmarkSupport.java b/sfqd-benchmarks/src/main/java/io/github/pzhin/sfqd/benchmarks/SchedulerBenchmarkSupport.java index 7766803..74b4dc6 100644 --- a/sfqd-benchmarks/src/main/java/io/github/pzhin/sfqd/benchmarks/SchedulerBenchmarkSupport.java +++ b/sfqd-benchmarks/src/main/java/io/github/pzhin/sfqd/benchmarks/SchedulerBenchmarkSupport.java @@ -1,6 +1,7 @@ package io.github.pzhin.sfqd.benchmarks; import io.github.pzhin.sfqd.CancelResult; +import io.github.pzhin.sfqd.CancellationAccounting; import io.github.pzhin.sfqd.CompletionResult; import io.github.pzhin.sfqd.Dispatch; import io.github.pzhin.sfqd.EnqueueResult; @@ -119,6 +120,17 @@ static final class Fixture { } Fixture(int flowCount, int depth, Scenario scenario, int inactiveRegistrations, int minimumAnchors) { + this(flowCount, depth, scenario, inactiveRegistrations, minimumAnchors, + CancellationAccounting.CHARGE_RESERVED_COST); + } + + Fixture( + int flowCount, + int depth, + Scenario scenario, + int inactiveRegistrations, + int minimumAnchors, + CancellationAccounting cancellationAccounting) { if (flowCount < 1 || inactiveRegistrations < 0) { throw new IllegalArgumentException("flow counts must be valid"); } @@ -129,7 +141,7 @@ static final class Fixture { int initiallyActive = expectedActiveFlows(flowCount, scenario); int plannedJobs = Math.addExact(Math.multiplyExact(initiallyActive, anchors), reserve); this.scheduler = new SfqdScheduler<>(new SchedulerConfig( - depth, registered, Math.addExact(plannedJobs, depth + 64))); + depth, registered, Math.addExact(plannedJobs, depth + 64), cancellationAccounting)); this.flowIds = new ArrayList<>(registered); this.flowHandles = new ArrayList<>(registered); this.queuedByFlow = new ArrayList<>(registered); diff --git a/sfqd-core/src/main/api/public-api.txt b/sfqd-core/src/main/api/public-api.txt index 11a7894..a55932d 100644 --- a/sfqd-core/src/main/api/public-api.txt +++ b/sfqd-core/src/main/api/public-api.txt @@ -29,6 +29,7 @@ FIELD owner=io.github.pzhin.sfqd.CancelResult name=CANCELLED modifiers=[public, FIELD owner=io.github.pzhin.sfqd.CancelResult name=NOT_LIVE modifiers=[public, static, final] type=io.github.pzhin.sfqd.CancelResult enumConstant=true synthetic=false FIELD owner=io.github.pzhin.sfqd.CancelResult name=TOO_LATE_ALREADY_DISPATCHED modifiers=[public, static, final] type=io.github.pzhin.sfqd.CancelResult enumConstant=true synthetic=false FIELD owner=io.github.pzhin.sfqd.CancellationAccounting name=CHARGE_RESERVED_COST modifiers=[public, static, final] type=io.github.pzhin.sfqd.CancellationAccounting enumConstant=true synthetic=false +FIELD owner=io.github.pzhin.sfqd.CancellationAccounting name=REFUND_CANCELLED_COST modifiers=[public, static, final] type=io.github.pzhin.sfqd.CancellationAccounting enumConstant=true synthetic=false FIELD owner=io.github.pzhin.sfqd.CloseFlowResult name=CLOSED modifiers=[public, static, final] type=io.github.pzhin.sfqd.CloseFlowResult enumConstant=true synthetic=false FIELD owner=io.github.pzhin.sfqd.CloseFlowResult name=FAIRNESS_DEBT_ACTIVE modifiers=[public, static, final] type=io.github.pzhin.sfqd.CloseFlowResult enumConstant=true synthetic=false FIELD owner=io.github.pzhin.sfqd.CloseFlowResult name=FLOW_ACTIVE modifiers=[public, static, final] type=io.github.pzhin.sfqd.CloseFlowResult enumConstant=true synthetic=false diff --git a/sfqd-core/src/main/java/io/github/pzhin/sfqd/CancellationAccounting.java b/sfqd-core/src/main/java/io/github/pzhin/sfqd/CancellationAccounting.java index 0398258..41dd386 100644 --- a/sfqd-core/src/main/java/io/github/pzhin/sfqd/CancellationAccounting.java +++ b/sfqd-core/src/main/java/io/github/pzhin/sfqd/CancellationAccounting.java @@ -3,8 +3,8 @@ /** * Defines how a successful queued-job cancellation affects virtual fairness accounting. * - *
The current implementation supports only {@link #CHARGE_RESERVED_COST}. The explicit policy prevents a queued - * cancellation from being mistaken for a free rollback of the job's scheduling tags. + *
{@link #CHARGE_RESERVED_COST} preserves accepted virtual debt and remains the default. + * {@link #REFUND_CANCELLED_COST} is opt-in and prospectively recomputes later queued work of the same flow. */ public enum CancellationAccounting { /** @@ -15,5 +15,14 @@ public enum CancellationAccounting { * performs its global idle reset. Completed-work fairness guarantees do not apply to traces containing a * cancellation. */ - CHARGE_RESERVED_COST + CHARGE_RESERVED_COST, + + /** + * Refunds a cancelled queued job's virtual cost from later queued work of the same flow. + * + *
Admission reserves sufficient exact-arithmetic budget so every later queued cancellation can recompute the + * affected suffix without a numeric failure. Cancellation does not revise earlier dispatch decisions, and + * completed-work fairness guarantees do not apply to traces containing cancellation. + */ + REFUND_CANCELLED_COST } diff --git a/sfqd-core/src/main/java/io/github/pzhin/sfqd/SchedulerConfig.java b/sfqd-core/src/main/java/io/github/pzhin/sfqd/SchedulerConfig.java index e71c9b6..4e0ef1e 100644 --- a/sfqd-core/src/main/java/io/github/pzhin/sfqd/SchedulerConfig.java +++ b/sfqd-core/src/main/java/io/github/pzhin/sfqd/SchedulerConfig.java @@ -13,13 +13,18 @@ * {@code [0, depth]}, registered flows are in {@code [0, maxFlows]}, and * queued plus running jobs are in {@code [0, maxLiveJobs]}. * + *
{@link CancellationAccounting#CHARGE_RESERVED_COST} retains the historical admission domain and is selected by + * the three-argument constructor. The opt-in {@link CancellationAccounting#REFUND_CANCELLED_COST} policy may reject + * an otherwise representable enqueue with {@link EnqueueResult.Rejected#NUMERIC_LIMIT} when the complete prospective + * flow queue would not reserve enough exact-arithmetic budget for every later cancellation. + * * @param depth maximum dispatched-but-not-completed issue depth, in * {@code [1, 1_000_000]} * @param maxFlows maximum simultaneously registered flows, in * {@code [1, Integer.MAX_VALUE]} * @param maxLiveJobs maximum queued plus running jobs, in * {@code [depth, Integer.MAX_VALUE]} - * @param cancellationAccounting virtual fairness accounting policy for cancelled queued jobs + * @param cancellationAccounting immutable virtual fairness accounting and admission policy for cancelled queued jobs * @param weightDomain registration policy for fixed flow weights */ public record SchedulerConfig( diff --git a/sfqd-core/src/main/java/io/github/pzhin/sfqd/SfqdScheduler.java b/sfqd-core/src/main/java/io/github/pzhin/sfqd/SfqdScheduler.java index 7d28bf5..1faaf63 100644 --- a/sfqd-core/src/main/java/io/github/pzhin/sfqd/SfqdScheduler.java +++ b/sfqd-core/src/main/java/io/github/pzhin/sfqd/SfqdScheduler.java @@ -18,8 +18,10 @@ *
The scheduler only makes admission and dispatch decisions. It neither executes jobs nor owns an executor, * thread pool, resource pool, or completion callback. Fairness is measured against the caller-supplied cost and the * registered flow weight, not against unknown actual execution time. - * Completed-work fairness guarantees do not apply to traces containing cancellation because the supported - * {@link CancellationAccounting#CHARGE_RESERVED_COST} policy retains cancelled jobs' virtual cost until global idle. + * Completed-work fairness guarantees do not apply to traces containing cancellation. The default + * {@link CancellationAccounting#CHARGE_RESERVED_COST} policy retains cancelled jobs' virtual cost until global idle; + * the opt-in {@link CancellationAccounting#REFUND_CANCELLED_COST} policy changes only future queued tags and does not + * revise earlier dispatch decisions. * *
All public operations are linearizable and may be invoked concurrently without external synchronization. This * baseline uses one private lock: a successful mutating operation linearizes when its complete state transition is @@ -29,10 +31,14 @@ * {@link CancelResult#NOT_LIVE} alone intentionally does not reveal the terminal cause. Completion and cancellation * successes are exactly once. * - *
Queued jobs are linked per flow and only each flow head is globally ordered. Enqueue and cancellation of a flow - * head are {@code O(log B)} for {@code B} backlogged flows; cancellation of a non-head and completion are expected - * {@code O(1)}. A batch selecting {@code m} jobs is {@code O(m log B + m)}. Snapshot is {@code O(1)}. The normative - * idle reset is {@code O(registeredFlows)}. A canonically triggered exact-tag rebase is + *
Queued jobs are linked per flow and only each flow head is globally ordered. Under charge-reserved accounting, + * enqueue and cancellation of a flow head are {@code O(log B)} for {@code B} backlogged flows; cancellation of a + * non-head is expected {@code O(1)}. Under refund accounting, enqueue scans the {@code K} queued jobs of its flow to + * reserve numeric budget and costs {@code O(K + log B)} when it creates a backlogged head or {@code O(K)} otherwise. + * Refund cancellation costs {@code O(K + log B)} when the indexed head changes and {@code O(K)} otherwise, for the + * suffix of {@code K} later jobs. Completion is expected {@code O(1)}. A batch selecting {@code m} jobs is + * {@code O(m log B + m)}. Snapshot is {@code O(1)}. The normative idle reset is + * {@code O(registeredFlows)}. A canonically triggered exact-tag rebase is * {@code O(queuedJobs + registeredFlows)} and is computed transactionally before it becomes observable. Internal * records are bounded by configured live-job and registration limits; terminal tombstones are not retained. * @@ -172,6 +178,10 @@ public CloseFlowResult closeFlow(FlowHandle flowHandle) { * tag, and every registered flow finish tag, then admission is retried. The rebase and accepted job commit as one * transition only when all transient and persistent results fit. Otherwise {@code NUMERIC_LIMIT} discards the * entire temporary computation: no tag, index, counter, sequence, or other observable state changes. + * Under {@link CancellationAccounting#REFUND_CANCELLED_COST}, admission additionally verifies that every tag + * reachable by later queued cancellations fits the same budget. This check scans the prospective flow queue and + * can return {@code NUMERIC_LIMIT} even when the candidate's immediate start and finish tags fit. A planned rebase + * must preserve this property for every affected queued flow or the whole enqueue remains a no-op. * * @param flowHandle registered flow capability * @param jobId stable non-null identifier, unique among live jobs @@ -211,6 +221,10 @@ public EnqueueResult enqueue(FlowHandle flowHandle, J jobId, P payload, long cos if (computation == null) { return EnqueueResult.Rejected.NUMERIC_LIMIT; } + if (config.cancellationAccounting() == CancellationAccounting.REFUND_CANCELLED_COST + && !refundClosureFitsAfterEnqueue(flow, computation.start, cost, computation.rebase)) { + return EnqueueResult.Rejected.NUMERIC_LIMIT; + } if (computation.rebase != null) { commitRebase(computation.rebase); } @@ -242,11 +256,13 @@ public EnqueueResult enqueue(FlowHandle flowHandle, J jobId, P payload, long cos * {@code NOT_LIVE} alone does not identify the winner or terminal cause. * *
Fairness accounting warning: under
- * {@link CancellationAccounting#CHARGE_RESERVED_COST}, cancellation does not reduce the flow's finish history and
- * does not recompute tags of its later jobs. The cancelled supplied cost remains a virtual charge until all live
- * jobs leave the scheduler and the global busy period ends. Completed-work fairness guarantees therefore do not
- * apply to traces containing cancellation. Workloads with frequent deadline or timeout cancellations must account
- * for the resulting dispatch delay.
+ * {@link CancellationAccounting#CHARGE_RESERVED_COST}, cancellation does not reduce the flow's finish history or
+ * recompute later tags, so the cancelled cost remains charged until global idle. Under
+ * {@link CancellationAccounting#REFUND_CANCELLED_COST}, cancellation recomputes every later queued job of the same
+ * flow from the cancelled job's start and updates the flow's finish history. Admission has already reserved the
+ * exact-arithmetic budget, so a queued cancellation has no numeric rejection or fallback. Recalculation occurs
+ * under the scheduler lock, does not change other flows, and does not revise earlier dispatch decisions.
+ * Completed-work fairness guarantees do not apply to cancellation traces under either policy.
*
* @param handle opaque job capability
* @return cancellation outcome at the operation's linearization point
@@ -258,7 +274,10 @@ public CancelResult cancel(JobHandle handle) {
try {
QueuedJob> commandTraces() {
});
}
+ @Provide
+ Arbitrary