From 75a0191d40efe34cffdfe3b9524f47d8b8e544fd Mon Sep 17 00:00:00 2001 From: Manuel Bustillo Date: Fri, 1 Aug 2025 12:25:43 +0200 Subject: [PATCH 1/5] Add a new status column to tables arrangements table --- app/models/tables_arrangement.rb | 1 + ...0250801102437_add_status_column_to_tables_arrangements.rb | 5 +++++ db/schema.rb | 3 ++- 3 files changed, 8 insertions(+), 1 deletion(-) create mode 100644 db/migrate/20250801102437_add_status_column_to_tables_arrangements.rb diff --git a/app/models/tables_arrangement.rb b/app/models/tables_arrangement.rb index b5fccb0..a0d4bb2 100644 --- a/app/models/tables_arrangement.rb +++ b/app/models/tables_arrangement.rb @@ -10,6 +10,7 @@ # digest :uuid not null # discomfort :integer # name :string not null +# status :string default("complete"), not null # created_at :datetime not null # updated_at :datetime not null # wedding_id :uuid not null diff --git a/db/migrate/20250801102437_add_status_column_to_tables_arrangements.rb b/db/migrate/20250801102437_add_status_column_to_tables_arrangements.rb new file mode 100644 index 0000000..abe56da --- /dev/null +++ b/db/migrate/20250801102437_add_status_column_to_tables_arrangements.rb @@ -0,0 +1,5 @@ +class AddStatusColumnToTablesArrangements < ActiveRecord::Migration[8.0] + def change + add_column :tables_arrangements, :status, :string, default: :complete, null: false + end +end diff --git a/db/schema.rb b/db/schema.rb index 2576583..b0279ad 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[8.0].define(version: 2025_06_08_181054) do +ActiveRecord::Schema[8.0].define(version: 2025_08_01_102437) do # These are extensions that must be enabled in order to support this database enable_extension "pg_catalog.plpgsql" @@ -216,6 +216,7 @@ ActiveRecord::Schema[8.0].define(version: 2025_06_08_181054) do t.string "name", null: false t.uuid "wedding_id", null: false t.uuid "digest", default: -> { "gen_random_uuid()" }, null: false + t.string "status", default: "complete", null: false t.index ["wedding_id"], name: "index_tables_arrangements_on_wedding_id" end -- 2.47.1 From dd14a96e98631c2bbb3483c039ae3289cdbd5695 Mon Sep 17 00:00:00 2001 From: Manuel Bustillo Date: Fri, 1 Aug 2025 12:29:13 +0200 Subject: [PATCH 2/5] Expose and document the new status attribute in the tables arrangements controller --- app/controllers/tables_arrangements_controller.rb | 4 ++-- spec/requests/tables_arrangements_spec.rb | 3 ++- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/app/controllers/tables_arrangements_controller.rb b/app/controllers/tables_arrangements_controller.rb index 45cf633..9d2243a 100644 --- a/app/controllers/tables_arrangements_controller.rb +++ b/app/controllers/tables_arrangements_controller.rb @@ -9,10 +9,10 @@ class TablesArrangementsController < ApplicationController render json: TablesArrangement .order(valid: :desc) .order(discomfort: :asc) - .select(:id, :name, :discomfort) + .select(:id, :name, :discomfort, :status) .select("digest = '#{current_digest}'::uuid as valid") .limit(20) - .as_json(only: %i[id name discomfort valid]) + .as_json(only: %i[id name discomfort valid status]) end def show diff --git a/spec/requests/tables_arrangements_spec.rb b/spec/requests/tables_arrangements_spec.rb index 3cc11c1..b8fedfe 100644 --- a/spec/requests/tables_arrangements_spec.rb +++ b/spec/requests/tables_arrangements_spec.rb @@ -19,7 +19,8 @@ RSpec.describe 'tables_arrangements' do id: { type: :string, format: :uuid }, name: { type: :string }, discomfort: { type: :integer }, - valid: { type: :boolean } + valid: { type: :boolean }, + status: { type: :string, enum: %w[complete in_progress] } } } xit -- 2.47.1 From ac659bef860be1756e1520eea09f0d6464a0bdc8 Mon Sep 17 00:00:00 2001 From: Manuel Bustillo Date: Mon, 8 Sep 2025 15:51:43 +0200 Subject: [PATCH 3/5] Update Tables::Distribution#save! to consider that the distribution may already be persisted --- app/models/seat.rb | 2 +- app/services/tables/distribution.rb | 11 +++++--- spec/services/tables/distribution_spec.rb | 33 +++++++++++++++++++++++ 3 files changed, 42 insertions(+), 4 deletions(-) diff --git a/app/models/seat.rb b/app/models/seat.rb index f4f7b97..4104d81 100644 --- a/app/models/seat.rb +++ b/app/models/seat.rb @@ -29,5 +29,5 @@ class Seat < ApplicationRecord acts_as_tenant :wedding belongs_to :guest - belongs_to :table_arrangement + belongs_to :tables_arrangement end diff --git a/app/services/tables/distribution.rb b/app/services/tables/distribution.rb index 4751cf8..bad8bf1 100644 --- a/app/services/tables/distribution.rb +++ b/app/services/tables/distribution.rb @@ -12,13 +12,14 @@ module Tables end end - attr_accessor :tables, :min_per_table, :max_per_table, :hierarchy + attr_accessor :tables, :min_per_table, :max_per_table, :hierarchy, :tables_arrangement_id - def initialize(min_per_table:, max_per_table:, hierarchy: AffinityGroupsHierarchy.new) + def initialize(min_per_table:, max_per_table:, hierarchy: AffinityGroupsHierarchy.new, tables_arrangement_id: nil) @min_per_table = min_per_table @max_per_table = max_per_table @hierarchy = hierarchy @tables = [] + @tables_arrangement_id = tables_arrangement_id end def random_distribution(people, random: Random.new) @@ -50,7 +51,11 @@ module Tables def save! ActiveRecord::Base.transaction do - arrangement = TablesArrangement.create! + arrangement = TablesArrangement.find_or_create_by!(id: tables_arrangement_id) + + self.tables_arrangement_id = arrangement.id + + arrangement.seats.delete_all records_to_store = [] diff --git a/spec/services/tables/distribution_spec.rb b/spec/services/tables/distribution_spec.rb index e598a52..912e4b3 100644 --- a/spec/services/tables/distribution_spec.rb +++ b/spec/services/tables/distribution_spec.rb @@ -6,6 +6,39 @@ require 'rails_helper' module Tables RSpec.describe Distribution do + describe '#save!' do + + around do |example| + ActsAsTenant.with_tenant(create(:wedding)) do + example.run + end + end + + let(:people) { create_list(:guest, 2, status: :invited) } + let(:distribution) do + described_class.new(min_per_table: 5, max_per_table: 10).tap{|d| d.random_distribution(people)} + end + + context 'when tables_arrangement_id is nil' do + + + it { expect { distribution.save! }.to change { TablesArrangement.count }.by(1) } + it { expect { distribution.save! }.to change { Seat.count }.by(2) } + end + + context 'when tables_arrangement_id is set' do + before do + existing_arrangement = TablesArrangement.create! + + existing_arrangement.seats.create!(guest: people.first, table_number: 1) + distribution.tables_arrangement_id = existing_arrangement.id + end + + it { expect { distribution.save! }.not_to change { TablesArrangement.count } } + it { expect { distribution.save! }.to change { Seat.count }.by(1) } + end + end + describe '#random_distribution' do subject(:distribution) { described_class.new(min_per_table: 5, max_per_table: 10) } -- 2.47.1 From 0d1b64256da7481c638789a261eb97a1584508e9 Mon Sep 17 00:00:00 2001 From: Manuel Bustillo Date: Mon, 8 Sep 2025 16:32:13 +0200 Subject: [PATCH 4/5] Provide notification callbacks for progress and new solutions --- app/services/vns/engine.rb | 17 ++++++++++++++++- lib/tasks/vns.rake | 8 ++++++++ 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/app/services/vns/engine.rb b/app/services/vns/engine.rb index dc0feff..a5f680c 100644 --- a/app/services/vns/engine.rb +++ b/app/services/vns/engine.rb @@ -5,6 +5,7 @@ module VNS class Engine PERTURBATION_SIZES = [1, 1, 1, 2, 2, 3].freeze + ITERATIONS = 50 class << self def sequence(elements) elements = elements.to_a @@ -26,6 +27,14 @@ module VNS @perturbations << klass end + def notify_progress(&block) + @progress_notifier = block + end + + def on_better_solution(&block) + @better_solution_notifier = block + end + attr_writer :initial_solution def run @@ -40,18 +49,24 @@ module VNS run_all_optimizations + @progress_notifier&.call(Rational(1, ITERATIONS + 1)) + best_solution = @current_solution - 50.times do + ITERATIONS.times do |iteration| @current_solution = Tables::WheelSwap.new(best_solution).call(PERTURBATION_SIZES.sample) @best_score = @target_function.call(@current_solution) Rails.logger.debug { "After perturbation: #{@best_score}" } run_all_optimizations + @progress_notifier&.call(Rational(iteration + 1, ITERATIONS + 1)) + next unless best_solution.discomfort > @current_solution.discomfort best_solution = @current_solution + @better_solution_notifier&.call(best_solution) + Rails.logger.debug do "Found better solution after perturbation optimization: #{@current_solution.discomfort}" end diff --git a/lib/tasks/vns.rake b/lib/tasks/vns.rake index 9554a76..b7c38ac 100644 --- a/lib/tasks/vns.rake +++ b/lib/tasks/vns.rake @@ -21,6 +21,14 @@ namespace :vns do engine.target_function(&:discomfort) + engine.notify_progress do |current_progress| + Rails.logger.info "Progress: #{(current_progress * 100.0).round(2)}%" + end + + engine.on_better_solution do |better_solution| + Rails.logger.info "New best solution found with discomfort: #{better_solution.discomfort}" + end + solution = Rails.benchmark('VNS Benchmarking') { engine.run } Rails.logger.info "Best solution found with discomfort: #{solution.discomfort}" -- 2.47.1 From 12174b6f20b53933279ebcf0d941ddc9aa627001 Mon Sep 17 00:00:00 2001 From: Manuel Bustillo Date: Mon, 8 Sep 2025 22:44:54 +0200 Subject: [PATCH 5/5] Persist VNS calculation progress whenever an improvement has been made --- .../tables_arrangements_controller.rb | 11 +++++--- app/jobs/table_simulator_job.rb | 25 +++++++++++++++++-- app/models/tables_arrangement.rb | 1 + app/services/tables/distribution.rb | 12 ++++++--- app/services/vns/engine.rb | 2 +- bin/jobs | 1 + ...119_add_progress_to_tables_arrangements.rb | 5 ++++ db/schema.rb | 3 ++- db/seeds.rb | 4 ++- nginx.conf | 5 ++++ 10 files changed, 56 insertions(+), 13 deletions(-) create mode 100644 db/migrate/20250908145119_add_progress_to_tables_arrangements.rb diff --git a/app/controllers/tables_arrangements_controller.rb b/app/controllers/tables_arrangements_controller.rb index 9d2243a..184b577 100644 --- a/app/controllers/tables_arrangements_controller.rb +++ b/app/controllers/tables_arrangements_controller.rb @@ -9,10 +9,10 @@ class TablesArrangementsController < ApplicationController render json: TablesArrangement .order(valid: :desc) .order(discomfort: :asc) - .select(:id, :name, :discomfort, :status) - .select("digest = '#{current_digest}'::uuid as valid") + .select(:id, :name, :discomfort, :status, :progress) + .select("digest = '#{current_digest}'::uuid OR discomfort IS NULL as valid") .limit(20) - .as_json(only: %i[id name discomfort valid status]) + .as_json(only: %i[id name discomfort valid status progress]) end def show @@ -25,7 +25,10 @@ class TablesArrangementsController < ApplicationController end def create - TableSimulatorJob.perform_later(current_tenant.id) + ActiveRecord::Base.transaction do + tables_arrangement = TablesArrangement.create!(status: :not_started) + TableSimulatorJob.perform_later(current_tenant.id, tables_arrangement.id) + end render json: {}, status: :created end diff --git a/app/jobs/table_simulator_job.rb b/app/jobs/table_simulator_job.rb index 0471795..44d4759 100644 --- a/app/jobs/table_simulator_job.rb +++ b/app/jobs/table_simulator_job.rb @@ -8,16 +8,35 @@ class TableSimulatorJob < ApplicationJob MIN_PER_TABLE = 8 MAX_PER_TABLE = 10 - def perform(wedding_id) + def perform(wedding_id, tables_arrangement_id) + Rails.logger.info "Starting table simulation #{tables_arrangement_id} for wedding #{wedding_id}" ActsAsTenant.with_tenant(Wedding.find(wedding_id)) do engine = VNS::Engine.new engine.add_optimization(Tables::Swap) engine.add_optimization(Tables::Shift) - initial_solution = Tables::Distribution.new(min_per_table: MIN_PER_TABLE, max_per_table: MAX_PER_TABLE) + tables_arrangement = TablesArrangement.find(tables_arrangement_id) + + initial_solution = Tables::Distribution.new( + min_per_table: MIN_PER_TABLE, + max_per_table: MAX_PER_TABLE, + tables_arrangement_id: + ) + initial_solution.random_distribution(Guest.potential.shuffle) + initial_solution.save! + + engine.notify_progress do |current_progress| + tables_arrangement.update_columns(status: :in_progress, progress: current_progress) + end + + engine.on_better_solution do |better_solution| + better_solution.save! + tables_arrangement.update_columns(discomfort: better_solution.discomfort) # TODO: remove? + end + engine.initial_solution = initial_solution engine.target_function(&:discomfort) @@ -25,6 +44,8 @@ class TableSimulatorJob < ApplicationJob best_solution = engine.run best_solution.save! + + tables_arrangement.update_columns(status: :completed) end end end diff --git a/app/models/tables_arrangement.rb b/app/models/tables_arrangement.rb index a0d4bb2..99f8c7c 100644 --- a/app/models/tables_arrangement.rb +++ b/app/models/tables_arrangement.rb @@ -10,6 +10,7 @@ # digest :uuid not null # discomfort :integer # name :string not null +# progress :float default(0.0), not null # status :string default("complete"), not null # created_at :datetime not null # updated_at :datetime not null diff --git a/app/services/tables/distribution.rb b/app/services/tables/distribution.rb index bad8bf1..922d184 100644 --- a/app/services/tables/distribution.rb +++ b/app/services/tables/distribution.rb @@ -14,7 +14,7 @@ module Tables attr_accessor :tables, :min_per_table, :max_per_table, :hierarchy, :tables_arrangement_id - def initialize(min_per_table:, max_per_table:, hierarchy: AffinityGroupsHierarchy.new, tables_arrangement_id: nil) + def initialize(min_per_table:, max_per_table:, tables_arrangement_id:, hierarchy: AffinityGroupsHierarchy.new) @min_per_table = min_per_table @max_per_table = max_per_table @hierarchy = hierarchy @@ -43,15 +43,19 @@ module Tables end def deep_dup - self.class.new(min_per_table: @min_per_table, max_per_table: @max_per_table, - hierarchy: @hierarchy).tap do |new_distribution| + self.class.new( + min_per_table: @min_per_table, + max_per_table: @max_per_table, + hierarchy: @hierarchy, + tables_arrangement_id: @tables_arrangement_id + ).tap do |new_distribution| new_distribution.tables = @tables.map(&:dup) end end def save! ActiveRecord::Base.transaction do - arrangement = TablesArrangement.find_or_create_by!(id: tables_arrangement_id) + arrangement = TablesArrangement.find(tables_arrangement_id) self.tables_arrangement_id = arrangement.id diff --git a/app/services/vns/engine.rb b/app/services/vns/engine.rb index a5f680c..5408bc9 100644 --- a/app/services/vns/engine.rb +++ b/app/services/vns/engine.rb @@ -53,7 +53,7 @@ module VNS best_solution = @current_solution - ITERATIONS.times do |iteration| + (1..ITERATIONS).each do |iteration| @current_solution = Tables::WheelSwap.new(best_solution).call(PERTURBATION_SIZES.sample) @best_score = @target_function.call(@current_solution) Rails.logger.debug { "After perturbation: #{@best_score}" } diff --git a/bin/jobs b/bin/jobs index dcf59f3..fcc5d90 100755 --- a/bin/jobs +++ b/bin/jobs @@ -3,4 +3,5 @@ require_relative "../config/environment" require "solid_queue/cli" +SolidQueue.logger = ActiveSupport::Logger.new($stdout) SolidQueue::Cli.start(ARGV) diff --git a/db/migrate/20250908145119_add_progress_to_tables_arrangements.rb b/db/migrate/20250908145119_add_progress_to_tables_arrangements.rb new file mode 100644 index 0000000..2783aa2 --- /dev/null +++ b/db/migrate/20250908145119_add_progress_to_tables_arrangements.rb @@ -0,0 +1,5 @@ +class AddProgressToTablesArrangements < ActiveRecord::Migration[8.0] + def change + add_column :tables_arrangements, :progress, :float, default: 0, null: false + end +end diff --git a/db/schema.rb b/db/schema.rb index b0279ad..fb57903 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[8.0].define(version: 2025_08_01_102437) do +ActiveRecord::Schema[8.0].define(version: 2025_09_08_145119) do # These are extensions that must be enabled in order to support this database enable_extension "pg_catalog.plpgsql" @@ -217,6 +217,7 @@ ActiveRecord::Schema[8.0].define(version: 2025_08_01_102437) do t.uuid "wedding_id", null: false t.uuid "digest", default: -> { "gen_random_uuid()" }, null: false t.string "status", default: "complete", null: false + t.float "progress", default: 0.0, null: false t.index ["wedding_id"], name: "index_tables_arrangements_on_wedding_id" end diff --git a/db/seeds.rb b/db/seeds.rb index f69b6e9..c1214ea 100644 --- a/db/seeds.rb +++ b/db/seeds.rb @@ -86,7 +86,9 @@ ActsAsTenant.with_tenant(wedding) do # TODO: Clean up invitations with no guests - ActiveJob.perform_all_later(3.times.map { TableSimulatorJob.new(wedding.id) }) + 3.times { TablesArrangement.create! } + .map { |arrangement| TableSimulatorJob.new(wedding.id, arrangement.id) } + .then { |jobs| ActiveJob.perform_all_later } "red".dup.paint.palette.triad(as: :hex).zip(Group.roots).each { |(color, group)| group.update!(color: color.paint.desaturate(40)) } diff --git a/nginx.conf b/nginx.conf index 58c2bb9..07f73fd 100644 --- a/nginx.conf +++ b/nginx.conf @@ -12,6 +12,11 @@ server { proxy_set_header Host $http_host; } + location /jobs/ { + proxy_pass http://backend:3000/jobs/; + proxy_set_header Host $http_host; + } + location /captcha/v2/media/ { proxy_pass http://libre-captcha:8888/v2/media/; proxy_set_header Host $http_host; -- 2.47.1