Skip to content

Add asynchronous historical import jobs - #721

Open
salmonumbrella wants to merge 7 commits into
kenn-io:mainfrom
salmonumbrella:feat/issue-378-import-jobs-controller
Open

Add asynchronous historical import jobs#721
salmonumbrella wants to merge 7 commits into
kenn-io:mainfrom
salmonumbrella:feat/issue-378-import-jobs-controller

Conversation

@salmonumbrella

@salmonumbrella salmonumbrella commented Aug 30, 2026

Copy link
Copy Markdown
Contributor

What changed

  • Added authenticated POST /api/v1/imports for bounded Gmail and IMAP historical imports.
  • Added GET /api/v1/imports/{job_id} for durable running, done, and failed status with progress and final counters.
  • Reused sync operations and the daemon operation gate instead of adding a second job queue or in-memory state store.
  • Added one execution lock per source. SQLite uses an operating-system file lock and PostgreSQL uses a database-session lock, so two workers cannot sync the same source at once.
  • Kept bounded Gmail imports out of the incremental cursor. Failed full syncs resume only when the new request has the same source type, query, limit, and resume mode.

The create request returns after the sync has written its durable operation row. That short wait is exempt from the ordinary HTTP request deadline. Cancellation records the operation as failed.

One source lock stays held for the complete sync invocation. This includes Gmail history recovery and the final IMAP mailbox-state write. It releases when the invocation completes, fails, or its process exits. Status reads use the same lock to distinguish a live worker from abandoned database rows. When no worker owns the lock, msgvault marks the abandoned run and operation as failed and preserves compatible checkpoints for the next sync.

Why

Historical imports were only available through the command line. The daemon now exposes the same bounded sync-full path while keeping status in the existing sync tables. Command-line, scheduled, and API syncs share one concurrency rule without a separate job system.

Closes #378

Usage

Create an import:

curl -X POST http://localhost:8080/api/v1/imports \
  -H 'Content-Type: application/json' \
  -H 'X-Api-Key: …' \
  -d '{"account":"archive@example.com","after":"2024-01-01","limit":500}'

Read its status:

curl http://localhost:8080/api/v1/imports/<job_id> \
  -H 'X-Api-Key: …'

@roborev-ci

roborev-ci Bot commented Aug 30, 2026

Copy link
Copy Markdown

roborev: Combined Review (041d043)

Verdict: No Medium, High, or Critical issues found.

Reviewers found no actionable findings at Medium severity or above.


Reviewers: 2 done | Synthesis: codex, 5s | Total: 10m24s

@salmonumbrella
salmonumbrella force-pushed the feat/issue-378-import-jobs-controller branch from 041d043 to 19a8bec Compare August 30, 2026 12:26
@roborev-ci

roborev-ci Bot commented Aug 30, 2026

Copy link
Copy Markdown

roborev: Combined Review (19a8bec)

Verdict: Two medium-severity issues require attention.

Medium

  • internal/api/import_jobs.go:421-424 — Any active sync is treated as resumable, including interrupted incremental Gmail syncs or full syncs with different filters. sync-full may reuse an incompatible checkpoint, causing messages to be skipped or misprocessed. Persist and validate sync type/request parameters before resuming; otherwise start a fresh run.

  • internal/api/import_jobs.go:558-566 — Completion statistics are read only from the latest sync row. When history recovery performs full enumeration followed by incremental catch-up, the job summary reports only catch-up counters rather than total import results. Aggregate the sync summary for the job when marking it complete.


Reviewers: 2 done | Synthesis: codex, 6s | Total: 9m48s

@salmonumbrella
salmonumbrella force-pushed the feat/issue-378-import-jobs-controller branch from 19a8bec to ad67f07 Compare August 30, 2026 14:03
@roborev-ci

roborev-ci Bot commented Aug 30, 2026

Copy link
Copy Markdown

roborev: Combined Review (ad67f07)

Verdict: One medium-severity issue found; no critical or high-severity issues.

Medium

  • internal/api/import_jobs.go:202-214 — Run attribution uses a start time truncated to seconds. A same-source sync created after the baseline but before the queued job starts may fall within the same second and be incorrectly included in the job summary. Capture the baseline after acquiring the operation gate, or use authoritative run IDs/job identifiers instead of wall-clock timestamps.

Reviewers: 2 done | Synthesis: codex, 6s | Total: 9m27s

@salmonumbrella
salmonumbrella force-pushed the feat/issue-378-import-jobs-controller branch from ad67f07 to e83dba5 Compare August 30, 2026 14:16
@roborev-ci

roborev-ci Bot commented Aug 30, 2026

Copy link
Copy Markdown

