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
40 changes: 38 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,8 +41,8 @@ Legacy Before/After hooks are not resource scopes: migrate resource acquisition
into a Test/Run callback before using the lifecycle helpers.

Use ordinary named `t.Run` cases (or `TestEach`) for repeated contracts. Hosted
execution can run packages concurrently and remains serial inside each package; `Parallel` is
only effective with `RunAsTest`. Do not make acceptance correctness depend on
execution keeps steps serial within each case; `Parallel` is only effective with
`RunAsTest`. Independent top-level cases can explicitly opt in as described below. Do not make acceptance correctness depend on
subtest scheduling, and do not use a parent's `defer` to release resources needed
by native parallel children. Cleanup waits for those children in native runs.
These callbacks cannot recover resources after process termination; persistent
Expand All @@ -66,3 +66,39 @@ consumers must handle incomplete coverage explicitly before enabling skips.
Any `Fail`, `Errorf`, fatal error or panic, including during cleanup, remains a
failure. A prerequisite-specific best-effort cleanup policy must report warnings
explicitly instead of weakening these generic failure semantics.

## Bounded independent cases

Register an independent case with `ConcurrentTest(name, tester)` instead of
`Test`. This is an author declaration that its fixture IDs, mutations and hooks
are safe to overlap with other opted-in cases. Never split dependent steps merely
to get concurrency: `t.Run` children still execute sequentially in hosted mode.

Create **one** `executor := testy.NewCaseExecutor(2)` per hosted worker and share
it in `RunOptions{CaseExecutor: executor}` across all `RunWithContext` or
`RunPackageWithOptions` calls. A case owns admission from `BeforeTest` through
body, children, cleanup and `AfterTest`. Non-opted-in cases and package hooks take
exclusive admission, preventing overlap with other cases in the same executor.
Package teardown waits for all of its admitted work. Hooks themselves are still
legacy non-resource scopes; migrate fixture acquisition into case callbacks.

A nil executor preserves existing serial-in-package behavior and existing package
concurrency. An executor of size one serializes all shared case lifecycles.
Native `RunAsTest` uses Go's `-parallel` limit for `ConcurrentTest`; existing manual
`Parallel` semantics are unchanged. Native `AfterPackage` now runs in enclosing
Go test cleanup, after parallel children and their cleanup, without changing test
names. A native bootstrap should contain one registered package, as before.

`RunPackageWithOptions(ctx, packageName, opts)` propagates caller cancellation,
then **waits** for admitted cases, their cleanup and hooks before returning
`ctx.Err()`. It cannot preempt non-cooperative callbacks or clean up after a killed
process. Cleanup I/O needs a fresh bounded context. Infra retries can still repeat
side effects; orchestration must not claim exactly-once execution.

The executor is not a fleet-wide limit. Deployment must bound simultaneous worker
replicas, surge and other test runners before increasing capacity. Keep a serial
rollback and drain old workers before enabling overlap. `ListCases` exposes a
copy of canonical case identities/indices and opt-in flags; reports retain this
order regardless of completion order. `TestResult.QueueDur` records delay from
case discovery after package setup to admission; `Dur` covers the admitted case
lifecycle, including its test hooks, separately from that queue delay.
66 changes: 66 additions & 0 deletions case_executor.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
package testy

import (
"context"
"fmt"
)

// CaseExecutor bounds admitted case lifecycles across all packages/runs sharing
// this instance. Share one per hosted worker, not one per package. It is not a
// cross-process limit. Non-ConcurrentTest cases and package hooks are exclusive.
// A permit covers setup, body, ordered children, cleanup and AfterTest.
type CaseExecutor struct {
gate chan struct{}
slots chan struct{}
}

func NewCaseExecutor(limit int) *CaseExecutor {
if limit < 1 {
limit = 1
}
return &CaseExecutor{gate: make(chan struct{}, 1), slots: make(chan struct{}, limit)}
}

// Serialize admission, not execution, so two exclusive waiters cannot each hold
// half the permits and deadlock. Cancellation rolls back partial acquisition.
func (e *CaseExecutor) acquire(ctx context.Context, concurrent bool) (func(), error) {
if err := ctx.Err(); err != nil {
return nil, err
}
if e == nil {
return func() {}, nil
}
if cap(e.slots) == 0 || cap(e.gate) != 1 {
return nil, fmt.Errorf("testy: CaseExecutor must be constructed with NewCaseExecutor")
}
select {
case e.gate <- struct{}{}:
case <-ctx.Done():
return nil, ctx.Err()
}
defer func() { <-e.gate }()
count := 1
if !concurrent {
count = cap(e.slots)
}
acquired := 0
release := func() {
for i := 0; i < acquired; i++ {
<-e.slots
}
}
for acquired < count {
select {
case e.slots <- struct{}{}:
acquired++
case <-ctx.Done():
release()
return nil, ctx.Err()
}
}
if err := ctx.Err(); err != nil {
release()
return nil, err
}
return release, nil
}
Loading
Loading