Skip to content

Commit 520f684

Browse files
authored
Merge pull request #4 from posit-dev/address-review-issues
Address SDK review issues: context, type safety, and robustness
2 parents 54d9ef9 + 90f4a1c commit 520f684

File tree

25 files changed

+449
-316
lines changed

25 files changed

+449
-316
lines changed

.gitignore

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,11 @@ go.work
3030
examples/inmemory/inmemory
3131
examples/inmemory/inmemory-plugin
3232

33+
# Built binaries in root
34+
/inmemory
35+
/smoketest
36+
cmd/smoketest/smoketest
37+
3338
# Temporary files
3439
*.tmp
3540
*.log

CHANGELOG.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,22 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
99

1010
### Changed
1111
- **BREAKING**: `cache.NewJobCache` no longer accepts a `dir` parameter. The SDK now defaults to in-memory caching, which aligns with how Launcher plugins are expected to work: the scheduler owns job state, and plugins populate the cache during `Bootstrap()` and keep it in sync via periodic polling.
12+
- **BREAKING**: Add `context.Context` as the first parameter to all non-streaming `Plugin` methods (`SubmitJob`, `GetJob`, `GetJobs`, `ControlJob`, `GetJobNetwork`, `ClusterInfo`) and extension interfaces (`Bootstrap`, `GetClusters`). Streaming methods already accepted context.
13+
- **BREAKING**: `Job.ID` type changed from `string` to `api.JobID` for end-to-end type safety. Since `api.JobID` is a named `string` type, JSON serialization and literal assignments work unchanged.
14+
- **BREAKING**: Cache public methods (`Lookup`, `Update`, `WriteJob`, `RunningJobContext`, `StreamJobStatus`) now accept `api.JobID` instead of `string`.
15+
- **BREAKING**: Conformance and plugintest helpers updated to use `api.JobID` (`SubmitJob` returns `api.JobID`; `GetJob`, `ControlJob`, `WaitForStatus`, `FindJobByID`, `AssertJobID`, `NewJobWithID`, `WithID` accept `api.JobID`).
16+
- Replace `goto`-based poll loops with idiomatic `for`+`select` loops in cache and protocol packages
17+
- Add panic recovery to cache background goroutine
18+
- Use non-blocking channel sends to prevent deadlocks under load
19+
- Add nil guards to stream `ResponseWriter` methods
20+
- Convert `Prune` to range-over-func syntax
21+
22+
### Fixed
23+
- File handle leak in logger when debug log creation fails
24+
- Race window in `RunningJobContext` with post-subscribe recheck
25+
- JSON unmarshal error in `requestFromJSON` now handled instead of silently discarded
26+
- Go version requirement corrected from 1.25 to 1.24 in README
27+
- `WithMemory()` reference corrected to `WithLimit()` in CONTRIBUTING.md
1228

1329
### Removed
1430
- BoltDB (`go.etcd.io/bbolt`) dependency — in-memory caching is now the standard approach

