Skip to content

Feature: active archive table#3

Open
tinchoz49 wants to merge 56 commits into
mainfrom
feature/active-archive-and-time-travel
Open

Feature: active archive table#3
tinchoz49 wants to merge 56 commits into
mainfrom
feature/active-archive-and-time-travel

Conversation

@tinchoz49

@tinchoz49 tinchoz49 commented Apr 27, 2026

Copy link
Copy Markdown
Contributor

Summary

This PR implements an Active/Archive split for Duron's PostgreSQL adapter to eliminate hot-path bloat and improve query performance.

Breaking Changes

  • New database schema with active/archive tables
  • New migration required: 20260421153337_large_nitro
  • Spans use a single spans table (no active/archive split)

What's Changed

Core Architecture:

  • Split jobs into jobs_active and jobs_archive
  • Split job_steps into job_steps_active and job_steps_archive
  • Single spans table with no FK constraints to avoid cascade issues
  • Status-based query routing: created/active → active, completed/failed/cancelled → archive

Archive Management:

  • _pruneArchive - scheduled cleanup with advisory locks
  • _truncateArchive - clear all archive data
  • _getArchiveStats - archive metrics
  • REST API endpoints for archive operations

Bug Fixes:

  • Fixed BigInt serialization issue in span inserts
  • Fixed FK constraint ordering during archive operations
  • Added orphan span cleanup in prune
  • Optimized prune to use SQL-native CTEs instead of JS round-trips

Dashboard:

  • "Live Jobs" default tab
  • "Archive" tab with filter toggles
  • "All Jobs" toggle using UNION ALL

Developer Experience:

  • Updated AGENTS.md: do not use git worktrees

Testing

  • 163 tests passing
  • Added test/archive.test.ts with 18 archive-specific tests
  • Added packages/shared-actions/test/process-order.test.ts integration test

- Split jobs/steps into active and archive tables
- Jobs move from active to archive on complete/fail/cancel
- Single spans table without FK constraints
- SQL-native archive operations with USING joins
- Prune with orphan span cleanup
- Time travel restores archived jobs to active
- Dashboard archive page and filter toggles
- Add archive-specific tests
The test was importing from 'duron' package which failed in CI
because workspace resolution doesn't work during test execution.
Moving it to the duron package allows using relative imports.
- Exclude expired active jobs from verify_concurrency CTE count
- _recoverJobs now archives expired jobs as failed (not reset to created)
- Non-expired jobs from unresponsive owners still reset to created
- Add recoverJobsInterval option (default 60s) for periodic recovery
- Recovery runs before fetch when interval has elapsed
- Add test: expired jobs no longer block new job selection
- Update docs: client-api.mdx and multi-worker.mdx
…ecovery test

- Change processTimeout default from 5000ms to 500ms for faster orphan detection
- Update docs (client-api, examples, multi-worker) with new default
- Rename and improve test: 'should recover expired orphan jobs from dead processes'
- Add multiProcessMode + recoverJobsInterval + fake client_id to simulate crash
- Add detailed comment explaining the multi-process orphan recovery scenario
- Keep fetch() recovery only in multiProcessMode to avoid single-process overhead
- Replace sql.raw interpolation with parameterized inArray for groupKey array filter
- Replace sql.raw(JSON.stringify) with sql.json for inputFilter/outputFilter
- Add comprehensive SQL injection tests for both attack vectors

Fixes #1 and #2 from FINDINGS.md
- Add status guard to _deleteJobs to always exclude active jobs
- Fix sql.json() → JSON.stringify() for drizzle-orm 1.0.0-rc.4 compatibility
- Add comprehensive tests for bulk delete safety

Fixes #3 from FINDINGS.md
- Add FOR UPDATE to locked_source CTE to lock the source job row
- Prevents concurrent retry calls from creating duplicate jobs

Fixes #4 from FINDINGS.md
- Register wait in #pendingJobWaits BEFORE checking job status
- If job is already terminal, remove wait and resolve immediately
- Prevents NOTIFY from being missed during the status check await gap

