diff --git a/.copier-answers.yml b/.copier-answers.yml index d4b6ba0151..99080f40d4 100644 --- a/.copier-answers.yml +++ b/.copier-answers.yml @@ -1,8 +1,7 @@ # Do NOT update manually; changes here will be overwritten by Copier -_commit: v1.29 +_commit: v1.44 _src_path: git+https://github.com/OCA/oca-addons-repo-template additional_ruff_rules: [] -ci: GitHub convert_readme_fragments_to_markdown: true enable_checklog_odoo: true generate_requirements_txt: true @@ -17,6 +16,7 @@ odoo_test_flavor: Both odoo_version: 18.0 org_name: Odoo Community Association (OCA) org_slug: OCA +postgres_image: '' rebel_module_groups: [] repo_description: queue repo_name: queue diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000000..e0d56685a9 --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +test-requirements.txt merge=union diff --git a/.github/workflows/pre-commit.yml b/.github/workflows/pre-commit.yml index 5eb021ef15..408069d32d 100644 --- a/.github/workflows/pre-commit.yml +++ b/.github/workflows/pre-commit.yml @@ -1,3 +1,4 @@ + name: pre-commit on: @@ -9,6 +10,10 @@ on: - "18.0" - "18.0-ocabot-*" +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + jobs: pre-commit: runs-on: ubuntu-22.04 @@ -16,7 +21,9 @@ jobs: - uses: actions/checkout@v4 - uses: actions/setup-python@v5 with: - python-version: "3.11" + python-version: "3.12" + cache: 'pip' + cache-dependency-path: '.pre-commit-config.yaml' - name: Get python version run: echo "PY=$(python -VV | sha256sum | cut -d' ' -f1)" >> $GITHUB_ENV - uses: actions/cache@v4 diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index a06488079f..cd982c1e44 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -9,6 +9,10 @@ on: - "18.0" - "18.0-ocabot-*" +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + jobs: unreleased-deps: runs-on: ubuntu-latest @@ -42,7 +46,7 @@ jobs: makepot: "true" services: postgres: - image: postgres:12.0 + image: postgres:12 env: POSTGRES_USER: odoo POSTGRES_PASSWORD: odoo @@ -65,6 +69,13 @@ jobs: run: oca_init_test_database - name: Run tests run: oca_run_tests + - name: Upload screenshots from JS tests + uses: actions/upload-artifact@v4 + if: ${{ failure() }} + with: + name: Screenshots of failed JS tests - ${{ matrix.name }}${{ join(matrix.include) }} + path: /tmp/odoo_tests/${{ env.PGDATABASE }} + if-no-files-found: ignore - uses: codecov/codecov-action@v4 with: token: ${{ secrets.CODECOV_TOKEN }} diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index c74099edcf..29c15eaf6a 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -21,7 +21,7 @@ exclude: | # You don't usually want a bot to modify your legal texts (LICENSE.*|COPYING.*) default_language_version: - python: python3 + python: python3.12 node: "22.9.0" repos: - repo: local @@ -38,12 +38,17 @@ repos: entry: found a en.po file language: fail files: '[a-zA-Z0-9_]*/i18n/en\.po$' + - id: obsolete dotfiles + name: obsolete dotfiles + entry: found obsolete files; remove them + files: '^(\.travis\.yml|\.t2d\.yml|CONTRIBUTING\.md|\.prettierrc\.yml|\.eslintrc\.yml)$' + language: fail - repo: https://github.com/sbidoul/whool - rev: v1.2 + rev: v1.3 hooks: - id: whool-init - repo: https://github.com/oca/maintainer-tools - rev: bf9ecb9938b6a5deca0ff3d870fbd3f33341fded + rev: 31bdbd8e4cb94be6534f0e82b1cafb84a455f671 hooks: # update the NOT INSTALLABLE ADDONS section above - id: oca-update-pre-commit-excluded-addons @@ -95,6 +100,7 @@ repos: additional_dependencies: - "eslint@9.12.0" - "eslint-plugin-jsdoc@50.3.1" + - "globals@16.0.0" - repo: https://github.com/pre-commit/pre-commit-hooks rev: v4.6.0 hooks: diff --git a/.pylintrc b/.pylintrc index 7c62b6d2ed..914fd235d9 100644 --- a/.pylintrc +++ b/.pylintrc @@ -4,6 +4,9 @@ load-plugins=pylint_odoo score=n +[IMPORTS] +deprecated-modules=pdb,pudb,ipdb,bs4 + [ODOOLINT] readme-template-url="https://github.com/OCA/maintainer-tools/blob/master/template/module/README.rst" manifest-required-authors=Odoo Community Association (OCA) @@ -25,19 +28,26 @@ disable=all enable=anomalous-backslash-in-string, api-one-deprecated, api-one-multi-together, - assignment-from-none, - attribute-deprecated, class-camelcase, - dangerous-default-value, dangerous-view-replace-wo-priority, - development-status-allowed, duplicate-id-csv, - duplicate-key, duplicate-xml-fields, duplicate-xml-record-id, eval-referenced, - eval-used, incoherent-interpreter-exec-perm, + openerp-exception-warning, + redundant-modulename-xml, + relative-import, + rst-syntax-error, + wrong-tabs-instead-of-spaces, + xml-syntax-error, + assignment-from-none, + attribute-deprecated, + dangerous-default-value, + deprecated-module, + development-status-allowed, + duplicate-key, + eval-used, license-allowed, manifest-author-string, manifest-deprecated-key, @@ -48,73 +58,68 @@ enable=anomalous-backslash-in-string, method-inverse, method-required-super, method-search, - openerp-exception-warning, pointless-statement, pointless-string-statement, print-used, redundant-keyword-arg, - redundant-modulename-xml, reimported, - relative-import, return-in-init, - rst-syntax-error, sql-injection, too-few-format-args, translation-field, translation-required, unreachable, use-vim-comment, - wrong-tabs-instead-of-spaces, - xml-syntax-error, - attribute-string-redundant, character-not-valid-in-resource-link, - consider-merging-classes-inherited, - context-overridden, create-user-wo-reset-password, dangerous-filter-wo-user, dangerous-qweb-replace-wo-priority, deprecated-data-xml-node, deprecated-openerp-xml-node, duplicate-po-message-definition, - except-pass, file-not-used, + missing-newline-extrafiles, + old-api7-method-defined, + po-msgstr-variables, + po-syntax-error, + str-format-used, + unnecessary-utf8-coding-comment, + xml-attribute-translatable, + xml-deprecated-qweb-directive, + xml-deprecated-tree-attribute, + attribute-string-redundant, + consider-merging-classes-inherited, + context-overridden, + except-pass, invalid-commit, manifest-maintainers-list, - missing-newline-extrafiles, missing-readme, missing-return, odoo-addons-relative-import, - old-api7-method-defined, - po-msgstr-variables, - po-syntax-error, renamed-field-parameter, resource-not-exist, - str-format-used, test-folder-imported, translation-contains-variable, translation-positional-used, - unnecessary-utf8-coding-comment, website-manifest-key-not-valid-uri, - xml-attribute-translatable, - xml-deprecated-qweb-directive, - xml-deprecated-tree-attribute, external-request-timeout, - # messages that do not cause the lint step to fail - consider-merging-classes-inherited, + missing-manifest-dependency, + too-complex, create-user-wo-reset-password, dangerous-filter-wo-user, - deprecated-module, file-not-used, - invalid-commit, - missing-manifest-dependency, missing-newline-extrafiles, - missing-readme, no-utf8-coding-comment, - odoo-addons-relative-import, old-api7-method-defined, + unnecessary-utf8-coding-comment, + # messages that do not cause the lint step to fail + consider-merging-classes-inherited, + deprecated-module, + invalid-commit, + missing-readme, + odoo-addons-relative-import, redefined-builtin, - too-complex, - unnecessary-utf8-coding-comment + manifest-external-assets [REPORTS] diff --git a/.pylintrc-mandatory b/.pylintrc-mandatory index 018fd61cdd..e4fd970b0d 100644 --- a/.pylintrc-mandatory +++ b/.pylintrc-mandatory @@ -3,6 +3,9 @@ load-plugins=pylint_odoo score=n +[IMPORTS] +deprecated-modules=pdb,pudb,ipdb,bs4 + [ODOOLINT] readme-template-url="https://github.com/OCA/maintainer-tools/blob/master/template/module/README.rst" manifest-required-authors=Odoo Community Association (OCA) @@ -17,19 +20,26 @@ disable=all enable=anomalous-backslash-in-string, api-one-deprecated, api-one-multi-together, - assignment-from-none, - attribute-deprecated, class-camelcase, - dangerous-default-value, dangerous-view-replace-wo-priority, - development-status-allowed, duplicate-id-csv, - duplicate-key, duplicate-xml-fields, duplicate-xml-record-id, eval-referenced, - eval-used, incoherent-interpreter-exec-perm, + openerp-exception-warning, + redundant-modulename-xml, + relative-import, + rst-syntax-error, + wrong-tabs-instead-of-spaces, + xml-syntax-error, + assignment-from-none, + attribute-deprecated, + dangerous-default-value, + deprecated-module, + development-status-allowed, + duplicate-key, + eval-used, license-allowed, manifest-author-string, manifest-deprecated-key, @@ -40,56 +50,50 @@ enable=anomalous-backslash-in-string, method-inverse, method-required-super, method-search, - openerp-exception-warning, pointless-statement, pointless-string-statement, print-used, redundant-keyword-arg, - redundant-modulename-xml, reimported, - relative-import, return-in-init, - rst-syntax-error, sql-injection, too-few-format-args, translation-field, translation-required, unreachable, use-vim-comment, - wrong-tabs-instead-of-spaces, - xml-syntax-error, - attribute-string-redundant, character-not-valid-in-resource-link, - consider-merging-classes-inherited, - context-overridden, create-user-wo-reset-password, dangerous-filter-wo-user, dangerous-qweb-replace-wo-priority, deprecated-data-xml-node, deprecated-openerp-xml-node, duplicate-po-message-definition, - except-pass, file-not-used, + missing-newline-extrafiles, + old-api7-method-defined, + po-msgstr-variables, + po-syntax-error, + str-format-used, + unnecessary-utf8-coding-comment, + xml-attribute-translatable, + xml-deprecated-qweb-directive, + xml-deprecated-tree-attribute, + attribute-string-redundant, + consider-merging-classes-inherited, + context-overridden, + except-pass, invalid-commit, manifest-maintainers-list, - missing-newline-extrafiles, missing-readme, missing-return, odoo-addons-relative-import, - old-api7-method-defined, - po-msgstr-variables, - po-syntax-error, renamed-field-parameter, resource-not-exist, - str-format-used, test-folder-imported, translation-contains-variable, translation-positional-used, - unnecessary-utf8-coding-comment, website-manifest-key-not-valid-uri, - xml-attribute-translatable, - xml-deprecated-qweb-directive, - xml-deprecated-tree-attribute, external-request-timeout [REPORTS] diff --git a/README.md b/README.md index fb36f7eddb..f949e56a9a 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,7 @@ +[![Support the OCA](https://odoo-community.org/readme-banner-image)](https://odoo-community.org/get-involved?utm_source=repo-readme) + +# queue [![Runboat](https://img.shields.io/badge/runboat-Try%20me-875A7B.png)](https://runboat.odoo-community.org/builds?repo=OCA/queue&target_branch=18.0) [![Pre-commit Status](https://github.com/OCA/queue/actions/workflows/pre-commit.yml/badge.svg?branch=18.0)](https://github.com/OCA/queue/actions/workflows/pre-commit.yml?query=branch%3A18.0) [![Build Status](https://github.com/OCA/queue/actions/workflows/test.yml/badge.svg?branch=18.0)](https://github.com/OCA/queue/actions/workflows/test.yml?query=branch%3A18.0) @@ -7,8 +10,6 @@ -# queue - queue @@ -22,12 +23,13 @@ Available addons addon | version | maintainers | summary --- | --- | --- | --- [base_import_async](base_import_async/) | 18.0.1.0.0 | | Import CSV files in the background -[queue_job](queue_job/) | 18.0.3.1.1 | guewen sbidoul | Job Queue -[queue_job_batch](queue_job_batch/) | 18.0.1.0.0 | | Job Queue Batch +[queue_job](queue_job/) | 18.0.4.0.0 | guewen sbidoul | Job Queue +[queue_job_batch](queue_job_batch/) | 18.0.1.1.1 | | Job Queue Batch [queue_job_cron](queue_job_cron/) | 18.0.1.1.1 | | Scheduled Actions as Queue Jobs [queue_job_cron_jobrunner](queue_job_cron_jobrunner/) | 18.0.1.0.1 | ivantodorovich | Run jobs without a dedicated JobRunner +[queue_job_profiler](queue_job_profiler/) | 18.0.1.0.1 | simahawk | Job Queue Profiler [queue_job_subscribe](queue_job_subscribe/) | 18.0.1.0.0 | | Control which users are subscribed to queue job notifications -[test_queue_job](test_queue_job/) | 18.0.2.0.5 | sbidoul | Queue Job Tests +[test_queue_job](test_queue_job/) | 18.0.2.1.0 | sbidoul | Queue Job Tests [test_queue_job_batch](test_queue_job_batch/) | 18.0.1.0.0 | | Test Job Queue Batch [//]: # (end addons) diff --git a/checklog-odoo.cfg b/checklog-odoo.cfg index 0b55b7bf66..58d43aa666 100644 --- a/checklog-odoo.cfg +++ b/checklog-odoo.cfg @@ -1,3 +1,5 @@ [checklog-odoo] ignore= WARNING.* 0 failed, 0 error\(s\).* + WARNING .* Killing chrome descendants-or-self .* + WARNING.* Missing widget: res_partner_many2one for field of type many2one.* diff --git a/eslint.config.cjs b/eslint.config.cjs index 0d5731f89a..dd0cbe0aef 100644 --- a/eslint.config.cjs +++ b/eslint.config.cjs @@ -1,3 +1,4 @@ +var globals = require('globals'); jsdoc = require("eslint-plugin-jsdoc"); const config = [{ @@ -16,6 +17,8 @@ const config = [{ openerp: "readonly", owl: "readonly", luxon: "readonly", + QUnit: "readonly", + ...globals.browser, }, ecmaVersion: 2024, @@ -191,7 +194,7 @@ const config = [{ }, }, { - files: ["**/*.esm.js"], + files: ["**/*.esm.js", "**/*test.js"], languageOptions: { ecmaVersion: 2024, diff --git a/queue_job/README.rst b/queue_job/README.rst index cfe02b7d37..f532b5ad4f 100644 --- a/queue_job/README.rst +++ b/queue_job/README.rst @@ -11,7 +11,7 @@ Job Queue !! This file is generated by oca-gen-addon-readme !! !! changes will be overwritten. !! !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! - !! source digest: sha256:a5cdcff42c2f45fbb57753e7693373b08e3497d0bc07212dac0f3ab56217eaf2 + !! source digest: sha256:3b16bf21213fd89fc480b21d848a287bc462883118d3e2e1a4ce7ed72d861bed !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! .. |badge1| image:: https://img.shields.io/badge/maturity-Mature-brightgreen.png @@ -123,22 +123,100 @@ Be sure to have the ``requests`` library. Configuration ============= -- Using environment variables and command line: +There are two ways to configure the job runner: + +Set ``channels`` (or ``ODOO_QUEUE_JOB_CHANNELS``) and every database +shares the same channel tree (we will call it server-wide channels): + +.. code:: ini + + [queue_job] + channels = root:10,root.priority:3,root.slow:1 + +Leave ``channels`` unset and set ``max_capacity`` instead. The job +runner then builds a separate channel tree for each database, based on +the *Job Channels* configured on each database (we will call it +per-database channels). + +.. code:: ini + + [queue_job] + max_capacity = 10 + +``channels`` always has precedence over ``max_capacity``. If +``channels`` is set, the per-database configuration is not used. If +neither ``channels`` nor ``max_capacity`` are set, the default execution +mode is per-database channels with a ``max_capacity`` of 1. + +In the per-database mode, channels are configured from the *Job +Channels* menu (or by XML data, see the Usage) instead of a global +configuration string. + +Alongside ``max_capacity``, a global configuration ``db_max_capacity`` +can be set. It represents the max number of jobs executed at the same +time for a single database (capped by the ``max_capacity`` anyway): + +.. code:: ini + + [queue_job] + max_capacity = 10 + db_max_capacity = 3 # no more than 3 simultaneous jobs per database + +``db_max_capacity`` may be an integer or a pattern such as +``prod_*:20,staging:2,*:5``, where the first match wins. When using a +pattern, unmatched databases can be configured by a global pattern at +the end (``*:n``), otherwise they will use the ``max_capacity``. + +The root channel capacity of a database can still be set independently, +however, will in any case be capped by the global ``max_capacity`` and +``db_max_capacity`` parameters. + +When set to 0, ``max_capacity`` or ``db_max_capacity`` means there is no +jobs executed. + +Editing a channel's capacity, sequential flag, throttle or set it to +pause from the *Job Channels* menu **is applied live on the job +runner**. + +.. note:: + + A new database still needs the jobrunner to be restarted. + +When using the server-wide channels, the configuration is static and +loaded at startup of the jobrunner. + +The execution of channels by the job runner is defined by: + +- ``capacity``: max number of jobs running at once in the channel (``0`` + means no limit of its own, the parent channel and ``max_capacity`` or + ``db_max_capacity`` still apply) +- ``sequential``: jobs run one after the other, and a failed job blocks + the channel (requires a capacity of 1) +- ``throttle``: minimum delay, in seconds, between the start of two jobs +- ``paused``: stop running jobs in this channel and its sub-channels + +**Job Runner Configuration Parameters** + +- Using environment variables: - Adjust environment variables (optional): - - ``ODOO_QUEUE_JOB_CHANNELS=root:4`` or any other channels - configuration. The default is ``root:1`` + - ``ODOO_QUEUE_JOB_CHANNELS=root:4`` or any other channels for + server-wide channels + - ``ODOO_QUEUE_JOB_MAX_CAPACITY=4``, max number of concurrent jobs + (not used if ``ODOO_QUEUE_JOB_CHANNELS`` is set) for per-database + channels + - ``ODOO_QUEUE_JOB_DB_MAX_CAPACITY=2``, max number of concurrent + jobs per DB (not used if ``ODOO_QUEUE_JOB_CHANNELS`` is set) - ``ODOO_QUEUE_JOB_PORT=8069``, default ``--http-port`` - ``ODOO_QUEUE_JOB_SCHEME=https``, default ``http`` - ``ODOO_QUEUE_JOB_HOST=load-balancer``, default ``--http-interface`` or ``localhost`` if unset - ``ODOO_QUEUE_JOB_HTTP_AUTH_USER=jobrunner``, default empty - ``ODOO_QUEUE_JOB_HTTP_AUTH_PASSWORD=s3cr3t``, default empty - - Start Odoo with ``--load=web,queue_job`` and ``--workers`` greater - than 1. [1]_ -- Using the Odoo configuration file: +- Using the Odoo configuration file (set either ``channels``, either + ``max_capacity`` and/or ``db_max_capacity``) .. code:: ini @@ -150,12 +228,26 @@ Configuration (...) [queue_job] channels = root:2 + max_capacity = 8 + db_max_capacity = 3 scheme = https host = load-balancer port = 443 http_auth_user = jobrunner http_auth_password = s3cr3t +- Odoo has to be started with ``queue_job`` as server-wide module, + either using the command line option ``--load=web,queue_job``, either + by setting it in the Odoo configuration file, and ``--workers`` + greater than 1. [1]_ + +.. code:: ini + + [options] + (...) + workers = 6 + server_wide_modules = web,queue_job + - Confirm the runner is starting correctly by checking the odoo log file: @@ -166,14 +258,25 @@ Configuration ...INFO...queue_job.jobrunner.runner: queue job runner ready for db ...INFO...queue_job.jobrunner.runner: database connections ready -- Create jobs (eg using ``base_import_async``) and observe they start - immediately and in parallel. +- Create jobs (you can create test jobs by opening + ``https://yourodoourl/queue_job/create_test_job``) and observe they + start immediately and in parallel. - Tip: to enable debug logging for the queue job, use ``--log-handler=odoo.addons.queue_job:DEBUG`` -- Jobs that remain in ``enqueued`` or ``started`` state (because, for - instance, their worker has been killed) will be automatically - re-queued. +**Migrating from server-wide channels to per-database channels** + +As long as ``channels`` (or ``ODOO_QUEUE_JOB_CHANNELS``) is set, the job +runner keeps using the server-wide channels. + +To move to channels per database: + +1. Configure the channels you need on each database, from the *Job + Channels* menu: capacity, sequential, throttle, pause +2. Once this configuration is done, remove the ``channels`` options and + set ``max_capacity`` (and optionally ``db_max_capacity``) instead (or + their corresponding environment variables) +3. Restart the job runner .. [1] It works with the threaded Odoo server too, although this way of diff --git a/queue_job/__manifest__.py b/queue_job/__manifest__.py index 9463b8cc07..41fb0bc6bf 100644 --- a/queue_job/__manifest__.py +++ b/queue_job/__manifest__.py @@ -2,7 +2,7 @@ { "name": "Job Queue", - "version": "18.0.3.1.1", + "version": "18.0.4.0.0", "author": "Camptocamp,ACSONE SA/NV,Odoo Community Association (OCA)", "website": "https://github.com/OCA/queue", "license": "LGPL-3", diff --git a/queue_job/controllers/main.py b/queue_job/controllers/main.py index 18e257f1c6..91cda8ca3b 100644 --- a/queue_job/controllers/main.py +++ b/queue_job/controllers/main.py @@ -186,6 +186,7 @@ def retry_postpone(job, message, seconds=None): vals = cls._get_failure_values(job, traceback_txt, orig_exception) job.set_failed(**vals) job.store() + job.on_fail(vals) buff.close() raise diff --git a/queue_job/i18n/ca.po b/queue_job/i18n/ca.po index 0aeb7599fb..e70e1798d0 100644 --- a/queue_job/i18n/ca.po +++ b/queue_job/i18n/ca.po @@ -20,7 +20,8 @@ msgstr "" #: model_terms:ir.ui.view,arch_db:queue_job.view_queue_job_form msgid "" "
\n" -" If the max. retries is 0, the number of retries is infinite." +" If the max. " +"retries is 0, the number of retries is infinite." msgstr "" "
\n" " Si el màxim " @@ -65,8 +66,8 @@ msgstr "Permetre Confirmació" #. module: queue_job #: model:ir.model.fields,help:queue_job.field_queue_job_function__allow_commit msgid "" -"Allows the job to commit transactions during execution. Under the hood, this" -" executes the job in a new database cursor, which incurs an overhead as it " +"Allows the job to commit transactions during execution. Under the hood, this " +"executes the job in a new database cursor, which incurs an overhead as it " "requires an extra connection to the database. " msgstr "" "Permet al procés confirmar transaccions durant l'execució. Internament, això " @@ -556,6 +557,12 @@ msgstr "Nom del mètode" msgid "Model" msgstr "Model" +#. module: queue_job +#: model:ir.model.fields,help:queue_job.field_queue_job_function__on_fail_method +msgid "" +"Model function to be called if the job is failed and will not be retried." +msgstr "" + #. module: queue_job #. odoo-python #: code:addons/queue_job/models/queue_job_function.py:0 @@ -620,6 +627,11 @@ msgstr "Nombre de missatges que requereixen alguna acció" msgid "Number of messages with delivery error" msgstr "Nombre de missatges amb error de lliurament" +#. module: queue_job +#: model:ir.model.fields,field_description:queue_job.field_queue_job_function__on_fail_method +msgid "On Fail Method" +msgstr "" + #. module: queue_job #: model:ir.model.fields,field_description:queue_job.field_queue_job_channel__parent_id msgid "Parent Channel" @@ -634,7 +646,10 @@ msgstr "Es requereix un canal pare." #. module: queue_job #: model:ir.model.fields,help:queue_job.field_queue_job_function__edit_retry_pattern msgid "" -"Pattern expressing from the count of retries on retryable errors, the number of of seconds to postpone the next execution. Setting the number of seconds to a 2-element tuple or list will randomize the retry interval between the 2 values.\n" +"Pattern expressing from the count of retries on retryable errors, the number " +"of of seconds to postpone the next execution. Setting the number of seconds " +"to a 2-element tuple or list will randomize the retry interval between the 2 " +"values.\n" "Example: {1: 10, 5: 20, 10: 30, 15: 300}.\n" "Example: {1: (1, 10), 5: (11, 20), 10: (21, 30), 15: (100, 300)}.\n" "See the module description for details." @@ -837,7 +852,9 @@ msgstr "Tasca" #. module: queue_job #: model:ir.model.fields,help:queue_job.field_queue_job_function__edit_related_action msgid "" -"The action when the button *Related Action* is used on a job. The default action is to open the view of the record related to the job. Configured as a dictionary with optional keys: enable, func_name, kwargs.\n" +"The action when the button *Related Action* is used on a job. The default " +"action is to open the view of the record related to the job. Configured as a " +"dictionary with optional keys: enable, func_name, kwargs.\n" "See the module description for details." msgstr "" "L'acció quan es prem el botó *Acció Relacionada* en una tasca. L'acció " @@ -903,12 +920,13 @@ msgstr "UUID" msgid "" "Unexpected format of Related Action for {}.\n" "Example of valid format:\n" -"{{\"enable\": True, \"func_name\": \"related_action_foo\", \"kwargs\" {{\"limit\": 10}}}}" +"{{\"enable\": True, \"func_name\": \"related_action_foo\", \"kwargs\" " +"{{\"limit\": 10}}}}" msgstr "" "Format inesperat d'Acció Relacionada per {}.\n" "Exemples de format vàlid:\n" -"{{\"enable\": True, \"func_name\": \"related_action_foo\", \"kwargs\" {" -"{\"limit\": 10}}}}" +"{{\"enable\": True, \"func_name\": \"related_action_foo\", \"kwargs\" " +"{{\"limit\": 10}}}}" #. module: queue_job #. odoo-python diff --git a/queue_job/i18n/de.po b/queue_job/i18n/de.po index 203a683948..4a37f2f10b 100644 --- a/queue_job/i18n/de.po +++ b/queue_job/i18n/de.po @@ -554,6 +554,12 @@ msgstr "Methodenname" msgid "Model" msgstr "Modell" +#. module: queue_job +#: model:ir.model.fields,help:queue_job.field_queue_job_function__on_fail_method +msgid "" +"Model function to be called if the job is failed and will not be retried." +msgstr "" + #. module: queue_job #. odoo-python #: code:addons/queue_job/models/queue_job_function.py:0 @@ -618,6 +624,11 @@ msgstr "" msgid "Number of messages with delivery error" msgstr "Das ist die Anzahl von Nachrichten mit Übermittlungsfehler" +#. module: queue_job +#: model:ir.model.fields,field_description:queue_job.field_queue_job_function__on_fail_method +msgid "On Fail Method" +msgstr "" + #. module: queue_job #: model:ir.model.fields,field_description:queue_job.field_queue_job_channel__parent_id msgid "Parent Channel" diff --git a/queue_job/i18n/es.po b/queue_job/i18n/es.po index a3191035f4..033cf53dec 100644 --- a/queue_job/i18n/es.po +++ b/queue_job/i18n/es.po @@ -555,6 +555,12 @@ msgstr "Nombre del método" msgid "Model" msgstr "Modelo" +#. module: queue_job +#: model:ir.model.fields,help:queue_job.field_queue_job_function__on_fail_method +msgid "" +"Model function to be called if the job is failed and will not be retried." +msgstr "" + #. module: queue_job #. odoo-python #: code:addons/queue_job/models/queue_job_function.py:0 @@ -619,6 +625,11 @@ msgstr "Número de mensajes que requieren una acción" msgid "Number of messages with delivery error" msgstr "Número de mensajes con error de envío" +#. module: queue_job +#: model:ir.model.fields,field_description:queue_job.field_queue_job_function__on_fail_method +msgid "On Fail Method" +msgstr "" + #. module: queue_job #: model:ir.model.fields,field_description:queue_job.field_queue_job_channel__parent_id msgid "Parent Channel" diff --git a/queue_job/i18n/fr.po b/queue_job/i18n/fr.po new file mode 100644 index 0000000000..6d423fba1a --- /dev/null +++ b/queue_job/i18n/fr.po @@ -0,0 +1,967 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * queue_job +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 18.0\n" +"Report-Msgid-Bugs-To: \n" +"PO-Revision-Date: 2026-06-11 14:32+0000\n" +"Last-Translator: Yann Papouin \n" +"Language-Team: none\n" +"Language: fr\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: \n" +"Plural-Forms: nplurals=2; plural=n > 1;\n" +"X-Generator: Weblate 5.15.2\n" + +#. module: queue_job +#: model_terms:ir.ui.view,arch_db:queue_job.view_queue_job_form +msgid "" +"
\n" +" If the max. " +"retries is 0, the number of retries is infinite." +msgstr "" +"
\n" +" Si le max de " +"tentatives est à 0, le nombre de tentatives sera illimité." + +#. module: queue_job +#. odoo-python +#: code:addons/queue_job/controllers/main.py:0 +msgid "Access Denied" +msgstr "Accès refusé" + +#. module: queue_job +#: model:ir.model.fields,field_description:queue_job.field_queue_job__message_needaction +msgid "Action Needed" +msgstr "Nécessite une action" + +#. module: queue_job +#: model:ir.model.fields,field_description:queue_job.field_queue_job__activity_ids +msgid "Activities" +msgstr "Activités" + +#. module: queue_job +#: model:ir.model.fields,field_description:queue_job.field_queue_job__activity_exception_decoration +msgid "Activity Exception Decoration" +msgstr "Décoration d’activité en exception" + +#. module: queue_job +#: model:ir.model.fields,field_description:queue_job.field_queue_job__activity_state +msgid "Activity State" +msgstr "Statut de l'activité" + +#. module: queue_job +#: model:ir.model.fields,field_description:queue_job.field_queue_job__activity_type_icon +msgid "Activity Type Icon" +msgstr "Icône de type d’activité" + +#. module: queue_job +#: model:ir.model.fields,field_description:queue_job.field_queue_job_function__allow_commit +msgid "Allow Commit" +msgstr "Autoriser « commit »" + +#. module: queue_job +#: model:ir.model.fields,help:queue_job.field_queue_job_function__allow_commit +msgid "" +"Allows the job to commit transactions during execution. Under the hood, this " +"executes the job in a new database cursor, which incurs an overhead as it " +"requires an extra connection to the database. " +msgstr "" +"Permet à la tâche de valider les transactions (commit) pendant son " +"exécution. En arrière-plan, cela exécute la tâche dans un nouveau curseur de " +"base de données, ce qui entraîne une surcharge, car cela nécessite une " +"connexion supplémentaire à la base de données. " + +#. module: queue_job +#: model:ir.model.fields,field_description:queue_job.field_queue_job__args +msgid "Args" +msgstr "Args" + +#. module: queue_job +#: model:ir.model.fields,field_description:queue_job.field_queue_job__message_attachment_count +msgid "Attachment Count" +msgstr "Nombre de pièces jointes" + +#. module: queue_job +#: model:ir.actions.server,name:queue_job.ir_cron_autovacuum_queue_jobs_ir_actions_server +msgid "AutoVacuum Job Queue" +msgstr "Vidange automatique de la file d'attente des tâches" + +#. module: queue_job +#: model:ir.model,name:queue_job.model_base +msgid "Base" +msgstr "Base" + +#. module: queue_job +#: model_terms:ir.ui.view,arch_db:queue_job.view_requeue_job +#: model_terms:ir.ui.view,arch_db:queue_job.view_set_jobs_cancelled +#: model_terms:ir.ui.view,arch_db:queue_job.view_set_jobs_done +msgid "Cancel" +msgstr "Annuler" + +#. module: queue_job +#: model:ir.model,name:queue_job.model_queue_jobs_to_cancelled +msgid "Cancel all selected jobs" +msgstr "Annuler toutes les tâches sélectionnées" + +#. module: queue_job +#: model_terms:ir.ui.view,arch_db:queue_job.view_queue_job_form +msgid "Cancel job" +msgstr "Annuler la tâche" + +#. module: queue_job +#: model:ir.actions.act_window,name:queue_job.action_set_jobs_cancelled +#: model_terms:ir.ui.view,arch_db:queue_job.view_set_jobs_cancelled +msgid "Cancel jobs" +msgstr "Annuler les tâches" + +#. module: queue_job +#: model:ir.model.fields.selection,name:queue_job.selection__queue_job__state__cancelled +#: model_terms:ir.ui.view,arch_db:queue_job.view_queue_job_search +msgid "Cancelled" +msgstr "Annulé" + +#. module: queue_job +#. odoo-python +#: code:addons/queue_job/models/queue_job.py:0 +msgid "Cancelled by {}" +msgstr "Annulé par {}" + +#. module: queue_job +#. odoo-python +#: code:addons/queue_job/models/queue_job_channel.py:0 +msgid "Cannot change the root channel" +msgstr "Impossible de changer le canal racine" + +#. module: queue_job +#. odoo-python +#: code:addons/queue_job/models/queue_job_channel.py:0 +msgid "Cannot remove the root channel" +msgstr "Impossible de supprimer le canal racine" + +#. module: queue_job +#: model:ir.model.fields,field_description:queue_job.field_queue_job__channel +#: model:ir.model.fields,field_description:queue_job.field_queue_job_function__channel_id +#: model_terms:ir.ui.view,arch_db:queue_job.view_queue_job_function_search +#: model_terms:ir.ui.view,arch_db:queue_job.view_queue_job_search +msgid "Channel" +msgstr "Canal" + +#. module: queue_job +#: model:ir.model.constraint,message:queue_job.constraint_queue_job_channel_name_uniq +msgid "Channel complete name must be unique" +msgstr "Le nom complet du canal doit être unique" + +#. module: queue_job +#: model:ir.actions.act_window,name:queue_job.action_queue_job_channel +#: model:ir.ui.menu,name:queue_job.menu_queue_job_channel +#: model_terms:ir.ui.view,arch_db:queue_job.view_queue_job_channel_form +#: model_terms:ir.ui.view,arch_db:queue_job.view_queue_job_channel_search +msgid "Channels" +msgstr "Canaux" + +#. module: queue_job +#: model:ir.model.fields,field_description:queue_job.field_queue_job__company_id +msgid "Company" +msgstr "Société" + +#. module: queue_job +#: model:ir.model.fields,field_description:queue_job.field_queue_job__channel_method_name +msgid "Complete Method Name" +msgstr "Nom complet de la méthode" + +#. module: queue_job +#: model:ir.model.fields,field_description:queue_job.field_queue_job_channel__complete_name +#: model:ir.model.fields,field_description:queue_job.field_queue_job_function__channel +msgid "Complete Name" +msgstr "Nom complet" + +#. module: queue_job +#: model:ir.model.fields,field_description:queue_job.field_queue_job__date_created +msgid "Created Date" +msgstr "Date de création" + +#. module: queue_job +#: model:ir.model.fields,field_description:queue_job.field_queue_job_channel__create_uid +#: model:ir.model.fields,field_description:queue_job.field_queue_job_lock__create_uid +#: model:ir.model.fields,field_description:queue_job.field_queue_jobs_to_cancelled__create_uid +#: model:ir.model.fields,field_description:queue_job.field_queue_jobs_to_done__create_uid +#: model:ir.model.fields,field_description:queue_job.field_queue_requeue_job__create_uid +msgid "Created by" +msgstr "Créé par" + +#. module: queue_job +#: model_terms:ir.ui.view,arch_db:queue_job.view_queue_job_search +msgid "Created date" +msgstr "Date de création" + +#. module: queue_job +#: model:ir.model.fields,field_description:queue_job.field_queue_job_channel__create_date +#: model:ir.model.fields,field_description:queue_job.field_queue_job_lock__create_date +#: model:ir.model.fields,field_description:queue_job.field_queue_jobs_to_cancelled__create_date +#: model:ir.model.fields,field_description:queue_job.field_queue_jobs_to_done__create_date +#: model:ir.model.fields,field_description:queue_job.field_queue_requeue_job__create_date +msgid "Created on" +msgstr "Créé le" + +#. module: queue_job +#: model:ir.model.fields,field_description:queue_job.field_queue_job__retry +msgid "Current try" +msgstr "Tentative courante" + +#. module: queue_job +#: model_terms:ir.ui.view,arch_db:queue_job.view_queue_job_form +msgid "Current try / max. retries" +msgstr "Tentative courante / tentatives max." + +#. module: queue_job +#: model:ir.model.fields,field_description:queue_job.field_queue_job__date_cancelled +msgid "Date Cancelled" +msgstr "Date d'annulation" + +#. module: queue_job +#: model:ir.model.fields,field_description:queue_job.field_queue_job__date_done +msgid "Date Done" +msgstr "Date d'achèvement" + +#. module: queue_job +#: model:ir.model.fields,field_description:queue_job.field_queue_job__dependencies +#: model_terms:ir.ui.view,arch_db:queue_job.view_queue_job_form +msgid "Dependencies" +msgstr "Dépendances" + +#. module: queue_job +#: model:ir.model.fields,field_description:queue_job.field_queue_job__dependency_graph +msgid "Dependency Graph" +msgstr "Graphe de dépendance" + +#. module: queue_job +#: model:ir.model.fields,field_description:queue_job.field_queue_job__name +msgid "Description" +msgstr "Description" + +#. module: queue_job +#: model:ir.model.fields,field_description:queue_job.field_queue_job__display_name +#: model:ir.model.fields,field_description:queue_job.field_queue_job_channel__display_name +#: model:ir.model.fields,field_description:queue_job.field_queue_job_function__display_name +#: model:ir.model.fields,field_description:queue_job.field_queue_job_lock__display_name +#: model:ir.model.fields,field_description:queue_job.field_queue_jobs_to_cancelled__display_name +#: model:ir.model.fields,field_description:queue_job.field_queue_jobs_to_done__display_name +#: model:ir.model.fields,field_description:queue_job.field_queue_requeue_job__display_name +msgid "Display Name" +msgstr "Intitulé" + +#. module: queue_job +#: model:ir.model.fields.selection,name:queue_job.selection__queue_job__state__done +#: model_terms:ir.ui.view,arch_db:queue_job.view_queue_job_search +msgid "Done" +msgstr "Terminé" + +#. module: queue_job +#: model:ir.model.fields,field_description:queue_job.field_queue_job__date_enqueued +msgid "Enqueue Time" +msgstr "Date de mise en file d'attente" + +#. module: queue_job +#: model:ir.model.fields.selection,name:queue_job.selection__queue_job__state__enqueued +#: model_terms:ir.ui.view,arch_db:queue_job.view_queue_job_search +msgid "Enqueued" +msgstr "Mis en file d'attente" + +#. module: queue_job +#: model:ir.model.fields,field_description:queue_job.field_queue_job__exc_name +#: model_terms:ir.ui.view,arch_db:queue_job.view_queue_job_search +msgid "Exception" +msgstr "Exception" + +#. module: queue_job +#: model:ir.model.fields,field_description:queue_job.field_queue_job__exc_info +msgid "Exception Info" +msgstr "Informations sur l'exception" + +#. module: queue_job +#: model_terms:ir.ui.view,arch_db:queue_job.view_queue_job_form +msgid "Exception Information" +msgstr "Informations sur l'exception" + +#. module: queue_job +#: model:ir.model.fields,field_description:queue_job.field_queue_job__exc_message +msgid "Exception Message" +msgstr "Message d’exception" + +#. module: queue_job +#: model_terms:ir.ui.view,arch_db:queue_job.view_queue_job_search +msgid "Exception message" +msgstr "Message d’exception" + +#. module: queue_job +#: model_terms:ir.ui.view,arch_db:queue_job.view_queue_job_form +msgid "Exception:" +msgstr "Exception :" + +#. module: queue_job +#: model:ir.model.fields,field_description:queue_job.field_queue_job__eta +msgid "Execute only after" +msgstr "Exécuter seulement après" + +#. module: queue_job +#: model:ir.model.fields,field_description:queue_job.field_queue_job__exec_time +msgid "Execution Time (avg)" +msgstr "Temps d’exécution (moyenne)" + +#. module: queue_job +#: model:ir.model.fields.selection,name:queue_job.selection__queue_job__state__failed +#: model_terms:ir.ui.view,arch_db:queue_job.view_queue_job_search +msgid "Failed" +msgstr "Echoué" + +#. module: queue_job +#: model:ir.model.fields,field_description:queue_job.field_ir_model_fields__ttype +msgid "Field Type" +msgstr "Type du champ" + +#. module: queue_job +#: model:ir.model,name:queue_job.model_ir_model_fields +msgid "Fields" +msgstr "Champs" + +#. module: queue_job +#: model:ir.model.fields,field_description:queue_job.field_queue_job__message_follower_ids +msgid "Followers" +msgstr "Abonnés" + +#. module: queue_job +#: model:ir.model.fields,field_description:queue_job.field_queue_job__message_partner_ids +msgid "Followers (Partners)" +msgstr "Abonnés (Partenaires)" + +#. module: queue_job +#: model:ir.model.fields,help:queue_job.field_queue_job__activity_type_icon +msgid "Font awesome icon e.g. fa-tasks" +msgstr "Icône Font Awesome ex : fa-tasks" + +#. module: queue_job +#: model_terms:ir.ui.view,arch_db:queue_job.view_queue_job_search +msgid "Graph" +msgstr "Graphe" + +#. module: queue_job +#: model_terms:ir.ui.view,arch_db:queue_job.view_queue_job_form +msgid "Graph Jobs" +msgstr "Tâches du graphe" + +#. module: queue_job +#: model:ir.model.fields,field_description:queue_job.field_queue_job__graph_jobs_count +msgid "Graph Jobs Count" +msgstr "Nombre de tâches du graphe" + +#. module: queue_job +#: model:ir.model.fields,field_description:queue_job.field_queue_job__graph_uuid +msgid "Graph UUID" +msgstr "Graphe UUID" + +#. module: queue_job +#: model_terms:ir.ui.view,arch_db:queue_job.view_queue_job_function_search +#: model_terms:ir.ui.view,arch_db:queue_job.view_queue_job_search +msgid "Group By" +msgstr "Regrouper par" + +#. module: queue_job +#: model:ir.model.fields,field_description:queue_job.field_queue_job__has_message +msgid "Has Message" +msgstr "A un message" + +#. module: queue_job +#: model:ir.model.fields,field_description:queue_job.field_queue_job__id +#: model:ir.model.fields,field_description:queue_job.field_queue_job_channel__id +#: model:ir.model.fields,field_description:queue_job.field_queue_job_function__id +#: model:ir.model.fields,field_description:queue_job.field_queue_job_lock__id +#: model:ir.model.fields,field_description:queue_job.field_queue_jobs_to_cancelled__id +#: model:ir.model.fields,field_description:queue_job.field_queue_jobs_to_done__id +#: model:ir.model.fields,field_description:queue_job.field_queue_requeue_job__id +msgid "ID" +msgstr "ID" + +#. module: queue_job +#: model:ir.model.fields,field_description:queue_job.field_queue_job__activity_exception_icon +msgid "Icon" +msgstr "Icône" + +#. module: queue_job +#: model:ir.model.fields,help:queue_job.field_queue_job__activity_exception_icon +msgid "Icon to indicate an exception activity." +msgstr "Icône pour indiquer une activité d'exception." + +#. module: queue_job +#: model:ir.model.fields,field_description:queue_job.field_queue_job__identity_key +msgid "Identity Key" +msgstr "Clé d'identification" + +#. module: queue_job +#: model:ir.model.fields,help:queue_job.field_queue_job__message_needaction +msgid "If checked, new messages require your attention." +msgstr "Si coché, de nouveaux messages requièrent votre attention." + +#. module: queue_job +#: model:ir.model.fields,help:queue_job.field_queue_job__message_has_error +msgid "If checked, some messages have a delivery error." +msgstr "Si coché, certains messages ont une erreur de livraison." + +#. module: queue_job +#. odoo-python +#: code:addons/queue_job/models/queue_job_function.py:0 +msgid "Invalid job function: {}" +msgstr "Fonction de tâche invalide : {}" + +#. module: queue_job +#: model:ir.model.fields,field_description:queue_job.field_queue_job__message_is_follower +msgid "Is Follower" +msgstr "Est abonné" + +#. module: queue_job +#: model:ir.model,name:queue_job.model_queue_job_channel +msgid "Job Channels" +msgstr "Canaux de tâches" + +#. module: queue_job +#: model:ir.model.fields,field_description:queue_job.field_queue_job__job_function_id +#: model_terms:ir.ui.view,arch_db:queue_job.view_queue_job_search +msgid "Job Function" +msgstr "Fonction de tâche" + +#. module: queue_job +#: model:ir.actions.act_window,name:queue_job.action_queue_job_function +#: model:ir.model,name:queue_job.model_queue_job_function +#: model:ir.model.fields,field_description:queue_job.field_queue_job_channel__job_function_ids +#: model:ir.ui.menu,name:queue_job.menu_queue_job_function +#: model_terms:ir.ui.view,arch_db:queue_job.view_queue_job_function_form +#: model_terms:ir.ui.view,arch_db:queue_job.view_queue_job_function_search +msgid "Job Functions" +msgstr "Fonctions de tâche" + +#. module: queue_job +#: model:ir.module.category,name:queue_job.module_category_queue_job +#: model:ir.ui.menu,name:queue_job.menu_queue_job_root +msgid "Job Queue" +msgstr "File d'attente des tâches" + +#. module: queue_job +#: model:res.groups,name:queue_job.group_queue_job_manager +msgid "Job Queue Manager" +msgstr "Gestionnaire de file d'attente des tâches" + +#. module: queue_job +#: model:ir.model.fields.selection,name:queue_job.selection__ir_model_fields__ttype__job_serialized +msgid "Job Serialized" +msgstr "Sérialisation de tâche" + +#. module: queue_job +#: model:mail.message.subtype,name:queue_job.mt_job_failed +msgid "Job failed" +msgstr "Tâche échouée" + +#. module: queue_job +#: model:ir.actions.act_window,name:queue_job.action_queue_job +#: model:ir.model.fields,field_description:queue_job.field_queue_jobs_to_cancelled__job_ids +#: model:ir.model.fields,field_description:queue_job.field_queue_jobs_to_done__job_ids +#: model:ir.model.fields,field_description:queue_job.field_queue_requeue_job__job_ids +#: model:ir.ui.menu,name:queue_job.menu_queue_job +#: model_terms:ir.ui.view,arch_db:queue_job.view_queue_job_form +#: model_terms:ir.ui.view,arch_db:queue_job.view_queue_job_graph +#: model_terms:ir.ui.view,arch_db:queue_job.view_queue_job_pivot +#: model_terms:ir.ui.view,arch_db:queue_job.view_queue_job_search +msgid "Jobs" +msgstr "Tâches" + +#. module: queue_job +#. odoo-python +#: code:addons/queue_job/models/queue_job.py:0 +msgid "Jobs for graph %s" +msgstr "Tâches du graphe %s" + +#. module: queue_job +#: model:ir.model.fields,field_description:queue_job.field_queue_job__kwargs +msgid "Kwargs" +msgstr "Kwargs" + +#. module: queue_job +#: model_terms:ir.ui.view,arch_db:queue_job.view_queue_job_search +msgid "Last 24 hours" +msgstr "Dernières 24 heures" + +#. module: queue_job +#: model_terms:ir.ui.view,arch_db:queue_job.view_queue_job_search +msgid "Last 30 days" +msgstr "Les 30 derniers jours" + +#. module: queue_job +#: model_terms:ir.ui.view,arch_db:queue_job.view_queue_job_search +msgid "Last 7 days" +msgstr "Les 7 derniers jours" + +#. module: queue_job +#: model:ir.model.fields,field_description:queue_job.field_queue_job_channel__write_uid +#: model:ir.model.fields,field_description:queue_job.field_queue_job_lock__write_uid +#: model:ir.model.fields,field_description:queue_job.field_queue_jobs_to_cancelled__write_uid +#: model:ir.model.fields,field_description:queue_job.field_queue_jobs_to_done__write_uid +#: model:ir.model.fields,field_description:queue_job.field_queue_requeue_job__write_uid +msgid "Last Updated by" +msgstr "Modifié par" + +#. module: queue_job +#: model:ir.model.fields,field_description:queue_job.field_queue_job_channel__write_date +#: model:ir.model.fields,field_description:queue_job.field_queue_job_lock__write_date +#: model:ir.model.fields,field_description:queue_job.field_queue_jobs_to_cancelled__write_date +#: model:ir.model.fields,field_description:queue_job.field_queue_jobs_to_done__write_date +#: model:ir.model.fields,field_description:queue_job.field_queue_requeue_job__write_date +msgid "Last Updated on" +msgstr "Modifié le" + +#. module: queue_job +#. odoo-python +#: code:addons/queue_job/models/queue_job.py:0 +msgid "Manually set to done by {}" +msgstr "Défini manuellement comme terminé par {}" + +#. module: queue_job +#: model:ir.model.fields,field_description:queue_job.field_queue_job__max_retries +msgid "Max. retries" +msgstr "Nombre max. de tentatives" + +#. module: queue_job +#: model:ir.model.fields,field_description:queue_job.field_queue_job__message_has_error +msgid "Message Delivery error" +msgstr "Erreur de livraison du message" + +#. module: queue_job +#: model:ir.model.fields,field_description:queue_job.field_queue_job__message_ids +msgid "Messages" +msgstr "Messages" + +#. module: queue_job +#: model:ir.model.fields,field_description:queue_job.field_queue_job_function__method +msgid "Method" +msgstr "Méthode" + +#. module: queue_job +#: model:ir.model.fields,field_description:queue_job.field_queue_job__method_name +msgid "Method Name" +msgstr "Nom de méthode" + +#. module: queue_job +#: model:ir.model.fields,field_description:queue_job.field_queue_job__model_name +#: model:ir.model.fields,field_description:queue_job.field_queue_job_function__model_id +#: model_terms:ir.ui.view,arch_db:queue_job.view_queue_job_search +msgid "Model" +msgstr "Modèle" + +#. module: queue_job +#: model:ir.model.fields,help:queue_job.field_queue_job_function__on_fail_method +msgid "" +"Model function to be called if the job is failed and will not be retried." +msgstr "" + +#. module: queue_job +#. odoo-python +#: code:addons/queue_job/models/queue_job_function.py:0 +msgid "Model {} not found" +msgstr "Modèle {} non trouvé" + +#. module: queue_job +#: model:ir.model.fields,field_description:queue_job.field_queue_job__my_activity_date_deadline +msgid "My Activity Deadline" +msgstr "Mon échéance d'activité" + +#. module: queue_job +#: model:ir.model.fields,field_description:queue_job.field_queue_job_channel__name +#: model:ir.model.fields,field_description:queue_job.field_queue_job_function__name +msgid "Name" +msgstr "Nom" + +#. module: queue_job +#: model:ir.model.fields,field_description:queue_job.field_queue_job__activity_date_deadline +msgid "Next Activity Deadline" +msgstr "Échéance de la prochaine activité" + +#. module: queue_job +#: model:ir.model.fields,field_description:queue_job.field_queue_job__activity_summary +msgid "Next Activity Summary" +msgstr "Résumé de la prochaine activité" + +#. module: queue_job +#: model:ir.model.fields,field_description:queue_job.field_queue_job__activity_type_id +msgid "Next Activity Type" +msgstr "Type de la prochaine activité" + +#. module: queue_job +#. odoo-python +#: code:addons/queue_job/models/queue_job.py:0 +msgid "No action available for this job" +msgstr "Aucune action disponible pour cette tâche" + +#. module: queue_job +#. odoo-python +#: code:addons/queue_job/models/queue_job.py:0 +msgid "Not allowed to change field(s): {}" +msgstr "Non autorisé à changer le ou les champs : {}" + +#. module: queue_job +#: model:ir.model.fields,field_description:queue_job.field_queue_job__message_needaction_counter +msgid "Number of Actions" +msgstr "Nombre d'actions" + +#. module: queue_job +#: model:ir.model.fields,field_description:queue_job.field_queue_job__message_has_error_counter +msgid "Number of errors" +msgstr "Nombre d'erreurs" + +#. module: queue_job +#: model:ir.model.fields,help:queue_job.field_queue_job__message_needaction_counter +msgid "Number of messages requiring action" +msgstr "Nombre de messages nécessitant une action" + +#. module: queue_job +#: model:ir.model.fields,help:queue_job.field_queue_job__message_has_error_counter +msgid "Number of messages with delivery error" +msgstr "Nombre de messages avec erreur de livraison" + +#. module: queue_job +#: model:ir.model.fields,field_description:queue_job.field_queue_job_function__on_fail_method +msgid "On Fail Method" +msgstr "" + +#. module: queue_job +#: model:ir.model.fields,field_description:queue_job.field_queue_job_channel__parent_id +msgid "Parent Channel" +msgstr "Canal parent" + +#. module: queue_job +#. odoo-python +#: code:addons/queue_job/models/queue_job_channel.py:0 +msgid "Parent channel required." +msgstr "Canal parent requis." + +#. module: queue_job +#: model:ir.model.fields,help:queue_job.field_queue_job_function__edit_retry_pattern +msgid "" +"Pattern expressing from the count of retries on retryable errors, the number " +"of of seconds to postpone the next execution. Setting the number of seconds " +"to a 2-element tuple or list will randomize the retry interval between the 2 " +"values.\n" +"Example: {1: 10, 5: 20, 10: 30, 15: 300}.\n" +"Example: {1: (1, 10), 5: (11, 20), 10: (21, 30), 15: (100, 300)}.\n" +"See the module description for details." +msgstr "" +"Modèle permettant de définir, à partir du nombre de tentatives pour les " +"erreurs pouvant faire l'objet d'une nouvelle tentative, le nombre de " +"secondes pendant lesquelles l'exécution suivante sera reportée. En " +"définissant ce nombre de secondes sous la forme d'un tuple ou d'une liste à " +"deux éléments, l'intervalle entre les tentatives sera aléatoire et compris " +"entre ces deux valeurs.\n" +"Exemple : {1 : 10, 5 : 20, 10 : 30, 15 : 300}.\n" +"Exemple : {1 : (1, 10), 5 : (11, 20), 10 : (21, 30), 15 : (100, 300)}.\n" +"Voir la description du module pour plus de détails." + +#. module: queue_job +#: model:ir.model.fields.selection,name:queue_job.selection__queue_job__state__pending +#: model_terms:ir.ui.view,arch_db:queue_job.view_queue_job_search +msgid "Pending" +msgstr "En attente" + +#. module: queue_job +#: model:ir.model.fields,field_description:queue_job.field_queue_job__priority +#: model_terms:ir.ui.view,arch_db:queue_job.view_queue_job_search +msgid "Priority" +msgstr "Priorité" + +#. module: queue_job +#: model:ir.ui.menu,name:queue_job.menu_queue +msgid "Queue" +msgstr "File d'attente" + +#. module: queue_job +#: model:ir.model,name:queue_job.model_queue_job +#: model:ir.model.fields,field_description:queue_job.field_queue_job_lock__queue_job_id +msgid "Queue Job" +msgstr "File d'attente des tâches" + +#. module: queue_job +#: model:ir.model,name:queue_job.model_queue_job_lock +msgid "Queue Job Lock" +msgstr "Verrour de la file d'attente des tâches" + +#. module: queue_job +#: model:ir.model.fields,field_description:queue_job.field_queue_job__records +msgid "Record(s)" +msgstr "Enregistrement(s)" + +#. module: queue_job +#: model_terms:ir.ui.view,arch_db:queue_job.view_queue_job_form +msgid "Related" +msgstr "En relation" + +#. module: queue_job +#: model:ir.model.fields,field_description:queue_job.field_queue_job_function__edit_related_action +msgid "Related Action" +msgstr "Action rattachée" + +#. module: queue_job +#: model:ir.model.fields,field_description:queue_job.field_queue_job_function__related_action +msgid "Related Action (serialized)" +msgstr "Action rattaché (sérialisé)" + +#. module: queue_job +#. odoo-python +#: code:addons/queue_job/models/queue_job.py:0 +msgid "Related Record" +msgstr "Enregistrement lié" + +#. module: queue_job +#. odoo-python +#: code:addons/queue_job/models/queue_job.py:0 +msgid "Related Records" +msgstr "Enregistrements liés" + +#. module: queue_job +#: model_terms:ir.ui.view,arch_db:queue_job.view_queue_job_tree +msgid "Remaining days to execute" +msgstr "Jours restants avant l'exécution" + +#. module: queue_job +#: model:ir.model.fields,field_description:queue_job.field_queue_job_channel__removal_interval +msgid "Removal Interval" +msgstr "Intervalle de suppression" + +#. module: queue_job +#: model_terms:ir.ui.view,arch_db:queue_job.view_requeue_job +msgid "Requeue" +msgstr "Remise en file d'attente" + +#. module: queue_job +#: model_terms:ir.ui.view,arch_db:queue_job.view_queue_job_form +msgid "Requeue Job" +msgstr "Remettre la tâche en file d'attente" + +#. module: queue_job +#: model:ir.actions.act_window,name:queue_job.action_requeue_job +#: model_terms:ir.ui.view,arch_db:queue_job.view_requeue_job +msgid "Requeue Jobs" +msgstr "Remettre les tâches en file d'attente" + +#. module: queue_job +#: model:ir.model.fields,field_description:queue_job.field_queue_job__activity_user_id +msgid "Responsible User" +msgstr "Responsable" + +#. module: queue_job +#: model:ir.model.fields,field_description:queue_job.field_queue_job__result +#: model_terms:ir.ui.view,arch_db:queue_job.view_queue_job_form +msgid "Result" +msgstr "Résultat" + +#. module: queue_job +#: model_terms:ir.ui.view,arch_db:queue_job.view_queue_job_form +msgid "Results" +msgstr "Résultats" + +#. module: queue_job +#: model:ir.model.fields,field_description:queue_job.field_queue_job_function__edit_retry_pattern +msgid "Retry Pattern" +msgstr "Modèle des tentatives" + +#. module: queue_job +#: model:ir.model.fields,field_description:queue_job.field_queue_job_function__retry_pattern +msgid "Retry Pattern (serialized)" +msgstr "Modèle des tentatives (sérialisé)" + +#. module: queue_job +#: model:ir.model,name:queue_job.model_queue_jobs_to_done +msgid "Set all selected jobs to done" +msgstr "Définir toutes les tâches sélectionnées comme terminées" + +#. module: queue_job +#: model_terms:ir.ui.view,arch_db:queue_job.view_set_jobs_done +msgid "Set jobs done" +msgstr "Définir les tâches comme terminées" + +#. module: queue_job +#: model:ir.actions.act_window,name:queue_job.action_set_jobs_done +msgid "Set jobs to done" +msgstr "Définir les tâches comme terminées" + +#. module: queue_job +#: model_terms:ir.ui.view,arch_db:queue_job.view_queue_job_form +msgid "Set to 'Done'" +msgstr "Mettre en \"Terminé\"" + +#. module: queue_job +#: model_terms:ir.ui.view,arch_db:queue_job.view_set_jobs_done +msgid "Set to done" +msgstr "Définir comme terminé" + +#. module: queue_job +#: model:ir.model.fields,help:queue_job.field_queue_job__graph_uuid +msgid "Single shared identifier of a Graph. Empty for a single job." +msgstr "Identifiant unique et partagé d'un graphe. Vide pour une tâche unique." + +#. module: queue_job +#. odoo-python +#: code:addons/queue_job/models/queue_job.py:0 +msgid "" +"Something bad happened during the execution of the job. More details in the " +"'Exception Information' section." +msgstr "" +"Quelque chose s'est mal passé pendant l'exécution de la tâche. Plus de " +"détails dans la section \"Information sur l'exception\"." + +#. module: queue_job +#: model:ir.model.fields,field_description:queue_job.field_queue_job__date_started +msgid "Start Date" +msgstr "Date de début" + +#. module: queue_job +#: model:ir.model.fields.selection,name:queue_job.selection__queue_job__state__started +#: model_terms:ir.ui.view,arch_db:queue_job.view_queue_job_search +msgid "Started" +msgstr "Commencé" + +#. module: queue_job +#: model:ir.model.fields,field_description:queue_job.field_queue_job__state +#: model_terms:ir.ui.view,arch_db:queue_job.view_queue_job_search +msgid "State" +msgstr "État" + +#. module: queue_job +#: model:ir.model.fields,help:queue_job.field_queue_job__activity_state +msgid "" +"Status based on activities\n" +"Overdue: Due date is already passed\n" +"Today: Activity date is today\n" +"Planned: Future activities." +msgstr "" +"Statut basé sur les activités\n" +"En retard : la date d'échéance est déjà dépassée\n" +"Aujourd'hui : la date d'activité est aujourd'hui\n" +"Planifiée : activités futures." + +#. module: queue_job +#: model:ir.model.fields,field_description:queue_job.field_queue_job__func_string +msgid "Task" +msgstr "Tâche" + +#. module: queue_job +#: model:ir.model.fields,help:queue_job.field_queue_job_function__edit_related_action +msgid "" +"The action when the button *Related Action* is used on a job. The default " +"action is to open the view of the record related to the job. Configured as a " +"dictionary with optional keys: enable, func_name, kwargs.\n" +"See the module description for details." +msgstr "" +"L’action lorsque le bouton « Action rattachée » est utilisé dans une tâche. " +"L’action par défaut est d’ouvrir une vue de l’enregistrement rattaché à la " +"tâche. Configuré avec un dictionnaire avec les clés optionnelles : enable, " +"func_name, kwargs.\n" +"Voir la description du module pour plus de détails." + +#. module: queue_job +#: model:ir.model.fields,help:queue_job.field_queue_job__max_retries +msgid "" +"The job will fail if the number of tries reach the max. retries.\n" +"Retries are infinite when empty." +msgstr "" +"La tâche échouera si le nombre d'essai atteint le max. de tentatives.\n" +"Quand vide, le nombre de tentatives est illimité." + +#. module: queue_job +#: model_terms:ir.ui.view,arch_db:queue_job.view_set_jobs_cancelled +msgid "The selected jobs will be cancelled." +msgstr "Les tâches sélectionnées seront définies comme annulée." + +#. module: queue_job +#: model_terms:ir.ui.view,arch_db:queue_job.view_requeue_job +msgid "The selected jobs will be requeued." +msgstr "Les tâches sélectionnées seront remises en file d'attente." + +#. module: queue_job +#: model_terms:ir.ui.view,arch_db:queue_job.view_set_jobs_done +msgid "The selected jobs will be set to done." +msgstr "Les tâches sélectionnées seront définies comme terminées." + +#. module: queue_job +#: model_terms:ir.ui.view,arch_db:queue_job.view_queue_job_form +msgid "Time (s)" +msgstr "Heure(s)" + +#. module: queue_job +#: model:ir.model.fields,help:queue_job.field_queue_job__exec_time +msgid "Time required to execute this job in seconds. Average when grouped." +msgstr "" +"Temps requis pour exécuter cette tâche en seconde. Moyenne lors des " +"regroupements." + +#. module: queue_job +#: model_terms:ir.ui.view,arch_db:queue_job.view_queue_job_search +msgid "Tried many times" +msgstr "Tentée plusieurs fois" + +#. module: queue_job +#: model:ir.model.fields,help:queue_job.field_queue_job__activity_exception_decoration +msgid "Type of the exception activity on record." +msgstr "Type d'activité d'exception enregistrée." + +#. module: queue_job +#: model:ir.model.fields,field_description:queue_job.field_queue_job__uuid +msgid "UUID" +msgstr "UUID" + +#. module: queue_job +#. odoo-python +#: code:addons/queue_job/models/queue_job_function.py:0 +msgid "" +"Unexpected format of Related Action for {}.\n" +"Example of valid format:\n" +"{{\"enable\": True, \"func_name\": \"related_action_foo\", \"kwargs\" " +"{{\"limit\": 10}}}}" +msgstr "" +"Format inattendu pour l’action rattachée de {}.\n" +"Exemple de format valide :\n" +"{{\"enable\" : True, \"func_name\" : \"related_action_foo\", \"kwargs\" " +"{{\"limit\" : 10}}}}" + +#. module: queue_job +#. odoo-python +#: code:addons/queue_job/models/queue_job_function.py:0 +msgid "" +"Unexpected format of Retry Pattern for {}.\n" +"Example of valid formats:\n" +"{{1: 300, 5: 600, 10: 1200, 15: 3000}}\n" +"{{1: (1, 10), 5: (11, 20), 10: (21, 30), 15: (100, 300)}}" +msgstr "" +"Format inattendu du modèle de nouvelle tentative pour {}.\n" +"Exemples de formats valides :\n" +"{{1 : 300, 5 : 600, 10 : 1200, 15 : 3000}}\n" +"{{1 : (1, 10), 5 : (11, 20), 10 : (21, 30), 15 : (100, 300)}}" + +#. module: queue_job +#: model:ir.model.fields,field_description:queue_job.field_queue_job__user_id +msgid "User ID" +msgstr "ID utilisateur" + +#. module: queue_job +#: model:ir.model.fields.selection,name:queue_job.selection__queue_job__state__wait_dependencies +#: model_terms:ir.ui.view,arch_db:queue_job.view_queue_job_search +msgid "Wait Dependencies" +msgstr "En attente des dépendances" + +#. module: queue_job +#: model:ir.model,name:queue_job.model_queue_requeue_job +msgid "Wizard to requeue a selection of jobs" +msgstr "Assistant pour mettre en file d'attente une sélection de tâches" + +#. module: queue_job +#: model:ir.model.fields,field_description:queue_job.field_queue_job__worker_pid +msgid "Worker Pid" +msgstr "Identifiant du processus de travail" diff --git a/queue_job/i18n/it.po b/queue_job/i18n/it.po index 033279cb4b..1092872bc2 100644 --- a/queue_job/i18n/it.po +++ b/queue_job/i18n/it.po @@ -6,7 +6,7 @@ msgid "" msgstr "" "Project-Id-Version: Odoo Server 17.0\n" "Report-Msgid-Bugs-To: \n" -"PO-Revision-Date: 2026-03-24 11:45+0000\n" +"PO-Revision-Date: 2026-09-16 09:46+0000\n" "Last-Translator: mymage \n" "Language-Team: none\n" "Language: it\n" @@ -558,6 +558,13 @@ msgstr "Nome metodo" msgid "Model" msgstr "Modello" +#. module: queue_job +#: model:ir.model.fields,help:queue_job.field_queue_job_function__on_fail_method +msgid "" +"Model function to be called if the job is failed and will not be retried." +msgstr "" +"Modello funziona da chiamare se il lavoro fallisce e non verrà ricevuto." + #. module: queue_job #. odoo-python #: code:addons/queue_job/models/queue_job_function.py:0 @@ -622,6 +629,11 @@ msgstr "Numero di messaggi che richiedono un'azione" msgid "Number of messages with delivery error" msgstr "Numero di messaggi con errore di consegna" +#. module: queue_job +#: model:ir.model.fields,field_description:queue_job.field_queue_job_function__on_fail_method +msgid "On Fail Method" +msgstr "Metodo al fallimento" + #. module: queue_job #: model:ir.model.fields,field_description:queue_job.field_queue_job_channel__parent_id msgid "Parent Channel" diff --git a/queue_job/i18n/nl.po b/queue_job/i18n/nl.po index 4fec32f618..bcb7b6f6c6 100644 --- a/queue_job/i18n/nl.po +++ b/queue_job/i18n/nl.po @@ -549,6 +549,12 @@ msgstr "" msgid "Model" msgstr "" +#. module: queue_job +#: model:ir.model.fields,help:queue_job.field_queue_job_function__on_fail_method +msgid "" +"Model function to be called if the job is failed and will not be retried." +msgstr "" + #. module: queue_job #. odoo-python #: code:addons/queue_job/models/queue_job_function.py:0 @@ -613,6 +619,11 @@ msgstr "" msgid "Number of messages with delivery error" msgstr "" +#. module: queue_job +#: model:ir.model.fields,field_description:queue_job.field_queue_job_function__on_fail_method +msgid "On Fail Method" +msgstr "" + #. module: queue_job #: model:ir.model.fields,field_description:queue_job.field_queue_job_channel__parent_id msgid "Parent Channel" diff --git a/queue_job/i18n/nl_NL.po b/queue_job/i18n/nl_NL.po index 9e4ce8d5ae..ab0046aca2 100644 --- a/queue_job/i18n/nl_NL.po +++ b/queue_job/i18n/nl_NL.po @@ -551,6 +551,12 @@ msgstr "Methodenaam" msgid "Model" msgstr "Model" +#. module: queue_job +#: model:ir.model.fields,help:queue_job.field_queue_job_function__on_fail_method +msgid "" +"Model function to be called if the job is failed and will not be retried." +msgstr "" + #. module: queue_job #. odoo-python #: code:addons/queue_job/models/queue_job_function.py:0 @@ -615,6 +621,11 @@ msgstr "Aantal berichten dat een actie vereist" msgid "Number of messages with delivery error" msgstr "Aantal berichten met een afleverfout" +#. module: queue_job +#: model:ir.model.fields,field_description:queue_job.field_queue_job_function__on_fail_method +msgid "On Fail Method" +msgstr "" + #. module: queue_job #: model:ir.model.fields,field_description:queue_job.field_queue_job_channel__parent_id msgid "Parent Channel" diff --git a/queue_job/i18n/queue_job.pot b/queue_job/i18n/queue_job.pot index ce552734bf..7ec6e49b02 100644 --- a/queue_job/i18n/queue_job.pot +++ b/queue_job/i18n/queue_job.pot @@ -22,6 +22,25 @@ msgid "" " If the max. retries is 0, the number of retries is infinite." msgstr "" +#. module: queue_job +#. odoo-python +#: code:addons/queue_job/models/queue_job_channel.py:0 +msgid "A channel with a sequential default must have a default capacity of 1." +msgstr "" + +#. module: queue_job +#: model:ir.model.fields,help:queue_job.field_queue_job_channel__paused +msgid "" +"A paused channel (an its sub-channels) do not execute any jobs until " +"resumed." +msgstr "" + +#. module: queue_job +#. odoo-python +#: code:addons/queue_job/models/queue_job_channel.py:0 +msgid "A sequential channel must have a capacity of 1." +msgstr "" + #. module: queue_job #. odoo-python #: code:addons/queue_job/controllers/main.py:0 @@ -133,6 +152,16 @@ msgstr "" msgid "Cannot remove the root channel" msgstr "" +#. module: queue_job +#: model:ir.model.fields,field_description:queue_job.field_queue_job_channel__capacity +msgid "Capacity" +msgstr "" + +#. module: queue_job +#: model:ir.model.fields,field_description:queue_job.field_queue_job_channel__capacity_default +msgid "Capacity Default" +msgstr "" + #. module: queue_job #: model:ir.model.fields,field_description:queue_job.field_queue_job__channel #: model:ir.model.fields,field_description:queue_job.field_queue_job_function__channel_id @@ -223,6 +252,13 @@ msgstr "" msgid "Date Done" msgstr "" +#. module: queue_job +#: model:ir.model.fields,help:queue_job.field_queue_job_channel__capacity_default +msgid "" +"Default capacity for unconfigured sub-channels. 0 means they would have the " +"same capacity as the current channel." +msgstr "" + #. module: queue_job #: model:ir.model.fields,field_description:queue_job.field_queue_job__dependencies #: model_terms:ir.ui.view,arch_db:queue_job.view_queue_job_form @@ -406,6 +442,11 @@ msgstr "" msgid "If checked, some messages have a delivery error." msgstr "" +#. module: queue_job +#: model:ir.model.fields,help:queue_job.field_queue_job_channel__sequential_default +msgid "If sequential is enabled for unconfigured sub-channels." +msgstr "" + #. module: queue_job #. odoo-python #: code:addons/queue_job/models/queue_job_function.py:0 @@ -433,6 +474,7 @@ msgstr "" #: model:ir.model,name:queue_job.model_queue_job_function #: model:ir.model.fields,field_description:queue_job.field_queue_job_channel__job_function_ids #: model:ir.ui.menu,name:queue_job.menu_queue_job_function +#: model_terms:ir.ui.view,arch_db:queue_job.view_queue_job_channel_form #: model_terms:ir.ui.view,arch_db:queue_job.view_queue_job_function_form #: model_terms:ir.ui.view,arch_db:queue_job.view_queue_job_function_search msgid "Job Functions" @@ -448,6 +490,11 @@ msgstr "" msgid "Job Queue Manager" msgstr "" +#. module: queue_job +#: model_terms:ir.ui.view,arch_db:queue_job.view_queue_job_channel_form +msgid "Job Runner" +msgstr "" + #. module: queue_job #: model:ir.model.fields.selection,name:queue_job.selection__ir_model_fields__ttype__job_serialized msgid "Job Serialized" @@ -471,6 +518,13 @@ msgstr "" msgid "Jobs" msgstr "" +#. module: queue_job +#: model:ir.model.fields,help:queue_job.field_queue_job_channel__sequential +msgid "" +"Jobs are executed one after the other and failed jobs block the channel. " +"Requires a capacity of 1." +msgstr "" + #. module: queue_job #. odoo-python #: code:addons/queue_job/models/queue_job.py:0 @@ -526,6 +580,14 @@ msgstr "" msgid "Max. retries" msgstr "" +#. module: queue_job +#: model:ir.model.fields,help:queue_job.field_queue_job_channel__capacity +msgid "" +"Maximum number of jobs running at the same time in this channel. 0 means no " +"limit, but they are still limited by the capacity of the parent channel. On " +"the root channel, 0 is limited by the global server-wide configuration." +msgstr "" + #. module: queue_job #: model:ir.model.fields,field_description:queue_job.field_queue_job__message_has_error msgid "Message Delivery error" @@ -546,6 +608,12 @@ msgstr "" msgid "Method Name" msgstr "" +#. module: queue_job +#: model:ir.model.fields,help:queue_job.field_queue_job_channel__throttle +msgid "" +"Minimum delay in seconds between the start of two jobs in this channel." +msgstr "" + #. module: queue_job #: model:ir.model.fields,field_description:queue_job.field_queue_job__model_name #: model:ir.model.fields,field_description:queue_job.field_queue_job_function__model_id @@ -553,6 +621,12 @@ msgstr "" msgid "Model" msgstr "" +#. module: queue_job +#: model:ir.model.fields,help:queue_job.field_queue_job_function__on_fail_method +msgid "" +"Model function to be called if the job is failed and will not be retried." +msgstr "" + #. module: queue_job #. odoo-python #: code:addons/queue_job/models/queue_job_function.py:0 @@ -607,6 +681,11 @@ msgstr "" msgid "Number of Actions" msgstr "" +#. module: queue_job +#: model:ir.model.fields,help:queue_job.field_queue_job_channel__removal_interval +msgid "Number of days after which done jobs are deleted." +msgstr "" + #. module: queue_job #: model:ir.model.fields,field_description:queue_job.field_queue_job__message_has_error_counter msgid "Number of errors" @@ -622,6 +701,11 @@ msgstr "" msgid "Number of messages with delivery error" msgstr "" +#. module: queue_job +#: model:ir.model.fields,field_description:queue_job.field_queue_job_function__on_fail_method +msgid "On Fail Method" +msgstr "" + #. module: queue_job #: model:ir.model.fields,field_description:queue_job.field_queue_job_channel__parent_id msgid "Parent Channel" @@ -642,6 +726,17 @@ msgid "" "See the module description for details." msgstr "" +#. module: queue_job +#: model_terms:ir.ui.view,arch_db:queue_job.view_queue_job_channel_form +msgid "Pause" +msgstr "" + +#. module: queue_job +#: model:ir.model.fields,field_description:queue_job.field_queue_job_channel__paused +#: model_terms:ir.ui.view,arch_db:queue_job.view_queue_job_channel_search +msgid "Paused" +msgstr "" + #. module: queue_job #: model:ir.model.fields.selection,name:queue_job.selection__queue_job__state__pending #: model_terms:ir.ui.view,arch_db:queue_job.view_queue_job_search @@ -749,6 +844,11 @@ msgstr "" msgid "Results" msgstr "" +#. module: queue_job +#: model_terms:ir.ui.view,arch_db:queue_job.view_queue_job_channel_form +msgid "Resume" +msgstr "" + #. module: queue_job #: model:ir.model.fields,field_description:queue_job.field_queue_job_function__edit_retry_pattern msgid "Retry Pattern" @@ -759,6 +859,16 @@ msgstr "" msgid "Retry Pattern (serialized)" msgstr "" +#. module: queue_job +#: model:ir.model.fields,field_description:queue_job.field_queue_job_channel__sequential +msgid "Sequential" +msgstr "" + +#. module: queue_job +#: model:ir.model.fields,field_description:queue_job.field_queue_job_channel__sequential_default +msgid "Sequential Default" +msgstr "" + #. module: queue_job #: model:ir.model,name:queue_job.model_queue_jobs_to_done msgid "Set all selected jobs to done" @@ -835,6 +945,18 @@ msgid "" "See the module description for details." msgstr "" +#. module: queue_job +#. odoo-python +#: code:addons/queue_job/models/queue_job_channel.py:0 +msgid "The capacity of a channel cannot be negative." +msgstr "" + +#. module: queue_job +#. odoo-python +#: code:addons/queue_job/models/queue_job_channel.py:0 +msgid "The default capacity of a channel cannot be negative." +msgstr "" + #. module: queue_job #: model:ir.model.fields,help:queue_job.field_queue_job__max_retries msgid "" @@ -857,6 +979,24 @@ msgstr "" msgid "The selected jobs will be set to done." msgstr "" +#. module: queue_job +#. odoo-python +#: code:addons/queue_job/models/queue_job_channel.py:0 +msgid "The throttle of a channel cannot be negative." +msgstr "" + +#. module: queue_job +#: model_terms:ir.ui.view,arch_db:queue_job.view_queue_job_channel_form +msgid "" +"These properties will be used only if the server-wide\n" +" channels configuration is not used." +msgstr "" + +#. module: queue_job +#: model:ir.model.fields,field_description:queue_job.field_queue_job_channel__throttle +msgid "Throttle" +msgstr "" + #. module: queue_job #: model_terms:ir.ui.view,arch_db:queue_job.view_queue_job_form msgid "Time (s)" diff --git a/queue_job/i18n/sv.po b/queue_job/i18n/sv.po index f215a7346f..b110c60722 100644 --- a/queue_job/i18n/sv.po +++ b/queue_job/i18n/sv.po @@ -554,6 +554,12 @@ msgstr "Metod Namn" msgid "Model" msgstr "Modell" +#. module: queue_job +#: model:ir.model.fields,help:queue_job.field_queue_job_function__on_fail_method +msgid "" +"Model function to be called if the job is failed and will not be retried." +msgstr "" + #. module: queue_job #. odoo-python #: code:addons/queue_job/models/queue_job_function.py:0 @@ -618,6 +624,11 @@ msgstr "Antal meddelanden som kräver åtgärder" msgid "Number of messages with delivery error" msgstr "Antal meddelanden med leveransfel" +#. module: queue_job +#: model:ir.model.fields,field_description:queue_job.field_queue_job_function__on_fail_method +msgid "On Fail Method" +msgstr "" + #. module: queue_job #: model:ir.model.fields,field_description:queue_job.field_queue_job_channel__parent_id msgid "Parent Channel" diff --git a/queue_job/i18n/tr.po b/queue_job/i18n/tr.po index 17258f6bc1..3a19a3ac42 100644 --- a/queue_job/i18n/tr.po +++ b/queue_job/i18n/tr.po @@ -554,6 +554,12 @@ msgstr "Yöntem Adı" msgid "Model" msgstr "" +#. module: queue_job +#: model:ir.model.fields,help:queue_job.field_queue_job_function__on_fail_method +msgid "" +"Model function to be called if the job is failed and will not be retried." +msgstr "" + #. module: queue_job #. odoo-python #: code:addons/queue_job/models/queue_job_function.py:0 @@ -618,6 +624,11 @@ msgstr "Eylem gerektiren mesaj sayısı" msgid "Number of messages with delivery error" msgstr "Teslimat hatası içeren mesaj sayısı" +#. module: queue_job +#: model:ir.model.fields,field_description:queue_job.field_queue_job_function__on_fail_method +msgid "On Fail Method" +msgstr "" + #. module: queue_job #: model:ir.model.fields,field_description:queue_job.field_queue_job_channel__parent_id msgid "Parent Channel" diff --git a/queue_job/i18n/tr_TR.po b/queue_job/i18n/tr_TR.po index 9c6946f1e0..0167c2d477 100644 --- a/queue_job/i18n/tr_TR.po +++ b/queue_job/i18n/tr_TR.po @@ -549,6 +549,12 @@ msgstr "" msgid "Model" msgstr "" +#. module: queue_job +#: model:ir.model.fields,help:queue_job.field_queue_job_function__on_fail_method +msgid "" +"Model function to be called if the job is failed and will not be retried." +msgstr "" + #. module: queue_job #. odoo-python #: code:addons/queue_job/models/queue_job_function.py:0 @@ -613,6 +619,11 @@ msgstr "" msgid "Number of messages with delivery error" msgstr "" +#. module: queue_job +#: model:ir.model.fields,field_description:queue_job.field_queue_job_function__on_fail_method +msgid "On Fail Method" +msgstr "" + #. module: queue_job #: model:ir.model.fields,field_description:queue_job.field_queue_job_channel__parent_id msgid "Parent Channel" diff --git a/queue_job/i18n/zh_CN.po b/queue_job/i18n/zh_CN.po index ecd02955da..50e985eeba 100644 --- a/queue_job/i18n/zh_CN.po +++ b/queue_job/i18n/zh_CN.po @@ -554,6 +554,12 @@ msgstr "方法名称" msgid "Model" msgstr "模型" +#. module: queue_job +#: model:ir.model.fields,help:queue_job.field_queue_job_function__on_fail_method +msgid "" +"Model function to be called if the job is failed and will not be retried." +msgstr "" + #. module: queue_job #. odoo-python #: code:addons/queue_job/models/queue_job_function.py:0 @@ -618,6 +624,11 @@ msgstr "需要处理的消息数量" msgid "Number of messages with delivery error" msgstr "递送错误消息数量" +#. module: queue_job +#: model:ir.model.fields,field_description:queue_job.field_queue_job_function__on_fail_method +msgid "On Fail Method" +msgstr "" + #. module: queue_job #: model:ir.model.fields,field_description:queue_job.field_queue_job_channel__parent_id msgid "Parent Channel" diff --git a/queue_job/job.py b/queue_job/job.py index 032bdb9339..a69a71ddc5 100644 --- a/queue_job/job.py +++ b/queue_job/job.py @@ -13,9 +13,30 @@ from random import randint import odoo +from odoo.models import BaseModel from .exception import FailedJobError, NoSuchJobError, RetryableJobError + +def _rebind_to_cr(value, cr): + """Rebind any BaseModel inside ``value`` to the cursor ``cr``. + + Recurses into lists, tuples and dicts. Preserves uid/su/context of each + inner env - only the cursor is swapped. Recordsets already bound to + ``cr`` and non-recordset values pass through untouched. Containers are + rebuilt, so in-place changes to the result are lost. + """ + if isinstance(value, BaseModel): + if value.env.cr is cr: + return value + return value.with_env(value.env(cr=cr)) + if isinstance(value, list | tuple): + return type(value)(_rebind_to_cr(v, cr) for v in value) + if isinstance(value, dict): + return {k: _rebind_to_cr(v, cr) for k, v in value.items()} + return value + + WAIT_DEPENDENCIES = "wait_dependencies" PENDING = "pending" ENQUEUED = "enqueued" @@ -409,6 +430,12 @@ def __init__( self.job_config = ( self.env["queue.job.function"].sudo().job_config(self.job_function_name) ) + on_fail_method_name = self.job_config.on_fail_method_name + if on_fail_method_name and not _is_model_method( + getattr(self.recordset, on_fail_method_name, None) + ): + raise TypeError("Job accepts only methods of Models") + self.on_fail_method_name = on_fail_method_name self.state = PENDING @@ -512,8 +539,8 @@ def perform(self): def in_temporary_env(self): with self.env.registry.cursor() as new_cr: env = self.env - self._env = env(cr=new_cr) try: + self._env = env(cr=new_cr) yield finally: self._env = env @@ -687,6 +714,24 @@ def env(self): def _env(self, env): self.recordset = self.recordset.with_env(env) + @property + def args(self): + """Positional arguments, rebound to the job's current cursor.""" + return _rebind_to_cr(self._args, self.env.cr) + + @args.setter + def args(self, value): + self._args = value + + @property + def kwargs(self): + """Keyword arguments, rebound to the job's current cursor.""" + return _rebind_to_cr(self._kwargs, self.env.cr) + + @kwargs.setter + def kwargs(self, value): + self._kwargs = value + @property def func(self): recordset = self.recordset.with_context(job_uuid=self.uuid) @@ -829,6 +874,13 @@ def set_failed(self, **kw): if v is not None: setattr(self, k, v) + def on_fail(self, fail_vals): + if not self.on_fail_method_name: + return + on_fail_func = getattr(self.recordset, self.on_fail_method_name, None) + if on_fail_func: + on_fail_func(**fail_vals) + def __repr__(self): return "" % (self.uuid, self.priority) diff --git a/queue_job/jobrunner/__init__.py b/queue_job/jobrunner/__init__.py index e2561b0e74..2a1168707b 100644 --- a/queue_job/jobrunner/__init__.py +++ b/queue_job/jobrunner/__init__.py @@ -20,7 +20,7 @@ queue_job_config = config.misc.get("queue_job", {}) -from .runner import QueueJobRunner, _channels +from .runner import QueueJobRunner, _channels, _max_capacity _logger = logging.getLogger(__name__) @@ -87,7 +87,12 @@ def signal_time_expired_handler(self, n, stack): def _is_runner_enabled(): - return not _channels().strip().startswith("root:0") + channel_config = _channels() + if channel_config and channel_config.strip().startswith("root:0"): + return False + elif channel_config: + return True + return _max_capacity() != 0 def _start_runner_thread(server_type): @@ -100,7 +105,8 @@ def _start_runner_thread(server_type): else: _logger.info( "jobrunner thread (in %s) NOT started, " - "because the root channel's capacity is set to 0", + "because the root channel's capacity or the max capacity " + "is set to 0", server_type, ) diff --git a/queue_job/jobrunner/channels.py b/queue_job/jobrunner/channels.py index c895d9caf3..2e8c6109da 100644 --- a/queue_job/jobrunner/channels.py +++ b/queue_job/jobrunner/channels.py @@ -3,6 +3,7 @@ # License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html) import logging from collections import namedtuple +from dataclasses import asdict, dataclass from functools import total_ordering from heapq import heappop, heappush from weakref import WeakValueDictionary @@ -10,12 +11,26 @@ from ..exception import ChannelNotFound from ..job import CANCELLED, DONE, ENQUEUED, FAILED, PENDING, STARTED, WAIT_DEPENDENCIES +RELOAD_PAYLOAD = "reload" NOT_DONE = (WAIT_DEPENDENCIES, PENDING, ENQUEUED, STARTED, FAILED) JobSortingKey = namedtuple("SortingKey", "eta priority date_created seq") _logger = logging.getLogger(__name__) +@dataclass +class ChannelConfig: + """Configuration of a channel""" + + name: str + capacity: int = 0 + sequential: bool = False + throttle: int = 0 + paused: bool = False + capacity_default: int = 0 + sequential_default: bool = False + + class PriorityQueue: """A priority queue that supports removing arbitrary objects. @@ -402,9 +417,22 @@ class Channel: with a capacity of 1. It is also possible to dedicate a channel with a limited capacity for application-autocreated subchannels without risking to overflow the system. + + A paused channel does not process any job until it is resumed. All subchannels + are blocked with their parent channel. """ - def __init__(self, name, parent, capacity=None, sequential=False, throttle=0): + def __init__( + self, + name, + parent, + capacity=None, + sequential=False, + throttle=0, + paused=False, + capacity_default=None, + sequential_default=False, + ): self.name = name self.parent = parent if self.parent: @@ -414,9 +442,14 @@ def __init__(self, name, parent, capacity=None, sequential=False, throttle=0): self._running = set() self._failed = set() self._pause_until = 0 # utc seconds since the epoch - self.capacity = capacity + self.capacity = ( + capacity if (capacity is not None) else (parent and parent.capacity_default) + ) + self.capacity_default = capacity_default self.throttle = throttle # seconds - self.sequential = sequential + self.sequential = sequential or (parent and parent.sequential_default) + self.sequential_default = sequential_default + self.paused = paused @property def sequential(self): @@ -432,13 +465,19 @@ def configure(self, config): Supported keys are: * capacity + * capacity_default (default for sub channels) * sequential + * sequential_default (default for sub channels) * throttle + * paused """ assert self.fullname.endswith(config["name"]) self.capacity = config.get("capacity", None) + self.capacity_default = config.get("capacity_default", None) self.sequential = bool(config.get("sequential", False)) + self.sequential_default = config.get("sequential_default", False) self.throttle = int(config.get("throttle", 0)) + self.paused = bool(config.get("paused", False)) if self.sequential and self.capacity != 1: raise ValueError("A sequential channel must have a capacity of 1") @@ -455,12 +494,13 @@ def get_subchannel_by_name(self, subchannel_name): def __str__(self): capacity = "∞" if self.capacity is None else str(self.capacity) - return "%s(C:%s,Q:%d,R:%d,F:%d)" % ( + return "%s(C:%s,Q:%d,R:%d,F:%d%s)" % ( self.fullname, capacity, len(self._queue), len(self._running), len(self._failed), + ",paused" if self.paused else "", ) def remove(self, job): @@ -517,6 +557,8 @@ def set_failed(self, job): _logger.debug("job %s marked failed in channel %s", job.uuid, self) def has_capacity(self): + if self.paused: + return False if self.sequential and self._failed: # a sequential queue blocks on failed jobs return False @@ -799,6 +841,31 @@ class ChannelManager: >>> cm.notify(db, 'S', 'S3', 3, 0, 10, None, 'done') >>> pp(list(cm.get_jobs_to_run(now=105))) [] + + Test pausing a channel + + >>> cm = ChannelManager() + >>> cm.simple_configure('root:4,P:2:paused,P.sub:1') + >>> cm.notify(db, 'P', 'P1', 1, 0, 10, None, 'pending') + >>> cm.notify(db, 'P.sub', 'PS1', 2, 0, 10, None, 'pending') + + Paused channel yields no job + + >>> pp(list(cm.get_jobs_to_run(now=100))) + [] + + Resuming the channel yields the pending jobs + + >>> cm.simple_configure('root:4,P:2') + >>> pp(list(cm.get_jobs_to_run(now=100))) + [, ] + + Pausing the root channel blocks everything + + >>> cm.simple_configure('root:4:paused') + >>> cm.notify(db, 'P', 'P3', 4, 0, 10, None, 'pending') + >>> pp(list(cm.get_jobs_to_run(now=106))) + [] """ def __init__(self): @@ -866,22 +933,23 @@ def parse_simple_config(cls, config_string): continue config = {} config_items = split_strip(channel_config_string, ":") - name = config_items[0] - if not name: + if not (name := config_items.pop(0)): raise ValueError( f"Invalid channel config {config_string}: missing channel name" ) config["name"] = name - if len(config_items) > 1: - capacity = config_items[1] + if len(config_items) > 0: try: - config["capacity"] = int(capacity) + config["capacity"] = int(config_items[0]) + config_items.pop(0) except Exception as ex: - raise ValueError( - f"Invalid channel config {config_string}: " - f"invalid capacity {capacity}" - ) from ex - for config_item in config_items[2:]: + if name == "root": + raise ValueError( + f"Invalid channel config {config_string}: " + f"invalid capacity {config_items[0]}" + ) from ex + + for config_item in config_items: kv = split_strip(config_item, "=") if len(kv) == 1: k, v = kv[0], True @@ -894,10 +962,18 @@ def parse_simple_config(cls, config_string): ) if k in config: raise ValueError( - f"Invalid channel config {config_string}: " - f"duplicate key {k}" + f"Invalid channel config {config_string}: duplicate key {k}" ) - config[k] = v + if k == "capacity_default": + try: + config[k] = int(v) + except Exception as ex: + raise ValueError( + f"Invalid channel config {config_string}: " + f"invalid capacity_default {v}" + ) from ex + else: + config[k] = v else: config["capacity"] = 1 res.append(config) @@ -910,6 +986,17 @@ def simple_configure(self, config_string): >>> c = cm.get_channel_by_name('root') >>> c.capacity 1 + + >>> cm.simple_configure('root:bogus') + Traceback (most recent call last): + ... + ValueError: Invalid channel config root:bogus: invalid capacity bogus + + >>> cm.simple_configure('root:4,:2') + Traceback (most recent call last): + ... + ValueError: Invalid channel config root:4,:2: missing channel name + >>> cm.simple_configure('root:4,autosub.sub:2,seq:1:sequential') >>> cm.get_channel_by_name('root').capacity 4 @@ -926,11 +1013,41 @@ def simple_configure(self, config_string): 1 >>> cm.get_channel_by_name('seq').sequential True - """ + + >>> cm.simple_configure('root:4:capacity_default=bogus') + Traceback (most recent call last): + ... + ValueError: Invalid channel config root:4:capacity_default=bogus: invalid capacity_default bogus + + >>> cm.simple_configure('root:4,sub:3:capacity_default=2') + >>> cm.get_channel_by_name('root.sub').capacity + 3 + >>> cm.get_channel_by_name('root.sub.auto', autocreate=True).capacity + 2 + + >>> cm.simple_configure('root:4,seq:2:sequential') + Traceback (most recent call last): + ... + ValueError: A sequential channel must have a capacity of 1 + + >>> cm.simple_configure('root:4,seq:sequential_default') + >>> cm.get_channel_by_name('root.seq.auto', autocreate=True).sequential + True + + """ # noqa: E501 for config in ChannelManager.parse_simple_config(config_string): self.get_channel_from_config(config) - def get_channel_from_config(self, config): + def configure(self, configs, db_name=None): + """Configure the channel manager from list of :class:`ChannelConfig` + + :param db_name: used to show the database name in the + logs when using per-database channels + """ + for config in configs: + self.get_channel_from_config(asdict(config), db_name=db_name) + + def get_channel_from_config(self, config, db_name=None): """Return a Channel object from a parsed configuration. If the channel does not exist it is created. @@ -942,7 +1059,10 @@ def get_channel_from_config(self, config): """ channel = self.get_channel_by_name(config["name"], autocreate=True) channel.configure(config) - _logger.info("Configured channel: %s", channel) + if db_name: + _logger.info("Configured channel: %s (db: %s)", channel, db_name) + else: + _logger.info("Configured channel: %s", channel) return channel def get_channel_by_name( @@ -1080,3 +1200,8 @@ def get_jobs_to_run(self, now): def get_wakeup_time(self): return self._root_channel.get_wakeup_time() + + @property + def running_count(self) -> int: + """Number of jobs currently running""" + return len(self._root_channel._running) diff --git a/queue_job/jobrunner/runner.py b/queue_job/jobrunner/runner.py index 95e134ba44..5cbd52ef48 100644 --- a/queue_job/jobrunner/runner.py +++ b/queue_job/jobrunner/runner.py @@ -19,11 +19,13 @@ anonymous ``/queue_job/runjob`` HTTP request. """ +import fnmatch import logging import os import selectors import threading import time +from collections import deque from contextlib import closing, contextmanager import psycopg2 @@ -34,7 +36,7 @@ from odoo.tools import config from . import queue_job_config -from .channels import ENQUEUED, NOT_DONE, ChannelManager +from .channels import ENQUEUED, NOT_DONE, RELOAD_PAYLOAD, ChannelConfig, ChannelManager SELECT_TIMEOUT = 60 ERROR_RECOVERY_DELAY = 5 @@ -57,14 +59,93 @@ class MasterElectionLost(Exception): # so we check it in addition to the environment variables. -def _channels(): +def _max_capacity() -> int: + """Maximum number of jobs running at the same time across all databases + + It comes from the ``ODOO_QUEUE_JOB_MAX_CAPACITY`` environment + variable, then ``max_capacity`` in the ``[queue_job]`` section of the + configuration file. + + If none is configured, the max capacity defaults to 1. + An explicit value of 0 means no job is dispatched at all. + """ + value = os.environ.get("ODOO_QUEUE_JOB_MAX_CAPACITY") or queue_job_config.get( + "max_capacity" + ) + if value: + return int(value) + return 1 + + +def _db_max_capacity() -> str: return ( - os.environ.get("ODOO_QUEUE_JOB_CHANNELS") - or queue_job_config.get("channels") - or "root:1" + os.environ.get("ODOO_QUEUE_JOB_DB_MAX_CAPACITY") + or queue_job_config.get("db_max_capacity") + or "" ) +def parse_db_max_capacity(spec): + """Parse a per-database max capacity configuration string + + The string is a comma-separated list of ``pattern:capacity`` items, where + ``pattern`` matches database names with fnmatch wildcards. + + The first matching pattern wins, so specific patterns must be first in the + string. + + A single integer is applied to all databases, as a shorthand for + ``*:capacity``. + + >>> parse_db_max_capacity('prod_*:20,staging:2,*:5') + [('prod_*', 20), ('staging', 2), ('*', 5)] + >>> parse_db_max_capacity('8') + [('*', 8)] + >>> parse_db_max_capacity('') + [] + >>> parse_db_max_capacity(None) + [] + """ + rules = [] + if not spec: + return rules + for item in spec.replace("\n", ",").split(","): + item = item.strip() + if not item: + continue + pattern, sep, capacity = item.rpartition(":") + if not sep: + pattern = "*" + try: + rules.append((pattern.strip(), int(capacity))) + except ValueError as ex: + raise ValueError(f"Invalid db max capacity {spec}: {capacity}") from ex + return rules + + +def db_max_capacity_for(db_name, rules, default=None): + """Max capacity of a database, first match wins + + >>> rules = parse_db_max_capacity('prod_*:20,staging:2,*:5') + >>> db_max_capacity_for('prod_foo', rules) + 20 + >>> db_max_capacity_for('staging', rules) + 2 + >>> db_max_capacity_for('dev', rules) + 5 + >>> db_max_capacity_for('dev', [], default=7) + 7 + """ + for pattern, capacity in rules: + if fnmatch.fnmatch(db_name, pattern): + return capacity + return default + + +def _channels(): + return os.environ.get("ODOO_QUEUE_JOB_CHANNELS") or queue_job_config.get("channels") + + def _odoo_now(): # important: this must return the same as postgresql # EXTRACT(EPOCH FROM TIMESTAMP dt) @@ -123,9 +204,11 @@ def __init__(self, db_name): try: self.conn.set_isolation_level(ISOLATION_LEVEL_AUTOCOMMIT) self.has_queue_job = self._has_queue_job() + self.has_channel_config_columns = False if self.has_queue_job: self._acquire_master_lock() self._initialize() + self.has_channel_config_columns = self._has_channel_config_columns() except BaseException: self.close() raise @@ -182,6 +265,64 @@ def _initialize(self): with closing(self.conn.cursor()) as cr: cr.execute("LISTEN queue_job") + def _has_channel_config_columns(self): + with closing(self.conn.cursor()) as cr: + cr.execute( + """ + SELECT count(*) + FROM information_schema.columns + WHERE table_name = %s + AND column_name IN ( + 'capacity', + 'sequential', + 'throttle', + 'paused', + 'capacity_default', + 'sequential_default' + ) + """, + ("queue_job_channel",), + ) + return cr.fetchone()[0] == 6 + + def load_channels_config(self): + """Return the channels configuration stored in the database""" + if not self.has_channel_config_columns: + return None + with closing(self.conn.cursor()) as cr: + cr.execute( + "SELECT complete_name, " + "COALESCE(capacity, 0), " + "COALESCE(sequential, false), " + "COALESCE(throttle, 0), " + "COALESCE(paused, false), " + "COALESCE(capacity_default, 0), " + "COALESCE(sequential_default, false) " + "FROM queue_job_channel " + ) + rows = cr.fetchall() + configs = [ + ChannelConfig( + name=name, + capacity=capacity, + sequential=sequential, + throttle=throttle, + paused=paused, + capacity_default=capacity_default, + sequential_default=sequential_default, + ) + for ( + name, + capacity, + sequential, + throttle, + paused, + capacity_default, + sequential_default, + ) in rows + ] + return configs + @contextmanager def select_jobs(self, where, args): # pylint: disable=sql-injection @@ -321,16 +462,40 @@ def __init__( user=None, password=None, channel_config_string=None, + max_capacity=None, + db_max_capacity=None, ): self.scheme = scheme self.host = host self.port = port self.user = user self.password = password - self.channel_manager = ChannelManager() + if channel_config_string is None: channel_config_string = _channels() - self.channel_manager.simple_configure(channel_config_string) + + self._server_wide_channel_manager = None + if channel_config_string: + channel_manager = ChannelManager() + channel_manager.simple_configure(channel_config_string) + self._server_wide_channel_manager = channel_manager + # max_capacity is always equal to the root channel in server-wide + # configuration + max_capacity = channel_manager.get_channel_by_name("root").capacity + + if max_capacity is None: + max_capacity = _max_capacity() + self.max_capacity = max(0, max_capacity) + + if db_max_capacity is None: + db_max_capacity = _db_max_capacity() + self.db_max_capacity_rules = parse_db_max_capacity(db_max_capacity) + + self._channel_manager_by_db = {} + self._channel_managers = [] + + self._round_robin_offset = 0 + self.db_by_name = {} self._stop = False self._stop_pipe = os.pipe() @@ -387,11 +552,98 @@ def close_databases(self, remove_jobs=True): for db_name, db in self.db_by_name.items(): try: if remove_jobs: - self.channel_manager.remove_db(db_name) + self._channel_manager_by_db[db_name].remove_db(db_name) db.close() except Exception: _logger.warning("error closing database %s", db_name, exc_info=True) self.db_by_name = {} + self._channel_manager_by_db = {} + self._channel_managers = [] + + @staticmethod + def _create_paused_root_channel_manager(db_name=None): + """Create a channel manager with a single root and paused channel + + It is used when the channels configuration cannot be used because + of a misconfiguration or because a database schema is outdated. + """ + channel_manager = ChannelManager() + channel_manager.configure( + [ChannelConfig(name="root", capacity=0, paused=True)], db_name=db_name + ) + return channel_manager + + def _build_channel_manager(self, db): + """Build and configure the channel manager of a database""" + db_max = max( + 0, + db_max_capacity_for( + db.db_name, self.db_max_capacity_rules, default=self.max_capacity + ), + ) + + channels_config = db.load_channels_config() + if channels_config is None: + # database not updated to the proper schema, + # no job execution until it is properly upgraded + _logger.error( + "database %s schema is outdated, -u queue_job required", db.db_name + ) + return self._create_paused_root_channel_manager(db_name=db.db_name) + + root_config = next( + (config for config in channels_config if config.name == "root"), None + ) + if root_config is None: + root_config = ChannelConfig("root") + channels_config.insert(0, root_config) + + if not db_max: + # if a database is set at 0, it does not run any jobs, pause it + root_config.paused = True + elif not root_config.capacity: + root_config.capacity = db_max + else: + root_config.capacity = min(root_config.capacity, db_max) + + channel_manager = ChannelManager() + try: + channel_manager.configure(channels_config, db_name=db.db_name) + except ValueError: + # A bad channel configuration on a single database + # (e.g. sequential with a capacity != 1) should not + # break other databases, use a paused root channel + # instead. If the configuration is corrected on the DB, + # the next reload should rebuild the channel manager. + _logger.warning( + "invalid channel configuration for db %s, pausing until resolution", + db.db_name, + exc_info=True, + ) + return self._create_paused_root_channel_manager(db_name=db.db_name) + return channel_manager + + def _register_db_server_wide(self, db): + """Register the server-wide channel manager for a DB and reload its jobs""" + db_name = db.db_name + channel_manager = self._server_wide_channel_manager + self._channel_manager_by_db[db_name] = channel_manager + self._channel_managers = [self._server_wide_channel_manager] + self._notify_existing_jobs(db, channel_manager) + + def _configure_db(self, db): + """Build the channel manager for a database and reload its jobs""" + db_name = db.db_name + channel_manager = self._build_channel_manager(db) + self._channel_manager_by_db[db_name] = channel_manager + self._channel_managers = list(self._channel_manager_by_db.values()) + self._notify_existing_jobs(db, channel_manager) + _logger.info("channels configuration loaded for db %s", db_name) + + def _notify_existing_jobs(self, db, channel_manager): + with db.select_jobs("state in %s", (NOT_DONE,)) as cr: + for job_data in cr: + channel_manager.notify(db.db_name, *job_data) def initialize_databases(self): for db_name in sorted(self.get_db_names()): @@ -399,9 +651,10 @@ def initialize_databases(self): db = Database(db_name) if db.has_queue_job: self.db_by_name[db_name] = db - with db.select_jobs("state in %s", (NOT_DONE,)) as cr: - for job_data in cr: - self.channel_manager.notify(db_name, *job_data) + if self._server_wide_channel_manager: + self._register_db_server_wide(db) + else: + self._configure_db(db) _logger.info("queue job runner ready for db %s", db_name) else: db.close() @@ -411,24 +664,71 @@ def requeue_dead_jobs(self): if db.has_queue_job: db.requeue_dead_jobs() - def run_jobs(self): - now = _odoo_now() - for job in self.channel_manager.get_jobs_to_run(now): - if self._stop: - break - _logger.info("asking Odoo to run job %s on db %s", job.uuid, job.db_name) - self.db_by_name[job.db_name].set_job_enqueued(job.uuid) - _async_http_get( - self.scheme, - self.host, - self.port, - self.user, - self.password, - job.db_name, - job.uuid, + def _all_running_count(self) -> int: + return sum( + channel_manager.running_count for channel_manager in self._channel_managers + ) + + def _dispatch_job(self, job): + _logger.info("asking Odoo to run job %s on db %s", job.uuid, job.db_name) + self.db_by_name[job.db_name].set_job_enqueued(job.uuid) + _async_http_get( + self.scheme, + self.host, + self.port, + self.user, + self.password, + job.db_name, + job.uuid, + ) + + def _round_robin_jobs(self, channel_managers, now): + managers_count = len(channel_managers) + # Ensure the channel managers are ordered in way that all have + # equal chances to enqueue jobs. The manager that was last last + # time will be first the next time and so on. + job_generators = deque( + ( + # store the actual position of the channel manager in the channel + # managers list + index % managers_count, + channel_managers[index % managers_count].get_jobs_to_run(now), + ) + for index in range( + self._round_robin_offset, self._round_robin_offset + managers_count ) + ) + while job_generators: + index, job_generator = job_generators.popleft() + job = next(job_generator, None) + if job is None: + # generator exhausted for this tick, remove from the deque + # it will come back next time + continue + job_generators.append((index, job_generator)) + self._round_robin_offset = index + 1 + yield job + + def run_jobs(self): + channel_managers = self._channel_managers + if not channel_managers: + return + + jobs = self._round_robin_jobs(channel_managers, _odoo_now()) + while not self._stop: + if self._all_running_count() >= self.max_capacity: + _logger.debug( + "max capacity of %s reached, waiting for capacity", + self.max_capacity, + ) + return + job = next(jobs, None) + if job is None: + return + self._dispatch_job(job) def process_notifications(self): + reload_db_names = set() for db in self.db_by_name.values(): if not db.conn.notifies: # If there are no activity in the queue_job table it seems that @@ -440,13 +740,39 @@ def process_notifications(self): if self._stop: break notification = db.conn.notifies.pop() - uuid = notification.payload + payload = notification.payload + if payload == RELOAD_PAYLOAD and not self._server_wide_channel_manager: + reload_db_names.add(db.db_name) + continue + + uuid = payload + channel_manager = self._channel_manager_by_db[db.db_name] with db.select_jobs("uuid = %s", (uuid,)) as cr: job_datas = cr.fetchone() if job_datas: - self.channel_manager.notify(db.db_name, *job_datas) + channel_manager.notify(db.db_name, *job_datas) else: - self.channel_manager.remove_job(uuid) + channel_manager.remove_job(uuid) + + for db_name in reload_db_names: + self._configure_db(self.db_by_name[db_name]) + + def next_wakeup_time(self): + # A wake-up time of 0 does not mean to wake immediately, but to stop + # until we get new notifications (practically, set a SELECT_TIMEOUT, + # and wait for notifications). Wake-ups of more than 0 are used to + # dispatch jobs with ETA, as these will not notify anything when they + # actually have to start. + # So when getting the next wake-up time, we find the first channel + # manager with ETA jobs (a wake-up time more than 0), and if none, + # a wake-up time of zero is returned to go back to the select waiting + # for notifications. + wakeup_times = [ + wakeup_time + for channel_manager in self._channel_managers + if (wakeup_time := channel_manager.get_wakeup_time()) + ] + return min(wakeup_times, default=0) def wait_notification(self): for db in self.db_by_name.values(): @@ -458,7 +784,7 @@ def wait_notification(self): conns = [db.conn for db in self.db_by_name.values()] conns.append(self._stop_pipe[0]) # look if the channels specify a wakeup time - wakeup_time = self.channel_manager.get_wakeup_time() + wakeup_time = self.next_wakeup_time() if not wakeup_time: # this could very well be no timeout at all, because # any activity in the job queue will wake us up, but diff --git a/queue_job/models/queue_job.py b/queue_job/models/queue_job.py index 011edf09db..fad95fb293 100644 --- a/queue_job/models/queue_job.py +++ b/queue_job/models/queue_job.py @@ -485,3 +485,6 @@ def _test_job( time.sleep(job_duration) if commit_within_job: self.env.cr.commit() # pylint: disable=invalid-commit + + def _test_on_fail(self, **kw): + pass diff --git a/queue_job/models/queue_job_channel.py b/queue_job/models/queue_job_channel.py index 4aabb0188c..4a86646c1f 100644 --- a/queue_job/models/queue_job_channel.py +++ b/queue_job/models/queue_job_channel.py @@ -4,12 +4,28 @@ from odoo import _, api, exceptions, fields, models +from ..jobrunner.channels import RELOAD_PAYLOAD + class QueueJobChannel(models.Model): _name = "queue.job.channel" _description = "Job Channels" _rec_name = "complete_name" + # fields that trigger a reload of the jobrunner for this database when changed + _JOBRUNNER_CONFIG_FIELDS = frozenset( + ( + "capacity", + "sequential", + "throttle", + "paused", + "name", + "parent_id", + "capacity_default", + "sequential_default", + ) + ) + name = fields.Char() complete_name = fields.Char( compute="_compute_complete_name", store=True, readonly=True, recursive=True @@ -23,13 +39,68 @@ class QueueJobChannel(models.Model): string="Job Functions", ) removal_interval = fields.Integer( - default=lambda self: self.env["queue.job"]._removal_interval, required=True + default=lambda self: self.env["queue.job"]._removal_interval, + required=True, + help="Number of days after which done jobs are deleted.", + ) + capacity = fields.Integer( + help="Maximum number of jobs running at the same time in this channel. " + "0 means no limit, but they are still limited by the capacity of the parent " + "channel. On the root channel, 0 is limited by the global server-wide " + "configuration." + ) + sequential = fields.Boolean( + help="Jobs are executed one after the other and failed jobs block the channel. " + "Requires a capacity of 1." + ) + throttle = fields.Integer( + help="Minimum delay in seconds between the start of two jobs in this channel." + ) + paused = fields.Boolean( + help="A paused channel (an its sub-channels) do not execute any jobs until " + "resumed." + ) + capacity_default = fields.Integer( + help="Default capacity for unconfigured sub-channels. " + "0 means they would have the same capacity as the current channel." + ) + sequential_default = fields.Boolean( + help="If sequential is enabled for unconfigured sub-channels." ) _sql_constraints = [ ("name_uniq", "unique(complete_name)", "Channel complete name must be unique") ] + @api.constrains( + "capacity", "sequential", "throttle", "capacity_default", "sequential_default" + ) + def _check_jobrunner_configuration(self): + for record in self: + if record.capacity < 0: + raise exceptions.ValidationError( + self.env._("The capacity of a channel cannot be negative.") + ) + if record.throttle < 0: + raise exceptions.ValidationError( + self.env._("The throttle of a channel cannot be negative.") + ) + if record.sequential and record.capacity != 1: + raise exceptions.ValidationError( + self.env._("A sequential channel must have a capacity of 1.") + ) + if record.capacity_default < 0: + raise exceptions.ValidationError( + self.env._("The default capacity of a channel cannot be negative.") + ) + if record.sequential_default and record.capacity_default != 1: + raise exceptions.ValidationError( + self.env._( + "A channel with a sequential default must have a " + "default capacity of 1." + ) + ) + @api.depends("name", "parent_id.complete_name") def _compute_complete_name(self): for record in self: @@ -70,8 +141,17 @@ def create(self, vals_list): new_vals_list.append(vals) vals_list = new_vals_list records |= super().create(vals_list) + records._notify_channel_config_changed() return records + @api.onchange("capacity") + def _onchange_capacity(self): + self.capacity_default = self.capacity + + @api.onchange("sequential") + def _onchange_sequential(self): + self.sequential_default = self.sequential + def write(self, values): for channel in self: if ( @@ -80,10 +160,25 @@ def write(self, values): and ("name" in values or "parent_id" in values) ): raise exceptions.UserError(_("Cannot change the root channel")) - return super().write(values) + res = super().write(values) + if self._JOBRUNNER_CONFIG_FIELDS.intersection(values): + self._notify_channel_config_changed() + return res def unlink(self): for channel in self: if channel.name == "root": raise exceptions.UserError(_("Cannot remove the root channel")) - return super().unlink() + res = super().unlink() + self._notify_channel_config_changed() + return res + + def action_pause(self): + self.write({"paused": True}) + + def action_resume(self): + self.write({"paused": False}) + + def _notify_channel_config_changed(self): + """Notify the jobrunner to reload its configuration""" + self.env.cr.execute("SELECT pg_notify('queue_job', %s)", (RELOAD_PAYLOAD,)) diff --git a/queue_job/models/queue_job_function.py b/queue_job/models/queue_job_function.py index edf90c9ab7..d29014adf4 100644 --- a/queue_job/models/queue_job_function.py +++ b/queue_job/models/queue_job_function.py @@ -29,7 +29,8 @@ class QueueJobFunction(models.Model): "related_action_func_name " "related_action_kwargs " "job_function_id " - "allow_commit", + "allow_commit " + "on_fail_method_name", ) def _default_channel(self): @@ -48,6 +49,10 @@ def _default_channel(self): comodel_name="ir.model", string="Model", ondelete="cascade" ) method = fields.Char() + on_fail_method = fields.Char( + help="Model function to be called if the job is failed and will not be " + "retried.", + ) channel_id = fields.Many2one( comodel_name="queue.job.channel", @@ -157,6 +162,7 @@ def job_default_config(self): related_action_kwargs={}, job_function_id=None, allow_commit=False, + on_fail_method_name=None, ) def _parse_retry_pattern(self): @@ -193,6 +199,7 @@ def job_config(self, name): related_action_kwargs=config.related_action.get("kwargs", {}), job_function_id=config.id, allow_commit=config.allow_commit, + on_fail_method_name=config.on_fail_method, ) def _retry_pattern_format_error_message(self): diff --git a/queue_job/readme/CONFIGURE.md b/queue_job/readme/CONFIGURE.md index 7239106218..f76ddf0a66 100644 --- a/queue_job/readme/CONFIGURE.md +++ b/queue_job/readme/CONFIGURE.md @@ -1,16 +1,89 @@ -- Using environment variables and command line: +There are two ways to configure the job runner: + +Set `channels` (or `ODOO_QUEUE_JOB_CHANNELS`) and every database shares the +same channel tree (we will call it server-wide channels): + +``` ini +[queue_job] +channels = root:10,root.priority:3,root.slow:1 +``` + +Leave `channels` unset and set `max_capacity` instead. The job runner then +builds a separate channel tree for each database, based on the *Job Channels* +configured on each database (we will call it per-database channels). + +``` ini +[queue_job] +max_capacity = 10 +``` + +`channels` always has precedence over `max_capacity`. If `channels` is set, the +per-database configuration is not used. If neither `channels` nor +`max_capacity` are set, the default execution mode is per-database channels +with a `max_capacity` of 1. + +In the per-database mode, channels are configured from the *Job Channels* menu +(or by XML data, see the Usage) instead of a global configuration string. + +Alongside `max_capacity`, a global configuration `db_max_capacity` can be set. +It represents the max number of jobs executed at the same time for a single +database (capped by the `max_capacity` anyway): + +``` ini +[queue_job] +max_capacity = 10 +db_max_capacity = 3 # no more than 3 simultaneous jobs per database +``` + +`db_max_capacity` may be an integer or a pattern such as +`prod_*:20,staging:2,*:5`, where the first match wins. When using a pattern, +unmatched databases can be configured by a global pattern at the end (`*:n`), +otherwise they will use the `max_capacity`. + +The root channel capacity of a database can still be set independently, +however, will in any case be capped by the global `max_capacity` and +`db_max_capacity` parameters. + +When set to 0, `max_capacity` or `db_max_capacity` means there is no jobs executed. + +Editing a channel's capacity, sequential flag, throttle or set it to pause from +the *Job Channels* menu **is applied live on the job runner**. + +> [!NOTE] +> A new database still needs the jobrunner to be restarted. + +When using the server-wide channels, the configuration is static and loaded at +startup of the jobrunner. + +The execution of channels by the job runner is defined by: + +- `capacity`: max number of jobs running at once in the channel (`0` means no + limit of its own, the parent channel and `max_capacity` or `db_max_capacity` + still apply) +- `sequential`: jobs run one after the other, and a failed job blocks the + channel (requires a capacity of 1) +- `throttle`: minimum delay, in seconds, between the start of two jobs +- `paused`: stop running jobs in this channel and its sub-channels + + +**Job Runner Configuration Parameters** + +- Using environment variables: - Adjust environment variables (optional): - - `ODOO_QUEUE_JOB_CHANNELS=root:4` or any other channels - configuration. The default is `root:1` + - `ODOO_QUEUE_JOB_CHANNELS=root:4` or any other channels for server-wide + channels + - `ODOO_QUEUE_JOB_MAX_CAPACITY=4`, max number of concurrent jobs (not used + if `ODOO_QUEUE_JOB_CHANNELS` is set) for per-database channels + - `ODOO_QUEUE_JOB_DB_MAX_CAPACITY=2`, max number of concurrent jobs per DB + (not used if `ODOO_QUEUE_JOB_CHANNELS` is set) - `ODOO_QUEUE_JOB_PORT=8069`, default `--http-port` - `ODOO_QUEUE_JOB_SCHEME=https`, default `http` - `ODOO_QUEUE_JOB_HOST=load-balancer`, default `--http-interface` or `localhost` if unset - `ODOO_QUEUE_JOB_HTTP_AUTH_USER=jobrunner`, default empty - `ODOO_QUEUE_JOB_HTTP_AUTH_PASSWORD=s3cr3t`, default empty - - Start Odoo with `--load=web,queue_job` and `--workers` greater than - 1.[^1] -- Using the Odoo configuration file: +- Using the Odoo configuration file (set either `channels`, either + `max_capacity` and/or `db_max_capacity`) ``` ini [options] @@ -21,6 +94,8 @@ server_wide_modules = web,queue_job (...) [queue_job] channels = root:2 +max_capacity = 8 +db_max_capacity = 3 scheme = https host = load-balancer port = 443 @@ -28,17 +103,29 @@ http_auth_user = jobrunner http_auth_password = s3cr3t ``` +- Odoo has to be started with `queue_job` as server-wide module, either using + the command line option `--load=web,queue_job`, either by setting it in the + Odoo configuration file, and `--workers` greater than 1.[^1] + +``` ini +[options] +(...) +workers = 6 +server_wide_modules = web,queue_job +``` + - Confirm the runner is starting correctly by checking the odoo log file: -``` +``` ...INFO...queue_job.jobrunner.runner: starting ...INFO...queue_job.jobrunner.runner: initializing database connections ...INFO...queue_job.jobrunner.runner: queue job runner ready for db ...INFO...queue_job.jobrunner.runner: database connections ready ``` -- Create jobs (eg using `base_import_async`) and observe they start +- Create jobs (you can create test jobs by opening + `https://yourodoourl/queue_job/create_test_job`) and observe they start immediately and in parallel. - Tip: to enable debug logging for the queue job, use `--log-handler=odoo.addons.queue_job:DEBUG` @@ -46,5 +133,17 @@ http_auth_password = s3cr3t [^1]: It works with the threaded Odoo server too, although this way of running Odoo is obviously not for production purposes. -* Jobs that remain in `enqueued` or `started` state (because, for instance, - their worker has been killed) will be automatically re-queued. + +**Migrating from server-wide channels to per-database channels** + +As long as `channels` (or `ODOO_QUEUE_JOB_CHANNELS`) is set, the job +runner keeps using the server-wide channels. + +To move to channels per database: + +1. Configure the channels you need on each database, from the *Job + Channels* menu: capacity, sequential, throttle, pause +2. Once this configuration is done, remove the `channels` options and set + `max_capacity` (and optionally `db_max_capacity`) instead (or their + corresponding environment variables) +3. Restart the job runner diff --git a/queue_job/static/description/index.html b/queue_job/static/description/index.html index 30983ba0c7..70e81392bc 100644 --- a/queue_job/static/description/index.html +++ b/queue_job/static/description/index.html @@ -372,7 +372,7 @@

Job Queue

!! This file is generated by oca-gen-addon-readme !! !! changes will be overwritten. !! !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! -!! source digest: sha256:a5cdcff42c2f45fbb57753e7693373b08e3497d0bc07212dac0f3ab56217eaf2 +!! source digest: sha256:3b16bf21213fd89fc480b21d848a287bc462883118d3e2e1a4ce7ed72d861bed !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! -->

Mature License: LGPL-3 OCA/queue Translate me on Weblate Try me on Runboat

This addon adds an integrated Job Queue to Odoo.

@@ -484,24 +484,87 @@

Installation

Configuration

+

There are two ways to configure the job runner:

+

Set channels (or ODOO_QUEUE_JOB_CHANNELS) and every database +shares the same channel tree (we will call it server-wide channels):

+
+[queue_job]
+channels = root:10,root.priority:3,root.slow:1
+
+

Leave channels unset and set max_capacity instead. The job +runner then builds a separate channel tree for each database, based on +the Job Channels configured on each database (we will call it +per-database channels).

+
+[queue_job]
+max_capacity = 10
+
+

channels always has precedence over max_capacity. If +channels is set, the per-database configuration is not used. If +neither channels nor max_capacity are set, the default execution +mode is per-database channels with a max_capacity of 1.

+

In the per-database mode, channels are configured from the Job +Channels menu (or by XML data, see the Usage) instead of a global +configuration string.

+

Alongside max_capacity, a global configuration db_max_capacity +can be set. It represents the max number of jobs executed at the same +time for a single database (capped by the max_capacity anyway):

+
+[queue_job]
+max_capacity = 10
+db_max_capacity = 3  # no more than 3 simultaneous jobs per database
+
+

db_max_capacity may be an integer or a pattern such as +prod_*:20,staging:2,*:5, where the first match wins. When using a +pattern, unmatched databases can be configured by a global pattern at +the end (*:n), otherwise they will use the max_capacity.

+

The root channel capacity of a database can still be set independently, +however, will in any case be capped by the global max_capacity and +db_max_capacity parameters.

+

When set to 0, max_capacity or db_max_capacity means there is no +jobs executed.

+

Editing a channel’s capacity, sequential flag, throttle or set it to +pause from the Job Channels menu is applied live on the job +runner.

+
+

Note

+

A new database still needs the jobrunner to be restarted.

+
+

When using the server-wide channels, the configuration is static and +loaded at startup of the jobrunner.

+

The execution of channels by the job runner is defined by:

+
    +
  • capacity: max number of jobs running at once in the channel (0 +means no limit of its own, the parent channel and max_capacity or +db_max_capacity still apply)
  • +
  • sequential: jobs run one after the other, and a failed job blocks +the channel (requires a capacity of 1)
  • +
  • throttle: minimum delay, in seconds, between the start of two jobs
  • +
  • paused: stop running jobs in this channel and its sub-channels
  • +
+

Job Runner Configuration Parameters

    -
  • Using environment variables and command line:
      +
    • Using environment variables:
      • Adjust environment variables (optional):
          -
        • ODOO_QUEUE_JOB_CHANNELS=root:4 or any other channels -configuration. The default is root:1
        • +
        • ODOO_QUEUE_JOB_CHANNELS=root:4 or any other channels for +server-wide channels
        • +
        • ODOO_QUEUE_JOB_MAX_CAPACITY=4, max number of concurrent jobs +(not used if ODOO_QUEUE_JOB_CHANNELS is set) for per-database +channels
        • +
        • ODOO_QUEUE_JOB_DB_MAX_CAPACITY=2, max number of concurrent +jobs per DB (not used if ODOO_QUEUE_JOB_CHANNELS is set)
        • ODOO_QUEUE_JOB_PORT=8069, default --http-port
        • ODOO_QUEUE_JOB_SCHEME=https, default http
        • ODOO_QUEUE_JOB_HOST=load-balancer, default --http-interface or localhost if unset
        • ODOO_QUEUE_JOB_HTTP_AUTH_USER=jobrunner, default empty
        • ODOO_QUEUE_JOB_HTTP_AUTH_PASSWORD=s3cr3t, default empty
        • -
        • Start Odoo with --load=web,queue_job and --workers greater -than 1. [1]
    • -
    • Using the Odoo configuration file:
    • +
    • Using the Odoo configuration file (set either channels, either +max_capacity and/or db_max_capacity)
     [options]
    @@ -512,6 +575,8 @@ 

    Configuration

    (...) [queue_job] channels = root:2 +max_capacity = 8 +db_max_capacity = 3 scheme = https host = load-balancer port = 443 @@ -519,6 +584,18 @@

    Configuration

    http_auth_password = s3cr3t
      +
    • Odoo has to be started with queue_job as server-wide module, +either using the command line option --load=web,queue_job, either +by setting it in the Odoo configuration file, and --workers +greater than 1. [1]
    • +
    +
    +[options]
    +(...)
    +workers = 6
    +server_wide_modules = web,queue_job
    +
    +
    • Confirm the runner is starting correctly by checking the odoo log file:
    @@ -529,14 +606,24 @@

    Configuration

    ...INFO...queue_job.jobrunner.runner: database connections ready
      -
    • Create jobs (eg using base_import_async) and observe they start -immediately and in parallel.
    • +
    • Create jobs (you can create test jobs by opening +https://yourodoourl/queue_job/create_test_job) and observe they +start immediately and in parallel.
    • Tip: to enable debug logging for the queue job, use --log-handler=odoo.addons.queue_job:DEBUG
    • -
    • Jobs that remain in enqueued or started state (because, for -instance, their worker has been killed) will be automatically -re-queued.
    +

    Migrating from server-wide channels to per-database channels

    +

    As long as channels (or ODOO_QUEUE_JOB_CHANNELS) is set, the job +runner keeps using the server-wide channels.

    +

    To move to channels per database:

    +
      +
    1. Configure the channels you need on each database, from the Job +Channels menu: capacity, sequential, throttle, pause
    2. +
    3. Once this configuration is done, remove the channels options and +set max_capacity (and optionally db_max_capacity) instead (or +their corresponding environment variables)
    4. +
    5. Restart the job runner
    6. +
    diff --git a/queue_job/tests/common.py b/queue_job/tests/common.py index ca88c31b47..8773d51cc6 100644 --- a/queue_job/tests/common.py +++ b/queue_job/tests/common.py @@ -1,21 +1,24 @@ # Copyright 2019 Camptocamp # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). -import doctest -import logging import typing from contextlib import contextmanager from itertools import groupby from operator import attrgetter from unittest import TestCase, mock -from odoo.tests.case import TestCase as _TestCase -from odoo.tests.common import MetaCase +from odoo.tests.common import SETATTR_SOURCES from odoo.addons.queue_job.delay import Graph # pylint: disable=odoo-addons-relative-import from odoo.addons.queue_job.job import Job +# Register queue_job patcher as a known path to prevent verbose warning +# in tests when methods are patched. +SETATTR_SOURCES["_patch_method"] = tuple( + list(SETATTR_SOURCES.get("_patch_method", [])) + ["/queue_job/models/base.py"], +) + @contextmanager def trap_jobs(): @@ -211,7 +214,7 @@ def assert_enqueued_job(self, method, args=None, kwargs=None, properties=None): if expected_call not in actual_calls: raise AssertionError( - "Job {} was not enqueued.\n" "Actual enqueued jobs:\n{}".format( + "Job {} was not enqueued.\nActual enqueued jobs:\n{}".format( self._format_job_call(expected_call), "\n".join( f" * {self._format_job_call(call)}" for call in actual_calls @@ -411,51 +414,3 @@ def test_export(self): delayable = mock.MagicMock(name="DelayableBinding") delayable_cls.return_value = delayable yield delayable_cls, delayable - - -class OdooDocTestCase(doctest.DocTestCase, _TestCase, MetaCase("DummyCase", (), {})): - """ - We need a custom DocTestCase class in order to: - - define test_tags to run as part of standard tests - - output a more meaningful test name than default "DocTestCase.runTest" - """ - - def __init__( - self, doctest, optionflags=0, setUp=None, tearDown=None, checker=None, seq=0 - ): - super().__init__( - doctest._dt_test, - optionflags=optionflags, - setUp=setUp, - tearDown=tearDown, - checker=checker, - ) - self.test_sequence = seq - - def setUp(self): - """Log an extra statement which test is started.""" - super().setUp() - logging.getLogger(__name__).info("Running tests for %s", self._dt_test.name) - - -def load_doctests(module): - """ - Generates a tests loading method for the doctests of the given module - https://docs.python.org/3/library/unittest.html#load-tests-protocol - """ - - def load_tests(loader, tests, ignore): - """ - Apply the 'test_tags' attribute to each DocTestCase found by the DocTestSuite. - Also extend the DocTestCase class trivially to fit the class teardown - that Odoo backported for its own test classes from Python 3.8. - """ - - for idx, test in enumerate(doctest.DocTestSuite(module)): - odoo_test = OdooDocTestCase(test, seq=idx) - odoo_test.test_tags = {"standard", "at_install", "queue_job", "doctest"} - tests.addTest(odoo_test) - - return tests - - return load_tests diff --git a/queue_job/tests/test_model_job_channel.py b/queue_job/tests/test_model_job_channel.py index 20ebbc0bfe..37a1f89485 100644 --- a/queue_job/tests/test_model_job_channel.py +++ b/queue_job/tests/test_model_job_channel.py @@ -1,9 +1,12 @@ # copyright 2018 Camptocamp # license lgpl-3.0 or later (http://www.gnu.org/licenses/lgpl.html) +from unittest import mock + from psycopg2 import IntegrityError import odoo +from odoo import exceptions from odoo.tests import common @@ -57,3 +60,127 @@ def test_channel_display_name(self): {"name": "test", "parent_id": self.root_channel.id} ) self.assertEqual(channel.display_name, channel.complete_name) + + def test_capacity_should_not_be_negative(self): + with self.assertRaisesRegex( + exceptions.ValidationError, + "The capacity of a channel cannot be negative.", + ): + self.Channel.create( + { + "name": "test_capacity", + "parent_id": self.root_channel.id, + "capacity": -1, + } + ) + + def test_capacity_default_should_not_be_negative(self): + with self.assertRaisesRegex( + exceptions.ValidationError, + "The default capacity of a channel cannot be negative.", + ): + self.Channel.create( + { + "name": "test_capacity_default", + "parent_id": self.root_channel.id, + "capacity_default": -1, + } + ) + + def test_throttle_should_not_be_negative(self): + with self.assertRaisesRegex( + exceptions.ValidationError, + "The throttle of a channel cannot be negative.", + ): + self.Channel.create( + { + "name": "test_throttle", + "parent_id": self.root_channel.id, + "throttle": -1, + } + ) + + def test_sequential_should_have_capacity_one(self): + with self.assertRaisesRegex( + exceptions.ValidationError, + "A sequential channel must have a capacity of 1.", + ): + self.Channel.create( + { + "name": "test_sequential", + "parent_id": self.root_channel.id, + "sequential": True, + "capacity": 2, + } + ) + + def test_sequential_default_should_have_capacity_default_one(self): + with self.assertRaisesRegex( + exceptions.ValidationError, + "A channel with a sequential default must have a default capacity of 1.", + ): + self.Channel.create( + { + "name": "test_sequential_default", + "parent_id": self.root_channel.id, + "sequential_default": True, + "capacity_default": 2, + } + ) + + def test_action_pause(self): + self.root_channel.action_pause() + self.assertTrue(self.root_channel.paused) + + def test_action_resume(self): + self.root_channel.paused = True + self.root_channel.action_resume() + self.assertFalse(self.root_channel.paused) + + def _patch_notify(self): + return mock.patch.object( + type(self.Channel), "_notify_channel_config_changed", autospec=True + ) + + def test_notify_create_channel(self): + with self._patch_notify() as notify: + self.Channel.create( + { + "name": "create_notify", + "parent_id": self.root_channel.id, + "capacity": 2, + } + ) + notify.assert_called_once() + + def test_notify_write_jobrunner_config(self): + channel = self.Channel.create( + {"name": "write_notify", "parent_id": self.root_channel.id} + ) + with self._patch_notify() as notify: + channel.capacity = 3 + notify.assert_called_once() + + with self._patch_notify() as notify: + channel.paused = True + notify.assert_called_once() + + with self._patch_notify() as notify: + channel.removal_interval = 60 + notify.assert_not_called() + + with self._patch_notify() as notify: + channel.capacity_default = 1 + notify.assert_called_once() + + with self._patch_notify() as notify: + channel.sequential_default = True + notify.assert_called_once() + + def test_notify_unlink_channel(self): + channel = self.Channel.create( + {"name": "unlink_notify", "parent_id": self.root_channel.id} + ) + with self._patch_notify() as notify: + channel.unlink() + notify.assert_called_once() diff --git a/queue_job/tests/test_model_job_function.py b/queue_job/tests/test_model_job_function.py index 9095f2a55e..b835c3c4ee 100644 --- a/queue_job/tests/test_model_job_function.py +++ b/queue_job/tests/test_model_job_function.py @@ -35,6 +35,7 @@ def test_function_job_config(self): { "model_id": self.env.ref("base.model_res_users").id, "method": "read", + "on_fail_method": "search_read", "channel_id": channel.id, "edit_retry_pattern": "{1: 2, 3: 4}", "edit_related_action": ( @@ -55,5 +56,6 @@ def test_function_job_config(self): related_action_kwargs={"b": 1}, job_function_id=job_function.id, allow_commit=True, + on_fail_method_name="search_read", ), ) diff --git a/queue_job/tests/test_run_rob_controller.py b/queue_job/tests/test_run_rob_controller.py index 1a15f4363a..eb1b5cace5 100644 --- a/queue_job/tests/test_run_rob_controller.py +++ b/queue_job/tests/test_run_rob_controller.py @@ -1,12 +1,23 @@ # License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). +from unittest.mock import patch from odoo.tests.common import TransactionCase +from odoo.tools import mute_logger from ..controllers.main import RunJobController +from ..exception import JobError from ..job import Job class TestRunJobController(TransactionCase): + def setUp(self): + super().setUp() + + def _clean_queue_job(): + self.env["queue.job"].search([]).unlink() + + self.addCleanup(_clean_queue_job) + def test_get_failure_values(self): method = self.env["res.users"].mapped job = Job(method) @@ -21,3 +32,31 @@ def test_runjob_success(self): RunJobController._runjob(self.env, job) self.assertEqual(job.state, "done") self.assertEqual(job.db_record().state, "done") + + def test_runjob_on_fail(self): + function = self.env.ref("queue_job.job_function_queue_job__test_job") + function.on_fail_method = "_test_on_fail" + job = self.env["queue.job"].with_delay()._test_job(failure_rate=1) + with ( + self.assertRaises(JobError), + patch( + "odoo.addons.queue_job.models.queue_job.QueueJob._test_on_fail" + ) as mocked_hook, + patch("odoo.addons.queue_job.job.Job.in_temporary_env") as mocked_temp_env, + mute_logger("odoo.addons.queue_job.controllers.main"), + ): + mocked_temp_env.return_value.__enter__.return_value = self.env + RunJobController._runjob(self.env, job) + self.assertEqual(job.state, "failed") + self.assertEqual(mocked_hook.call_count, 1) + + def test_runjob_on_fail_not_configured(self): + job = self.env["queue.job"].with_delay()._test_job(failure_rate=1) + with ( + self.assertRaises(JobError), + patch("odoo.addons.queue_job.job.Job.in_temporary_env") as mocked_temp_env, + mute_logger("odoo.addons.queue_job.controllers.main"), + ): + mocked_temp_env.return_value.__enter__.return_value = self.env + RunJobController._runjob(self.env, job) + self.assertEqual(job.state, "failed") diff --git a/queue_job/tests/test_runner_channels.py b/queue_job/tests/test_runner_channels.py index d323d00683..313e350bd3 100644 --- a/queue_job/tests/test_runner_channels.py +++ b/queue_job/tests/test_runner_channels.py @@ -1,10 +1,18 @@ # Copyright 2015-2016 Camptocamp SA # License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html) +import doctest + +from odoo.tests import BaseCase, tagged # pylint: disable=odoo-addons-relative-import # we are testing, we want to test as we were an external consumer of the API from odoo.addons.queue_job.jobrunner import channels -from .common import load_doctests -load_tests = load_doctests(channels) +@tagged("doctest") +class TestDoctest(BaseCase): + def test_doctest(self): + results = doctest.testmod( + channels, exclude_empty=True, optionflags=doctest.REPORT_ONLY_FIRST_FAILURE + ) + self.assertEqual(results.failed, 0, "doctest failed") diff --git a/queue_job/tests/test_runner_runner.py b/queue_job/tests/test_runner_runner.py index 131ce6322d..93b5674237 100644 --- a/queue_job/tests/test_runner_runner.py +++ b/queue_job/tests/test_runner_runner.py @@ -1,21 +1,75 @@ # Copyright 2015-2016 Camptocamp SA # License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html) - -# pylint: disable=odoo-addons-relative-import -# we are testing, we want to test as we were an external consumer of the API +import doctest import os +from unittest import mock from odoo.tests import BaseCase, tagged +from odoo.tools import mute_logger from odoo.addons.queue_job.jobrunner import runner -from .common import load_doctests +# pylint: disable=odoo-addons-relative-import +# we are testing, we want to test as we were an external consumer of the API +from odoo.addons.queue_job.jobrunner.channels import ChannelConfig, ChannelManager + -load_tests = load_doctests(runner) +@tagged("doctest") +class TestDoctest(BaseCase): + def test_doctest(self): + results = doctest.testmod( + runner, exclude_empty=True, optionflags=doctest.REPORT_ONLY_FIRST_FAILURE + ) + self.assertEqual(results.failed, 0, "doctest failed") @tagged("-at_install", "post_install") class TestRunner(BaseCase): + def setUp(self): + super().setUp() + # ensure there is no collision with actual environment variables/config + env_patcher = mock.patch.dict( + "os.environ", + { + "ODOO_QUEUE_JOB_MAX_CAPACITY": "", + "ODOO_QUEUE_JOB_DB_MAX_CAPACITY": "", + "ODOO_QUEUE_JOB_CHANNELS": "", + }, + ) + env_patcher.start() + self.addCleanup(env_patcher.stop) + + conf_patcher = mock.patch.object(runner, "queue_job_config", {}) + conf_patcher.start() + self.addCleanup(conf_patcher.stop) + + @staticmethod + def _mock_db(db_name, channels_config): + """Mock a Database + + :param channels_config: list of ChannelConfig as configured in the DB + """ + return mock.MagicMock( + db_name=db_name, + has_queue_job=True, + load_channels_config=mock.MagicMock(return_value=channels_config), + ) + + def _register_channel_manager(self, jobrunner, db_name, channel_manager): + jobrunner._channel_manager_by_db[db_name] = channel_manager + jobrunner._channel_managers.append(channel_manager) + + def _new_channel_manager(self, jobrunner, db_name, channel_config, pending_jobs=0): + channel_manager = ChannelManager() + channel_manager.configure(channel_config) + self._register_channel_manager(jobrunner, db_name, channel_manager) + jobrunner.db_by_name[db_name] = self._mock_db(db_name, channel_config) + for number in range(pending_jobs): + channel_manager.notify( + db_name, "root", f"{db_name}-{number}", number, 0, 10, None, "pending" + ) + return channel_manager + @classmethod def _is_open_file_descriptor(cls, fd): try: @@ -57,3 +111,378 @@ def test_runner_file_closed_write_descriptor(self): self.assertFalse(self._is_open_file_descriptor(read_fd)) self.assertFalse(self._is_open_file_descriptor(write_fd)) + + def test_max_capacity_from_env(self): + with ( + mock.patch.dict( + os.environ, {"ODOO_QUEUE_JOB_MAX_CAPACITY": "5"}, clear=True + ), + mock.patch.object(runner, "queue_job_config", {}), + ): + self.assertEqual(runner._max_capacity(), 5) + + def test_max_capacity_from_odoo_config(self): + with ( + mock.patch.dict(os.environ, {}, clear=True), + mock.patch.object(runner, "queue_job_config", {"max_capacity": "3"}), + ): + self.assertEqual(runner._max_capacity(), 3) + + def test_max_capacity_env_priority_over_odoo_config(self): + with ( + mock.patch.dict( + os.environ, {"ODOO_QUEUE_JOB_MAX_CAPACITY": "5"}, clear=True + ), + mock.patch.object(runner, "queue_job_config", {"max_capacity": "3"}), + ): + self.assertEqual(runner._max_capacity(), 5) + + def test_max_capacity_default(self): + with ( + mock.patch.dict(os.environ, {}, clear=True), + mock.patch.object(runner, "queue_job_config", {}), + ): + self.assertEqual(runner._max_capacity(), 1) + + def test_max_capacity_zero(self): + with ( + mock.patch.dict( + os.environ, {"ODOO_QUEUE_JOB_MAX_CAPACITY": "0"}, clear=True + ), + mock.patch.object(runner, "queue_job_config", {}), + ): + self.assertEqual(runner._max_capacity(), 0) + + def test_db_max_capacity_from_env(self): + with ( + mock.patch.dict( + os.environ, {"ODOO_QUEUE_JOB_DB_MAX_CAPACITY": "prod_*:20,*:5"} + ), + mock.patch.object(runner, "queue_job_config", {}), + ): + self.assertEqual(runner._db_max_capacity(), "prod_*:20,*:5") + + def test_db_max_capacity_from_odoo_config(self): + with ( + mock.patch.dict(os.environ, {}, clear=True), + mock.patch.object( + runner, "queue_job_config", {"db_max_capacity": "prod_*:20,*:5"} + ), + ): + self.assertEqual(runner._db_max_capacity(), "prod_*:20,*:5") + + def test_db_max_capacity_env_priority_over_odoo_config(self): + with ( + mock.patch.dict(os.environ, {"ODOO_QUEUE_JOB_DB_MAX_CAPACITY": "*:5"}), + mock.patch.object(runner, "queue_job_config", {"db_max_capacity": "*:3"}), + ): + self.assertEqual(runner._db_max_capacity(), "*:5") + + def test_db_max_capacity_default(self): + with ( + mock.patch.dict(os.environ, {}, clear=True), + mock.patch.object(runner, "queue_job_config", {}), + ): + self.assertEqual(runner._db_max_capacity(), "") + + def test_max_capacity_negative(self): + jobrunner = runner.QueueJobRunner(max_capacity=-5) + self.assertEqual(jobrunner.max_capacity, 0) + + def test_max_capacity_equals_to_channel_config_string(self): + jobrunner = runner.QueueJobRunner(channel_config_string="root:3") + self.assertEqual(jobrunner.max_capacity, 3) + + def test_max_capacity_server_wide_has_priority(self): + jobrunner = runner.QueueJobRunner( + channel_config_string="root:3", max_capacity=1 + ) + self.assertEqual(jobrunner.max_capacity, 3) + + def test_max_capacity_channel_config_no_root(self): + jobrunner = runner.QueueJobRunner(channel_config_string="sub:3") + self.assertEqual(jobrunner.max_capacity, 1) + + def test_no_job_dispatched_when_no_database_channel_manager(self): + jobrunner = runner.QueueJobRunner(max_capacity=3) + with mock.patch.object(jobrunner, "_dispatch_job") as dispatch: + jobrunner.run_jobs() + dispatch.assert_not_called() + + def test_global_capacity_across_databases(self): + jobrunner = runner.QueueJobRunner(max_capacity=3) + manager_a = self._new_channel_manager( + jobrunner, "db_a", [ChannelConfig("root", 5)], pending_jobs=4 + ) + manager_b = self._new_channel_manager( + jobrunner, "db_b", [ChannelConfig("root", 5)], pending_jobs=4 + ) + with mock.patch.object(jobrunner, "_dispatch_job") as dispatch: + jobrunner.run_jobs() + # number of running jobs must be limited by max_capacity + self.assertEqual(dispatch.call_count, 3) + self.assertEqual(manager_a.running_count + manager_b.running_count, 3) + + def test_round_robin(self): + jobrunner = runner.QueueJobRunner(max_capacity=3) + self._new_channel_manager( + jobrunner, "db_a", [ChannelConfig("root", 3)], pending_jobs=2 + ) + self._new_channel_manager( + jobrunner, "db_b", [ChannelConfig("root", 3)], pending_jobs=2 + ) + dispatched = [] + with mock.patch.object( + jobrunner, "_dispatch_job", side_effect=lambda job: dispatched.append(job) + ): + jobrunner.run_jobs() + # with max_capacity=3 and 2 pending jobs per db, the first + # run_jobs() will dispatch 3 jobs in alternating order + self.assertEqual( + [job.db_name for job in dispatched], ["db_a", "db_b", "db_a"] + ) + # notify the first dispatched job as done so a slot is freed up + # so we can check that the next call to run_jobs() continues + # to round-robin + jobrunner._channel_manager_by_db[dispatched[0].db_name].notify( + dispatched[0].db_name, + "root", + dispatched[0].uuid, + dispatched[0].seq, + 0, + 10, + None, + "done", + ) + jobrunner.run_jobs() + # round-robin continues on the next dispatch "ticks" + self.assertEqual( + [job.db_name for job in dispatched], ["db_a", "db_b", "db_a", "db_b"] + ) + + def test_round_robin_empty_channel_manager(self): + jobrunner = runner.QueueJobRunner(max_capacity=3) + self._new_channel_manager( + jobrunner, "db_a", [ChannelConfig("root", 3)], pending_jobs=1 + ) + self._new_channel_manager( + jobrunner, "db_b", [ChannelConfig("root", 3)], pending_jobs=3 + ) + dispatched = [] + with mock.patch.object( + jobrunner, "_dispatch_job", side_effect=lambda job: dispatched.append(job) + ): + jobrunner.run_jobs() + # round-robin continues with the only channel manager still having pending jobs + self.assertEqual([job.db_name for job in dispatched], ["db_a", "db_b", "db_b"]) + + def test_build_channel_manager_root_db_max_capacity(self): + jobrunner = runner.QueueJobRunner(max_capacity=10) + db = self._mock_db("db_a", []) + channel_manager = jobrunner._build_channel_manager(db) + root = channel_manager.get_channel_by_name("root") + # the root channels inherits the global max capacity + self.assertEqual(root.capacity, 10) + + def test_build_channel_manager_db_max_zero(self): + jobrunner = runner.QueueJobRunner(max_capacity=3, db_max_capacity="db_a:0,*:5") + db = self._mock_db("db_a", [ChannelConfig("root", 5)]) + channel_manager = jobrunner._build_channel_manager(db) + root = channel_manager.get_channel_by_name("root") + # when db max capacity is at 0, the root channel of the channel manager + # is considered paused + self.assertTrue(root.paused) + + def test_build_channel_manager_db_max_negative(self): + jobrunner = runner.QueueJobRunner(max_capacity=3, db_max_capacity="db_a:-1,*:5") + db = self._mock_db("db_a", [ChannelConfig("root", 5)]) + channel_manager = jobrunner._build_channel_manager(db) + root = channel_manager.get_channel_by_name("root") + # negative value is handled as 0, which is paused + self.assertTrue(root.paused) + + def test_build_channel_manager_root_capacity_db(self): + jobrunner = runner.QueueJobRunner(max_capacity=3, db_max_capacity="*:2") + db = self._mock_db("db_a", [ChannelConfig("root", 20)]) + channel_manager = jobrunner._build_channel_manager(db) + root = channel_manager.get_channel_by_name("root") + # the root channel is set to the db max capacity + self.assertEqual(root.capacity, 2) + + @mute_logger("odoo.addons.queue_job.jobrunner.runner") + def test_build_channel_manager_schema_error(self): + jobrunner = runner.QueueJobRunner(max_capacity=3) + # when the database schema has no columns, None is returned as channels config + db = self._mock_db("db_a", None) + channel_manager = jobrunner._build_channel_manager(db) + root = channel_manager.get_channel_by_name("root") + self.assertEqual(root.capacity, 0) + self.assertTrue(root.paused) + + def test_build_channel_manager_handle_missing_root(self): + jobrunner = runner.QueueJobRunner(max_capacity=3) + # the configuration does not have root + db = self._mock_db("db_a", [ChannelConfig("sub", 2)]) + channel_manager = jobrunner._build_channel_manager(db) + # does not raise, "root" is automatically added + root = channel_manager.get_channel_by_name("root", autocreate=False) + self.assertEqual(root.capacity, 3) + + def test_register_db_server_wide(self): + jobrunner = runner.QueueJobRunner(channel_config_string="root:3") + global_manager = jobrunner._server_wide_channel_manager + db = self._mock_db("db_a", None) + jobrunner._register_db_server_wide(db) + + self.assertEqual(len(jobrunner._channel_managers), 1) + self.assertEqual(jobrunner._channel_managers, [global_manager]) + self.assertEqual(jobrunner._channel_manager_by_db[db.db_name], global_manager) + + def test_max_capacity_zero_no_dispatch(self): + jobrunner = runner.QueueJobRunner(max_capacity=0) + self._new_channel_manager( + jobrunner, "db_a", [ChannelConfig("root", 10)], pending_jobs=4 + ) + self._new_channel_manager( + jobrunner, "db_b", [ChannelConfig("root", 10)], pending_jobs=4 + ) + with mock.patch.object(jobrunner, "_dispatch_job") as dispatch: + jobrunner.run_jobs() + dispatch.assert_not_called() + + def test_max_capacity_unconfigured_defaults_to_one(self): + jobrunner = runner.QueueJobRunner() + self.assertEqual(jobrunner.max_capacity, 1) + self._new_channel_manager( + jobrunner, "db_a", [ChannelConfig("root", 10)], pending_jobs=4 + ) + self._new_channel_manager( + jobrunner, "db_b", [ChannelConfig("root", 10)], pending_jobs=4 + ) + with mock.patch.object(jobrunner, "_dispatch_job") as dispatch: + jobrunner.run_jobs() + self.assertEqual(dispatch.call_count, 1) + + def test_notify_reload(self): + jobrunner = runner.QueueJobRunner(max_capacity=3) + db = self._mock_db("db_a", []) + db.conn.notifies = [mock.Mock(payload=runner.RELOAD_PAYLOAD)] + jobrunner.db_by_name = {"db_a": db} + + with mock.patch.object(jobrunner, "_configure_db") as reconfigure: + jobrunner.process_notifications() + reconfigure.assert_called_once_with(db) + + def test_notify_reload_ignored_with_server_wide_channels(self): + jobrunner = runner.QueueJobRunner(channel_config_string="root:3") + db = self._mock_db("db_a", []) + db.conn.notifies = [mock.Mock(payload=runner.RELOAD_PAYLOAD)] + jobrunner.db_by_name = {"db_a": db} + jobrunner._channel_manager_by_db = {"db_a": mock.MagicMock()} + with mock.patch.object(jobrunner, "_configure_db") as reconfigure: + jobrunner.process_notifications() + reconfigure.assert_not_called() + + def test_notify_job_started(self): + jobrunner = runner.QueueJobRunner(max_capacity=3) + db = self._mock_db("db_a", None) + channel_manager = ChannelManager() + channel_manager.configure([ChannelConfig("root", 3)]) + jobrunner.db_by_name = {"db_a": db} + self._register_channel_manager(jobrunner, "db_a", channel_manager) + + db.conn.notifies = [mock.Mock(payload="job-1")] + # channel, uuid, seq, date_created, priority, eta, state + job_row = ("root", "job-1", 0, 0, 10, None, "started") + db.select_jobs.return_value.__enter__.return_value.fetchone.return_value = ( + job_row + ) + + jobrunner.process_notifications() + + db.select_jobs.assert_called_once_with("uuid = %s", ("job-1",)) + # the channel manager now counts the job as a running job + self.assertEqual(channel_manager.running_count, 1) + + @mute_logger("odoo.addons.queue_job.jobrunner.runner") + def test_build_channel_manager_invalid_configuration(self): + jobrunner = runner.QueueJobRunner(max_capacity=3) + db = self._mock_db("db_a", [ChannelConfig("root", 2, sequential=True)]) + + channel_manager = jobrunner._build_channel_manager(db) + + root = channel_manager.get_channel_by_name("root") + self.assertTrue(root.paused) + + def test_notify_existing_jobs(self): + jobrunner = runner.QueueJobRunner(max_capacity=3) + db = self._mock_db("db_a", [ChannelConfig("root", 3)]) + # channel, uuid, seq, date_created, priority, eta, state + job_row = ("root", "db_a-job1", 0, 0, 10, None, "pending") + db.select_jobs.return_value.__enter__.return_value = [job_row] + + jobrunner.db_by_name = {"db_a": db} + + channel_manager = jobrunner._build_channel_manager(db) + self._register_channel_manager(jobrunner, "db_a", channel_manager) + + jobrunner._notify_existing_jobs(db, channel_manager) + + db.select_jobs.assert_called_once_with("state in %s", (runner.NOT_DONE,)) + pending_jobs = list(channel_manager.get_jobs_to_run(0)) + self.assertEqual([job.uuid for job in pending_jobs], ["db_a-job1"]) + + def test_close_databases_reset_state(self): + jobrunner = runner.QueueJobRunner(max_capacity=3) + channel_manager = self._new_channel_manager( + jobrunner, "db_a", [ChannelConfig("root", 3)], pending_jobs=1 + ) + db = jobrunner.db_by_name["db_a"] + + jobrunner.close_databases() + + self.assertEqual(list(channel_manager.get_jobs_to_run(0)), []) + db.close.assert_called_once() + self.assertEqual(jobrunner.db_by_name, {}) + self.assertEqual(jobrunner._channel_manager_by_db, {}) + self.assertEqual(jobrunner._channel_managers, []) + + def test_close_databases_remove_jobs_false(self): + jobrunner = runner.QueueJobRunner(max_capacity=3) + channel_manager = self._new_channel_manager( + jobrunner, "db_a", [ChannelConfig("root", 3)], pending_jobs=1 + ) + + jobrunner.close_databases(remove_jobs=False) + + # job is kept when remove_jobs=False + self.assertEqual(len(list(channel_manager.get_jobs_to_run(0))), 1) + + def test_next_wakeup_time_no_channel_managers(self): + jobrunner = runner.QueueJobRunner(max_capacity=3) + self.assertEqual(jobrunner.next_wakeup_time(), 0) + + def test_next_wakeup_time_one_db_with_eta(self): + jobrunner = runner.QueueJobRunner(max_capacity=3) + channel_manager = self._new_channel_manager( + jobrunner, "db_a", [ChannelConfig("root", 3)] + ) + channel_manager.notify("db_a", "root", "db_a-0", 0, 0, 10, 500, "pending") + self.assertEqual(jobrunner.next_wakeup_time(), 500) + + def test_next_wakeup_time_several_db(self): + jobrunner = runner.QueueJobRunner(max_capacity=3) + channel_manager_a = self._new_channel_manager( + jobrunner, "db_a", [ChannelConfig("root", 3)] + ) + channel_manager_b = self._new_channel_manager( + jobrunner, "db_b", [ChannelConfig("root", 3)] + ) + channel_manager_c = self._new_channel_manager( + jobrunner, "db_c", [ChannelConfig("root", 3)] + ) + # channel, uuid, seq, date_created, priority, eta, state + channel_manager_a.notify("db_a", "root", "db_a-0", 0, 0, 10, 500, "pending") + channel_manager_b.notify("db_b", "root", "db_b-0", 0, 0, 10, 200, "pending") + channel_manager_c.notify("db_c", "root", "db_b-0", 0, 0, 10, 0, "pending") + self.assertEqual(jobrunner.next_wakeup_time(), 200) diff --git a/queue_job/utils.py b/queue_job/utils.py index 66b0430c3e..d037b8b530 100644 --- a/queue_job/utils.py +++ b/queue_job/utils.py @@ -4,6 +4,8 @@ import logging import os +from odoo import tools + _logger = logging.getLogger(__name__) @@ -17,5 +19,6 @@ def must_run_without_delay(env): return True if env.context.get("queue_job__no_delay"): - _logger.info("`queue_job__no_delay` ctx key found. NO JOB scheduled.") + if not tools.config["test_enable"]: + _logger.info("`queue_job__no_delay` ctx key found. NO JOB scheduled.") return True diff --git a/queue_job/views/queue_job_channel_views.xml b/queue_job/views/queue_job_channel_views.xml index 50c245716b..2c4341239d 100644 --- a/queue_job/views/queue_job_channel_views.xml +++ b/queue_job/views/queue_job_channel_views.xml @@ -5,23 +5,66 @@ queue.job.channel
    - - + + + + + diff --git a/queue_job_subscribe/i18n/sl.po b/queue_job_subscribe/i18n/sl.po index 65fe1eaf3a..aa6e2902ba 100644 --- a/queue_job_subscribe/i18n/sl.po +++ b/queue_job_subscribe/i18n/sl.po @@ -10,15 +10,16 @@ msgstr "" "Project-Id-Version: Odoo Server 9.0c\n" "Report-Msgid-Bugs-To: \n" "POT-Creation-Date: 2017-04-05 00:42+0000\n" -"PO-Revision-Date: 2017-04-05 00:42+0000\n" -"Last-Translator: Matjaž Mozetič , 2017\n" +"PO-Revision-Date: 2026-06-16 20:46+0000\n" +"Last-Translator: Matjaz Mozetic \n" "Language-Team: Slovenian (https://www.transifex.com/oca/teams/23907/sl/)\n" "Language: sl\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" "Content-Transfer-Encoding: \n" -"Plural-Forms: nplurals=4; plural=(n%100==1 ? 0 : n%100==2 ? 1 : n%100==3 || " -"n%100==4 ? 2 : 3);\n" +"Plural-Forms: nplurals=4; plural=n%100==1 ? 0 : n%100==2 ? 1 : n%100==3 || " +"n%100==4 ? 2 : 3;\n" +"X-Generator: Weblate 5.15.2\n" #. module: queue_job_subscribe #: model_terms:ir.ui.view,arch_db:queue_job_subscribe.view_user_connector_form @@ -36,19 +37,18 @@ msgstr "" #. module: queue_job_subscribe #: model:ir.model.fields,field_description:queue_job_subscribe.field_res_users__subscribe_job -#, fuzzy msgid "Job Notifications" -msgstr "Obvestila o delu" +msgstr "Obvestila o nalogah" #. module: queue_job_subscribe #: model:ir.model,name:queue_job_subscribe.model_queue_job msgid "Queue Job" -msgstr "Delo čakalne vrste" +msgstr "Naloga v čakalni vrsti" #. module: queue_job_subscribe #: model:ir.model,name:queue_job_subscribe.model_res_users msgid "User" -msgstr "" +msgstr "Uporabnik" #~ msgid "Users" #~ msgstr "Uporabniki" diff --git a/renovate.json b/renovate.json new file mode 100644 index 0000000000..bd6c2a4f6f --- /dev/null +++ b/renovate.json @@ -0,0 +1,6 @@ +{ + "$schema": "https://docs.renovatebot.com/renovate-schema.json", + "extends": ["config:recommended", ":disableDependencyDashboard"], + "baseBranchPatterns": ["19.0", "18.0", "17.0", "16.0", "15.0"], + "enabledManagers": ["copier"] +} diff --git a/setup/_metapackage/pyproject.toml b/setup/_metapackage/pyproject.toml index 8ad6da4a41..a7f0370299 100644 --- a/setup/_metapackage/pyproject.toml +++ b/setup/_metapackage/pyproject.toml @@ -1,12 +1,13 @@ [project] name = "odoo-addons-oca-queue" -version = "18.0.20250415.0" +version = "18.0.20260526.0" dependencies = [ "odoo-addon-base_import_async==18.0.*", "odoo-addon-queue_job==18.0.*", "odoo-addon-queue_job_batch==18.0.*", "odoo-addon-queue_job_cron==18.0.*", "odoo-addon-queue_job_cron_jobrunner==18.0.*", + "odoo-addon-queue_job_profiler==18.0.*", "odoo-addon-queue_job_subscribe==18.0.*", "odoo-addon-test_queue_job==18.0.*", "odoo-addon-test_queue_job_batch==18.0.*", diff --git a/test_queue_job/__manifest__.py b/test_queue_job/__manifest__.py index 0064305f70..e49661b6a3 100644 --- a/test_queue_job/__manifest__.py +++ b/test_queue_job/__manifest__.py @@ -3,7 +3,7 @@ { "name": "Queue Job Tests", - "version": "18.0.2.0.5", + "version": "18.0.2.1.0", "author": "Camptocamp,Odoo Community Association (OCA)", "license": "LGPL-3", "category": "Generic Modules", diff --git a/test_queue_job/data/queue_job_function_data.xml b/test_queue_job/data/queue_job_function_data.xml index 8338045141..c01903b0f3 100644 --- a/test_queue_job/data/queue_job_function_data.xml +++ b/test_queue_job/data/queue_job_function_data.xml @@ -20,6 +20,14 @@ job_with_retry_pattern__no_zero + + + job_commit_with_arg_records + +