feat(jobs): persist job lifecycle to PostgreSQL job_audit
UpsertQueued/Running/MarkTerminal через SetPersistHooks; исправлен deadlock fireEnqueued под Registry mutex. Co-authored-by: Cursor <[email protected]>
This commit is contained in:
@@ -56,18 +56,40 @@ func BootstrapWorkers(ctx context.Context, opts Options) (store.Backend, *jobs.R
|
|||||||
wk.Registry = reg
|
wk.Registry = reg
|
||||||
if pool != nil {
|
if pool != nil {
|
||||||
audit := repository.NewJobAuditWriter(pool)
|
audit := repository.NewJobAuditWriter(pool)
|
||||||
reg.SetTerminalHook(func(j *jobs.Job) {
|
jobMeta := func(j *jobs.Job) map[string]any {
|
||||||
if j == nil {
|
if j == nil {
|
||||||
return
|
return nil
|
||||||
}
|
}
|
||||||
st := j.Snapshot()
|
st := j.Snapshot()
|
||||||
status, _ := st["status"].(string)
|
meta, _ := st["meta"].(map[string]any)
|
||||||
var errMsg *string
|
return meta
|
||||||
if e, ok := st["error"].(string); ok && e != "" {
|
}
|
||||||
errMsg = &e
|
reg.SetPersistHooks(
|
||||||
}
|
func(j *jobs.Job) {
|
||||||
audit.MarkTerminal(context.Background(), j.TenantID, j.ID, status, errMsg, time.Now().UTC())
|
if j == nil {
|
||||||
})
|
return
|
||||||
|
}
|
||||||
|
audit.UpsertQueued(context.Background(), j.TenantID, j.ID, j.Kind, j.IdempotencyKey, j.ModuleID, jobMeta(j))
|
||||||
|
},
|
||||||
|
func(j *jobs.Job) {
|
||||||
|
if j == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
audit.UpsertRunning(context.Background(), j.TenantID, j.ID, j.Kind, j.IdempotencyKey, jobMeta(j))
|
||||||
|
},
|
||||||
|
func(j *jobs.Job) {
|
||||||
|
if j == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
st := j.Snapshot()
|
||||||
|
status, _ := st["status"].(string)
|
||||||
|
var errMsg *string
|
||||||
|
if e, ok := st["error"].(string); ok && e != "" {
|
||||||
|
errMsg = &e
|
||||||
|
}
|
||||||
|
audit.MarkTerminal(context.Background(), j.TenantID, j.ID, status, errMsg, time.Now().UTC())
|
||||||
|
},
|
||||||
|
)
|
||||||
}
|
}
|
||||||
observability.RegisterStoreBackend(backend)
|
observability.RegisterStoreBackend(backend)
|
||||||
return backend, reg, pool, nil
|
return backend, reg, pool, nil
|
||||||
|
|||||||
+49
-4
@@ -182,6 +182,8 @@ type Registry struct {
|
|||||||
workerStart func(j *Job)
|
workerStart func(j *Job)
|
||||||
workerSem chan struct{}
|
workerSem chan struct{}
|
||||||
onTerminal func(j *Job)
|
onTerminal func(j *Job)
|
||||||
|
onEnqueued func(j *Job)
|
||||||
|
onRunning func(j *Job)
|
||||||
}
|
}
|
||||||
|
|
||||||
type idempoKey struct {
|
type idempoKey struct {
|
||||||
@@ -209,6 +211,44 @@ func (r *Registry) SetTerminalHook(fn func(j *Job)) {
|
|||||||
r.onTerminal = fn
|
r.onTerminal = fn
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SetPersistHooks registers best-effort callbacks for job lifecycle persistence.
|
||||||
|
func (r *Registry) SetPersistHooks(onEnqueued, onRunning, onTerminal func(j *Job)) {
|
||||||
|
if r == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
r.mu.Lock()
|
||||||
|
defer r.mu.Unlock()
|
||||||
|
r.onEnqueued = onEnqueued
|
||||||
|
r.onRunning = onRunning
|
||||||
|
if onTerminal != nil {
|
||||||
|
r.onTerminal = onTerminal
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Registry) fireEnqueued(j *Job) {
|
||||||
|
if r == nil || j == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
r.mu.RLock()
|
||||||
|
fn := r.onEnqueued
|
||||||
|
r.mu.RUnlock()
|
||||||
|
if fn != nil {
|
||||||
|
fn(j)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Registry) fireRunning(j *Job) {
|
||||||
|
if r == nil || j == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
r.mu.RLock()
|
||||||
|
fn := r.onRunning
|
||||||
|
r.mu.RUnlock()
|
||||||
|
if fn != nil {
|
||||||
|
fn(j)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func (r *Registry) fireTerminal(j *Job) {
|
func (r *Registry) fireTerminal(j *Job) {
|
||||||
if r == nil || j == nil {
|
if r == nil || j == nil {
|
||||||
return
|
return
|
||||||
@@ -271,8 +311,6 @@ func (r *Registry) pruneTerminalIfOver(maxJobs int) {
|
|||||||
// Enqueue creates a job or returns an existing one for the same idempotency key.
|
// Enqueue creates a job or returns an existing one for the same idempotency key.
|
||||||
func (r *Registry) Enqueue(tenantID, kind string, idempotencyKey *string, moduleID *string, meta map[string]any) (*Job, bool, error) {
|
func (r *Registry) Enqueue(tenantID, kind string, idempotencyKey *string, moduleID *string, meta map[string]any) (*Job, bool, error) {
|
||||||
r.mu.Lock()
|
r.mu.Lock()
|
||||||
defer r.mu.Unlock()
|
|
||||||
|
|
||||||
maxJobs := registryMaxJobsFromEnv()
|
maxJobs := registryMaxJobsFromEnv()
|
||||||
r.pruneTerminalIfOver(maxJobs)
|
r.pruneTerminalIfOver(maxJobs)
|
||||||
|
|
||||||
@@ -281,6 +319,7 @@ func (r *Registry) Enqueue(tenantID, kind string, idempotencyKey *string, module
|
|||||||
if existing, ok := r.byIdempo[k]; ok {
|
if existing, ok := r.byIdempo[k]; ok {
|
||||||
st := existing.statusLocked()
|
st := existing.statusLocked()
|
||||||
if st == StatusQueued || st == StatusRunning {
|
if st == StatusQueued || st == StatusRunning {
|
||||||
|
r.mu.Unlock()
|
||||||
return existing, false, nil
|
return existing, false, nil
|
||||||
}
|
}
|
||||||
delete(r.byIdempo, k)
|
delete(r.byIdempo, k)
|
||||||
@@ -302,8 +341,14 @@ func (r *Registry) Enqueue(tenantID, kind string, idempotencyKey *string, module
|
|||||||
}
|
}
|
||||||
r.byID[j.ID] = j
|
r.byID[j.ID] = j
|
||||||
r.pruneTerminalIfOver(maxJobs)
|
r.pruneTerminalIfOver(maxJobs)
|
||||||
|
enqueuedHook := r.onEnqueued
|
||||||
|
workerStart := r.workerStart
|
||||||
|
r.mu.Unlock()
|
||||||
|
|
||||||
if r.workerStart != nil {
|
if enqueuedHook != nil {
|
||||||
|
enqueuedHook(j)
|
||||||
|
}
|
||||||
|
if workerStart != nil {
|
||||||
go func() {
|
go func() {
|
||||||
r.workerSem <- struct{}{}
|
r.workerSem <- struct{}{}
|
||||||
active := len(r.workerSem)
|
active := len(r.workerSem)
|
||||||
@@ -313,7 +358,7 @@ func (r *Registry) Enqueue(tenantID, kind string, idempotencyKey *string, module
|
|||||||
<-r.workerSem
|
<-r.workerSem
|
||||||
observability.RecordJobQueueDepth(len(r.workerSem), capacity)
|
observability.RecordJobQueueDepth(len(r.workerSem), capacity)
|
||||||
}()
|
}()
|
||||||
r.workerStart(j)
|
workerStart(j)
|
||||||
}()
|
}()
|
||||||
}
|
}
|
||||||
return j, true, nil
|
return j, true, nil
|
||||||
|
|||||||
@@ -95,6 +95,9 @@ func (w *Worker) Process(j *Job) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
j.MarkRunning()
|
j.MarkRunning()
|
||||||
|
if w != nil && w.Registry != nil {
|
||||||
|
w.Registry.fireRunning(j)
|
||||||
|
}
|
||||||
if j.IsCancelRequested() {
|
if j.IsCancelRequested() {
|
||||||
j.MarkCancelled()
|
j.MarkCancelled()
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -20,6 +20,28 @@ func NewJobAuditWriter(pool *pgxpool.Pool) *JobAuditWriter {
|
|||||||
return &JobAuditWriter{pool: pool}
|
return &JobAuditWriter{pool: pool}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// UpsertQueued inserts a queued job row (best-effort).
|
||||||
|
func (w *JobAuditWriter) UpsertQueued(ctx context.Context, tenantID, jobID, kind string, idempotencyKey *string, moduleID *string, meta map[string]any) {
|
||||||
|
if w == nil || w.pool == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
metaJSON, _ := json.Marshal(meta)
|
||||||
|
var idem any
|
||||||
|
if idempotencyKey != nil && *idempotencyKey != "" {
|
||||||
|
idem = *idempotencyKey
|
||||||
|
}
|
||||||
|
var mod any
|
||||||
|
if moduleID != nil && *moduleID != "" {
|
||||||
|
mod = *moduleID
|
||||||
|
}
|
||||||
|
_, _ = w.pool.Exec(ctx, `
|
||||||
|
INSERT INTO job_audit (id, tenant_id, kind, status, idempotency_key, module_id, meta_json, created_at)
|
||||||
|
VALUES ($1::uuid, $2::uuid, $3, 'queued', $4, $5::uuid, $6::jsonb, now())
|
||||||
|
ON CONFLICT (tenant_id, idempotency_key) WHERE idempotency_key IS NOT NULL
|
||||||
|
DO UPDATE SET status='queued', meta_json=EXCLUDED.meta_json, module_id=EXCLUDED.module_id`,
|
||||||
|
jobID, tenantID, kind, idem, mod, metaJSON)
|
||||||
|
}
|
||||||
|
|
||||||
// UpsertRunning inserts or updates a running job row (best-effort).
|
// UpsertRunning inserts or updates a running job row (best-effort).
|
||||||
func (w *JobAuditWriter) UpsertRunning(ctx context.Context, tenantID, jobID, kind string, idempotencyKey *string, meta map[string]any) {
|
func (w *JobAuditWriter) UpsertRunning(ctx context.Context, tenantID, jobID, kind string, idempotencyKey *string, meta map[string]any) {
|
||||||
if w == nil || w.pool == nil {
|
if w == nil || w.pool == nil {
|
||||||
@@ -33,8 +55,7 @@ func (w *JobAuditWriter) UpsertRunning(ctx context.Context, tenantID, jobID, kin
|
|||||||
_, _ = w.pool.Exec(ctx, `
|
_, _ = w.pool.Exec(ctx, `
|
||||||
INSERT INTO job_audit (id, tenant_id, kind, status, idempotency_key, meta_json, created_at, started_at)
|
INSERT INTO job_audit (id, tenant_id, kind, status, idempotency_key, meta_json, created_at, started_at)
|
||||||
VALUES ($1::uuid, $2::uuid, $3, 'running', $4, $5::jsonb, now(), now())
|
VALUES ($1::uuid, $2::uuid, $3, 'running', $4, $5::jsonb, now(), now())
|
||||||
ON CONFLICT (tenant_id, idempotency_key) WHERE idempotency_key IS NOT NULL
|
ON CONFLICT (id) DO UPDATE SET status='running', started_at=COALESCE(job_audit.started_at, now()), meta_json=EXCLUDED.meta_json`,
|
||||||
DO UPDATE SET status='running', started_at=now(), meta_json=EXCLUDED.meta_json`,
|
|
||||||
jobID, tenantID, kind, idem, metaJSON)
|
jobID, tenantID, kind, idem, metaJSON)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user