Files
kandev/apps/backend/internal/task/repository/sqlite/dynamic_route.go
T
2026-08-21 22:04:17 +01:00

367 lines
13 KiB
Go

package sqlite
import (
"context"
"crypto/rand"
"database/sql"
"encoding/json"
"errors"
"strings"
"time"
"github.com/google/uuid"
dynamicruntime "github.com/kandev/kandev/internal/agent/runtime/dynamic"
"github.com/kandev/kandev/internal/agent/runtime/routingerr"
)
var _ dynamicruntime.Persistence = (*Repository)(nil)
var _ dynamicruntime.ContinuationPersistence = (*Repository)(nil)
func (r *Repository) SaveRouteState(ctx context.Context, state dynamicruntime.RouteState) error {
if isTransientRouteSession(state.SessionID) {
return nil
}
updatedAt := state.UpdatedAt
if updatedAt.IsZero() {
updatedAt = time.Now().UTC()
}
_, err := r.db.ExecContext(ctx, r.db.Rebind(`
INSERT INTO dynamic_route_states (
session_id, logical_profile_id, execution_profile_id,
route_generation, profile_version, state, continuation_json, policy_state_json, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(session_id) DO UPDATE SET
logical_profile_id = excluded.logical_profile_id,
execution_profile_id = excluded.execution_profile_id,
route_generation = excluded.route_generation,
profile_version = excluded.profile_version,
state = excluded.state,
continuation_json = excluded.continuation_json,
policy_state_json = excluded.policy_state_json,
updated_at = excluded.updated_at
`), state.SessionID, state.LogicalProfileID, state.ExecutionProfileID,
state.Generation, state.ProfileVersion, state.Status, state.ContinuationJSON, state.PolicyStateJSON, updatedAt)
return err
}
// ClaimRouteState advances a route generation only when the durable row still
// has the caller's expected generation. The insert path is reserved for the
// initial generation, so a restart cannot accidentally reset an existing
// session to generation one.
func (r *Repository) ClaimRouteState(ctx context.Context, expectedGeneration int64, state dynamicruntime.RouteState) (bool, error) {
if isTransientRouteSession(state.SessionID) {
return true, nil
}
var (
result sql.Result
err error
)
if expectedGeneration == 0 {
result, err = r.db.ExecContext(ctx, r.db.Rebind(`
INSERT INTO dynamic_route_states (
session_id, logical_profile_id, execution_profile_id,
route_generation, profile_version, state, continuation_json, policy_state_json, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(session_id) DO NOTHING
`), state.SessionID, state.LogicalProfileID, state.ExecutionProfileID,
state.Generation, state.ProfileVersion, state.Status, state.ContinuationJSON, state.PolicyStateJSON, state.UpdatedAt)
} else {
result, err = r.db.ExecContext(ctx, r.db.Rebind(`
UPDATE dynamic_route_states
SET logical_profile_id = ?, execution_profile_id = ?,
route_generation = ?, profile_version = ?, state = ?, continuation_json = ?, policy_state_json = ?, updated_at = ?
WHERE session_id = ? AND route_generation = ?
`), state.LogicalProfileID, state.ExecutionProfileID, state.Generation,
state.ProfileVersion, state.Status, state.ContinuationJSON, state.PolicyStateJSON, state.UpdatedAt, state.SessionID,
expectedGeneration)
}
if err != nil {
return false, err
}
rows, err := result.RowsAffected()
return rows == 1, err
}
// RecordRouteDecision commits the generation claim and immutable attempt row
// together. A stale claim returns dynamicruntime.ErrStaleGeneration.
func (r *Repository) RecordRouteDecision(ctx context.Context, decision dynamicruntime.RouteDecision, state dynamicruntime.RouteState) error {
if isTransientRouteSession(state.SessionID) {
return nil
}
tx, err := r.db.BeginTxx(ctx, nil)
if err != nil {
return err
}
defer func() { _ = tx.Rollback() }()
expectedGeneration := state.Generation - 1
var result sql.Result
if expectedGeneration == 0 {
result, err = tx.ExecContext(ctx, r.db.Rebind(`
INSERT INTO dynamic_route_states (
session_id, logical_profile_id, execution_profile_id,
route_generation, profile_version, state, continuation_json, policy_state_json, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(session_id) DO NOTHING
`), state.SessionID, state.LogicalProfileID, state.ExecutionProfileID,
state.Generation, state.ProfileVersion, state.Status, state.ContinuationJSON, state.PolicyStateJSON, state.UpdatedAt)
} else {
result, err = tx.ExecContext(ctx, r.db.Rebind(`
UPDATE dynamic_route_states
SET logical_profile_id = ?, execution_profile_id = ?,
route_generation = ?, profile_version = ?, state = ?, continuation_json = ?, policy_state_json = ?, updated_at = ?
WHERE session_id = ? AND route_generation = ?
`), state.LogicalProfileID, state.ExecutionProfileID, state.Generation,
state.ProfileVersion, state.Status, state.ContinuationJSON, state.PolicyStateJSON, state.UpdatedAt, state.SessionID,
expectedGeneration)
}
if err != nil {
return err
}
rows, err := result.RowsAffected()
if err != nil {
return err
}
if rows != 1 {
return dynamicruntime.ErrStaleGeneration
}
createdAt := decisionReasonTime(decision, state)
if _, err := tx.ExecContext(ctx, r.db.Rebind(`
INSERT INTO dynamic_route_attempts (
id, session_id, logical_profile_id, execution_profile_id,
route_generation, profile_version, reason, created_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
`), uuid.New().String(), decision.SessionID, decision.LogicalProfileID,
decision.ExecutionProfileID, decision.Generation, decision.ProfileVersion,
decision.Reason, createdAt); err != nil {
return err
}
return tx.Commit()
}
func decisionReasonTime(_ dynamicruntime.RouteDecision, state dynamicruntime.RouteState) time.Time {
if !state.UpdatedAt.IsZero() {
return state.UpdatedAt
}
return time.Now().UTC()
}
func (r *Repository) AppendRouteAttempt(ctx context.Context, attempt dynamicruntime.RouteAttempt) error {
if isTransientRouteSession(attempt.SessionID) {
return nil
}
createdAt := attempt.CreatedAt
if createdAt.IsZero() {
createdAt = time.Now().UTC()
}
_, err := r.db.ExecContext(ctx, r.db.Rebind(`
INSERT INTO dynamic_route_attempts (
id, session_id, logical_profile_id, execution_profile_id,
route_generation, profile_version, reason, created_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
`), uuid.New().String(), attempt.SessionID, attempt.LogicalProfileID,
attempt.ExecutionProfileID, attempt.Generation, attempt.ProfileVersion,
attempt.Reason, createdAt)
return err
}
func (r *Repository) LoadRouteState(ctx context.Context, sessionID string) (*dynamicruntime.RouteState, error) {
if isTransientRouteSession(sessionID) {
return nil, nil
}
state := &dynamicruntime.RouteState{}
err := r.ro.QueryRowContext(ctx, r.ro.Rebind(`
SELECT session_id, logical_profile_id, execution_profile_id,
route_generation, profile_version, state, continuation_json, policy_state_json, updated_at
FROM dynamic_route_states WHERE session_id = ?
`), sessionID).Scan(
&state.SessionID, &state.LogicalProfileID, &state.ExecutionProfileID,
&state.Generation, &state.ProfileVersion, &state.Status, &state.ContinuationJSON, &state.PolicyStateJSON, &state.UpdatedAt,
)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, nil
}
return nil, err
}
return state, nil
}
// ListPendingRouteStates returns only states whose durable policy deadline can
// be reconciled automatically. States marked retrying are intentionally not
// returned after restart because dispatch may already have crossed the
// process boundary and must remain a manual recovery decision.
func (r *Repository) ListPendingRouteStates(ctx context.Context) ([]dynamicruntime.RouteState, error) {
rows, err := r.ro.QueryContext(ctx, r.ro.Rebind(`
SELECT session_id, logical_profile_id, execution_profile_id,
route_generation, profile_version, state, continuation_json, policy_state_json, updated_at
FROM dynamic_route_states
WHERE state IN (?, ?) ORDER BY updated_at ASC
`), string("retry_wait"), string("waiting_for_reset"))
if err != nil {
return nil, err
}
defer func() { _ = rows.Close() }()
states := make([]dynamicruntime.RouteState, 0)
for rows.Next() {
var state dynamicruntime.RouteState
if err := rows.Scan(
&state.SessionID, &state.LogicalProfileID, &state.ExecutionProfileID,
&state.Generation, &state.ProfileVersion, &state.Status,
&state.ContinuationJSON, &state.PolicyStateJSON, &state.UpdatedAt,
); err != nil {
return nil, err
}
states = append(states, state)
}
if err := rows.Err(); err != nil {
return nil, err
}
return states, nil
}
func (r *Repository) SaveRouteContinuation(ctx context.Context, record dynamicruntime.ContinuationRecord) error {
if isTransientRouteSession(record.SessionID) {
return nil
}
payload, err := json.Marshal(record.Continuation)
if err != nil {
return err
}
updatedAt := record.UpdatedAt
if updatedAt.IsZero() {
updatedAt = time.Now().UTC()
}
result, err := r.db.ExecContext(ctx, r.db.Rebind(`
UPDATE dynamic_route_states
SET continuation_json = ?, updated_at = ?
WHERE session_id = ? AND route_generation = ?
`), string(payload), updatedAt, record.SessionID, record.Generation)
if err != nil {
return err
}
rows, err := result.RowsAffected()
if err != nil {
return err
}
if rows != 1 {
return dynamicruntime.ErrStaleGeneration
}
return nil
}
func (r *Repository) SaveCircuit(ctx context.Context, snapshot dynamicruntime.CircuitSnapshot) error {
_, err := r.db.ExecContext(ctx, r.db.Rebind(`
INSERT INTO dynamic_resource_circuits
(resource_key, state, until_at, code, probe_until, updated_at)
VALUES (?, ?, ?, ?, ?, ?)
ON CONFLICT(resource_key) DO UPDATE SET
state = excluded.state,
until_at = excluded.until_at,
code = excluded.code,
probe_until = excluded.probe_until,
updated_at = excluded.updated_at
`), snapshot.Key, snapshot.State, nullableTime(snapshot.Until), snapshot.Code,
nullableTime(snapshot.ProbeUntil), time.Now().UTC())
return err
}
func (r *Repository) LoadCircuits(ctx context.Context) ([]dynamicruntime.CircuitSnapshot, error) {
rows, err := r.ro.QueryContext(ctx, r.ro.Rebind(`
SELECT resource_key, state, until_at, code, probe_until
FROM dynamic_resource_circuits
WHERE state <> ? ORDER BY resource_key
`), dynamicruntime.CircuitClosed)
if err != nil {
return nil, err
}
defer func() { _ = rows.Close() }()
var snapshots []dynamicruntime.CircuitSnapshot
for rows.Next() {
var snapshot dynamicruntime.CircuitSnapshot
var until, probeUntil sql.NullTime
var state string
var code string
if err := rows.Scan(&snapshot.Key, &state, &until, &code, &probeUntil); err != nil {
return nil, err
}
snapshot.State = dynamicruntime.CircuitState(state)
snapshot.Code = routingerr.Code(code)
if until.Valid {
snapshot.Until = until.Time
}
if probeUntil.Valid {
snapshot.ProbeUntil = probeUntil.Time
}
snapshots = append(snapshots, snapshot)
}
return snapshots, rows.Err()
}
func (r *Repository) LoadOrCreate(ctx context.Context) ([]byte, error) {
var key []byte
err := r.ro.QueryRowContext(ctx, `SELECT key_bytes FROM dynamic_installation_keys WHERE id = 1`).Scan(&key)
if err == nil {
return append([]byte(nil), key...), nil
}
if !errors.Is(err, sql.ErrNoRows) {
return nil, err
}
key = make([]byte, 32)
if _, err := rand.Read(key); err != nil {
return nil, err
}
_, err = r.db.ExecContext(ctx, r.db.Rebind(`
INSERT INTO dynamic_installation_keys (id, key_bytes, created_at) VALUES (1, ?, ?)
`), key, time.Now().UTC())
if err != nil {
// Another process may have won the first insert. Read its stable key.
if readErr := r.ro.QueryRowContext(ctx, `SELECT key_bytes FROM dynamic_installation_keys WHERE id = 1`).Scan(&key); readErr == nil {
return append([]byte(nil), key...), nil
}
return nil, err
}
return key, nil
}
func nullableTime(value time.Time) interface{} {
if value.IsZero() {
return nil
}
return value
}
func (r *Repository) ListRouteAttempts(ctx context.Context, sessionID string) ([]dynamicruntime.RouteAttempt, error) {
if isTransientRouteSession(sessionID) {
return []dynamicruntime.RouteAttempt{}, nil
}
rows, err := r.ro.QueryContext(ctx, r.ro.Rebind(`
SELECT session_id, logical_profile_id, execution_profile_id,
route_generation, profile_version, reason, created_at
FROM dynamic_route_attempts
WHERE session_id = ? ORDER BY route_generation ASC
`), sessionID)
if err != nil {
return nil, err
}
defer func() { _ = rows.Close() }()
attempts := make([]dynamicruntime.RouteAttempt, 0)
for rows.Next() {
var attempt dynamicruntime.RouteAttempt
if err := rows.Scan(&attempt.SessionID, &attempt.LogicalProfileID,
&attempt.ExecutionProfileID, &attempt.Generation, &attempt.ProfileVersion,
&attempt.Reason, &attempt.CreatedAt); err != nil {
return nil, err
}
attempts = append(attempts, attempt)
}
if err := rows.Err(); err != nil {
return nil, err
}
return attempts, nil
}
func isTransientRouteSession(sessionID string) bool {
return strings.HasPrefix(sessionID, "utility:")
}