CONTRIBUTING.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -298,7 +298,7 @@ func ExampleJobBuilder() {
298298
job := plugintest.NewJob().
299299
WithUser("alice").
300300
WithCommand("python train.py").
301-
WithMemory("8GB").
301+
WithLimit("memory", "8GB").
302302
Build()
303303

304304
fmt.Println(job.User)

README.md

Lines changed: 18 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -56,8 +56,8 @@ type MyPlugin struct {
5656
nextID int32
5757
}
5858

59-
func (p *MyPlugin) SubmitJob(w launcher.ResponseWriter, user string, job *api.Job) {
60-
job.ID = fmt.Sprintf("job-%d", atomic.AddInt32(&p.nextID, 1))
59+
func (p *MyPlugin) SubmitJob(ctx context.Context, w launcher.ResponseWriter, user string, job *api.Job) {
60+
job.ID = api.JobID(fmt.Sprintf("job-%d", atomic.AddInt32(&p.nextID, 1)))
6161
job.Status = api.StatusPending
6262
if err := p.cache.AddOrUpdate(job); err != nil {
6363
w.WriteError(err)
@@ -66,8 +66,8 @@ func (p *MyPlugin) SubmitJob(w launcher.ResponseWriter, user string, job *api.Jo
6666
p.cache.WriteJob(w, user, job.ID)
6767
}
6868

69-
func (p *MyPlugin) GetJob(w launcher.ResponseWriter, user string, id api.JobID, fields []string) {
70-
p.cache.WriteJob(w, user, string(id))
69+
func (p *MyPlugin) GetJob(_ context.Context, w launcher.ResponseWriter, user string, id api.JobID, fields []string) {
70+
p.cache.WriteJob(w, user, id)
7171
}
7272

7373
// ... implement other Plugin methods ...
@@ -111,16 +111,16 @@ All plugins implement the `launcher.Plugin` interface:
111111

112112
```go
113113
type Plugin interface {
114-
SubmitJob(w ResponseWriter, user string, job *api.Job)
115-
GetJob(w ResponseWriter, user string, id api.JobID, fields []string)
116-
GetJobs(w ResponseWriter, user string, filter *api.JobFilter, fields []string)
117-
ControlJob(w ResponseWriter, user string, id api.JobID, op api.JobOperation)
114+
SubmitJob(ctx context.Context, w ResponseWriter, user string, job *api.Job)
115+
GetJob(ctx context.Context, w ResponseWriter, user string, id api.JobID, fields []string)
116+
GetJobs(ctx context.Context, w ResponseWriter, user string, filter *api.JobFilter, fields []string)
117+
ControlJob(ctx context.Context, w ResponseWriter, user string, id api.JobID, op api.JobOperation)
118118
GetJobStatus(ctx context.Context, w StreamResponseWriter, user string, id api.JobID)
119119
GetJobStatuses(ctx context.Context, w StreamResponseWriter, user string)
120120
GetJobOutput(ctx context.Context, w StreamResponseWriter, user string, id api.JobID, outputType api.JobOutput)
121121
GetJobResourceUtil(ctx context.Context, w StreamResponseWriter, user string, id api.JobID)
122-
GetJobNetwork(w ResponseWriter, user string, id api.JobID)
123-
ClusterInfo(w ResponseWriter, user string)
122+
GetJobNetwork(ctx context.Context, w ResponseWriter, user string, id api.JobID)
123+
ClusterInfo(ctx context.Context, w ResponseWriter, user string)
124124
}
125125
```
126126

@@ -147,7 +147,12 @@ cache.StreamJobStatus(ctx, w, user, jobID)
147147
Write tests using the provided mocks and builders:
148148

149149
```go
150-
import "github.com/posit-dev/launcher-go-sdk/plugintest"
150+
import (
151+
"context"
152+
"testing"
153+
154+
"github.com/posit-dev/launcher-go-sdk/plugintest"
155+
)
151156

152157
func TestSubmitJob(t *testing.T) {
153158
w := plugintest.NewMockResponseWriter()
@@ -158,7 +163,7 @@ func TestSubmitJob(t *testing.T) {
158163
WithCommand("echo hello").
159164
Build()
160165

161-
plugin.SubmitJob(w, "alice", job)
166+
plugin.SubmitJob(context.Background(), w, "alice", job)
162167

163168
plugintest.AssertNoError(t, w)
164169
plugintest.AssertJobCount(t, w, 1)
@@ -189,7 +194,7 @@ Once we release v1.0.0, we will strictly maintain backwards compatibility within
189194

190195
## Requirements
191196

192-
- Go 1.25 or later
197+
- Go 1.24 or later
193198
- Linux, macOS (we test on Linux and macOS)
194199
- Workbench 2023.09.0 or later or Connect 2024.08.0 or later (for deployment)
195200

api/types.go

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -110,7 +110,11 @@ func (e *Error) Error() string {
110110
return e.Code.String()
111111
}
112112

113-
// Is makes comparisons with errors.Is() possible.
113+
// Is reports whether err matches e by error code. When either error has an
114+
// empty message, only the code is compared. This enables sentinel-style usage
115+
// where a code-only Error (e.g., ErrJobNotRunning) matches any Error with the
116+
// same code regardless of its message text. When both errors have non-empty
117+
// messages, they must also match for Is to return true.
114118
func (e *Error) Is(err error) bool {
115119
apiErr, ok := err.(*Error)
116120
if !ok {
@@ -271,10 +275,16 @@ func (o *JobOutput) UnmarshalText(text []byte) error {
271275
return nil
272276
}
273277

274-
// JobID is represented by a string, but may be "*" in some cases to indicate
275-
// any or all jobs.
278+
// JobID is a string-based job identifier. The special value "*" (see
279+
// [JobIDWildcard]) indicates any or all jobs in some API contexts.
276280
type JobID string
277281

282+
// JobIDWildcard is the sentinel value meaning "any or all jobs."
283+
const JobIDWildcard JobID = "*"
284+
285+
// IsWildcard reports whether the JobID is the wildcard value.
286+
func (id JobID) IsWildcard() bool { return id == JobIDWildcard }
287+
278288
// Container holds container fields for a Job.
279289
type Container struct {
280290
// The name of the container image to use.
@@ -326,7 +336,7 @@ func (e *Env) Set(s string) error {
326336
// Job is Launcher's representation of a job.
327337
type Job struct {
328338
// The unique ID of the Job.
329-
ID string `json:"id"`
339+
ID JobID `json:"id"`
330340

331341
// The cluster of the Job. Optional.
332342
Cluster string `json:"cluster,omitempty"`

api/types_test.go

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -180,6 +180,16 @@ func TestJobID(t *testing.T) {
180180
if string(id) != "test-123" {
181181
t.Errorf("JobID conversion failed: got %q, want %q", string(id), "test-123")
182182
}
183+
184+
if id.IsWildcard() {
185+
t.Error("regular JobID should not be wildcard")
186+
}
187+
if !JobIDWildcard.IsWildcard() {
188+
t.Error("JobIDWildcard should be wildcard")
189+
}
190+
if string(JobIDWildcard) != "*" {
191+
t.Errorf("JobIDWildcard = %q, want %q", string(JobIDWildcard), "*")
192+
}
183193
}
184194

185195
func TestJobOutput(t *testing.T) {

0 commit comments

Comments
 (0)