Skip to content
Draft
Show file tree
Hide file tree
Changes from 4 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
41 changes: 40 additions & 1 deletion pkg/connectorrunner/runner.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import (
"path/filepath"
"strings"
"sync"
"syscall"
"time"

"github.com/conductorone/baton-sdk/pkg/bid"
Expand Down Expand Up @@ -48,6 +49,42 @@ type connectorRunner struct {

var ErrSigTerm = errors.New("context cancelled by process shutdown")

// shutdownDrainTimeout bounds how long run() waits, on cancellation, for
// in-flight task goroutines to finish before returning. Each task goroutine
// is untracked (dispatched via a bare `go func`) and Close() — called
// immediately after Run() returns by every caller of this runner — tears
// down the connector client via c.cw.Close() with no synchronization against
// those goroutines. Without a drain, a SIGTERM can still race the very
// checkpoint this package exists to protect: Run() returns as soon as ctx is
// Done, Close() runs, and the in-flight sync's forced checkpoint write (see
// sync/parallel_syncer.go's handleOperationError, bounded to 15s) can be torn
// out from under it.
//
// This budget intentionally only targets that checkpoint write, not a full
// c1z finalize: syncer.Close() (called by the task handler on every path,
// including sync failure) bounds its own detached finalize by
// dotc1z.FinalizeTimeout, which defaults to 1 hour — no realistic
// termination grace period can wait that out, and this drain does not try
// to. It only aims to let the smaller, higher-priority checkpoint write
// complete before the connector client is torn down.
const shutdownDrainTimeout = 25 * time.Second

// drainInFlightTasks waits for every currently-held semaphore slot to be
// released — i.e. every dispatched task goroutine (see the `go func(t
// *v1.Task)` dispatch below) to return — bounded by shutdownDrainTimeout.
// Called right before run() returns on cancellation, so the caller's
// deferred Close() (which tears down the connector client) doesn't race an
// in-flight sync that's still writing its forced checkpoint.
func (c *connectorRunner) drainInFlightTasks(ctx context.Context, sem *semaphore.Weighted, l *zap.Logger) {
drainCtx, cancelDrain := context.WithTimeout(context.WithoutCancel(ctx), shutdownDrainTimeout)
defer cancelDrain()
if err := sem.Acquire(drainCtx, int64(c.taskConcurrency)); err != nil {
l.Warn("runner: shutdown drain timed out; in-flight tasks may not have finished checkpointing", zap.Error(err))
return
}
sem.Release(int64(c.taskConcurrency))
}

// setupPersistentLog ensures that a log file on disk is created,
// when required by either the stored Manager or by a Task.
// A log file created by a stored Manager persists for our entire run,
Expand Down Expand Up @@ -138,7 +175,7 @@ func (c *connectorRunner) Run(ctx context.Context) error {
}

sigChan := make(chan os.Signal, 1)
signal.Notify(sigChan, os.Interrupt)
signal.Notify(sigChan, os.Interrupt, syscall.SIGTERM)
Comment thread
sergiocorral-conductorone marked this conversation as resolved.
Comment thread
sergiocorral-conductorone marked this conversation as resolved.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[major] [Major] Trapping SIGTERM is the right call, but nothing waits for the work it interrupts, so the checkpoint this PR forces can still be lost to process exit — in exactly the deployment shape the PR targets.

In the long-lived (non-oneShot) mode a hosted/containerized connector runs in, every task is dispatched to a goroutine (go func(t *v1.Task), runner.go:279) and nothing tracks it. Once this handler calls cancel(ErrSigTerm):

  1. run()'s loop select hits <-ctx.Done() and returns c.handleContextCancel(ctx) (runner.go:221-222), which returns nil for ErrSigTerm — and if it happens to be blocked in sem.Acquire(ctx, 1) that fails and returns via the same helper (:226-230). Either way it returns essentially immediately.
  2. Run() returns nil, the deferred r.Close(runCtx) in pkg/cli/commands.go:467 runs — and Close() doesn't wait for task goroutines either; it calls c.cw.Close(), tearing down the connector client out from under the in-flight sync.
  3. The command returns and the process exits, while the sync goroutine is still inside handleOperationError's forcedCheckpointTimeout (15s) write and, after that, the detached c1z finalize bounded by dotc1z.FinalizeTimeout() (default 1 hour, dotc1z/finalize_timeout.go:14).

So the forced checkpoint is written to a store whose finalize may never complete, and there is no WaitGroup/semaphore drain and no bounded shutdown window anywhere between the signal and process exit. The oneShot CLI path is fine (the task is processed synchronously at :265), which is likely why the new tests — which call syncer.Sync directly, never through the runner — don't surface this.

Suggest draining before returning from run(), e.g. acquire all slots on a detached, bounded context:

drainCtx, cancelDrain := context.WithTimeout(context.WithoutCancel(ctx), shutdownDrainTimeout)
defer cancelDrain()
if err := sem.Acquire(drainCtx, int64(c.taskConcurrency)); err != nil {
    l.Warn("runner: shutdown drain timed out; in-flight tasks may lose progress", zap.Error(err))
}

and not closing the connector wrapper until that drain returns. Whatever the drain budget is, it also needs to be reconcilable with FinalizeTimeout's 1h default, which no realistic grace period can accommodate — worth saying explicitly in the PR which of the two wins.

Happy to be told this is out of scope and belongs in a follow-up, but if so the PR's claim to recover the CXH-2121 hours should be qualified, because for the concurrent runner path the checkpoint's durability is still not guaranteed.

Verifier (codex): CONFIRM — adds SIGTERM handling but no shutdown drain/join for in-flight task goroutines, so Run() can return and teardown can race/beat checkpoint finalization.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed, and this is the most important catch on the PR so far — traced the exact race: run() returns almost immediately on ctx.Done() via handleContextCancel, every caller (pkg/cli/commands.go:467) defers Close() right after Run() returns, and nothing tracked the task goroutine in between. Fixed in 7b4b712: added drainInFlightTasks, called before both cancellation return points in run(), which acquires the full semaphore weight on a detached context.WithoutCancel(ctx) bounded by a new 25s shutdownDrainTimeout — so it waits for every dispatched task goroutine to actually finish (or times out) before run() returns. Also added TestRun_DrainsInFlightTaskBeforeReturningOnCancellation with a task manager whose Process deliberately ignores ctx.Done() (mirroring the detached checkpoint write) — verified it fails without the drain and passes with it, including under -race. Documented in the PR body that this budget targets the checkpoint write specifically, not syncer.Close()'s own separately-bounded (1h default) finalize tail — no realistic grace period covers that, and I didn't want to imply otherwise.

go func() {
for range sigChan {
cancel(ErrSigTerm)
Expand Down Expand Up @@ -218,13 +255,15 @@ func (c *connectorRunner) run(ctx context.Context) error {
for !stopForLoop {
select {
case <-ctx.Done():
c.drainInFlightTasks(ctx, sem, l)
return c.handleContextCancel(ctx)
case <-time.After(nextCheckAfter):
l.Debug("runner: claiming worker")
// Acquire a worker slot before we call Next() so we don't claim a task before we can actually process it.
err = sem.Acquire(ctx, 1)
if err != nil {
l.Error("runner: error acquiring semaphore to claim worker", zap.Error(err))
c.drainInFlightTasks(ctx, sem, l)
return c.handleContextCancel(ctx)
}
l.Debug("runner: worker claimed, checking for next task")
Expand Down
97 changes: 97 additions & 0 deletions pkg/connectorrunner/runner_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,58 @@ func TestRun_TaskConcurrencyCapsProcessing(t *testing.T) {
}
}

// TestRun_DrainsInFlightTaskBeforeReturningOnCancellation covers the gap
// between catching SIGTERM and actually protecting an in-flight sync's
// forced checkpoint: every task is dispatched to an untracked goroutine
// (`go func(t *v1.Task)` in run()), and every caller of this runner closes
// the connector client (Close -> c.cw.Close()) immediately after Run()
// returns. Without draining, run() returns as soon as ctx is Done
// regardless of what that goroutine is still doing -- including a
// checkpoint write deliberately running on a context.WithoutCancel(ctx)
// scope specifically so cancellation doesn't cut it off.
//
// detachedTaskManager's Process ignores ctx.Done() (mirroring that
// detached-context write) and only returns when explicitly released, so
// this test can assert run() actually waits for it instead of just
// hoping the timing works out.
func TestRun_DrainsInFlightTaskBeforeReturningOnCancellation(t *testing.T) {
t.Parallel()

ctx, cancel := context.WithCancelCause(context.Background())
defer cancel(ErrSigTerm)

tm := newDetachedTaskManager()
runner := &connectorRunner{
cw: noopClientWrapper{},
tasks: tm,
taskConcurrency: 1,
}

errCh := make(chan error, 1)
go func() {
errCh <- runner.run(ctx)
}()

waitForStartedTasks(t, tm.started, 1)

cancel(ErrSigTerm)

select {
case <-errCh:
require.Fail(t, "run() returned before the in-flight task finished; the shutdown drain did not wait for it")
case <-time.After(100 * time.Millisecond):
}

close(tm.release)

select {
case err := <-errCh:
require.NoError(t, err)
case <-time.After(time.Second):
require.Fail(t, "timed out waiting for runner to stop after releasing the in-flight task")
}
}

func waitForStartedTasks(t *testing.T, started <-chan struct{}, count int) {
t.Helper()
for range count {
Expand Down Expand Up @@ -233,6 +285,51 @@ func (m *blockingTaskManager) GetTempDir() string {
return ""
}

// detachedTaskManager's Process deliberately does not select on ctx.Done()
// -- it mirrors a sync's forced checkpoint write, which runs on a
// context.WithoutCancel(ctx) scope specifically so the caller's cancellation
// doesn't cut it off. It only returns once explicitly released.
type detachedTaskManager struct {
nextCalls atomic.Int64
started chan struct{}
release chan struct{}
}

func newDetachedTaskManager() *detachedTaskManager {
return &detachedTaskManager{
started: make(chan struct{}, 1),
release: make(chan struct{}),
}
}

func (m *detachedTaskManager) Next(ctx context.Context) (*v1.Task, time.Duration, error) {
if err := ctx.Err(); err != nil {
return nil, 0, err
}
if m.nextCalls.Add(1) > 1 {
return nil, time.Hour, nil
}
return v1.Task_builder{
Id: "task-1",
Status: v1.Task_STATUS_PENDING,
Hello: &v1.Task_HelloTask{},
}.Build(), 0, nil
}

func (m *detachedTaskManager) Process(ctx context.Context, task *v1.Task, cc types.ConnectorClient) error {
m.started <- struct{}{}
<-m.release
return nil
}

func (m *detachedTaskManager) ShouldDebug() bool {
return false
}

func (m *detachedTaskManager) GetTempDir() string {
return ""
}

type noopClientWrapper struct{}

func (noopClientWrapper) C(ctx context.Context) (types.ConnectorClient, error) {
Expand Down
126 changes: 89 additions & 37 deletions pkg/sync/parallel_syncer.go
Original file line number Diff line number Diff line change
Expand Up @@ -161,9 +161,22 @@ func (s *syncer) parallelSync(
break
}

// Checked before the throttled checkpoint below: that checkpoint runs
// on the raw ctx, which may already be Done (a SIGTERM-driven
// cancellation cancels ctx itself, not just runCtx's derived
// deadline) -- checking cancellation first means a cancelled ctx
// never gets a chance to fail that write and return a raw,
// non-resumable error before this iteration reaches
// handleOperationError.
select {
case <-runCtx.Done():
return s.handleOperationError(ctx, runCtx, warnings, nil)
default:
}

err := s.Checkpoint(ctx, false)
if err != nil {
return warnings, err
return s.handleOperationError(ctx, runCtx, warnings, err)
}

// If we have more than 10 warnings and more than 10% of actions ended in a warning, exit the sync.
Expand All @@ -173,28 +186,6 @@ func (s *syncer) parallelSync(
return warnings, fmt.Errorf("%w: warnings: %v completed actions: %d", ErrTooManyWarnings, warnings, completedActionsCount)
}
}
select {
case <-runCtx.Done():
err = context.Cause(runCtx)
switch {
case errors.Is(err, context.DeadlineExceeded):
if s.recordStats {
l.Info("sync run duration has expired, exiting sync early", s.syncSummaryFields(trace.SpanFromContext(ctx))...)
} else {
l.Info("sync run duration has expired, exiting sync early", zap.String("sync_id", s.syncID))
}
// It would be nice to remove this once we're more confident in the checkpointing logic.
checkpointErr := s.Checkpoint(ctx, true)
if checkpointErr != nil {
l.Error("error checkpointing before exiting sync", zap.Error(checkpointErr))
}
return warnings, errors.Join(checkpointErr, ErrSyncNotComplete)
default:
l.Error("sync context cancelled", zap.String("sync_id", s.syncID), zap.Error(err))
return warnings, err
}
default:
}

switch stateAction.Op {
case InitOp:
Expand All @@ -220,7 +211,7 @@ func (s *syncer) parallelSync(
s.state.PushAction(ctx, Action{Op: SyncResourceTypesOp})
err = s.Checkpoint(ctx, true)
if err != nil {
return warnings, err
return s.handleOperationError(ctx, runCtx, warnings, err)
}
// Don't do grant expansion or external resources in partial syncs, as we likely lack related resources/entitlements/grants
continue
Expand All @@ -238,7 +229,7 @@ func (s *syncer) parallelSync(
s.state.SetNeedsExpansion()
err = s.Checkpoint(ctx, true)
if err != nil {
return warnings, err
return s.handleOperationError(ctx, runCtx, warnings, err)
}
continue
}
Expand All @@ -256,7 +247,7 @@ func (s *syncer) parallelSync(

err = s.Checkpoint(ctx, true)
if err != nil {
return warnings, err
return s.handleOperationError(ctx, runCtx, warnings, err)
}
continue

Expand Down Expand Up @@ -400,7 +391,7 @@ func (s *syncer) parallelSync(
}
if err := s.store.SyncMeta().MarkSyncSupportsDiff(ctx, s.syncID); err != nil {
l.Error("failed to set supports_diff marker", zap.Error(err))
return warnings, err
return s.handleOperationError(ctx, runCtx, warnings, err)
}
}

Expand All @@ -422,29 +413,90 @@ func (s *syncer) parallelSync(
return warnings, nil
}

// forcedCheckpointTimeout bounds the detached (context.WithoutCancel) context
// used for the forced checkpoint write below. This runs precisely during an
// orchestrator's termination grace period, so it must stay well short of a
// typical grace period (Kubernetes defaults to 30s) — unlike
// dotc1z.FinalizeTimeout, which bounds the much heavier WAL
// checkpoint+save+upload tail, this is a single small state write.
const forcedCheckpointTimeout = 15 * time.Second

// handleOperationError checks whether runCtx has been cancelled — by a
// deadline timeout, a SIGTERM/SIGINT shutdown (connectorrunner.ErrSigTerm),
// a server-directed task cancellation, a heartbeat failure, or any other
// cause — and if so treats it uniformly as resumable: force a checkpoint so
// the next resume picks up from here instead of restarting, and return an
// error that both IsSyncPreservable recognizes (via ErrSyncNotComplete) and
// still lets a caller distinguish *why* via errors.Is(err, cause).
//
// batchErr is the error an in-flight operation returned (nil when called
// from the top-of-loop check, where cancellation was observed between
// actions rather than surfaced by a connector call). If runCtx was not
// actually cancelled, batchErr is a genuine, unrelated operation error and
// is returned unchanged — this function only special-cases cancellation.
func (s *syncer) handleOperationError(
ctx context.Context,
runCtx context.Context,
warnings []error,
batchErr error,
) ([]error, error) {
if !errors.Is(context.Cause(runCtx), context.DeadlineExceeded) {
cause := context.Cause(runCtx)
if cause == nil {
return warnings, batchErr
}
// The run duration expired. The operation error is usually just the
// resulting cancellation, but a genuine connector/store failure can
// land in the same window — log it so expiry never masks a real bug.
// The sync resumes from the checkpoint, so the work is retried either
// way; only ErrSyncNotComplete is surfaced to keep the resumable
// contract for callers.
// The operation error is usually just the resulting cancellation, but a
// genuine connector/store failure can land in the same window — log it
// so cancellation never masks a real bug. The sync resumes from the
// checkpoint, so the work is retried either way; only ErrSyncNotComplete
// (plus cause) is surfaced to keep the resumable contract for callers.
if batchErr != nil && !errors.Is(batchErr, context.Canceled) && !errors.Is(batchErr, context.DeadlineExceeded) {
ctxzap.Extract(ctx).Error(
"sync operation failed while run duration expired; exiting early for resume",
"sync operation failed while the sync was being cancelled; exiting early for resume",
zap.Error(batchErr),
zap.NamedError("cancel_cause", cause),
)
}
checkpointErr := s.Checkpoint(ctx, true)
return warnings, errors.Join(checkpointErr, ErrSyncNotComplete)
l := ctxzap.Extract(ctx)
switch {
case errors.Is(cause, context.DeadlineExceeded):
if s.recordStats {
l.Info("sync run duration has expired, exiting sync early", s.syncSummaryFields(trace.SpanFromContext(ctx))...)
} else {
l.Info("sync run duration has expired, exiting sync early", zap.String("sync_id", s.syncID))
}
case s.recordStats:
// Same per-step/per-resource-type/retry-wait fields as the deadline
// branch above — these are exactly the counters used to reconstruct
// a sync's timeline after the fact (see CXH-2121), so a
// SIGTERM-interrupted sync must not produce less diagnostic output
// than a deadline-expired one.
l.Info("sync context cancelled, exiting sync early",
append(s.syncSummaryFields(trace.SpanFromContext(ctx)), zap.NamedError("cancel_cause", cause))...)
default:
l.Info("sync context cancelled, exiting sync early", zap.String("sync_id", s.syncID), zap.Error(cause))

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[minor] [Minor] This branch loses the sync stats summary for exactly the case the PR is about — a regression against pre-PR behaviour, and against the deadline branch three lines up.

Before this change, a non-deadline cancellation returned the raw cause, so returnSyncError (syncer.go:669-675) took its normal path and logged "sync stats so far" with the full syncSummaryFields(span) — per-step durations, per-resource-type counters, retry/rate-limit wall time. Now the returned error satisfies errors.Is(err, ErrSyncNotComplete), so returnSyncError short-circuits on its first line and emits nothing, while this else logs only sync_id. Net effect: a SIGTERM-interrupted sync produces less diagnostic output than before, and the missing fields are the same counters used to reconstruct the CXH-2121 timeline in the first place.

Suggest mirroring the deadline branch:

} else if s.recordStats {
    l.Info("sync context cancelled, exiting sync early", append(s.syncSummaryFields(trace.SpanFromContext(ctx)), zap.NamedError("cancel_cause", cause))...)
} else {
    l.Info("sync context cancelled, exiting sync early", zap.String("sync_id", s.syncID), zap.Error(cause))
}

Secondary: our earlier suggestion to keep l.Error for causes that aren't a benign shutdown wasn't taken — everything is Info now. That's much less important given the cause is joined into the returned error, but note the discriminator can't be ErrSigTerm (pkg/sync importing pkg/connectorrunner would cycle), so if you want the distinction it has to be something like errors.Is(cause, context.Canceled) || errors.Is(cause, context.DeadlineExceeded) → Info, else Error. Also note s.recordStats is only true on the Pebble engine (s.recordStats = s.store.Metadata().Engine == string(c1zstore.EnginePebble)), so on the default SQLite engine there is no summary either way.

Verifier (codex): CONFIRM — the new non-deadline branch logs only sync_id/cause, and since the returned error now includes ErrSyncNotComplete, the prior stats-logging path can be skipped.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed and fixed in 7b4b712 — the non-deadline branch now mirrors the deadline branch's s.recordStats check and logs the full syncSummaryFields set (plus cancel_cause) when available, instead of just sync_id. On the log-level discriminator: kept it at Info uniformly rather than splitting by cause. I'd actually settled on that earlier in review with the PR author's team for a specific reason -- treating a successfully-checkpointed SIGTERM as Info matches how the deadline branch already treated the equivalent case, and flipping it to Error for anything that isn't literally context.Canceled/DeadlineExceeded would put ErrSigTerm itself in the Error bucket (it's a plain sentinel, doesn't wrap context.Canceled), which is exactly the noisy-false-positive-under-k8s outcome I was trying to avoid. Agreed it's secondary now that cause is preserved in the returned error either way.

}
// ctx itself may already be Done here (e.g. a SIGTERM-driven cancellation
// cancels ctx directly, not just runCtx's derived deadline), so this
// checkpoint must run on a context that survives that cancellation —
// same pattern as the other finalize/cleanup writes in this codebase
// (c1file.go, clone_sync.go, syncer.go) — but still bounded, so a wedged
// store write during the grace period doesn't just burn the whole window
// before getting SIGKILLed anyway.
// It would be nice to remove this once we're more confident in the checkpointing logic.
cpCtx, cpCancel := context.WithTimeout(context.WithoutCancel(ctx), forcedCheckpointTimeout)
defer cpCancel()
checkpointErr := s.Checkpoint(cpCtx, true)
if checkpointErr != nil {
l.Error("error checkpointing before exiting sync", zap.Error(checkpointErr))
}
// cause and batchErr are joined in (not just logged) so a caller with
// more context — e.g. pkg/tasks/c1api distinguishing ErrTaskCancelled
// from a plain shutdown, or a retry loop that wants to know a genuine
// store/connector failure happened to land in the same window — can
// still inspect errors.Is(err, ...) instead of it being lost behind
// ErrSyncNotComplete. errors.Join drops nils, so this is a no-op when
// called from the top-of-loop check (batchErr is nil there).
return warnings, errors.Join(checkpointErr, ErrSyncNotComplete, cause, batchErr)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Suggestion: Newly joining batchErr into the returned error is inspectable by callers as intended, but it also changes the gRPC status reported to C1 for mid-action cancellations. In pkg/tasks/c1api/manager.go finishTask, status.FromError(taskError) now traverses batchErr; if the interrupted connector RPC returned a gRPC-status error, FinishTask reports that code instead of the previous codes.Unknown/codes.Canceled. Resumability is unaffected (ErrSyncNotComplete still present, NonRetryable stays false), so this is observability only — worth confirming the code change is intended. (medium-low confidence)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Correct, and this exposed an inaccuracy in my own PR-body note on this same topic -- I'd only accounted for cause there, written before batchErr was joined in to address a separate finding. Fixed the wire-visible-behavior section in the PR body to cover both: when batchErr is nil or doesn't implement GRPCStatus(), it falls to the sentinel switch same as before; when it does (a gRPC-status error from the interrupted connector call itself), status.FromError's errors.As traversal finds it directly and bypasses the switch. Both directions are more accurate than the previous blanket codes.Unknown, resumability/NonRetryable are unaffected, and it's now grouped with the other wire-visible-change caveat for whoever confirms C1's retry handling before merge. No code change, this is observability-only as you noted.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code LGTM on the SIGTERM → drain → checkpoint → ErrSyncNotComplete path (Temporal preserves the c1z via IsSyncPreservable). Two process nits before I'd approve:

  1. Mark the open review threads resolved — the drain major and the minors look fixed in head; they're just still open in the UI.
  2. Quick ack from whoever owns the Temporal/task-engine side that treating SIGTERM/cancel as preservable incomplete sync (success-ish on the activity) is the intended wire behavior — including when batchErr carries a gRPC status through finishTask.

No further code asks from me unless that ack says otherwise.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ran a deep-code-review pass to verify this before merging on your ack request. Verdict on your two claims:

  1. The SIGTERM -> drain -> checkpoint -> ErrSyncNotComplete path is confirmed end-to-end against the actual code (traced connectorrunner.Run -> drainInFlightTasks -> handleOperationError -> errors.Is(err, ErrSyncNotComplete)), backed by passing tests.
  2. "Temporal preserves the c1z via IsSyncPreservable" — confirmed there are zero in-repo callers of IsSyncPreservable, so this is exactly the kind of claim that can't be verified from baton-sdk itself; it depends entirely on the closed-source Temporal/task-engine side. Your ask for an explicit ack from whoever owns that is the right call, not just a process nit.

Bonus finding from the same pass, unrelated to your comment but worth fixing before merge: run() actually has a third exit path from its loop, not just the two ctx-cancellation ones — a dispatched task goroutine can set stopForLoop on a "grpc: the client connection is closing" error, which falls straight to the post-loop return with no drain call at all. With taskConcurrency > 1 another task could still be mid-checkpoint-write when that fires. Fixed in 50a2fb0 by moving the drain into a single defer covering every exit from run() instead of a third per-site call (this was the second time a return path got added without one). Writing the regression test for it also caught a real pre-existing data race on stopForLoop itself (plain bool written by a goroutine, read by the main loop) -- converted to atomic.Bool.

Re: marking the review threads resolved -- I don't have permission to do that from here (GitHub review-thread resolution isn't exposed via the API token I'm using), so that'll need a maintainer/reviewer action.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Suggestion: Joining cause/batchErr into the returned error changes the gRPC status code finishTask reports to C1 (manager.go:350-360): a deadline-expired sync now surfaces codes.DeadlineExceeded instead of codes.Unknown, and a mid-action batchErr implementing GRPCStatus() is surfaced directly. IsSyncPreservable and NonRetryable are unaffected, but whether C1's task engine treats these codes as retryable is out of this repo — confirm with that owner before merge, as the PR body notes. Non-blocking.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Already covered — this is the same point as an earlier pass of this same bot (comment further up this file, before the stopForLoop fix), which I addressed by expanding the PR body's "Wire-visible behavior change worth flagging before merge" section to cover both cause and batchErr explicitly, including the exact codes.DeadlineExceeded/GRPCStatus() mechanics you're describing here. No code change needed — as you say, IsSyncPreservable/NonRetryable are unaffected, and it's flagged in the PR body as something to confirm with whoever owns C1's task engine before merge.

}

func tooManyWarnings(warningCount int, completedActionsCount uint64) bool {
Expand Down
Loading
Loading