diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 0d8e57d9f..dd65f46e0 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -52,6 +52,9 @@ jobs: if: github.event.pull_request.draft == false needs: rubocop runs-on: ubuntu-latest + permissions: + contents: read + actions: write # Buildx GHA cache read/write steps: - name: Checkout uses: actions/checkout@v4 @@ -62,9 +65,9 @@ jobs: mv /tmp/gist/* test/fixtures rm -rf /tmp/gist - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v2 + uses: docker/setup-buildx-action@v3 - name: Build - uses: docker/build-push-action@v2 + uses: docker/build-push-action@v6 with: context: . file: Dockerfile @@ -74,12 +77,8 @@ jobs: VROOM_VERSION=${{ secrets.VROOM_VERSION }} tags: optimizer-api:latest outputs: type=docker,dest=/tmp/optimizer-api.tar - cache-from: type=local,src=/tmp/.buildx-cache - cache-to: type=local,mode=max,dest=/tmp/.buildx-cache-new - - name: Move buildx cache - run: | - rm -rf /tmp/.buildx-cache - mv /tmp/.buildx-cache-new /tmp/.buildx-cache + cache-from: type=gha + cache-to: type=gha,mode=max - name: Upload Docker image artifact uses: actions/upload-artifact@v4 with: diff --git a/Dockerfile b/Dockerfile index 46d481bdc..ccf161a87 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,5 +1,12 @@ +# syntax=docker/dockerfile:1.4 +# Rosomaxa CLI is a musl binary. Pin matches the published GitHub/crates.io release. +ARG ROSOMAXA_VERSION=1.25.0 +FROM ghcr.io/reinterpretcat/vrp/vrp-cli:${ROSOMAXA_VERSION} AS rosomaxa-cli + FROM ghcr.io/cartoway/optimizer-ortools:master +COPY --from=rosomaxa-cli /solver/vrp-cli /usr/local/bin/vrp-cli + ARG BUNDLE_WITHOUT="test development" # Install Vroom ARG VROOM_RELEASE=v1.14.0 @@ -94,7 +101,6 @@ ADD . /srv/app EXPOSE 80 HEALTHCHECK \ - --start-interval=1s \ --start-period=30s \ --interval=30s \ --timeout=20s \ diff --git a/Gemfile b/Gemfile index d17577d3c..52d8fa969 100644 --- a/Gemfile +++ b/Gemfile @@ -1,5 +1,5 @@ source 'https://rubygems.org' -ruby '>= 3' +ruby '>= 3.2' gem 'require_all' @@ -12,19 +12,21 @@ gem 'rake' gem 'thin' # API -gem 'grape' +# ~> 3.2: ParamScopeTracker (thread-safe validation); keeps mustermann 3.x (sinatra/resque) +# grape >= 3.2 needs Ruby >= 3.2 (matches Docker/CI) +gem 'grape', '~> 3.2.0' gem 'grape-entity' -gem 'grape_logging' -gem 'grape-swagger' +gem 'grape_logging', '>= 3.0' +gem 'grape-swagger', '~> 2.1', '>= 2.1.3' # 2.1.3+ allows grape < 4; 2.2 needs Ruby >= 3.1 OK gem 'grape-swagger-entity' # Models -gem 'actionpack', require: 'action_dispatch' +gem 'actionpack', '>= 7.2', require: 'action_dispatch' # waiting for the following PRs to get merged and "released!" # https://github.com/zilkey/active_hash/pull/231 and https://github.com/zilkey/active_hash/pull/233 gem 'active_hash' -gem 'activemodel' -gem 'activesupport', require: 'active_support' +gem 'activemodel', '>= 7.2' +gem 'activesupport', '>= 7.2', require: 'active_support' gem 'google-protobuf', '>=3', require: 'google/protobuf' gem 'oj' diff --git a/Gemfile.lock b/Gemfile.lock index 64e420e91..6b18ddc50 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -24,28 +24,38 @@ GIT GEM remote: https://rubygems.org/ specs: - actionpack (7.0.8) - actionview (= 7.0.8) - activesupport (= 7.0.8) - rack (~> 2.0, >= 2.2.4) + actionpack (7.2.1.2) + actionview (= 7.2.1.2) + activesupport (= 7.2.1.2) + nokogiri (>= 1.8.5) + racc + rack (>= 2.2.4, < 3.2) + rack-session (>= 1.0.1) rack-test (>= 0.6.3) - rails-dom-testing (~> 2.0) - rails-html-sanitizer (~> 1.0, >= 1.2.0) - actionview (7.0.8) - activesupport (= 7.0.8) + rails-dom-testing (~> 2.2) + rails-html-sanitizer (~> 1.6) + useragent (~> 0.16) + actionview (7.2.1.2) + activesupport (= 7.2.1.2) builder (~> 3.1) - erubi (~> 1.4) - rails-dom-testing (~> 2.0) - rails-html-sanitizer (~> 1.1, >= 1.2.0) + erubi (~> 1.11) + rails-dom-testing (~> 2.2) + rails-html-sanitizer (~> 1.6) active_hash (4.0.0) activesupport (>= 6.1.0) - activemodel (7.0.8) - activesupport (= 7.0.8) - activesupport (7.0.8) - concurrent-ruby (~> 1.0, >= 1.0.2) + activemodel (7.2.1.2) + activesupport (= 7.2.1.2) + activesupport (7.2.1.2) + base64 + bigdecimal + concurrent-ruby (~> 1.0, >= 1.3.1) + connection_pool (>= 2.2.5) + drb i18n (>= 1.6, < 2) + logger (>= 1.4.2) minitest (>= 5.1) - tzinfo (~> 2.0) + securerandom (>= 0.3) + tzinfo (~> 2.0, >= 2.0.5) addressable (2.8.5) public_suffix (>= 2.0.2, < 6.0) ai4r (1.13) @@ -57,66 +67,74 @@ GEM benchmark (0.2.1) benchmark-ips (2.12.0) bigdecimal (4.1.2) - builder (3.2.4) + builder (3.3.0) byebug (11.1.3) charlock_holmes (0.7.9) chunky_png (1.4.0) color-generator (0.0.4) - concurrent-ruby (1.3.6) + concurrent-ruby (1.3.8) + connection_pool (3.0.2) crack (0.4.5) rexml - crass (1.0.6) + crass (1.0.7) daemons (1.4.1) diff-lcs (1.5.0) docile (1.4.0) domain_name (0.5.20190701) unf (>= 0.0.5, < 1.0.0) dotenv (3.1.7) - dry-core (1.0.1) + drb (2.2.3) + dry-configurable (1.3.0) + dry-core (~> 1.1) + zeitwerk (~> 2.6) + dry-core (1.2.0) concurrent-ruby (~> 1.0) + logger zeitwerk (~> 2.6) - dry-inflector (1.0.0) - dry-logic (1.5.0) + dry-inflector (1.3.1) + dry-logic (1.6.0) + bigdecimal concurrent-ruby (~> 1.0) - dry-core (~> 1.0, < 2) + dry-core (~> 1.1) zeitwerk (~> 2.6) - dry-types (1.7.1) + dry-types (1.9.1) + bigdecimal (>= 3.0) concurrent-ruby (~> 1.0) dry-core (~> 1.0) dry-inflector (~> 1.0) dry-logic (~> 1.4) zeitwerk (~> 2.6) e2mmap (0.1.0) - erubi (1.12.0) + erubi (1.13.1) eventmachine (1.2.7) geojson2image (0.2.2) chunky_png (~> 1.3) oj (~> 3.6) google-protobuf (3.24.3-x86_64-linux) - grape (1.8.0) - activesupport (>= 5) - builder + grape (3.2.1) + activesupport (>= 7.2) + dry-configurable dry-types (>= 1.1) - mustermann-grape (~> 1.0.0) - rack (>= 1.3.0) - rack-accept - grape-entity (1.0.0) + mustermann-grape (~> 1.1.0) + rack (>= 2) + zeitwerk + grape-entity (1.1.0) activesupport (>= 3.0.0) - multi_json (>= 1.3.2) - grape-swagger (1.6.1) - grape (~> 1.3) - grape-swagger-entity (0.5.2) - grape-entity (>= 0.6.0) - grape-swagger (>= 1.2.0) - grape_logging (1.8.4) - grape + grape-swagger (2.2.0) + grape (>= 2.1, < 5.0) + grape-swagger-entity (0.7.1) + grape-entity (~> 1) + grape-swagger (~> 2) + grape_logging (3.0.1) + grape (>= 2.4.0) rack + zeitwerk hashdiff (1.0.1) http-accept (1.7.0) http-cookie (1.0.5) domain_name (~> 0.5) http_accept_language (2.1.1) - i18n (1.14.8) + i18n (1.15.2) concurrent-ruby (~> 1.0) jaro_winkler (1.5.6) json (2.7.1) @@ -125,13 +143,14 @@ GEM kramdown-parser-gfm (1.1.0) kramdown (~> 2.0) language_server-protocol (3.17.0.3) - loofah (2.21.3) + logger (1.7.0) + loofah (2.25.2) crass (~> 1.0.2) nokogiri (>= 1.12.0) mime-types (3.5.1) mime-types-data (~> 3.2015) mime-types-data (3.2023.0808) - minitest (5.26.1) + minitest (5.27.0) minitest-around (0.5.0) minitest (~> 5.0) minitest-bisect (1.7.0) @@ -150,14 +169,13 @@ GEM minitest (~> 5.16) minitest-stub_any_instance (1.0.3) mono_logger (1.1.2) - multi_json (1.15.0) - mustermann (3.0.0) - ruby2_keywords (~> 0.0.1) - mustermann-grape (1.0.2) + multi_json (1.21.2) + mustermann (3.1.1) + mustermann-grape (1.1.0) mustermann (>= 1.0.0) netrc (0.11.0) nio4r (2.5.9) - nokogiri (1.15.4-x86_64-linux) + nokogiri (1.19.4-x86_64-linux-gnu) racc (~> 1.4) oj (3.17.3) bigdecimal (>= 3.0) @@ -172,10 +190,8 @@ GEM public_suffix (5.0.3) puma (6.3.1) nio4r (~> 2.0) - racc (1.7.3) - rack (2.2.8) - rack-accept (0.4.5) - rack (>= 0.4) + racc (1.8.1) + rack (2.2.24) rack-contrib (2.3.0) rack (~> 2.0) rack-cors (2.0.1) @@ -184,15 +200,17 @@ GEM rack (~> 2.2, >= 2.2.4) rack-server-pages (0.1.0) rack - rack-test (2.1.0) + rack-session (1.0.2) + rack (< 3) + rack-test (2.2.0) rack (>= 1.3) - rails-dom-testing (2.2.0) + rails-dom-testing (2.3.0) activesupport (>= 5.0.0) minitest nokogiri (>= 1.6) - rails-html-sanitizer (1.6.0) - loofah (~> 2.21) - nokogiri (~> 1.14) + rails-html-sanitizer (1.7.1) + loofah (~> 2.25, >= 2.25.2) + nokogiri (>= 1.15.7, != 1.16.7, != 1.16.6, != 1.16.5, != 1.16.4, != 1.16.3, != 1.16.2, != 1.16.1, != 1.16.0.rc1, != 1.16.0) rainbow (3.1.1) rake (13.4.2) rake-compiler-dock (1.12.0) @@ -248,7 +266,7 @@ GEM rubocop (>= 1.7.0, < 2.0) rubocop-ast (>= 0.4.0) ruby-progressbar (1.13.0) - ruby2_keywords (0.0.5) + securerandom (0.4.1) sentry-resque (5.18.2) resque (>= 1.24) sentry-ruby (~> 5.18.2) @@ -293,6 +311,7 @@ GEM unf_ext unf_ext (0.0.8.2) unicode-display_width (2.5.0) + useragent (0.16.11) vegas (0.1.11) rack (>= 1.0.0) webmock (3.19.1) @@ -300,16 +319,16 @@ GEM crack (>= 0.3.2) hashdiff (>= 0.4.0, < 2.0.0) yard (0.9.34) - zeitwerk (2.6.11) + zeitwerk (2.8.3) PLATFORMS x86_64-linux DEPENDENCIES - actionpack + actionpack (>= 7.2) active_hash - activemodel - activesupport + activemodel (>= 7.2) + activesupport (>= 7.2) ai4r balanced_vrp_clustering! benchmark-ips @@ -317,11 +336,11 @@ DEPENDENCIES charlock_holmes dotenv google-protobuf (>= 3) - grape + grape (~> 3.2.0) grape-entity - grape-swagger + grape-swagger (~> 2.1, >= 2.1.3) grape-swagger-entity - grape_logging + grape_logging (>= 3.0) http_accept_language i18n minitest @@ -359,7 +378,7 @@ DEPENDENCIES webmock RUBY VERSION - ruby 3.0.6p216 + ruby 3.2.2p53 BUNDLED WITH 2.4.19 diff --git a/README.md b/README.md index 8c955195a..3dc3cbaee 100644 --- a/README.md +++ b/README.md @@ -5,6 +5,7 @@ Run an optimizer REST API depending of many constraints for a Vehicle Routing Pr This project use some solver engines: * [Vroom v1.14.0](https://github.com/VROOM-Project/vroom/releases/tag/v1.14.0) * [Optimizer-ortools v1.17.1](https://github.com/cartoroute/optimizer-ortools) & [OR-Tools v7.8](https://github.com/google/or-tools/releases/tag/v7.8) (use the version corresponding to your system operator, not source code). +* [vrp-cli 1.25.0](https://github.com/reinterpretcat/vrp/releases/tag/v1.25.0) (Rosomaxa, Apache-2.0, separate process). Service `:rosomaxa`, appended after the existing solvers. Covers matrix problems with single-activity services, a vehicle start, an optional end (open route), capacities, time windows, simple shipments, reload depots and lunch breaks. A missing start depot, regulatory rests, and relations other than simple shipments are skipped. `maximum_reloads` is not enforced. ## API diff --git a/api/root.rb b/api/root.rb index 2690d8787..61e2d48e6 100644 --- a/api/root.rb +++ b/api/root.rb @@ -22,12 +22,21 @@ module Api class Root < Grape::API mount ApiV01 + # Omit VRP body from access logs (payloads are huge; dumps go to dump_vrp_dir when enabled) + VRP_LOG_PARAM_FILTER = %w[ + vrp points vehicles services matrices shipments routes relations + units rests zones subtours quantities capacities timewindows configuration + ].freeze + logger.formatter = GrapeLogging::Formatters::Default.new - use GrapeLogging::Middleware::RequestLogger, logger: logger + use GrapeLogging::Middleware::RequestLogger, + logger: logger, + include: [GrapeLogging::Loggers::FilterParameters.new(VRP_LOG_PARAM_FILTER)] + + format :json desc 'Ping hook. Responds by "pong".' get '/ping' do - content_type 'application/json' 'pong' end end diff --git a/api/v01/api.rb b/api/v01/api.rb index 71422abf0..24315956d 100644 --- a/api/v01/api.rb +++ b/api/v01/api.rb @@ -169,24 +169,25 @@ def split_key(key) e.is_a?(OptimizerWrapper::UnsupportedProblemError) || e.is_a?(OptimizerWrapper::UnsupportedRouterModeError) response[:message] += ' ' + e.data.map { |service| service.join(', ') }.join(' | ') if e.is_a?(OptimizerWrapper::UnsupportedProblemError) - rack_response(format_message(response, e.backtrace), 400) + error!(response, 400, {}, e.backtrace) elsif e.is_a?(Grape::Exceptions::MethodNotAllowed) - rack_response(format_message(response, e.backtrace), 405) + error!(response, 405, {}, e.backtrace) elsif e.is_a?(QuotaExceededError) - headers = { 'Content-Type' => content_type, - 'X-RateLimit-Limit' => e.data[:limit], - 'X-RateLimit-Remaining' => e.data[:remaining], - 'X-RateLimit-Reset' => if e.data[:reset] == :daily - count_time.to_date.next_day - elsif e.data[:reset] == :monthly - count_time.to_date.next_month - elsif e.data[:reset] == :yearly - count_time.to_date.next_year - end.to_time.to_i } - rack_response(format_message(response, nil), 429, headers) + headers = { + 'X-RateLimit-Limit' => e.data[:limit], + 'X-RateLimit-Remaining' => e.data[:remaining], + 'X-RateLimit-Reset' => if e.data[:reset] == :daily + count_time.to_date.next_day + elsif e.data[:reset] == :monthly + count_time.to_date.next_month + elsif e.data[:reset] == :yearly + count_time.to_date.next_year + end.to_time.to_i + } + error!(response, 429, headers) else Sentry.capture_exception(e) - rack_response(format_message(response, e.backtrace), 500) + error!(response, 500, {}, e.backtrace) end end diff --git a/api/v01/entities/vrp_result.rb b/api/v01/entities/vrp_result.rb index 709dc78c0..1f68350f7 100644 --- a/api/v01/entities/vrp_result.rb +++ b/api/v01/entities/vrp_result.rb @@ -114,8 +114,10 @@ class VrpResultSolutionSkippedSolver < Grape::Entity end class VrpResultSolution < Grape::Entity + expose :name, documentation: { type: String, desc: 'Name of the source VRP problem' } expose :heuristic_synthesis, documentation: { is_array: true, desc: 'When first_solution_strategies are provided, sum up of tryied heuristics and their performance.' } expose :solvers, documentation: { is_array: true, type: String, desc: 'Solvers used to perform the optimization' } + expose :interpreters, documentation: { is_array: true, type: String, desc: 'Interpreters used during the resolution (split, dichotomous, periodic, …)' } expose :skipped_services, using: VrpResultSolutionSkippedSolver, documentation: { is_array: true, desc: 'Solvers which were not able to solve the problem or sub problems' } expose :cost, documentation: { type: Float, desc: 'The actual cost of the solution considering all costs' } expose :cost_details, using: VRPResultDetailedCosts, documentation: { desc: 'The detail of the different costs which impact the solution' } diff --git a/api/v01/vrp.rb b/api/v01/vrp.rb index 4080999c9..830a2e05a 100644 --- a/api/v01/vrp.rb +++ b/api/v01/vrp.rb @@ -58,7 +58,7 @@ def self.call(object, _env) end class Vrp < APIBase - include Grape::Extensions::Hash::ParamBuilder + build_with :hash parser :csv, CSVParser @@ -114,7 +114,11 @@ class Vrp < APIBase Sentry.set_user(api_key: params[:api_key]) # Filtered in sentry if user_context vrp_params = d_params[:points] ? d_params : d_params[:vrp] - APIBase.dump_vrp_dir.write([key_print, vrp_params[:name] || 'no_vrp_name', checksum].compact.join('_'), d_params.to_json) if OptimizerWrapper.config[:dump][:vrp] + vrp_params[:name] = Models::Vrp.normalize_name(vrp_params[:name]) if vrp_params[:name] + if OptimizerWrapper.config[:dump][:vrp] + dump_key = Core::Services::JobService.vrp_dump_key(api_key, vrp_params[:name], checksum) + APIBase.dump_vrp_dir.write(dump_key, d_params.to_json) + end Sentry.set_extras(vrp_name: vrp_params[:name]) @@ -197,6 +201,32 @@ class Vrp < APIBase end resource :jobs do + desc 'Download dumped VRP for a job', { + nickname: 'get_job_vrp', + failure: [ + { code: 404, message: 'Not Found', model: ::Api::V01::Status } + ], + detail: 'Download the VRP JSON dump associated with the job, when dump is enabled.' + } + params { + requires :id, type: String, desc: 'Job id returned by creating VRP problem.' + } + get ':id/vrp' do + id = params[:id] + job = Resque::Plugins::Status::Hash.get(id) + content = Core::Services::JobService.read_vrp_dump(params[:api_key], job) + if content.nil? + status 404 + error!({ message: "VRP dump for job id='#{id}' not found" }, 404) + end + + filename = "#{(job.options && job.options['vrp_name']) || id}.json" + header 'Content-Disposition', "attachment; filename=\"#{filename}\"" + content_type 'application/json' + env['api.format'] = :binary + body content + end + desc 'Fetch vrp job status', { nickname: 'get_job', success: VrpResult, @@ -224,7 +254,8 @@ class Vrp < APIBase if job&.completed? # job can still be nil if we have the solution from the dump APIBase.dump_vrp_dir.write([id, params[:api_key], 'solution'].join('_'), Oj.dump(result_object)) if stored_result.nil? && OptimizerWrapper.config[:dump][:solution] - Core::Services::JobService.job_remove(params[:api_key], id) + # Keep completed jobs for a few hours so the UI list / compare stay usable after retrieval + Core::Services::JobService.job_remove(params[:api_key], id) if Core::Services::JobService.retention_expired?(job) end status 200 @@ -252,7 +283,7 @@ class Vrp < APIBase desc 'List vrp jobs', { nickname: 'get_job_list', success: VrpJobsList, - detail: 'List running or queued jobs.' + detail: 'List queued, working and recently completed jobs.' } get do status 200 diff --git a/config/access.rb b/config/access.rb index 69c1937f2..4cbc59e4e 100644 --- a/config/access.rb +++ b/config/access.rb @@ -26,5 +26,6 @@ module OptimizerWrapper 'solvers' => { profile: :solvers }, 'vroom' => { profile: :vroom }, 'ortools' => { profile: :ortools }, + 'rosomaxa' => { profile: :rosomaxa }, } end diff --git a/config/environments/development.rb b/config/environments/development.rb index 8492d5ed1..eec020bf1 100644 --- a/config/environments/development.rb +++ b/config/environments/development.rb @@ -19,6 +19,7 @@ require './wrappers/demo' require './wrappers/ortools' require './wrappers/pyvrp' +require './wrappers/rosomaxa' require './wrappers/vroom' require './lib/cache_manager' @@ -38,6 +39,10 @@ module OptimizerWrapper 'LD_LIBRARY_PATH=../or-tools/dependencies/install/lib/:../or-tools/lib/ ../optimizer-ortools/tsp_simple'.freeze ORTOOLS = Wrappers::Ortools.new(tmp_dir: TMP_DIR, exec_ortools: ORTOOLS_EXEC) PYVRP = Wrappers::PyVRP.new(tmp_dir: TMP_DIR) + ROSOMAXA = Wrappers::Rosomaxa.new( + tmp_dir: TMP_DIR, + exec_rosomaxa: ENV['ROSOMAXA_PATH'] || '/usr/local/bin/vrp-cli' + ) VROOM = Wrappers::Vroom.new(tmp_dir: TMP_DIR, threads: 1, exec_vroom: '/usr/local/bin/vroom') PARAMS_LIMIT = { points: 100000, vehicles: 1000 }.freeze @@ -61,13 +66,22 @@ module OptimizerWrapper demo: DEMO, ortools: ORTOOLS, pyvrp: PYVRP, + rosomaxa: ROSOMAXA, vroom: VROOM, }, profiles: { demo: { queue: 'DEFAULT', services: { - vrp: [:vroom, :ortools, :pyvrp] + vrp: [:vroom, :ortools, :pyvrp, :rosomaxa] + }, + params_limit: PARAMS_LIMIT, + quotas: QUOTAS, # Only taken into account if REDIS_COUNT + }, + rosomaxa: { + queue: 'DEFAULT', + services: { + vrp: [:rosomaxa] }, params_limit: PARAMS_LIMIT, quotas: QUOTAS, # Only taken into account if REDIS_COUNT diff --git a/config/environments/production.rb b/config/environments/production.rb index 4ff5e4e36..452f452f0 100644 --- a/config/environments/production.rb +++ b/config/environments/production.rb @@ -19,6 +19,7 @@ require './wrappers/demo' require './wrappers/ortools' require './wrappers/pyvrp' +require './wrappers/rosomaxa' require './wrappers/vroom' require './lib/cache_manager' @@ -39,6 +40,10 @@ module OptimizerWrapper 'LD_LIBRARY_PATH=../or-tools/dependencies/install/lib/:../or-tools/lib/ ../optimizer-ortools/tsp_simple'.freeze ORTOOLS = Wrappers::Ortools.new(tmp_dir: TMP_DIR, exec_ortools: ORTOOLS_EXEC, threads: 4) PYVRP = Wrappers::PyVRP.new(tmp_dir: TMP_DIR) + ROSOMAXA = Wrappers::Rosomaxa.new( + tmp_dir: TMP_DIR, + exec_rosomaxa: ENV['ROSOMAXA_PATH'] || '/usr/local/bin/vrp-cli' + ) VROOM = Wrappers::Vroom.new(tmp_dir: TMP_DIR, threads: 1, exec_vroom: '/usr/local/bin/vroom') PARAMS_LIMIT = { points: 100000, vehicles: 1000 }.freeze @@ -62,13 +67,22 @@ module OptimizerWrapper demo: DEMO, ortools: ORTOOLS, pyvrp: PYVRP, + rosomaxa: ROSOMAXA, vroom: VROOM, }, profiles: { demo: { queue: 'DEFAULT', services: { - vrp: [:vroom, :ortools, :pyvrp] + vrp: [:vroom, :ortools, :pyvrp, :rosomaxa] + }, + params_limit: PARAMS_LIMIT, + quotas: QUOTAS, # Only taken into account if REDIS_COUNT + }, + rosomaxa: { + queue: 'DEFAULT', + services: { + vrp: [:rosomaxa] }, params_limit: PARAMS_LIMIT, quotas: QUOTAS, # Only taken into account if REDIS_COUNT diff --git a/config/environments/test.rb b/config/environments/test.rb index 65037fa48..3fc73a4bb 100644 --- a/config/environments/test.rb +++ b/config/environments/test.rb @@ -19,6 +19,7 @@ require './wrappers/demo' require './wrappers/ortools' require './wrappers/pyvrp' +require './wrappers/rosomaxa' require './wrappers/vroom' require './lib/cache_manager' @@ -42,6 +43,10 @@ module OptimizerWrapper ORTOOLS = Wrappers::Ortools.new(tmp_dir: TMP_DIR, exec_ortools: ORTOOLS_EXEC) VROOM = Wrappers::Vroom.new(tmp_dir: TMP_DIR, threads: 1, exec_vroom: ENV['VROOM_PATH'] || '/usr/local/bin/vroom') PYVRP = Wrappers::PyVRP.new(tmp_dir: TMP_DIR) + ROSOMAXA = Wrappers::Rosomaxa.new( + tmp_dir: TMP_DIR, + exec_rosomaxa: ENV['ROSOMAXA_PATH'] || '/usr/local/bin/vrp-cli' + ) PARAMS_LIMIT = { points: 150, vehicles: 10 }.freeze QUOTAS = [{ daily: 100000, monthly: 1000000, yearly: 10000000 }].freeze # Only taken into account if REDIS_COUNT @@ -65,6 +70,7 @@ module OptimizerWrapper ortools: ORTOOLS, vroom: VROOM, pyvrp: PYVRP, + rosomaxa: ROSOMAXA, }, profiles: { demo: { @@ -98,6 +104,12 @@ module OptimizerWrapper vrp: [:pyvrp] }, }, + rosomaxa: { + queue: 'DEFAULT', + services: { + vrp: [:rosomaxa] + }, + }, vroom: { queue: 'DEFAULT', services: { diff --git a/core/services/job_service.rb b/core/services/job_service.rb index b872f0e16..283559a76 100644 --- a/core/services/job_service.rb +++ b/core/services/job_service.rb @@ -1,20 +1,32 @@ module Core module Services module JobService + # Keep completed jobs listable/retrievable for a few hours after resolution ends. + COMPLETED_RETENTION = (ENV['OPTIM_JOB_RETENTION_HOURS'] || 4).to_i.hours + def job_list(api_key) - (OptimizerWrapper::JobList.get(api_key) || []).collect{ |e| - if job = Resque::Plugins::Status::Hash.get(e) # rubocop: disable Lint/AssignmentInCondition - { - time: job.time, - uuid: job.uuid, - status: job.status, - avancement: job.message, - checksum: job.options && job.options['checksum'] - } - else + (OptimizerWrapper::JobList.get(api_key) || []).filter_map{ |e| + job = Resque::Plugins::Status::Hash.get(e) + if job.nil? OptimizerWrapper::Result.remove(api_key, e) + next + end + if retention_expired?(job) + job_remove(api_key, e) + next end - }.compact + + options = job.options || {} + { + time: job.time, + uuid: job.uuid, + status: job.status, + avancement: job.message, + checksum: options['checksum'], + name: options['vrp_name'], + vrp_dump: vrp_dump?(api_key, job) + } + } end def job_kill(_api_key, id) @@ -29,7 +41,57 @@ def job_remove(api_key, id) Resque::Plugins::Status::Hash.remove(id) end end - module_function :job_list, :job_kill, :job_remove + + def vrp_dump_key(api_key, vrp_name, checksum) + return unless checksum + + key_print = api_key.to_s.rpartition('-')[0] + key_print = api_key.to_s[0..3] if key_print.empty? + [key_print, vrp_name.presence || 'no_vrp_name', checksum].compact.join('_') + end + + def read_vrp_dump(api_key, job) + return unless job&.dig('options', 'api_key') == api_key + + options = job.options || {} + key = options['vrp_dump_key'].presence || vrp_dump_key(api_key, options['vrp_name'], options['checksum']) + return unless key + + OptimizerWrapper.dump_vrp_dir.read(key) + end + + def vrp_dump?(api_key, job) + return false unless job&.dig('options', 'checksum') + + options = job.options || {} + key = options['vrp_dump_key'].presence || vrp_dump_key(api_key, options['vrp_name'], options['checksum']) + return false unless key + + OptimizerWrapper.dump_vrp_dir.exist?(key) + end + + def retention_expired?(job) + return false unless job&.completed? + + finished_at = completed_at(job) + return false unless finished_at + + Time.now - finished_at >= COMPLETED_RETENTION + end + + def completed_at(job) + message = job.message.to_s + if (match = message.match(/\ACompleted at (.+)\z/)) + Time.parse(match[1]) + else + job.time + end + rescue ArgumentError, TypeError + job.time + end + + module_function :job_list, :job_kill, :job_remove, :vrp_dump_key, :read_vrp_dump, :vrp_dump?, + :retention_expired?, :completed_at end end end diff --git a/core/strategies/orchestration.rb b/core/strategies/orchestration.rb index b4c245fa3..1296873bf 100644 --- a/core/strategies/orchestration.rb +++ b/core/strategies/orchestration.rb @@ -115,6 +115,7 @@ def define_process(service_vrp, job = nil, &block) log "<-- define_process levels (dicho: #{dicho_level}, split: #{split_level}) "\ "elapsed: #{(Time.now - tic).round(2)} sec" solution.configuration.deprecated_headers = vrp.configuration.restitution.use_deprecated_csv_headers + solution.absorb_interpreters!(service_vrp) solution end @@ -137,6 +138,7 @@ def solve(service_vrp, job = nil, block = nil) unfeasible_services = {} if !vrp.subtours.empty? + service_vrp.mark_interpreter!(:multimodal) multi_modal = Interpreters::MultiModal.new(vrp, service) optim_solution = multi_modal.multimodal_routes elsif vrp.vehicles.empty? || vrp.services.empty? @@ -198,6 +200,7 @@ def solve(service_vrp, job = nil, block = nil) # vrp.periodic_heuristic check the first_solution_stategy which may change right after periodic heuristic periodic_heuristic_flag = vrp.periodic_heuristic? + service_vrp.mark_interpreter!(:periodic) if periodic_heuristic_flag # TODO: refactor with dedicated class if vrp.schedule? periodic = Interpreters::PeriodicVisits.new(vrp) @@ -214,6 +217,7 @@ def solve(service_vrp, job = nil, block = nil) regulatory_rest.apply!(vrp) if vrp.configuration.resolution.solver && (!periodic_heuristic_flag || vrp.services.size < 200) if vrp.configuration.preprocessing.cluster_threshold.to_f.positive? + service_vrp.mark_interpreter!(:clique_cluster) block&.call(nil, nil, nil, 'process clique clustering : threshold '\ "(#{vrp.configuration.preprocessing.cluster_threshold.to_f}) ", @@ -315,6 +319,7 @@ def solve(service_vrp, job = nil, block = nil) optim_solution = vrp.empty_solution(service, unfeasible_services.values) end + optim_solution&.absorb_interpreters!(service_vrp) log "<-- optim_wrap::solve elapsed: #{(Time.now - tic).round(2)}sec", level: :debug optim_solution ensure diff --git a/environment.rb b/environment.rb index 36852351d..8c54074b0 100644 --- a/environment.rb +++ b/environment.rb @@ -35,6 +35,11 @@ # gems from standard library require 'csv' require 'date' +# Preload Digest implementations: Digest::MD5/SHA* use const_missing and race under +# multi-threaded Puma ("Digest::Base cannot be directly inherited in Ruby"). +require 'digest/md5' +require 'digest/sha1' +require 'digest/sha2' require 'fileutils' require 'json' require 'logger' diff --git a/lib/cache_manager.rb b/lib/cache_manager.rb index 35e2e7457..584bfd91c 100644 --- a/lib/cache_manager.rb +++ b/lib/cache_manager.rb @@ -27,8 +27,13 @@ def initialize(cache) FileUtils.mkdir_p(@cache) end + def exist?(name) + filtered_name = sanitize_filename(name) + File.exist?(File.join(@cache, filtered_name) + '.gz') || File.exist?(File.join(@cache, filtered_name)) + end + def read(name, _options = nil) - filtered_name = name.to_s.parameterize(separator: '') + filtered_name = sanitize_filename(name) if File.exist?(File.join(@cache, filtered_name) + '.gz') # Gzip dumps Zlib::GzipReader.open(File.join(@cache, filtered_name) + '.gz', &:read) elsif File.exist?(File.join(@cache, filtered_name)) # Zlib (and uncompressed) dumps @@ -49,7 +54,7 @@ def write(name, value, mode: 'w', gz: true) raise CacheError.new('Stored value is not a String') if !value.is_a? String if gz - File.open(File.join(@cache, name.to_s.parameterize(separator: '')) + '.gz', mode) do |f| + File.open(File.join(@cache, sanitize_filename(name)) + '.gz', mode) do |f| gz = Zlib::GzipWriter.new(f) if value.bytesize < @data_bytesize_limit_in_mb.megabytes gz.write value @@ -74,4 +79,12 @@ def cleanup(options = nil) raise CacheError.new("Got error \"#{e}\" attempting to clean cache.") end end + + private + + # ActiveSupport 7.2+ refuses to parameterize ASCII-8BIT strings. + # Keep '_' so VRP names stay segmentable (cart_c352_lundipierre_…). + def sanitize_filename(name) + name.to_s.encode(Encoding::UTF_8, invalid: :replace, undef: :replace).parameterize(separator: '_') + end end diff --git a/lib/heuristics/dichotomous_approach.rb b/lib/heuristics/dichotomous_approach.rb index 5cf0a8626..9450e74ff 100644 --- a/lib/heuristics/dichotomous_approach.rb +++ b/lib/heuristics/dichotomous_approach.rb @@ -166,6 +166,7 @@ def self.feasible_vrp(solution, service_vrp) def self.dichotomous_heuristic(service_vrp, job = nil, &block) solution = nil if dichotomous_candidate?(service_vrp) + service_vrp.mark_interpreter!(:dichotomous) vrp = service_vrp.vrp level = service_vrp.dicho_level dicho_data = service_vrp.dicho_data @@ -197,6 +198,7 @@ def self.dichotomous_heuristic(service_vrp, job = nil, &block) else service_vrp.vrp.configuration.resolution.init_duration = nil end + solution&.absorb_interpreters!(service_vrp) solution end diff --git a/lib/interpreters/compute_several_solutions.rb b/lib/interpreters/compute_several_solutions.rb index e3e40eb90..3deaebdb6 100644 --- a/lib/interpreters/compute_several_solutions.rb +++ b/lib/interpreters/compute_several_solutions.rb @@ -213,7 +213,7 @@ def self.custom_heuristics(service, vrp, block = nil, service_vrp: nil) preprocessing_fss = vrp.configuration.preprocessing.first_solution_strategy - return service_vrp if service == :vroom || service == :pyvrp || + return service_vrp if service == :vroom || service == :pyvrp || service == :rosomaxa || preprocessing_fss.empty? || preprocessing_fss.include?('periodic') || (preprocessing_fss.size == 1 && !self_selection?(preprocessing_fss)) diff --git a/lib/interpreters/multi_trip.rb b/lib/interpreters/multi_trip.rb index c255f9012..68205e4cf 100644 --- a/lib/interpreters/multi_trip.rb +++ b/lib/interpreters/multi_trip.rb @@ -6,63 +6,16 @@ def self.presolve(service_vrp, job = nil, &block) new.presolve(service_vrp, job, &block) end - def presolvable?(service_vrp) - service_vrp[:service] == :pyvrp && service_vrp[:skipped_services].any?{ |skipped_service| - skipped_service[:service] == :vroom && - skipped_service[:reasons].all?{ |reason| reason == :assert_vehicles_no_reload_depots } - } + def presolvable?(_service_vrp) + # Disabled: PyVRP handles reloads natively. Re-enable VROOM seeding here later if needed. + false end - def resolvable?(solution) - solution.routes.any?(&:under_used?) - end - - def presolve(service_vrp, job = nil, &block) - if presolvable?(service_vrp) - service_vrp.vrp.configuration.resolution.solver = :vroom - service_vrp[:service] = :vroom - - solution = Core::Strategies::Orchestration.solve(service_vrp, job, &block) - - # extract routes below 80% of work_time of the vehicle to build a new service_vrp - if resolvable?(solution) - under_used_routes = solution.routes.select(&:under_used?) - log "Under used routes: #{under_used_routes.size}" - solution.routes -= under_used_routes - sub_service_ids = - under_used_routes.map{ |route| route.stops.map(&:service_id) }.flatten + - solution.unassigned_stops.map(&:service_id) - sub_services = - service_vrp.vrp.services.select{ |service| sub_service_ids.include?(service.id) } - solution.unassigned_stops = [] + def presolve(service_vrp, _job = nil, &_block) + return nil unless presolvable?(service_vrp) - vehicles = under_used_routes.map(&:vehicle) - reload_depots = vehicles.flat_map(&:reload_depots).uniq - points = - vehicles.map(&:start_point) + - vehicles.map(&:end_point) + - reload_depots.map(&:point) + - sub_services.map{ |service| service.activity.point } - sub_vrp_hash = { - points: points.uniq.map(&:as_json), - vehicles: vehicles.map(&:as_json), - services: sub_services.map(&:as_json), - # relations: [], # PyVRP does not hand relations - units: service_vrp.vrp.units.map(&:as_json), - reload_depots: reload_depots.map(&:as_json), - configuration: service_vrp.vrp.configuration.as_json - } - sub_vrp_hash[:vehicles].each{ |vehicle| vehicle.delete(:matrix_id) } - sub_vrp_hash[:points].each{ |point| point.delete(:matrix_index) } - sub_vrp_hash[:configuration][:resolution][:duration] -= solution.elapsed - new_service_vrp = - Models::ResolutionContext.new(service: :pyvrp, vrp: Models::Vrp.create(sub_vrp_hash, check: false)) - sub_solution = Core::Strategies::Orchestration.solve(new_service_vrp, job, &block) - solution.routes += sub_solution.routes - solution.unassigned_stops = sub_solution.unassigned_stops - end - solution - end + service_vrp.mark_interpreter!(:multi_trip) + nil end end end diff --git a/lib/interpreters/split_clustering.rb b/lib/interpreters/split_clustering.rb index b6be16dd3..195c522e5 100644 --- a/lib/interpreters/split_clustering.rb +++ b/lib/interpreters/split_clustering.rb @@ -31,6 +31,7 @@ def self.split_clusters(service_vrp, job = nil, &block) vrp = service_vrp.vrp if vrp.configuration.preprocessing.partitions&.any? + service_vrp.mark_interpreter!(:split) splited_service_vrps = generate_split_vrps(service_vrp, job, block) if OptimizerWrapper.config[:debug][:output_clusters] && splited_service_vrps.size > 1 OutputHelper::Clustering.generate_files(splited_service_vrps, @@ -71,9 +72,10 @@ def self.split_clusters(service_vrp, job = nil, &block) vrp.services = splited_service_vrps.flat_map{ |sv| sv.vrp.services } vrp.relations = splited_service_vrps.flat_map{ |sv| sv.vrp.relations } - split_solutions.reduce(&:+) # return + split_solutions.reduce(&:+)&.absorb_interpreters!(service_vrp) # return elsif split_solve_candidate?(service_vrp) - split_solve(service_vrp, &block) # return + service_vrp.mark_interpreter!(:split) + split_solve(service_vrp, &block)&.absorb_interpreters!(service_vrp) # return else service_vrp.dicho_level ||= 0 nil # return diff --git a/models/base.rb b/models/base.rb index 385618faa..37ee8aef9 100644 --- a/models/base.rb +++ b/models/base.rb @@ -30,28 +30,42 @@ class Base < ActiveHash::Base include ActiveHash::Associations + # Only Array/Hash/Set defaults need per-instance isolation (shared mutable state). + MUTABLE_DEFAULT_TYPES = [Array, Hash, Set].freeze + def initialize(hash) - super(hash.each_with_object({}){ |(k, v), memo| - memo[k.to_sym] = convert(k, v) - }) - - # Make sure default values are not the same object for all - self.attributes.each{ |k, v| - # If the key doesn't exist in the hash and its relevant substructures then it must be a default value - next if hash.key?(k) || - !v.duplicable? || - ["#{k}_id", "#{ActiveSupport::Inflector.singularize(k.to_s)}_ids"].any?{ |key| - hash.key?(key.to_sym) - } || - hash[:configuration] && [:preprocessing, :restitution, :schedule, :resolution].any?{ |symbol| - hash[:configuration][symbol]&.key?(k[symbol.size + 1..-1]&.to_sym) - } - - self[k] = v.dup # dup to make sure they are different objects - } + provided = + hash.each_with_object({}){ |(k, v), memo| + memo[k.to_sym] = convert(k, v) + } + super(provided) + isolate_mutable_defaults!(provided) end class << self + # Thread-local ActiveHash store. Child Thread.new does not inherit the parent + # store — keep VRP work on the request/worker thread, or copy Thread.current[:models_active_hash]. + def thread_store + stores = Thread.current[:models_active_hash] ||= {} + stores[name] ||= { records: [], record_index: {}, max_id: 0, dirty: false } + end + + def records + thread_store[:records] + end + + def record_index + thread_store[:record_index] + end + + def dirty + thread_store[:dirty] + end + + def dirty=(value) + thread_store[:dirty] = value + end + def create(attributes = {}) record = new(attributes) validate_unique_id(record) if dirty @@ -60,27 +74,62 @@ def create(attributes = {}) record end - # Override active_hash to improve performances + def all(options = {}) + relation = ActiveHash::Relation.new(self, records) + relation = relation.where!(options[:conditions]) if options[:conditions] + relation + end + + # Override active_hash to improve performances (+ thread-local store). + # ActiveHash::Base#insert uses next_id which scans all records (O(n)); + # keep a running max_id instead. def insert(record) - @records ||= [] record_id(record) validate_unique_id(record) if dirty mark_dirty - add_to_record_index({ record.id.to_s => @records.length }) - @records << record + add_to_record_index({ record.id.to_s => records.length }) + records << record + end + + def delete_all + mark_dirty + reset_record_index + thread_store[:records] = [] + thread_store[:max_id] = 0 + end + + # Override active_hash next_id (avoids scanning all records) + def next_id + thread_store[:max_id] = thread_store[:max_id].to_i.succ end - # Override active_hash to improve performances + # Override active_hash to improve performances (+ thread-local store) def record_id(record) - # sets record[:id] to @max_id+1 if it doesn't exist + # sets record[:id] to max_id+1 if it doesn't exist if record[:id]&.is_a?(Numeric) - @max_id = [@max_id || 0, record[:id].ceil].max + thread_store[:max_id] = [thread_store[:max_id] || 0, record[:id].ceil].max else - record[:id] ||= (@max_id = @max_id.to_i.succ) + record[:id] ||= next_id end end + def reset_record_index + record_index.clear + end + + def add_to_record_index(entry) + record_index.merge!(entry) + end + + def mark_dirty + self.dirty = true + end + + def mark_clean + self.dirty = false + end + def json_fields @json_fields ||= [] end @@ -92,6 +141,23 @@ def vrp_result_fields def types @types ||= {} end + + # Precomputed once per class: mutable default key => companion keys that mean "already set". + # Avoids ActiveSupport::Inflector.singularize on every Models::Base#initialize (hot path). + def mutable_default_keys + @mutable_default_keys ||= + (default_attributes || {}).each_with_object({}){ |(k, v), memo| + next unless MUTABLE_DEFAULT_TYPES.any?{ |type| v.is_a?(type) } + + memo[k] = [:"#{k}_id", :"#{ActiveSupport::Inflector.singularize(k)}_ids"] + } + end + + def clear_mutable_default_keys_cache + @mutable_default_keys = nil + end + + private :record_index, :reset_record_index, :add_to_record_index, :mark_dirty, :mark_clean, :thread_store end def to_hash @@ -150,6 +216,7 @@ def self.field(name, options = {}) if options[:type] types[name] = options[:type] end + clear_mutable_default_keys_cache super(name, options) end @@ -162,6 +229,7 @@ def self.has_many(name, options = {}) # rubocop: disable Naming/PredicateName, S add_default_value(name, options[:default] || []) add_default_value(ids_function_name, options[:default] || []) + clear_mutable_default_keys_cache case options[:as_json] when :ids @@ -191,18 +259,19 @@ def self.has_many(name, options = {}) # rubocop: disable Naming/PredicateName, S self[name] ||= self.class.default_attributes[name] end + assoc_class = nil redefine_method("#{name}=") do |vals| - c = class_from_string(options[:class_name]) + assoc_class ||= class_from_string(options[:class_name]) self[name] = vals&.collect{ |val| - if c == Symbol + if assoc_class == Symbol val&.to_sym - elsif val.is_a?(c) + elsif val.is_a?(assoc_class) val elsif !val.empty? - c.create(val) + assoc_class.create(val) end }&.compact || [] - self[ids_function_name] = self[name]&.map(&:id) if c.module_parent == Models + self[ids_function_name] = self[name]&.map(&:id) if assoc_class.module_parent == Models self[name] end @@ -213,8 +282,8 @@ def self.has_many(name, options = {}) # rubocop: disable Naming/PredicateName, S end redefine_method("#{ids_function_name}=") do |vals| - c = class_from_string(options[:class_name]) - self[name] = vals && vals.split(',').flat_map{ |val_id| c.find(val_id) } + assoc_class ||= class_from_string(options[:class_name]) + self[name] = vals && vals.split(',').flat_map{ |val_id| assoc_class.find(val_id) } self[ids_function_name] = self[name]&.map(&:id) end end @@ -224,6 +293,7 @@ def self.belongs_to(name, options = {}) field_names << name id_function_name = "#{name}_id".to_sym + clear_mutable_default_keys_cache case options[:as_json] when :id @@ -249,10 +319,11 @@ def self.belongs_to(name, options = {}) self[name] end + assoc_class = nil redefine_method("#{name}=") do |val| - c = class_from_string(options[:class_name]) - self[name] = val&.is_a?(Hash) ? c.create(val) : val - self[id_function_name] = self[name]&.id if c.module_parent == Models + assoc_class ||= class_from_string(options[:class_name]) + self[name] = val&.is_a?(Hash) ? assoc_class.create(val) : val + self[id_function_name] = self[name]&.id if assoc_class.module_parent == Models self[name] end @@ -263,8 +334,8 @@ def self.belongs_to(name, options = {}) end redefine_method("#{id_function_name}=") do |val_id| - c = class_from_string(options[:class_name]) - self[name] = val_id && c.find(val_id) + assoc_class ||= class_from_string(options[:class_name]) + self[name] = val_id && assoc_class.find(val_id) self[id_function_name] = self[name]&.id end end @@ -272,8 +343,29 @@ def self.belongs_to(name, options = {}) private + def isolate_mutable_defaults!(provided) + configuration = provided[:configuration] + self.class.mutable_default_keys.each{ |k, companions| + next if provided.key?(k) + next if companions.any?{ |ck| provided.key?(ck) } + next if configuration && config_provides_key?(configuration, k) + + @attributes[k] = self.class.default_attributes[k].dup + } + end + + def config_provides_key?(configuration, key) + %i[preprocessing restitution schedule resolution].any?{ |section| + nested = key[section.size + 1..]&.to_sym + nested && configuration[section]&.key?(nested) + } + end + def convert(key, value) - convert_type(self.class.types[key].to_s, value) + type = self.class.types[key] + return value unless type + + convert_type(type.to_s, value) end def convert_type(type, value) @@ -282,8 +374,6 @@ def convert_type(type, value) end case type - when '' - value when 'Symbol' value&.to_sym when 'Date' diff --git a/models/resolution_context.rb b/models/resolution_context.rb index b0a584da2..6557dd72f 100644 --- a/models/resolution_context.rb +++ b/models/resolution_context.rb @@ -18,9 +18,16 @@ class ResolutionContext < Base field :original_duration_ms, default: nil # Set once at dicho root after self_selection; propagated to sub-problems (empty string = use supplied routes) field :selected_first_solution_strategy, default: nil + # Interpreters marked as soon as they are eligible for this resolution context + field :interpreters, default: [] has_many :skipped_services, class_name: 'Models::SkippedService' belongs_to :vrp, class_name: 'Models::Vrp' + def mark_interpreter!(name) + self.interpreters |= [name.to_s] + self + end + # Migrate old hash format to ResolutionContext objects def self.migrate_from_hash(hash_data) if hash_data.is_a?(Hash) diff --git a/models/solution/solution.rb b/models/solution/solution.rb index 96032bd9f..4fd3ae9f3 100644 --- a/models/solution/solution.rb +++ b/models/solution/solution.rb @@ -25,6 +25,19 @@ class Solution < Base field :heuristic_synthesis, default: [] field :iterations field :solvers, default: [] + field :interpreters, default: [] + + def mark_interpreter!(name) + self.interpreters |= [name.to_s] + self + end + + def absorb_interpreters!(service_vrp) + return self unless service_vrp + + self.interpreters |= Array(service_vrp.interpreters) + self + end has_many :routes, class_name: 'Models::Solution::Route' has_many :skipped_services, class_name: 'Models::SkippedService' @@ -87,9 +100,11 @@ def count_used_routes def +(other) solution = Solution.new({}) + solution.name = self.name || other.name solution.elapsed = self.elapsed + other.elapsed solution.heuristic_synthesis = self.heuristic_synthesis + other.heuristic_synthesis solution.solvers = self.solvers + other.solvers + solution.interpreters = (self.interpreters + other.interpreters).uniq solution.routes = self.routes + other.routes solution.skipped_services = self.skipped_services + other.skipped_services solution.unassigned_stops = self.unassigned_stops + other.unassigned_stops diff --git a/models/types/solver_type.rb b/models/types/solver_type.rb index 74e27a2c7..a47159a21 100644 --- a/models/types/solver_type.rb +++ b/models/types/solver_type.rb @@ -1,5 +1,5 @@ class SolverType - ALL_SOLVERS = %i[demo ortools pyvrp vroom].freeze + ALL_SOLVERS = %i[demo ortools pyvrp rosomaxa vroom].freeze def self.type_cast(value) value = value.split(',') if value.is_a?(String) diff --git a/models/vrp.rb b/models/vrp.rb index a191a438a..e643a56a5 100644 --- a/models/vrp.rb +++ b/models/vrp.rb @@ -33,6 +33,13 @@ class Vrp < Base field :name, default: nil field :router, default: OptimizerWrapper.router(OptimizerWrapper.config[:router][:api_key]), as_json: :none + # Keep '_' as segment separator (filterable); turn spaces/punctuation into '_'. + def self.normalize_name(name) + return if name.nil? + + name.to_s.encode(Encoding::UTF_8, invalid: :replace, undef: :replace).parameterize(separator: '_') + end + has_many :matrices, class_name: 'Models::Matrix' has_many :points, class_name: 'Models::Point' has_many :units, class_name: 'Models::Unit' @@ -68,6 +75,7 @@ def self.create(hash, options = {}) self.ensure_retrocompatibility(hash) self.filter(hash) if options[:check] # TODO : add filters.rb here vrp.check_consistency(hash) if options[:check] # TODO: this check can be directly done on the hash without concern + hash[:name] = normalize_name(hash[:name]) if hash[:name] [:name, :matrices, :units, :points, :rests, :zones, :capacities, :quantities, :timewindows, :reload_depots, :vehicles, :services, :relations, :subtours, :routes, :configuration].each{ |key| vrp.send("#{key}=", hash[key]) if hash[key] @@ -102,6 +110,7 @@ def expand_vehicles_for_consistent_empty_result def empty_solution(solver, unassigned_with_reason = [], already_expanded = true) self.vehicles = expand_vehicles_for_consistent_empty_result if self.schedule? && !already_expanded solution = Models::Solution.new( + name: self.name, solvers: [solver], routes: self.vehicles.map{ |v| self.empty_route(v) }, unassigned_stops: (unassigned_visits(unassigned_with_reason) + diff --git a/optimizer_wrapper.rb b/optimizer_wrapper.rb index 632e51950..7793070d3 100644 --- a/optimizer_wrapper.rb +++ b/optimizer_wrapper.rb @@ -81,6 +81,8 @@ def self.wrapper_vrp(api_key, profile, vrp, checksum, job_id = nil) job_id = Job.enqueue_to(profile[:queue], Job, services_vrps: Base64.encode64(Marshal.dump(services_vrps)), api_key: api_key, checksum: checksum, + vrp_name: vrp.name, + vrp_dump_key: vrp_dump_key(api_key, vrp.name, checksum), pids: []) JobList.add(api_key, job_id) { diff --git a/public/css/style.css b/public/css/style.css index 81417340b..87f6f36d5 100644 --- a/public/css/style.css +++ b/public/css/style.css @@ -2,6 +2,12 @@ body { font-family: verdana, arial, helvetica, sans-serif; font-size: 0.9em; } +body:not([data-page=result]) { + font-family: "Segoe UI", system-ui, sans-serif; + background: #f4f6f8; + color: #1f2933; + margin: 0; +} h1 { margin: 5px; font-variant: small-caps; @@ -16,11 +22,81 @@ form { border: 1px solid lightgrey; } +body:not([data-page=result]) form { + border: 0; +} + input[type=text] { padding-left: 3px; padding-right: 3px; } +.panel-form { + margin: 16px 8px; + background: #fff; + border: 1px solid #e3e8ee !important; + border-radius: 12px; + padding: 16px 18px; + display: flex; + flex-direction: column; + gap: 12px; +} +.panel-form-row { + display: flex; + flex-wrap: wrap; + gap: 10px; + align-items: center; +} +.panel-form-row label { + color: #52606d; + font-size: 0.9rem; +} +.panel-form input[type=file] { + font: inherit; + color: #345; +} +.job-action-primary { + border: 0; + background: #1f4b99; + color: #fff; + border-radius: 8px; + padding: 8px 14px; +} +.job-action-primary:disabled { + opacity: 0.55; + cursor: not-allowed; +} +#files-infos { + color: #52606d; + font-size: 0.85rem; +} +.result-panel { + margin: 16px 8px 24px; + background: #fff; + border: 1px solid #e3e8ee; + border-radius: 12px; + padding: 16px 18px; +} +.result-toolbar { + display: flex; + flex-wrap: wrap; + gap: 8px; + align-items: center; + margin-bottom: 10px; +} +.result-panel #result { + width: calc(100% - 330px); + min-width: 280px; + box-sizing: border-box; + border: 1px solid #d0d7e2; + border-radius: 8px; + padding: 10px; + font-family: ui-monospace, monospace; + font-size: 0.82rem; + color: #1f2933; + background: #f8fafc; +} + fieldset { border: #E8E8E8 1px solid; } @@ -71,16 +147,195 @@ form #optim-options input { min-width: 300px; } -#jobs-list{ - height: 155px; +#optim-infos { + display: inline-flex; + flex-wrap: wrap; + gap: 8px; + align-items: center; + vertical-align: middle; +} +#optim-infos .optim-summary, +#optim-infos #optim-status, +#optim-infos #avancement, +#optim-infos #timer { + color: #52606d; + font-size: 0.9rem; +} + +#optim-list-status { + border: 0; + background: transparent; + padding: 0; +} +#optim-list-legend { + font-size: 1.05rem; + font-weight: 650; + padding: 0 0 8px; +} +.jobs-filters { + display: flex; + flex-wrap: wrap; + gap: 8px; + align-items: center; + margin: 0 0 10px; +} +.jobs-filters-label { + color: #52606d; + font-size: 0.85rem; +} +.jobs-filter-select { + border: 1px solid #d0d7e2; + border-radius: 8px; + padding: 6px 8px; + font: inherit; + background: #fff; + min-width: 8rem; +} +#jobs-list { + max-height: 320px; width: 100%; box-sizing: border-box; - overflow-y: scroll; - display: inline-block; + overflow-y: auto; + display: flex; + flex-direction: column; + gap: 8px; +} +.job-card { + display: flex; + justify-content: space-between; + gap: 12px; + align-items: center; + background: #fff; + border: 1px solid #e3e8ee; + border-radius: 10px; + padding: 10px 12px; + line-height: 1.4; +} +.job-card-main, .job-card-actions { + display: flex; + flex-wrap: wrap; + gap: 8px; + align-items: center; } .job_title { - font-family: Courier New, Courier, Lucida Sans Typewriter, Lucida Typewriter, monospace; + font-family: ui-monospace, monospace; + font-size: 0.82rem; + background: #f4f6f8; + padding: 2px 6px; + border-radius: 4px; +} +.status-pill { + font-size: 0.75rem; + border-radius: 999px; + padding: 2px 8px; + background: #e8eef5; + color: #345; +} +.status-completed { background: #e5f6ec; color: #146c36; } +.status-working { background: #fff4d6; color: #8a5a00; } +.status-failed, .status-killed { background: #fde8e8; color: #9b1c1c; } +.job-action { + border: 1px solid #d0d7e2; + background: #fff; + border-radius: 6px; + padding: 4px 8px; + font: inherit; + color: #1f4b99; + text-decoration: none; + cursor: pointer; +} +.job-action-danger { color: #9b1c1c; } +.compare { + margin: 16px 8px 24px; + background: #fff; + border: 1px solid #e3e8ee; + border-radius: 12px; + padding: 16px 18px 8px; +} +.compare-header { + display: flex; + justify-content: space-between; + gap: 16px; + align-items: flex-start; +} +.compare-header h2 { + margin: 0 0 4px; + font-size: 1.15rem; + font-weight: 650; +} +.compare-header p { + margin: 0; + color: #62707e; + font-size: 0.85rem; +} +#compare-add { + display: flex; + gap: 8px; + border: 0; + margin: 0; } +#compare-job-id { + width: 220px; + border: 1px solid #d0d7e2; + border-radius: 8px; + padding: 8px 10px; + font: inherit; +} +#compare-submit { + border: 0; + background: #1f4b99; + color: #fff; + border-radius: 8px; + padding: 8px 14px; + font: inherit; + cursor: pointer; +} +#compare-submit:hover, +.job-action-primary:hover:not(:disabled) { + background: #183c7a; +} +.compare-scroll { overflow-x: auto; margin-top: 12px; } +.compare-table { + border-collapse: separate; + border-spacing: 0; + width: 100%; + min-width: 640px; +} +.compare-table th, .compare-table td { + border-bottom: 1px solid #eef1f4; + padding: 10px 12px; + text-align: left; + vertical-align: top; + font-weight: 400; +} +.compare-table tbody th { + color: #52606d; + width: 180px; + white-space: nowrap; +} +.compare-plan.is-reference { + box-shadow: inset 0 0 0 1.5px #1f2933; + border-radius: 8px 8px 0 0; +} +.plan-head { position: relative; min-width: 180px; padding-right: 18px; } +.plan-title { font-weight: 650; word-break: break-all; } +.plan-when { color: #62707e; font-size: 0.75rem; margin-top: 2px; } +.plan-close { + position: absolute; + top: 0; + right: 0; + border: 0; + background: transparent; + font-size: 1.1rem; + cursor: pointer; + color: #62707e; +} +.plan-ref { display: block; margin-top: 8px; font-size: 0.8rem; color: #52606d; } +.metric { font-variant-numeric: tabular-nums; } +.delta { font-size: 0.78rem; color: #9aa5b1; min-height: 1em; } +.tone-worse .metric, .tone-worse .delta { color: #c0392b; } +.tone-better .metric, .tone-better .delta { color: #1e7f4f; } +.tone-diff .metric { color: #8a5a00; } .error { font-weight: bold; @@ -189,4 +444,4 @@ body[data-page=result] { #select-container .select2-container { max-height: 200px; overflow-y: auto; -} \ No newline at end of file +} diff --git a/public/index.html.erb b/public/index.html.erb index 92a710923..ed920cc48 100644 --- a/public/index.html.erb +++ b/public/index.html.erb @@ -14,36 +14,54 @@