Skip to content

Latest commit

 

History

History
258 lines (213 loc) · 11.9 KB

dependency-kinds.md

File metadata and controls

258 lines (213 loc) · 11.9 KB

Dependency kinds

A service-dependencies entry says which service another one needs. Until now it did not say why, so every edge constrained every operation: a generated client that only matters at build time forced the producer to be started, and a consumed endpoint that only matters at run time forced the producer to be built first.

Declaring a kind says which execution stage the edge constrains.

Kinds

Kind Meaning Stages it constrains What the consumer waits for
(absent) Legacy edge build, run consumed endpoint health
build Build/codegen input — a generated client, a shared artifact build nothing
schema The producer's declared API contract is read at build time build nothing
runtime A consumed endpoint run consumed endpoint health
completion One-shot work (a migration, a seed job) must finish run completed run
external A capability provided outside the workspace none nothing (unless composition ordering binds the pair — see below)

Stages and phases are different things

A stage is an elementary graph that can be topologically sorted: build or run. A phase is an operation a caller asks for, and it decomposes into ordered stages:

Phase Stages, in order
build build
run run
test build, then run
deploy build, then run

So a build dependency does constrain test and deploy — you cannot test or deploy a service without building it first. What it never does is order a run.

The stages must stay separate graphs. Merging them would union a build-time edge with a runtime edge pointing the other way and report a cycle — exactly the misclassification kinds exist to remove.

completion and runtime are not interchangeable. Polling a migration job for endpoint health never succeeds; treating a long-running service as "done" starts its consumers early. A completion dependency therefore cannot declare endpoints — that combination is rejected at load.

An external dependency is declared so configuration and documentation can name the capability. Codefly never builds, starts or orders it.

external describes the edge, not the producer. When the producer is also a real service of the workspace — it is loaded, or another service consumes it under any other kind — it stays a service in the graph and runs whenever that other consumer runs; the external declaration only means the declaring service does not pull it in or wait for it. The order services are listed in never changes this.

Composition ordering can supersede it

A composition root can bind the same two services itself, by writing an ${endpoint:<module>/<service>/<endpoint>} reference into a workspace configuration group the consumer declares (workspace-configuration-dependencies). That reference is not documentation: it resolves to the producer's address and is injected into the consumer's environment, so the producer has to be running for the value to mean anything. When a run is built with those references (architecture.WithConfigurationReferences), such an edge is a runtime edge: the producer is included in the run, started before the consumer, and waited for on the endpoint the reference names — the consumer's own external declaration of the same pair is superseded, and the run logs that it was.

The declaration still decides everything the root did not bind: a producer no group of the consumer references is pulled in and waited for by nobody, exactly as above. Declaring external a producer the composition root binds anyway is worth rewriting as kind: runtime, which says the same thing in the consumer's own manifest — the override exists so a composition that did not is still correct, not as a second way to spell it.

An explicit runtime prerequisite can also supersede an external graph edge. architecture.WithRuntimePrerequisites takes consumer-to-prerequisite module/service identities when constructing NewServiceDependencies. It adds ordering through the existing runtime graph, without modifying service manifests or granting endpoint access, configuration or readiness declarations. The caller still owns the actual readiness or admission condition before starting a consumer. No configuration group or endpoint reference is needed for a pure ordering rule.

Both identities must name loaded workspace services; a dangling dependency node is not a loaded service. A real excluded or skipped consumer imposes no outgoing requirement. An active consumer naming a missing or excluded prerequisite is refused. A skipped prerequisite remains selectable by its consumers. Mandatory prerequisites are applied before configuration-reference edges, and cycles are refused per stage rather than silently dropping a mandatory edge. They affect run ordering and transitive closure, not build ordering. Use stage views as for other kinded edges; a union of build/run graphs can contain a legitimate cycle.

The option snapshots its input; repeated uses replace the previous map. Derived graph views hold independent copies. Nil or empty input is a no-op. The older manifest-only SelectClosure walk refuses a nonempty runtime prerequisite option instead of silently returning a closure that omits it.

Cycles are a per-stage property

The two-stage relationship below is legitimate: the API talks to the database at run time, and the database bootstrap reads the API contract to generate its migrations at build time. The union of both edges is a cycle; neither stage's graph is.

# services/api/service.codefly.yaml
name: api
service-dependencies:
    - name: database
      kind: runtime
      endpoints:
        - name: postgres
endpoints:
    - name: grpc
      api: grpc
      visibility: public
      exposure: none
# services/database/service.codefly.yaml
name: database
service-dependencies:
    - name: api
      kind: schema
      endpoints:
        - name: grpc
