Repository navigation
Let dev builds shorten the connector's handoff grace, and test requests from before a restart #825
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Closed
Closed
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,108 @@ | ||
| //go:build linux || darwin | ||
|
|
||
| package connect | ||
|
|
||
| import ( | ||
| "encoding/json" | ||
| "strconv" | ||
| "testing" | ||
| "time" | ||
|
|
||
| "github.com/stretchr/testify/assert" | ||
| "github.com/stretchr/testify/require" | ||
|
|
||
| "github.com/basecamp/basecamp-cli/internal/connector/fakebasecamp" | ||
| ) | ||
|
|
||
| // The handoff grace the after-grace test runs with, and how long before it | ||
| // is emitted the fake says its request was made. The age is past the grace | ||
| // and well inside connector.HandoffGrace, the minute a release build waits: | ||
| // the request is discarded only because the shortened grace is in effect. | ||
| const ( | ||
| shortGrace = 2 * time.Second | ||
| requestAge = 10 * time.Second | ||
| ) | ||
|
|
||
| // MsgBeforeThisRun is logged when the handoff closes a request made before | ||
| // the run's grace began, without writing it. | ||
| const MsgBeforeThisRun = "connector: a request from before this run started is not handed off" | ||
|
|
||
| // The connector admits a request but crashes before it is handed off: here | ||
| // it is held with --hold, so nothing is handed off, and killed outright. | ||
| // The operator releases the hold. The connector starts again only after the | ||
| // grace has passed since the request was made, and by then nobody is there | ||
| // to take it: the request is closed as before_this_run, and no request line | ||
| // is written for it. | ||
| // | ||
| // The fake stamps the request requestAge before it is emitted, rather than | ||
| // the test waiting out the grace. That is the same request to the | ||
| // connector: whether it hands a record off depends only on the event's | ||
| // created_at, as the feed served it, against when the run started | ||
| // (handOff in internal/connector/handoff.go), and nothing else in intake or | ||
| // admission reads created_at. The age exceeds the grace on the restart | ||
| // however fast the steps between run, and no step waits on a timer. | ||
| // | ||
| // Within the grace, the same steps hand the request off; that is the | ||
| // knob-free failure scenarios' test, under the default grace. | ||
| func TestARequestMadeBeforeTheGraceIsNotHandedOffOnARestart(t *testing.T) { | ||
| if !devBuild { | ||
| t.Skip("the handoff grace can be shortened only in a dev build (go test -tags dev, as make test and CI run it); a release build reads no environment for it") | ||
| } | ||
| t.Parallel() | ||
| h := NewHarness(t, world(), fakebasecamp.WithClock(func() time.Time { return time.Now().Add(-requestAge) })) | ||
| h.env = append(h.env, "BASECAMP_CONNECT_HANDOFF_GRACE="+shortGrace.String()) | ||
| h.Setup(t) | ||
|
|
||
| held := h.Connect(t, "--hold") | ||
| held.WaitStreaming(t, 1) | ||
| ev := comment(h, fakebasecamp.OperatorID, askAgent(), fakebasecamp.Live) | ||
| out := held.WaitFor(t, "the event line", func(o Output) bool { return len(o.EventsFor(ev.ID)) > 0 }) | ||
| events := out.EventsFor(ev.ID) | ||
| require.Len(t, events, 1) | ||
| require.Equal(t, "admitted", events[0].State, "a trusted mention is admitted under the hold") | ||
| held.Kill(t) | ||
| out = held.Wait(t) | ||
| require.Empty(t, out.RequestsFor(ev.ID), "nothing is handed off under the hold") | ||
|
|
||
| h.MustRun(t, "connect", "release", "-P", Agent) | ||
|
|
||
| // The request's age as the connector will judge it, from the created_at | ||
| // the feed served: it is already past the grace, before the restart. | ||
| pointers := out.PointersFor(ev.ID) | ||
| require.Len(t, pointers, 1) | ||
| createdAt, err := time.Parse(time.RFC3339Nano, pointers[0].CreatedAt) | ||
| require.NoError(t, err) | ||
| require.Greater(t, time.Since(createdAt), shortGrace, "the request is older than the grace before the connector starts again") | ||
|
|
||
| restarted := h.Connect(t) | ||
| id := strconv.FormatInt(ev.ID, 10) | ||
| restarted.WaitFor(t, "the request closed as before this run", func(o Output) bool { | ||
| for _, l := range o.Logged(MsgBeforeThisRun) { | ||
| if l.Attrs["event_id"] == id { | ||
| return true | ||
| } | ||
| } | ||
| return false | ||
| }) | ||
| out = restarted.Stop(t) | ||
|
|
||
| assert.Empty(t, out.Requests(), "no request line on the restart") | ||
| overridden := out.Logged("connector: the handoff grace is overridden for this dev build") | ||
| require.Len(t, overridden, 1) | ||
| assert.Equal(t, shortGrace.String(), overridden[0].Attrs["grace"]) | ||
|
|
||
| // The ledger agrees: the record is closed, so no later run hands it | ||
| // off either. The connector logs the close only once it is written. | ||
| res := h.MustRun(t, "connect", "status", "-P", Agent, "--json") | ||
| var status struct { | ||
| Data struct { | ||
| Status struct { | ||
| Queues map[string]int `json:"queues"` | ||
| } `json:"status"` | ||
| } `json:"data"` | ||
| } | ||
| require.NoError(t, json.Unmarshal([]byte(res.Stdout), &status), res.Stdout) | ||
| require.NotNil(t, status.Data.Status.Queues, "status reports its queues\n%s", res.Stdout) | ||
| assert.Zero(t, status.Data.Status.Queues["admitted"], res.Stdout) | ||
| assert.Zero(t, status.Data.Status.Queues["queued"], res.Stdout) | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,37 @@ | ||
| //go:build dev | ||
|
|
||
| package commands | ||
|
|
||
| import ( | ||
| "fmt" | ||
| "os" | ||
| "time" | ||
| ) | ||
|
|
||
| // connectHandoffGraceEnv sets, in a dev build only, how long before the run | ||
| // started a request may have been made and still be handed off, as a Go | ||
| // duration. The end-to-end tests shorten it, so a run that starts after the | ||
| // grace has passed is one they can reach in seconds rather than a minute. A | ||
| // release build reads no environment for it at all: see | ||
| // connect_handoff_grace_stub.go. | ||
| const connectHandoffGraceEnv = "BASECAMP_CONNECT_HANDOFF_GRACE" | ||
|
|
||
| // connectHandoffGrace is the grace connectHandoffGraceEnv sets, zero when | ||
| // it is unset, so the handoff's own default applies. | ||
| func connectHandoffGrace() (time.Duration, error) { | ||
| return parseConnectHandoffGrace(os.Getenv(connectHandoffGraceEnv)) | ||
| } | ||
|
|
||
| func parseConnectHandoffGrace(v string) (time.Duration, error) { | ||
| if v == "" { | ||
| return 0, nil | ||
| } | ||
| d, err := time.ParseDuration(v) | ||
| if err != nil { | ||
| return 0, fmt.Errorf("%s is not a duration: %w", connectHandoffGraceEnv, err) | ||
| } | ||
| if d <= 0 { | ||
| return 0, fmt.Errorf("%s must be a positive duration, not %s", connectHandoffGraceEnv, v) | ||
| } | ||
| return d, nil | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,39 @@ | ||
| //go:build dev | ||
|
|
||
| package commands | ||
|
|
||
| import ( | ||
| "testing" | ||
| "time" | ||
|
|
||
| "github.com/stretchr/testify/assert" | ||
| "github.com/stretchr/testify/require" | ||
| ) | ||
|
|
||
| func TestConnectHandoffGraceParsesAGoDuration(t *testing.T) { | ||
| for in, want := range map[string]time.Duration{ | ||
| "": 0, | ||
| "2s": 2 * time.Second, | ||
| "750ms": 750 * time.Millisecond, | ||
| "1m30s": 90 * time.Second, | ||
| } { | ||
| got, err := parseConnectHandoffGrace(in) | ||
| require.NoError(t, err, "%q", in) | ||
| assert.Equal(t, want, got, "%q", in) | ||
| } | ||
| } | ||
|
|
||
| func TestConnectHandoffGraceRefusesWhatIsNotAPositiveDuration(t *testing.T) { | ||
| for _, in := range []string{"2", "soon", "0s", "-1s"} { | ||
| _, err := parseConnectHandoffGrace(in) | ||
| require.Error(t, err, "%q", in) | ||
| assert.Contains(t, err.Error(), connectHandoffGraceEnv, "%q", in) | ||
| } | ||
| } | ||
|
|
||
| func TestConnectHandoffGraceReadsTheEnvironment(t *testing.T) { | ||
| t.Setenv(connectHandoffGraceEnv, "3s") | ||
| got, err := connectHandoffGrace() | ||
| require.NoError(t, err) | ||
| assert.Equal(t, 3*time.Second, got) | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,10 @@ | ||
| //go:build !dev | ||
|
|
||
| package commands | ||
|
|
||
| import "time" | ||
|
|
||
| // connectHandoffGrace is always zero in a release build, so the handoff's | ||
| // own default applies: only a dev build lets the environment change it | ||
| // (connect_handoff_grace_dev.go). | ||
| func connectHandoffGrace() (time.Duration, error) { return 0, nil } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
|
|
@@ -138,6 +138,11 @@ func runConnect(cmd *cobra.Command, f *connectRunFlags) error { | |||||
| if err != nil { | ||||||
| return err | ||||||
| } | ||||||
| // Only a dev build reads it; a release build's is always zero. | ||||||
| grace, err := connectHandoffGrace() | ||||||
| if err != nil { | ||||||
| return output.ErrUsage(err.Error()) | ||||||
| } | ||||||
|
|
||||||
| path, err := setup.Path(config.GlobalConfigDir(), name) | ||||||
| if err != nil { | ||||||
|
|
@@ -184,6 +189,9 @@ func runConnect(cmd *cobra.Command, f *connectRunFlags) error { | |||||
| } | ||||||
|
|
||||||
| logger := slog.New(slog.NewTextHandler(cmd.ErrOrStderr(), nil)) | ||||||
| if grace > 0 { | ||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P3: Shadow runs never invoke Prompt for AI agents
Suggested change
|
||||||
| logger.Warn("connector: the handoff grace is overridden for this dev build", "grace", grace, "default", connector.HandoffGrace) | ||||||
| } | ||||||
| tokens := &managerTokens{mgr: app.Auth} | ||||||
| // Every read below needs a token first. One the running feed would | ||||||
| // wait for is waited for here too, rather than ending the start. | ||||||
|
|
@@ -358,7 +366,7 @@ func runConnect(cmd *cobra.Command, f *connectRunFlags) error { | |||||
| runPart("handoff", func(ctx context.Context) error { | ||||||
| return connector.RunHandoff(ctx, connector.HandoffOptions{ | ||||||
| Ledger: ledger, Served: served.Current, Buckets: buckets, AgentID: agentID, | ||||||
| Lines: lines, Logger: logger, Started: started, | ||||||
| Lines: lines, Logger: logger, Started: started, Grace: grace, | ||||||
| }) | ||||||
| }) | ||||||
| } | ||||||
|
|
||||||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
P2: An explicit zero override is rejected, despite zero being the documented way to keep the one-minute default. Reject only negative durations so
0scan reach the handoff's default behavior.Prompt for AI agents