Fixes #5 from FINDINGS.md
- Remove redundant assignment that overwrote parsed input with raw input
- Now input is properly validated by Zod schema before reaching action handlers
- Invalid input will throw validation error instead of silently passing through

Fixes #6 from FINDINGS.md
- End span and delete from #stepSpans before early return
- Prevents memory leak and orphaned 'started but never ended' spans in telemetry

Fixes #7 from FINDINGS.md
- Store listener references in fields for proper cleanup
- Remove listeners in stop() before database.stop() to prevent use-after-close
- Add guard to #setupPushListener to prevent duplicate registration
- Reset listener setup flags on stop() for clean restart

Fixes #8 from FINDINGS.md
- Add check for #starting at top of stop()
- Prevents tearing down incomplete initialization when stop() is called during start()

Fixes #9 from FINDINGS.md
- Add orphan span cleanup query after _deleteJobs
- Removes spans whose job no longer exists in active or archive tables
- Prevents orphan span accumulation after bulk deletion

Fixes #10 from FINDINGS.md
- Add orphan span cleanup query at end of _truncateArchive
- Removes spans whose job no longer exists in active or archive tables
- Prevents permanent orphan spans after archive truncation

Fixes #11 from FINDINGS.md
…utdown

- Add optional signal parameter to RecoverJobsOptions
- Check signal.aborted inside the ping loop to allow early termination
- Prevents slow shutdown when stop() is called during recovery

Fixes #12 from FINDINGS.md
- Replace sql.raw(timeoutMs.toString()) with parameterized ( * interval '1 millisecond')
- Prevents potential SQL injection through timeout values
- Improves code auditability

Fixes #13 from FINDINGS.md
- Replace explicit field-by-field copy with { ...job } and { ...s }
- Prevents new columns from being silently dropped during time-travel restore
- More resilient to schema changes

Fixes #14 from FINDINGS.md
- These dependencies were not imported anywhere in the codebase
- Reduces install size and attack surface

Fixes #16 from FINDINGS.md
- Update @default from 5000 to 500 to match schema default
- Prevents user confusion about actual timeout behavior

Fixes #19 from FINDINGS.md
- Spread enumerable properties from Error instances into result
- Preserves custom properties like 'code' from Node.js errors (ENOENT, ECONNREFUSED)
- Improves debugging context in logs and persisted errors

Fixes #20 from FINDINGS.md
tinchoz49 added 26 commits July 20, 2026 10:21
- Remove 1801-line backup file from repository
- Add *.backup to .gitignore to prevent future commits

Fixes #21 from FINDINGS.md
- Document DATABASE_URL, JWT_SECRET, and OPENAI_API_KEY
- Helps new contributors and users understand required configuration

Fixes #31 from FINDINGS.md
- Add repository URL pointing to GitHub
- Add bugs URL for issue tracking
- Add homepage URL for documentation
- Improves npm discoverability and user experience

Fixes #32 from FINDINGS.md
- Make PostgresBaseAdapter class abstract to prevent direct instantiation
- Make _initDb method abstract to enforce implementation in subclasses
- Prevents runtime errors from missing implementations

Fixes #33 from FINDINGS.md
Remove indexes that are never used in WHERE clauses or are redundant:

jobs_active (15 → 8):
- Remove description, started_at (dashboard-only)
- Remove concurrency_limit, concurrency_step_limit (NEVER queried)
- Remove action_status (redundant with single-column)
- Remove input_fts, output_fts (output is NULL for active jobs)

job_steps_active (8 → 5):
- Remove job_status, job_name (redundant composites)
- Remove output_fts (expensive GIN, rarely used)

spans (11 → 7):
- Remove kind, status_code (rarely queried)
- Remove attributes, events (expensive GIN, dashboard-only)

Total: 34 → 20 indexes (~40% reduction in write overhead)

Fixes #34 from FINDINGS.md
- Add onConflictDoNothing() to job and step restore inserts
- Prevents unhandled PK violations on concurrent time-travel of same job
- Gracefully handles race condition as no-op

