diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d24fe63..fdd040a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -55,12 +55,11 @@ jobs: matrix: features: - "" - - "--features serde" - - "--features english" - - "--features cron" - - "--features serde,english" - - "--features serde,cron" - - "--features english,cron" + - "--features migrate" + - "--features async-std-comp" + - "--features async-std-comp-native-tls" + - "--features tokio-comp" + - "--features tokio-comp-native-tls" - "--all-features" steps: - name: Checkout sources diff --git a/.gitignore b/.gitignore index ad67955..ee807f0 100644 --- a/.gitignore +++ b/.gitignore @@ -19,3 +19,9 @@ target # and can be added to the global gitignore or merged into this file. For a more nuclear # option (not recommended) you can uncomment the following to ignore the entire idea folder. #.idea/ + +# Ignore dotenv environment variable files +.env + +# Cargo +Cargo.lock diff --git a/.sqlx/query-06e70d80d4e7d8f96590795ed48fa43f0c11121df0630e4ac7c5cb937048648c.json b/.sqlx/query-06e70d80d4e7d8f96590795ed48fa43f0c11121df0630e4ac7c5cb937048648c.json new file mode 100644 index 0000000..d4db347 --- /dev/null +++ b/.sqlx/query-06e70d80d4e7d8f96590795ed48fa43f0c11121df0630e4ac7c5cb937048648c.json @@ -0,0 +1,18 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE\n apalis.jobs\nSET\n status = $4,\n attempts = $2,\n last_result = $3,\n done_at = NOW()\nWHERE\n id = $1\n AND lock_by = $5\n", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text", + "Int4", + "Jsonb", + "Text", + "Text" + ] + }, + "nullable": [] + }, + "hash": "06e70d80d4e7d8f96590795ed48fa43f0c11121df0630e4ac7c5cb937048648c" +} diff --git a/.sqlx/query-2081bdcf70787cdab55f2df7bbbc076581cba29b6513d45172cf23ad2a234479.json b/.sqlx/query-2081bdcf70787cdab55f2df7bbbc076581cba29b6513d45172cf23ad2a234479.json new file mode 100644 index 0000000..1f294a2 --- /dev/null +++ b/.sqlx/query-2081bdcf70787cdab55f2df7bbbc076581cba29b6513d45172cf23ad2a234479.json @@ -0,0 +1,40 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT\n 1 AS priority,\n 'Number' AS type,\n 'RUNNING_JOBS' AS statistic,\n SUM(CASE WHEN status = 'Running' THEN 1 ELSE 0 END)::REAL AS value\nFROM apalis.jobs\nWHERE job_type = $1\n\nUNION ALL\n\nSELECT\n 1, 'Number', 'PENDING_JOBS',\n SUM(CASE WHEN status = 'Pending' THEN 1 ELSE 0 END)::REAL\nFROM apalis.jobs\nWHERE job_type = $1\n\nUNION ALL\n\nSELECT\n 2, 'Number', 'FAILED_JOBS',\n SUM(CASE WHEN status = 'Failed' THEN 1 ELSE 0 END)::REAL\nFROM apalis.jobs\nWHERE job_type = $1\n\nUNION ALL\n\nSELECT\n 2, 'Number', 'ACTIVE_JOBS',\n SUM(CASE WHEN status IN ('Pending', 'Queued', 'Running') THEN 1 ELSE 0 END)::REAL\nFROM apalis.jobs\nWHERE job_type = $1\n\nUNION ALL\n\nSELECT\n 2, 'Number', 'STALE_RUNNING_JOBS',\n COUNT(*)::REAL\nFROM apalis.jobs\nWHERE job_type = $1\n AND status = 'Running'\n AND run_at < now() - INTERVAL '1 hour'\n\nUNION ALL\n\nSELECT\n 2, 'Percentage', 'KILL_RATE',\n ROUND(100.0 * SUM(CASE WHEN status = 'Killed' THEN 1 ELSE 0 END) / NULLIF(COUNT(*), 0), 2)::REAL\nFROM apalis.jobs\nWHERE job_type = $1\n\nUNION ALL\n\nSELECT\n 3, 'Number', 'JOBS_PAST_HOUR',\n COUNT(*)::REAL\nFROM apalis.jobs\nWHERE job_type = $1\n AND run_at >= now() - INTERVAL '1 hour'\n\nUNION ALL\n\nSELECT\n 3, 'Number', 'JOBS_TODAY',\n COUNT(*)::REAL\nFROM apalis.jobs\nWHERE job_type = $1\n AND run_at::date = CURRENT_DATE\n\nUNION ALL\n\nSELECT\n 3, 'Number', 'KILLED_JOBS_TODAY',\n SUM(CASE WHEN status = 'Killed' THEN 1 ELSE 0 END)::REAL\nFROM apalis.jobs\nWHERE job_type = $1\n AND run_at::date = CURRENT_DATE\n\nUNION ALL\n\nSELECT\n 3, 'Decimal', 'AVG_JOBS_PER_MINUTE_PAST_HOUR',\n ROUND(COUNT(*) / 60.0, 2)::REAL\nFROM apalis.jobs\nWHERE job_type = $1\n AND run_at >= now() - INTERVAL '1 hour'\n\nUNION ALL\n\nSELECT\n 4, 'Number', 'TOTAL_JOBS',\n COUNT(*)::REAL\nFROM apalis.jobs\nWHERE job_type = $1\n\nUNION ALL\n\nSELECT\n 4, 'Number', 'DONE_JOBS',\n SUM(CASE WHEN status = 'Done' THEN 1 ELSE 0 END)::REAL\nFROM apalis.jobs\nWHERE job_type = $1\n\nUNION ALL\n\nSELECT\n 4, 'Number', 'COMPLETED_JOBS',\n SUM(CASE WHEN status IN ('Done', 'Failed', 'Killed') THEN 1 ELSE 0 END)::REAL\nFROM apalis.jobs\nWHERE job_type = $1\n\nUNION ALL\n\nSELECT\n 4, 'Number', 'KILLED_JOBS',\n SUM(CASE WHEN status = 'Killed' THEN 1 ELSE 0 END)::REAL\nFROM apalis.jobs\nWHERE job_type = $1\n\nUNION ALL\n\nSELECT\n 4, 'Percentage', 'SUCCESS_RATE',\n ROUND(100.0 * SUM(CASE WHEN status = 'Done' THEN 1 ELSE 0 END) / NULLIF(COUNT(*), 0), 2)::REAL\nFROM apalis.jobs\nWHERE job_type = $1\n\nUNION ALL\n\nSELECT\n 5, 'Decimal', 'AVG_JOB_DURATION_MINS',\n ROUND(AVG(EXTRACT(EPOCH FROM (done_at - run_at)) / 60.0), 2)::REAL\nFROM apalis.jobs\nWHERE job_type = $1\n AND status IN ('Done', 'Failed', 'Killed')\n AND done_at IS NOT NULL\n\nUNION ALL\n\nSELECT\n 5, 'Decimal', 'LONGEST_RUNNING_JOB_MINS',\n ROUND(MAX(CASE WHEN status = 'Running' THEN EXTRACT(EPOCH FROM (now() - run_at)) / 60.0 ELSE 0 END), 2)::REAL\nFROM apalis.jobs\nWHERE job_type = $1\n\nUNION ALL\n\nSELECT\n 5, 'Number', 'QUEUE_BACKLOG',\n SUM(CASE WHEN status = 'Pending' AND run_at <= now() THEN 1 ELSE 0 END)::REAL\nFROM apalis.jobs\nWHERE job_type = $1\n\nUNION ALL\n\nSELECT\n 6, 'Number', 'JOBS_PAST_24_HOURS',\n COUNT(*)::REAL\nFROM apalis.jobs\nWHERE job_type = $1\n AND run_at >= now() - INTERVAL '1 day'\n\nUNION ALL\n\nSELECT\n 6, 'Number', 'JOBS_PAST_7_DAYS',\n COUNT(*)::REAL\nFROM apalis.jobs\nWHERE job_type = $1\n AND run_at >= now() - INTERVAL '7 days'\n\nUNION ALL\n\nSELECT\n 6, 'Number', 'KILLED_JOBS_PAST_7_DAYS',\n SUM(CASE WHEN status = 'Killed' THEN 1 ELSE 0 END)::REAL\nFROM apalis.jobs\nWHERE job_type = $1\n AND run_at >= now() - INTERVAL '7 days'\n\nUNION ALL\n\nSELECT\n 6, 'Percentage', 'SUCCESS_RATE_PAST_24H',\n ROUND(100.0 * SUM(CASE WHEN status = 'Done' THEN 1 ELSE 0 END) / NULLIF(COUNT(*), 0), 2)::REAL\nFROM apalis.jobs\nWHERE job_type = $1\n AND run_at >= now() - INTERVAL '1 day'\n\nUNION ALL\n\nSELECT\n 7, 'Decimal', 'AVG_JOBS_PER_HOUR_PAST_24H',\n ROUND(COUNT(*) / 24.0, 2)::REAL\nFROM apalis.jobs\nWHERE job_type = $1\n AND run_at >= now() - INTERVAL '1 day'\n\nUNION ALL\n\nSELECT\n 7, 'Decimal', 'AVG_JOBS_PER_DAY_PAST_7D',\n ROUND(COUNT(*) / 7.0, 2)::REAL\nFROM apalis.jobs\nWHERE job_type = $1\n AND run_at >= now() - INTERVAL '7 days'\n\nUNION ALL\n\nSELECT\n 8, 'Timestamp', 'MOST_RECENT_JOB',\n EXTRACT(EPOCH FROM MAX(run_at))::REAL\nFROM apalis.jobs\nWHERE job_type = $1\n\nUNION ALL\n\nSELECT\n 8, 'Timestamp', 'OLDEST_PENDING_JOB',\n EXTRACT(EPOCH FROM MIN(run_at))::REAL\nFROM apalis.jobs\nWHERE job_type = $1\n AND status = 'Pending'\n AND run_at <= now()\n\nUNION ALL\n\nSELECT\n 8, 'Number', 'PEAK_HOUR_JOBS',\n MAX(hourly_count)::REAL\nFROM (\n SELECT COUNT(*) as hourly_count\n FROM apalis.jobs\n WHERE job_type = $1\n AND run_at >= now() - INTERVAL '1 day'\n GROUP BY EXTRACT(HOUR FROM run_at)\n) subquery\n\nORDER BY priority, statistic;\n", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "priority", + "type_info": "Int4" + }, + { + "ordinal": 1, + "name": "type", + "type_info": "Text" + }, + { + "ordinal": 2, + "name": "statistic", + "type_info": "Text" + }, + { + "ordinal": 3, + "name": "value", + "type_info": "Float4" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + null, + null, + null, + null + ] + }, + "hash": "2081bdcf70787cdab55f2df7bbbc076581cba29b6513d45172cf23ad2a234479" +} diff --git a/.sqlx/query-23e8905ae8a0b00ab7f157dd83dd15952577dd54443b3445275e66d5fa7419f8.json b/.sqlx/query-23e8905ae8a0b00ab7f157dd83dd15952577dd54443b3445275e66d5fa7419f8.json new file mode 100644 index 0000000..a487202 --- /dev/null +++ b/.sqlx/query-23e8905ae8a0b00ab7f157dd83dd15952577dd54443b3445275e66d5fa7419f8.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE\n apalis.workers\nSET\n last_seen = NOW()\nWHERE\n id = $1 AND worker_type = $2;\n", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text", + "Text" + ] + }, + "nullable": [] + }, + "hash": "23e8905ae8a0b00ab7f157dd83dd15952577dd54443b3445275e66d5fa7419f8" +} diff --git a/.sqlx/query-37cf19d29005b40bb20786f9bfc0518adf4d213b30c7f0a0848dc54e9e3f6852.json b/.sqlx/query-37cf19d29005b40bb20786f9bfc0518adf4d213b30c7f0a0848dc54e9e3f6852.json new file mode 100644 index 0000000..9e28ae7 --- /dev/null +++ b/.sqlx/query-37cf19d29005b40bb20786f9bfc0518adf4d213b30c7f0a0848dc54e9e3f6852.json @@ -0,0 +1,34 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT\n id,\n status,\n last_result AS result\nFROM\n apalis.jobs\nWHERE\n id IN (\n SELECT\n value::text\n FROM\n jsonb_array_elements_text($1) AS value\n )\n AND (\n status = 'Done'\n OR (\n status = 'Failed'\n AND attempts >= max_attempts\n )\n OR status = 'Killed'\n );\n", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Text" + }, + { + "ordinal": 1, + "name": "status", + "type_info": "Text" + }, + { + "ordinal": 2, + "name": "result", + "type_info": "Jsonb" + } + ], + "parameters": { + "Left": [ + "Jsonb" + ] + }, + "nullable": [ + false, + false, + true + ] + }, + "hash": "37cf19d29005b40bb20786f9bfc0518adf4d213b30c7f0a0848dc54e9e3f6852" +} diff --git a/.sqlx/query-3b0fccfab61f95863ef5a2e5c4ca4a888ce3c14e404ace182b985b8a16999f71.json b/.sqlx/query-3b0fccfab61f95863ef5a2e5c4ca4a888ce3c14e404ace182b985b8a16999f71.json new file mode 100644 index 0000000..3196f3e --- /dev/null +++ b/.sqlx/query-3b0fccfab61f95863ef5a2e5c4ca4a888ce3c14e404ace182b985b8a16999f71.json @@ -0,0 +1,95 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE apalis.jobs\nSET \n status = 'Running',\n lock_at = now(),\n lock_by = $2\nWHERE \n status = 'Queued'\n AND run_at < now()\n AND id = ANY($1)\nRETURNING *;\n", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "job", + "type_info": "Bytea" + }, + { + "ordinal": 1, + "name": "id", + "type_info": "Text" + }, + { + "ordinal": 2, + "name": "job_type", + "type_info": "Text" + }, + { + "ordinal": 3, + "name": "status", + "type_info": "Text" + }, + { + "ordinal": 4, + "name": "attempts", + "type_info": "Int4" + }, + { + "ordinal": 5, + "name": "max_attempts", + "type_info": "Int4" + }, + { + "ordinal": 6, + "name": "run_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 7, + "name": "last_result", + "type_info": "Jsonb" + }, + { + "ordinal": 8, + "name": "lock_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 9, + "name": "lock_by", + "type_info": "Text" + }, + { + "ordinal": 10, + "name": "done_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 11, + "name": "priority", + "type_info": "Int4" + }, + { + "ordinal": 12, + "name": "metadata", + "type_info": "Jsonb" + } + ], + "parameters": { + "Left": [ + "TextArray", + "Text" + ] + }, + "nullable": [ + false, + false, + false, + false, + false, + false, + false, + true, + true, + true, + true, + true, + true + ] + }, + "hash": "3b0fccfab61f95863ef5a2e5c4ca4a888ce3c14e404ace182b985b8a16999f71" +} diff --git a/.sqlx/query-54c019f6453cd767b36f76e31d27434375c562b9a88ed32895ec39793459268b.json b/.sqlx/query-54c019f6453cd767b36f76e31d27434375c562b9a88ed32895ec39793459268b.json new file mode 100644 index 0000000..e06f2fd --- /dev/null +++ b/.sqlx/query-54c019f6453cd767b36f76e31d27434375c562b9a88ed32895ec39793459268b.json @@ -0,0 +1,53 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT\n *\nFROM\n apalis.workers\nORDER BY\n last_seen DESC\nLIMIT\n $1 OFFSET $2\n", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Text" + }, + { + "ordinal": 1, + "name": "worker_type", + "type_info": "Text" + }, + { + "ordinal": 2, + "name": "storage_name", + "type_info": "Text" + }, + { + "ordinal": 3, + "name": "layers", + "type_info": "Text" + }, + { + "ordinal": 4, + "name": "last_seen", + "type_info": "Timestamptz" + }, + { + "ordinal": 5, + "name": "started_at", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Int8", + "Int8" + ] + }, + "nullable": [ + false, + false, + false, + false, + false, + true + ] + }, + "hash": "54c019f6453cd767b36f76e31d27434375c562b9a88ed32895ec39793459268b" +} diff --git a/.sqlx/query-5d7ccd8d4267874312eb02ff9b8ff0de07d7deb30bbe797ce1128d2d15e3a35d.json b/.sqlx/query-5d7ccd8d4267874312eb02ff9b8ff0de07d7deb30bbe797ce1128d2d15e3a35d.json new file mode 100644 index 0000000..a50a8b9 --- /dev/null +++ b/.sqlx/query-5d7ccd8d4267874312eb02ff9b8ff0de07d7deb30bbe797ce1128d2d15e3a35d.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE\n apalis.jobs\nSET\n status = 'Pending',\n done_at = NULL,\n lock_by = NULL,\n lock_at = NULL,\n attempts = attempts + 1,\n last_result = '{\"Err\": \"Re-enqueued due to worker heartbeat timeout.\"}'\nWHERE\n id IN (\n SELECT\n jobs.id\n FROM\n apalis.jobs\n INNER JOIN apalis.workers ON lock_by = workers.id\n WHERE\n (\n status = 'Running'\n OR status = 'Queued'\n )\n AND NOW() - apalis.workers.last_seen >= $1\n AND apalis.workers.worker_type = $2\n );\n", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Interval", + "Text" + ] + }, + "nullable": [] + }, + "hash": "5d7ccd8d4267874312eb02ff9b8ff0de07d7deb30bbe797ce1128d2d15e3a35d" +} diff --git a/.sqlx/query-704294ce11c5e6c120ed1a67f8094be7fe0492322bf806a80d51f59d3b941745.json b/.sqlx/query-704294ce11c5e6c120ed1a67f8094be7fe0492322bf806a80d51f59d3b941745.json new file mode 100644 index 0000000..2217c75 --- /dev/null +++ b/.sqlx/query-704294ce11c5e6c120ed1a67f8094be7fe0492322bf806a80d51f59d3b941745.json @@ -0,0 +1,18 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO\n apalis.workers (id, worker_type, storage_name, layers, last_seen)\nVALUES\n ($1, $2, $3, $4, $5) ON CONFLICT (id) DO\nUPDATE\nSET\n worker_type = EXCLUDED.worker_type,\n storage_name = EXCLUDED.storage_name,\n layers = EXCLUDED.layers,\n last_seen = NOW()\nWHERE\n pg_try_advisory_lock(hashtext(workers.id));\n", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text", + "Text", + "Text", + "Text", + "Timestamptz" + ] + }, + "nullable": [] + }, + "hash": "704294ce11c5e6c120ed1a67f8094be7fe0492322bf806a80d51f59d3b941745" +} diff --git a/.sqlx/query-7084948e4ad9e6ce5238a11cd3a80b62a46384d8c75433cbcee1746e26934f14.json b/.sqlx/query-7084948e4ad9e6ce5238a11cd3a80b62a46384d8c75433cbcee1746e26934f14.json new file mode 100644 index 0000000..7599759 --- /dev/null +++ b/.sqlx/query-7084948e4ad9e6ce5238a11cd3a80b62a46384d8c75433cbcee1746e26934f14.json @@ -0,0 +1,54 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT\n *\nFROM\n apalis.workers\nWHERE\n worker_type = $1\nORDER BY\n last_seen DESC\nLIMIT\n $2 OFFSET $3\n", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Text" + }, + { + "ordinal": 1, + "name": "worker_type", + "type_info": "Text" + }, + { + "ordinal": 2, + "name": "storage_name", + "type_info": "Text" + }, + { + "ordinal": 3, + "name": "layers", + "type_info": "Text" + }, + { + "ordinal": 4, + "name": "last_seen", + "type_info": "Timestamptz" + }, + { + "ordinal": 5, + "name": "started_at", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Text", + "Int8", + "Int8" + ] + }, + "nullable": [ + false, + false, + false, + false, + false, + true + ] + }, + "hash": "7084948e4ad9e6ce5238a11cd3a80b62a46384d8c75433cbcee1746e26934f14" +} diff --git a/.sqlx/query-7e5fb4248558ac31dd2b4ff2c34a58930c43feb8ee8c4505e0a4e0521ee6890c.json b/.sqlx/query-7e5fb4248558ac31dd2b4ff2c34a58930c43feb8ee8c4505e0a4e0521ee6890c.json new file mode 100644 index 0000000..511c1ee --- /dev/null +++ b/.sqlx/query-7e5fb4248558ac31dd2b4ff2c34a58930c43feb8ee8c4505e0a4e0521ee6890c.json @@ -0,0 +1,20 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO\n apalis.jobs (\n id,\n job_type,\n job,\n status,\n attempts,\n max_attempts,\n run_at,\n priority,\n metadata\n )\nSELECT\n unnest($1::text[]) as id,\n $2::text as job_type,\n unnest($3::bytea[]) as job,\n 'Pending' as status,\n 0 as attempts,\n unnest($4::integer []) as max_attempts,\n unnest($5::timestamptz []) as run_at,\n unnest($6::integer []) as priority,\n unnest($7::jsonb []) as metadata\n", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "TextArray", + "Text", + "ByteaArray", + "Int4Array", + "TimestamptzArray", + "Int4Array", + "JsonbArray" + ] + }, + "nullable": [] + }, + "hash": "7e5fb4248558ac31dd2b4ff2c34a58930c43feb8ee8c4505e0a4e0521ee6890c" +} diff --git a/.sqlx/query-a0dcac1deb02eb19959aedcde6e8864a459891ff9b5447882bcd6a43856b91f4.json b/.sqlx/query-a0dcac1deb02eb19959aedcde6e8864a459891ff9b5447882bcd6a43856b91f4.json new file mode 100644 index 0000000..b196afe --- /dev/null +++ b/.sqlx/query-a0dcac1deb02eb19959aedcde6e8864a459891ff9b5447882bcd6a43856b91f4.json @@ -0,0 +1,94 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT\n *\nFROM\n apalis.jobs\nWHERE\n id = $1\nLIMIT\n 1;\n", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "job", + "type_info": "Bytea" + }, + { + "ordinal": 1, + "name": "id", + "type_info": "Text" + }, + { + "ordinal": 2, + "name": "job_type", + "type_info": "Text" + }, + { + "ordinal": 3, + "name": "status", + "type_info": "Text" + }, + { + "ordinal": 4, + "name": "attempts", + "type_info": "Int4" + }, + { + "ordinal": 5, + "name": "max_attempts", + "type_info": "Int4" + }, + { + "ordinal": 6, + "name": "run_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 7, + "name": "last_result", + "type_info": "Jsonb" + }, + { + "ordinal": 8, + "name": "lock_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 9, + "name": "lock_by", + "type_info": "Text" + }, + { + "ordinal": 10, + "name": "done_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 11, + "name": "priority", + "type_info": "Int4" + }, + { + "ordinal": 12, + "name": "metadata", + "type_info": "Jsonb" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + false, + false, + false, + false, + false, + false, + false, + true, + true, + true, + true, + true, + true + ] + }, + "hash": "a0dcac1deb02eb19959aedcde6e8864a459891ff9b5447882bcd6a43856b91f4" +} diff --git a/.sqlx/query-aec15451aa407010d95e6014b0e6b4a361a6cc85c26c56ea82cdd2c37a123dac.json b/.sqlx/query-aec15451aa407010d95e6014b0e6b4a361a6cc85c26c56ea82cdd2c37a123dac.json new file mode 100644 index 0000000..e3cc46d --- /dev/null +++ b/.sqlx/query-aec15451aa407010d95e6014b0e6b4a361a6cc85c26c56ea82cdd2c37a123dac.json @@ -0,0 +1,97 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT\n *\nFROM\n apalis.jobs\nWHERE\n status = $1\n AND job_type = $2\nORDER BY\n done_at DESC,\n run_at DESC\nLIMIT\n $3 OFFSET $4\n", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "job", + "type_info": "Bytea" + }, + { + "ordinal": 1, + "name": "id", + "type_info": "Text" + }, + { + "ordinal": 2, + "name": "job_type", + "type_info": "Text" + }, + { + "ordinal": 3, + "name": "status", + "type_info": "Text" + }, + { + "ordinal": 4, + "name": "attempts", + "type_info": "Int4" + }, + { + "ordinal": 5, + "name": "max_attempts", + "type_info": "Int4" + }, + { + "ordinal": 6, + "name": "run_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 7, + "name": "last_result", + "type_info": "Jsonb" + }, + { + "ordinal": 8, + "name": "lock_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 9, + "name": "lock_by", + "type_info": "Text" + }, + { + "ordinal": 10, + "name": "done_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 11, + "name": "priority", + "type_info": "Int4" + }, + { + "ordinal": 12, + "name": "metadata", + "type_info": "Jsonb" + } + ], + "parameters": { + "Left": [ + "Text", + "Text", + "Int8", + "Int8" + ] + }, + "nullable": [ + false, + false, + false, + false, + false, + false, + false, + true, + true, + true, + true, + true, + true + ] + }, + "hash": "aec15451aa407010d95e6014b0e6b4a361a6cc85c26c56ea82cdd2c37a123dac" +} diff --git a/.sqlx/query-fa9eb268af4a8630fca6604989866f69d94231d3564da84daeeac720eed22570.json b/.sqlx/query-fa9eb268af4a8630fca6604989866f69d94231d3564da84daeeac720eed22570.json new file mode 100644 index 0000000..3ea4892 --- /dev/null +++ b/.sqlx/query-fa9eb268af4a8630fca6604989866f69d94231d3564da84daeeac720eed22570.json @@ -0,0 +1,38 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT\n 1 AS priority,\n 'Number' AS type,\n 'RUNNING_JOBS' AS statistic,\n SUM(CASE WHEN status = 'Running' THEN 1 ELSE 0 END)::REAL AS value\nFROM apalis.jobs\n\nUNION ALL\n\nSELECT\n 1, 'Number', 'PENDING_JOBS',\n SUM(CASE WHEN status = 'Pending' THEN 1 ELSE 0 END)::REAL\nFROM apalis.jobs\n\nUNION ALL\n\nSELECT\n 2, 'Number', 'FAILED_JOBS',\n SUM(CASE WHEN status = 'Failed' THEN 1 ELSE 0 END)::REAL\nFROM apalis.jobs\n\nUNION ALL\n\nSELECT\n 2, 'Number', 'ACTIVE_JOBS',\n SUM(CASE WHEN status IN ('Pending', 'Running', 'Queued') THEN 1 ELSE 0 END)::REAL\nFROM apalis.jobs\n\nUNION ALL\n\nSELECT\n 2, 'Number', 'STALE_RUNNING_JOBS',\n COUNT(*)::REAL\nFROM apalis.jobs\nWHERE status = 'Running'\n AND run_at < now() - INTERVAL '1 hour'\n\nUNION ALL\n\nSELECT\n 2, 'Percentage', 'KILL_RATE',\n ROUND(100.0 * SUM(CASE WHEN status = 'Killed' THEN 1 ELSE 0 END) / NULLIF(COUNT(*), 0), 2)::REAL\nFROM apalis.jobs\n\nUNION ALL\n\nSELECT\n 3, 'Number', 'JOBS_PAST_HOUR',\n COUNT(*)::REAL\nFROM apalis.jobs\nWHERE run_at >= now() - INTERVAL '1 hour'\n\nUNION ALL\n\nSELECT\n 3, 'Number', 'JOBS_TODAY',\n COUNT(*)::REAL\nFROM apalis.jobs\nWHERE run_at::date = CURRENT_DATE\n\nUNION ALL\n\nSELECT\n 3, 'Number', 'KILLED_JOBS_TODAY',\n SUM(CASE WHEN status = 'Killed' THEN 1 ELSE 0 END)::REAL\nFROM apalis.jobs\nWHERE run_at::date = CURRENT_DATE\n\nUNION ALL\n\nSELECT\n 3, 'Decimal', 'AVG_JOBS_PER_MINUTE_PAST_HOUR',\n ROUND(COUNT(*) / 60.0, 2)::REAL\nFROM apalis.jobs\nWHERE run_at >= now() - INTERVAL '1 hour'\n\nUNION ALL\n\nSELECT\n 4, 'Number', 'TOTAL_JOBS',\n COUNT(*)::REAL\nFROM apalis.jobs\n\nUNION ALL\n\nSELECT\n 4, 'Number', 'DONE_JOBS',\n SUM(CASE WHEN status = 'Done' THEN 1 ELSE 0 END)::REAL\nFROM apalis.jobs\n\nUNION ALL\n\nSELECT\n 4, 'Number', 'COMPLETED_JOBS',\n SUM(CASE WHEN status IN ('Done', 'Failed', 'Killed') THEN 1 ELSE 0 END)::REAL\nFROM apalis.jobs\n\nUNION ALL\n\nSELECT\n 4, 'Number', 'KILLED_JOBS',\n SUM(CASE WHEN status = 'Killed' THEN 1 ELSE 0 END)::REAL\nFROM apalis.jobs\n\nUNION ALL\n\nSELECT\n 4, 'Percentage', 'SUCCESS_RATE',\n ROUND(100.0 * SUM(CASE WHEN status = 'Done' THEN 1 ELSE 0 END) / NULLIF(COUNT(*), 0), 2)::REAL\nFROM apalis.jobs\n\nUNION ALL\n\nSELECT\n 5, 'Decimal', 'AVG_JOB_DURATION_MINS',\n ROUND(AVG(EXTRACT(EPOCH FROM (done_at - run_at)) / 60.0), 2)::REAL\nFROM apalis.jobs\nWHERE status IN ('Done', 'Failed', 'Killed')\n AND done_at IS NOT NULL\n\nUNION ALL\n\nSELECT\n 5, 'Decimal', 'LONGEST_RUNNING_JOB_MINS',\n ROUND(MAX(CASE WHEN status = 'Running' THEN EXTRACT(EPOCH FROM (now() - run_at)) / 60.0 ELSE 0 END), 2)::REAL\nFROM apalis.jobs\n\nUNION ALL\n\nSELECT\n 5, 'Number', 'QUEUE_BACKLOG',\n SUM(CASE WHEN status = 'Pending' AND run_at <= now() THEN 1 ELSE 0 END)::REAL\nFROM apalis.jobs\n\nUNION ALL\n\nSELECT\n 6, 'Number', 'JOBS_PAST_24_HOURS',\n COUNT(*)::REAL\nFROM apalis.jobs\nWHERE run_at >= now() - INTERVAL '1 day'\n\nUNION ALL\n\nSELECT\n 6, 'Number', 'JOBS_PAST_7_DAYS',\n COUNT(*)::REAL\nFROM apalis.jobs\nWHERE run_at >= now() - INTERVAL '7 days'\n\nUNION ALL\n\nSELECT\n 6, 'Number', 'KILLED_JOBS_PAST_7_DAYS',\n SUM(CASE WHEN status = 'Killed' THEN 1 ELSE 0 END)::REAL\nFROM apalis.jobs\nWHERE run_at >= now() - INTERVAL '7 days'\n\nUNION ALL\n\nSELECT\n 6, 'Percentage', 'SUCCESS_RATE_PAST_24H',\n ROUND(100.0 * SUM(CASE WHEN status = 'Done' THEN 1 ELSE 0 END) / NULLIF(COUNT(*), 0), 2)::REAL\nFROM apalis.jobs\nWHERE run_at >= now() - INTERVAL '1 day'\n\nUNION ALL\n\nSELECT\n 7, 'Decimal', 'AVG_JOBS_PER_HOUR_PAST_24H',\n ROUND(COUNT(*) / 24.0, 2)::REAL\nFROM apalis.jobs\nWHERE run_at >= now() - INTERVAL '1 day'\n\nUNION ALL\n\nSELECT\n 7, 'Decimal', 'AVG_JOBS_PER_DAY_PAST_7D',\n ROUND(COUNT(*) / 7.0, 2)::REAL\nFROM apalis.jobs\nWHERE run_at >= now() - INTERVAL '7 days'\n\nUNION ALL\n\nSELECT\n 8, 'Timestamp', 'MOST_RECENT_JOB',\n EXTRACT(EPOCH FROM MAX(run_at))::REAL\nFROM apalis.jobs\n\nUNION ALL\n\nSELECT\n 8, 'Timestamp', 'OLDEST_PENDING_JOB',\n EXTRACT(EPOCH FROM MIN(run_at))::REAL\nFROM apalis.jobs\nWHERE status = 'Pending'\n AND run_at <= now()\n\nUNION ALL\n\nSELECT\n 8, 'Number', 'PEAK_HOUR_JOBS',\n MAX(hourly_count)::REAL\nFROM (\n SELECT COUNT(*) as hourly_count\n FROM apalis.jobs\n WHERE run_at >= now() - INTERVAL '1 day'\n GROUP BY EXTRACT(HOUR FROM run_at)\n) subquery\n\nUNION ALL\n\nSELECT\n 9, 'Number', 'DB_PAGE_SIZE',\n current_setting('block_size')::INTEGER::REAL\n\nUNION ALL\n\nSELECT\n 9, 'Number', 'DB_PAGE_COUNT',\n (pg_total_relation_size('apalis.jobs') / current_setting('block_size')::INTEGER)::REAL\n\nUNION ALL\n\nSELECT\n 9, 'Number', 'DB_SIZE',\n pg_total_relation_size('apalis.jobs')::REAL\n\nORDER BY priority, statistic;\n", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "priority", + "type_info": "Int4" + }, + { + "ordinal": 1, + "name": "type", + "type_info": "Text" + }, + { + "ordinal": 2, + "name": "statistic", + "type_info": "Text" + }, + { + "ordinal": 3, + "name": "value", + "type_info": "Float4" + } + ], + "parameters": { + "Left": [] + }, + "nullable": [ + null, + null, + null, + null + ] + }, + "hash": "fa9eb268af4a8630fca6604989866f69d94231d3564da84daeeac720eed22570" +} diff --git a/.sqlx/query-fb557b3bd6802a07dd75fab3c7f321f8e515bf2b0d1ae1c1d8ca8067bd3bfbe3.json b/.sqlx/query-fb557b3bd6802a07dd75fab3c7f321f8e515bf2b0d1ae1c1d8ca8067bd3bfbe3.json new file mode 100644 index 0000000..8332fb6 --- /dev/null +++ b/.sqlx/query-fb557b3bd6802a07dd75fab3c7f321f8e515bf2b0d1ae1c1d8ca8067bd3bfbe3.json @@ -0,0 +1,38 @@ +{ + "db_name": "PostgreSQL", + "query": "WITH queue_stats AS (\n SELECT\n job_type,\n jsonb_agg(\n jsonb_build_object(\n 'title', statistic,\n 'stat_type', type,\n 'value', value,\n 'priority', priority\n ) ORDER BY priority, statistic\n ) as stats\n FROM (\n -- Priority 1: Current Status\n SELECT\n job_type,\n 1 AS priority,\n 'Number' AS type,\n 'RUNNING_JOBS' AS statistic,\n SUM(CASE WHEN status = 'Running' THEN 1 ELSE 0 END)::TEXT AS value\n FROM apalis.jobs\n GROUP BY job_type\n \n UNION ALL\n \n SELECT\n job_type, 1, 'Number', 'PENDING_JOBS',\n SUM(CASE WHEN status = 'Pending' THEN 1 ELSE 0 END)::TEXT\n FROM apalis.jobs\n GROUP BY job_type\n \n UNION ALL\n \n SELECT\n job_type, 1, 'Number', 'FAILED_JOBS',\n SUM(CASE WHEN status = 'Failed' THEN 1 ELSE 0 END)::TEXT\n FROM apalis.jobs\n GROUP BY job_type\n \n UNION ALL\n \n -- Priority 2: Health Metrics\n SELECT\n job_type, 2, 'Number', 'ACTIVE_JOBS',\n SUM(CASE WHEN status IN ('Pending', 'Queued', 'Running') THEN 1 ELSE 0 END)::TEXT\n FROM apalis.jobs\n GROUP BY job_type\n \n UNION ALL\n \n SELECT\n job_type, 2, 'Number', 'STALE_RUNNING_JOBS',\n COUNT(*)::TEXT\n FROM apalis.jobs\n WHERE status = 'Running' AND run_at < now() - INTERVAL '1 hour'\n GROUP BY job_type\n \n UNION ALL\n \n SELECT\n job_type, 2, 'Percentage', 'KILL_RATE',\n ROUND(100.0 * SUM(CASE WHEN status = 'Killed' THEN 1 ELSE 0 END) / NULLIF(COUNT(*), 0), 2)::TEXT\n FROM apalis.jobs\n GROUP BY job_type\n \n UNION ALL\n \n -- Priority 3: Recent Activity\n SELECT\n job_type, 3, 'Number', 'JOBS_PAST_HOUR',\n COUNT(*)::TEXT\n FROM apalis.jobs\n WHERE run_at >= now() - INTERVAL '1 hour'\n GROUP BY job_type\n \n UNION ALL\n \n SELECT\n job_type, 3, 'Number', 'JOBS_TODAY',\n COUNT(*)::TEXT\n FROM apalis.jobs\n WHERE run_at::date = CURRENT_DATE\n GROUP BY job_type\n \n UNION ALL\n \n SELECT\n job_type, 3, 'Number', 'KILLED_JOBS_TODAY',\n SUM(CASE WHEN status = 'Killed' THEN 1 ELSE 0 END)::TEXT\n FROM apalis.jobs\n WHERE run_at::date = CURRENT_DATE\n GROUP BY job_type\n \n UNION ALL\n \n SELECT\n job_type, 3, 'Decimal', 'AVG_JOBS_PER_MINUTE_PAST_HOUR',\n ROUND(COUNT(*) / 60.0, 2)::TEXT\n FROM apalis.jobs\n WHERE run_at >= now() - INTERVAL '1 hour'\n GROUP BY job_type\n \n UNION ALL\n \n -- Priority 4: Overall Stats\n SELECT\n job_type, 4, 'Number', 'TOTAL_JOBS',\n COUNT(*)::TEXT\n FROM apalis.jobs\n GROUP BY job_type\n \n UNION ALL\n \n SELECT\n job_type, 4, 'Number', 'DONE_JOBS',\n SUM(CASE WHEN status = 'Done' THEN 1 ELSE 0 END)::TEXT\n FROM apalis.jobs\n GROUP BY job_type\n \n UNION ALL\n \n SELECT\n job_type, 4, 'Number', 'KILLED_JOBS',\n SUM(CASE WHEN status = 'Killed' THEN 1 ELSE 0 END)::TEXT\n FROM apalis.jobs\n GROUP BY job_type\n \n UNION ALL\n \n SELECT\n job_type, 4, 'Percentage', 'SUCCESS_RATE',\n ROUND(100.0 * SUM(CASE WHEN status = 'Done' THEN 1 ELSE 0 END) / NULLIF(COUNT(*), 0), 2)::TEXT\n FROM apalis.jobs\n GROUP BY job_type\n \n UNION ALL\n \n -- Priority 5: Performance\n SELECT\n job_type, 5, 'Decimal', 'AVG_JOB_DURATION_MINS',\n ROUND(AVG(EXTRACT(EPOCH FROM (done_at - run_at)) / 60.0), 2)::TEXT\n FROM apalis.jobs\n WHERE status IN ('Done', 'Failed', 'Killed') AND done_at IS NOT NULL\n GROUP BY job_type\n \n UNION ALL\n \n SELECT\n job_type, 5, 'Decimal', 'LONGEST_RUNNING_JOB_MINS',\n ROUND(MAX(CASE WHEN status = 'Running' THEN EXTRACT(EPOCH FROM now() - run_at) / 60.0 ELSE 0 END), 2)::TEXT\n FROM apalis.jobs\n GROUP BY job_type\n \n UNION ALL\n \n -- Priority 6: Historical\n SELECT\n job_type, 6, 'Number', 'JOBS_PAST_7_DAYS',\n COUNT(*)::TEXT\n FROM apalis.jobs\n WHERE run_at >= now() - INTERVAL '7 days'\n GROUP BY job_type\n \n UNION ALL\n \n -- Priority 8: Timestamps\n SELECT\n job_type, 8, 'Timestamp', 'MOST_RECENT_JOB',\n MAX(run_at)::TEXT\n FROM apalis.jobs\n GROUP BY job_type\n ) subquery\n GROUP BY job_type\n),\nall_job_types AS (\n SELECT worker_type AS job_type\n FROM apalis.workers\n UNION\n SELECT DISTINCT job_type\n FROM apalis.jobs\n)\nSELECT\n jt.job_type as name,\n COALESCE(qs.stats, '[]'::jsonb) as stats,\n COALESCE(\n (\n SELECT jsonb_agg(DISTINCT lock_by)\n FROM apalis.jobs\n WHERE job_type = jt.job_type AND lock_by IS NOT NULL\n ),\n '[]'::jsonb\n ) as workers,\n COALESCE(\n (\n SELECT jsonb_agg(daily_count ORDER BY run_date)\n FROM (\n SELECT\n COUNT(*) as daily_count,\n run_at::date AS run_date\n FROM apalis.jobs\n WHERE job_type = jt.job_type\n AND run_at >= now() - INTERVAL '7 days'\n GROUP BY run_at::date\n ORDER BY run_date\n ) t\n ),\n '[]'::jsonb\n ) as activity\nFROM all_job_types jt\nLEFT JOIN queue_stats qs ON jt.job_type = qs.job_type\nORDER BY name;\n", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "name", + "type_info": "Text" + }, + { + "ordinal": 1, + "name": "stats", + "type_info": "Jsonb" + }, + { + "ordinal": 2, + "name": "workers", + "type_info": "Jsonb" + }, + { + "ordinal": 3, + "name": "activity", + "type_info": "Jsonb" + } + ], + "parameters": { + "Left": [] + }, + "nullable": [ + null, + null, + null, + null + ] + }, + "hash": "fb557b3bd6802a07dd75fab3c7f321f8e515bf2b0d1ae1c1d8ca8067bd3bfbe3" +} diff --git a/.sqlx/query-fc3801ddf6016402eb1ab46da4e6d733992fd5b98269ad25c25dc9e9d7a1db0a.json b/.sqlx/query-fc3801ddf6016402eb1ab46da4e6d733992fd5b98269ad25c25dc9e9d7a1db0a.json new file mode 100644 index 0000000..e2aad84 --- /dev/null +++ b/.sqlx/query-fc3801ddf6016402eb1ab46da4e6d733992fd5b98269ad25c25dc9e9d7a1db0a.json @@ -0,0 +1,96 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT\n *\nFROM\n apalis.get_jobs($1, $2, $3)\n", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "job", + "type_info": "Bytea" + }, + { + "ordinal": 1, + "name": "id", + "type_info": "Text" + }, + { + "ordinal": 2, + "name": "job_type", + "type_info": "Text" + }, + { + "ordinal": 3, + "name": "status", + "type_info": "Text" + }, + { + "ordinal": 4, + "name": "attempts", + "type_info": "Int4" + }, + { + "ordinal": 5, + "name": "max_attempts", + "type_info": "Int4" + }, + { + "ordinal": 6, + "name": "run_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 7, + "name": "last_result", + "type_info": "Jsonb" + }, + { + "ordinal": 8, + "name": "lock_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 9, + "name": "lock_by", + "type_info": "Text" + }, + { + "ordinal": 10, + "name": "done_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 11, + "name": "priority", + "type_info": "Int4" + }, + { + "ordinal": 12, + "name": "metadata", + "type_info": "Jsonb" + } + ], + "parameters": { + "Left": [ + "Text", + "Text", + "Int4" + ] + }, + "nullable": [ + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null + ] + }, + "hash": "fc3801ddf6016402eb1ab46da4e6d733992fd5b98269ad25c25dc9e9d7a1db0a" +} diff --git a/.sqlx/query-fe90494e81b507098f8b8704e8d61500f5e81da8d38ffd1e03db0148eb889c78.json b/.sqlx/query-fe90494e81b507098f8b8704e8d61500f5e81da8d38ffd1e03db0148eb889c78.json new file mode 100644 index 0000000..3f5b84e --- /dev/null +++ b/.sqlx/query-fe90494e81b507098f8b8704e8d61500f5e81da8d38ffd1e03db0148eb889c78.json @@ -0,0 +1,96 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT\n *\nFROM\n apalis.jobs\nWHERE\n status = $1\nORDER BY\n done_at DESC,\n run_at DESC\nLIMIT\n $2 OFFSET $3\n", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "job", + "type_info": "Bytea" + }, + { + "ordinal": 1, + "name": "id", + "type_info": "Text" + }, + { + "ordinal": 2, + "name": "job_type", + "type_info": "Text" + }, + { + "ordinal": 3, + "name": "status", + "type_info": "Text" + }, + { + "ordinal": 4, + "name": "attempts", + "type_info": "Int4" + }, + { + "ordinal": 5, + "name": "max_attempts", + "type_info": "Int4" + }, + { + "ordinal": 6, + "name": "run_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 7, + "name": "last_result", + "type_info": "Jsonb" + }, + { + "ordinal": 8, + "name": "lock_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 9, + "name": "lock_by", + "type_info": "Text" + }, + { + "ordinal": 10, + "name": "done_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 11, + "name": "priority", + "type_info": "Int4" + }, + { + "ordinal": 12, + "name": "metadata", + "type_info": "Jsonb" + } + ], + "parameters": { + "Left": [ + "Text", + "Int8", + "Int8" + ] + }, + "nullable": [ + false, + false, + false, + false, + false, + false, + false, + true, + true, + true, + true, + true, + true + ] + }, + "hash": "fe90494e81b507098f8b8704e8d61500f5e81da8d38ffd1e03db0148eb889c78" +} diff --git a/Cargo.lock b/Cargo.lock deleted file mode 100644 index 45d7c3b..0000000 --- a/Cargo.lock +++ /dev/null @@ -1,2812 +0,0 @@ -# This file is automatically @generated by Cargo. -# It is not intended for manual editing. -version = 4 - -[[package]] -name = "addr2line" -version = "0.24.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dfbe277e56a376000877090da837660b4427aad530e3028d44e0bffe4f89a1c1" -dependencies = [ - "gimli", -] - -[[package]] -name = "adler2" -version = "2.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" - -[[package]] -name = "allocator-api2" -version = "0.2.21" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" - -[[package]] -name = "android_system_properties" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" -dependencies = [ - "libc", -] - -[[package]] -name = "apalis-core" -version = "1.0.0-alpha.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4af49df8b7f9aeacae0323afba50719026dcf105b65cc27e8d3602e2c1fcb846" -dependencies = [ - "futures-channel", - "futures-core", - "futures-sink", - "futures-timer", - "futures-util", - "pin-project", - "serde", - "serde_json", - "thiserror", - "tower-layer", - "tower-service", -] - -[[package]] -name = "apalis-postgres" -version = "1.0.0-alpha.1" -dependencies = [ - "apalis-core", - "async-std", - "chrono", - "futures", - "once_cell", - "pin-project", - "serde", - "serde_json", - "sqlx", - "tokio", - "ulid", -] - -[[package]] -name = "async-channel" -version = "1.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "81953c529336010edd6d8e358f886d9581267795c61b19475b71314bffa46d35" -dependencies = [ - "concurrent-queue", - "event-listener 2.5.3", - "futures-core", -] - -[[package]] -name = "async-channel" -version = "2.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2" -dependencies = [ - "concurrent-queue", - "event-listener-strategy", - "futures-core", - "pin-project-lite", -] - -[[package]] -name = "async-executor" -version = "1.13.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "497c00e0fd83a72a79a39fcbd8e3e2f055d6f6c7e025f3b3d91f4f8e76527fb8" -dependencies = [ - "async-task", - "concurrent-queue", - "fastrand 2.3.0", - "futures-lite 2.6.1", - "pin-project-lite", - "slab", -] - -[[package]] -name = "async-global-executor" -version = "2.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "05b1b633a2115cd122d73b955eadd9916c18c8f510ec9cd1686404c60ad1c29c" -dependencies = [ - "async-channel 2.5.0", - "async-executor", - "async-io 2.6.0", - "async-lock 3.4.1", - "blocking", - "futures-lite 2.6.1", - "once_cell", -] - -[[package]] -name = "async-io" -version = "1.13.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0fc5b45d93ef0529756f812ca52e44c221b35341892d3dcc34132ac02f3dd2af" -dependencies = [ - "async-lock 2.8.0", - "autocfg", - "cfg-if", - "concurrent-queue", - "futures-lite 1.13.0", - "log", - "parking", - "polling 2.8.0", - "rustix 0.37.28", - "slab", - "socket2 0.4.10", - "waker-fn", -] - -[[package]] -name = "async-io" -version = "2.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "456b8a8feb6f42d237746d4b3e9a178494627745c3c56c6ea55d92ba50d026fc" -dependencies = [ - "autocfg", - "cfg-if", - "concurrent-queue", - "futures-io", - "futures-lite 2.6.1", - "parking", - "polling 3.11.0", - "rustix 1.1.2", - "slab", - "windows-sys 0.61.0", -] - -[[package]] -name = "async-lock" -version = "2.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "287272293e9d8c41773cec55e365490fe034813a2f172f502d6ddcf75b2f582b" -dependencies = [ - "event-listener 2.5.3", -] - -[[package]] -name = "async-lock" -version = "3.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5fd03604047cee9b6ce9de9f70c6cd540a0520c813cbd49bae61f33ab80ed1dc" -dependencies = [ - "event-listener 5.4.1", - "event-listener-strategy", - "pin-project-lite", -] - -[[package]] -name = "async-std" -version = "1.13.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2c8e079a4ab67ae52b7403632e4618815d6db36d2a010cfe41b02c1b1578f93b" -dependencies = [ - "async-channel 1.9.0", - "async-global-executor", - "async-io 2.6.0", - "async-lock 3.4.1", - "crossbeam-utils", - "futures-channel", - "futures-core", - "futures-io", - "futures-lite 2.6.1", - "gloo-timers", - "kv-log-macro", - "log", - "memchr", - "once_cell", - "pin-project-lite", - "pin-utils", - "slab", - "wasm-bindgen-futures", -] - -[[package]] -name = "async-task" -version = "4.7.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de" - -[[package]] -name = "atoi" -version = "2.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f28d99ec8bfea296261ca1af174f24225171fea9664ba9003cbebee704810528" -dependencies = [ - "num-traits", -] - -[[package]] -name = "atomic-waker" -version = "1.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" - -[[package]] -name = "autocfg" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" - -[[package]] -name = "backtrace" -version = "0.3.75" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6806a6321ec58106fea15becdad98371e28d92ccbc7c8f1b3b6dd724fe8f1002" -dependencies = [ - "addr2line", - "cfg-if", - "libc", - "miniz_oxide", - "object", - "rustc-demangle", - "windows-targets 0.52.6", -] - -[[package]] -name = "base64" -version = "0.22.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" - -[[package]] -name = "base64ct" -version = "1.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "55248b47b0caf0546f7988906588779981c43bb1bc9d0c44087278f80cdb44ba" - -[[package]] -name = "bitflags" -version = "1.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" - -[[package]] -name = "bitflags" -version = "2.9.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2261d10cca569e4643e526d8dc2e62e433cc8aba21ab764233731f8d369bf394" -dependencies = [ - "serde", -] - -[[package]] -name = "block-buffer" -version = "0.10.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" -dependencies = [ - "generic-array", -] - -[[package]] -name = "blocking" -version = "1.6.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e83f8d02be6967315521be875afa792a316e28d57b5a2d401897e2a7921b7f21" -dependencies = [ - "async-channel 2.5.0", - "async-task", - "futures-io", - "futures-lite 2.6.1", - "piper", -] - -[[package]] -name = "bumpalo" -version = "3.19.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "46c5e41b57b8bba42a04676d81cb89e9ee8e859a1a66f80a5a72e1cb76b34d43" - -[[package]] -name = "byteorder" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" - -[[package]] -name = "bytes" -version = "1.10.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d71b6127be86fdcfddb610f7182ac57211d4b18a3e9c82eb2d17662f2227ad6a" - -[[package]] -name = "cc" -version = "1.2.37" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "65193589c6404eb80b450d618eaf9a2cafaaafd57ecce47370519ef674a7bd44" -dependencies = [ - "find-msvc-tools", - "shlex", -] - -[[package]] -name = "cfg-if" -version = "1.0.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2fd1289c04a9ea8cb22300a459a72a385d7c73d3259e2ed7dcb2af674838cfa9" - -[[package]] -name = "chrono" -version = "0.4.42" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "145052bdd345b87320e369255277e3fb5152762ad123a901ef5c262dd38fe8d2" -dependencies = [ - "iana-time-zone", - "js-sys", - "num-traits", - "serde", - "wasm-bindgen", - "windows-link", -] - -[[package]] -name = "concurrent-queue" -version = "2.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" -dependencies = [ - "crossbeam-utils", -] - -[[package]] -name = "const-oid" -version = "0.9.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" - -[[package]] -name = "core-foundation" -version = "0.9.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" -dependencies = [ - "core-foundation-sys", - "libc", -] - -[[package]] -name = "core-foundation-sys" -version = "0.8.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" - -[[package]] -name = "cpufeatures" -version = "0.2.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" -dependencies = [ - "libc", -] - -[[package]] -name = "crc" -version = "3.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9710d3b3739c2e349eb44fe848ad0b7c8cb1e42bd87ee49371df2f7acaf3e675" -dependencies = [ - "crc-catalog", -] - -[[package]] -name = "crc-catalog" -version = "2.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "19d374276b40fb8bbdee95aef7c7fa6b5316ec764510eb64b8dd0e2ed0d7e7f5" - -[[package]] -name = "crossbeam-queue" -version = "0.3.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0f58bbc28f91df819d0aa2a2c00cd19754769c2fad90579b3592b1c9ba7a3115" -dependencies = [ - "crossbeam-utils", -] - -[[package]] -name = "crossbeam-utils" -version = "0.8.21" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" - -[[package]] -name = "crypto-common" -version = "0.1.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3" -dependencies = [ - "generic-array", - "typenum", -] - -[[package]] -name = "der" -version = "0.7.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" -dependencies = [ - "const-oid", - "pem-rfc7468", - "zeroize", -] - -[[package]] -name = "digest" -version = "0.10.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" -dependencies = [ - "block-buffer", - "const-oid", - "crypto-common", - "subtle", -] - -[[package]] -name = "displaydoc" -version = "0.2.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "dotenvy" -version = "0.15.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b" - -[[package]] -name = "either" -version = "1.15.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" -dependencies = [ - "serde", -] - -[[package]] -name = "equivalent" -version = "1.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" - -[[package]] -name = "errno" -version = "0.3.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" -dependencies = [ - "libc", - "windows-sys 0.61.0", -] - -[[package]] -name = "etcetera" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "136d1b5283a1ab77bd9257427ffd09d8667ced0570b6f938942bc7568ed5b943" -dependencies = [ - "cfg-if", - "home", - "windows-sys 0.48.0", -] - -[[package]] -name = "event-listener" -version = "2.5.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0206175f82b8d6bf6652ff7d71a1e27fd2e4efde587fd368662814d6ec1d9ce0" - -[[package]] -name = "event-listener" -version = "5.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab" -dependencies = [ - "concurrent-queue", - "parking", - "pin-project-lite", -] - -[[package]] -name = "event-listener-strategy" -version = "0.5.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" -dependencies = [ - "event-listener 5.4.1", - "pin-project-lite", -] - -[[package]] -name = "fastrand" -version = "1.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e51093e27b0797c359783294ca4f0a911c270184cb10f85783b118614a1501be" -dependencies = [ - "instant", -] - -[[package]] -name = "fastrand" -version = "2.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" - -[[package]] -name = "find-msvc-tools" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7fd99930f64d146689264c637b5af2f0233a933bef0d8570e2526bf9e083192d" - -[[package]] -name = "flume" -version = "0.11.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da0e4dd2a88388a1f4ccc7c9ce104604dab68d9f408dc34cd45823d5a9069095" -dependencies = [ - "futures-core", - "futures-sink", - "spin", -] - -[[package]] -name = "foldhash" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" - -[[package]] -name = "foreign-types" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" -dependencies = [ - "foreign-types-shared", -] - -[[package]] -name = "foreign-types-shared" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" - -[[package]] -name = "form_urlencoded" -version = "1.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" -dependencies = [ - "percent-encoding", -] - -[[package]] -name = "futures" -version = "0.3.31" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "65bc07b1a8bc7c85c5f2e110c476c7389b4554ba72af57d8445ea63a576b0876" -dependencies = [ - "futures-channel", - "futures-core", - "futures-executor", - "futures-io", - "futures-sink", - "futures-task", - "futures-util", -] - -[[package]] -name = "futures-channel" -version = "0.3.31" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10" -dependencies = [ - "futures-core", - "futures-sink", -] - -[[package]] -name = "futures-core" -version = "0.3.31" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" - -[[package]] -name = "futures-executor" -version = "0.3.31" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e28d1d997f585e54aebc3f97d39e72338912123a67330d723fdbb564d646c9f" -dependencies = [ - "futures-core", - "futures-task", - "futures-util", -] - -[[package]] -name = "futures-intrusive" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d930c203dd0b6ff06e0201a4a2fe9149b43c684fd4420555b26d21b1a02956f" -dependencies = [ - "futures-core", - "lock_api", - "parking_lot", -] - -[[package]] -name = "futures-io" -version = "0.3.31" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6" - -[[package]] -name = "futures-lite" -version = "1.13.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "49a9d51ce47660b1e808d3c990b4709f2f415d928835a17dfd16991515c46bce" -dependencies = [ - "fastrand 1.9.0", - "futures-core", - "futures-io", - "memchr", - "parking", - "pin-project-lite", - "waker-fn", -] - -[[package]] -name = "futures-lite" -version = "2.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad" -dependencies = [ - "fastrand 2.3.0", - "futures-core", - "futures-io", - "parking", - "pin-project-lite", -] - -[[package]] -name = "futures-macro" -version = "0.3.31" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "futures-sink" -version = "0.3.31" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e575fab7d1e0dcb8d0c7bcf9a63ee213816ab51902e6d244a95819acacf1d4f7" - -[[package]] -name = "futures-task" -version = "0.3.31" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988" - -[[package]] -name = "futures-timer" -version = "3.0.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f288b0a4f20f9a56b5d1da57e2227c661b7b16168e2f72365f57b63326e29b24" - -[[package]] -name = "futures-util" -version = "0.3.31" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" -dependencies = [ - "futures-channel", - "futures-core", - "futures-io", - "futures-macro", - "futures-sink", - "futures-task", - "memchr", - "pin-project-lite", - "pin-utils", - "slab", -] - -[[package]] -name = "generic-array" -version = "0.14.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" -dependencies = [ - "typenum", - "version_check", -] - -[[package]] -name = "getrandom" -version = "0.2.16" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "335ff9f135e4384c8150d6f27c6daed433577f86b4750418338c01a1a2528592" -dependencies = [ - "cfg-if", - "libc", - "wasi 0.11.1+wasi-snapshot-preview1", -] - -[[package]] -name = "getrandom" -version = "0.3.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "26145e563e54f2cadc477553f1ec5ee650b00862f0a58bcd12cbdc5f0ea2d2f4" -dependencies = [ - "cfg-if", - "libc", - "r-efi", - "wasi 0.14.7+wasi-0.2.4", -] - -[[package]] -name = "gimli" -version = "0.31.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "07e28edb80900c19c28f1072f2e8aeca7fa06b23cd4169cefe1af5aa3260783f" - -[[package]] -name = "gloo-timers" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bbb143cf96099802033e0d4f4963b19fd2e0b728bcf076cd9cf7f6634f092994" -dependencies = [ - "futures-channel", - "futures-core", - "js-sys", - "wasm-bindgen", -] - -[[package]] -name = "hashbrown" -version = "0.15.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" -dependencies = [ - "allocator-api2", - "equivalent", - "foldhash", -] - -[[package]] -name = "hashlink" -version = "0.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7382cf6263419f2d8df38c55d7da83da5c18aef87fc7a7fc1fb1e344edfe14c1" -dependencies = [ - "hashbrown", -] - -[[package]] -name = "heck" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" - -[[package]] -name = "hermit-abi" -version = "0.3.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d231dfb89cfffdbc30e7fc41579ed6066ad03abda9e567ccafae602b97ec5024" - -[[package]] -name = "hermit-abi" -version = "0.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" - -[[package]] -name = "hex" -version = "0.4.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" - -[[package]] -name = "hkdf" -version = "0.12.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" -dependencies = [ - "hmac", -] - -[[package]] -name = "hmac" -version = "0.12.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" -dependencies = [ - "digest", -] - -[[package]] -name = "home" -version = "0.5.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "589533453244b0995c858700322199b2becb13b627df2851f64a2775d024abcf" -dependencies = [ - "windows-sys 0.59.0", -] - -[[package]] -name = "iana-time-zone" -version = "0.1.64" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "33e57f83510bb73707521ebaffa789ec8caf86f9657cad665b092b581d40e9fb" -dependencies = [ - "android_system_properties", - "core-foundation-sys", - "iana-time-zone-haiku", - "js-sys", - "log", - "wasm-bindgen", - "windows-core", -] - -[[package]] -name = "iana-time-zone-haiku" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" -dependencies = [ - "cc", -] - -[[package]] -name = "icu_collections" -version = "2.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "200072f5d0e3614556f94a9930d5dc3e0662a652823904c3a75dc3b0af7fee47" -dependencies = [ - "displaydoc", - "potential_utf", - "yoke", - "zerofrom", - "zerovec", -] - -[[package]] -name = "icu_locale_core" -version = "2.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0cde2700ccaed3872079a65fb1a78f6c0a36c91570f28755dda67bc8f7d9f00a" -dependencies = [ - "displaydoc", - "litemap", - "tinystr", - "writeable", - "zerovec", -] - -[[package]] -name = "icu_normalizer" -version = "2.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "436880e8e18df4d7bbc06d58432329d6458cc84531f7ac5f024e93deadb37979" -dependencies = [ - "displaydoc", - "icu_collections", - "icu_normalizer_data", - "icu_properties", - "icu_provider", - "smallvec", - "zerovec", -] - -[[package]] -name = "icu_normalizer_data" -version = "2.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "00210d6893afc98edb752b664b8890f0ef174c8adbb8d0be9710fa66fbbf72d3" - -[[package]] -name = "icu_properties" -version = "2.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "016c619c1eeb94efb86809b015c58f479963de65bdb6253345c1a1276f22e32b" -dependencies = [ - "displaydoc", - "icu_collections", - "icu_locale_core", - "icu_properties_data", - "icu_provider", - "potential_utf", - "zerotrie", - "zerovec", -] - -[[package]] -name = "icu_properties_data" -version = "2.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "298459143998310acd25ffe6810ed544932242d3f07083eee1084d83a71bd632" - -[[package]] -name = "icu_provider" -version = "2.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "03c80da27b5f4187909049ee2d72f276f0d9f99a42c306bd0131ecfe04d8e5af" -dependencies = [ - "displaydoc", - "icu_locale_core", - "stable_deref_trait", - "tinystr", - "writeable", - "yoke", - "zerofrom", - "zerotrie", - "zerovec", -] - -[[package]] -name = "idna" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" -dependencies = [ - "idna_adapter", - "smallvec", - "utf8_iter", -] - -[[package]] -name = "idna_adapter" -version = "1.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344" -dependencies = [ - "icu_normalizer", - "icu_properties", -] - -[[package]] -name = "indexmap" -version = "2.11.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92119844f513ffa41556430369ab02c295a3578af21cf945caa3e9e0c2481ac3" -dependencies = [ - "equivalent", - "hashbrown", -] - -[[package]] -name = "instant" -version = "0.1.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e0242819d153cba4b4b05a5a8f2a7e9bbf97b6055b2a002b395c96b5ff3c0222" -dependencies = [ - "cfg-if", -] - -[[package]] -name = "io-lifetimes" -version = "1.0.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eae7b9aee968036d54dce06cebaefd919e4472e753296daccd6d344e3e2df0c2" -dependencies = [ - "hermit-abi 0.3.9", - "libc", - "windows-sys 0.48.0", -] - -[[package]] -name = "io-uring" -version = "0.7.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "046fa2d4d00aea763528b4950358d0ead425372445dc8ff86312b3c69ff7727b" -dependencies = [ - "bitflags 2.9.4", - "cfg-if", - "libc", -] - -[[package]] -name = "itoa" -version = "1.0.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c" - -[[package]] -name = "js-sys" -version = "0.3.78" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c0b063578492ceec17683ef2f8c5e89121fbd0b172cbc280635ab7567db2738" -dependencies = [ - "once_cell", - "wasm-bindgen", -] - -[[package]] -name = "kv-log-macro" -version = "1.0.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0de8b303297635ad57c9f5059fd9cee7a47f8e8daa09df0fcd07dd39fb22977f" -dependencies = [ - "log", -] - -[[package]] -name = "lazy_static" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" -dependencies = [ - "spin", -] - -[[package]] -name = "libc" -version = "0.2.175" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6a82ae493e598baaea5209805c49bbf2ea7de956d50d7da0da1164f9c6d28543" - -[[package]] -name = "libm" -version = "0.2.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f9fbbcab51052fe104eb5e5d351cf728d30a5be1fe14d9be8a3b097481fb97de" - -[[package]] -name = "libredox" -version = "0.1.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "416f7e718bdb06000964960ffa43b4335ad4012ae8b99060261aa4a8088d5ccb" -dependencies = [ - "bitflags 2.9.4", - "libc", - "redox_syscall", -] - -[[package]] -name = "libsqlite3-sys" -version = "0.30.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2e99fb7a497b1e3339bc746195567ed8d3e24945ecd636e3619d20b9de9e9149" -dependencies = [ - "pkg-config", - "vcpkg", -] - -[[package]] -name = "linux-raw-sys" -version = "0.3.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ef53942eb7bf7ff43a617b3e2c1c4a5ecf5944a7c1bc12d7ee39bbb15e5c1519" - -[[package]] -name = "linux-raw-sys" -version = "0.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039" - -[[package]] -name = "litemap" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "241eaef5fd12c88705a01fc1066c48c4b36e0dd4377dcdc7ec3942cea7a69956" - -[[package]] -name = "lock_api" -version = "0.4.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "96936507f153605bddfcda068dd804796c84324ed2510809e5b2a624c81da765" -dependencies = [ - "autocfg", - "scopeguard", -] - -[[package]] -name = "log" -version = "0.4.28" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "34080505efa8e45a4b816c349525ebe327ceaa8559756f0356cba97ef3bf7432" -dependencies = [ - "value-bag", -] - -[[package]] -name = "md-5" -version = "0.10.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf" -dependencies = [ - "cfg-if", - "digest", -] - -[[package]] -name = "memchr" -version = "2.7.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32a282da65faaf38286cf3be983213fcf1d2e2a58700e808f83f4ea9a4804bc0" - -[[package]] -name = "miniz_oxide" -version = "0.8.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" -dependencies = [ - "adler2", -] - -[[package]] -name = "mio" -version = "1.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "78bed444cc8a2160f01cbcf811ef18cac863ad68ae8ca62092e8db51d51c761c" -dependencies = [ - "libc", - "wasi 0.11.1+wasi-snapshot-preview1", - "windows-sys 0.59.0", -] - -[[package]] -name = "native-tls" -version = "0.2.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "87de3442987e9dbec73158d5c715e7ad9072fda936bb03d19d7fa10e00520f0e" -dependencies = [ - "libc", - "log", - "openssl", - "openssl-probe", - "openssl-sys", - "schannel", - "security-framework", - "security-framework-sys", - "tempfile", -] - -[[package]] -name = "num-bigint-dig" -version = "0.8.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc84195820f291c7697304f3cbdadd1cb7199c0efc917ff5eafd71225c136151" -dependencies = [ - "byteorder", - "lazy_static", - "libm", - "num-integer", - "num-iter", - "num-traits", - "rand 0.8.5", - "smallvec", - "zeroize", -] - -[[package]] -name = "num-integer" -version = "0.1.46" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" -dependencies = [ - "num-traits", -] - -[[package]] -name = "num-iter" -version = "0.1.45" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1429034a0490724d0075ebb2bc9e875d6503c3cf69e235a8941aa757d83ef5bf" -dependencies = [ - "autocfg", - "num-integer", - "num-traits", -] - -[[package]] -name = "num-traits" -version = "0.2.19" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" -dependencies = [ - "autocfg", - "libm", -] - -[[package]] -name = "object" -version = "0.36.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "62948e14d923ea95ea2c7c86c71013138b66525b86bdc08d2dcc262bdb497b87" -dependencies = [ - "memchr", -] - -[[package]] -name = "once_cell" -version = "1.21.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" - -[[package]] -name = "openssl" -version = "0.10.73" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8505734d46c8ab1e19a1dce3aef597ad87dcb4c37e7188231769bd6bd51cebf8" -dependencies = [ - "bitflags 2.9.4", - "cfg-if", - "foreign-types", - "libc", - "once_cell", - "openssl-macros", - "openssl-sys", -] - -[[package]] -name = "openssl-macros" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "openssl-probe" -version = "0.1.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e" - -[[package]] -name = "openssl-sys" -version = "0.9.109" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "90096e2e47630d78b7d1c20952dc621f957103f8bc2c8359ec81290d75238571" -dependencies = [ - "cc", - "libc", - "pkg-config", - "vcpkg", -] - -[[package]] -name = "parking" -version = "2.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" - -[[package]] -name = "parking_lot" -version = "0.12.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "70d58bf43669b5795d1576d0641cfb6fbb2057bf629506267a92807158584a13" -dependencies = [ - "lock_api", - "parking_lot_core", -] - -[[package]] -name = "parking_lot_core" -version = "0.9.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bc838d2a56b5b1a6c25f55575dfc605fabb63bb2365f6c2353ef9159aa69e4a5" -dependencies = [ - "cfg-if", - "libc", - "redox_syscall", - "smallvec", - "windows-targets 0.52.6", -] - -[[package]] -name = "pem-rfc7468" -version = "0.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412" -dependencies = [ - "base64ct", -] - -[[package]] -name = "percent-encoding" -version = "2.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" - -[[package]] -name = "pin-project" -version = "1.1.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "677f1add503faace112b9f1373e43e9e054bfdd22ff1a63c1bc485eaec6a6a8a" -dependencies = [ - "pin-project-internal", -] - -[[package]] -name = "pin-project-internal" -version = "1.1.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e918e4ff8c4549eb882f14b3a4bc8c8bc93de829416eacf579f1207a8fbf861" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "pin-project-lite" -version = "0.2.16" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b" - -[[package]] -name = "pin-utils" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" - -[[package]] -name = "piper" -version = "0.2.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "96c8c490f422ef9a4efd2cb5b42b76c8613d7e7dfc1caf667b8a3350a5acc066" -dependencies = [ - "atomic-waker", - "fastrand 2.3.0", - "futures-io", -] - -[[package]] -name = "pkcs1" -version = "0.7.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c8ffb9f10fa047879315e6625af03c164b16962a5368d724ed16323b68ace47f" -dependencies = [ - "der", - "pkcs8", - "spki", -] - -[[package]] -name = "pkcs8" -version = "0.10.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" -dependencies = [ - "der", - "spki", -] - -[[package]] -name = "pkg-config" -version = "0.3.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" - -[[package]] -name = "polling" -version = "2.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4b2d323e8ca7996b3e23126511a523f7e62924d93ecd5ae73b333815b0eb3dce" -dependencies = [ - "autocfg", - "bitflags 1.3.2", - "cfg-if", - "concurrent-queue", - "libc", - "log", - "pin-project-lite", - "windows-sys 0.48.0", -] - -[[package]] -name = "polling" -version = "3.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218" -dependencies = [ - "cfg-if", - "concurrent-queue", - "hermit-abi 0.5.2", - "pin-project-lite", - "rustix 1.1.2", - "windows-sys 0.61.0", -] - -[[package]] -name = "potential_utf" -version = "0.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "84df19adbe5b5a0782edcab45899906947ab039ccf4573713735ee7de1e6b08a" -dependencies = [ - "zerovec", -] - -[[package]] -name = "ppv-lite86" -version = "0.2.21" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" -dependencies = [ - "zerocopy", -] - -[[package]] -name = "proc-macro2" -version = "1.0.101" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "89ae43fd86e4158d6db51ad8e2b80f313af9cc74f5c0e03ccb87de09998732de" -dependencies = [ - "unicode-ident", -] - -[[package]] -name = "quote" -version = "1.0.40" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1885c039570dc00dcb4ff087a89e185fd56bae234ddc7f056a945bf36467248d" -dependencies = [ - "proc-macro2", -] - -[[package]] -name = "r-efi" -version = "5.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" - -[[package]] -name = "rand" -version = "0.8.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" -dependencies = [ - "libc", - "rand_chacha 0.3.1", - "rand_core 0.6.4", -] - -[[package]] -name = "rand" -version = "0.9.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" -dependencies = [ - "rand_chacha 0.9.0", - "rand_core 0.9.3", -] - -[[package]] -name = "rand_chacha" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" -dependencies = [ - "ppv-lite86", - "rand_core 0.6.4", -] - -[[package]] -name = "rand_chacha" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" -dependencies = [ - "ppv-lite86", - "rand_core 0.9.3", -] - -[[package]] -name = "rand_core" -version = "0.6.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" -dependencies = [ - "getrandom 0.2.16", -] - -[[package]] -name = "rand_core" -version = "0.9.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "99d9a13982dcf210057a8a78572b2217b667c3beacbf3a0d8b454f6f82837d38" -dependencies = [ - "getrandom 0.3.3", -] - -[[package]] -name = "redox_syscall" -version = "0.5.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5407465600fb0548f1442edf71dd20683c6ed326200ace4b1ef0763521bb3b77" -dependencies = [ - "bitflags 2.9.4", -] - -[[package]] -name = "ring" -version = "0.17.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" -dependencies = [ - "cc", - "cfg-if", - "getrandom 0.2.16", - "libc", - "untrusted", - "windows-sys 0.52.0", -] - -[[package]] -name = "rsa" -version = "0.9.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "78928ac1ed176a5ca1d17e578a1825f3d81ca54cf41053a592584b020cfd691b" -dependencies = [ - "const-oid", - "digest", - "num-bigint-dig", - "num-integer", - "num-traits", - "pkcs1", - "pkcs8", - "rand_core 0.6.4", - "signature", - "spki", - "subtle", - "zeroize", -] - -[[package]] -name = "rustc-demangle" -version = "0.1.26" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "56f7d92ca342cea22a06f2121d944b4fd82af56988c270852495420f961d4ace" - -[[package]] -name = "rustix" -version = "0.37.28" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "519165d378b97752ca44bbe15047d5d3409e875f39327546b42ac81d7e18c1b6" -dependencies = [ - "bitflags 1.3.2", - "errno", - "io-lifetimes", - "libc", - "linux-raw-sys 0.3.8", - "windows-sys 0.48.0", -] - -[[package]] -name = "rustix" -version = "1.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cd15f8a2c5551a84d56efdc1cd049089e409ac19a3072d5037a17fd70719ff3e" -dependencies = [ - "bitflags 2.9.4", - "errno", - "libc", - "linux-raw-sys 0.11.0", - "windows-sys 0.61.0", -] - -[[package]] -name = "rustls" -version = "0.23.31" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c0ebcbd2f03de0fc1122ad9bb24b127a5a6cd51d72604a3f3c50ac459762b6cc" -dependencies = [ - "once_cell", - "ring", - "rustls-pki-types", - "rustls-webpki", - "subtle", - "zeroize", -] - -[[package]] -name = "rustls-pki-types" -version = "1.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "229a4a4c221013e7e1f1a043678c5cc39fe5171437c88fb47151a21e6f5b5c79" -dependencies = [ - "zeroize", -] - -[[package]] -name = "rustls-webpki" -version = "0.103.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8572f3c2cb9934231157b45499fc41e1f58c589fdfb81a844ba873265e80f8eb" -dependencies = [ - "ring", - "rustls-pki-types", - "untrusted", -] - -[[package]] -name = "rustversion" -version = "1.0.22" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" - -[[package]] -name = "ryu" -version = "1.0.20" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f" - -[[package]] -name = "schannel" -version = "0.1.28" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "891d81b926048e76efe18581bf793546b4c0eaf8448d72be8de2bbee5fd166e1" -dependencies = [ - "windows-sys 0.61.0", -] - -[[package]] -name = "scopeguard" -version = "1.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" - -[[package]] -name = "security-framework" -version = "2.11.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02" -dependencies = [ - "bitflags 2.9.4", - "core-foundation", - "core-foundation-sys", - "libc", - "security-framework-sys", -] - -[[package]] -name = "security-framework-sys" -version = "2.15.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cc1f0cbffaac4852523ce30d8bd3c5cdc873501d96ff467ca09b6767bb8cd5c0" -dependencies = [ - "core-foundation-sys", - "libc", -] - -[[package]] -name = "serde" -version = "1.0.225" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fd6c24dee235d0da097043389623fb913daddf92c76e9f5a1db88607a0bcbd1d" -dependencies = [ - "serde_core", - "serde_derive", -] - -[[package]] -name = "serde_core" -version = "1.0.225" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "659356f9a0cb1e529b24c01e43ad2bdf520ec4ceaf83047b83ddcc2251f96383" -dependencies = [ - "serde_derive", -] - -[[package]] -name = "serde_derive" -version = "1.0.225" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ea936adf78b1f766949a4977b91d2f5595825bd6ec079aa9543ad2685fc4516" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "serde_json" -version = "1.0.145" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "402a6f66d8c709116cf22f558eab210f5a50187f702eb4d7e5ef38d9a7f1c79c" -dependencies = [ - "itoa", - "memchr", - "ryu", - "serde", - "serde_core", -] - -[[package]] -name = "serde_urlencoded" -version = "0.7.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" -dependencies = [ - "form_urlencoded", - "itoa", - "ryu", - "serde", -] - -[[package]] -name = "sha1" -version = "0.10.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" -dependencies = [ - "cfg-if", - "cpufeatures", - "digest", -] - -[[package]] -name = "sha2" -version = "0.10.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" -dependencies = [ - "cfg-if", - "cpufeatures", - "digest", -] - -[[package]] -name = "shlex" -version = "1.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" - -[[package]] -name = "signature" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" -dependencies = [ - "digest", - "rand_core 0.6.4", -] - -[[package]] -name = "slab" -version = "0.4.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a2ae44ef20feb57a68b23d846850f861394c2e02dc425a50098ae8c90267589" - -[[package]] -name = "smallvec" -version = "1.15.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" -dependencies = [ - "serde", -] - -[[package]] -name = "socket2" -version = "0.4.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9f7916fc008ca5542385b89a3d3ce689953c143e9304a9bf8beec1de48994c0d" -dependencies = [ - "libc", - "winapi", -] - -[[package]] -name = "socket2" -version = "0.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "233504af464074f9d066d7b5416c5f9b894a5862a6506e306f7b816cdd6f1807" -dependencies = [ - "libc", - "windows-sys 0.59.0", -] - -[[package]] -name = "spin" -version = "0.9.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" -dependencies = [ - "lock_api", -] - -[[package]] -name = "spki" -version = "0.7.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" -dependencies = [ - "base64ct", - "der", -] - -[[package]] -name = "sqlx" -version = "0.8.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fefb893899429669dcdd979aff487bd78f4064e5e7907e4269081e0ef7d97dc" -dependencies = [ - "sqlx-core", - "sqlx-macros", - "sqlx-mysql", - "sqlx-postgres", - "sqlx-sqlite", -] - -[[package]] -name = "sqlx-core" -version = "0.8.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ee6798b1838b6a0f69c007c133b8df5866302197e404e8b6ee8ed3e3a5e68dc6" -dependencies = [ - "async-io 1.13.0", - "async-std", - "base64", - "bytes", - "chrono", - "crc", - "crossbeam-queue", - "either", - "event-listener 5.4.1", - "futures-core", - "futures-intrusive", - "futures-io", - "futures-util", - "hashbrown", - "hashlink", - "indexmap", - "log", - "memchr", - "native-tls", - "once_cell", - "percent-encoding", - "rustls", - "serde", - "serde_json", - "sha2", - "smallvec", - "thiserror", - "tokio", - "tokio-stream", - "tracing", - "url", - "webpki-roots 0.26.11", -] - -[[package]] -name = "sqlx-macros" -version = "0.8.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a2d452988ccaacfbf5e0bdbc348fb91d7c8af5bee192173ac3636b5fb6e6715d" -dependencies = [ - "proc-macro2", - "quote", - "sqlx-core", - "sqlx-macros-core", - "syn", -] - -[[package]] -name = "sqlx-macros-core" -version = "0.8.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "19a9c1841124ac5a61741f96e1d9e2ec77424bf323962dd894bdb93f37d5219b" -dependencies = [ - "async-std", - "dotenvy", - "either", - "heck", - "hex", - "once_cell", - "proc-macro2", - "quote", - "serde", - "serde_json", - "sha2", - "sqlx-core", - "sqlx-mysql", - "sqlx-postgres", - "sqlx-sqlite", - "syn", - "tokio", - "url", -] - -[[package]] -name = "sqlx-mysql" -version = "0.8.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aa003f0038df784eb8fecbbac13affe3da23b45194bd57dba231c8f48199c526" -dependencies = [ - "atoi", - "base64", - "bitflags 2.9.4", - "byteorder", - "bytes", - "chrono", - "crc", - "digest", - "dotenvy", - "either", - "futures-channel", - "futures-core", - "futures-io", - "futures-util", - "generic-array", - "hex", - "hkdf", - "hmac", - "itoa", - "log", - "md-5", - "memchr", - "once_cell", - "percent-encoding", - "rand 0.8.5", - "rsa", - "serde", - "sha1", - "sha2", - "smallvec", - "sqlx-core", - "stringprep", - "thiserror", - "tracing", - "whoami", -] - -[[package]] -name = "sqlx-postgres" -version = "0.8.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "db58fcd5a53cf07c184b154801ff91347e4c30d17a3562a635ff028ad5deda46" -dependencies = [ - "atoi", - "base64", - "bitflags 2.9.4", - "byteorder", - "chrono", - "crc", - "dotenvy", - "etcetera", - "futures-channel", - "futures-core", - "futures-util", - "hex", - "hkdf", - "hmac", - "home", - "itoa", - "log", - "md-5", - "memchr", - "once_cell", - "rand 0.8.5", - "serde", - "serde_json", - "sha2", - "smallvec", - "sqlx-core", - "stringprep", - "thiserror", - "tracing", - "whoami", -] - -[[package]] -name = "sqlx-sqlite" -version = "0.8.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c2d12fe70b2c1b4401038055f90f151b78208de1f9f89a7dbfd41587a10c3eea" -dependencies = [ - "atoi", - "chrono", - "flume", - "futures-channel", - "futures-core", - "futures-executor", - "futures-intrusive", - "futures-util", - "libsqlite3-sys", - "log", - "percent-encoding", - "serde", - "serde_urlencoded", - "sqlx-core", - "thiserror", - "tracing", - "url", -] - -[[package]] -name = "stable_deref_trait" -version = "1.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a8f112729512f8e442d81f95a8a7ddf2b7c6b8a1a6f509a95864142b30cab2d3" - -[[package]] -name = "stringprep" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7b4df3d392d81bd458a8a621b8bffbd2302a12ffe288a9d931670948749463b1" -dependencies = [ - "unicode-bidi", - "unicode-normalization", - "unicode-properties", -] - -[[package]] -name = "subtle" -version = "2.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" - -[[package]] -name = "syn" -version = "2.0.106" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ede7c438028d4436d71104916910f5bb611972c5cfd7f89b8300a8186e6fada6" -dependencies = [ - "proc-macro2", - "quote", - "unicode-ident", -] - -[[package]] -name = "synstructure" -version = "0.13.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "tempfile" -version = "3.22.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "84fa4d11fadde498443cca10fd3ac23c951f0dc59e080e9f4b93d4df4e4eea53" -dependencies = [ - "fastrand 2.3.0", - "getrandom 0.3.3", - "once_cell", - "rustix 1.1.2", - "windows-sys 0.61.0", -] - -[[package]] -name = "thiserror" -version = "2.0.16" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3467d614147380f2e4e374161426ff399c91084acd2363eaf549172b3d5e60c0" -dependencies = [ - "thiserror-impl", -] - -[[package]] -name = "thiserror-impl" -version = "2.0.16" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6c5e1be1c48b9172ee610da68fd9cd2770e7a4056cb3fc98710ee6906f0c7960" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "tinystr" -version = "0.8.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d4f6d1145dcb577acf783d4e601bc1d76a13337bb54e6233add580b07344c8b" -dependencies = [ - "displaydoc", - "zerovec", -] - -[[package]] -name = "tinyvec" -version = "1.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bfa5fdc3bce6191a1dbc8c02d5c8bffcf557bafa17c124c5264a458f1b0613fa" -dependencies = [ - "tinyvec_macros", -] - -[[package]] -name = "tinyvec_macros" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" - -[[package]] -name = "tokio" -version = "1.47.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "89e49afdadebb872d3145a5638b59eb0691ea23e46ca484037cfab3b76b95038" -dependencies = [ - "backtrace", - "bytes", - "io-uring", - "libc", - "mio", - "pin-project-lite", - "slab", - "socket2 0.6.0", - "tokio-macros", - "windows-sys 0.59.0", -] - -[[package]] -name = "tokio-macros" -version = "2.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e06d43f1345a3bcd39f6a56dbb7dcab2ba47e68e8ac134855e7e2bdbaf8cab8" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "tokio-stream" -version = "0.1.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eca58d7bba4a75707817a2c44174253f9236b2d5fbd055602e9d5c07c139a047" -dependencies = [ - "futures-core", - "pin-project-lite", - "tokio", -] - -[[package]] -name = "tower-layer" -version = "0.3.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" - -[[package]] -name = "tower-service" -version = "0.3.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" - -[[package]] -name = "tracing" -version = "0.1.41" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "784e0ac535deb450455cbfa28a6f0df145ea1bb7ae51b821cf5e7927fdcfbdd0" -dependencies = [ - "log", - "pin-project-lite", - "tracing-attributes", - "tracing-core", -] - -[[package]] -name = "tracing-attributes" -version = "0.1.30" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "81383ab64e72a7a8b8e13130c49e3dab29def6d0c7d76a03087b3cf71c5c6903" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "tracing-core" -version = "0.1.34" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9d12581f227e93f094d3af2ae690a574abb8a2b9b7a96e7cfe9647b2b617678" -dependencies = [ - "once_cell", -] - -[[package]] -name = "typenum" -version = "1.18.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1dccffe3ce07af9386bfd29e80c0ab1a8205a2fc34e4bcd40364df902cfa8f3f" - -[[package]] -name = "ulid" -version = "1.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "470dbf6591da1b39d43c14523b2b469c86879a53e8b758c8e090a470fe7b1fbe" -dependencies = [ - "rand 0.9.2", - "web-time", -] - -[[package]] -name = "unicode-bidi" -version = "0.3.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5" - -[[package]] -name = "unicode-ident" -version = "1.0.19" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f63a545481291138910575129486daeaf8ac54aee4387fe7906919f7830c7d9d" - -[[package]] -name = "unicode-normalization" -version = "0.1.24" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5033c97c4262335cded6d6fc3e5c18ab755e1a3dc96376350f3d8e9f009ad956" -dependencies = [ - "tinyvec", -] - -[[package]] -name = "unicode-properties" -version = "0.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e70f2a8b45122e719eb623c01822704c4e0907e7e426a05927e1a1cfff5b75d0" - -[[package]] -name = "untrusted" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" - -[[package]] -name = "url" -version = "2.5.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "08bc136a29a3d1758e07a9cca267be308aeebf5cfd5a10f3f67ab2097683ef5b" -dependencies = [ - "form_urlencoded", - "idna", - "percent-encoding", - "serde", -] - -[[package]] -name = "utf8_iter" -version = "1.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" - -[[package]] -name = "value-bag" -version = "1.11.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "943ce29a8a743eb10d6082545d861b24f9d1b160b7d741e0f2cdf726bec909c5" - -[[package]] -name = "vcpkg" -version = "0.2.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" - -[[package]] -name = "version_check" -version = "0.9.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" - -[[package]] -name = "waker-fn" -version = "1.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "317211a0dc0ceedd78fb2ca9a44aed3d7b9b26f81870d485c07122b4350673b7" - -[[package]] -name = "wasi" -version = "0.11.1+wasi-snapshot-preview1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" - -[[package]] -name = "wasi" -version = "0.14.7+wasi-0.2.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "883478de20367e224c0090af9cf5f9fa85bed63a95c1abf3afc5c083ebc06e8c" -dependencies = [ - "wasip2", -] - -[[package]] -name = "wasip2" -version = "1.0.1+wasi-0.2.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0562428422c63773dad2c345a1882263bbf4d65cf3f42e90921f787ef5ad58e7" -dependencies = [ - "wit-bindgen", -] - -[[package]] -name = "wasite" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8dad83b4f25e74f184f64c43b150b91efe7647395b42289f38e50566d82855b" - -[[package]] -name = "wasm-bindgen" -version = "0.2.101" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7e14915cadd45b529bb8d1f343c4ed0ac1de926144b746e2710f9cd05df6603b" -dependencies = [ - "cfg-if", - "once_cell", - "rustversion", - "wasm-bindgen-macro", - "wasm-bindgen-shared", -] - -[[package]] -name = "wasm-bindgen-backend" -version = "0.2.101" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e28d1ba982ca7923fd01448d5c30c6864d0a14109560296a162f80f305fb93bb" -dependencies = [ - "bumpalo", - "log", - "proc-macro2", - "quote", - "syn", - "wasm-bindgen-shared", -] - -[[package]] -name = "wasm-bindgen-futures" -version = "0.4.51" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ca85039a9b469b38336411d6d6ced91f3fc87109a2a27b0c197663f5144dffe" -dependencies = [ - "cfg-if", - "js-sys", - "once_cell", - "wasm-bindgen", - "web-sys", -] - -[[package]] -name = "wasm-bindgen-macro" -version = "0.2.101" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7c3d463ae3eff775b0c45df9da45d68837702ac35af998361e2c84e7c5ec1b0d" -dependencies = [ - "quote", - "wasm-bindgen-macro-support", -] - -[[package]] -name = "wasm-bindgen-macro-support" -version = "0.2.101" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7bb4ce89b08211f923caf51d527662b75bdc9c9c7aab40f86dcb9fb85ac552aa" -dependencies = [ - "proc-macro2", - "quote", - "syn", - "wasm-bindgen-backend", - "wasm-bindgen-shared", -] - -[[package]] -name = "wasm-bindgen-shared" -version = "0.2.101" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f143854a3b13752c6950862c906306adb27c7e839f7414cec8fea35beab624c1" -dependencies = [ - "unicode-ident", -] - -[[package]] -name = "web-sys" -version = "0.3.78" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77e4b637749ff0d92b8fad63aa1f7cff3cbe125fd49c175cd6345e7272638b12" -dependencies = [ - "js-sys", - "wasm-bindgen", -] - -[[package]] -name = "web-time" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" -dependencies = [ - "js-sys", - "wasm-bindgen", -] - -[[package]] -name = "webpki-roots" -version = "0.26.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9" -dependencies = [ - "webpki-roots 1.0.2", -] - -[[package]] -name = "webpki-roots" -version = "1.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7e8983c3ab33d6fb807cfcdad2491c4ea8cbc8ed839181c7dfd9c67c83e261b2" -dependencies = [ - "rustls-pki-types", -] - -[[package]] -name = "whoami" -version = "1.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d4a4db5077702ca3015d3d02d74974948aba2ad9e12ab7df718ee64ccd7e97d" -dependencies = [ - "libredox", - "wasite", -] - -[[package]] -name = "winapi" -version = "0.3.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" -dependencies = [ - "winapi-i686-pc-windows-gnu", - "winapi-x86_64-pc-windows-gnu", -] - -[[package]] -name = "winapi-i686-pc-windows-gnu" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" - -[[package]] -name = "winapi-x86_64-pc-windows-gnu" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" - -[[package]] -name = "windows-core" -version = "0.62.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "57fe7168f7de578d2d8a05b07fd61870d2e73b4020e9f49aa00da8471723497c" -dependencies = [ - "windows-implement", - "windows-interface", - "windows-link", - "windows-result", - "windows-strings", -] - -[[package]] -name = "windows-implement" -version = "0.60.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a47fddd13af08290e67f4acabf4b459f647552718f683a7b415d290ac744a836" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "windows-interface" -version = "0.59.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bd9211b69f8dcdfa817bfd14bf1c97c9188afa36f4750130fcdf3f400eca9fa8" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "windows-link" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "45e46c0661abb7180e7b9c281db115305d49ca1709ab8242adf09666d2173c65" - -[[package]] -name = "windows-result" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7084dcc306f89883455a206237404d3eaf961e5bd7e0f312f7c91f57eb44167f" -dependencies = [ - "windows-link", -] - -[[package]] -name = "windows-strings" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7218c655a553b0bed4426cf54b20d7ba363ef543b52d515b3e48d7fd55318dda" -dependencies = [ - "windows-link", -] - -[[package]] -name = "windows-sys" -version = "0.48.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" -dependencies = [ - "windows-targets 0.48.5", -] - -[[package]] -name = "windows-sys" -version = "0.52.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" -dependencies = [ - "windows-targets 0.52.6", -] - -[[package]] -name = "windows-sys" -version = "0.59.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" -dependencies = [ - "windows-targets 0.52.6", -] - -[[package]] -name = "windows-sys" -version = "0.61.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e201184e40b2ede64bc2ea34968b28e33622acdbbf37104f0e4a33f7abe657aa" -dependencies = [ - "windows-link", -] - -[[package]] -name = "windows-targets" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" -dependencies = [ - "windows_aarch64_gnullvm 0.48.5", - "windows_aarch64_msvc 0.48.5", - "windows_i686_gnu 0.48.5", - "windows_i686_msvc 0.48.5", - "windows_x86_64_gnu 0.48.5", - "windows_x86_64_gnullvm 0.48.5", - "windows_x86_64_msvc 0.48.5", -] - -[[package]] -name = "windows-targets" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" -dependencies = [ - "windows_aarch64_gnullvm 0.52.6", - "windows_aarch64_msvc 0.52.6", - "windows_i686_gnu 0.52.6", - "windows_i686_gnullvm", - "windows_i686_msvc 0.52.6", - "windows_x86_64_gnu 0.52.6", - "windows_x86_64_gnullvm 0.52.6", - "windows_x86_64_msvc 0.52.6", -] - -[[package]] -name = "windows_aarch64_gnullvm" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" - -[[package]] -name = "windows_aarch64_gnullvm" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" - -[[package]] -name = "windows_aarch64_msvc" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" - -[[package]] -name = "windows_aarch64_msvc" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" - -[[package]] -name = "windows_i686_gnu" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" - -[[package]] -name = "windows_i686_gnu" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" - -[[package]] -name = "windows_i686_gnullvm" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" - -[[package]] -name = "windows_i686_msvc" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" - -[[package]] -name = "windows_i686_msvc" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" - -[[package]] -name = "windows_x86_64_gnu" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" - -[[package]] -name = "windows_x86_64_gnu" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" - -[[package]] -name = "windows_x86_64_gnullvm" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" - -[[package]] -name = "windows_x86_64_gnullvm" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" - -[[package]] -name = "windows_x86_64_msvc" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" - -[[package]] -name = "windows_x86_64_msvc" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" - -[[package]] -name = "wit-bindgen" -version = "0.46.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f17a85883d4e6d00e8a97c586de764dabcc06133f7f1d55dce5cdc070ad7fe59" - -[[package]] -name = "writeable" -version = "0.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ea2f10b9bb0928dfb1b42b65e1f9e36f7f54dbdf08457afefb38afcdec4fa2bb" - -[[package]] -name = "yoke" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f41bb01b8226ef4bfd589436a297c53d118f65921786300e427be8d487695cc" -dependencies = [ - "serde", - "stable_deref_trait", - "yoke-derive", - "zerofrom", -] - -[[package]] -name = "yoke-derive" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "38da3c9736e16c5d3c8c597a9aaa5d1fa565d0532ae05e27c24aa62fb32c0ab6" -dependencies = [ - "proc-macro2", - "quote", - "syn", - "synstructure", -] - -[[package]] -name = "zerocopy" -version = "0.8.27" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0894878a5fa3edfd6da3f88c4805f4c8558e2b996227a3d864f47fe11e38282c" -dependencies = [ - "zerocopy-derive", -] - -[[package]] -name = "zerocopy-derive" -version = "0.8.27" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "88d2b8d9c68ad2b9e4340d7832716a4d21a22a1154777ad56ea55c51a9cf3831" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "zerofrom" -version = "0.1.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "50cc42e0333e05660c3587f3bf9d0478688e15d870fab3346451ce7f8c9fbea5" -dependencies = [ - "zerofrom-derive", -] - -[[package]] -name = "zerofrom-derive" -version = "0.1.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" -dependencies = [ - "proc-macro2", - "quote", - "syn", - "synstructure", -] - -[[package]] -name = "zeroize" -version = "1.8.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ced3678a2879b30306d323f4542626697a464a97c0a07c9aebf7ebca65cd4dde" - -[[package]] -name = "zerotrie" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "36f0bbd478583f79edad978b407914f61b2972f5af6fa089686016be8f9af595" -dependencies = [ - "displaydoc", - "yoke", - "zerofrom", -] - -[[package]] -name = "zerovec" -version = "0.11.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e7aa2bd55086f1ab526693ecbe444205da57e25f4489879da80635a46d90e73b" -dependencies = [ - "yoke", - "zerofrom", - "zerovec-derive", -] - -[[package]] -name = "zerovec-derive" -version = "0.11.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b96237efa0c878c64bd89c436f661be4e46b2f3eff1ebb976f7ef2321d2f58f" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] diff --git a/Cargo.toml b/Cargo.toml index 880e74a..45a98e2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,11 +4,17 @@ version = "1.0.0-alpha.1" authors = ["Njuguna Mureithi "] edition = "2024" repository = "https://github.com/apalis-dev/apalis-postgres" -license = "MIT" +license-file = "LICENSE" description = "Background task processing for rust using apalis and postgres" +readme = "README.md" +homepage = "https://github.com/apalis-dev/apalis-postgres" +documentation = "https://docs.rs/apalis-postgres" +keywords = ["apalis", "postgres", "jobs", "queue", "worker"] +categories = ["asynchronous", "databases", "network-programming"] +publish = true [features] -default = ["migrate"] +default = ["migrate", "tokio-comp"] migrate = ["sqlx/migrate", "sqlx/macros"] async-std-comp = ["async-std", "sqlx/runtime-async-std-rustls"] async-std-comp-native-tls = ["async-std", "sqlx/runtime-async-std-native-tls"] @@ -16,19 +22,21 @@ tokio-comp = ["tokio", "sqlx/runtime-tokio-rustls"] tokio-comp-native-tls = ["tokio", "sqlx/runtime-tokio-native-tls"] [dependencies] -apalis-core = { version = "1.0.0-alpha.1", default-features = false, features = [ +apalis-core = { version = "1.0.0-alpha.7", default-features = false, features = [ "sleep", "json", ] } +apalis-sql = { version = "1.0.0-alpha.7", default-features = false } serde = { version = "1", features = ["derive"] } chrono = { version = "0.4", features = ["serde"] } pin-project = "1.1.10" serde_json = "1" futures = "0.3.30" - +thiserror = "2" tokio = { version = "1", features = ["rt", "net"], optional = true } async-std = { version = "1.13.0", optional = true } -ulid = { version = "1" } +ulid = { version = "1", features = ["serde"] } + [dependencies.sqlx] version = "0.8.1" @@ -38,4 +46,5 @@ features = ["chrono", "postgres", "json"] [dev-dependencies] tokio = { version = "1", features = ["macros", "rt-multi-thread"] } once_cell = "1.19.0" -apalis-core = { version = "1.0.0-alpha.1", features = ["test-utils"] } +apalis-core = { version = "1.0.0-alpha.7", features = ["test-utils"] } +apalis-workflow = { version = "0.1.0-alpha.5" } diff --git a/README.md b/README.md index c9e3d6a..96c5322 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,197 @@ # apalis-postgres -Background task processing in rust using apalis and postgres + +Background task processing in rust using `apalis` and `postgres` + +## Features + +- **Reliable job queue** using Postgres as the backend. +- **Multiple storage types**: standard polling and `trigger` based storages. +- **Custom codecs** for serializing/deserializing job arguments as bytes. +- **Heartbeat and orphaned job re-enqueueing** for robust task processing. +- **Integration with `apalis` workers and middleware.** + +## Storage Types + +- [`PostgresStorage`]: Standard polling-based storage. +- [`PostgresStorageWithListener`]: Event-driven storage using Postgres `NOTIFY` for low-latency job fetching. +- [`SharedPostgresStorage`]: Shared storage for multiple job types, uses Postgres `NOTIFY`. + +The naming is designed to clearly indicate the storage mechanism and its capabilities, but under the hood the result is the `PostgresStorage` struct with different configurations. + +## Examples + +### Basic Worker Example + +```rust,no_run +#[tokio::main] +async fn main() { + let pool = PgPool::connect(env!("DATABASE_URL").unwrap()).await.unwrap(); + PostgresStorage::setup(&pool).await.unwrap(); + let mut backend = PostgresStorage::new(&pool); + + let mut start = 0; + let mut items = stream::repeat_with(move || { + start += 1; + let task = Task::builder(start) + .run_after(Duration::from_secs(1)) + .with_ctx(SqlContext::new().with_priority(1)) + .build(); + Ok(task) + }) + .take(10); + backend.send_all(&mut items).await.unwrap(); + + async fn send_reminder(item: usize, wrk: WorkerContext) -> Result<(), BoxDynError> { + Ok(()) + } + + let worker = WorkerBuilder::new("worker-1") + .backend(backend) + .build(send_reminder); + worker.run().await.unwrap(); +} +``` + +### `NOTIFY` listener example + +```rust,no_run + +#[tokio::main] +async fn main() { + let pool = PostgresPool::connect(env!("DATABASE_URL").unwrap()).await.unwrap(); + PostgresStorage::setup(&pool).await.unwrap(); + + let lazy_strategy = StrategyBuilder::new() + .apply(IntervalStrategy::new(Duration::from_secs(5))) + .build(); + let config = Config::new("queue") + .with_poll_interval(lazy_strategy) + .set_buffer_size(5); + let backend = PostgresStorage::new_with_notify(&pool, &config).await; + + tokio::spawn({ + let pool = pool.clone(); + let config = config.clone(); + async move { + tokio::time::sleep(Duration::from_secs(2)).await; + let mut start = 0; + let items = stream::repeat_with(move || { + start += 1; + Task::builder(serde_json::to_vec(&start).unwrap()) + .run_after(Duration::from_secs(1)) + .with_ctx(SqlContext::new().with_priority(start)) + .build() + }) + .take(20) + .collect::>() + .await; + apalis_postgres::sink::push_tasks(pool, config, items).await.unwrap(); + } + }); + + async fn send_reminder(item: usize, wrk: WorkerContext) -> Result<(), BoxDynError> { + Ok(()) + } + + let worker = WorkerBuilder::new("worker-2") + .backend(backend) + .build(send_reminder); + worker.run().await.unwrap(); +} +``` + +### Workflow Example + +```rust,no_run +#[tokio::main] +async fn main() { + let workflow = WorkFlow::new("odd-numbers-workflow") + .then(|a: usize| async move { + Ok::<_, WorkflowError>((0..=a).collect::>()) + }) + .filter_map(|x| async move { + if x % 2 != 0 { Some(x) } else { None } + }) + .filter_map(|x| async move { + if x % 3 != 0 { Some(x) } else { None } + }) + .filter_map(|x| async move { + if x % 5 != 0 { Some(x) } else { None } + }) + .delay_for(Duration::from_millis(1000)) + .then(|a: Vec| async move { + println!("Sum: {}", a.iter().sum::()); + Ok::<(), WorkflowError>(()) + }); + + let pool = PostgresPool::connect(env!("DATABASE_URL")).await.unwrap(); + PostgresStorage::setup(&pool).await.unwrap(); + let mut backend = PostgresStorage::new_in_queue(&pool, "test-workflow"); + + backend.push(100usize).await.unwrap(); + + let worker = WorkerBuilder::new("rango-tango") + .backend(backend) + .on_event(|ctx, ev| { + println!("On Event = {:?}", ev); + if matches!(ev, Event::Error(_)) { + ctx.stop().unwrap(); + } + }) + .build(workflow); + + worker.run().await.unwrap(); +} +``` + +### Shared Example + +This shows an example of multiple backends using the same connection. +This can improve performance if you have many types of jobs. + +```rs +#[tokio::main] +async fn main() { + let pool = PgPool::connect(env!("DATABASE_URL")) + .await + .unwrap(); + let mut store = SharedPostgresStorage::new(pool); + + let mut map_store = store.make_shared().unwrap(); + + let mut int_store = store.make_shared().unwrap(); + + map_store + .push_stream(&mut stream::iter(vec![HashMap::::new()])) + .await + .unwrap(); + int_store.push(99).await.unwrap(); + + async fn send_reminder( + _: T, + task_id: TaskId, + wrk: WorkerContext, + ) -> Result<(), BoxDynError> { + tokio::time::sleep(Duration::from_secs(2)).await; + wrk.stop().unwrap(); + Ok(()) + } + + let int_worker = WorkerBuilder::new("rango-tango-2") + .backend(int_store) + .build(send_reminder); + let map_worker = WorkerBuilder::new("rango-tango-1") + .backend(map_store) + .build(send_reminder); + tokio::try_join!(int_worker.run(), map_worker.run()).unwrap(); +} +``` + +## Observability + +You can track your jobs using [apalis-board](https://github.com/apalis-dev/apalis-board). +![Task](https://github.com/apalis-dev/apalis-board/raw/master/screenshots/task.png) + +## License + +Licensed under either of Apache License, Version 2.0 or MIT license at your option. diff --git a/migrations/20251018164839_update_result.sql b/migrations/20251018164839_update_result.sql new file mode 100644 index 0000000..3d4a86a --- /dev/null +++ b/migrations/20251018164839_update_result.sql @@ -0,0 +1,7 @@ +ALTER TABLE apalis.jobs +RENAME COLUMN last_error TO last_result; + +ALTER TABLE apalis.jobs + ALTER COLUMN last_result +SET DATA TYPE jsonb +USING last_result::jsonb; diff --git a/migrations/20251018164912_add_metadata.sql b/migrations/20251018164912_add_metadata.sql new file mode 100644 index 0000000..232a347 --- /dev/null +++ b/migrations/20251018164912_add_metadata.sql @@ -0,0 +1,4 @@ +ALTER TABLE + apalis.jobs +ADD + COLUMN metadata jsonb; diff --git a/migrations/20251018165007_move_to_bytes.sql b/migrations/20251018165007_move_to_bytes.sql new file mode 100644 index 0000000..cd6d214 --- /dev/null +++ b/migrations/20251018165007_move_to_bytes.sql @@ -0,0 +1,4 @@ +ALTER TABLE + apalis.jobs +ALTER COLUMN + job TYPE bytea USING convert_to(job::text, 'UTF8'); diff --git a/migrations/20251018165033_add_started_at.sql b/migrations/20251018165033_add_started_at.sql new file mode 100644 index 0000000..ffc3d08 --- /dev/null +++ b/migrations/20251018165033_add_started_at.sql @@ -0,0 +1,4 @@ +ALTER TABLE + apalis.workers +ADD + COLUMN started_at TIMESTAMP WITH TIME ZONE; diff --git a/migrations/20251018165056_queue_jobs.sql b/migrations/20251018165056_queue_jobs.sql new file mode 100644 index 0000000..f4ba45a --- /dev/null +++ b/migrations/20251018165056_queue_jobs.sql @@ -0,0 +1,28 @@ +DROP FUNCTION apalis.get_jobs( + worker_id TEXT, + v_job_type TEXT, + v_job_count integer + ); + +CREATE OR REPLACE FUNCTION apalis.get_jobs( + worker_id TEXT, + v_job_type TEXT, + v_job_count integer DEFAULT 5 :: integer + ) RETURNS setof apalis.jobs AS $$ BEGIN RETURN QUERY +UPDATE apalis.jobs +SET status = 'Queued', + lock_by = worker_id, + lock_at = now() +WHERE id IN ( + SELECT id + FROM apalis.jobs + WHERE (status='Pending' OR (status = 'Failed' AND attempts < max_attempts)) + AND run_at < now() + AND job_type = v_job_type + ORDER BY priority DESC, run_at ASC + LIMIT v_job_count FOR + UPDATE SKIP LOCKED + ) +returning *; +END; +$$ LANGUAGE plpgsql volatile; diff --git a/migrations/20251018165121_notify_run_at.sql b/migrations/20251018165121_notify_run_at.sql new file mode 100644 index 0000000..5412e5c --- /dev/null +++ b/migrations/20251018165121_notify_run_at.sql @@ -0,0 +1,22 @@ +DROP TRIGGER IF EXISTS notify_workers ON apalis.jobs; +DROP FUNCTION IF EXISTS apalis.notify_new_jobs; + +CREATE FUNCTION apalis.notify_new_jobs() RETURNS TRIGGER AS $$ +BEGIN + IF NEW.run_at <= now() THEN + PERFORM pg_notify( + 'apalis::job::insert', + json_build_object( + 'job_type', NEW.job_type, + 'id', NEW.id, + 'run_at', NEW.run_at + )::text + ); + END IF; + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +CREATE TRIGGER notify_workers +AFTER INSERT ON apalis.jobs +FOR EACH ROW EXECUTE FUNCTION apalis.notify_new_jobs(); diff --git a/queries/backend/fetch_completed_tasks.sql b/queries/backend/fetch_completed_tasks.sql new file mode 100644 index 0000000..c9727fb --- /dev/null +++ b/queries/backend/fetch_completed_tasks.sql @@ -0,0 +1,21 @@ +SELECT + id, + status, + last_result AS result +FROM + apalis.jobs +WHERE + id IN ( + SELECT + value::text + FROM + jsonb_array_elements_text($1) AS value + ) + AND ( + status = 'Done' + OR ( + status = 'Failed' + AND attempts >= max_attempts + ) + OR status = 'Killed' + ); diff --git a/queries/backend/fetch_next.sql b/queries/backend/fetch_next.sql new file mode 100644 index 0000000..f2552f9 --- /dev/null +++ b/queries/backend/fetch_next.sql @@ -0,0 +1,20 @@ +UPDATE Jobs +SET + status = 'Queued', + lock_by = ?1, + lock_at = strftime('%s', 'now') +WHERE + ROWID IN ( + SELECT ROWID + FROM Jobs + WHERE job_type = ?2 + AND ( + (status = 'Pending' AND lock_by IS NULL) + OR + (status = 'Failed' AND attempts < max_attempts) + ) + AND (run_at IS NULL OR run_at <= strftime('%s', 'now')) + ORDER BY priority DESC, run_at ASC, id ASC + LIMIT ?3 + ) +RETURNING * diff --git a/queries/backend/fetch_next_shared.sql b/queries/backend/fetch_next_shared.sql new file mode 100644 index 0000000..80fd356 --- /dev/null +++ b/queries/backend/fetch_next_shared.sql @@ -0,0 +1,26 @@ +UPDATE Jobs +SET + status = 'Queued', + lock_at = strftime('%s', 'now') +WHERE ROWID IN ( + SELECT ROWID + FROM Jobs + WHERE job_type IN ( + SELECT value FROM json_each(?1) + ) + AND status = 'Pending' + AND lock_by IS NULL + AND ( + run_at IS NULL + OR run_at <= strftime('%s', 'now') + ) + AND ROWID IN ( + SELECT value FROM json_each(?2) + ) + ORDER BY + priority DESC, + run_at ASC, + id ASC + LIMIT ?3 +) +RETURNING *; diff --git a/queries/backend/keep_alive.sql b/queries/backend/keep_alive.sql new file mode 100644 index 0000000..820b82a --- /dev/null +++ b/queries/backend/keep_alive.sql @@ -0,0 +1,6 @@ +UPDATE + apalis.workers +SET + last_seen = NOW() +WHERE + id = $1 AND worker_type = $2; diff --git a/queries/backend/list_all_jobs.sql b/queries/backend/list_all_jobs.sql new file mode 100644 index 0000000..23f14ea --- /dev/null +++ b/queries/backend/list_all_jobs.sql @@ -0,0 +1,11 @@ +SELECT + * +FROM + apalis.jobs +WHERE + status = $1 +ORDER BY + done_at DESC, + run_at DESC +LIMIT + $2 OFFSET $3 diff --git a/queries/backend/list_all_workers.sql b/queries/backend/list_all_workers.sql new file mode 100644 index 0000000..a9b3b9f --- /dev/null +++ b/queries/backend/list_all_workers.sql @@ -0,0 +1,8 @@ +SELECT + * +FROM + apalis.workers +ORDER BY + last_seen DESC +LIMIT + $1 OFFSET $2 diff --git a/queries/backend/list_jobs.sql b/queries/backend/list_jobs.sql new file mode 100644 index 0000000..669f528 --- /dev/null +++ b/queries/backend/list_jobs.sql @@ -0,0 +1,12 @@ +SELECT + * +FROM + apalis.jobs +WHERE + status = $1 + AND job_type = $2 +ORDER BY + done_at DESC, + run_at DESC +LIMIT + $3 OFFSET $4 diff --git a/queries/backend/list_queues.sql b/queries/backend/list_queues.sql new file mode 100644 index 0000000..bc46401 --- /dev/null +++ b/queries/backend/list_queues.sql @@ -0,0 +1,210 @@ +WITH queue_stats AS ( + SELECT + job_type, + jsonb_agg( + jsonb_build_object( + 'title', statistic, + 'stat_type', type, + 'value', value, + 'priority', priority + ) ORDER BY priority, statistic + ) as stats + FROM ( + -- Priority 1: Current Status + SELECT + job_type, + 1 AS priority, + 'Number' AS type, + 'RUNNING_JOBS' AS statistic, + SUM(CASE WHEN status = 'Running' THEN 1 ELSE 0 END)::TEXT AS value + FROM apalis.jobs + GROUP BY job_type + + UNION ALL + + SELECT + job_type, 1, 'Number', 'PENDING_JOBS', + SUM(CASE WHEN status = 'Pending' THEN 1 ELSE 0 END)::TEXT + FROM apalis.jobs + GROUP BY job_type + + UNION ALL + + SELECT + job_type, 1, 'Number', 'FAILED_JOBS', + SUM(CASE WHEN status = 'Failed' THEN 1 ELSE 0 END)::TEXT + FROM apalis.jobs + GROUP BY job_type + + UNION ALL + + -- Priority 2: Health Metrics + SELECT + job_type, 2, 'Number', 'ACTIVE_JOBS', + SUM(CASE WHEN status IN ('Pending', 'Queued', 'Running') THEN 1 ELSE 0 END)::TEXT + FROM apalis.jobs + GROUP BY job_type + + UNION ALL + + SELECT + job_type, 2, 'Number', 'STALE_RUNNING_JOBS', + COUNT(*)::TEXT + FROM apalis.jobs + WHERE status = 'Running' AND run_at < now() - INTERVAL '1 hour' + GROUP BY job_type + + UNION ALL + + SELECT + job_type, 2, 'Percentage', 'KILL_RATE', + ROUND(100.0 * SUM(CASE WHEN status = 'Killed' THEN 1 ELSE 0 END) / NULLIF(COUNT(*), 0), 2)::TEXT + FROM apalis.jobs + GROUP BY job_type + + UNION ALL + + -- Priority 3: Recent Activity + SELECT + job_type, 3, 'Number', 'JOBS_PAST_HOUR', + COUNT(*)::TEXT + FROM apalis.jobs + WHERE run_at >= now() - INTERVAL '1 hour' + GROUP BY job_type + + UNION ALL + + SELECT + job_type, 3, 'Number', 'JOBS_TODAY', + COUNT(*)::TEXT + FROM apalis.jobs + WHERE run_at::date = CURRENT_DATE + GROUP BY job_type + + UNION ALL + + SELECT + job_type, 3, 'Number', 'KILLED_JOBS_TODAY', + SUM(CASE WHEN status = 'Killed' THEN 1 ELSE 0 END)::TEXT + FROM apalis.jobs + WHERE run_at::date = CURRENT_DATE + GROUP BY job_type + + UNION ALL + + SELECT + job_type, 3, 'Decimal', 'AVG_JOBS_PER_MINUTE_PAST_HOUR', + ROUND(COUNT(*) / 60.0, 2)::TEXT + FROM apalis.jobs + WHERE run_at >= now() - INTERVAL '1 hour' + GROUP BY job_type + + UNION ALL + + -- Priority 4: Overall Stats + SELECT + job_type, 4, 'Number', 'TOTAL_JOBS', + COUNT(*)::TEXT + FROM apalis.jobs + GROUP BY job_type + + UNION ALL + + SELECT + job_type, 4, 'Number', 'DONE_JOBS', + SUM(CASE WHEN status = 'Done' THEN 1 ELSE 0 END)::TEXT + FROM apalis.jobs + GROUP BY job_type + + UNION ALL + + SELECT + job_type, 4, 'Number', 'KILLED_JOBS', + SUM(CASE WHEN status = 'Killed' THEN 1 ELSE 0 END)::TEXT + FROM apalis.jobs + GROUP BY job_type + + UNION ALL + + SELECT + job_type, 4, 'Percentage', 'SUCCESS_RATE', + ROUND(100.0 * SUM(CASE WHEN status = 'Done' THEN 1 ELSE 0 END) / NULLIF(COUNT(*), 0), 2)::TEXT + FROM apalis.jobs + GROUP BY job_type + + UNION ALL + + -- Priority 5: Performance + SELECT + job_type, 5, 'Decimal', 'AVG_JOB_DURATION_MINS', + ROUND(AVG(EXTRACT(EPOCH FROM (done_at - run_at)) / 60.0), 2)::TEXT + FROM apalis.jobs + WHERE status IN ('Done', 'Failed', 'Killed') AND done_at IS NOT NULL + GROUP BY job_type + + UNION ALL + + SELECT + job_type, 5, 'Decimal', 'LONGEST_RUNNING_JOB_MINS', + ROUND(MAX(CASE WHEN status = 'Running' THEN EXTRACT(EPOCH FROM now() - run_at) / 60.0 ELSE 0 END), 2)::TEXT + FROM apalis.jobs + GROUP BY job_type + + UNION ALL + + -- Priority 6: Historical + SELECT + job_type, 6, 'Number', 'JOBS_PAST_7_DAYS', + COUNT(*)::TEXT + FROM apalis.jobs + WHERE run_at >= now() - INTERVAL '7 days' + GROUP BY job_type + + UNION ALL + + -- Priority 8: Timestamps + SELECT + job_type, 8, 'Timestamp', 'MOST_RECENT_JOB', + MAX(run_at)::TEXT + FROM apalis.jobs + GROUP BY job_type + ) subquery + GROUP BY job_type +), +all_job_types AS ( + SELECT worker_type AS job_type + FROM apalis.workers + UNION + SELECT DISTINCT job_type + FROM apalis.jobs +) +SELECT + jt.job_type as name, + COALESCE(qs.stats, '[]'::jsonb) as stats, + COALESCE( + ( + SELECT jsonb_agg(DISTINCT lock_by) + FROM apalis.jobs + WHERE job_type = jt.job_type AND lock_by IS NOT NULL + ), + '[]'::jsonb + ) as workers, + COALESCE( + ( + SELECT jsonb_agg(daily_count ORDER BY run_date) + FROM ( + SELECT + COUNT(*) as daily_count, + run_at::date AS run_date + FROM apalis.jobs + WHERE job_type = jt.job_type + AND run_at >= now() - INTERVAL '7 days' + GROUP BY run_at::date + ORDER BY run_date + ) t + ), + '[]'::jsonb + ) as activity +FROM all_job_types jt +LEFT JOIN queue_stats qs ON jt.job_type = qs.job_type +ORDER BY name; diff --git a/queries/backend/list_workers.sql b/queries/backend/list_workers.sql new file mode 100644 index 0000000..4a1fb68 --- /dev/null +++ b/queries/backend/list_workers.sql @@ -0,0 +1,10 @@ +SELECT + * +FROM + apalis.workers +WHERE + worker_type = $1 +ORDER BY + last_seen DESC +LIMIT + $2 OFFSET $3 diff --git a/queries/backend/overview.sql b/queries/backend/overview.sql new file mode 100644 index 0000000..af731ff --- /dev/null +++ b/queries/backend/overview.sql @@ -0,0 +1,229 @@ +SELECT + 1 AS priority, + 'Number' AS type, + 'RUNNING_JOBS' AS statistic, + SUM(CASE WHEN status = 'Running' THEN 1 ELSE 0 END)::REAL AS value +FROM apalis.jobs + +UNION ALL + +SELECT + 1, 'Number', 'PENDING_JOBS', + SUM(CASE WHEN status = 'Pending' THEN 1 ELSE 0 END)::REAL +FROM apalis.jobs + +UNION ALL + +SELECT + 2, 'Number', 'FAILED_JOBS', + SUM(CASE WHEN status = 'Failed' THEN 1 ELSE 0 END)::REAL +FROM apalis.jobs + +UNION ALL + +SELECT + 2, 'Number', 'ACTIVE_JOBS', + SUM(CASE WHEN status IN ('Pending', 'Running', 'Queued') THEN 1 ELSE 0 END)::REAL +FROM apalis.jobs + +UNION ALL + +SELECT + 2, 'Number', 'STALE_RUNNING_JOBS', + COUNT(*)::REAL +FROM apalis.jobs +WHERE status = 'Running' + AND run_at < now() - INTERVAL '1 hour' + +UNION ALL + +SELECT + 2, 'Percentage', 'KILL_RATE', + ROUND(100.0 * SUM(CASE WHEN status = 'Killed' THEN 1 ELSE 0 END) / NULLIF(COUNT(*), 0), 2)::REAL +FROM apalis.jobs + +UNION ALL + +SELECT + 3, 'Number', 'JOBS_PAST_HOUR', + COUNT(*)::REAL +FROM apalis.jobs +WHERE run_at >= now() - INTERVAL '1 hour' + +UNION ALL + +SELECT + 3, 'Number', 'JOBS_TODAY', + COUNT(*)::REAL +FROM apalis.jobs +WHERE run_at::date = CURRENT_DATE + +UNION ALL + +SELECT + 3, 'Number', 'KILLED_JOBS_TODAY', + SUM(CASE WHEN status = 'Killed' THEN 1 ELSE 0 END)::REAL +FROM apalis.jobs +WHERE run_at::date = CURRENT_DATE + +UNION ALL + +SELECT + 3, 'Decimal', 'AVG_JOBS_PER_MINUTE_PAST_HOUR', + ROUND(COUNT(*) / 60.0, 2)::REAL +FROM apalis.jobs +WHERE run_at >= now() - INTERVAL '1 hour' + +UNION ALL + +SELECT + 4, 'Number', 'TOTAL_JOBS', + COUNT(*)::REAL +FROM apalis.jobs + +UNION ALL + +SELECT + 4, 'Number', 'DONE_JOBS', + SUM(CASE WHEN status = 'Done' THEN 1 ELSE 0 END)::REAL +FROM apalis.jobs + +UNION ALL + +SELECT + 4, 'Number', 'COMPLETED_JOBS', + SUM(CASE WHEN status IN ('Done', 'Failed', 'Killed') THEN 1 ELSE 0 END)::REAL +FROM apalis.jobs + +UNION ALL + +SELECT + 4, 'Number', 'KILLED_JOBS', + SUM(CASE WHEN status = 'Killed' THEN 1 ELSE 0 END)::REAL +FROM apalis.jobs + +UNION ALL + +SELECT + 4, 'Percentage', 'SUCCESS_RATE', + ROUND(100.0 * SUM(CASE WHEN status = 'Done' THEN 1 ELSE 0 END) / NULLIF(COUNT(*), 0), 2)::REAL +FROM apalis.jobs + +UNION ALL + +SELECT + 5, 'Decimal', 'AVG_JOB_DURATION_MINS', + ROUND(AVG(EXTRACT(EPOCH FROM (done_at - run_at)) / 60.0), 2)::REAL +FROM apalis.jobs +WHERE status IN ('Done', 'Failed', 'Killed') + AND done_at IS NOT NULL + +UNION ALL + +SELECT + 5, 'Decimal', 'LONGEST_RUNNING_JOB_MINS', + ROUND(MAX(CASE WHEN status = 'Running' THEN EXTRACT(EPOCH FROM (now() - run_at)) / 60.0 ELSE 0 END), 2)::REAL +FROM apalis.jobs + +UNION ALL + +SELECT + 5, 'Number', 'QUEUE_BACKLOG', + SUM(CASE WHEN status = 'Pending' AND run_at <= now() THEN 1 ELSE 0 END)::REAL +FROM apalis.jobs + +UNION ALL + +SELECT + 6, 'Number', 'JOBS_PAST_24_HOURS', + COUNT(*)::REAL +FROM apalis.jobs +WHERE run_at >= now() - INTERVAL '1 day' + +UNION ALL + +SELECT + 6, 'Number', 'JOBS_PAST_7_DAYS', + COUNT(*)::REAL +FROM apalis.jobs +WHERE run_at >= now() - INTERVAL '7 days' + +UNION ALL + +SELECT + 6, 'Number', 'KILLED_JOBS_PAST_7_DAYS', + SUM(CASE WHEN status = 'Killed' THEN 1 ELSE 0 END)::REAL +FROM apalis.jobs +WHERE run_at >= now() - INTERVAL '7 days' + +UNION ALL + +SELECT + 6, 'Percentage', 'SUCCESS_RATE_PAST_24H', + ROUND(100.0 * SUM(CASE WHEN status = 'Done' THEN 1 ELSE 0 END) / NULLIF(COUNT(*), 0), 2)::REAL +FROM apalis.jobs +WHERE run_at >= now() - INTERVAL '1 day' + +UNION ALL + +SELECT + 7, 'Decimal', 'AVG_JOBS_PER_HOUR_PAST_24H', + ROUND(COUNT(*) / 24.0, 2)::REAL +FROM apalis.jobs +WHERE run_at >= now() - INTERVAL '1 day' + +UNION ALL + +SELECT + 7, 'Decimal', 'AVG_JOBS_PER_DAY_PAST_7D', + ROUND(COUNT(*) / 7.0, 2)::REAL +FROM apalis.jobs +WHERE run_at >= now() - INTERVAL '7 days' + +UNION ALL + +SELECT + 8, 'Timestamp', 'MOST_RECENT_JOB', + EXTRACT(EPOCH FROM MAX(run_at))::REAL +FROM apalis.jobs + +UNION ALL + +SELECT + 8, 'Timestamp', 'OLDEST_PENDING_JOB', + EXTRACT(EPOCH FROM MIN(run_at))::REAL +FROM apalis.jobs +WHERE status = 'Pending' + AND run_at <= now() + +UNION ALL + +SELECT + 8, 'Number', 'PEAK_HOUR_JOBS', + MAX(hourly_count)::REAL +FROM ( + SELECT COUNT(*) as hourly_count + FROM apalis.jobs + WHERE run_at >= now() - INTERVAL '1 day' + GROUP BY EXTRACT(HOUR FROM run_at) +) subquery + +UNION ALL + +SELECT + 9, 'Number', 'DB_PAGE_SIZE', + current_setting('block_size')::INTEGER::REAL + +UNION ALL + +SELECT + 9, 'Number', 'DB_PAGE_COUNT', + (pg_total_relation_size('apalis.jobs') / current_setting('block_size')::INTEGER)::REAL + +UNION ALL + +SELECT + 9, 'Number', 'DB_SIZE', + pg_total_relation_size('apalis.jobs')::REAL + +ORDER BY priority, statistic; diff --git a/queries/backend/overview_by_queue.sql b/queries/backend/overview_by_queue.sql new file mode 100644 index 0000000..317352c --- /dev/null +++ b/queries/backend/overview_by_queue.sql @@ -0,0 +1,238 @@ +SELECT + 1 AS priority, + 'Number' AS type, + 'RUNNING_JOBS' AS statistic, + SUM(CASE WHEN status = 'Running' THEN 1 ELSE 0 END)::REAL AS value +FROM apalis.jobs +WHERE job_type = $1 + +UNION ALL + +SELECT + 1, 'Number', 'PENDING_JOBS', + SUM(CASE WHEN status = 'Pending' THEN 1 ELSE 0 END)::REAL +FROM apalis.jobs +WHERE job_type = $1 + +UNION ALL + +SELECT + 2, 'Number', 'FAILED_JOBS', + SUM(CASE WHEN status = 'Failed' THEN 1 ELSE 0 END)::REAL +FROM apalis.jobs +WHERE job_type = $1 + +UNION ALL + +SELECT + 2, 'Number', 'ACTIVE_JOBS', + SUM(CASE WHEN status IN ('Pending', 'Queued', 'Running') THEN 1 ELSE 0 END)::REAL +FROM apalis.jobs +WHERE job_type = $1 + +UNION ALL + +SELECT + 2, 'Number', 'STALE_RUNNING_JOBS', + COUNT(*)::REAL +FROM apalis.jobs +WHERE job_type = $1 + AND status = 'Running' + AND run_at < now() - INTERVAL '1 hour' + +UNION ALL + +SELECT + 2, 'Percentage', 'KILL_RATE', + ROUND(100.0 * SUM(CASE WHEN status = 'Killed' THEN 1 ELSE 0 END) / NULLIF(COUNT(*), 0), 2)::REAL +FROM apalis.jobs +WHERE job_type = $1 + +UNION ALL + +SELECT + 3, 'Number', 'JOBS_PAST_HOUR', + COUNT(*)::REAL +FROM apalis.jobs +WHERE job_type = $1 + AND run_at >= now() - INTERVAL '1 hour' + +UNION ALL + +SELECT + 3, 'Number', 'JOBS_TODAY', + COUNT(*)::REAL +FROM apalis.jobs +WHERE job_type = $1 + AND run_at::date = CURRENT_DATE + +UNION ALL + +SELECT + 3, 'Number', 'KILLED_JOBS_TODAY', + SUM(CASE WHEN status = 'Killed' THEN 1 ELSE 0 END)::REAL +FROM apalis.jobs +WHERE job_type = $1 + AND run_at::date = CURRENT_DATE + +UNION ALL + +SELECT + 3, 'Decimal', 'AVG_JOBS_PER_MINUTE_PAST_HOUR', + ROUND(COUNT(*) / 60.0, 2)::REAL +FROM apalis.jobs +WHERE job_type = $1 + AND run_at >= now() - INTERVAL '1 hour' + +UNION ALL + +SELECT + 4, 'Number', 'TOTAL_JOBS', + COUNT(*)::REAL +FROM apalis.jobs +WHERE job_type = $1 + +UNION ALL + +SELECT + 4, 'Number', 'DONE_JOBS', + SUM(CASE WHEN status = 'Done' THEN 1 ELSE 0 END)::REAL +FROM apalis.jobs +WHERE job_type = $1 + +UNION ALL + +SELECT + 4, 'Number', 'COMPLETED_JOBS', + SUM(CASE WHEN status IN ('Done', 'Failed', 'Killed') THEN 1 ELSE 0 END)::REAL +FROM apalis.jobs +WHERE job_type = $1 + +UNION ALL + +SELECT + 4, 'Number', 'KILLED_JOBS', + SUM(CASE WHEN status = 'Killed' THEN 1 ELSE 0 END)::REAL +FROM apalis.jobs +WHERE job_type = $1 + +UNION ALL + +SELECT + 4, 'Percentage', 'SUCCESS_RATE', + ROUND(100.0 * SUM(CASE WHEN status = 'Done' THEN 1 ELSE 0 END) / NULLIF(COUNT(*), 0), 2)::REAL +FROM apalis.jobs +WHERE job_type = $1 + +UNION ALL + +SELECT + 5, 'Decimal', 'AVG_JOB_DURATION_MINS', + ROUND(AVG(EXTRACT(EPOCH FROM (done_at - run_at)) / 60.0), 2)::REAL +FROM apalis.jobs +WHERE job_type = $1 + AND status IN ('Done', 'Failed', 'Killed') + AND done_at IS NOT NULL + +UNION ALL + +SELECT + 5, 'Decimal', 'LONGEST_RUNNING_JOB_MINS', + ROUND(MAX(CASE WHEN status = 'Running' THEN EXTRACT(EPOCH FROM (now() - run_at)) / 60.0 ELSE 0 END), 2)::REAL +FROM apalis.jobs +WHERE job_type = $1 + +UNION ALL + +SELECT + 5, 'Number', 'QUEUE_BACKLOG', + SUM(CASE WHEN status = 'Pending' AND run_at <= now() THEN 1 ELSE 0 END)::REAL +FROM apalis.jobs +WHERE job_type = $1 + +UNION ALL + +SELECT + 6, 'Number', 'JOBS_PAST_24_HOURS', + COUNT(*)::REAL +FROM apalis.jobs +WHERE job_type = $1 + AND run_at >= now() - INTERVAL '1 day' + +UNION ALL + +SELECT + 6, 'Number', 'JOBS_PAST_7_DAYS', + COUNT(*)::REAL +FROM apalis.jobs +WHERE job_type = $1 + AND run_at >= now() - INTERVAL '7 days' + +UNION ALL + +SELECT + 6, 'Number', 'KILLED_JOBS_PAST_7_DAYS', + SUM(CASE WHEN status = 'Killed' THEN 1 ELSE 0 END)::REAL +FROM apalis.jobs +WHERE job_type = $1 + AND run_at >= now() - INTERVAL '7 days' + +UNION ALL + +SELECT + 6, 'Percentage', 'SUCCESS_RATE_PAST_24H', + ROUND(100.0 * SUM(CASE WHEN status = 'Done' THEN 1 ELSE 0 END) / NULLIF(COUNT(*), 0), 2)::REAL +FROM apalis.jobs +WHERE job_type = $1 + AND run_at >= now() - INTERVAL '1 day' + +UNION ALL + +SELECT + 7, 'Decimal', 'AVG_JOBS_PER_HOUR_PAST_24H', + ROUND(COUNT(*) / 24.0, 2)::REAL +FROM apalis.jobs +WHERE job_type = $1 + AND run_at >= now() - INTERVAL '1 day' + +UNION ALL + +SELECT + 7, 'Decimal', 'AVG_JOBS_PER_DAY_PAST_7D', + ROUND(COUNT(*) / 7.0, 2)::REAL +FROM apalis.jobs +WHERE job_type = $1 + AND run_at >= now() - INTERVAL '7 days' + +UNION ALL + +SELECT + 8, 'Timestamp', 'MOST_RECENT_JOB', + EXTRACT(EPOCH FROM MAX(run_at))::REAL +FROM apalis.jobs +WHERE job_type = $1 + +UNION ALL + +SELECT + 8, 'Timestamp', 'OLDEST_PENDING_JOB', + EXTRACT(EPOCH FROM MIN(run_at))::REAL +FROM apalis.jobs +WHERE job_type = $1 + AND status = 'Pending' + AND run_at <= now() + +UNION ALL + +SELECT + 8, 'Number', 'PEAK_HOUR_JOBS', + MAX(hourly_count)::REAL +FROM ( + SELECT COUNT(*) as hourly_count + FROM apalis.jobs + WHERE job_type = $1 + AND run_at >= now() - INTERVAL '1 day' + GROUP BY EXTRACT(HOUR FROM run_at) +) subquery + +ORDER BY priority, statistic; diff --git a/queries/backend/queue_length.sql b/queries/backend/queue_length.sql new file mode 100644 index 0000000..0dd2c31 --- /dev/null +++ b/queries/backend/queue_length.sql @@ -0,0 +1,12 @@ +Select + COUNT(*) AS count +FROM + Jobs +WHERE + ( + status = 'Pending' + OR ( + status = 'Failed' + AND attempts < max_attempts + ) + ) diff --git a/queries/backend/reenqueue_orphaned.sql b/queries/backend/reenqueue_orphaned.sql new file mode 100644 index 0000000..f00c6a1 --- /dev/null +++ b/queries/backend/reenqueue_orphaned.sql @@ -0,0 +1,24 @@ +UPDATE + apalis.jobs +SET + status = 'Pending', + done_at = NULL, + lock_by = NULL, + lock_at = NULL, + attempts = attempts + 1, + last_result = '{"Err": "Re-enqueued due to worker heartbeat timeout."}' +WHERE + id IN ( + SELECT + jobs.id + FROM + apalis.jobs + INNER JOIN apalis.workers ON lock_by = workers.id + WHERE + ( + status = 'Running' + OR status = 'Queued' + ) + AND NOW() - apalis.workers.last_seen >= $1 + AND apalis.workers.worker_type = $2 + ); diff --git a/queries/backend/stats.sql b/queries/backend/stats.sql new file mode 100644 index 0000000..5ee7796 --- /dev/null +++ b/queries/backend/stats.sql @@ -0,0 +1,11 @@ +SELECT + CAST(COUNT(*) AS INTEGER) AS total, + CAST(SUM(CASE WHEN status = 'Pending' THEN 1 ELSE 0 END) AS INTEGER) AS pending, + CAST(SUM(CASE WHEN status = 'Running' THEN 1 ELSE 0 END) AS INTEGER) AS running, + CAST(SUM(CASE WHEN status = 'Done' THEN 1 ELSE 0 END) AS INTEGER) AS done, + CAST(SUM(CASE WHEN status = 'Failed' THEN 1 ELSE 0 END) AS INTEGER) AS failed, + CAST(SUM(CASE WHEN status = 'Killed' THEN 1 ELSE 0 END) AS INTEGER) AS killed, + CAST(SUM(CASE WHEN status IN ('Done', 'Failed', 'Killed') THEN 1 ELSE 0 END) AS INTEGER) AS completed, + CAST(SUM(CASE WHEN status IN ('Pending', 'Running') THEN 1 ELSE 0 END) AS INTEGER) AS active +FROM Jobs +WHERE job_type = ?1 diff --git a/queries/backend/vacuum.sql b/queries/backend/vacuum.sql new file mode 100644 index 0000000..94050db --- /dev/null +++ b/queries/backend/vacuum.sql @@ -0,0 +1,11 @@ +Delete from + Jobs +where + status = 'Done' + OR status = 'Killed' + OR ( + status = 'Failed' + AND max_attempts <= attempts + ); + +VACUUM; diff --git a/queries/task/ack.sql b/queries/task/ack.sql new file mode 100644 index 0000000..869e6ba --- /dev/null +++ b/queries/task/ack.sql @@ -0,0 +1,10 @@ +UPDATE + apalis.jobs +SET + status = $4, + attempts = $2, + last_result = $3, + done_at = NOW() +WHERE + id = $1 + AND lock_by = $5 diff --git a/queries/task/fetch_next.sql b/queries/task/fetch_next.sql new file mode 100644 index 0000000..84754e9 --- /dev/null +++ b/queries/task/fetch_next.sql @@ -0,0 +1,4 @@ +SELECT + * +FROM + apalis.get_jobs($1, $2, $3) diff --git a/queries/task/find_by_id.sql b/queries/task/find_by_id.sql new file mode 100644 index 0000000..fb94222 --- /dev/null +++ b/queries/task/find_by_id.sql @@ -0,0 +1,8 @@ +SELECT + * +FROM + apalis.jobs +WHERE + id = $1 +LIMIT + 1; diff --git a/queries/task/lock_by_id.sql b/queries/task/lock_by_id.sql new file mode 100644 index 0000000..a62350f --- /dev/null +++ b/queries/task/lock_by_id.sql @@ -0,0 +1,10 @@ +UPDATE apalis.jobs +SET + status = 'Running', + lock_at = now(), + lock_by = $2 +WHERE + status = 'Queued' + AND run_at < now() + AND id = ANY($1) +RETURNING *; diff --git a/queries/task/sink.sql b/queries/task/sink.sql new file mode 100644 index 0000000..e992e87 --- /dev/null +++ b/queries/task/sink.sql @@ -0,0 +1,22 @@ +INSERT INTO + apalis.jobs ( + id, + job_type, + job, + status, + attempts, + max_attempts, + run_at, + priority, + metadata + ) +SELECT + unnest($1::text[]) as id, + $2::text as job_type, + unnest($3::bytea[]) as job, + 'Pending' as status, + 0 as attempts, + unnest($4::integer []) as max_attempts, + unnest($5::timestamptz []) as run_at, + unnest($6::integer []) as priority, + unnest($7::jsonb []) as metadata diff --git a/queries/worker/register.sql b/queries/worker/register.sql new file mode 100644 index 0000000..22ab4e7 --- /dev/null +++ b/queries/worker/register.sql @@ -0,0 +1,12 @@ +INSERT INTO + apalis.workers (id, worker_type, storage_name, layers, last_seen) +VALUES + ($1, $2, $3, $4, $5) ON CONFLICT (id) DO +UPDATE +SET + worker_type = EXCLUDED.worker_type, + storage_name = EXCLUDED.storage_name, + layers = EXCLUDED.layers, + last_seen = NOW() +WHERE + pg_try_advisory_lock(hashtext(workers.id)); diff --git a/src/ack.rs b/src/ack.rs index 084df0e..5c1fa10 100644 --- a/src/ack.rs +++ b/src/ack.rs @@ -1,15 +1,15 @@ use apalis_core::{ error::BoxDynError, + layers::{Layer, Service}, task::{Parts, status::Status}, - worker::ext::ack::Acknowledge, + worker::{context::WorkerContext, ext::ack::Acknowledge}, }; -use chrono::Utc; use futures::{FutureExt, future::BoxFuture}; use serde::Serialize; use sqlx::PgPool; use ulid::Ulid; -use crate::context::PgContext; +use crate::{PgTask, context::PgContext}; #[derive(Clone)] pub struct PgAck { @@ -29,17 +29,16 @@ impl Acknowledge for PgAck { res: &Result, parts: &Parts, ) -> Self::Future { - let task_id = parts.task_id.clone(); + let task_id = parts.task_id; let worker_id = parts.ctx.lock_by().clone(); - let response = serde_json::to_string(&res.as_ref().map_err(|e| e.to_string())); + let response = serde_json::to_value(res.as_ref().map_err(|e| e.to_string())); let status = calculate_status(parts, res); let attempt = parts.attempt.current() as i32; - let now = Utc::now(); let pool = self.pool.clone(); async move { let res = sqlx::query_file!( - "src/queries/task/ack.sql", + "queries/task/ack.sql", task_id .ok_or(sqlx::Error::ColumnNotFound("TASK_ID_FOR_ACK".to_owned()))? .to_string(), @@ -68,8 +67,87 @@ pub fn calculate_status( Ok(_) => Status::Done, Err(e) => match &e { // Error::Abort(_) => State::Killed, - e if parts.ctx.max_attempts() as usize <= parts.attempt.current() => Status::Killed, + _ if parts.ctx.max_attempts() as usize <= parts.attempt.current() => Status::Killed, _ => Status::Failed, }, } } + +pub async fn lock_task(pool: &PgPool, task_id: &Ulid, worker_id: &str) -> Result<(), sqlx::Error> { + let task_id = vec![task_id.to_string()]; + sqlx::query_file!("queries/task/lock_by_id.sql", &task_id, &worker_id,) + .fetch_one(pool) + .await?; + Ok(()) +} + +pub struct LockTaskLayer { + pool: PgPool, +} + +impl LockTaskLayer { + pub fn new(pool: PgPool) -> Self { + Self { pool } + } +} + +impl Layer for LockTaskLayer { + type Service = LockTaskService; + + fn layer(&self, inner: S) -> Self::Service { + LockTaskService { + inner, + pool: self.pool.clone(), + } + } +} + +pub struct LockTaskService { + inner: S, + pool: PgPool, +} + +impl Service> for LockTaskService +where + S: Service> + Send + 'static, + S::Future: Send + 'static, + S::Error: Into, + Args: Send + 'static, +{ + type Response = S::Response; + type Error = BoxDynError; + type Future = BoxFuture<'static, Result>; + + fn poll_ready( + &mut self, + cx: &mut std::task::Context<'_>, + ) -> std::task::Poll> { + self.inner.poll_ready(cx).map_err(|e| e.into()) + } + + fn call(&mut self, req: PgTask) -> Self::Future { + let pool = self.pool.clone(); + let worker_id = req + .parts + .data + .get::() + .map(|w| w.name().to_owned()) + .unwrap(); + let parts = &req.parts; + let task_id = match &parts.task_id { + Some(id) => *id.inner(), + None => { + return async { + Err(sqlx::Error::ColumnNotFound("TASK_ID_FOR_LOCK".to_owned()).into()) + } + .boxed(); + } + }; + let fut = self.inner.call(req); + async move { + lock_task(&pool, &task_id, &worker_id).await.unwrap(); + fut.await.map_err(|e| e.into()) + } + .boxed() + } +} diff --git a/src/config.rs b/src/config.rs index c3b53f7..2e5ed99 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1,128 +1,17 @@ -use std::time::Duration; +use apalis_core::backend::{Backend, ConfigExt, queue::Queue}; +use apalis_sql::context::SqlContext; +use ulid::Ulid; -#[derive(Debug, Clone)] -pub struct Config { - keep_alive: Duration, - buffer_size: usize, - poll_interval: Duration, - reenqueue_orphaned_after: Duration, - namespace: String, - ack: bool, - max_attempts: i32, -} - -impl Default for Config { - fn default() -> Self { - Self { - keep_alive: Duration::from_secs(30), - buffer_size: 10, - poll_interval: Duration::from_millis(100), - reenqueue_orphaned_after: Duration::from_secs(300), // 5 minutes - namespace: String::from("apalis::postgres"), - ack: true, - max_attempts: 25, - } - } -} - -impl Config { - /// Create a new config with a jobs namespace - pub fn new(namespace: &str) -> Self { - Config::default().set_namespace(namespace) - } - - /// Interval between database poll queries - /// - /// Defaults to 100ms - pub fn set_poll_interval(mut self, interval: Duration) -> Self { - self.poll_interval = interval; - self - } - - /// Interval between worker keep-alive database updates - /// - /// Defaults to 30s - pub fn set_keep_alive(mut self, keep_alive: Duration) -> Self { - self.keep_alive = keep_alive; - self - } - - /// Buffer size to use when querying for jobs - /// - /// Defaults to 10 - pub fn set_buffer_size(mut self, buffer_size: usize) -> Self { - self.buffer_size = buffer_size; - self - } - - /// Set the namespace to consume and push jobs to - /// - /// Defaults to "apalis::sql" - pub fn set_namespace(mut self, namespace: &str) -> Self { - self.namespace = namespace.to_string(); - self - } - - /// Gets a reference to the keep_alive duration. - pub fn keep_alive(&self) -> &Duration { - &self.keep_alive - } - - /// Gets a mutable reference to the keep_alive duration. - pub fn keep_alive_mut(&mut self) -> &mut Duration { - &mut self.keep_alive - } +pub use apalis_sql::config::*; - /// Gets the buffer size. - pub fn buffer_size(&self) -> usize { - self.buffer_size - } - - /// Gets a reference to the poll_interval duration. - pub fn poll_interval(&self) -> &Duration { - &self.poll_interval - } - - /// Gets a mutable reference to the poll_interval duration. - pub fn poll_interval_mut(&mut self) -> &mut Duration { - &mut self.poll_interval - } - - /// Gets a reference to the namespace. - pub fn namespace(&self) -> &String { - &self.namespace - } - - /// Gets a mutable reference to the namespace. - pub fn namespace_mut(&mut self) -> &mut String { - &mut self.namespace - } - - /// Gets the reenqueue_orphaned_after duration. - pub fn reenqueue_orphaned_after(&self) -> Duration { - self.reenqueue_orphaned_after - } - - /// Gets a mutable reference to the reenqueue_orphaned_after. - pub fn reenqueue_orphaned_after_mut(&mut self) -> &mut Duration { - &mut self.reenqueue_orphaned_after - } - - /// Occasionally some workers die, or abandon jobs because of panics. - /// This is the time a task takes before its back to the queue - /// - /// Defaults to 5 minutes - pub fn set_reenqueue_orphaned_after(mut self, after: Duration) -> Self { - self.reenqueue_orphaned_after = after; - self - } - - pub fn ack(&self) -> bool { - self.ack - } +use crate::{CompactType, PostgresStorage}; - pub fn set_ack(mut self, auto_ack: bool) -> Self { - self.ack = auto_ack; - self +impl ConfigExt for PostgresStorage +where + PostgresStorage: + Backend, +{ + fn get_queue(&self) -> Queue { + self.config.queue().clone() } } diff --git a/src/fetcher.rs b/src/fetcher.rs index eaa2801..09130e2 100644 --- a/src/fetcher.rs +++ b/src/fetcher.rs @@ -2,7 +2,6 @@ use std::{ collections::VecDeque, marker::PhantomData, pin::Pin, - sync::Arc, task::{Context, Poll}, time::{Duration, Instant}, }; @@ -13,19 +12,16 @@ use apalis_core::{ timer::Delay, worker::context::WorkerContext, }; -use futures::{ - Future, FutureExt, StreamExt, - future::BoxFuture, - stream::{self, Stream}, -}; +use apalis_sql::from_row::TaskRow; +use futures::{Future, FutureExt, future::BoxFuture, stream::Stream}; use pin_project::pin_project; -use serde_json::Value; + use sqlx::{PgPool, Pool, Postgres}; use ulid::Ulid; -use crate::{config::Config, context::PgContext, from_row::TaskRow, PgTask}; +use crate::{CompactType, PgTask, config::Config, context::PgContext, from_row::PgTaskRow}; -async fn fetch_next>( +async fn fetch_next>( pool: PgPool, config: Config, worker: WorkerContext, @@ -33,13 +29,12 @@ async fn fetch_next>( where D::Error: std::error::Error + Send + Sync + 'static, { - use futures::TryFutureExt; - let job_type = config.namespace(); + let job_type = config.queue().to_string(); let buffer_size = config.buffer_size() as i32; sqlx::query_file_as!( - TaskRow, - "src/queries/task/fetch_next.sql", + PgTaskRow, + "queries/task/fetch_next.sql", worker.name(), job_type, buffer_size @@ -47,7 +42,11 @@ where .fetch_all(&pool) .await? .into_iter() - .map(|r| r.try_into_task::()) + .map(|r| { + let row: TaskRow = r.try_into()?; + row.try_into_task::() + .map_err(|e| sqlx::Error::Protocol(e.to_string())) + }) .collect() } @@ -56,11 +55,16 @@ enum StreamState { Delay(Delay), Fetch(BoxFuture<'static, Result>, sqlx::Error>>), Buffered(VecDeque>), - Empty, +} + +/// Dispatcher for fetching tasks from a PostgreSQL backend via [PgPollFetcher] +#[derive(Clone, Debug)] +pub struct PgFetcher { + pub _marker: PhantomData<(Args, Compact, Decode)>, } #[pin_project] -pub struct PgFetcher> { +pub struct PgPollFetcher> { pool: PgPool, config: Config, wrk: WorkerContext, @@ -71,7 +75,7 @@ pub struct PgFetcher> { last_fetch_time: Option, } -impl Clone for PgFetcher { +impl Clone for PgPollFetcher { fn clone(&self) -> Self { Self { pool: self.pool.clone(), @@ -85,10 +89,10 @@ impl Clone for PgFetcher { } } -impl PgFetcher { +impl PgPollFetcher { pub fn new(pool: &Pool, config: &Config, wrk: &WorkerContext) -> Self where - Decode: Codec + 'static, + Decode: Codec + 'static, Decode::Error: std::error::Error + Send + Sync + 'static, { let initial_backoff = Duration::from_secs(1); @@ -104,23 +108,26 @@ impl PgFetcher { } } -impl Stream for PgFetcher +impl Stream for PgPollFetcher where Decode::Error: std::error::Error + Send + Sync + 'static, Args: Send + 'static + Unpin, - Decode: Codec + 'static, + Decode: Codec + 'static, // Compact: Unpin + Send + 'static, { type Item = Result>, sqlx::Error>; fn poll_next(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { - let mut this = self.get_mut(); + let this = self.get_mut(); loop { match this.state { StreamState::Ready => { - let stream = - fetch_next::(this.pool.clone(), this.config.clone(), this.wrk.clone()); + let stream = fetch_next::( + this.pool.clone(), + this.config.clone(), + this.wrk.clone(), + ); this.state = StreamState::Fetch(stream.boxed()); } StreamState::Delay(ref mut delay) => match Pin::new(delay).poll(cx) { @@ -168,19 +175,18 @@ where this.state = StreamState::Ready; } } - - StreamState::Empty => return Poll::Ready(None), } } } } -impl PgFetcher { +impl PgPollFetcher { fn next_backoff(&self, current: Duration) -> Duration { let doubled = current * 2; std::cmp::min(doubled, Duration::from_secs(60 * 5)) } + #[allow(unused)] pub fn take_pending(&mut self) -> VecDeque> { match &mut self.state { StreamState::Buffered(tasks) => std::mem::take(tasks), diff --git a/src/lib.rs b/src/lib.rs index 4057772..94872c1 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,60 +1,300 @@ -use std::{ - backtrace::Backtrace, - collections::HashMap, - fmt::Debug, - future::Future, - marker::PhantomData, - panic, - pin::{self, Pin}, - str::FromStr, - sync::Arc, - task::{Context, Poll}, -}; +//! # apalis-postgres +//! +//! Background task processing in rust using `apalis` and `postgres` +//! +//! ## Features +//! +//! - **Reliable job queue** using Postgres as the backend. +//! - **Multiple storage types**: standard polling and `trigger` based storages. +//! - **Custom codecs** for serializing/deserializing job arguments as bytes. +//! - **Heartbeat and orphaned job re-enqueueing** for robust task processing. +//! - **Integration with `apalis` workers and middleware.** +//! +//! ## Storage Types +//! +//! - [`PostgresStorage`]: Standard polling-based storage. +//! - [`PostgresStorageWithListener`]: Event-driven storage using Postgres `NOTIFY` for low-latency job fetching. +//! - [`SharedPostgresStorage`]: Shared storage for multiple job types, uses Postgres `NOTIFY`. +//! +//! The naming is designed to clearly indicate the storage mechanism and its capabilities, but under the hood the result is the `PostgresStorage` struct with different configurations. +//! +//! ## Examples +//! +//! ### Basic Worker Example +//! +//! ```rust,no_run +//! # use std::time::Duration; +//! # use apalis_postgres::PostgresStorage; +//! # use apalis_core::worker::builder::WorkerBuilder; +//! # use apalis_core::worker::event::Event; +//! # use apalis_core::task::Task; +//! # use apalis_core::worker::context::WorkerContext; +//! # use apalis_core::error::BoxDynError; +//! # use sqlx::PgPool; +//! # use futures::stream; +//! # use apalis_sql::context::SqlContext; +//! # use futures::SinkExt; +//! # use apalis_sql::config::Config; +//! # use futures::StreamExt; +//! +//! #[tokio::main] +//! async fn main() { +//! let pool = PgPool::connect(env!("DATABASE_URL")).await.unwrap(); +//! PostgresStorage::setup(&pool).await.unwrap(); +//! let mut backend = PostgresStorage::new_with_config(&pool, &Config::new("int-queue")); +//! +//! let mut start = 0; +//! let mut items = stream::repeat_with(move || { +//! start += 1; +//! let task = Task::builder(serde_json::to_vec(&start).unwrap()) +//! .run_after(Duration::from_secs(1)) +//! .with_ctx(SqlContext::new().with_priority(1)) +//! .build(); +//! Ok(task) +//! }) +//! .take(10); +//! backend.send_all(&mut items).await.unwrap(); +//! +//! async fn send_reminder(item: usize, wrk: WorkerContext) -> Result<(), BoxDynError> { +//! Ok(()) +//! } +//! +//! let worker = WorkerBuilder::new("worker-1") +//! .backend(backend) +//! .build(send_reminder); +//! worker.run().await.unwrap(); +//! } +//! ``` +//! +//! ### `NOTIFY` listener example +//! +//! ```rust,no_run +//! # use std::time::Duration; +//! # use apalis_postgres::PostgresStorage; +//! # use apalis_core::worker::builder::WorkerBuilder; +//! # use apalis_core::worker::event::Event; +//! # use apalis_core::task::Task; +//! # use sqlx::PgPool; +//! # use apalis_core::backend::poll_strategy::StrategyBuilder; +//! # use apalis_core::backend::poll_strategy::IntervalStrategy; +//! # use apalis_sql::config::Config; +//! # use futures::stream; +//! # use apalis_sql::context::SqlContext; +//! # use apalis_core::error::BoxDynError; +//! # use futures::StreamExt; +//! +//! #[tokio::main] +//! async fn main() { +//! let pool = PgPool::connect(env!("DATABASE_URL")).await.unwrap(); +//! PostgresStorage::setup(&pool).await.unwrap(); +//! +//! let lazy_strategy = StrategyBuilder::new() +//! .apply(IntervalStrategy::new(Duration::from_secs(5))) +//! .build(); +//! let config = Config::new("queue") +//! .with_poll_interval(lazy_strategy) +//! .set_buffer_size(5); +//! let backend = PostgresStorage::new_with_notify(&pool, &config).await; +//! +//! tokio::spawn({ +//! let pool = pool.clone(); +//! let config = config.clone(); +//! async move { +//! tokio::time::sleep(Duration::from_secs(2)).await; +//! let mut start = 0; +//! let items = stream::repeat_with(move || { +//! start += 1; +//! Task::builder(serde_json::to_vec(&start).unwrap()) +//! .run_after(Duration::from_secs(1)) +//! .with_ctx(SqlContext::new().with_priority(start)) +//! .build() +//! }) +//! .take(20) +//! .collect::>() +//! .await; +//! apalis_postgres::sink::push_tasks(pool, config, items).await.unwrap(); +//! } +//! }); +//! +//! async fn send_reminder(task: usize) -> Result<(), BoxDynError> { +//! Ok(()) +//! } +//! +//! let worker = WorkerBuilder::new("worker-2") +//! .backend(backend) +//! .build(send_reminder); +//! worker.run().await.unwrap(); +//! } +//! ``` +//! +//! ### Workflow Example +//! +//! ```rust,no_run +//! # use apalis_workflow::WorkFlow; +//! # use apalis_workflow::WorkflowError; +//! # use std::time::Duration; +//! # use apalis_postgres::PostgresStorage; +//! # use apalis_core::worker::builder::WorkerBuilder; +//! # use apalis_core::worker::ext::event_listener::EventListenerExt; +//! # use apalis_core::worker::event::Event; +//! # use sqlx::PgPool; +//! # use apalis_sql::config::Config; +//! # use apalis_core::backend::WeakTaskSink; +//! +//! #[tokio::main] +//! async fn main() { +//! let workflow = WorkFlow::new("odd-numbers-workflow") +//! .then(|a: usize| async move { +//! Ok::<_, WorkflowError>((0..=a).collect::>()) +//! }) +//! .filter_map(|x| async move { +//! if x % 2 != 0 { Some(x) } else { None } +//! }) +//! .filter_map(|x| async move { +//! if x % 3 != 0 { Some(x) } else { None } +//! }) +//! .filter_map(|x| async move { +//! if x % 5 != 0 { Some(x) } else { None } +//! }) +//! .delay_for(Duration::from_millis(1000)) +//! .then(|a: Vec| async move { +//! println!("Sum: {}", a.iter().sum::()); +//! Ok::<(), WorkflowError>(()) +//! }); +//! +//! let pool = PgPool::connect(env!("DATABASE_URL")).await.unwrap(); +//! PostgresStorage::setup(&pool).await.unwrap(); +//! let mut backend = PostgresStorage::new_with_config(&pool, &Config::new("workflow-queue")); +//! +//! backend.push(100usize).await.unwrap(); +//! +//! let worker = WorkerBuilder::new("rango-tango") +//! .backend(backend) +//! .on_event(|ctx, ev| { +//! println!("On Event = {:?}", ev); +//! if matches!(ev, Event::Error(_)) { +//! ctx.stop().unwrap(); +//! } +//! }) +//! .build(workflow); +//! +//! worker.run().await.unwrap(); +//! } +//! ``` +//! +//! ## Observability +//! +//! You can track your jobs using [apalis-board](https://github.com/apalis-dev/apalis-board). +//! ![Task](https://github.com/apalis-dev/apalis-board/raw/master/screenshots/task.png) +//! +//! ## License +//! +//! Licensed under either of Apache License, Version 2.0 or MIT license at your option. +//! +//! [`PostgresStorageWithListener`]: crate::PostgresStorage +//! [`SharedPostgresStorage`]: crate::shared::SharedPostgresStorage +use std::{fmt::Debug, marker::PhantomData}; use apalis_core::{ backend::{ - Backend, TaskSink, TaskStream, + Backend, TaskStream, codec::{Codec, json::JsonCodec}, }, - error::{BoxDynError, WorkerError}, - layers::Identity, - task::{Parts, Task, attempt::Attempt, status::Status, task_id::TaskId}, - worker::{ - context::WorkerContext, - ext::ack::{Acknowledge, AcknowledgeLayer}, - }, + layers::Stack, + task::{Task, task_id::TaskId}, + worker::{context::WorkerContext, ext::ack::AcknowledgeLayer}, }; -use chrono::{DateTime, Utc}; +use apalis_sql::from_row::TaskRow; use futures::{ - FutureExt, Sink, SinkExt, Stream, StreamExt, TryFutureExt, - channel::mpsc::{Receiver, Sender}, - future::{BoxFuture, ready}, - lock::Mutex, + FutureExt, StreamExt, TryStreamExt, + future::ready, stream::{self, BoxStream, select}, }; -use serde::{Deserialize, Serialize, de::DeserializeOwned}; -use serde_json::{Map, Value, json}; +use serde::Deserialize; use sqlx::{PgPool, postgres::PgListener}; use ulid::Ulid; -use crate::{ack::PgAck, config::Config, context::PgContext, fetcher::PgFetcher, sink::PgSink}; +use crate::{ + ack::{LockTaskLayer, PgAck}, + config::Config, + context::PgContext, + fetcher::{PgFetcher, PgPollFetcher}, + queries::{ + keep_alive::{initial_heartbeat, keep_alive_stream}, + reenqueue_orphaned::reenqueue_orphaned_stream, + }, + sink::PgSink, +}; mod ack; mod config; -mod context; mod fetcher; -mod from_row; -mod shared; -mod sink; +mod from_row { + use chrono::{DateTime, Utc}; + #[derive(Debug)] + pub struct PgTaskRow { + pub job: Option>, + pub id: Option, + pub job_type: Option, + pub status: Option, + pub attempts: Option, + pub max_attempts: Option, + pub run_at: Option>, + pub last_result: Option, + pub lock_at: Option>, + pub lock_by: Option, + pub done_at: Option>, + pub priority: Option, + pub metadata: Option, + } + impl TryInto for PgTaskRow { + type Error = sqlx::Error; + + fn try_into(self) -> Result { + Ok(apalis_sql::from_row::TaskRow { + job: self.job.unwrap_or_default(), + id: self + .id + .ok_or_else(|| sqlx::Error::Protocol("Missing id".into()))?, + job_type: self + .job_type + .ok_or_else(|| sqlx::Error::Protocol("Missing job_type".into()))?, + status: self + .status + .ok_or_else(|| sqlx::Error::Protocol("Missing status".into()))?, + attempts: self + .attempts + .ok_or_else(|| sqlx::Error::Protocol("Missing attempts".into()))? + as usize, + max_attempts: self.max_attempts.map(|v| v as usize), + run_at: self.run_at, + last_result: self.last_result, + lock_at: self.lock_at, + lock_by: self.lock_by, + done_at: self.done_at, + priority: self.priority.map(|v| v as usize), + metadata: self.metadata, + }) + } + } +} +mod context { + pub type PgContext = apalis_sql::context::SqlContext; +} +mod queries; +pub mod shared; +pub mod sink; pub type PgTask = Task; +pub type CompactType = Vec; + #[pin_project::pin_project] pub struct PostgresStorage< Args, - Compact = Value, - Codec = JsonCodec, - Fetcher = PhantomData>, + Compact = CompactType, + Codec = JsonCodec, + Fetcher = PgFetcher, > { _marker: PhantomData<(Args, Compact, Codec)>, pool: PgPool, @@ -65,32 +305,68 @@ pub struct PostgresStorage< sink: PgSink, } +impl Clone + for PostgresStorage +{ + fn clone(&self) -> Self { + Self { + _marker: PhantomData, + pool: self.pool.clone(), + config: self.config.clone(), + fetcher: self.fetcher.clone(), + sink: self.sink.clone(), + } + } +} + +impl PostgresStorage<(), (), ()> { + /// Perform migrations for storage + #[cfg(feature = "migrate")] + pub async fn setup(pool: &PgPool) -> Result<(), sqlx::Error> { + Self::migrations().run(pool).await?; + Ok(()) + } + + /// Get postgres migrations without running them + #[cfg(feature = "migrate")] + pub fn migrations() -> sqlx::migrate::Migrator { + sqlx::migrate!("./migrations") + } +} + impl PostgresStorage { + pub fn new(pool: &PgPool) -> Self { + let config = Config::new(std::any::type_name::()); + Self::new_with_config(pool, &config) + } + /// Creates a new PostgresStorage instance. - pub fn new(pool: PgPool, config: Config) -> Self { - let sink = PgSink::new(&pool, &config); + pub fn new_with_config(pool: &PgPool, config: &Config) -> Self { + let sink = PgSink::new(pool, config); Self { _marker: PhantomData, - pool, - config, - fetcher: PhantomData, + pool: pool.clone(), + config: config.clone(), + fetcher: PgFetcher { + _marker: PhantomData, + }, sink, } } pub async fn new_with_notify( - pool: PgPool, - config: Config, - ) -> PostgresStorage, PgListener> { - let sink = PgSink::new(&pool, &config); - let mut fetcher = PgListener::connect_with(&pool) + pool: &PgPool, + config: &Config, + ) -> PostgresStorage, PgListener> { + let sink = PgSink::new(pool, config); + let mut fetcher = PgListener::connect_with(pool) .await .expect("Failed to create listener"); fetcher.listen("apalis::job::insert").await.unwrap(); PostgresStorage { _marker: PhantomData, - pool, - config, + pool: pool.clone(), + config: config.clone(), fetcher, sink, } @@ -107,42 +383,17 @@ impl PostgresStorage { } } -pub(crate) async fn register( - pool: PgPool, - worker_type: String, - worker: WorkerContext, - last_seen: i64, - backend_type: &str -) -> Result<(), sqlx::Error> { - let last_seen = DateTime::from_timestamp(last_seen, 0).ok_or(sqlx::Error::Io( - std::io::Error::new(std::io::ErrorKind::InvalidInput, "Invalid Timestamp"), - ))?; - let res = sqlx::query_file!( - "src/queries/worker/register.sql", - worker.name(), - worker_type, - backend_type, - worker.get_service(), - last_seen - ) - .execute(&pool) - .await?; - if res.rows_affected() == 0 { - return Err(sqlx::Error::Io(std::io::Error::new( - std::io::ErrorKind::AddrInUse, - "WORKER_ALREADY_EXISTS", - ))); - } - Ok(()) -} - -impl Backend - for PostgresStorage>> +impl Backend + for PostgresStorage> where Args: Send + 'static + Unpin, - Decode: Codec + 'static, + Decode: Codec + Send + 'static, Decode::Error: std::error::Error + Send + Sync + 'static, { + type Args = Args; + + type Compact = CompactType; + type IdType = Ulid; type Context = PgContext; @@ -151,39 +402,62 @@ where type Error = sqlx::Error; - type Stream = PgFetcher; + type Stream = TaskStream, sqlx::Error>; type Beat = BoxStream<'static, Result<(), sqlx::Error>>; - type Layer = AcknowledgeLayer; + type Layer = Stack>; fn heartbeat(&self, worker: &WorkerContext) -> Self::Beat { - let worker_type = self.config.namespace().to_owned(); - let fut = register( + let pool = self.pool.clone(); + let config = self.config.clone(); + let worker = worker.clone(); + let keep_alive = keep_alive_stream(pool, config, worker); + let reenqueue = reenqueue_orphaned_stream( self.pool.clone(), - worker_type, - worker.clone(), - Utc::now().timestamp(), - "PostgresStorage" - ); - stream::once(fut).boxed() + self.config.clone(), + *self.config.keep_alive(), + ) + .map_ok(|_| ()); + futures::stream::select(keep_alive, reenqueue).boxed() } fn middleware(&self) -> Self::Layer { - AcknowledgeLayer::new(PgAck::new(self.pool.clone())) + Stack::new( + LockTaskLayer::new(self.pool.clone()), + AcknowledgeLayer::new(PgAck::new(self.pool.clone())), + ) } fn poll(self, worker: &WorkerContext) -> Self::Stream { - PgFetcher::new(&self.pool, &self.config, worker) + let register_worker = initial_heartbeat( + self.pool.clone(), + self.config.clone(), + worker.clone(), + "PostgresStorage", + ) + .map(|_| Ok(None)); + let register = stream::once(register_worker); + register + .chain(PgPollFetcher::::new( + &self.pool, + &self.config, + worker, + )) + .boxed() } } -impl Backend for PostgresStorage +impl Backend for PostgresStorage where Args: Send + 'static + Unpin, - Decode: Codec + 'static + Send, + Decode: Codec + 'static + Send, Decode::Error: std::error::Error + Send + Sync + 'static, { + type Args = Args; + + type Compact = CompactType; + type IdType = Ulid; type Context = PgContext; @@ -196,28 +470,41 @@ where type Beat = BoxStream<'static, Result<(), sqlx::Error>>; - type Layer = AcknowledgeLayer; + type Layer = Stack>; fn heartbeat(&self, worker: &WorkerContext) -> Self::Beat { - let worker_type = self.config.namespace().to_owned(); - let fut = register( + let pool = self.pool.clone(); + let config = self.config.clone(); + let worker = worker.clone(); + let keep_alive = keep_alive_stream(pool, config, worker); + let reenqueue = reenqueue_orphaned_stream( self.pool.clone(), - worker_type, - worker.clone(), - Utc::now().timestamp(), - "PostgresStorageWithNotify" - ); - stream::once(fut).boxed() + self.config.clone(), + *self.config.keep_alive(), + ) + .map_ok(|_| ()); + futures::stream::select(keep_alive, reenqueue).boxed() } fn middleware(&self) -> Self::Layer { - AcknowledgeLayer::new(PgAck::new(self.pool.clone())) + Stack::new( + LockTaskLayer::new(self.pool.clone()), + AcknowledgeLayer::new(PgAck::new(self.pool.clone())), + ) } fn poll(self, worker: &WorkerContext) -> Self::Stream { let pool = self.pool.clone(); let worker_id = worker.name().to_owned(); - let namespace = self.config.namespace().to_owned(); + let namespace = self.config.queue().to_string(); + let register_worker = initial_heartbeat( + self.pool.clone(), + self.config.clone(), + worker.clone(), + "PostgresStorageWithNotify", + ) + .map(|_| Ok(None)); + let register = stream::once(register_worker); let lazy_fetcher = self .fetcher .into_stream() @@ -241,15 +528,21 @@ where let worker_id = worker_id.clone(); async move { let mut tx = pool.begin().await?; - use crate::from_row::TaskRow; + use crate::from_row::PgTaskRow; let res: Vec<_> = sqlx::query_file_as!( - TaskRow, - "src/queries/task/lock_by_id.sql", + PgTaskRow, + "queries/task/lock_by_id.sql", &ids, &worker_id ) .fetch(&mut *tx) - .map(|r| Ok(Some(r?.try_into_task::()?))) + .map(|r| { + let row: TaskRow = r?.try_into()?; + Ok(Some( + row.try_into_task::() + .map_err(|e| sqlx::Error::Protocol(e.to_string()))?, + )) + }) .collect() .await; tx.commit().await?; @@ -266,12 +559,12 @@ where }) .boxed(); - let eager_fetcher = StreamExt::boxed(PgFetcher::::new( + let eager_fetcher = StreamExt::boxed(PgPollFetcher::::new( &self.pool, &self.config, worker, )); - select(lazy_fetcher, eager_fetcher).boxed() + register.chain(select(lazy_fetcher, eager_fetcher)).boxed() } } @@ -283,39 +576,33 @@ pub struct InsertEvent { #[cfg(test)] mod tests { - use std::{str::FromStr, time::Duration}; + use std::{collections::HashMap, env, time::Duration}; - use chrono::Local; + use apalis_workflow::{WorkFlow, WorkflowError}; use apalis_core::{ error::BoxDynError, + task::data::Data, worker::{builder::WorkerBuilder, event::Event, ext::event_listener::EventListenerExt}, }; + use serde::{Deserialize, Serialize}; use super::*; #[tokio::test] async fn basic_worker() { - let mut backend = PostgresStorage::new( - PgPool::connect("postgres://postgres:postgres@localhost/apalis_dev") - .await - .unwrap(), - Default::default(), - ); - - let mut items = stream::repeat_with(|| { - let task = Task::builder(HashMap::default()) - .run_after(Duration::from_secs(1)) - .with_ctx({ - let mut ctx = PgContext::default(); - ctx.set_priority(1); - ctx - }) - .build(); - Ok(task) - }) - .take(1); - backend.send_all(&mut items).await.unwrap(); + use apalis_core::backend::TaskSink; + let pool = PgPool::connect( + env::var("DATABASE_URL") + .unwrap_or("postgres://postgres:postgres@localhost/apalis_dev".to_owned()) + .as_str(), + ) + .await + .unwrap(); + let mut backend = PostgresStorage::new(&pool); + + let mut items = stream::repeat_with(HashMap::default).take(1); + backend.push_stream(&mut items).await.unwrap(); async fn send_reminder( _: HashMap, @@ -334,24 +621,24 @@ mod tests { #[tokio::test] async fn notify_worker() { - let pool = PgPool::connect("postgres://postgres:postgres@localhost/apalis_dev") - .await - .unwrap(); - let config = Config::new("test").set_poll_interval(Duration::from_secs(5)); - let mut backend = PostgresStorage::new_with_notify(pool, config).await; + use apalis_core::backend::TaskSink; + let pool = PgPool::connect( + env::var("DATABASE_URL") + .unwrap_or("postgres://postgres:postgres@localhost/apalis_dev".to_owned()) + .as_str(), + ) + .await + .unwrap(); + let config = Config::new("test"); + let mut backend = PostgresStorage::new_with_notify(&pool, &config).await; let mut items = stream::repeat_with(|| { - let task = Task::builder(Default::default()) - .with_ctx({ - let mut ctx = PgContext::default(); - ctx.set_priority(1); - ctx - }) - .build(); - Ok(task) + Task::builder(42u32) + .with_ctx(PgContext::new().with_priority(1)) + .build() }) .take(1); - backend.send_all(&mut items).await.unwrap(); + backend.push_all(&mut items).await.unwrap(); async fn send_reminder(_: u32, wrk: WorkerContext) -> Result<(), BoxDynError> { tokio::time::sleep(Duration::from_secs(2)).await; @@ -364,4 +651,135 @@ mod tests { .build(send_reminder); worker.run().await.unwrap(); } + + #[tokio::test] + async fn test_workflow_complete() { + use apalis_core::backend::WeakTaskSink; + #[derive(Debug, Serialize, Deserialize, Clone)] + struct PipelineConfig { + min_confidence: f32, + enable_sentiment: bool, + } + + #[derive(Debug, Serialize, Deserialize)] + struct UserInput { + text: String, + } + + #[derive(Debug, Serialize, Deserialize)] + struct Classified { + text: String, + label: String, + confidence: f32, + } + + #[derive(Debug, Serialize, Deserialize)] + struct Summary { + text: String, + sentiment: Option, + } + + let workflow = WorkFlow::new("text-pipeline") + // Step 1: Preprocess input (e.g., tokenize, lowercase) + .then(|input: UserInput, mut worker: WorkerContext| async move { + worker.emit(&Event::Custom(Box::new(format!( + "Preprocessing input: {}", + input.text + )))); + let processed = input.text.to_lowercase(); + Ok::<_, WorkflowError>(processed) + }) + // Step 2: Classify text + .then(|text: String| async move { + let confidence = 0.85; // pretend model confidence + let items = text.split_whitespace().collect::>(); + let results = items + .into_iter() + .map(|x| Classified { + text: x.to_string(), + label: if x.contains("rust") { + "Tech" + } else { + "General" + } + .to_string(), + confidence, + }) + .collect::>(); + Ok::<_, WorkflowError>(results) + }) + // Step 3: Filter out low-confidence predictions + .filter_map( + |c: Classified| async move { if c.confidence >= 0.6 { Some(c) } else { None } }, + ) + .filter_map(move |c: Classified, config: Data| { + let cfg = config.enable_sentiment; + async move { + if !cfg { + return Some(Summary { + text: c.text, + sentiment: None, + }); + } + + // pretend we run a sentiment model + let sentiment = if c.text.contains("delightful") { + "positive" + } else { + "neutral" + }; + Some(Summary { + text: c.text, + sentiment: Some(sentiment.to_string()), + }) + } + }) + .then(|a: Vec, mut worker: WorkerContext| async move { + dbg!(&a); + worker.emit(&Event::Custom(Box::new(format!( + "Generated {} summaries", + a.len() + )))); + worker.stop() + }); + + let pool = PgPool::connect( + env::var("DATABASE_URL") + .unwrap_or("postgres://postgres:postgres@localhost/apalis_dev".to_owned()) + .as_str(), + ) + .await + .unwrap(); + let config = Config::new("test"); + let mut backend: PostgresStorage> = + PostgresStorage::new_with_config(&pool, &config); + + let input = UserInput { + text: "Rust makes systems programming delightful!".to_string(), + }; + backend.push(input).await.unwrap(); + + let worker = WorkerBuilder::new("rango-tango") + .backend(backend) + .data(PipelineConfig { + min_confidence: 0.8, + enable_sentiment: true, + }) + .on_event(|ctx, ev| match ev { + Event::Custom(msg) => { + if let Some(m) = msg.downcast_ref::() { + println!("Custom Message: {m}"); + } + } + Event::Error(_) => { + println!("On Error = {ev:?}"); + ctx.stop().unwrap(); + } + _ => { + println!("On Event = {ev:?}"); + } + }) + .build(workflow); + worker.run().await.unwrap(); + } } diff --git a/src/queries/fetch_by_id.rs b/src/queries/fetch_by_id.rs new file mode 100644 index 0000000..f8055ea --- /dev/null +++ b/src/queries/fetch_by_id.rs @@ -0,0 +1,38 @@ +use apalis_core::{ + backend::{Backend, FetchById, codec::Codec}, + task::task_id::TaskId, +}; + +use apalis_sql::from_row::TaskRow; +use ulid::Ulid; + +use crate::{CompactType, PgTask, PostgresStorage, context::PgContext, from_row::PgTaskRow}; + +impl FetchById for PostgresStorage +where + PostgresStorage: + Backend, + D: Codec, + D::Error: std::error::Error + Send + Sync + 'static, + Args: 'static, +{ + fn fetch_by_id( + &mut self, + id: &TaskId, + ) -> impl Future>, Self::Error>> + Send { + let pool = self.pool.clone(); + let id = id.to_string(); + async move { + let task = sqlx::query_file_as!(PgTaskRow, "queries/task/find_by_id.sql", id) + .fetch_optional(&pool) + .await? + .map(|r: PgTaskRow| { + let row: TaskRow = r.try_into()?; + row.try_into_task::() + .map_err(|e| sqlx::Error::Protocol(e.to_string())) + }) + .transpose()?; + Ok(task) + } + } +} diff --git a/src/queries/keep_alive.rs b/src/queries/keep_alive.rs new file mode 100644 index 0000000..c0a5a4a --- /dev/null +++ b/src/queries/keep_alive.rs @@ -0,0 +1,61 @@ +use apalis_core::worker::context::WorkerContext; +use chrono::Utc; +use futures::{FutureExt, Stream, stream}; +use sqlx::PgPool; + +use crate::{ + Config, + queries::{ + reenqueue_orphaned::reenqueue_orphaned, register_worker::register as register_worker, + }, +}; + +pub async fn keep_alive( + pool: PgPool, + config: Config, + worker: WorkerContext, +) -> Result<(), sqlx::Error> { + let worker = worker.name().to_owned(); + let queue = config.queue().to_string(); + let res = sqlx::query_file!("queries/backend/keep_alive.sql", worker, queue) + .execute(&pool) + .await?; + if res.rows_affected() == 0 { + return Err(sqlx::Error::Io(std::io::Error::new( + std::io::ErrorKind::NotFound, + "WORKER_DOES_NOT_EXIST", + ))); + } + Ok(()) +} + +pub async fn initial_heartbeat( + pool: PgPool, + config: Config, + worker: WorkerContext, + storage_type: &str, +) -> Result<(), sqlx::Error> { + reenqueue_orphaned(pool.clone(), config.clone()).await?; + let last_seen = Utc::now(); + register_worker( + pool, + config.queue().to_string(), + worker, + last_seen, + storage_type, + ) + .await?; + Ok(()) +} + +pub fn keep_alive_stream( + pool: PgPool, + config: Config, + worker: WorkerContext, +) -> impl Stream> + Send { + stream::unfold((), move |_| { + let register = keep_alive(pool.clone(), config.clone(), worker.clone()); + let interval = apalis_core::timer::Delay::new(*config.keep_alive()); + interval.then(move |_| register.map(|res| Some((res, ())))) + }) +} diff --git a/src/queries/list_queues.rs b/src/queries/list_queues.rs new file mode 100644 index 0000000..0d59665 --- /dev/null +++ b/src/queries/list_queues.rs @@ -0,0 +1,37 @@ +use apalis_core::backend::{Backend, ListQueues, QueueInfo}; +use apalis_sql::context::SqlContext; +use serde_json::Value; +use ulid::Ulid; + +use crate::{CompactType, PostgresStorage}; + +impl ListQueues for PostgresStorage +where + PostgresStorage: + Backend, +{ + fn list_queues(&self) -> impl Future, Self::Error>> + Send { + let pool = self.pool.clone(); + struct QueueInfoRow { + pub name: Option, + pub stats: Option, + pub workers: Option, + pub activity: Option, + } + + async move { + let queues = sqlx::query_file_as!(QueueInfoRow, "queries/backend/list_queues.sql") + .fetch_all(&pool) + .await? + .into_iter() + .map(|row| QueueInfo { + name: row.name.unwrap_or_default(), + stats: serde_json::from_value(row.stats.unwrap()).unwrap_or_default(), + workers: serde_json::from_value(row.workers.unwrap()).unwrap_or_default(), + activity: serde_json::from_value(row.activity.unwrap()).unwrap_or_default(), + }) + .collect(); + Ok(queues) + } + } +} diff --git a/src/queries/list_tasks.rs b/src/queries/list_tasks.rs new file mode 100644 index 0000000..1069795 --- /dev/null +++ b/src/queries/list_tasks.rs @@ -0,0 +1,94 @@ +use apalis_core::{ + backend::{Backend, Filter, ListAllTasks, ListTasks, codec::Codec}, + task::{Task, status::Status}, +}; +use apalis_sql::{context::SqlContext, from_row::TaskRow}; +use ulid::Ulid; + +use crate::{CompactType, PgTask, PostgresStorage, from_row::PgTaskRow}; + +impl ListTasks for PostgresStorage +where + PostgresStorage: + Backend, + D: Codec, + D::Error: std::error::Error + Send + Sync + 'static, + Args: 'static, +{ + fn list_tasks( + &self, + queue: &str, + filter: &Filter, + ) -> impl Future>, Self::Error>> + Send { + let queue = queue.to_string(); + let pool = self.pool.clone(); + let limit = filter.limit() as i64; + let offset = filter.offset() as i64; + let status = filter + .status + .as_ref() + .unwrap_or(&Status::Pending) + .to_string(); + async move { + let tasks = sqlx::query_file_as!( + PgTaskRow, + "queries/backend/list_jobs.sql", + status, + queue, + limit, + offset + ) + .fetch_all(&pool) + .await? + .into_iter() + .map(|r| { + let row: TaskRow = r.try_into()?; + row.try_into_task::() + .map_err(|e| sqlx::Error::Protocol(e.to_string())) + }) + .collect::, _>>()?; + Ok(tasks) + } + } +} + +impl ListAllTasks for PostgresStorage +where + PostgresStorage: + Backend, +{ + fn list_all_tasks( + &self, + filter: &Filter, + ) -> impl Future< + Output = Result>, Self::Error>, + > + Send { + let status = filter + .status + .as_ref() + .map(|s| s.to_string()) + .unwrap_or(Status::Pending.to_string()); + let pool = self.pool.clone(); + let limit = filter.limit() as i64; + let offset = filter.offset() as i64; + async move { + let tasks = sqlx::query_file_as!( + PgTaskRow, + "queries/backend/list_all_jobs.sql", + status, + limit, + offset + ) + .fetch_all(&pool) + .await? + .into_iter() + .map(|r| { + let row: TaskRow = r.try_into()?; + row.try_into_task_compact() + .map_err(|e| sqlx::Error::Protocol(e.to_string())) + }) + .collect::, _>>()?; + Ok(tasks) + } + } +} diff --git a/src/queries/list_workers.rs b/src/queries/list_workers.rs new file mode 100644 index 0000000..1d7ebfd --- /dev/null +++ b/src/queries/list_workers.rs @@ -0,0 +1,89 @@ +use apalis_core::backend::{Backend, ListWorkers, RunningWorker}; +use apalis_sql::{ context::SqlContext}; +use chrono::{DateTime, Utc}; +use futures::TryFutureExt; +use ulid::Ulid; + +#[derive(Debug)] +pub struct WorkerRow { + pub id: String, + pub worker_type: String, + pub storage_name: String, + pub layers: Option, + pub last_seen: DateTime, + pub started_at: Option>, +} + + +use crate::{CompactType, PostgresStorage}; + +impl ListWorkers for PostgresStorage +where + PostgresStorage: + Backend, +{ + fn list_workers( + &self, + queue: &str, + ) -> impl Future, Self::Error>> + Send { + let queue = queue.to_string(); + let pool = self.pool.clone(); + let limit = 100; + let offset = 0; + async move { + let workers = sqlx::query_file_as!( + WorkerRow, + "queries/backend/list_workers.sql", + queue, + limit, + offset + ) + .fetch_all(&pool) + .map_ok(|w| { + w.into_iter() + .map(|w| RunningWorker { + id: w.id, + backend: w.storage_name, + started_at: w.started_at.map(|t| t.timestamp()).unwrap_or_default() as u64, + last_heartbeat: w.last_seen.timestamp() as u64, + layers: w.layers.unwrap_or_default(), + queue: w.worker_type, + }) + .collect() + }) + .await?; + Ok(workers) + } + } + + fn list_all_workers( + &self, + ) -> impl Future, Self::Error>> + Send { + let pool = self.pool.clone(); + let limit = 100; + let offset = 0; + async move { + let workers = sqlx::query_file_as!( + WorkerRow, + "queries/backend/list_all_workers.sql", + limit, + offset + ) + .fetch_all(&pool) + .map_ok(|w| { + w.into_iter() + .map(|w| RunningWorker { + id: w.id, + backend: w.storage_name, + started_at: w.started_at.map(|t| t.timestamp()).unwrap_or_default() as u64, + last_heartbeat: w.last_seen.timestamp() as u64, + layers: w.layers.unwrap_or_default(), + queue: w.worker_type, + }) + .collect() + }) + .await?; + Ok(workers) + } + } +} diff --git a/src/queries/metrics.rs b/src/queries/metrics.rs new file mode 100644 index 0000000..4967f81 --- /dev/null +++ b/src/queries/metrics.rs @@ -0,0 +1,62 @@ +use apalis_core::backend::{Backend, Metrics, Statistic}; +use apalis_sql::context::SqlContext; +use ulid::Ulid; + +use crate::{CompactType, PostgresStorage}; + +struct StatisticRow { + priority: Option, + r#type: Option, + statistic: Option, + value: Option, +} + +impl Metrics for PostgresStorage +where + PostgresStorage: + Backend, +{ + fn global(&self) -> impl Future, Self::Error>> + Send { + let pool = self.pool.clone(); + + async move { + let rec = sqlx::query_file_as!(StatisticRow, "queries/backend/overview.sql") + .fetch_all(&pool) + .await? + .into_iter() + .map(|r| Statistic { + priority: Some(r.priority.unwrap_or_default() as u64), + stat_type: apalis_sql::stat_type_from_string(&r.r#type.unwrap_or_default()), + title: r.statistic.unwrap_or_default(), + value: r.value.unwrap_or_default().to_string(), + }) + .collect(); + Ok(rec) + } + } + fn fetch_by_queue( + &self, + queue_id: &str, + ) -> impl Future, Self::Error>> + Send { + let pool = self.pool.clone(); + let queue_id = queue_id.to_string(); + async move { + let rec = sqlx::query_file_as!( + StatisticRow, + "queries/backend/overview_by_queue.sql", + queue_id + ) + .fetch_all(&pool) + .await? + .into_iter() + .map(|r| Statistic { + priority: Some(r.priority.unwrap_or_default() as u64), + stat_type: apalis_sql::stat_type_from_string(&r.r#type.unwrap_or_default()), + title: r.statistic.unwrap_or_default(), + value: r.value.unwrap_or_default().to_string(), + }) + .collect(); + Ok(rec) + } + } +} diff --git a/src/queries/mod.rs b/src/queries/mod.rs new file mode 100644 index 0000000..aab99c3 --- /dev/null +++ b/src/queries/mod.rs @@ -0,0 +1,9 @@ +pub mod fetch_by_id; +pub mod keep_alive; +pub mod list_queues; +pub mod list_tasks; +pub mod list_workers; +pub mod metrics; +pub mod reenqueue_orphaned; +pub mod register_worker; +pub mod wait_for; diff --git a/src/queries/reenqueue_orphaned.rs b/src/queries/reenqueue_orphaned.rs new file mode 100644 index 0000000..e2cd953 --- /dev/null +++ b/src/queries/reenqueue_orphaned.rs @@ -0,0 +1,57 @@ +use std::time::Duration; + +use futures::{FutureExt, Stream, stream}; +use sqlx::{postgres::types::PgInterval, PgPool}; + +use crate::Config; + +pub fn reenqueue_orphaned( + pool: PgPool, + config: Config, +) -> impl Future> + Send { + let dead_for = config.reenqueue_orphaned_after().as_secs() as i64; + let queue = config.queue().to_string(); + let dead_for = PgInterval { + months: 0, + days: 0, + microseconds: dead_for * 1_000_000, + }; + async move { + match sqlx::query_file!("queries/backend/reenqueue_orphaned.sql", dead_for, queue,) + .execute(&pool) + .await + { + Ok(res) => { + if res.rows_affected() > 0 { + // log::info!( + // "Re-enqueued {} orphaned tasks that were being processed by dead workers", + // res.rows_affected() + // ); + } + Ok(res.rows_affected()) + } + Err(e) => { + // log::error!("Failed to re-enqueue orphaned tasks: {e}"); + Err(e) + } + } + } +} + +pub fn reenqueue_orphaned_stream( + pool: PgPool, + config: Config, + interval: Duration, +) -> impl Stream> + Send { + let config = config.clone(); + stream::unfold((), move |_| { + let pool = pool.clone(); + let config = config.clone(); + let interval = apalis_core::timer::Delay::new(interval); + let fut = async move { + interval.await; + reenqueue_orphaned(pool, config).await + }; + fut.map(|res| Some((res, ()))) + }) +} diff --git a/src/queries/register_worker.rs b/src/queries/register_worker.rs new file mode 100644 index 0000000..641c4f5 --- /dev/null +++ b/src/queries/register_worker.rs @@ -0,0 +1,29 @@ +use apalis_core::worker::context::WorkerContext; +use chrono::{DateTime, Utc}; +use sqlx::PgPool; + +pub async fn register( + pool: PgPool, + worker_type: String, + worker: WorkerContext, + last_seen: DateTime, + backend_type: &str, +) -> Result<(), sqlx::Error> { + let res = sqlx::query_file!( + "queries/worker/register.sql", + worker.name(), + worker_type, + backend_type, + worker.get_service(), + last_seen + ) + .execute(&pool) + .await?; + if res.rows_affected() == 0 { + return Err(sqlx::Error::Io(std::io::Error::new( + std::io::ErrorKind::AddrInUse, + "WORKER_ALREADY_EXISTS", + ))); + } + Ok(()) +} diff --git a/src/queries/wait_for.rs b/src/queries/wait_for.rs new file mode 100644 index 0000000..b99ece5 --- /dev/null +++ b/src/queries/wait_for.rs @@ -0,0 +1,119 @@ +use std::{collections::HashSet, str::FromStr, vec}; + +use apalis_core::{ + backend::{Backend, TaskResult, WaitForCompletion}, + task::{status::Status, task_id::TaskId}, +}; +use apalis_sql::context::SqlContext; +use futures::{StreamExt, stream::BoxStream}; +use serde::de::DeserializeOwned; +use ulid::Ulid; + +use crate::{CompactType, PostgresStorage}; + +#[derive(Debug)] +pub struct TaskResultRow { + pub id: Option, + pub status: Option, + pub result: Option, +} + +impl WaitForCompletion + for PostgresStorage +where + PostgresStorage: + Backend, + Result: DeserializeOwned, +{ + type ResultStream = BoxStream<'static, Result, Self::Error>>; + fn wait_for( + &self, + task_ids: impl IntoIterator>, + ) -> Self::ResultStream { + let pool = self.pool.clone(); + let ids: HashSet = task_ids.into_iter().map(|id| id.to_string()).collect(); + + let stream = futures::stream::unfold(ids, move |mut remaining_ids| { + let pool = pool.clone(); + async move { + if remaining_ids.is_empty() { + return None; + } + + let ids_vec: Vec = remaining_ids.iter().cloned().collect(); + let ids_vec = serde_json::to_value(&ids_vec).unwrap(); + let rows = sqlx::query_file_as!( + TaskResultRow, + "queries/backend/fetch_completed_tasks.sql", + ids_vec + ) + .fetch_all(&pool) + .await + .ok()?; + + if rows.is_empty() { + apalis_core::timer::sleep(std::time::Duration::from_millis(500)).await; + return Some((futures::stream::iter(vec![]), remaining_ids)); + } + + let mut results = Vec::new(); + for row in rows { + let task_id = row.id.clone().unwrap(); + remaining_ids.remove(&task_id); + // Here we would normally decode the output O from the row + // For simplicity, we assume O is String and the output is stored in row.output + let result: Result = + serde_json::from_value(row.result.unwrap()).unwrap(); + results.push(Ok(TaskResult::new( + TaskId::from_str(&task_id).ok()?, + Status::from_str(&row.status.unwrap()).ok()?, + result, + ))); + } + + Some((futures::stream::iter(results), remaining_ids)) + } + }); + stream.flatten().boxed() + } + + // Implementation of check_status + fn check_status( + &self, + task_ids: impl IntoIterator> + Send, + ) -> impl Future>, Self::Error>> + Send { + let pool = self.pool.clone(); + let ids: Vec = task_ids.into_iter().map(|id| id.to_string()).collect(); + + async move { + let ids = serde_json::to_value(&ids).unwrap(); + let rows = sqlx::query_file_as!( + TaskResultRow, + "queries/backend/fetch_completed_tasks.sql", + ids + ) + .fetch_all(&pool) + .await?; + + let mut results = Vec::new(); + for row in rows { + let task_id = TaskId::from_str(&row.id.unwrap()) + .map_err(|_| sqlx::Error::Protocol("Invalid task ID".into()))?; + + let result: Result = serde_json::from_value(row.result.unwrap()) + .map_err(|_| sqlx::Error::Protocol("Failed to decode result".into()))?; + + results.push(TaskResult::new( + task_id, + row.status + .unwrap() + .parse() + .map_err(|_| sqlx::Error::Protocol("Invalid status value".into()))?, + result, + )); + } + + Ok(results) + } + } +} diff --git a/src/shared.rs b/src/shared.rs index d4c441b..d3c6baf 100644 --- a/src/shared.rs +++ b/src/shared.rs @@ -1,5 +1,5 @@ use std::{ - collections::{HashMap, HashSet}, + collections::HashMap, future::ready, marker::PhantomData, pin::Pin, @@ -8,33 +8,38 @@ use std::{ }; use crate::{ - Config, InsertEvent, PgTask, PostgresStorage, ack::PgAck, context::PgContext, - fetcher::PgFetcher, register, + CompactType, Config, InsertEvent, PgTask, PostgresStorage, + ack::{LockTaskLayer, PgAck}, + context::PgContext, + fetcher::PgPollFetcher, + queries::{ + keep_alive::{initial_heartbeat, keep_alive_stream}, + reenqueue_orphaned::reenqueue_orphaned_stream, + }, }; -use crate::{from_row::TaskRow, sink::PgSink}; +use crate::{from_row::PgTaskRow, sink::PgSink}; use apalis_core::{ backend::{ Backend, TaskStream, codec::{Codec, json::JsonCodec}, shared::MakeShared, }, - task::{Task, task_id::TaskId}, + layers::Stack, + task::task_id::TaskId, worker::{context::WorkerContext, ext::ack::AcknowledgeLayer}, }; -use chrono::Utc; +use apalis_sql::from_row::TaskRow; use futures::{ FutureExt, SinkExt, Stream, StreamExt, TryStreamExt, channel::mpsc::{self, Receiver, Sender}, - future::{self, BoxFuture, Shared}, + future::{BoxFuture, Shared}, lock::Mutex, stream::{self, BoxStream, select}, }; -use serde::{Deserialize, Serialize, de::DeserializeOwned}; -use serde_json::Value; use sqlx::{PgPool, postgres::PgListener}; use ulid::Ulid; -pub struct SharedPostgresStorage> { +pub struct SharedPostgresStorage> { pool: PgPool, registry: Arc>>>, drive: Shared>, @@ -84,9 +89,14 @@ impl SharedPostgresStorage { } } } -#[derive(Debug)] +#[derive(Debug, thiserror::Error)] pub enum SharedPostgresError { + /// Namespace not found + #[error("namespace already exists: {0}")] NamespaceExists(String), + + /// Registry locked + #[error("registry locked")] RegistryLocked, } @@ -109,9 +119,9 @@ impl MakeShared for SharedPostgresStorage, cx: &mut Context<'_>) -> Poll> { + fn poll_next(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { let this = self.get_mut(); // Keep the poller alive by polling it, but ignoring the output let _ = this.poller.poll_unpin(cx); @@ -146,45 +156,62 @@ impl Stream for SharedFetcher { } } -impl Backend for PostgresStorage +impl Backend for PostgresStorage where Args: Send + 'static + Unpin, - Decode: Codec + 'static + Unpin + Send, + Decode: Codec + 'static + Unpin + Send, Decode::Error: std::error::Error + Send + Sync + 'static, { + type Args = Args; + + type Compact = CompactType; + type IdType = Ulid; type Error = sqlx::Error; - type Stream = TaskStream, sqlx::Error>; + type Stream = TaskStream, Self::Error>; - type Beat = BoxStream<'static, Result<(), sqlx::Error>>; + type Beat = BoxStream<'static, Result<(), Self::Error>>; type Codec = Decode; type Context = PgContext; - type Layer = AcknowledgeLayer; + type Layer = Stack>; fn heartbeat(&self, worker: &WorkerContext) -> Self::Beat { - let worker_type = self.config.namespace().to_owned(); - let fut = register( + let pool = self.pool.clone(); + let config = self.config.clone(); + let worker = worker.clone(); + let keep_alive = keep_alive_stream(pool, config, worker); + let reenqueue = reenqueue_orphaned_stream( self.pool.clone(), - worker_type, - worker.clone(), - Utc::now().timestamp(), - "SharedPostgresStorage", - ); - stream::once(fut).boxed() + self.config.clone(), + *self.config.keep_alive(), + ) + .map_ok(|_| ()); + futures::stream::select(keep_alive, reenqueue).boxed() } fn middleware(&self) -> Self::Layer { - AcknowledgeLayer::new(PgAck::new(self.pool.clone())) + Stack::new( + LockTaskLayer::new(self.pool.clone()), + AcknowledgeLayer::new(PgAck::new(self.pool.clone())), + ) } fn poll(self, worker: &WorkerContext) -> Self::Stream { let pool = self.pool.clone(); let worker_id = worker.name().to_owned(); + let register_worker = initial_heartbeat( + self.pool.clone(), + self.config.clone(), + worker.clone(), + "SharedPostgresStorage", + ) + .map(|_| Ok(None)); + let register = stream::once(register_worker); let lazy_fetcher = self .fetcher .map(|t| t.to_string()) @@ -195,13 +222,19 @@ where async move { let mut tx = pool.begin().await?; let res: Vec<_> = sqlx::query_file_as!( - TaskRow, - "src/queries/task/lock_by_id.sql", + PgTaskRow, + "queries/task/lock_by_id.sql", &ids, &worker_id ) .fetch(&mut *tx) - .map(|r| Ok(Some(r?.try_into_task::()?))) + .map(|r| { + let row: TaskRow = r?.try_into()?; + Ok(Some( + row.try_into_task::() + .map_err(|e| sqlx::Error::Protocol(e.to_string()))?, + )) + }) .collect() .await; tx.commit().await?; @@ -218,28 +251,20 @@ where }) .boxed(); - let eager_fetcher = StreamExt::boxed(PgFetcher::::new( + let eager_fetcher = StreamExt::boxed(PgPollFetcher::::new( &self.pool, &self.config, worker, )); - select(lazy_fetcher, eager_fetcher).boxed() + register.chain(select(lazy_fetcher, eager_fetcher)).boxed() } } #[cfg(test)] mod tests { - use std::{str::FromStr, time::Duration}; - - use chrono::Local; + use std::time::Duration; - use apalis_core::{ - backend::{TaskSink, memory::MemoryStorage}, - error::BoxDynError, - worker::{builder::WorkerBuilder, event::Event, ext::event_listener::EventListenerExt}, - }; - - use crate::context::PgContext; + use apalis_core::{backend::TaskSink, error::BoxDynError, worker::builder::WorkerBuilder}; use super::*; @@ -254,24 +279,15 @@ mod tests { let mut int_store = store.make_shared().unwrap(); - let task = Task::builder(99u32) - .run_after(Duration::from_secs(2)) - .with_ctx({ - let mut ctx = PgContext::default(); - ctx.set_priority(1); - ctx - }) - .build(); - map_store - .send_all(&mut stream::iter(vec![task].into_iter().map(Ok))) + .push_stream(&mut stream::iter(vec![HashMap::::new()])) .await .unwrap(); int_store.push(99).await.unwrap(); async fn send_reminder( _: T, - task_id: TaskId, + _task_id: TaskId, wrk: WorkerContext, ) -> Result<(), BoxDynError> { tokio::time::sleep(Duration::from_secs(2)).await; @@ -285,6 +301,6 @@ mod tests { let map_worker = WorkerBuilder::new("rango-tango-1") .backend(map_store) .build(send_reminder); - let res = tokio::try_join!(int_worker.run(), map_worker.run()).unwrap(); + tokio::try_join!(int_worker.run(), map_worker.run()).unwrap(); } } diff --git a/src/sink.rs b/src/sink.rs index bb74a85..6bb6035 100644 --- a/src/sink.rs +++ b/src/sink.rs @@ -1,27 +1,29 @@ use std::{ - pin::{self, Pin}, + pin::Pin, + sync::Arc, task::{Context, Poll}, }; -use apalis_core::{ - backend::codec::{Codec, json::JsonCodec}, - error::BoxDynError, -}; +use apalis_core::backend::codec::json::JsonCodec; use chrono::DateTime; -use futures::Sink; -use serde_json::Value; -use sqlx::{PgPool, Postgres}; +use futures::{ + FutureExt, Sink, TryFutureExt, + future::{BoxFuture, Shared}, +}; +use sqlx::PgPool; use ulid::Ulid; -use crate::{PgTask, PostgresStorage, config::Config}; +use crate::{CompactType, PgTask, PostgresStorage, config::Config}; + +type FlushFuture = BoxFuture<'static, Result<(), Arc>>; #[pin_project::pin_project] -pub struct PgSink> { +pub struct PgSink> { pool: PgPool, config: Config, buffer: Vec>, #[pin] - flush_future: Option> + Send>>>, + flush_future: Option>, _marker: std::marker::PhantomData<(Args, Codec)>, } @@ -37,18 +39,19 @@ impl Clone for PgSink { } } -async fn push_tasks( +pub async fn push_tasks( pool: PgPool, cfg: Config, - buffer: Vec>, + buffer: Vec>, ) -> Result<(), sqlx::Error> { - let job_type = cfg.namespace(); + let job_type = cfg.queue().to_string(); // Build the multi-row INSERT with UNNEST let mut ids = Vec::new(); let mut job_data = Vec::new(); let mut run_ats = Vec::new(); let mut priorities = Vec::new(); let mut max_attempts_vec = Vec::new(); + let mut metadata = Vec::new(); for task in buffer { ids.push( @@ -62,18 +65,20 @@ async fn push_tasks( DateTime::from_timestamp(task.parts.run_at as i64, 0) .ok_or(sqlx::Error::ColumnNotFound("run_at".to_owned()))?, ); - priorities.push(*task.parts.ctx.priority()); + priorities.push(task.parts.ctx.priority()); max_attempts_vec.push(task.parts.ctx.max_attempts()); + metadata.push(serde_json::Value::Object(task.parts.ctx.meta().clone())); } sqlx::query_file!( - "src/queries/task/sink.sql", + "queries/task/sink.sql", &ids, &job_type, &job_data, &max_attempts_vec, &run_ats, - &priorities + &priorities, + &metadata ) .execute(&pool) .await?; @@ -92,12 +97,10 @@ impl PgSink { } } -impl Sink> for PostgresStorage +impl Sink> + for PostgresStorage where Args: Unpin + Send + Sync + 'static, - Encode: Codec + Unpin, - Encode::Error: std::error::Error + Send + Sync + 'static, - Encode::Error: Into, Fetcher: Unpin, { type Error = sqlx::Error; @@ -106,12 +109,9 @@ where Poll::Ready(Ok(())) } - fn start_send(mut self: Pin<&mut Self>, item: PgTask) -> Result<(), Self::Error> { + fn start_send(self: Pin<&mut Self>, item: PgTask) -> Result<(), Self::Error> { // Add the item to the buffer - self.get_mut() - .sink - .buffer - .push(item.try_map(|s| Encode::encode(&s).map_err(|e| sqlx::Error::Encode(e.into())))?); + self.get_mut().sink.buffer.push(item); Ok(()) } @@ -128,20 +128,20 @@ where let pool = this.pool.clone(); let config = this.config.clone(); let buffer = std::mem::take(&mut this.sink.buffer); - let sink_fut = push_tasks(pool, config, buffer); - this.sink.flush_future = Some(Box::pin(sink_fut)); + let sink_fut = push_tasks(pool, config, buffer).map_err(Arc::new); + this.sink.flush_future = Some(sink_fut.boxed().shared()); } // Poll the existing future if let Some(mut fut) = this.sink.flush_future.take() { - match fut.as_mut().poll(cx) { + match fut.poll_unpin(cx) { Poll::Ready(Ok(())) => { // Future completed successfully, don't put it back Poll::Ready(Ok(())) } Poll::Ready(Err(e)) => { // Future completed with error, don't put it back - Poll::Ready(Err(e)) + Poll::Ready(Err(Arc::::into_inner(e).unwrap())) } Poll::Pending => { // Future is still pending, put it back and return Pending