roborev: Combined Review (e83dba5)

No issues found.


Reviewers: 2 done | Synthesis: codex | Total: 22m50s

@mariusvniekerk mariusvniekerk self-assigned this Aug 30, 2026
@mariusvniekerk

Copy link
Copy Markdown
Contributor

This seems a bit excessive, most of the machinery needed for this was already present.

Historical imports already run through the sync system, but the API kept a
second in-memory job queue and progress model. That duplicated lifecycle and
counter logic, lost status on restart, and made one API route much larger than
the behavior required.

Store the operation status with its sync runs and let the existing operation
gate control concurrency. Imports now use the same durable state and shutdown
rules as other sync work, with no separate queue or retention policy.

Generated with Codex
Co-authored-by: Codex <noreply@openai.com>
@roborev-ci

roborev-ci Bot commented Aug 30, 2026

Copy link
Copy Markdown

roborev: Combined Review (2502532)

Verdict: Three medium-severity reliability issues require attention.

Medium

  • Canceled imports may be reported as successful
    cmd/msgvault/cmd/serve.go:1464-1469
    A canceled import can exit the CLI subprocess with nil after preserving its resumable checkpoint, causing the parent to mark the durable operation done despite interruption. Detect cancellation separately and record an interrupted or failed state.

  • Slow job startup can cause an undiscoverable job
    internal/api/import_jobs.go:165-186; internal/api/server.go:1107-1110
    The POST handler waits for the first sync row while subject to the standard request timeout. If startup is slow, the client may receive no response while work continues without a returned job ID. Create the durable job record before launching work, or return the job ID without waiting for sync-row creation.

  • Operations can remain permanently marked as running
    cmd/msgvault/cmd/serve.go:1460-1469; internal/store/sync.go:531-541
    Completion is recorded only after the subprocess returns. A daemon crash or forced termination can leave completed or abandoned imports reported as running, with no reconciliation path. Reconcile operation state on startup and/or derive terminal state from attributed sync runs.


Reviewers: 2 done | Synthesis: codex, 8s | Total: 11m1s

The import progress test crossed the repository's limit for direct testify
package calls. The regular lint command did not catch that rule, but the
separate CI checker rejected the branch after all Go tests passed.

Use local assertion objects in that test so the checker and the test suite
enforce the same repository convention without changing test behavior.

Generated with Codex
Co-authored-by: Codex <noreply@openai.com>
@roborev-ci

This comment has been minimized.

Starting a second sync for one source used to fail the first run and let the
replacement proceed while the original worker could still be writing. Reject
the second start instead. A stopped worker now leaves a failed, resumable
checkpoint, and the next attempt resumes that checkpoint in a new run.

Historical import creation now reports an active sync as a conflict. It also
waits for its first durable operation row without the ordinary request
deadline, and cancellation records the operation as failed rather than done.

Addresses the three findings reported in the same-head roborev-ci comment:
kenn-io#721 (comment)

Generated with Codex
Co-authored-by: Codex <noreply@openai.com>
@roborev-ci

roborev-ci Bot commented Aug 31, 2026

Copy link
Copy Markdown

roborev: Combined Review (9a948ec)

Verdict: Medium-severity reliability issue found; no concrete security vulnerabilities identified.

Medium

  • Stale sync and import jobs can block future work after a crash.
    Location: internal/store/sync.go:428-435, internal/sync/sync.go:393-395
    If a process crashes or is forcibly terminated, its sync_runs row remains running. Future syncs reject that source with ErrSyncAlreadyActive, and --noresume cannot recover it because StartSync always checks for an active row. Async import jobs can likewise remain permanently running. Add worker ownership/lease recovery or startup reconciliation to mark abandoned runs failed while preserving checkpoints, and finalize abandoned import operations.

Reviewers: 2 done | Synthesis: codex, 7s | Total: 11m23s

A durable running row cannot prove that its worker is still alive. Rejecting
new work from that row alone can leave a source blocked forever after a hard
process exit.

Hold one operating-system or database-session lock for each active source.
Live workers cannot overlap, while process exit releases ownership without a
lease timeout. The next worker records the abandoned run as failed and keeps
its checkpoint available for resumption.

Addresses the stale-worker finding reported at:
kenn-io#721 (comment)

Generated with Codex
Co-authored-by: Codex <noreply@openai.com>
@roborev-ci

roborev-ci Bot commented Aug 31, 2026

Copy link
Copy Markdown

roborev: Combined Review (bf1961a)

Verdict: Three medium-severity issues remain around stale-row recovery, operation finalization, and multi-phase lock ownership.