Fixes #15 from FINDINGS.md
- Remove _notify calls from completeJobStep, failJobStep, delayJobStep, cancelJobStep
- Remove step-status-changed and step-delayed event type definitions
- Eliminates wasted DB work from unhandled NOTIFYs

Fixes #17 from FINDINGS.md
- Add JSDoc explaining that bulk delete only affects active (non-running) jobs
- Archive jobs are not affected by bulk delete
- Document that active jobs are always excluded for safety

Fixes #18 from FINDINGS.md
- Move job existence check before step update
- Prevents wasted DB work when cancelling non-existent jobs

Fixes #26 from FINDINGS.md
- Replace djb2 hash with fixed key (42)
- Simpler and avoids potential lock contention from hash collisions

Fixes #24 from FINDINGS.md
- Rename column in schema.ts for both active and archive step tables
- Update all raw SQL references in base.ts
- TypeScript field was already 'parallel', now DB column matches

Fixes #25 from FINDINGS.md
- Mark Finding #30 as already fixed in FINDINGS.md
- Regenerate migrations for branch→parallel rename and index removals
- Add improvement plans directory
- Remove double-check of concurrency limit after acquiring lock
- Use count from eligible_groups instead of re-counting
- Eliminates correlated subquery per candidate job

Fixes #23 from FINDINGS.md
…rips

- Query both active and archive tables in single query
- Add NULL as jobFinishedAt placeholder for active table
- Return camelCase column names for Zod validation

Fixes #27 from FINDINGS.md
…y leak

- Store abort listener reference
- Remove listener when release() is called
- Prevents listener from staying attached after resolution

Fixes #28 from FINDINGS.md
- Add .catch() to getJobStatus/getJobById chain in waitForJob
- Settle wait as null on error instead of hanging forever
- Clean up pending wait entry on failure
- Remove unused registeredResolve variable

Found in post-fix re-audit
- Use explicit column list in outer SELECT instead of SELECT *
- output stays in subquery only for the search filter
- Postgres no longer transfers output jsonb to the client
- Drop unnecessary NULL jobFinishedAt placeholder

Found in post-fix re-audit
…rter support

- Remove telemetry.local option and LocalSpanExporter
- Remove spans table from schema
- Remove /jobs/:id/spans and /steps/:id/spans API endpoints
- Remove dashboard SpansPanel, SpansContext, useJobSpans/useStepSpans
- Remove spansEnabled getter, getJobSpans(), getStepSpans(), flushTelemetry()
- Move @opentelemetry/sdk-trace-base and sdk-trace-node to optional peer deps
- Add traceExporter array support (SpanExporter | SpanExporter[])
- Add batchDelayMs option for configuring export interval
- Add otel-integration.test.ts with in-memory exporter tests
- Add ADR 0001 documenting the decision
- Add CONTEXT.md with domain glossary
- Update telemetry.mdx to focus on external exporters
- Remove spans table documentation from adapters.mdx
- Remove getSpans, spansEnabled, flushTelemetry from client-api.mdx
- Remove spansEnabled and spans endpoints from server-api.mdx
- Remove spans visualization from dashboard.mdx
- Fix trailing comma in packages/duron/package.json exports (root cause of TS1295 errors)
- Remove dead spans-related code from dashboard (SpansProvider, spans-panel, spans-context refs)
- Remove unused imports/variables left from otelia removal (adapter.ts, schema.ts, base.ts, test files)
- Run oxfmt on affected files
client.ts statically imports @opentelemetry/resources,
@opentelemetry/semantic-conventions, sdk-trace-base, and
sdk-trace-node. These must be in dependencies — the module
loader resolves all static imports on first import of
'duron/client', regardless of whether telemetry is configured.

Removes the optional peerDependencies since they're not optional.
- telemetry.mdx: update installation to only require exporter package
- client-api.mdx: remove stale local/spanProcessors options, show current API
- Fix stale comment in client.ts referencing telemetry.local
- Remove spansCount from ArchiveStatsResponse type (server no longer returns it)
- Remove dead 'Archived Spans' card from archive page
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant