Skip to content
Merged
Show file tree
Hide file tree
Changes from 44 commits
Commits
Show all changes
48 commits
Select commit Hold shift + click to select a range
e4fe14d
feat(briefs): add brief + campaign API and async orchestrator
mrautela365 Jul 9, 2026
d3c3541
fix(briefs): archived-slug reuse + surface transient 412 re-fetch errors
mrautela365 Jul 9, 2026
04f5ea7
fix(briefs): tenant-scope GetJob, 400 on empty token, 503 no-db, tests
mrautela365 Jul 9, 2026
ca31564
fix(briefs): preserve campaign fields on update, tests, comment fixes
mrautela365 Jul 9, 2026
17928fa
fix(briefs): reject empty platforms, log running-status error, align doc
mrautela365 Jul 9, 2026
92121b1
refactor(orchestrator): drop unused briefs repository dependency
mrautela365 Jul 9, 2026
42dd26c
fix(orchestrator): guard against a dispatcher returning nil campaign
mrautela365 Jul 9, 2026
b51d8cb
fix(review): address Copilot findings on brief API + orchestrator (PR…
mrautela365 Jul 10, 2026
acacb37
fix(review): address third-round Copilot findings on orchestrator (PR…
mrautela365 Jul 10, 2026
904d5e5
fix(review): round-3 Copilot findings — UUID validation, approval con…
mrautela365 Jul 11, 2026
4d1f3d0
fix(review): round-4 Copilot findings on orchestrator + design (PR #11)
mrautela365 Jul 11, 2026
73a7b47
fix(review): round-5 Copilot findings — error sanitization, panic rec…
mrautela365 Jul 11, 2026
bc7531c
feat(orchestrator): durable dispatch — advisory lock, drain, recovery…
mrautela365 Jul 11, 2026
c397ec7
fix(review): round-6 Copilot findings — lock correctness, recovery sa…
mrautela365 Jul 11, 2026
e142163
fix(review): preserve orphaned upstream id on persist failure (PR #11)
mrautela365 Jul 11, 2026
5527600
fix(review): rework single-flight to a claim row; recovery + schema f…
mrautela365 Jul 11, 2026
ea1e213
fix(review): cancel in-flight runs on drain timeout; document orphan …
mrautela365 Jul 11, 2026
86fb436
fix(review): claim safety, global dispatch limit (PR #11)
mrautela365 Jul 11, 2026
6b9e490
fix(review): retain claim on ambiguous dispatch error; stale comments…
mrautela365 Jul 11, 2026
7fdc6e5
fix(review): quote ETag responses; release claim on pre-create dispat…
mrautela365 Jul 11, 2026
3c74e80
fix(review): sequence shutdown; roll back claim on read failure (PR #11)
mrautela365 Jul 11, 2026
77f3279
fix(ci): add license header to 000003 migration files (PR #11)
mrautela365 Jul 11, 2026
a92aff0
fix(review): detach claim-release context; clarify skip semantics; co…
mrautela365 Jul 11, 2026
c665414
fix(review): finalize jobs on detached ctx; bounded shutdown; claim r…
mrautela365 Jul 11, 2026
7697d44
fix(review): reject weak entity-tags in If-Match (PR #11)
mrautela365 Jul 11, 2026
7152257
fix(review): reject unbalanced If-Match quote; grace >= finalize time…
mrautela365 Jul 11, 2026
6a6ae83
fix(review): retain claim on ambiguous nil/empty result; bound drain+…
mrautela365 Jul 11, 2026
7635b6b
fix(review): defensively copy caller slices before async dispatch (PR…
mrautela365 Jul 11, 2026
c83c90d
fix(review): bound total graceful-shutdown time across both phases (P…
mrautela365 Jul 11, 2026
e0ea197
fix(review): give the shutdown grace phase its own budget (PR #11)
mrautela365 Jul 11, 2026
0e0b454
fix(review): bound provider calls and add periodic stuck-job sweep (P…
mrautela365 Jul 11, 2026
98b71e7
fix(review): honor ctx cancel in grace wait, bound dispatch queue wai…
mrautela365 Jul 11, 2026
0d5449c
fix(review): index recovery query; interruptible sweeper; force-close…
mrautela365 Jul 13, 2026
07ac3cd
fix(review): atomic approve-to-dispatch; persist under grace ctx; saf…
mrautela365 Jul 14, 2026
f1c9ed2
fix(review): job-result corruption, conflict msg, close error (PR #11)
mrautela365 Jul 14, 2026
9b6653a
fix(review): poll inflight counter in Wait to avoid goroutine leak (P…
mrautela365 Jul 14, 2026
df237f3
fix(review): honor sub-tick timeouts in inflight Wait (PR #11)
mrautela365 Jul 14, 2026
961fca9
docs(review): correct comments to ErrStaleApproval, not ErrConflict (…
mrautela365 Jul 14, 2026
8fc6651
fix(review): dedicated inflight-drain budget; correct dispatch fast-p…
mrautela365 Jul 14, 2026
6ea394b
fix(review): serialize approve-dispatch; skip not fail; project_id TEXT
mrautela365 Jul 14, 2026
69f9fa8
fix(review): terminalize skipped-platform jobs; align schema doc to T…
mrautela365 Jul 14, 2026
16e1d5a
fix(review): count handlers outermost; surface skipped platform in po…
mrautela365 Jul 14, 2026
154b817
fix(review): reachable skipped message, empty-result guard, shutdown …
mrautela365 Jul 14, 2026
915b923
fix(review): persist partial campaign ID on dispatch error for reconc…
mrautela365 Jul 15, 2026
cee2b11
fix(review): address 4 minor findings on PR #11 (project scoping, orp…
mrautela365 Jul 20, 2026
3f13a6e
Merge remote-tracking branch 'origin/main' into feat/LFXV2-2626-brief…
mrautela365 Jul 20, 2026
f0f5ff5
fix(orchestrator): surface idempotency-lookup DB errors instead of di…
mrautela365 Jul 20, 2026
10754e8
Merge remote-tracking branch 'origin/main' into feat/LFXV2-2626-brief…
mrautela365 Jul 20, 2026
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
2 changes: 1 addition & 1 deletion cmd/campaign-service/kodata/gen/http/openapi.json

Large diffs are not rendered by default.

1,716 changes: 1,466 additions & 250 deletions cmd/campaign-service/kodata/gen/http/openapi.yaml

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion cmd/campaign-service/kodata/gen/http/openapi3.json

Large diffs are not rendered by default.

3,634 changes: 2,756 additions & 878 deletions cmd/campaign-service/kodata/gen/http/openapi3.yaml

Large diffs are not rendered by default.

173 changes: 127 additions & 46 deletions cmd/campaign-service/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,12 @@ import (
"log/slog"
"net/http"
"os"
"sync"
"time"

briefsvcsvr "github.com/linuxfoundation/lfx-v2-campaign-service/gen/http/lfx_v2_campaign_service_briefs/server"
connsvcsvr "github.com/linuxfoundation/lfx-v2-campaign-service/gen/http/lfx_v2_campaign_service_connections/server"
svcsvr "github.com/linuxfoundation/lfx-v2-campaign-service/gen/http/lfx_v2_campaign_service_svc/server"
briefsvc "github.com/linuxfoundation/lfx-v2-campaign-service/gen/lfx_v2_campaign_service_briefs"
connsvc "github.com/linuxfoundation/lfx-v2-campaign-service/gen/lfx_v2_campaign_service_connections"
svc "github.com/linuxfoundation/lfx-v2-campaign-service/gen/lfx_v2_campaign_service_svc"
"github.com/linuxfoundation/lfx-v2-campaign-service/internal/container"
Expand All @@ -39,32 +41,37 @@ func StartServer(ctx context.Context, cfg *config.Config) error {

// NOTE: debug.LogPayloads() is intentionally NOT applied. Every authenticated
// payload carries a BearerToken (a valid JWT), and the connection service's
// create/set-credential payloads carry plaintext provider credentials, so
// enabling it would leak those secrets into logs. debug.HTTP() IS still
// applied below, but in clue v1.2.1 it does not log headers or statuses — it
// only propagates the runtime /debug toggle into each request's context (so
// debug-level logs elsewhere activate); it decodes no payload.
// create/set-credential payloads carry plaintext provider credentials (OAuth
// refresh tokens, client secrets), so enabling it would leak those secrets
// into logs. debug.HTTP() IS still applied below, but in clue v1.2.1 it does
// not log headers or statuses — it only propagates the runtime /debug toggle
// into each request's context (so debug-level logs elsewhere activate); it
// decodes no payload.
endpoints := svc.NewEndpoints(cont.Service)

// The container always initializes Connections (NewContainer wires it in both
// the DB and no-DB paths), so construct the endpoints unconditionally. Fail
// loudly if it's unexpectedly nil rather than silently skipping the mount —
// a mis-wired container should crash at startup, not serve 404s on the
// connection routes.
// The container always initializes Connections and Briefs (NewContainer wires
// them in both the DB and no-DB paths), so construct the endpoints
// unconditionally. Fail loudly if either is unexpectedly nil rather than
// silently skipping the mount — a mis-wired container should crash at startup,
// not serve 404s on the connection/brief routes.
if cont.Connections == nil {
return fmt.Errorf("container misconfigured: Connections service is nil")
}
connEndpoints := connsvc.NewEndpoints(cont.Connections)
if cont.Briefs == nil {
return fmt.Errorf("container misconfigured: Briefs service is nil")
}
briefEndpoints := briefsvc.NewEndpoints(cont.Briefs)

return handleHTTPServer(ctx, cfg, endpoints, connEndpoints, cont)
return handleHTTPServer(ctx, cfg, endpoints, connEndpoints, briefEndpoints, cont)
}

// buildMux constructs the Goa muxer and mounts BOTH the campaign service and the
// connection service onto it. It is a seam so tests can assert that the
// connection routes are actually reachable (the bug this fixes — routes that
// compile but are never mounted return 404) without standing up a full server.
// It returns an error only for a programmer-level mis-wiring (nil connEndpoints).
func buildMux(ctx context.Context, cfg *config.Config, endpoints *svc.Endpoints, connEndpoints *connsvc.Endpoints) (goahttp.Muxer, error) {
// buildMux constructs the Goa muxer and mounts the campaign, connection, and
// brief services onto it. It is a seam so tests can assert that the routes are
// actually reachable (the bug this fixes — routes that compile but are never
// mounted return 404) without standing up a full server. It returns an error
// only for a programmer-level mis-wiring (nil endpoints).
func buildMux(ctx context.Context, cfg *config.Config, endpoints *svc.Endpoints, connEndpoints *connsvc.Endpoints, briefEndpoints *briefsvc.Endpoints) (goahttp.Muxer, error) {
mux := goahttp.NewMuxer()
if cfg.Debug {
debug.MountPprofHandlers(debug.Adapt(mux))
Expand Down Expand Up @@ -92,27 +99,39 @@ func buildMux(ctx context.Context, cfg *config.Config, endpoints *svc.Endpoints,
)
svcsvr.Mount(mux, server)

// Mount the connection routes unconditionally. connEndpoints is always
// non-nil (StartServer constructs it and fails loudly if the service is
// mis-wired), and the connection service is wired even without a database
// (with a nil repo) so its routes return the typed 503 rather than a bare
// 404. A nil here would be a programmer error, so fail loudly rather than
// silently skipping the mount and serving 404s.
// Mount the connection and brief routes unconditionally. Both endpoint sets
// are always non-nil (StartServer constructs them and fails loudly if a
// service is mis-wired), and both are wired even without a database (nil repo)
// so their routes return the typed 503 rather than a bare 404. A nil here would
// be a programmer error, so fail loudly rather than silently skipping the mount
// and serving 404s.
if connEndpoints == nil {
return nil, fmt.Errorf("buildMux: connEndpoints is nil (connection routes would be unmounted)")
}
connServer := connsvcsvr.New(connEndpoints, mux, goahttp.RequestDecoder, goahttp.ResponseEncoder, eh, nil)
connsvcsvr.Mount(mux, connServer)

if briefEndpoints == nil {
return nil, fmt.Errorf("buildMux: briefEndpoints is nil (brief routes would be unmounted)")
}
briefServer := briefsvcsvr.New(briefEndpoints, mux, goahttp.RequestDecoder, goahttp.ResponseEncoder, eh, nil)
briefsvcsvr.Mount(mux, briefServer)

return mux, nil
}

func handleHTTPServer(ctx context.Context, cfg *config.Config, endpoints *svc.Endpoints, connEndpoints *connsvc.Endpoints, cont *container.Container) error {
mux, err := buildMux(ctx, cfg, endpoints, connEndpoints)
func handleHTTPServer(ctx context.Context, cfg *config.Config, endpoints *svc.Endpoints, connEndpoints *connsvc.Endpoints, briefEndpoints *briefsvc.Endpoints, cont *container.Container) error {
mux, err := buildMux(ctx, cfg, endpoints, connEndpoints, briefEndpoints)
if err != nil {
return err
}

// Track in-flight handlers so shutdown can wait (bounded) for stragglers to
// return before the DB pool closes. http.Server.Close does not wait for
// handler goroutines, so without this a straggler could touch the pool as it
// closes; the tracker is waited on between the forced Close and cont.Close.
inflight := middleware.NewInflightTracker()

var handler http.Handler = mux
handler = middleware.RequestIDMiddleware()(handler)
if cfg.Debug {
Expand All @@ -126,6 +145,12 @@ func handleHTTPServer(ctx context.Context, cfg *config.Config, endpoints *svc.En
return p != "/healthz" && p != "/livez" && p != "/readyz"
}),
)
// Wrap the inflight tracker OUTERMOST (applied last), so a request is counted
// from the instant it enters the handler chain — before the request-ID / debug /
// OTel wrappers. If it were inner, a handler that has started but not yet reached
// the inner wrapper would be invisible to Wait, and shutdown could observe zero,
// close the pool, and let that straggler touch a closing pool.
handler = inflight.Middleware()(handler)

srv := &http.Server{
Addr: cfg.ServerAddress(),
Expand All @@ -135,7 +160,13 @@ func handleHTTPServer(ctx context.Context, cfg *config.Config, endpoints *svc.En
IdleTimeout: constants.DefaultIdleTimeout,
}

return runServerWithContext(ctx, srv, cont)
return runServerWithContext(ctx, srv, cont, inflight)
}

// inflightWaiter is the subset of middleware.InflightTracker the shutdown path
// needs: wait (bounded) for in-flight handlers to return.
type inflightWaiter interface {
Wait(timeout time.Duration) bool
}

func errorHandler(logCtx context.Context) func(context.Context, http.ResponseWriter, error) {
Expand All @@ -144,7 +175,7 @@ func errorHandler(logCtx context.Context) func(context.Context, http.ResponseWri
}
}

func runServerWithContext(ctx context.Context, srv *http.Server, cont *container.Container) error {
func runServerWithContext(ctx context.Context, srv *http.Server, cont *container.Container, inflight inflightWaiter) error {
serverErr := make(chan error, 1)

go func() {
Expand All @@ -161,25 +192,75 @@ func runServerWithContext(ctx context.Context, srv *http.Server, cont *container
slog.InfoContext(ctx, "shutdown initiated")
}

var wg sync.WaitGroup
wg.Add(2)

go func() {
defer wg.Done()
shutdownCtx, cancel := context.WithTimeout(context.Background(), constants.DefaultShutdownTimeout)
defer cancel()
if err := srv.Shutdown(shutdownCtx); err != nil {
slog.ErrorContext(ctx, "HTTP server shutdown error", log.ErrKey, err)
// Shut down in order, not concurrently: first stop the HTTP server (stops
// accepting new requests and drains in-flight handlers, so no new dispatch
// can be Started), THEN close the container (which drains in-flight dispatch
// and only then closes the DB pool). Running these concurrently risks a
// just-accepted request Starting a dispatch while the pool is being closed.
//
// Each phase gets its OWN bounded deadline rather than sharing one context.
// A shared context would not bound the total: srv.Shutdown could consume the
// whole budget draining HTTP handlers, leaving Container.Close's drain+grace
// (whose orchestrator grace timer is otherwise wall-clock, not ctx-bound) to
// add its full window on top — pushing the real total past
// DefaultShutdownTimeout and risking a SIGKILL mid-drain. Budgeting the
// phases separately makes the total a true sum: httpShutdownTimeout +
// (dispatchDrainTimeout + CancelGracePeriod) <= DefaultShutdownTimeout, which
// container.go asserts at init.
// Shutdown ordering is explicit:
// 1. graceful srv.Shutdown(httpCtx) — stop accepting, drain handlers.
// 2. on httpCtx expiry, force srv.Close() — http.Server.Shutdown RETURNS on
// ctx expiry even if handlers are STILL running; those stragglers could
// then touch the DB pool while container/pool close runs (and pgxpool.Close
// itself can block on a connection a handler still holds). Close() forcibly
// terminates all connections so no handler is still using the pool when it
// closes. Force-close only after a timeout — on a clean Shutdown there are
// no lingering connections and Close() is a harmless no-op.
// 3. wait (bounded) for any in-flight handler goroutine to actually RETURN.
// srv.Close() terminates connections but does NOT wait for the handler
// goroutines themselves, so a straggler could still be executing (and
// touching the pool) after Close returns. The inflight tracker lets us
// wait for those goroutines to finish before closing the pool; the wait
// is bounded by whatever remains of the HTTP-shutdown budget so a hung
// handler can never wedge shutdown past its window.
// 4. THEN cont.Close(closeCtx) — drain in-flight dispatch, then close the pool.
// A residual remains only if a handler outlasts the bounded wait; that window
// is tracked under LFXV2-2665.
// Reserve HandlerDrainTimeout for the post-force-close inflight wait (step 3),
// so the graceful Shutdown gets the rest of the HTTP budget. This guarantees
// the inflight wait has a real budget even when Shutdown times out (which would
// otherwise exhaust the whole HTTP context).
shutdownWait := container.HTTPShutdownTimeout - container.HandlerDrainTimeout
if shutdownWait <= 0 {
// HandlerDrainTimeout must leave room within the overall shutdown budget;
// if misconfigured so it doesn't, fall back to the full budget rather than
// a zero/negative deadline that would force-close immediately.
shutdownWait = container.HTTPShutdownTimeout
}
httpCtx, cancelHTTP := context.WithTimeout(context.Background(), shutdownWait)
defer cancelHTTP()
if err := srv.Shutdown(httpCtx); err != nil {
slog.ErrorContext(ctx, "HTTP server shutdown error; force-closing lingering connections", log.ErrKey, err)
// Shutdown's ctx expired with handlers still running (or it otherwise
// failed). Force-close so no straggler can touch the pool as it closes.
if cerr := srv.Close(); cerr != nil {
Comment thread
mrautela365 marked this conversation as resolved.
slog.ErrorContext(ctx, "HTTP server force-close error", log.ErrKey, cerr)
Comment thread
mrautela365 marked this conversation as resolved.
}
Comment thread
mrautela365 marked this conversation as resolved.
}()

go func() {
defer wg.Done()
if err := cont.Close(); err != nil {
slog.ErrorContext(ctx, "container close error", log.ErrKey, err)
}
Comment thread
mrautela365 marked this conversation as resolved.
// Wait for in-flight handler goroutines to return before closing the pool. On a
// clean Shutdown they are already drained and this returns immediately; on the
// force-close path it waits up to the DEDICATED HandlerDrainTimeout (NOT the
// HTTP context's remaining time — that is exhausted precisely when Shutdown
// timed out and a straggler is running, which would make the wait a no-op).
if inflight != nil {
if !inflight.Wait(container.HandlerDrainTimeout) {
slog.ErrorContext(ctx, "in-flight HTTP handlers did not return before pool close; a straggler may touch the pool (LFXV2-2665)")
Comment thread
mrautela365 marked this conversation as resolved.
}
}()

wg.Wait()
}
closeCtx, cancelClose := context.WithTimeout(context.Background(), container.ContainerCloseTimeout)
defer cancelClose()
if err := cont.Close(closeCtx); err != nil {
slog.ErrorContext(ctx, "container close error", log.ErrKey, err)
}
return nil
}
28 changes: 19 additions & 9 deletions cmd/campaign-service/server_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import (
"strings"
"testing"

briefsvc "github.com/linuxfoundation/lfx-v2-campaign-service/gen/lfx_v2_campaign_service_briefs"
connsvc "github.com/linuxfoundation/lfx-v2-campaign-service/gen/lfx_v2_campaign_service_connections"
svc "github.com/linuxfoundation/lfx-v2-campaign-service/gen/lfx_v2_campaign_service_svc"
"github.com/linuxfoundation/lfx-v2-campaign-service/internal/infrastructure/config"
Expand All @@ -25,11 +26,13 @@ import (
// (any non-404 status, e.g. 401/503, proves it is mounted).
func TestConnectionRoutesAreMounted(t *testing.T) {
endpoints := svc.NewEndpoints(service.NewCampaignService(nil))
// The no-DB connection service is a real Service whose routes return a typed
// 503 rather than 404 — perfect for proving the route is mounted.
// The no-DB connection and brief services are real Services whose routes
// return a typed 503 rather than 404 — perfect for proving the route is
// mounted.
connEndpoints := connsvc.NewEndpoints(service.NewConnectionService(nil, nil))
briefEndpoints := briefsvc.NewEndpoints(service.NewBriefService(nil, nil, nil, nil))

mux, err := buildMux(context.Background(), &config.Config{}, endpoints, connEndpoints)
mux, err := buildMux(context.Background(), &config.Config{}, endpoints, connEndpoints, briefEndpoints)
if err != nil {
t.Fatalf("buildMux: %v", err)
}
Expand All @@ -41,6 +44,7 @@ func TestConnectionRoutesAreMounted(t *testing.T) {
path string
}{
{"connection google-ads create", http.MethodPost, "/projects/proj-123/connection-google-ads"},
{"brief create", http.MethodPost, "/projects/proj-123/briefs"},
{"campaign health livez", http.MethodGet, "/livez"},
}
for _, tc := range cases {
Expand All @@ -56,12 +60,18 @@ func TestConnectionRoutesAreMounted(t *testing.T) {
}
}

// TestBuildMuxNilConnEndpointsFailsLoud verifies the fail-loud guard: a nil
// connEndpoints (a programmer-level mis-wiring) returns an error rather than
// silently building a mux with the connection routes unmounted.
func TestBuildMuxNilConnEndpointsFailsLoud(t *testing.T) {
// TestBuildMuxNilEndpointsFailsLoud verifies the fail-loud guard: a nil
// connEndpoints or briefEndpoints (a programmer-level mis-wiring) returns an
// error rather than silently building a mux with those routes unmounted.
func TestBuildMuxNilEndpointsFailsLoud(t *testing.T) {
endpoints := svc.NewEndpoints(service.NewCampaignService(nil))
if _, err := buildMux(context.Background(), &config.Config{}, endpoints, nil); err == nil {
t.Fatal("expected buildMux to fail loudly when connEndpoints is nil, got nil error")
connEndpoints := connsvc.NewEndpoints(service.NewConnectionService(nil, nil))
briefEndpoints := briefsvc.NewEndpoints(service.NewBriefService(nil, nil, nil, nil))

if _, err := buildMux(context.Background(), &config.Config{}, endpoints, nil, briefEndpoints); err == nil {
t.Error("expected buildMux to fail loudly when connEndpoints is nil, got nil error")
}
if _, err := buildMux(context.Background(), &config.Config{}, endpoints, connEndpoints, nil); err == nil {
t.Error("expected buildMux to fail loudly when briefEndpoints is nil, got nil error")
}
}
Loading
Loading