Repository navigation
hammerwork 2.0.0
A hardening release (#7). It fixes
jobs that could run twice, lost spawned jobs, concurrent migrations crashing, data loss in
CLI backups, plaintext writes to encrypted queues, SQL injection in the CLI and several
dashboard authentication flaws. It also finishes features that were stubs or ignored their
configuration. It is a major version because of the breaking changes listed below.
Upgrading from 1.x
- Run the migrations before starting 2.0 workers:
cargo hammerwork migration run --database-url <url>, orauto_migrate. 2.0 reads new columns on every job query.
Migrations are idempotent and serialized by a database lock, so several replicas can
run them at once. On MySQL 8, 2.0 also completes schemas that 1.15's migrations left
half-applied. - Encryption (
encryptionfeature):- Upgrade every process that decrypts before any process encrypts with 2.0; older
versions can't verify the new payload hash. vault://andazure://keys must be exactly 32 bytes; padding is no longer applied.- AWS and GCP KMS deployments store a data key on their first start.
- Upgrade every process that decrypts before any process encrypts with 2.0; older
- Dashboard (
hammerwork-web):- It refuses to start with authentication enabled and no password. Configure a
password hash, or pass--no-authto run open explicitly. --corsneeds at least one--allowed-origin.- API clients must send
Content-Type: application/jsonandContent-Lengthwith
request bodies. - The
authfeature (bcrypt) is on by default.
- It refuses to start with authentication enabled and no password. Configure a
- CLI (
cargo-hammerwork):--database-urlis-uand the queue flag is-n/--queueeverywhere; the old
spellings still work.backup createwrites format2.0; 1.x backups still restore.workflow createneeds--jobs-file, and thearchive *-policycommands are gone.- Connections time out after 10 s (
connect_timeout_secs).
- API:
- Settings and methods that never had any effect are removed (see Removed). Old
configuration files that still contain them keep loading. - Signatures changed:
DatabaseQueue::heartbeat_jobtakes the dequeued&Job.JobSpawnExt::with_spawn_config/with_spawningreturnResult.RetryStrategy::Customholds anArc.hammerwork_web::server::app_routestakes the allowed origins.
- Several config and stats structs gained fields. Build them with
..Default::default().
- Settings and methods that never had any effect are removed (see Removed). Old
- Behaviour:
WorkerPool::new()no longer autoscales.- A spawn failure now fails (and retries) the parent.
- A disabled recurring job's pending run is held.
- A recurring job that starts late after an outage runs once and then follows its
schedule; it no longer runs a second, immediate catch-up. - Zero
polling_interval/job_timeoutvalues are rejected. WorkerPool::from_hammerwork_configarchives and purges on a schedule when
[archive] enabled = true.
Added
- Live dashboard updates (#71). Connected WebSocket clients now receive
JobUpdatemessages when jobs are created, started, completed, failed or timed out, andQueueUpdatemessages when a queue's counts change, filtered by each client's subscriptions. Nothing published them before, so the dashboard's live updates never fired. The standalone dashboard runs apart from the workers and cannot see their in-process events, so it polls the database while at least one client is connected: everywebsocket.live_update_interval(default 2 s, zero disables), at mostwebsocket.live_update_max_jobschanged jobs per poll (default 100). Both settings are optional in configuration files. A dashboard embedded in the workers' process can also forward theirEventManagerevents as they happen withWebDashboard::with_event_manager. Newhammerwork_web::livemodule (LiveUpdates,forward_job_events) andJobHistory::database_now/recent_job_changes. - Automatic archival (
[archive] enabled = trueused to do nothing at runtime, andpurge_archived_after/delete_afterwere never applied):WorkerPool::with_archival(policy, config, interval)/without_archival()(off by default). Everyintervalthe pool archives the jobs of every queue that the policy makes eligible, batch by batch (reasonAutomatic, archived by"worker_pool"), then purges archived jobs older thanpurge_archived_after, and logs the result. The first pass runs at startup. Passes run in their own task, so workers are never blocked; on shutdown no new pass starts and a running pass stops before its next batch. Concurrent pools and processes are safe (rows are claimed withFOR UPDATE SKIP LOCKED; verified on both backends).WorkerPool::from_hammerwork_configenables it whenarchive.enabled, usingarchive_after,delete_after,compression_leveland the newarchive.check_interval(default"1h";ArchiveConfig::validaterejects zero, andHammerworkConfig::from_filecalls it). Files withoutcheck_intervalstill load.JobArchiver::run_scheduled_pass(queue, archived_by, should_stop)(returnsScheduledArchivalPass),JobArchiver::set_default_policy/with_default_policy/default_policy(the policy for queues without their own and for operations over all queues; was alwaysArchivalPolicy::default()), andJobArchiver::with_config.ArchivalConfig::verify_compression(on by default) is now applied: each compressed payload is decompressed and compared with the original before it is stored, and a mismatch fails the batch withHammerworkError::Archive(the transaction is rolled back).
- Dashboard table maintenance:
POST /api/system/maintenancevacuum,reindexandoptimizework on PostgreSQL and MySQL (they returned 501). PostgreSQL runsVACUUM (ANALYZE),REINDEX TABLEandANALYZErespectively; MySQL runsOPTIMIZE TABLEfor all three (InnoDB rebuilds the table and its indexes and refreshes statistics). They run on every Hammerwork table that exists, or only ontargetwhen it names one; other names are rejected with 400 and never reach SQL. The response lists each statement (and MySQL's result messages), anddry_runlists them without running anything. NewJobHistory::table_maintenance,TableMaintenanceandHAMMERWORK_TABLESinhammerwork_web::api::history. Job::with_scheduled_at(DateTime<Utc>)builder to schedule a job at a specific time (the guides already used it).- Real data in place of placeholders (part of #41):
- CLI
worker status [--queue] [--jobs]: running jobs per queue with their lease/heartbeat state (active, expired, none), using the database clock. - CLI
webhook add/list/update/toggle/removenow persist towebhooks.jsonbesideconfig.toml(HAMMERWORK_WEBHOOKS_FILEoverrides; mode 0600) instead of silently discarding the configuration, andwebhook testsends a real request (configured method, headers, auth and HMAC signature) and fails when the endpoint is unreachable or returns an error status. Not-found and unconfirmed removals are now errors instead of exit 0. - Web
GET /api/stats/trends,/api/stats/detailedand/api/queues/{name}: hourly completed/failed counts, average processing time and error patterns (realfirst_seen,last_seenandaffected_queues) are computed fromhammerwork_jobsfor PostgreSQL and MySQL;time_rangeis honoured and validated. Hours with no activity are zero-filled;avg_processing_time_msisnullwhen nothing completed. - Web
GET /api/queues/{name}/jobsreturns the queue's jobs (it returned a stub message),last_job_at/oldest_pending_jobare filled in on queue details, andPOST /api/queues/{name}/actionsclear_completedworks (it always failed). The dashboard's "clear queue" button now calls it (it called a nonexistent endpoint). - Web maintenance
cleanupdry run reports the real number of dead jobs it would delete.
- CLI
- Delivery that does what it reports (#41, library part). See Fixed for what used to happen.
- Webhook payload templates:
WebhookConfig::payload_template(new builderwith_payload_template) is a JSON document whose strings can contain{{event.*}}placeholders (event,event.job_id,event.queue_name,event.error.message,event.payload.<path>,event.metadata.<key>, ...). A string that is exactly one placeholder keeps the value's JSON type; placeholders inside text are interpolated. Rendering works on the parsed JSON, so event data cannot inject JSON. Newwebhooks::PayloadTemplate(parse,render) andWebhookConfig::validate. Seedocs/webhooks.md. - SMTP email alerts (
alertingfeature, vialettrewith rustls):AlertTarget::Emailhas a newsmtp: Option<SmtpConfig>field (host, port, from, username, password asSecretSource::Environment/Static,SmtpTls::StartTls(default, required) /Implicit/None, timeout). NewAlertingConfig::email_via_smtp,AlertingConfig::validate,AlertManager::try_new.SmtpConfigandSmtpTlsare re-exported at the crate root. Seedocs/monitoring.md. StreamRetryPolicy::delay_for_retry(n),StreamStats::last_error, anddocs/streaming.md.
- Webhook payload templates:
- 🔐 Job payloads are encrypted at rest (#11), PostgreSQL and MySQL.
Job::with_encryption/with_pii_fields/with_retention_policyused to be stored on theJoband ignored, so "encrypted" payloads were written and processed in plaintext (see Security).- New
JobQueue::with_encryption(engine)(andencryption_engine()).enqueue,enqueue_batch,enqueue_workflowandenqueue_cron_jobencrypt every job that has an encryption config before anything is written and fill in the migration 011 columns (is_encrypted,encryption_key_id,encryption_algorithm,encrypted_payload,encryption_nonce,encryption_tag,encryption_metadata,payload_hash,pii_fields,retention_policy,retention_delete_at,encrypted_at). No new migration is needed. - The
payloadcolumn never holds encrypted data in plaintext: it is{"encrypted": true}when the whole payload is encrypted, or the payload with each PII field's value replaced by"[ENCRYPTED]"(encryption::job_payload). PII field names are paths ("customer.ssn"). - Workers decrypt the payload just before calling the handler (new
JobQueue::decrypt_job); hooks, events and the recorded outcome keep the redacted job.dequeue,get_joband the other reads return jobs as stored, so the web dashboard and CLI show the redacted payload and never need a key. - Fails closed: enqueueing a job with an encryption config on a queue without an engine, or with a different algorithm or key id than the engine's, fails with the new
HammerworkError::Encryptionand writes nothing. A worker that cannot decrypt a job (no engine, unknown key, tampered data, or a build without theencryptionfeature) fails the run without calling the handler. - Archiving and restoring copy the encrypted columns between
hammerwork_jobsandhammerwork_jobs_archivewithout decrypting (also in builds without theencryptionfeature);get_jobof an archived job reportsis_encryptedandpii_fields. - Retention is enforced by the new
DatabaseQueue::purge_expired_encrypted_jobs()(returnsEncryptedJobPurge): it deletes finished (Completed,Failed,Dead,TimedOut) encrypted jobs whoseretention_delete_athas passed, and expired encrypted jobs in the archive. Pending and running jobs are never deleted. Default implementation returns an error, so customDatabaseQueueimplementations keep compiling. EncryptionEngine::with_decryption_key(key_id, &source)adds a decrypt-only key, so jobs encrypted under a previous key id stay readable after switching keys. NewEncryptionEngine::config(),key_id()andalgorithm().- CLI:
cargo hammerwork maintenance purge-encrypted [--dry-run | --confirm];cargo hammerwork job showprints the key id, algorithm and retention of encrypted jobs.
- New
- 🔐 Encryption follow-ups (#38)
- Ciphertexts are bound to their job. Job payloads are encrypted with AEAD associated data covering the job id, queue name, key id, algorithm, PII fields and encrypted fields, so swapping
encrypted_payload/ nonce / tag between rows, moving a row to another queue, or changing its key id or PII fields makes decryption fail (the worker fails the run without calling the handler).encryption_metadatarecords the newformat_version(EncryptionMetadata::FORMAT_JOB_BOUND); payloads without it (written before this change) still decrypt without associated data and are not re-encrypted. Archive and restore keep every bound value. NewEncryptionEngine::encrypt_payload_with_associated_data/decrypt_payload_with_associated_dataandencryption::job_payload::job_associated_data. No migration. [encryption]configuration section (HammerworkConfig::encryption,PayloadEncryptionConfig): algorithm, key source, key id, compression, default retention, purge interval,encrypted_queuesand decrypt-onlydecryption_keys.JobQueue::from_config(newJobQueue::apply_encryption_config) builds the engine withnew_with_pool, so a configured queue encrypts without code. Keys are only referenced (KeySourceRef:env://VAR,aws://,gcp://,vault://,azure://), never written in the file, printed byDebugor saved bysave_to_file. Fails closed on unknown fields, invalid key sources or algorithms, unloadable keys, and an enabled section without theencryptionfeature.HammerworkConfig::from_envreadsHAMMERWORK_ENCRYPTION_*and rejects invalid values. The section is optional, so existing files still load.JobQueue::with_encrypted_queues(["payments"])("*": all) encrypts every job on those queues, also jobs withoutwith_encryption.- Scheduled retention purge:
WorkerPool::with_encrypted_job_purge(interval)/without_encrypted_job_purge()(off by default) andWorkerPool::from_hammerwork_config(worker, &config)(usesencryption.purge_interval_secs). Each purge runs in its own task, so shutdown never cancels one mid-transaction; the stale job reaper now runs the same way. TestQueueencryption:TestQueue::with_encryption,with_encrypted_queues,encryption_engineanddecrypt_jobapply the same sealing, redaction, fail-closed and decryption semantics as the database backends;purge_expired_encrypted_jobsis implemented against theMockClock.- Archived encrypted jobs must be restored to be decrypted (documented in
docs/encryption.md);decrypt_jobon an archived job now says so instead of reporting a missing ciphertext.
- Ciphertexts are bound to their job. Job payloads are encrypted with AEAD associated data covering the job id, queue name, key id, algorithm, PII fields and encrypted fields, so swapping
- ⚙️
HammerworkConfigis now consumed by the library (#3)JobQueue::<Postgres|MySql>::from_configbuilds the connection pool fromdatabase(URL, pool size, connection timeout), runs migrations whenauto_migrateis set, and registers per-queue throttles fromrate_limitingWorker::with_config(&WorkerConfig)applies poll interval, job timeout, priority weights and retry strategyWorker::with_hammerwork_config(&HammerworkConfig)also applies the matching rate-limit throttle and alerting configWorkerPool::from_configcreatespool_sizeworkers and configures autoscaling fromWorkerConfigWebhookManager::from_configandStreamManager::from_configbuild managers from thewebhooksandstreamingsectionsArchiveConfig::archival_policy/archival_config,RateLimitingConfig::throttle_for,WorkerConfig::autoscale_config,DatabaseConfig::connection_timeout
- 🩺 Job leases and stale job recovery (#19 C2): jobs left
Runningby a crashed or killed worker are no longer stuck forever- Migration
015_add_job_leasesaddslast_heartbeat_atandlease_expires_attohammerwork_jobs(PostgreSQL and MySQL). Runcargo hammerwork migration runbefore upgrading workers. - Workers heartbeat running jobs every third of their lease (
Worker::with_lease_duration, default 5 minutes) DatabaseQueue::heartbeat_jobandDatabaseQueue::requeue_stale_jobs(older_than)(returnsStaleJobRecovery). Expired jobs go back toPending, or toDeadwhen out of attempts. Safe to run concurrently (FOR UPDATE SKIP LOCKEDplus astatus = 'Running'guard). Both methods have default implementations, so customDatabaseQueueimplementations keep compiling.WorkerPoolruns the reaper every 60 seconds (with_stale_job_reaper,without_stale_job_reaper)- CLI:
cargo hammerwork job requeue-stale [--older-than-secs N]
- Migration
Worker::with_shutdown_grace_period(default 30 seconds) andRetryStrategy::validate- 🔒 Job lifecycle state machine (#19 C3/H2/H3/H5/H6/M1–M3)
DatabaseQueue::finish_job_run(&run, JobOutcome) -> Option<RecordedOutcome>: workers record each run through it. It applies only while the job is stillRunningthe run that was dequeued (sameattemptsandstarted_at) and returnsNoneotherwise, so a zombie worker whose job was reclaimed by the stale job reaper, changed by an operator or re-run elsewhere cannot overwrite it. Has a default implementation (non-transactional, composed from the manual methods), so customDatabaseQueueimplementations keep compiling.- New types
JobOutcome,RecordedOutcomeandJobTransition(re-exported at the crate root) and modulequeue::lifecycle; new errorHammerworkError::InvalidJobTransition. - Migration
018_add_job_retry_strategyaddshammerwork_jobs.retry_strategy(JSON) and an index on(batch_id, status). Required: every job query reads the new column, so runcargo hammerwork migration runbefore upgrading.
Changed
TestQueuereschedules recurring jobs in place (#71), like PostgreSQL and MySQL: after a run (finish_job_run) and onreschedule_cron_jobthe same job (same id) goes back toPendingfor its next run, instead of a new job being created for it.reschedule_cron_jobnow checks the job's status like the database backends (not fromCompletedorArchived),get_due_cron_jobslists pending recurring jobs whose time has come (it listedCompletedones), and completing a recurring job by hand withcomplete_jobno longer computes a next run. Tests that counted the extra job need updating.hammerwork_web::config::WebSocketConfighas two new public fields,live_update_intervalandlive_update_max_jobs; code that builds it with a struct literal needs..Default::default().- Upgrade notes for #64 (see Security):
vault://andazure://keys must be exactly 32 bytes (base64 in the Vaultkeyfield). A source that relied on padding or passphrase hashing now fails to load; store a real 32-byte key. Jobs encrypted under the old derived key cannot be decrypted with the new one, so drain or re-enqueue them before switching.cargo hammerwork backup createwrites format2.0: every column, withpriorityas its numeric level.backup restorestill reads1.0files.cargo hammerwork job enqueue,batch enqueue,cron createandworkflow createfail with an error when they would store a plaintext job on a queue that holds encrypted jobs, or when the configured encryption key cannot be loaded;hammerwork-webdoes not start in the latter case.ExternalKmsConfig,WebhookConfig,WebhookAuth,AlertTarget,StreamBackend,DatabaseConfig,cargo_hammerwork::config::Config,hammerwork_web::DashboardConfigandAuthConfigimplementDebugby hand (secrets redacted) instead of deriving it.cargo_hammerwork::config::Confighas a newencryption_configfield.
WorkerPool::new()no longer autoscales (#64 H9). Autoscaling is enabled only bywith_autoscaling(...)orWorkerPool::from_configwithworker.autoscaling_enabled.AutoscaleConfig::default()and the presets are still enabled, sowith_autoscaling(AutoscaleConfig::default())keeps working.- Alerts are checked by the worker's monitoring task, not its polling loop (#64 H10): queue depth, worker starvation, error rate and processing time alerts are evaluated every
Worker::with_monitoring_interval(new, default 30 seconds) instead of on every poll. - The rate limiter only spends a token when a job is claimed (#64 M6). New
RateLimiter::refundandTokenBucket::refund. - Zero durations (#64 M5):
HammerworkConfig::from_file/from_envandWorkerPool::from_hammerwork_configrejectworker.polling_interval = 0andworker.job_timeout = 0(newWorkerConfig::validate).Worker::with_poll_intervalwaits at leastMIN_POLL_INTERVAL(10ms) between idle polls, andwith_default_timeout(Duration::ZERO)means no default timeout. DatabaseQueue::heartbeat_jobtakes the run (&Jobas dequeued) instead of a job id, andRecordedOutcomehas a newspawnedfield.- CLI
cron enable/cron disableuse the library'senable_recurring_job/disable_recurring_job(see Fixed). - CLI flag consistency (#61); old spellings keep working as aliases:
queuecommands take-u/--database-urllike every other group (-dremains as a hidden alias).- The queue filter is
-n/--queueeverywhere (job purge,spawn list/stats/pending/monitor,archive run/list/stats);-Qandarchive --queue-namestill work. job retryandjob cancelaccept the job id positionally (job retry <JOB_ID>);--job-idstill works, and passing both is an error.archive run --no-compressdisables compression (--compress falseis still accepted);batch enqueue --progress-everyis an alias of--batch-size.- Help text fixes:
job retryalso retries timed-out jobs,batch enqueue --queueis required,--batch-sizeonly controls progress output,batch retry --max-attempts-reachedis a flag.
- Migrations run exactly as written. Both runners used to re-serialize each migration through
sqlparser(PostgreSQL also patched the output to restoreEXECUTE FUNCTION f()), and fell back to naive splitting with anERRORlog when the parser couldn't handle a statement such as aDO $$block. They now share a small splitter that only finds top-level;terminators, skipping string literals, quoted identifiers, comments and PostgreSQL dollar-quoted bodies, and executes the original text. Thesqlparserdependency is removed. The resulting schema is identical on both backends. - Web
POST /api/queues/{name}/actionsclear_deadnow only deletes the named queue's dead jobs older than 7 days; it previously purged dead jobs from every queue while reporting it had cleared one./api/system/metricscustom_metrics_countandperformance_metrics.{database_response_time_ms, active_workers, worker_utilization}are now nullable, andhourly_trends[].avg_processing_time_msisnull(not a global average) for hours without completions. DashboardGET /api/stats/detailedreports the real uptime. - Breaking (
alerting):AlertTarget::Emailhas a newsmtpfield, so code that constructs or exhaustively matches the variant must add it (smtp: None/..). Configuration files without it still parse, butfrom_filethen rejects the target.AlertingConfig::email(recipient)is deprecated because the target it creates cannot send; useemail_via_smtp. - Breaking:
StreamStatshas a newlast_errorfield (defaults toNonewhen deserializing); struct literals must set it. - Behaviour:
StreamRetryPolicy::max_attemptsis the total number of delivery attempts per event, including the first one (it was never applied before). - MSRV is now 1.91.1 (first raised to 1.88 for the Azure SDK 1.x, then to 1.91.1 because every AWS SDK release containing the fix for GHSA-8ffr-xgwf-xj56 in
aws-smithy-jsonrequires it). Was previously declared as 1.86, but dependencies already needed a newer compiler.
The workspace uses resolver 3, so the committedCargo.lockis resolved for 1.91.1, and CI builds every crate and target on 1.91.1. Cargo.lockis committed for reproducible builds.- Breaking:
RetryStrategy::Customnow holds anArc<dyn Fn(u32) -> Duration + Send + Sync>(retry::CustomRetryFn) instead of aBox, so it can be cloned. Code usingRetryStrategy::custom(..)is unaffected; code constructing the variant directly must switchBox::newtoArc::new. TwoCustomstrategies now compare equal when they share the same function. WorkerPool::shutdownnow waits until every worker has stopped (bounded by each worker's shutdown grace period) instead of returning as soon as the signal is sent- CI: replaced the disabled
Integration Testsworkflow with.github/workflows/ci.yml: rustfmt, clippy (--all-targets --all-features -D warnings), unit tests, PostgreSQL 16 and MySQL 8 integration jobs, and acargo auditjob. Runs on pushes and pull requests tomasterand on demand (#7). - Behaviour (#19): manual status transitions are guarded.
complete_job,fail_job,retry_job,mark_job_dead,mark_job_timed_out,reschedule_cron_jobandretry_dead_jobonly apply from the statuses inJobTransition::allowed_from(e.g.Completedis final,Deadis only left throughretry_dead_job, timeouts only apply toRunningjobs) and returnHammerworkError::InvalidJobTransitionotherwise, orJobNotFoundfor a missing job. They used to update any row by id and returnOkeven when the job did not exist.retry_dead_jobnow also re-runsTimedOutjobs. - Behaviour (#19):
complete_job,fail_job,mark_job_deadandmark_job_timed_outapply the job's side effects in the same transaction: dependents are resolved or failed, the workflow'sFailurePolicyand the batch'sPartialFailureModeare applied, and workflow and batch progress is updated. - Behaviour (#19 H3): the job's
max_attemptsis the retry limit.Worker::with_max_retries(n)now only caps it (min(max_attempts, n)) and workers have no cap by default; it used to replacemax_attempts(default 3). Workers no longer leave jobs in a terminalFailedstate: the last failed attempt makes the jobDead. - Behaviour (#19 M1): a timed-out run is retried while the job has attempts left (the
on_job_timeouthook andTimedOutevent fire for every timeout, followed by the retry hook/event);TimedOutis only terminal on the last attempt. - Behaviour (#19 M2): enqueueing a job whose retry strategy is
RetryStrategy::Customfails withInvalidJobPayload, since a closure cannot be stored; it used to be dropped silently. Set custom strategies withWorker::with_default_retry_strategy. - hammerwork-web: the
retryjob action re-runsDeadjobs throughretry_dead_job(which resets attempts) and other jobs throughretry_job; non-retryable statuses (e.g.Completed) return an error. - AWS KMS clients (
aws-kmsfeature) now load config withBehaviorVersion::latest()instead of the deprecatedv2025_01_17, matching the Kinesis client. This picks up the SDK's newer defaults, including HTTP(S) proxy settings from the environment.
Removed
- CLI and dashboard placeholders that printed or returned made-up data (part of #41):
cargo hammerwork worker start,worker listandworker stop: there is no worker registry, so they could only print simulated output. Useworker status(new, below) andjob requeue-stale.cargo hammerwork monitor logs: it printed simulated log lines; no log storage exists.cargo hammerwork webhook stats: delivery statistics only exist in the memory of the process running theWebhookManager.- The whole
cargo hammerwork streamingcommand: it never read or wrote configuration, and itstest,statsandhealthsubcommands printed success without contacting anything. Configure streams in code withStreamManager. - The web dashboard's
GET /api/spawn/infoplaceholder route, which advertised spawn endpoints that never existed (also removed from the web README anddocs/job-spawning.md). Spawn trees remain available throughcargo hammerwork spawn. - Made-up dashboard values: mock
recent_operationsinGET /api/archive/stats, estimateddatabase_response_time_ms, andactive_workers/worker_utilizationinGET /api/stats/detailed(running jobs are not workers). These are now measured ornull.
- Unused
ArchiveConfigfieldsarchive_directory,max_file_size_bytesandinclude_payloads. Existing TOML files containing them still load. - Breaking: settings and APIs that never did anything (they were briefly deprecated during the #64 hardening and are removed for 2.0.0). Configuration files that still contain the removed keys keep loading; the keys are ignored.
PriorityWeights::with_fairness_factor/fairness_factor(and thefairness_factorkey). Weighted selection already gives every priority with runnable jobs a share proportional to its weight, so nothing starves; raise a weight instead.AutoscaleConfig::idle_timeout/with_idle_timeout. Scale-down followsevaluation_windowandcooldown_period.AlertingConfig::custom_thresholds/with_custom_threshold(and the[alerting.custom_thresholds]table). No alert read them.StatsConfig::cleanup_interval_secs. Old events are pruned on every insert.DatabaseConfig::create_tables(and thecreate_tableskey). Tables are created by migrations (auto_migrate).MetricsConfig::registry_name(and theregistry_namekey). The Prometheus registry never used it.encryption:EncryptionEngine::set_key_manager. The engine never used the key manager.- hammerwork-web:
DashboardConfig::request_timeout(and therequest_timeoutkey; the dashboard applies no timeout, put one on a reverse proxy) and the unusedapi::archive::PolicyRequest/PolicyResponsetypes. - cargo-hammerwork: the unused public
commands::spawn::SpawnOperationtype.
- Breaking:
ArchivalConfig::max_payload_sizeandArchivalConfig::with_max_payload_size. Nothing ever read the limit, and skipping or truncating oversized payloads would leave jobs that can never be archived or restored intact. Serialized configs containing the field still deserialize.
Security
- Encryption and data-safety hardening (part of #64: C3, H1, H2, H4, M14, M15, M17):
- C3 —
cargo hammerwork backupdestroyed encrypted payloads.backup createexported 13 columns, so an encrypted job was backed up as its redacted placeholder and restored withis_encrypted = falseand no ciphertext (the payload was unrecoverable); cron schedules, recurrence, timeouts, retry strategies, dependencies, workflows, batches, results and tracing ids were dropped too. JSON backups (format2.0) now hold every column ofhammerwork_jobs, read frominformation_schemaso columns added by future migrations are included (a column type the backup cannot copy is an error), with one encoding for both backends (bytes as base64, timestamps as RFC 3339 with microseconds, arrays as JSON arrays).backup restorewrites every column back, checks the whole file first and inserts in one transaction; existing ids are skipped, never overwritten or duplicated (MySQL no longer usesINSERT IGNORE, which turned conversion errors into warnings); a non-null value for a column the database lacks is an error. Format1.0backups still restore. CSV stays an export and cannot be restored. Newcargo_hammerwork::utils::job_rows. - H1 — the CLI and the web dashboard wrote plaintext to encrypted queues. They built their queue without the application's
[encryption]settings. Commands that write jobs (job enqueue,batch enqueue,cron create,workflow create) andPOST /api/jobsnow load the application's settings (newPayloadEncryptionConfig::load: the[encryption]section of ahammerwork.tomlplusHAMMERWORK_ENCRYPTION_*; CLIconfig set encryption_config <path>/HAMMERWORK_ENCRYPTION_CONFIG, dashboardHAMMERWORK_ENCRYPTION_CONFIG) and encrypt like the application. If the key cannot be loaded the CLI refuses to write and the dashboard refuses to start. Without settings, both refuse to store a plaintext job on a queue that already holds encrypted jobs (newJobQueue::with_plaintext_guard, applied toenqueue,enqueue_batch,enqueue_workflowandenqueue_cron_job). Newjob enqueue --encrypt/--pii-field.cargo-hammerworkandhammerwork-webnow enable hammerwork'sencryptionfeature;cargo-hammerworkgainsaws-kms,gcp-kms,vault-kmsandazure-kvfeatures for KMS key sources. - H2 —
get_dead_jobs/get_dead_jobs_by_queuereturned encrypted jobs as plaintext. They selected 15 columns and hard-codedis_encrypted = false, sodecrypt_jobreturned the redacted payload without error. They now return the full job (ciphertext, key id, PII fields, cron, batch, workflow, tracing and result columns) on both backends; a test covers every read path that builds aJob. - H4 — after a KMS data-key rotation, other processes sent new jobs to Dead. An engine only knew the key versions it loaded at start.
EncryptionEngine::new_with_poolengines now keep the pool and, when a payload does not decrypt with the keys they hold, load the stored versions they lack (and other key ids stored in the table) and retry; they then encrypt with the newest active version. NewEncryptionEngine::reload_kms_keys(). A failed reload leaves the run failed and retried as before. - M14 — secrets in
Debugoutput, logs andconfig show.Debugno longer prints the database URL password (DatabaseConfig, CLIConfig, webDashboardConfig), webhook signing secrets, header values, auth tokens and passwords and URL paths (WebhookConfig,WebhookAuth,AlertTarget), streaming credentials and config values (StreamBackend), KMS credentials (ExternalKmsConfig, the Azure client secret) or the dashboard password hash (AuthConfig). Adding a webhook logs only the scheme and host of its URL.cargo hammerwork config showmasks the database password. Newhammerwork::config::redact_urlandredact_url_path. - M15 — weak Vault and Azure keys were padded. A Vault
keythat was not base64 of exactly 32 bytes was hashed or padded into a key (a 1-byte value gave a 32-byte key with 8 bits of entropy), and short Azure key material was padded. Both are now rejected withEncryptionError::InvalidConfiguration, for engine data keys andKeyManagermaster keys. - M17 — key material is zeroized. Engine keys (now shared, not copied per operation), KMS data keys,
KeyManagermaster, root and cached keys, and keys read from the environment, files, Vault and Azure are held inzeroize::Zeroizingand wiped when dropped (new dependencyzeroize, part of theencryptionfeature).
- C3 —
- SQL injection in
cargo-hammerwork(part of #7):cron list/cron next,monitor dashboard/metrics,backup create,queue stats/health,spawn list/stats/pendingspliced the--queuename into the SQL text inside quotes, so a queue name such asx' OR '1'='1changed the statement. Every value in these statements (queue names, hour/day windows,LIMIT, cutoff timestamps, status names) is now a bound parameter with the placeholder style of the backend ($nPostgreSQL,?MySQL), built by the newcargo_hammerwork::utils::sql::SqlParams;job list's--last-hours/--limit,job purge --older-than-days,maintenance vacuumcutoffs and the bulk selectors behindbatch retry/cancelandmaintenance cleanup/check(JobSelector, whose free-formextraSQL fragments were replaced by typed, bound filters) no longer format numbers or timestamps into the SQL either.hammerwork-webwas audited and already binds everything. Each fixed command has database-backed tests with a queue name full of quotes and SQL metacharacters on both backends, andtests/sql_query_tests.rsnow exercises the real query builders instead of copies of their SQL. - Job payload encryption was never applied (#11): with the
encryptionfeature, jobs created withwith_encryption/with_pii_fieldswere stored and processed in plaintext and the encryption columns stayed empty. They are now encrypted at rest (see Added). Jobs enqueued by earlier versions remain in plaintext. - The payload integrity hash stored next to an encrypted payload is now an HMAC-SHA256 keyed by the data key (
hmac-sha256:<hex>) instead of a plain SHA-256 of the plaintext, which could be reversed by brute force for low-entropy values such as SSNs or card numbers. Payloads with the old hash still decrypt. - Decrypting a payload with a malformed nonce, tag or key returns an error instead of panicking.
aws://andgcp://keys are stored only in KMS-encrypted form (envelope encryption) and are stable across restarts; see Fixed (#26). Plaintext data keys are never written to the database.- External key loading fails closed (#16). With the
encryptionfeature, the AWS KMS, GCP KMS, HashiCorp Vault and Azure Key Vault loaders inKeyManager(master key) andEncryptionEngine(data key) used to log a failure and continue with a key derived from the public source string (key ID, region, Vault path or address, vault URL). The same happened when the source's cargo feature was not enabled. They now return an error, andKeyManager::new/EncryptionEngine::newfail:- KMS or vault unreachable, credentials missing or rejected, secret or
keyfield missing:EncryptionError::KeyManagement - malformed source (Vault path without a mount, GCP resource not
projects/<p>/locations/<l>/...,azure://without a vault host) or the source's feature (aws-kms,gcp-kms,vault-kms,azure-kv) not enabled:EncryptionError::InvalidConfiguration - Vault no longer defaults to
https://vault.example.com; setaddr=orVAULT_ADDR. A Vault secret without a stringkeyfield is an error instead of a key hashed from the path. - There is no opt-in fallback. For development without a KMS use
KeySource::Static(base64 key) orKeySource::Generated(random, in memory).
- KMS or vault unreachable, credentials missing or rejected, secret or
KeyManagernever writes plaintext key material or the configured master key to the database: keys are encrypted with AES-256-GCM under the master key, andkey_sourcestores only a label. PreviouslyKeySource::Static(key)would have been stored with the key inkey_source(#9).cargo auditis clean and the CIsecurity auditjob is now blocking (#7). Dependency upgrades that clear the open advisories:azure-kv: moved fromazure_security_keyvault/azure_identity/azure_core0.20 to the 1.x Azure SDK (azure_security_keyvault_keys,azure_identity,azure_core). Fixes RUSTSEC-2026-0275 (legacyazure_corelogged theauthorizationheader) and dropshttp-types(RUSTSEC-2026-0174),rand0.7 (RUSTSEC-2026-0097),instantandpaste(unmaintained).metrics/hammerwork-web:warp0.3 → 0.4 (hyper 1.x, h2 0.4).aws-kms/kinesis:aws-sdk-kmsandaws-sdk-kinesisno longer enable the legacyrustlsfeature (hyper 0.14 + rustls 0.21); they use the SDK'sdefault-https-client.gcp-kms/google-pubsub:google-cloud-kms0.6,google-cloud-auth0.4,google-cloud-pubsub0.25 andgoogle-cloud-googleapis0.13 replaced by the same author's renamed, maintainedgcloud-kms,gcloud-auth,gcloud-pubsubandgcloud-googleapis1.x (tonic 0.14). Dropsring0.16 (RUSTSEC-2025-0009),rustls-webpki0.101 (RUSTSEC-2026-0104 / -0098 / -0099) andrustls-pemfile(unmaintained).tracing:opentelemetry/opentelemetry_sdk/opentelemetry-otlp0.22/0.15 → 0.33 andtracing-opentelemetry0.23 → 0.34 (drops tonic 0.11 / hyper 0.14).- Together these remove
h20.3 (RUSTSEC-2026-0258) andhyper0.14 from the dependency tree. cargo-hammerwork:indicatif0.17 → 0.18 (drops unmaintainednumber_prefix).
- RUSTSEC-2023-0071 (
rsaMarvin attack, no fixed release) is ignored in.cargo/audit.toml: it comes only fromsqlx-mysql, which uses RSA public-key encryption of the password duringcaching_sha2_passwordauth; the vulnerable private-key decryption path is never used. - Web dashboard hardening (part of #64: H5, H6, H7, H8, M11, M12, M13, M16):
- H5: the dashboard compared the password to
password_hashas plain text unless built with theauthfeature, so the documented bcrypt hash was the password.auth(bcrypt) is now a default feature, the hash is only ever verified with bcrypt, and a build withoutauthrefuses to start with authentication enabled (DashboardConfig::validate). - H6: the lockout never ended for an account that kept receiving bad attempts, five bad requests locked the administrator out for everyone, and the failure table grew with every made-up username. Failures are now counted per client address (the connection's peer address, not a header) and username, a lockout ends after
lockout_durationand the count starts over, made-up usernames share one record per address, and at most 10,000 clients are tracked. - H7: bcrypt ran on the async worker threads for every request. It now runs on the blocking pool (at most one verification per CPU), and a successful login is remembered for 60 seconds (or
session_timeout, if shorter) under a keyed hash, so polling does not pay for bcrypt each time. - H8: any web page could make the browser send state-changing requests (enqueue, bulk delete, purge, ...) to the dashboard. Requests with a body now need
Content-Type: application/json(415), andPOST/PUT/DELETErequests and WebSocket handshakes that a browser sends from another origin are refused (403), judged bySec-Fetch-SiteorOriginagainstHost. Clients that send neither header (curl, scripts) are unaffected. Newallowed_originssetting /--allowed-originflag for other trusted origins, and the newhammerwork_web::securitymodule. - M11: JSON bodies are limited to 1 MiB (413; a
Content-Lengthheader is required, 411), a bulk job action to 1,000 IDs, and/api/archive/jobsfilters, counts and pages in the database (newJobHistory::archived_jobs) instead of reading the whole archive. The purge dry run counts in the database too. - M12: the WebSocket settings were ignored.
max_message_sizenow limits incoming messages and frames, each connection queues at mostmessage_buffer_sizeoutgoing messages (messages for a client that does not read are dropped),Subscribeaccepts only the known event types, and the handshake checks the origin. - M13:
limit=5000&page=2skipped 4,000 rows because the offset used the unclamped limit, and(page - 1) * limitcould overflow. The offset now uses the clamped limit (1 to 1,000) with saturating arithmetic. - M16: usernames are compared in constant time (keyed hash,
subtle), and bcrypt runs whether or not the username matched. CORS (enable_cors/--cors) is only granted to the origins inallowed_originsinstead of any origin; enabling it without any is a startup error. - Behaviour changes:
--cors/enable_cors = truenow needs at least one--allowed-origin/allowed_originsentry. API clients must sendContent-Type: application/jsonandContent-Lengthwith request bodies.server::app_routestakes the allowed origins as a new last argument, andapi::archive::archive_routesneeds aJobHistoryqueue.
- H5: the dashboard compared the password to
Changed (breaking, feature-gated)
encryption:EncryptionEngineis no longer generic over the database (#11). Its encryption methods used to requireu64: Encode<DB>, so they only worked with MySQL; the engine now works with PostgreSQL, MySQL or no database.new_with_poolandrotate_kms_keyare generic over the pool's database instead. Code naming the type with a parameter (EncryptionEngine<sqlx::MySql>,EncryptionEngine::<sqlx::MySql>::new) must drop it;EncryptionEngine::new(config)is unchanged.encryption:encrypt_payload,encrypt_payload_with_retention,decrypt_payload,cleanup_expired_dataandrotate_kms_keytake&selfinstead of&mut self(the engine is shared by queues through anArc). Existing calls compile;let mut enginebindings now trigger theunused_mutlint.encryption: payloads encrypted by this version carry the keyed payload hash (see Security), which earlier versions cannot verify, so they cannot decrypt them. Upgrade every process that decrypts before any process starts encrypting.encryption:KeyManager's methods now requireDB: KeyManagerBackend(implemented forsqlx::Postgresandsqlx::MySql) instead of the previous generic sqlx bounds. Code usingKeyManager<Postgres>orKeyManager<MySql>is unaffected.encryption: external key sources fail instead of falling back to a derived key (see Security). Deployments that ran on the fallback key without noticing will fail to start; data encrypted under a fallback key can only be read by recreating that key.azure-kv: the Azure SDK 1.x has noDefaultAzureCredential. Hammerwork now picks a credential from the environment:ClientSecretCredentialwhenAZURE_TENANT_ID/AZURE_CLIENT_ID/AZURE_CLIENT_SECRETare set,WorkloadIdentityCredentialwhenAZURE_FEDERATED_TOKEN_FILEis set, otherwise managed identity followed by the Azure CLI / Azure Developer CLI. The optional dependency (and implicit feature)azure_security_keyvaultis renamedazure_security_keyvault_keys; enableazure-kvrather than the dependency name.tracing:shutdown_tracing()now flushes and shuts down the provider installed byinit_tracing()(OpenTelemetry 0.33 removed the global shutdown hook). Code that uses theopentelemetrycrates directly alongside Hammerwork must move to 0.33.
Fixed
- The dashboard's documented configuration file did not load.
hammerwork-web/README.mdwrites durations as strings (ping_interval = "30s",lockout_duration = "15m"), butDashboardConfigonly read serde's{ secs, nanos }tables, so the example failed to parse. Dashboard durations now use the new publichammerwork::config::serde_durationformat (the oneHammerworkConfigalready used), which reads"500ms"/"30s"/"5m"/"1h"/"2d", bare seconds, and the old table form, and writes the readable strings. A test loads the README example. - Sub-second and huge job timeouts were stored wrongly on PostgreSQL and MySQL.
timeout_secondsheldas_secs() as i32, sowith_timeout(Duration::from_millis(200))was stored as 0 and the job timed out immediately, and a timeout abovei32::MAXseconds wrapped to a negative value that every later read of the row rejected. Fractional timeouts are now rounded up to whole seconds, huge ones saturate ati32::MAXseconds, and a zero job timeout (stored or in memory) means no job-level timeout, so the worker's default applies. - One undecodable row no longer breaks a whole listing (#71). A leftover row with, say, a negative
timeout_secondsmade the dashboard's job list, job search, queue jobs and archive list answer 500, and the library's listing methods fail.get_ready_jobs,get_dead_jobs,get_dead_jobs_by_queue,get_recurring_jobsandlist_archived_jobson PostgreSQL and MySQL, and the dashboard's archive listing, now skip such rows and log a warning with the job id. Reading the job itself (get_job,GET /api/jobs/{id}) still returns the error. - Archiving and restoring retry deadlocks (#71).
archive_jobsandrestore_archived_jobmove rows betweenhammerwork_jobsandhammerwork_jobs_archiveunder row locks, and a concurrent archive, restore or job transition could abort them as a deadlock victim (MySQL error 1213; PostgreSQL40P01). Both now run again, like job transitions do, on both backends. - Dashboard WebSocket messages: the dashboard script expected
stats_update,job_updateandqueue_updatemessages the server never sends; it now handles the server'sJobUpdate,QueueUpdateandSystemAlertmessages, coalescing reloads to at most one per second, and no longer answers apingtext message with apongthe server rejects. Its "is the section visible" check required the whole section on screen, so tall job and queue tables were never refreshed. - Job lifecycle hardening (#64 C1, C2, H3, H9, H10, M1, M2, M5, M6, M8, M10):
- C1: the stale job reaper no longer reclaims jobs that are still running. A claim wrote no lease, so until the worker's first heartbeat (a third of its lease in) any reaper with a shorter staleness window, such as another pool's or
cargo hammerwork job requeue-stale --older-than-secs N, moved the running job back toPendingand it ran twice. The claim now writeslease_expires_at(andlast_heartbeat_at) in the same statement on PostgreSQL, MySQL andTestQueue, and the reaper reclaims a job only once its lease expired;older_thanonly applies to jobs claimed by older versions without a lease. NewDatabaseQueue::dequeue_leased(queue, weights, lease)(workers pass their lease;dequeuetakesqueue::DEFAULT_LEASE_DURATION, 5 minutes). A zero lease never expires (the reaper leaves the job alone; it used to be reclaimed after the pool's fallback window).heartbeat_jobonly extends the run it belongs to (sameattemptsandstarted_at), so a worker whose job was reclaimed and re-run elsewhere getsfalseand no longer keeps the new run's lease alive. No migration: the columns come from015_add_job_leases. - C2: spawned children are no longer lost. The worker completed the parent and only then enqueued its children one at a time, so a database error, a spawn handler error,
SpawnLimitExceeded, a crash or a shutdown left aCompletedparent with some or none of its children. The children are now built before the completion (SpawnManager::prepare_spawn) and enqueued in the same transaction that completes the parent (newDatabaseQueue::complete_job_run_with_children); any spawn failure fails the parent's run, so it is retried.on_spawn_completeruns after the commit. The completion hook no longer fires twice for spawning jobs. - H3: concurrent migrations no longer fail.
MigrationManager::run_migrationsholds a migration lock for the whole run (pg_advisory_lockon PostgreSQL,GET_LOCKon MySQL, on a dedicated connection that runs every statement), re-reads the applied migrations after acquiring it, and releases it on every path. Several replicas withauto_migrate, or the CLI and an application, can migrate at once. Recording a migration ignores an existing row. NewMigrationRunner::acquire_migration_lock/release_migration_lock(default: no lock). The PostgreSQL runner checks for the tracking table incurrent_schema()instead ofpublic. - H9: autoscaling only starts or retires workers of the queue it scales (the template's, or the first worker's). It used to retire the highest-index workers whatever their queue, so other queues stopped being processed.
- H10: a slow or failing alert target no longer stalls job processing. Alert HTTP requests time out after
alerting::ALERT_HTTP_TIMEOUT(10 seconds), and an alert that reached no target is retried afterFAILED_ALERT_RETRY_DELAY(60 seconds, or the cooldown if shorter) instead of on every check. - M1:
disable_recurring_jobstops the next run, andenable_recurring_jobrevives a finished job. Disabling used to leave the pending occurrence runnable; it is now held (a job with a cron schedule is only dequeued while it is recurring) andnext_run_atis cleared. Enabling reschedules the job for the next occurrence after now (Pending, attempts reset) unless it is running, so a job whose last run finished while disabled runs again. Both reportJobNotFoundfor missing jobs and an error for jobs without a cron schedule, on every backend. - M2: the
Worker::with_max_retriesexample claimed it raises the attempt limit; it can only lower it.Worker::newno longer documents a 3-attempt cap. - M5: the 500ms default
polling_intervalwas saved to TOML as"0s"and loaded back as zero; durations with sub-second precision are now written in milliseconds ("500ms"), andmsis accepted when parsing. - M6: the token bucket dropped the sub-millisecond part of the elapsed time on every refill, so frequent checks refilled slower than the configured rate.
- M8: a worker with a metrics collector no longer runs
COUNT(*)for the queue depth on every poll; the monitoring task already updates it. - M10: a worker's monitoring task is aborted however
runends (an error, a panic, or the future being dropped), so supervisor restarts no longer leak monitoring loops. TestQueue::mark_job_deadandmark_job_timed_outapply the workflow failure policy and a fail-fast batch's failure like the database backends,TestQueue::finish_job_runreports the jobs failed with a run inRecordedOutcome::cancelled, and a stale job theTestQueuereaper marksDeadapplies the policy too.
- C1: the stale job reaper no longer reclaims jobs that are still running. A claim wrote no lease, so until the worker's first heartbeat (a third of its lease in) any reaper with a shorter staleness window, such as another pool's or
- Webhook and stream delivery, and config values that were ignored (part of #64: H11, H12, H13, M3, M4, M7, M9):
- H11:
WebhookManager::update_webhookstarted a second listener next to the old one, so after N updates every event was POSTed N+1 times. It now swaps the configuration in place (no gap, statistics kept); removing a webhook or stream, or adding one again under the same id, stops the old listener at once. - H12: a webhook or stream whose endpoint was down piled up one task per event without limit. At most
WebhookManagerConfig::max_pending_deliveriesdeliveries per webhook (new, default 1000) andBufferConfig::max_eventsevents per stream are pending; beyond that the listener waits, and events that overflow the event manager's buffer are dropped oldest-first, logged, and counted in the newWebhookStats::dropped_events/StreamStats::dropped_events. Retry backoff no longer holds amax_concurrent_deliveries/max_concurrent_processorsslot. - H13: the webhook timeout covered only the response headers, so an endpoint that trickled its body held a delivery slot forever.
timeout_secsnow covers the whole attempt including the body, and at mostmax_response_body_sizebytes of the body are read. - M9: webhook and stream listeners subscribed in their own task, so events published right after
add_webhook/add_streamreturned were missed. The subscription is now made before those calls return. - M7:
InMemoryStatsCollectorshifted its whole buffer on every event once full and copied up to 100,000 events per statistics query. Recording is now amortised O(1) (a time-ordered ring buffer) and queries aggregate the events in their window in place. - M4: settings that were accepted and never read now work:
WebhookManagerConfig::default_timeout_secs(used when a webhook'stimeout_secsis 0 or missing; alsowebhooks.global_settings.default_timeout_secs),StreamManagerConfig::global_flush_interval_secs(caps every stream'smax_buffer_time_secs),MetricsConfig::update_interval(the worker monitoring interval when a metrics collector is set; it was fixed at 30 s),StatsConfig::max_event_age_secs(applied on every insert) andStatsConfig::collect_timing, and[logging]through the newLoggingConfig::try_init().JobBatch::with_batch_sizeis documented as what it is: the chunk size ofinto_chunks(enqueue_batchalways writes the whole batch in one transaction). - Behaviour changes: webhook statistics survive
update_webhook; an attempt whose status arrived in time but whose body did not counts by its status; a webhook withtimeout_secs = 0uses the manager's default instead of timing out at once; a stream flushes when its oldest buffered event has waitedmax_buffer_time_secs(it used to flush a lone event after an idle period immediately); with a metrics collector the worker monitoring task runs everyupdate_interval(15 s by default) instead of every 30 s;InMemoryStatsCollectordrops events older thanmax_event_age_secs(1 hour by default) without waiting forcleanup_old_events. Breaking for struct literals:WebhookManagerConfig(max_pending_deliveries),WebhookGlobalSettings(default_timeout_secs,max_pending_deliveries),WebhookStatsandStreamStats(dropped_events) have new fields; use..Default::default()where available.tracing-subscribernow enables itsjsonfeature.
- H11:
- The CLI no longer hangs when the database is unreachable: connecting gives up after 10 seconds with a clear error. Configure with
connect_timeout_secsinconfig.toml,cargo hammerwork config set connect_timeout_secs N, orHAMMERWORK_CONNECT_TIMEOUT. - Queue failure semantics are the same on every backend (#59):
- PostgreSQL/MySQL
fail_job_dependenciesnow applies the workflow'sFailurePolicy, as documented. It used to fail the failed job's dependents under every policy: underManualit failed jobs that should keep waiting, and underFailFastit left the workflow's other pending jobs and its status alone. It now locks the job and its workflow like a terminal transition, appliesFailFast(the workflow'sPending/Retryingjobs and all dependents fail, workflowfailed),ContinueOnFailure(only the transitive dependents fail) orManual(nothing changes), and updates the workflow's counters and status. TestQueue::fail_jobis a terminalFailed, as the trait documents and the database backends implement. It used to incrementattemptsand move the job toRetrying, or toDeadonce attempts ran out. It now also applies the workflow policy and aFailFastbatch's failure, like the database backends. Code that relied on the old behaviour should do what a worker does:retry_jobwhileattempts < max_attempts, thenmark_job_dead.TestQueue::dequeuecounts an attempt (attempts + 1) like the database backends, so aWorker's retry/dead decision works the same on it.TestQueue::fail_job_dependenciesapplies the policies like the database backends (it failed nothing underContinueOnFailureand ignored jobs outside workflows),get_workflow_statusderives the status from the policy (a failure underContinueOnFailureorManualno longer makes the workflowfailedwhile jobs can still run), andenqueue_workflowhonours each job's owndepends_on.- The
FailurePolicyandfail_job_dependenciesdocs describe the behaviour of each policy, includingManual(dependents wait until the failed job is re-run or the workflow is cancelled).
- PostgreSQL/MySQL
- CLI and web bugs found by testing every command and endpoint against both databases (#44):
- CLI
backup restorebound text into UUID/INT columns on PostgreSQL (it never worked there), dropped timestamps and the error message, and counted skipped duplicates as restored;--skip-existingcompared a UUID with text. CSV backups now quote fields instead of replacing commas. - CLI
cronwas mostly stubs:createwrote text ids/priorities that PostgreSQL rejected and accepted five-field schedules the scheduler cannot run;list/nextprinted "coming soon";updatedid nothing. They are now real (library scheduling, real next-run times,updaterecomputes the next run).cron create --description(never stored) was removed. - CLI
queue list/pause/resume/pauseduse the library's queue-pause API (UTC clock on MySQL) andqueue listincludes paused queues without jobs. - CLI
job showfailed on PostgreSQL (UUID/priority decoded as text) and on MySQL (priority); a missing job is now an error.batch enqueuewrote text into UUID/INT columns on PostgreSQL and panicked on--batch-size 0;batch exportprinted "coming soon" and is now implemented.maintenance reindexnamed indexes that do not exist on PostgreSQL.spawn tree/lineagefailed on MySQL (invalid JSON path), listings panicked on multi-byte queue names, andspawn monitoralways errored.workflowlist/show/cancelwere placeholders andcreatestored nothing: they now use the library (createtakes--jobs-file);dependentsare derived fromdepends_on(the column is never written).archive --compress falsecould not disable compression, theset-policy/get-policy/remove-policysubcommands only printed that policy storage does not exist and were removed.webhookoptions:--prioritiestook only capitalised names, header values and basic-auth passwords containing=/:were rejected, a lone--api-keywas silently ignored. - CLI raw SQL used MySQL
NOW()(session time zone) where the library usesUTC_TIMESTAMP(6); windows now use the database UTC clock.HAMMERWORK_CONFIG(documented) is honoured, a malformedconfig.tomlis reported instead of ignored, and URLs are redacted in migration logs. The unusedutils::db_helpersmodule was removed. - Web: archive routes were mounted at
/api/api/archive/...; unauthenticated or unknown/apirequests were answered with the HTML page (200) instead of 401/404; non-auth rejections (400/404/405) were reported as 500; the lockout status was never produced; an empty configured password accepted an empty login; one WebSocket client held the global lock for its whole connection (blocking other clients, pings and broadcasts) and received nothing unless it subscribed; a clientPingwas answered to everyone;POST /api/jobsignoredcron_scheduleand silently turned unknown priorities intonormal; job search listed recurring jobs twice;status=failednever matched dead jobs; paging reported a different page size than served; retry/delete of unknown jobs were 500s (now 404/409); archive purge dry runs ignoredolder_than, archive list ignored all filters except the queue.hammerwork-webcommand-line defaults overrode the configuration file,DATABASE_URLwas documented but unused, and authentication defaulted on with no password. Behaviour change:hammerwork-webnow refuses to start when authentication is enabled (the default) and no password is configured; configure one (--auth --password/--password-file, orauth.password_hashin the config file) or pass the new--no-authflag to run without authentication explicitly, which logs a warning. - Dashboard script called endpoints that do not exist (
/api/stats/throughput,/api/jobs/{id}/retry,DELETE /api/jobs/{id}), readdata.jobs/data.queuesinstead ofdata.items, used the wrong overview field names, and passed fetch options to a helper that expected a method string (archive actions). NewGET /api/stats/trends?hours=N.
- CLI
- Job results visible after completion: the worker recorded a job as Completed and only then stored its result, so a client that saw the job complete could read no result (and a failed result write left the job Completed with only a warning). The result is now stored before the completion is recorded, and a failed write is logged as an error. Result TTLs too large for
chrono::Durationsaturate instead of silently becoming 24 hours. - Worker pool autoscaling never added or removed a worker (#44). It only updated
AutoscaleMetrics::active_workers. The pool now starts workers from its template when the autoscaler scales up (up tomax_workers) and gracefully retires workers when it scales down (down tomin_workers); retired workers are not restarted. ThrottleConfig::max_concurrentwas ignored (#44). Workers given the throttle (and their clones, so a wholeWorkerPool) now run at mostmax_concurrentjobs at once (0is treated as1).Worker::with_throttle_configignores a throttle withenabled = false; it used to apply its rate limit anyway.- Queue depth alerts from a worker needed a Prometheus collector (#44): the worker's monitoring task only checked
queue_depth_thresholdwhenwith_metrics_collectorwas also set. encryption.purge_interval_secs = 0was accepted when encryption was disabled (#44), andWorkerPool::from_hammerwork_configthen ran the retention purge in a tight loop. It is now rejected whether or not encryption is enabled.InMemoryStatsCollectorcountedStartedevents as processed jobs (#44), sototal_processedandthroughput_per_minutewere doubled anderror_ratecould never exceed 50% (a queue where every job failed reported 0.5, soalert_on_high_error_rate(0.5)never fired).total_processednow counts finished runs, and runs that failed but are retried (Retried) count as errors.PrometheusMetricsCollector::newpanicked with custom gauges or histograms unless it ran inside a multi-threaded Tokio runtime (it usedblock_in_place) (#44).MetricsConfig::custom_labels("labels added to all metrics") andcollect_histograms = falsewere ignored; both now take effect, and invalid label names are rejected withHammerworkError::Metrics.shutdown_tracing()blocked the async runtime (#44): it shut the tracer provider down on the calling runtime thread, which the OTLP exporter needs to flush, so on a current-thread runtime it stalled for the provider's 5 second timeout. It now waits on a blocking thread.- Retry strategies without a maximum delay could not be loaded from TOML (#44).
max_delay_mswas a required field (nullmeant no maximum), but TOML has no null, soHammerworkConfig::save_to_filewrote aworker.retry_strategy(the defaultLinear/Exponential/Fibonacciwithout a cap) thatfrom_filethen rejected. A missingmax_delay_msnow means no maximum. KeySource::Generatedreplaced its stored key on every start (#44). AnEncryptionEnginewithGenerated("file:///path")(or a plain path, orenv://VAR) generated a new key and overwrote the stored one each time it was created, so nothing encrypted before a restart could be decrypted. The stored key is now reused; a new key is generated only when none is stored (created withcreate_newand mode 0600, so concurrent starts do not overwrite each other), and a stored value that is not a valid key is an error instead of being replaced.stdout://still generates a new key each time.- A
vault://key source with an invalidaddrpanicked inside the Vault client (#44); it is now anEncryptionError::InvalidConfiguration. Thevault-kmsfeature now enables theurldependency (already in the dependency tree). EncryptionEngine::identify_pii_fieldsskipped arrays inside objects (#44):{"contacts": [{"phone": ...}]}reported nothing; it now reportscontacts[0].phone.KeySource::Statickeys were printed byDebugandDisplay(#44), so logging aKeySource,EncryptionConfigorKeyManagerConfigexposed the key. Both now printStatic(<redacted>).- Creating a Google Cloud KMS or Pub/Sub client panicked (#44) with "Could not automatically determine the process-level CryptoProvider": the dependency tree enables both of rustls' crypto backends, so rustls cannot choose one. Hammerwork now installs the aws-lc-rs provider as the process default (unless the application installed one) before creating these clients.
gcp-kmsandgoogle-pubsubnow enable a directrustlsdependency (already in the tree). enqueue_cron_jobon PostgreSQL and MySQL accepted jobs without a cron schedule (#44) and stored them as one-off jobs (not recurring), unlikeTestQueue, which rejects them. Both backends now reject a job without a valid schedule, mark the job recurring, and computenext_run_at(and schedule the job for it) when it is not set.- A stream disabled with
StreamManager::disable_streamnever delivered again afterenable_stream(#44): disabling stopped the stream's listener for good. A disabled stream now drops events but keeps listening, so enabling it resumes delivery. WebhookManager::disable_webhookdelivered one more event and then stopped the webhook for good (#44): the listener used the configuration it read before waiting for the event, then exited, soenable_webhooknever resumed delivery. The state is now checked when each event arrives, and a disabled webhook keeps listening.TestQueuediffered from the database backends (#44):get_job_counts_by_statuskeyed counts by lowercase names ("pending") instead of the stored status ("Pending") and listed empty statuses, and a fail-fastfail_job_dependenciesleft the failed jobs'dependency_statusasWaiting. Code tested againstTestQueuethat reads the counts by key needs the capitalised names.- Event streams never sent anything (#41).
StreamManagerprepared each batch and then recorded it as delivered without calling the processor, so Kafka, Kinesis and Pub/Sub streams dropped every event while their stats reported success. Batches now go through the stream'sStreamProcessor::send_batch. Events that fail (or get no delivery result) are retried per the stream'sStreamRetryPolicy:max_attemptsattempts in total, exponential backoff capped atmax_delay_secs, optional jitter. Statistics come from the per-event results:successful_deliveries,failed_deliveries,success_rate,avg_delivery_time_ms,last_success_at/last_failure_atand the newlast_error. Events that cannot be serialized count as failed.StreamManager::shutdownstops retries: no retry starts after shutdown begins, a batch waiting out its backoff stops waiting, and the events it gives up on are counted as failed. - Kinesis and Pub/Sub ignored the stream's serialization format and always sent JSON. They now send the bytes produced by
serialization(MessagePack, Avro, Protobuf), like Kafka. - Pub/Sub
health_checkalways returnedtrue. It now fetches the topic (bounded byhealth.check.timeout.ms, default 5000):Ok(true)if the topic exists,Ok(false)if it does not, if Pub/Sub is unavailable or if the check times out, and an error if Pub/Sub rejects the request (for examplePERMISSION_DENIED). - Webhook
payload_templatewas ignored and the raw event was sent. It is now rendered (see Added). Invalid templates (bad JSON, unknown placeholder, unterminated{{) are rejected byadd_webhook,update_webhook(which leaves the old webhook in place),WebhookManager::from_configandHammerworkConfig::from_file. - Email alert targets did nothing:
send_email_alertlogged the alert and returnedOk. Email targets now send through SMTP (see Added). A target without SMTP settings is a configuration error inAlertingConfig::validate,AlertManager::try_newandHammerworkConfig::from_file;AlertManager::newlogs it and every alert to that target fails. - Alert delivery failures were swallowed.
AlertManagerlogged a failed target and returnedOk.check_thresholds,check_queue_depth,check_worker_starvationandsend_custom_alertnow returnHammerworkError::Alertingnaming every target that failed (all targets are still tried). The cooldown only starts once some target received the alert, so an alert that reached nobody is retried on the next check. JobSpawnExt::with_spawn_config/with_spawningno longer drop the configuration silently or panic (part of #7): they stored the config in the payload only when it was a JSON object, silently skipped any other payload, andunwrapped serialization. Breaking: both now returnResult<Job>and fail withHammerworkError::InvalidJobPayloadwhen the payload is not a JSON object (the job schema has no spawn column, so the configuration travels in the payload under the new publicspawn::SPAWN_CONFIG_KEY). Newspawn::spawn_config_from_payloadreports an invalid_spawn_configinstead of ignoring it, and the worker logs it as a warning instead of a debug line.- Encrypted jobs now spawn children: the worker looked for
_spawn_configin the stored payload, which for an encrypted job is a placeholder or redacted, so spawning silently never happened. It now reads the configuration from the decrypted payload and hands the spawn handler the decrypted parent.cargo hammerwork spawn list/stats/pendingquery the stored payload and therefore do not list encrypted jobs (documented indocs/job-spawning.md). - CLI commands that were never exercised against a database now work:
queue stats --detailed,monitor dashboardandbackup createreadpriorityas text from an integer column (andbackup createread the PostgreSQLUUIDid as text) and failed; status icons matched lowercase names although statuses are stored capitalized;spawn list/statsjoinedhammerwork_jobsto itself with an unqualifiedqueue_name/created_at(ambiguous column on PostgreSQL);spawn list/pendingon MySQL could not decode theJSON_EXTRACTresult. - Queue backend follow-ups (#31, #17), PostgreSQL and MySQL. No migration.
- Late dependents no longer wait forever: a job enqueued (alone, in a batch or in a workflow) after its dependencies finished was left
waiting, because dependents are only released when a dependency finishes. Enqueue now settles it:satisfiedwhen every dependency completed (archived ones included); inserted asFailed(dependency_status = failed, error naming the dependency, together with the jobs of the same insert that depend on it) when one failed, died or timed out, unless that dependency's workflow usesFailurePolicy::Manual. The dependencies are lockedFOR SHAREduring the insert, so one completing at the same moment cannot strand the new job. - Queue pause is enforced by the dequeue queries (
dequeue,dequeue_with_priority_weights), not only by the worker's pre-check.TestQueuedoes the same. - Weighted dequeue no longer starves low priorities (#19 M4): it only looked at the 20 highest-priority runnable jobs, so a lower priority never ran while 20 higher-priority jobs were queued. It now probes each priority level for a runnable job, picks a level by weight among those that have one, and claims that level's oldest job (
FOR UPDATE SKIP LOCKED), trying the other levels if all its jobs are locked. - Database clock (#19 M6): due-time checks (dequeue,
get_ready_jobs,get_due_cron_jobs, result expiry), run timestamps (started_at,completed_at,failed_at,timed_out_at), heartbeats and leases, the stale-job reaper and cron rescheduling use the database's clock instead of each application server's. A worker's retry backoff keeps its length on the database clock. On MySQL all times are written and compared withUTC_TIMESTAMP(6)instead of the session-localNOW()(pause_queuewrote local times when the sessiontime_zonewas not UTC). - MySQL year-2038 limit (#19 M7): MySQL's
TIMESTAMP(6)columns end at 2038-01-19 03:14:07 UTC. Enqueueing a job scheduled after that, andretry_job/reschedule_cron_job/store_job_resultwith such a time, now fail withHammerworkError::InvalidJobPayloadnaming the limit instead of a database error; library-computed times (worker retry backoff, leases) are capped, and a recurring job whose next occurrence is beyond the limit is not rescheduled. The columns are not converted toDATETIME(6): MySQL rebuilds the table for that, blocking writes tohammerwork_jobs, which an automatically applied migration must not do. See docs/migrations.md. cancel_workflowcancels running jobs too: their status becomesFailed("Workflow cancelled") along with pending and retrying jobs. The handler cannot be interrupted, but its outcome is discarded by the lifecycle guard (finish_job_runreturnsNone, heartbeats returnfalse).- Cron next run is computed from the run's slot, not from when the run ended, so a run that overran its next slot no longer skips it. Occurrences that pass while a run is still going are coalesced into one catch-up run, due immediately; a run that starts late (workers were down, queue paused) is itself the catch-up, so it runs once and the job goes straight back on schedule. Missed occurrences never pile up. See docs/cron-scheduling.md.
- PostgreSQL
get_queue_depthand the dequeue queries comparestatuswith a literal, so the partial polling index applies to generic (prepared) plans; with a bind parameter the depth count scanned every pending job of every queue. - Dependency resolution on completion uses a constant number of queries for all dependents instead of one per dependent. MySQL's
JSON_CONTAINSlookup of dependents still cannot use an index (documented). get_priority_statsswallowed every database error and always reported empty average processing times: the processing time isNUMERIC(PostgreSQL) /DECIMAL(MySQL) and never decoded as an integer. It is now cast in SQL and errors propagate.get_processing_timesfailed with a decode error for the same reason. PostgreSQLget_jobs_completed_in_rangematched the lowercase status'completed'and never found anything.- Rows are read with
try_get(..)?instead of panickingRow::get(queue backends and migration runners). Corrupt column values are errors instead of silent defaults:depends_on/dependents/pii_fieldsthat are not JSON lists, unknowndependency_statusvalues, undecodableretry_strategy/result_config(including in the archive), unparseable archivedbatch_ids and batch error ids. Negative or out-of-range integers (timeout_seconds,result_ttl_seconds, counts) are errors instead of wrapping to huge values. Archiving a job no longer stores an empty string for a retry strategy that fails to serialize.
- Late dependents no longer wait forever: a job enqueued (alone, in a batch or in a workflow) after its dependencies finished was left
- Abandoned database transactions on worker shutdown: the worker loop raced
acquire_job()against shutdown, and the shutdown grace period cancelled outcome recording, so a dequeue orfinish_job_runtransaction could be dropped mid-flight. That could return a pooled connection with the transaction still open and its row locks held: on MySQL,Lock wait timeout exceededand error 1568 on every later claim on that connection. Shutdown now interrupts only the worker's waits; dequeues always complete, and outcomes are recorded in a spawned task. As a safety net, the MySQL claim path rolls back a connection it finds still inside an abandoned transaction. - 🚨 Webhooks and event streams dropped events: each webhook and stream listener re-subscribed to the event channel on every loop iteration, so events published while it handled the previous one were skipped. In a test burst of 100 events, a stream processor received 1. Listeners also exited on any receive error, so a burst larger than the event buffer (
RecvError::Lagged) permanently stopped delivery for that webhook or stream. Listeners now keep a single subscription, log and continue when they fall behind, and stop only when the channel closes or the webhook or stream is removed. - CLI correctness (#31)
cargo hammerwork spawn stats,spawn pending,archive ...andjob purgeno longer panic in debug builds:-qwas both--queueand the global--quiet. The short flag for--queueon those subcommands is now-Q(long flags unchanged;-qis the global--quiet). A unit test runs clap'sdebug_assertover the whole CLI.job retry,job cancel,batch retry,batch canceland themaintenancedead-job cleanup and integrity fixes no longer rewritehammerwork_jobswith raw SQL (which wrote a lowercase'pending'the library never reads). They find the affected jobs, then apply each change throughretry_job/retry_dead_job/mark_job_dead/delete_job, so they get the lifecycle transition guards and side effects. Jobs that changed state in the meantime are skipped and reported instead of overwritten.- Lowercase status literals in
queue list,queue clear --pending-only,queue health,monitorandjob purgematched nothing (statuses are stored capitalized); they now usePending,Running,Completed,Failed,Dead.job purgebinds the queue name instead of interpolating it.maintenance checkcompared the integerprioritycolumn with text; it now checks0..=4.
- Detached tasks are tracked (#17 M6):
WebhookManagerandStreamManagerkeep their listener and delivery/batch tasks instead of dropping theJoinHandles, so a panic is logged (and counted in the newtask_panics()) rather than lost. NewWebhookManager::shutdown(grace)andStreamManager::shutdown(grace)stop the listeners, wait up togracefor in-flight deliveries (and their retries), abort stragglers and return how many were aborted. - Robustness: web dashboard, CLI, stats, webhooks, streaming and metrics (#17, medium items)
hammerwork-webhandlers no longer turn database errors into empty results: job/queue/stats/archive endpoints log the error and answer500with the usual{"success": false, "error": ...}body (bad IDs are400, unknown queues404). The archive dry-run purge no longer reports0on error, and a negative job age (clock skew) no longer wraps to ~1.8e19 seconds. The dashboard shows the server's error message.WebDashboard::newandDashboardConfig::from_filereject a zerowebsocket.ping_interval(newDashboardConfig::validate) instead of panicking later in the ping task.cargo-hammerworkreads rows withtry_get(..)?(schema drift is an error, not a panic) and no longer swallows decode/query errors inspawn,workflow,monitorandjobcommands; they exit non-zero.backup restorerejects unparseable timestamps and out-of-range integers instead of substituting "now"/truncating.job enqueue --delay/--max-attemptsandmonitor dashboard --refresh 0are validated. The spawn statistics andmonitor metricsaverages are cast to double in SQL (they areNUMERIC/DECIMAL, which never decoded asf64).WebhookManager::try_newreturns an error instead of panicking when the HTTP client cannot be built (newstill panics, now documented);from_configuses it.max_concurrent_deliveries/max_concurrent_processorsof0(every delivery waits forever) or aboveSemaphore::MAX_PERMITS(panic) are clamped with a warning. The webhook delivery duration, stream delivery durations and retry backoff no longer use lossy casts, and Kinesis access-key masking no longer panics inside a multibyte character. A non-numeric Kafkahealth.check.timeout.msis a configuration error rather than silently the default.InMemoryStatsCollectorrecovers from poisoned locks and no longer panics on huge windows (Duration::MAX);AlertManager::check_worker_starvationno longer panics on a huge threshold.PrometheusMetricsCollector::start_exposition_serverbinds the address before returning, so a port that is in use or not permitted is returned asHammerworkError::Metricsinstead of the server task dying silently.
- Migrations are safe to re-run after a partial failure (#7): MySQL DDL auto-commits, so a migration that failed part-way stayed half-applied and every retry failed with
Duplicate column name/Duplicate key name. All MySQL migrations now guard eachADD COLUMN,CREATE INDEX,DROP INDEXandADD/DROP CHECKagainstinformation_schema(one statement per column instead of multi-columnALTERs). The resulting schema is unchanged. PostgreSQL already ran each migration in a transaction; its remaining non-idempotent statements (ADD CONSTRAINT, migration 013 indexes, the one-way JSONB-to-array conversion in 012) were guarded too. Newmigration_idempotency_testsreplay every migration over a finished schema on both backends and compare the schema. - Streams for disabled backends now fail at configuration time (#23): without the
kafka,kinesisorgoogle-pubsubcargo feature,KafkaProcessor::new/KinesisProcessor::new/PubSubProcessor::newreturnHammerworkError::Streamingnaming the feature, soStreamManager::add_streamandfrom_configreject the stream immediately. Previously the Kafka placeholder silently "delivered" events (simulated delay and random failures, nothing sent) and the others only failed per event at send time. The simulation settingsbatch.delay.msandtest.error.rateare gone. Behaviour change: configurations that relied on the placeholder must enable the matching feature. - The crate failed to compile without the
webhooksfeature (including--no-default-features --features postgresormysql), becauseHammerworkConfigusedevents::EventConfigfrom a module gated onwebhooks. Theeventsmodule and its re-exports no longer requirewebhooks; CI now checks the library across feature combinations. - 🚨 AWS and GCP KMS keys no longer change on every restart (#26).
aws://andgcp://key sources called AWS KMSGenerateDataKey/ GCP KMSGenerateRandomByteson every load, so each process got a new random key and anything encrypted or wrapped by an earlier process could not be decrypted after a restart. They now use envelope encryption:- On first use the KMS generates a data key and only its KMS-encrypted form is stored, in the new
hammerwork_kms_data_keystable, with the KMS key id, version and creation time. Later loads call KMSDecrypton the stored blob, so every process and restart gets the same key. Processes starting at once converge on one stored key (unique(key_name, kms_provider, kms_key_id, key_version), insert-if-absent, read back). - The ciphertext is bound to the key's name (AWS encryption context, GCP additional authenticated data).
KeyManager::newdoes this foraws:///gcp://master keys. NewEncryptionEngine::new_with_pool(config, &pool)does it for engine data keys (PostgreSQL or MySQL);EncryptionEngine::newnow returnsEncryptionError::InvalidConfigurationfor these sources, since it has nowhere to store the key.- Rotation: new
KeyManager::rotate_kms_master_keyandEncryptionEngine::rotate_kms_keyadd a new active version and retire the previous one, which stays decryptable (keys and key-encryption keys wrapped by an older master key version, and payloads encrypted with an older engine key, still decrypt). - Migration 017 (
017_add_kms_data_keys, PostgreSQL and MySQL) createshammerwork_kms_data_keys. Runcargo hammerwork migration runbefore upgrading. - Upgrading: existing AWS/GCP deployments generate and store a key on their first start after the upgrade. Data encrypted by the earlier per-process keys was already unrecoverable and stays so.
- GCP sources now also need permission to
Encrypt/Decryptwith the configured CryptoKey (previously onlyGenerateRandomByteswas called); AWS sources needkms:Decryptin addition tokms:GenerateDataKey.
- On first use the KMS generates a data key and only its KMS-encrypted form is stored, in the new
- 🚨 Workflow dependencies never resolved (#19 C3): nothing called
resolve_job_dependenciesorfail_job_dependencies, so dependents stayedwaitingforever and failure policies and workflow counters were never applied. Completing a job now marks dependents whose dependencies all completed assatisfied(dependents are locked first, so two parents completing at once cannot both miss each other). A terminal failure applies the workflow's policy:FailFastfails the workflow's pending jobs,ContinueOnFailurefails the jobs that (transitively) depend on the failed one,Manualleaves them waiting forretry_dead_job. Workflowcompleted_jobs,failed_jobs,status,completed_atandfailed_atare kept up to date. Stale jobs the reaper marksDeadget the same treatment. The PostgreSQL dependency lookup now uses the GIN index ondepends_on. - Terminal jobs could be overwritten or resurrected (#19 H2): status updates had no current-status guard, so a
TimedOutjob could be completed and aDeadone put back toPending, and a late worker could overwrite a job the reaper had reclaimed (seefinish_job_runand the guarded manual transitions above). - Retry limit ignored the job's
max_attempts(#19 H3): a job withmax_attempts = 5stopped after 3 runs in aFailedstate that nothing retried, andmax_attempts = 1still ran 3 times. - Recurring jobs stopped recurring after one failure (#19 H5): a cron job whose run died or timed out stayed
Dead/TimedOutforever. It is now rescheduled for its next run; the failed run's error and failure time stay on the job until then. - Batch failure modes were not enforced (#19 H6):
PartialFailureMode::FailFastnow fails the batch's not-yet-started jobs on the first terminal failure, and thehammerwork_batchesrow (status, counters,completed_at,error_summary) is written when the batch finishes. Removed the worker comment that claimed the queue layer already did this. - Timeouts were always final (#19 M1): see Changed.
Job::with_retry_strategywas lost on enqueue (#19 M2): it is stored in the newretry_strategycolumn and read back by every query, so the worker's per-job strategy is used.enqueue_batchdropped job fields (#19 M3): PostgreSQL batch inserts dropped the result config, dependency, workflow and trace fields; MySQL dropped the dependency, workflow and trace fields, so batch jobs with dependencies ran immediately.enqueue,enqueue_batch,enqueue_workflowand archive restore now share one insert that stores every field.- MySQL dequeue reported a
started_atslightly later than the one stored. - 🚨 Duplicate job execution with weighted priority dequeue (PostgreSQL):
dequeue_with_priority_weightsran itsFOR UPDATE SKIP LOCKEDcandidate query outside a transaction, so the row locks were released before the claimingUPDATE, and thatUPDATEdidn't check the job's status. Concurrent workers could claim and run the same job more than once. In a 16-worker test, 76 of 200 jobs ran more than once. Workers configured with non-strictPriorityWeights(including those built throughWorker::with_config) used this path. The lock and claim now happen in one transaction, and the claim requiresstatus = 'Pending'. - Weighted dequeue on PostgreSQL returned jobs with default values for result storage, dependencies, workflow and trace fields, so job results were not stored. The job is now built from the full row.
- MySQL dequeues and the PostgreSQL weighted dequeue ignored
dependency_statusand could run a job before its dependencies completed. - MySQL dequeues now use
FOR UPDATE SKIP LOCKED(MySQL 8.0+), so concurrent workers skip rows being claimed instead of blocking on them. - MySQL job claims run at READ COMMITTED and retry when InnoDB aborts them as deadlock victims (error 1213), which concurrent workers otherwise hit intermittently.
- Worker robustness (#17, #19)
- Errors from completing, failing or retrying a job (and from dequeuing) are logged and followed by an exponential backoff that honours
ThrottleConfig::backoff_on_error, instead of being discarded with the worker spinning hot (H1) - A panicking job handler no longer kills the worker: the panic fails the job with
Job handler panicked: <message>and goes through the normal retry / dead path (H2) WorkerPoolsupervises its workers: a worker that dies is logged and restarted instead of the pool returning on the first failure and detaching the rest (H3)WorkerPool::shutdownis graceful: in-flight jobs finish (up to the grace period) and record their outcome before workers stop, andshutdownwaits for them. Dropping the pool also shuts it down. Previously shutdown cancelled the running handler and left the jobRunning(C2)- Huge retry delays no longer panic in
chrono::Duration::from_std(..).unwrap(); retry delays are clamped to 365 days (H4) RetryStrategy::calculate_delayno longer panics on overflow (exponential from attempt ~65, Fibonacci ~94, huge linear values) or on a NaN / negative multiplier; it saturates and clamps tomax_delay. Jitter with an invalid factor no longer panics (H5)- Cloning a
RetryStrategy::Custom(e.g. throughWorker::clonefor autoscaling) no longer panics (H6) - Webhook delivery no longer panics when truncating a response body in the middle of a multi-byte UTF-8 character (H7)
- The autoscaler no longer panics on a zero
evaluation_window
- Errors from completing, failing or retrying a job (and from dequeuing) are logged and followed by an exponential backoff that honours
KeyManagerpersistence works on PostgreSQL and MySQL (#9). Key storage had never worked: the generic storage methods were stubs returning "Database-specific implementation required", sogenerate_key,get_key,rotate_keyandgenerate_master_key(with auditing) always failed.- Storage is implemented through a sealed
KeyManagerBackendtrait forsqlx::Postgresandsqlx::MySql; theKeyManagermethods are now generic over it (is_key_due_for_rotation,get_keys_due_for_rotation,query_database_statisticsand the rotation-schedule methods were previously separate per-backend inherent methods with the same signatures). rotate_keyinserts the new version and retires the old one in one transaction; old versions stay readable with the newKeyManager::get_key_versionand are pruned tomax_key_versions(0 keeps all). Generating an existing key ID is an error instead of an overwrite.perform_automatic_rotationrotates keys whosenext_rotation_athas passed (it was a no-op) and reports keys it failed to rotate.start_rotation_servicerejects a non-positive interval instead of panicking.refresh_stats(also run byKeyManager::new) reads key counts and ages from the database; it was a no-op. The statistics queries no longer fail to decodeAVGresults.generate_master_keystores the new key-encryption key (encrypted with the configured master key) and retires the previous one; keys it encrypted can still be decrypted, and newKeyManagerinstances load it. AKeyManagerwhose configured master key cannot decrypt the stored key-encryption key fails to start.key_sourceis stored as a label that satisfies the schema CHECK constraint (Generated, ...) instead ofGenerated(rotation).parse_key_sourceaccepts these bare labels (andDerived) as well as the parenthesised form.- PostgreSQL
rotation_intervalis bound and read as anINTERVAL(it was bound as text and failed). - Key usage counters only count the active version.
- Storage is implemented through a sealed
- Migration 016 (
016_versioned_encryption_keys, PostgreSQL and MySQL):hammerwork_encryption_keysis unique on(key_id, key_version)instead ofkey_id, so rotated versions are kept, and the audit log accepts theUpdateoperation. - Doctests: fixed the encryption engine, key manager,
lib.rsconfiguration andhammerwork-webqueue/archive examples to match the current API. They all compile (andignored examples pass under--include-ignored). - Archiving (#14), PostgreSQL and MySQL:
archive_jobsnow moves jobs: the archive insert and the delete fromhammerwork_jobsrun in one transaction, and candidates are selected withFOR UPDATE SKIP LOCKEDso concurrent archivers do not collide. Previously the row stayed inhammerwork_jobswith its old status.get_jobfalls back to the archive table and returns archived jobs withJobStatus::Archived(previously it returned the stale pre-archive row)restore_archived_jobno longer fails with a duplicate key; it moves the row back atomically and returnsHammerworkError::JobNotFoundfor an id that is not archived. Rows left inhammerwork_jobsby the old archiver are cleaned up on restore.list_archived_jobsreports the storedArchivalReasoninstead of alwaysAutomatic. AddedArchivalReason::as_str/parse_from_db(accepts the plain and legacy JSON-quoted forms).- MySQL
get_archival_statsno longer fails to decodeSUM()results (DECIMAL) JobArchiver::archive_jobs_with_progress/archive_jobs_with_eventsarchive batch after batch until no eligible jobs remain (bounded byMAX_ARCHIVAL_BATCHES_PER_OPERATION) instead of a singlebatch_sizebatch. The progress callback is called after each batch;archive_jobs_with_eventspublishesBulkArchiveProgressbetween batches.
- Batches (#14):
- PostgreSQL
get_batch_statusfailed to decode theVARCHARstatus column.BatchStatusandJobStatusnow accept every column typeStringdecodes from. get_batch_status(PostgreSQL and MySQL) reports live progress tallied from the batch's jobs, including archived ones. The counters stored inhammerwork_batcheswere never updated after enqueue, so pending/completed/failed counts and the status were frozen.
- PostgreSQL
- hammerwork-web (#14):
WebDashboard::start()no longer panics when CORS is disabled; no CORS filter is installed in that case- With both
postgresandmysqlfeatures enabled, the dashboard picks the backend from the database URL scheme instead of rejecting MySQL URLs. The pool now honourspool_size. - Building without a database feature fails with a single clear
compile_error!
- cargo-hammerwork (#14):
spawnandworkflowcommands on PostgreSQL treateddepends_on/dependentsas JSONB, but they areUUID[](since migration 012); the queries failed withoperator does not exist: uuid[] @> jsonbjob listcompared the integerprioritycolumn with a name (integer = texton PostgreSQL), filtered by lowercase status names the library never stores, and failed to decodepriority. It now binds parameters instead of interpolating them, maps status names to stored values and priorities to integers.
EventManager::newpanicked whenmax_buffer_sizewas 0 (as set byHammerworkConfig::with_events_enabled(false)). A zero buffer now disables event publishing.- 🔧 MySQL 8 migrations
- Migration statements now run over the text protocol, so the
PREPARE/EXECUTEblocks in migration 010 no longer fail with error 1295 - Replaced MariaDB-only
DROP INDEX IF EXISTS(010) andADD COLUMN IF NOT EXISTS(011) syntax with MySQL 8 compatible statements
- Migration statements now run over the text protocol, so the
- Library, CLI, web dashboard, integration binaries and examples build without warnings and pass
cargo clippy -- -D warnings(#7).spawn_cli_examplecompiles again, andspawn_examplenow declaresrequired-features = ["postgres"].