blob: fd417137897a9fa374962d657ec8056b15fc243a [file] [edit]
// Copyright 2025 The Chromium Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
syntax = "proto3";
package turboci.graph.executor.v1;
import "turboci/graph/executor/v1/cancel_stage.proto";
import "turboci/graph/executor/v1/run_stage.proto";
import "turboci/graph/executor/v1/validate_stage.proto";
option go_package = "go.chromium.org/turboci/proto/go/graph/executor/v1/grpcpb;executorgrpcpb";
option java_multiple_files = true;
// TurboCIStageExecutor defines the interface that all stage executors must
// implement.
//
// The TurboCI Orchestrator can be configured to use many different services
// which implement TurboCIStageExecutor to execute stages of different types.
//
// TBD: Explain end-user-credential propagation.
service TurboCIStageExecutor {
// ValidateStage instructs the executor to validate the stage.
//
// It is called once (i.e. not per attempt) when the stage is being inserted
// into the graph. It should check the executor can run the stage at all (this
// includes checking ACLs if appropriate).
//
// If the stage looks good (i.e. this RPC returns OK), the executor has
// opportunity to adjust some of stages parameters before the stage is
// inserted into the graph by returning them in ValidateStageResponse
// (currently contains only turboci.orchestrator.v1.StageExecutionPolicy).
//
// If the stage doesn't look good (i.e. this RPC returns an error), the entire
// WriteNodes transaction that was attempting to insert the stage will be
// aborted. The graph will be left unchanged.
//
// RunStage RPC is generally expected to redo the validation, since the stage
// parameters may change after the stage is inserted.
//
// TBD: Formalize how the error will be propagated back to the client
// attempting to insert this stage. How would we allow details in the
// response?
//
// Sidenote - do we need to record this failed node insertion somewhere
// in the graph/ledger for observability?
//
// Must have no side effects, since it observes stages that potentially will
// never exist in the graph for real.
rpc ValidateStage(ValidateStageRequest) returns (ValidateStageResponse) {}
// RunStage instructs the executor to run the given stage attempt.
//
// This is called per attempt to either run the stage to completion (for
// *fast* "synchronous" stages) or to quickly dispatch it to execute
// elsewhere (for "asynchronous" stages).
//
// How fast is *fast*? As a rule of thumb, stages which take far less than
// 30s can be done synchronously, and stages which take longer than that
// should be done asynchronously. If this is not knowable in advance, this
// handler format allows the executor to make this decision dynamically on a
// per-attempt basis.
//
// RunStage RPC is generally expected to redo the validation done by
// ValidateStage, since the stage parameters may change after the stage is
// inserted. If the stage is no longer passing the validation, RunStage should
// essentially finish it as failed by calling TurboCIOrchestrator.WriteNodes
// (see below).
//
// Expected behavior of the handler is that it will call WriteNodes at least
// once before the RPC deadline to update the current attempt state:
// * Synchronous stages - COMPLETE/INCOMPLETE.
// * Asynchronous stages - SCHEDULED/RUNNING/TEARING_DOWN.
// * Over capacity/backoff - THROTTLED.
//
// The RunStage return value and even status code are essentially ignored.
// The orchestrator *always* looks at the state of the stage attempt after
// RunStage finishes (successfully or not) to decide if the request succeeded
// or should be retried. This is needed to unify handling of synchronous and
// asynchronous stages and to simplify handling of a class of race conditions
// related to RPC peers (or network between them) dying midway through
// execution.
//
// # Interaction with stage cancellation
//
// A stage attempt can be cancelled at any moment before or during RunStage
// execution.
//
// If it is cancelled before RunStage switches the attempt into SCHEDULED or
// RUNNING state, WriteNodes RPCs (e.g. calls to switch the attempt into
// RUNNING state) will return FAILED_PRECONDITION error and the gRPC status
// details will contain StageAttemptCurrentState with INCOMPLETE `state`
// and `cancelled_at` populated.
//
// If the stage attempt is cancelled after RunStage switches it to SCHEDULED
// or RUNNING state, the orchestrator will switch the attempt into CANCELLING
// state and will call CancelStage RPC to notify the executor the cancellation
// is happening (this is most useful for SCHEDULED attempts that just sit idle
// in some queue). The executor can also passively check if the attempt is
// CANCELLING now by looking at `current_attempt_state` in WriteNodes RPC
// response (useful for running attempts that periodically send heartbeats by
// calling WriteNodes with `current_attempt` set).
//
// Either way the executor must acknowledge the cancellation by switching
// the attempt into TEARING_DOWN state (or any terminal states). This can
// happen either inside CancelStage implementation or in the executor's
// run loop. The orchestrator will keep calling CancelStage as long as the
// attempt is in CANCELLING state, up to `cancelling` timeout, after which the
// attempt will be marked as INCOMPLETE.
//
// If there's no `cancelling` timeout, then cancelling a stage will
// immediately transition the attempt into INCOMPLETE state (CancelStage will
// not be called). The executor can discover this happened by examining
// FAILED_PRECONDITION error details as explained above.
//
//
// Examples:
//
// # Fast Synchronous stage - single write:
//
// This shows an implementation which does some work quickly (e.g. much less
// than 30 seconds) and does its writes and state update in a single atomic
// write_nodes call.
//
// If this crashes then the orchestrator will retry the attempt.
//
// def run_stage(req):
// with transaction:
// dat = query_nodes(...)
// # do some work quickly
// write_nodes(current_attempt={COMPLETE}, <other node updates>)
//
// # Fast Synchronous stage - multiple writes:
//
// This shows a synchronous implementation which cannot do all its writes
// at once.
//
// If this crashes after the first write, then the orchestrator will mark
// this attempt as incomplete (via RUNNING timeout/heartbeat), and if
// the stage policy allows, create a new attempt.
//
// def run_stage(req):
// write_nodes(current_attempt={RUNNING}, <other node updates>)
// # work
// write_nodes(current_attempt={progress}, <other node updates>)
// # work
// write_nodes(
// current_attempt={progress, COMPLETE}, <other node updates>,
// )
//
// # Asynchronous stage - immediate handoff:
//
// This shows an asynchronous stage implementation which must take a long
// time to do its work, but which will start working immediately so the
// run_stage handler can coordinate directly with the worker.
//
// If this crashes after the first write, then the orchestrator will mark
// this attempt as incomplete (via timeout/heartbeat), and if the stage
// policy allows, create a new attempt.
//
// (apologies for the pseudocode; `ch` should be interpreted as a Go channel)
//
// def run_stage(req):
// ch = start_work(req)
// <-ch
//
// def start_work(req):
// ch = Channel()
// def _worker:
// write_nodes(current_attempt={RUNNING, process_id=hostname+threadid})
//
// # Unblock `run_stage`; it can return now that the attempt is
// # RUNNING.
// close(ch)
//
// final_update = None
// try:
// # do a bunch of work
// # set final_update to COMPLETE/INCOMPLETE write_nodes call.
// finally:
// # example of cleanup work
// write_nodes(current_attempt=TEARING_DOWN)
// write_nodes(final_update)
//
// run_in_background(_worker)
// return ch
//
// # Asynchronous stage - delayed handoff:
//
// This shows an asynchronous stage which the executor will enqueue and run
// later.
//
// If this crashes after the first write, then the orchestrator will mark
// this attempt as incomplete (via timeout/heartbeat), and if the stage
// policy allows, create a new attempt.
//
// def run_stage(req):
// send_work_to_gce(req)
// write_nodes(current_attempt=SCHEDULED)
//
// # sometime possibly much later
// def gce_worker(req):
// write_nodes(current_attempt={RUNNING, process_id=hostname+process_id})
// # NOTE: in the case where work is multiply-enqueued, the process_id
// # during the transition to RUNNING should ensure that only one of
// # these write_nodes calls succeeds.
// final_update = None
// try:
// # do a bunch of work
// # set final_update to COMPLETE/INCOMPLETE write_nodes call.
// finally:
// # example of cleanup work
// write_nodes(current_attempt=TEARING_DOWN)
// write_nodes(final_update)
//
// # Hybrid stage:
//
// This shows a stage which is handled dynamically.
//
// def run_stage(req):
// cached = check_cache(req) # fast, no graph writes
// if cached:
// write_nodes(current_attempt=COMPLETE, ...)
// else:
// # See 'delayed handoff' above for send_work_to_gce.
// send_work_to_gce(req)
// write_nodes(current_attempt=SCHEDULED)
//
// # Executor over capacity
//
// This shows a stage preamble where the executor needs to implement
// backpressure. It can be used in conjunction with any of the other
// techniques above.
//
// When an attempt is THROTTLED, the orchestrator will call RunStage again
// later, but not until after the timestamp provided when writing the
// THROTTLED state.
//
// def run_stage(req):
// ok, next_check_time = check_capacity()
// if not ok:
// write_nodes(current_attempt={THROTTLED, until=next_check_time})
// return
//
// # Any other stage implementation.
rpc RunStage(RunStageRequest) returns (RunStageResponse) {}
// CancelStage instructs the executor to cancel the given stage attempt.
//
// It is called for attempts in CANCELLING state soon after they are
// cancelled.
//
// To handle this RPC, the executor may
// * Switch the attempt into COMPLETE/INCOMPLETE state if it can do so
// (for example, if the stage attempt has not started running yet).
// * Switch the attempt into TEARING_DOWN state if cancelling the attempt
// requires some additional work.
//
// CancelStage will be called (with exponential backoff) as long as the
// attempt remains in CANCELLING state.
//
// CancelStage will not be called at all if the StageAttemptExecutionPolicy of
// the attempt has no `cancelling` timeout. Such attempts transition into
// INCOMPLETE on cancellation immediately.
rpc CancelStage(CancelStageRequest) returns (CancelStageResponse) {}
}