diff --git a/Gemfile b/Gemfile index 7390283..2bd95bd 100644 --- a/Gemfile +++ b/Gemfile @@ -29,6 +29,8 @@ gem 'strong_migrations' # Use safe Migration patterns gem 'rubocop' +gem "activerecord-slotted_counters" + group :development, :test do gem 'active_record_doctor' gem 'benchmark-ips' diff --git a/Gemfile.lock b/Gemfile.lock index 40200c0..0ddc6b1 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -73,6 +73,8 @@ GEM timeout (>= 0.4.0) activerecord-import (1.8.1) activerecord (>= 4.2) + activerecord-slotted_counters (0.3.0) + activerecord (>= 6.1) activestorage (7.2.2.1) actionpack (= 7.2.2.1) activejob (= 7.2.2.1) @@ -358,6 +360,7 @@ PLATFORMS DEPENDENCIES active_record_doctor activerecord-import (~> 1.5) + activerecord-slotted_counters bcrypt (~> 3.1) benchmark-ips benchmark-memory diff --git a/app/models/rider.rb b/app/models/rider.rb index 119e36e..2425ad0 100644 --- a/app/models/rider.rb +++ b/app/models/rider.rb @@ -1,4 +1,7 @@ class Rider < User + # https://github.com/evilmartians/activerecord-slotted_counters + has_slotted_counter :trip_requests + has_many :trip_requests has_many :trips, through: :trip_requests end diff --git a/app/models/user.rb b/app/models/user.rb index 1791121..583fc4a 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -1,4 +1,5 @@ class User < ApplicationRecord + # counter_cache trips_count has_secure_password validates :first_name, :last_name, presence: true validates :drivers_license_number, diff --git a/db/migrate/20250617150055_create_slotted_counters.rb b/db/migrate/20250617150055_create_slotted_counters.rb new file mode 100644 index 0000000..3d2eb43 --- /dev/null +++ b/db/migrate/20250617150055_create_slotted_counters.rb @@ -0,0 +1,15 @@ +class CreateSlottedCounters < ActiveRecord::Migration[7.2] + def change + create_table :slotted_counters do |t| + t.string :counter_name, null: false + t.string :associated_record_type, null: false + t.integer :associated_record_id, null: false + t.integer :slot, null: false + t.integer :count, null: false + + t.timestamps + end + + add_index :slotted_counters, [:associated_record_id, :associated_record_type, :counter_name, :slot], unique: true, name: 'index_slotted_counters' + end +end diff --git a/db/structure.sql b/db/structure.sql index 8da33d7..c506269 100644 --- a/db/structure.sql +++ b/db/structure.sql @@ -27,6 +27,7 @@ DROP INDEX IF EXISTS rideshare.index_trips_on_driver_id; DROP INDEX IF EXISTS rideshare.index_trip_requests_on_start_location_id; DROP INDEX IF EXISTS rideshare.index_trip_requests_on_rider_id; DROP INDEX IF EXISTS rideshare.index_trip_requests_on_end_location_id; +DROP INDEX IF EXISTS rideshare.index_slotted_counters; DROP INDEX IF EXISTS rideshare.index_locations_on_address; DROP INDEX IF EXISTS rideshare.index_fast_search_results_on_driver_id; ALTER TABLE IF EXISTS ONLY rideshare.vehicles DROP CONSTRAINT IF EXISTS vehicles_pkey; @@ -35,6 +36,7 @@ ALTER TABLE IF EXISTS ONLY rideshare.users DROP CONSTRAINT IF EXISTS users_pkey; ALTER TABLE IF EXISTS ONLY rideshare.trips DROP CONSTRAINT IF EXISTS trips_pkey; ALTER TABLE IF EXISTS ONLY rideshare.trip_requests DROP CONSTRAINT IF EXISTS trip_requests_pkey; ALTER TABLE IF EXISTS ONLY rideshare.trip_positions DROP CONSTRAINT IF EXISTS trip_positions_pkey; +ALTER TABLE IF EXISTS ONLY rideshare.slotted_counters DROP CONSTRAINT IF EXISTS slotted_counters_pkey; ALTER TABLE IF EXISTS ONLY rideshare.schema_migrations DROP CONSTRAINT IF EXISTS schema_migrations_pkey; ALTER TABLE IF EXISTS ONLY rideshare.vehicle_reservations DROP CONSTRAINT IF EXISTS non_overlapping_vehicle_registration; ALTER TABLE IF EXISTS ONLY rideshare.locations DROP CONSTRAINT IF EXISTS locations_pkey; @@ -46,6 +48,7 @@ ALTER TABLE IF EXISTS rideshare.users ALTER COLUMN id DROP DEFAULT; ALTER TABLE IF EXISTS rideshare.trips ALTER COLUMN id DROP DEFAULT; ALTER TABLE IF EXISTS rideshare.trip_requests ALTER COLUMN id DROP DEFAULT; ALTER TABLE IF EXISTS rideshare.trip_positions ALTER COLUMN id DROP DEFAULT; +ALTER TABLE IF EXISTS rideshare.slotted_counters ALTER COLUMN id DROP DEFAULT; ALTER TABLE IF EXISTS rideshare.locations ALTER COLUMN id DROP DEFAULT; DROP SEQUENCE IF EXISTS rideshare.vehicles_id_seq; DROP TABLE IF EXISTS rideshare.vehicles; @@ -57,6 +60,8 @@ DROP SEQUENCE IF EXISTS rideshare.trip_requests_id_seq; DROP TABLE IF EXISTS rideshare.trip_requests; DROP SEQUENCE IF EXISTS rideshare.trip_positions_id_seq; DROP TABLE IF EXISTS rideshare.trip_positions; +DROP SEQUENCE IF EXISTS rideshare.slotted_counters_id_seq; +DROP TABLE IF EXISTS rideshare.slotted_counters; DROP VIEW IF EXISTS rideshare.search_results; DROP TABLE IF EXISTS rideshare.schema_migrations; DROP SEQUENCE IF EXISTS rideshare.locations_id_seq; @@ -314,6 +319,41 @@ CREATE VIEW rideshare.search_results AS ORDER BY (count(t.rating)) DESC; +-- +-- Name: slotted_counters; Type: TABLE; Schema: rideshare; Owner: - +-- + +CREATE TABLE rideshare.slotted_counters ( + id bigint NOT NULL, + counter_name character varying NOT NULL, + associated_record_type character varying NOT NULL, + associated_record_id integer NOT NULL, + slot integer NOT NULL, + count integer NOT NULL, + created_at timestamp(6) without time zone NOT NULL, + updated_at timestamp(6) without time zone NOT NULL +); + + +-- +-- Name: slotted_counters_id_seq; Type: SEQUENCE; Schema: rideshare; Owner: - +-- + +CREATE SEQUENCE rideshare.slotted_counters_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +-- +-- Name: slotted_counters_id_seq; Type: SEQUENCE OWNED BY; Schema: rideshare; Owner: - +-- + +ALTER SEQUENCE rideshare.slotted_counters_id_seq OWNED BY rideshare.slotted_counters.id; + + -- -- Name: trip_positions; Type: TABLE; Schema: rideshare; Owner: - -- @@ -491,6 +531,13 @@ ALTER SEQUENCE rideshare.vehicles_id_seq OWNED BY rideshare.vehicles.id; ALTER TABLE ONLY rideshare.locations ALTER COLUMN id SET DEFAULT nextval('rideshare.locations_id_seq'::regclass); +-- +-- Name: slotted_counters id; Type: DEFAULT; Schema: rideshare; Owner: - +-- + +ALTER TABLE ONLY rideshare.slotted_counters ALTER COLUMN id SET DEFAULT nextval('rideshare.slotted_counters_id_seq'::regclass); + + -- -- Name: trip_positions id; Type: DEFAULT; Schema: rideshare; Owner: - -- @@ -573,6 +620,14 @@ ALTER TABLE ONLY rideshare.schema_migrations ADD CONSTRAINT schema_migrations_pkey PRIMARY KEY (version); +-- +-- Name: slotted_counters slotted_counters_pkey; Type: CONSTRAINT; Schema: rideshare; Owner: - +-- + +ALTER TABLE ONLY rideshare.slotted_counters + ADD CONSTRAINT slotted_counters_pkey PRIMARY KEY (id); + + -- -- Name: trip_positions trip_positions_pkey; Type: CONSTRAINT; Schema: rideshare; Owner: - -- @@ -635,6 +690,13 @@ CREATE UNIQUE INDEX index_fast_search_results_on_driver_id ON rideshare.fast_sea CREATE UNIQUE INDEX index_locations_on_address ON rideshare.locations USING btree (address); +-- +-- Name: index_slotted_counters; Type: INDEX; Schema: rideshare; Owner: - +-- + +CREATE UNIQUE INDEX index_slotted_counters ON rideshare.slotted_counters USING btree (associated_record_id, associated_record_type, counter_name, slot); + + -- -- Name: index_trip_requests_on_end_location_id; Type: INDEX; Schema: rideshare; Owner: - -- @@ -776,6 +838,7 @@ ALTER TABLE ONLY rideshare.trip_requests SET search_path TO rideshare; INSERT INTO "schema_migrations" (version) VALUES +('20250617150055'), ('20231220043547'), ('20231218215836'), ('20231213045957'), diff --git a/docs/slotted_counters.md b/docs/slotted_counters.md new file mode 100644 index 0000000..0efb726 --- /dev/null +++ b/docs/slotted_counters.md @@ -0,0 +1,26 @@ +# Slotted Counters + +A Rider can track its trip_requests. + +```rb +# https://github.com/evilmartians/activerecord-slotted_counters +has_slotted_counter :trip_requests +``` + +Increment counter manually +``` +rideshare(dev)> Rider.increment_counter(:trip_requests_count, rider.id) + TRANSACTION (9.1ms) BEGIN + (2.3ms) INSERT INTO "slotted_counters" ("counter_name","associated_record_type","associated_record_id","slot","count","created_at","updated_at") VALUES ('trip_requests_count', 'Rider', 20200, 86, 1, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP) ON CONFLICT ("associated_record_id","associated_record_type","counter_name","slot") DO UPDATE SET count = slotted_counters.count + EXCLUDED.count RETURNING "id" + TRANSACTION (0.3ms) COMMIT +``` + +Access: +``` +rider.trip_requests_count +``` + +## Why the "slot"? +By having a limit of rand(100), there are up to 100 possible records representing the total count. + +A slot keeps a counter, and its counter is updated on increment. This distributes the updates and avoids locking the same row. diff --git a/docs/slotted_counters_bench.rb b/docs/slotted_counters_bench.rb new file mode 100644 index 0000000..68c3b19 --- /dev/null +++ b/docs/slotted_counters_bench.rb @@ -0,0 +1,15 @@ +# Run with Rails Runner +rider = Rider.last +10_000.times do + Rider.increment_counter(:trip_requests_count, rider.id) +end + +# Although there are 10,000 increments for the same +# Rider, we don't have 10K records in slotted_counters +# due to the "slot" distributing the inserts +# +# This controls the growth of slotter_counters +# compared with a insert-per-increment approach + +puts "getting Rider trip_requests_count" +puts rider.trip_requests_count diff --git a/erd.pdf b/erd.pdf index 0847dbf..f2c40fa 100644 Binary files a/erd.pdf and b/erd.pdf differ