Co-authored-by: nova28 <17953305+nova28@users.noreply.github.com> Co-authored-by: Carlos Florencio <carlosmflorencio@gmail.com>
12 KiB
id, title, status, wave, depends_on, plan, requirements, acceptance_criteria, system_design
| id | title | status | wave | depends_on | plan | requirements | acceptance_criteria | system_design | |||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 02-reachability-persistence | Reachability record persistence | done | 1 | plan.md |
|
|
|
Task 02: Reachability Record Persistence
Summary
Add the executor_reachability table, its model, and its repository methods.
Exactly one row per SSH executor, surviving restart, converging on the later
observation when two writes race, and deleted when its executor is
soft-deleted. No probe writes to it yet.
In scope
models.ExecutorReachabilitywith the state and reason constants, plus aListSSHExecutorsForReachabilityprojection returning eligible executors (type = 'ssh',deleted_at IS NULL,status = 'active') ordered ascending byexecutors.id.- A single additive
r.migrate.Apply("executor_reachability.table", …)inbase_migrations.go, following therepository_secret_bindingsprecedent so it is idempotent on SQLite, on Postgres, and on an already-migrated database. GetExecutorReachability,ListExecutorReachability,UpsertExecutorReachability,DeleteExecutorReachabilityonExecutorRepositoryand its SQLite implementation.- Columns per the system design, including
last_success_atalongsidechecked_at: the pre-launch warning needs the age of the last successful probe, andchecked_atis overwritten by every failure.updated_at TIMESTAMP NOT NULLis a distinct column fromchecked_at: it advances on every write including a reset (whosechecked_atisNULL), and is the field task 04's client reconciliation depends on. - Last-write-wins on
checked_at: the upsert's conflict clause writes only when the incomingchecked_atis strictly later than the stored one, or the stored one isNULL. A write whosechecked_atequals the stored value is discarded, and timestamps are persisted at millisecond resolution or finer so two probes in the same second still order. The consecutive-failure counter is read and written inside that same statement's transaction so a streak cannot be lost to an interleaving. - A second, distinct method,
ResetExecutorReachability(ctx, executorID, host string) error, is its own SQL statement, not a branch of the upsert: the upsert'sstateCASE has nounknownbranch and itsWHEREadmits only a strictly laterchecked_at, which a reset does not carry. It writesunknown, a zero counter, cleared reason/message, the newly savedhost, and bothchecked_at/last_success_atsetNULL, guarded only bytype = 'ssh' AND deleted_at IS NULL AND status = 'active'— nochecked_atorupdated_atpin, because a reset is ordered by the save that caused it, not by an observation clock, and must win over whatever is stored. An executor that issshbut notactiveis not reset (eligibility wins perAC-…-001.17), and the statement lands zero rows for it rather than erroring. This task owns only the statement; task 04 owns who calls it. DeleteExecutorremoves the reachability row alongside the soft delete.
Out of scope
- The poller, the ticker, the hysteresis rule, and the decision of what counter value or state to write. This task stores what it is given.
- Any HTTP route, event, or DTO.
- Columns on
executors. Observed state must not share the row that holds user-authoredconfigand the user-controlledstatusswitch. - Retention beyond one row per executor: no history, trend, or incident log.
Acceptance
- Two upserts for one executor carrying an older and a newer
checked_atleave the newer values stored regardless of which transaction commits second, including when the older one lands on a whole second and the newer one carries a fraction of that same second. - Soft-deleting an executor removes its reachability row, and re-reading it returns a not-found rather than a stale record.
- A record written, then read back through a reopened repository against the
same database file, is byte-identical including both
checked_atandlast_success_at. - An upsert whose
checked_atequals the stored value leaves the row unchanged. ResetExecutorReachabilityon a record with a populatedchecked_at,last_success_at, and non-zero counter leavesstate = unknown, the counter zeroed, reason/message cleared,hostset to the new value, and both timestampsNULL;updated_atstill advances even thoughchecked_atdoes not.ResetExecutorReachabilityagainst an executor whosestatusis notactive(but is stilltype = 'ssh'and not soft-deleted) affects zero rows and leaves the existing record untouched.
Verification
Start with the concurrency test as a failing test — issue the newer write
first and the older write second, assert the newer survives, and confirm a
plain ON CONFLICT DO UPDATE without the timestamp predicate fails it. Then
run:
# From apps/backend:
go test -tags fts5 -race ./internal/task/repository/sqlite/ -run 'ExecutorReachability|ResetExecutorReachability'
go test -tags fts5 -race ./internal/task/repository/... -run 'Migration|Schema'
make lint
Postgres parity runs through the existing *_postgres_test.go harness in the
same package.
Files likely touched
apps/backend/internal/task/models/executor_reachability.goapps/backend/internal/task/repository/interface.goapps/backend/internal/task/repository/sqlite/executor_reachability.goapps/backend/internal/task/repository/sqlite/executor_reachability_test.goapps/backend/internal/task/repository/sqlite/executor_reachability_postgres_test.goapps/backend/internal/task/repository/sqlite/base_migrations.goapps/backend/internal/task/repository/sqlite/executor.go
Dependencies
None.
Risks
MigrateLogger.Applyswallows non-"already exists" errors, so a malformedCREATE TABLEfails silently at migration time and only surfaces as a "no such table" at first write. Assert the table's presence in a migration test rather than trusting the apply to be loud.TIMESTAMPcomparison semantics differ between SQLite and Postgres, and the difference is not symmetric. Postgres compares itstimestamptype temporally; SQLite stores the driver's text encoding in a NUMERIC-affinity column and compares it lexically. Write UTC and bindtime.Timerather than a pre-formatted string, per the design's Persistence section — an RFC3339Zstring inverts the comparison for any whole-second value. A Postgres-only assertion passes while SQLite keeps the wrong row, so the ordering test must run on both, with a sub-second pair and a whole-second/sub-second pair.- Adding methods to
ExecutorRepositorywidens an interface with existing fakes across the codebase; expect compile breaks in unrelated test packages and extend those fakes rather than narrowing the interface.
Parallelism
parallel-safe with task 01 — disjoint packages, and task 01 carries no
schema change.
Inputs
- System design, section Persistence.
base_migrations.gorepository_secret_bindingsblock as the create-table-through-migration precedent.executor.gofor the existing executor CRUD and soft-delete shape.models.ExecutorTypeSSHandmodels.ExecutorStatusActive.
Results
Implemented as specified:
models.ExecutorReachability(internal/task/models/executor_reachability.go) withExecutorReachabilityState/ExecutorReachabilityReasonconstants,ErrExecutorReachabilityNotFound, andExecutorReachabilityObservation(the upsert's input struct, carryingInitialState/InitialFailuresfor the insert-only path plusSeenUpdatedAt/FailureThresholdfor the eligibility and hysteresis guards).executor_reachabilitytable added via one additiver.migrate.Apply("executor_reachability.table", …)inbase_migrations.go, following therepository_secret_bindingsprecedent (idempotentCREATE TABLE IF NOT EXISTS, no separateinit*Schemaentry needed for a brand-new table).ListSSHExecutorsForReachability,GetExecutorReachability,ListExecutorReachability,UpsertExecutorReachability,ResetExecutorReachability,DeleteExecutorReachabilityadded toExecutorRepository(repository/interface.go) and implemented in the newrepository/sqlite/executor_reachability.go.UpsertExecutorReachabilityis a singleINSERT … SELECT … WHERE EXISTS … ON CONFLICT DO UPDATEstatement per the system design: theWHERE EXISTSguard checks the executor is still active SSH with the caller's observedupdated_at; the conflict clause derivesconsecutive_failuresandstatefrom the stored row viaCASE, and the finalWHEREonchecked_atenforces last-write-wins (aNULLstoredchecked_ator a strictly later incoming one wins; an equal or older one is a no-op).InitialState/InitialFailuresare used only by theINSERTbranch (no prior row); every later write derives both from the stored row.ResetExecutorReachabilityis its own statement (not a branch of the upsert): unconditionally writesunknown/zeroed counter/cleared reason and message/newhost/NULLtimestamps, guarded only bytype='ssh' AND deleted_at IS NULL AND status='active'— no timestamp pin, since a reset must win over whatever is stored regardless of observation order. A non-active-but-not-deleted SSH executor is a no-op (AC-…-001.17).DeleteExecutor(repository/sqlite/executor.go) now runs the soft-deleteUPDATEandDELETE FROM executor_reachabilityin one transaction.- Both writes normalize to UTC and bind
time.Timedirectly (never a pre-formatted string), per the design's SQLite-lexical-vs-Postgres-temporalTIMESTAMPcomparison risk. - Widening
ExecutorRepositorybroke one pre-existing test fake (mockRepositoryininternal/task/handlers/process_handlers_test.go, which hand-implements methods rather than embedding the interface); extended it with the six new methods rather than narrowing the interface, per the task's own Risks section.
Tests added (repository/sqlite/executor_reachability_test.go,
executor_reachability_postgres_test.go):
TestUpsertExecutorReachabilityNewerCheckedAtWinsRegardlessOfOrder— issues the newer write first and the older write second (including a whole-second/sub-second pair), asserts the newer values survive.TestUpsertExecutorReachabilityEqualCheckedAtIsDiscardedTestExecutorReachabilityRoundTripAcrossReopen— write, reopen the SQLite file, read back byte-identicalchecked_at/last_success_at.TestDeleteExecutorRemovesReachabilityRecordTestResetExecutorReachabilityClearsTheRecordButAdvancesUpdatedAtTestResetExecutorReachabilityIsANoOpForANonActiveExecutorTestListSSHExecutorsForReachabilityOrdersByIDTestPostgresExecutorReachabilityCheckedAtOrdering(env-gated onKANDEV_TEST_POSTGRES_DSN; skipped in this environment, proves the fix doesn't regress on Postgres — the SQLite test above is the one that actually catches the lexical-comparison bug).
Verification commands run from apps/backend, all green:
go test -tags fts5 -race ./internal/task/repository/sqlite/ -run 'Reachability' # 7 passed, 1 skipped (no Postgres DSN)
go test -tags fts5 -race ./internal/task/repository/... -run 'Migration|Schema' # all passed, several skipped (no Postgres DSN)
make lint # 0 issues
go build ./... && go vet ./... # clean
go test -race ./internal/task/... also run as a broader sanity check: one
unrelated pre-existing failure
(TestRepositoryBranchPolicyServiceGitflowStarterIsAtomicAndOneTime, a
/tmp vs /private/tmp path-canonicalization issue on this macOS
environment) reproduces identically on the task-01 commit before any task-02
change, confirmed via a throwaway git worktree at 7ca5d4b0e. Not caused by
this work order.