Medium

  • internal/api/import_jobs.go:124-128; internal/sync/sync.go:388-395
    Callers reject any durable running row before attempting the new liveness lock. After a worker crash, the lock is released but the row remains running, potentially blocking resumable syncs and API imports indefinitely. Use a lock-aware active check and allow stale rows to be recovered by StartSync.

  • cmd/msgvault/cmd/serve.go:1469-1481; internal/store/sync.go:437-450,551-560
    Operation finalization occurs in a separate update after the subprocess exits. If the daemon crashes after the final sync run becomes terminal but before FinishSyncOperation, the operation can remain stuck as running. Persist operations with ownership/recovery, atomically finalize them with the final sync phase, or reconcile unfinished operations on restart.

  • internal/sync/sync.go:928-938; internal/store/sync.go:733-734,803-804
    History recovery releases the source execution lock after the full phase, before incremental catch-up begins. Another process can start a sync during this gap, causing catch-up failure or overlapping phases. Hold an operation-level lock across all recovery phases and release it only after the final phase completes.


Reviewers: 2 done | Synthesis: codex, 10s | Total: 12m24s

@wesm

wesm commented Sep 1, 2026

Copy link
Copy Markdown
Member

reviewing

A running database row cannot prove that its worker still owns the source.
Checking that row before the process lock prevented restart after a crash, and
releasing the lock between Gmail recovery phases allowed another worker into
the same source.

Keep one source owner for the complete sync invocation. Status reads use that
same ownership check to fail abandoned runs and operations, while a live owner
continues to reject every competing sync.

Addresses the three findings reported at:
kenn-io#721 (comment)

Generated with Codex
Co-authored-by: Codex <noreply@openai.com>
@roborev-ci

roborev-ci Bot commented Sep 1, 2026

Copy link
Copy Markdown

roborev: Combined Review (2344f1e)

Verdict: Three medium-severity issues require attention.

Medium

  • internal/sync/sync.go:1223-1234 — Limited or filtered historical imports publish the current Gmail history ID as the source cursor, causing later incremental syncs to skip omitted pre-existing messages. Advance the cursor only for unbounded full syncs or maintain a separate partial-import cursor.

  • internal/api/import_jobs.go:142-145, internal/sync/sync.go:389-417 — Imports can resume a failed checkpoint without verifying that its query or date bounds match the new request, potentially skipping messages or causing provider errors. Persist and compare a request/filter fingerprint, or restart enumeration for incompatible requests.

  • cmd/msgvault/cmd/syncfull.go:597-612 — The sync lock and operation status are finalized before IMAP folder-state persistence completes, allowing another worker to start and have its metadata overwritten by stale state from the previous run. Retain ownership until persistence finishes or move it inside the locked operation.


Reviewers: 2 done | Synthesis: codex, 8s | Total: 14m5s

A bounded Gmail import cannot establish the incremental baseline for mail it
did not enumerate. Preserve the prior source cursor for partial runs, and bind
resumable checkpoints to the exact request that created them.

IMAP also has provider state to save after message enumeration. Keep the same
source owner through that final write so no second worker can start from stale
folder metadata.

Addresses the three findings reported at:
kenn-io#721 (comment)

Generated with Codex
Co-authored-by: Codex <noreply@openai.com>
@roborev-ci

roborev-ci Bot commented Sep 1, 2026

Copy link
Copy Markdown

roborev: Combined Review (333969e)

Verdict: One high-severity and one medium-severity issue remain; no concrete security vulnerabilities were identified.

High

  • internal/store/sync.go:1058-1070; internal/importer/mbox_import.go:137-177; internal/importer/emlx_import.go:152-221; internal/importer/pst_import.go:178-235
    • Importers may reuse an active sync run, allowing concurrent workers to share a sync generation and bypass source locking. After a worker crash, the abandoned checkpoint may also be skipped.
    • Acquire SyncExecution before reading resume state, reject unavailable ownership, then read the recovered checkpoint and create a new run without reusing a live run ID.

Medium

  • internal/calsync/calsync.go:217-231
    • Calendar resume state is read before ownership recovery and only accepted when the row is already failed. A crashed worker therefore loses its page token and restarts from the beginning.
    • Acquire ownership first, then load the latest checkpoint after abandoned rows transition to failed.

Reviewers: 2 done | Synthesis: codex, 8s | Total: 17m20s

@wesm

wesm commented Sep 1, 2026

Copy link
Copy Markdown
Member

@mariusvniekerk please don't make more fixes here because there is a need to reconcile #721 vs #722

@wesm

wesm commented Sep 1, 2026

Copy link
Copy Markdown
Member

rebasing

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Development

Successfully merging this pull request may close these issues.

Expose bounded historical imports as authenticated async HTTP jobs

3 participants