endpoints:
    - name: postgres
      api: tcp
      visibility: public
      exposure: none

A cycle within one stage — two services each declaring a runtime dependency on the other — still fails, and the error names the stage and the path (run dependencies have a cycle: a -> b -> a).

One-shot prerequisites

# services/worker/service.codefly.yaml
name: worker
service-dependencies:
    - name: migration      # must have COMPLETED before the worker starts
      kind: completion
    - name: api            # only needed to generate a client
      kind: build
      endpoints:
        - name: grpc
    - name: stripe         # provided outside the workspace
      module: vendor
      kind: external

What a kind consumes, and what it reaches

Stage participation is not consumption. The kind states both, for every kind (DependencyKind.ConsumesEndpoints, DependencyKind.ReachesEndpoints):

Kind Stages Consumes the producer's endpoints Reaches them at run time
(untyped) build, run yes (calls them) yes
build build yes (reads the contract) no
schema build yes (reads the contract) no
runtime run yes (calls them) yes
completion run no — it waits for the producer to finish no
external none no — the producer is outside the workspace no

An edge that consumes nothing selects no endpoint, whatever it names or omits: an omitted endpoint list on a completion edge is not "all", so the consumer is handed no address of the job it waits on, and the derived allow-list (network-model.md) records no ask from it. Only a reaching edge derives an entry: a build edge reads the contract and never calls, so it grants nothing either.

What does not change

  • Visibility is enforced on every consumed interface regardless of kind: a build edge crosses the same export boundary as a runtime one.
  • An endpoint reference that names an endpoint the producer does not export is rejected for every kind.
  • An entry with no kind behaves exactly as it did before kinds existed, so an existing workspace keeps its order and its validation errors. In particular an endpointless producer under an undeclared kind stays valid: that is how workspaces expressed one-shot work before completion existed.

Migration

Existing declaration Keep as is when Declare instead
Dependency whose endpoints the consumer calls at run time always safe kind: runtime
Dependency used only to generate a client or read a schema it also needs to be running kind: build / kind: schema
Dependency on a job that must finish first never — legacy waits for endpoint health kind: completion
kind: runtime onto a producer with no endpoints never — it waits forever; this is rejected kind: completion
Dependency on something codefly does not manage it is a real workspace service kind: external

Migration is per-edge and additive: an undeclared kind stays legacy, so a workspace can be migrated one dependency at a time.

API

deps, _ := architecture.NewServiceDependencies(ctx, workspace)

// Fails when any stage's graph deadlocks, naming the stage and the cycle.
err := deps.VerifyAcyclic(ctx)

// What a caller wants: the ordered stages of an operation. `test` and `deploy`
// return a build order followed by a run order.
stages, _ := deps.OrderFor(ctx, resources.PhaseTest, unique)
for _, stage := range stages {
    // stage.Stage is resources.StageBuild, then resources.StageRun
    // stage.Services is that stage's order
}

// A single stage's graph, when that is what you need.
build, _ := deps.ForStage(resources.StageBuild)
buildOrder, _ := build.OrderTo(ctx, unique)

An unknown stage or phase name is an error, never an empty result: a graph with no edges is indistinguishable from "nothing depends on anything", so a typo would silently remove all ordering.

Restricting a graph — by stage or to a single service — keeps the edge kinds and drops the service lookup entries for nodes it removed, so a service that is not in the restricted graph cannot be resolved through it.

Reciprocal configuration references

A configuration reference retains its producer in the run even when the reverse startup dependency already exists: a service whose configuration names a callback owner that itself depends on the service gets that owner selected, started and ordered as a member of the same single-root run. Selection computes the fixed point of referenced producers and their declared dependencies; it does not add extra roots or ignore excluded services. This participation applies to runtime selection, not build-only selection, and survives graph restriction.

Participation is membership, not a resolved address. Startup ordering remains acyclic, so the consumer may initialize before the callback owner has published where it listens, and whether the consumer's reference resolves at that moment is the orchestrator's to answer. It can when the owner's address is a function of its identity — deterministic named ports, a deployed in-cluster address — and it cannot when addresses are allocated per run at initialization (the CLI's --temporary-ports). In that case the orchestrator owns the verdict: drop the value for this consumer as it did while the owner was outside the run, or allocate addresses before any service initializes. It must not ask the consumer to order or declare the owner, which is the cycle this edge was dropped to avoid. Core states who takes part; it does not promise the consumer a mapping.

A reference that would close a startup cycle adds no pre-start health wait for the service waiting on that consumer. The callback owner still starts in the selected flow and undergoes the ordinary service readiness checks. Configuration references do not make a declared startup cycle valid or prove either application's request-time readiness.