Git Product home page Git Product logo

counter's Introduction

Counter

Tests

Counting and aggregation library for Rails.

By the time you need Rails counter_caches you probably have other needs too. You probably want to sum column values, have conditional counters, and you probably have enough throughput that updating a single column value will cause lock contention problems.

Counter is different from other solutions like Rails counter caches and counter_culture:

  • Counters are objects. This makes it possible for them to have an API that allows you to define them, reset, and recalculate them. The definition of a counter is seperate from the value
  • Counters are persisted as a ActiveRecord models (not a column of an existing model)
  • Counters can also perform aggregation (e.g. sum of column values instead of counting rows) or be calculated from other counters
  • Avoids lock-contention found in other solutions. By storing the value in another object we reduce the contention on the main e.g. User instance. This is only a small improvement though. By using the background change event pattern, we can batch perform the updates reducing the number of processes requiring a lock.
  • Incrementing counters can be safely performed in a background job via a change event/deferred reconciliation pattern (coming in a future iteration)

Usage

You probably shouldn't use it right now unless you're the sort of person that checks if something is poisonous by licking it—or you're working at Podia where we are testing it in production.

Installation

Add this line to your application's Gemfile:

gem 'counterwise', require: 'counter'

And then execute:

$ bundle

Install the model migrations:

$ rails counter:install:migrations

Main concepts

Counter::Definition defines what the counter is, what model it's connected to, what association it counts, how the count is performed etc. You create a subclass of Counter::Definition and call a few class methods to configure it. The definition is available through counter.definition for any counter value…

Counter::Value is the value of a counter. So, for example, a User might have many Posts, so a User would have a counters association containing a Counter::Value for the number of posts. Counters can be accessed via their name user.posts_counter or via the find_counter method on the association, e.g. user.counters.find_counter PostCounter

Basic usage

Define a counter

Counters are defined in a seperate class using a small DSL.

Given a Store with many Orders, it would be defined as…

class OrderCounter < Counter::Definition
  count :orders
end

class Store < ApplicationRecord
  include Counter::Counters

  has_many :orders
  counter OrderCounter
end

First we define the counter class itself using count to specify the association we're counting, then "attach" it to the parent Store model.

By default, the counter will be available as <association>_counter, e.g. store.orders_counter. To customise this, use the as method:

class OrderCounter < Counter::Definition
  include Counter::Counters
  count :orders
  as :total_orders
end

store.total_orders

The counter's value will be stored as a Counter::Value with the name prefixed by the model name. e.g. store-total_orders

Access counter values

Since counters are represented as objects, you need to call value on them to retrieve the count.

store.total_orders        #=> Counter::Value
store.total_orders.value  #=> 200

Recalculate a counter

Counters have a habit of drifting over time, particularly if ActiveRecords hooks aren't run (e.g. with a pure SQL data migration) so you need a method of re-counting the metric. Counters make this easy because they are objects in their own right.

You could refresh a store's revenue stats with:

store.order_revenue.recalc!

this would use the definition of the counter, including any option to sum a column. In the case of conditional counters, they are expected to be attached to an association which matched the conditions so the recalculated count remains accurate.

Reset a counter

You can also reset a counter by calling reset.

store.order_revenue.reset

Since counters are ActiveRecord objects, you could also reset them using:

Counter::Value.update value: 0

Verify a counter

You might like to check if a counter is correct

store.product_revenue.correct? #=> false

This will re-count / re-calculate the value and compare it to the current one. If you wish to also update the value when it's not correct, use correct!:

store.product_revenue #=>200
store.product_revenue.reset!
store.product_revenue #=>0
store.product_revenue.correct? #=> false
store.product_revenue.correct! #=> false
store.product_revenue #=>200

Advanced usage

Sort or filter parent models by a counter value

Say a Customer has a total revenue counter, and you'd like to sort the list of customers with the highest spenders at the top. Since the counts aren't stored on the Customer model, you can't just call Customer.order(total_orders: :desc). Instead, Counterwise provides a convenience method to pull the counter values into the resultset.

Customer.order_by_counter TotalRevenueCounter => :desc

# You can sort by multiple counters or mix counters and model attributes
Customer.order_by_counter TotalRevenueCounter => :desc, name: :asc

Under the hood, order_by_counter will uses with_counter_data_from to pull the counter values into the resultset. This is useful if you want to use the counter values in a where clause or select statement.

Customer.with_counter_data_from(TotalRevenueCounter).where("total_revenue_data > 1000")

These methods pull in the counter data itself but don't include the counter instances themselves. To do this, call

customers = Customer.with_counters TotalRevenueCounter
# Since the counters are now preloaded, this avoids an N+1 query
customers.each &:total_revenue

Aggregate a value (e.g. sum of order revenue)

Sometimes you don'y want to count the number of orders but instead sum the value of those orders..

Given an ActiveRecord model Order, we can count a storefront's revenue like so

class Store < ApplicationRecord
  include Counter::Counters

  counter OrderRevenue
end

Define the counter like so

class OrderRevenue < Counter::Definition
  count :orders
  sum :total_price
end

and access it like

  store.orders.create total_price: 100
  store.orders.create total_price: 100
  store.order_revenue.value #=> 200

Hooks

You can add an after_change hook to your counter definition to perform some action when the counter is updated. For example, you might want to send a notification when a counter reaches a certain value.

class OrderRevenueCounter < Counter::Definition
  count :orders, as: :order_revenue
  sum :price

  after_change :send_congratulations_email

  # Only send an email when they cross $1000
  def send_congratulations_email counter, old_value, new_value
    return unless old_value < 1000 && new_value >= 1000
    send_email "Congratulations! You've made #{to} dollars!"
  end
end

Manual counters

Most counters are associated with a model instance and association—these counters are automatically incremented when the associated collection changes but sometimes you just need a manual counter that you can increment.

Manual counters just need a name

class TotalOrderCounter < Counter::Definition
  as "total_orders"
end

TotalOrderCounter.counter.value #=> 5
TotalOrderCounter.counter.increment! #=> 6

Calculating a value from other counters

You may also need have a common need to calculate a value from other counters. For example, given counters for the number of purchases and the number of visits, you might want to calculate the conversion rate. You can do this with a calculate_from block.

class ConversionRateCounter < Counter::Definition
  count nil, as: "conversion_rate"

  calculated_from VisitsCounter, OrdersCounter do |visits, orders|
    (orders.value.to_f / visits.value) * 100
  end
end

This recalculates the conversion rate each time the visits or order counters are updated. If either dependant counter is not present, the calculation will not be run (i.e., visits and order will never be nil).

Defining a conditional counter

Conditional counters allow you to count a subset of an association, like just the premium product with a price >= 1000.

class Product < ApplicationRecord
  include Counter::Counters
  include Counter::Changable

  belongs_to :user

  scope :premium, -> { where("price >= 1000") }

  def premium?
    price >= 1000
  end
end

Conditional counters are more complex to define since we also need to specify when the counter should be incremented or decremented, for each create/delete/update.

class PremiumProductCounter < Counter::Definition
  # Define the association we're counting
  count :premium_products

  on :create do
    increment_if ->(product) { product.premium? }
  end

  on :delete do
    decrement_if ->(product) { product.premium? }
  end

  on :update do
    increment_if ->(product) {
      product.has_changed? :price, from: ->(price) { price < 1000 }, to: ->(price) { price >= 1000 }
    }

    decrement_if ->(product) {
      product.has_changed? :price, from: ->(price) { price >= 1000 }, to: ->(price) { price < 1000 }
    }
  end
end

There is a lot going on here!

First, we define the counter on a scoped association. This ensures that when we call counter.recalc() we will count using the association's SQL to get the correct results.

We also define several conditions that operate on the instance level, i.e. when we create/update/delete an instance. On create and delete we define a block to determine if the counter should be updated. In this case, we only increment the counter when a premium product is created, and only decrement it when a premium product is deleted.

update is more complex because there are two scenarios: either a product has been updated to make it premium or downgrade from premium to some other state. On update, we increment the counter if the price has gone above 1000; and decrement is the price has now gone below 1000.

We use the has_changed? helper to query the ActiveRecord previous_changes hash and check what has changed. You can specify either Procs or values for from/to. If you only specify a from value, to will default to "any value" (Counter::Any.instance)

Conditional counters work best with a single attribute. If the counter is conditional on e.g. confirmed and subscribed, the update tracking logic becomes very complex especially if the values are both updated at the same time. The solution to this is hopefully Rails generated columns in 7.1 so you can store a "subscribed_and_confirmed" column and check the value of that instead. Rails dirty tracking will need to work with generated columns though; see this PR.

Testing

Using Rspec

If you use RSpec, you can include Counter::RSpecMatchers on your helpers and test your counter definitions.

require "counter/rspec/matchers"

RSpec.configure do |config|
  config.include Counter::RSpecMatchers, type: :counter
end

Now you can test your counter definitions like so:

require "rails_helper"

RSpec.describe PremiumProductCounter, type: :counter do
  let(:store) { create(:store) }

  describe "on :create" do
    context "when the product is premium" do
      it "increments the counter" do
        expect { create(:product, :premium, store: store) }.to increment_counter_for(described_class, store)
      end
    end

    context "when the product is not premium" do
      it "doesn't increment the counter" do
        expect { create(:product, store: store) }.not_to increment_counter_for(described_class, store)
      end
    end
  end

  describe "on :delete" do
    context "when the product is premium" do
      it "decrements the counter" do
        expect { create(:product, :premium, store: store) }.to decrement_counter_for(described_class, store)
      end
    end

    context "when the product is not premium" do
      it "doesn't decrement the counter" do
        expect { create(:product, store: store) }.not_to decrement_counter_for(described_class, store)
      end
    end
  end
end

In production

test in prod or live a lie — Charity Majors

It's very useful to verify the accuracy of the counters in production, especially if you are concerned about conditional counters etc causing counter drift over time.

A simple approach would be:

Counter::Value.all.each &:correct!

If you have a large number of counters though it's best to take a sampling approach to randomly select a counter and verify that the value is correct

Counter::Value.sample_and_verify samples: 1000, verbose: true, on_error: :correct

Options:

  • scope — allows you to scope the counters to a particular model or set of models, e.g. scope: -> { where("name LIKE 'store-%'") }. By default, all counters are sampled
  • samples — the number of counters to sample. Default: 1000
  • verbose — print out the counter details and whether it was correct. Default: true
  • on_error — what to do when a counter is incorrect. :correct will correct the counter, :raise will raise an error, :log will log the error to Rails.logger. Default: :raise

TODO

See the asociated project in Github but roughly I'm thinking:

  • Implement the background job pattern for incrementing counters
  • Hierarchical counters. For example, a Site sends many Newsletters and each Newsletter results in many EmailMessages. Each EmailMessage can be marked as spam. How do you create counters for how many spam emails were sent at the Newsletter level and the Site level?
  • Time-based counters for analytics. Instead of a User having one OrderRevenue counter, they would have an OrderRevenue counter for each day. These counters would then be used to produce a chart of their product revenue over the month. Not sure if these are just special counters or something else entirely? Do they use the same ActiveRecord model?
  • In a similar vein of supporting different value types, can we support HLL values? Instead of increment an integer we add the items hash to a HyperLogLog so we can count unique items. An example would be counting site visits in a time-based daily counter, then combine the daily counts and still obtain an estimated number of monthly unique visits. Again, not sure if this is the same ActiveRecord model or something different.
  • Actually start running this in production for basic use cases

Contributing

Bug reports and pull requests are welcome, especially around naming, internal APIs, bug fixes, and additional features. Please open an issue first if you're thinking of adding a new feature so we can discuss it.

I'm unlikely to entertain suport for older Ruby or Rails versions, or databases other than Postgres.

License

The gem is available as open source under the terms of the MIT License.

counter's People

Contributors

ideasasylum avatar jasoncharnes avatar afomera avatar diogobeda avatar mariozugaj avatar

Stargazers

Josh Powell avatar Adam Zapaśnik avatar Trevor Turk avatar Armin Schreger avatar Vlado Cingel avatar Rui Carvalho avatar Khairi Adnan avatar Stefan Vermaas avatar Matteo Piotto avatar Vlad Radulescu avatar Georg Ledermann avatar Janko Marohnić avatar Dmytro Piliugin avatar Steve Agalloco avatar Benjamín Silva avatar Richard avatar Pedro Schmitt avatar Dean Perry avatar Bertra[N]d Gauriat avatar Felipe Menegazzi avatar  avatar Brandon Hicks avatar Drew Bragg avatar Igor Zubkov avatar Sandalots avatar Marco Roth avatar Adrien Poly avatar Benoit Tigeot avatar Benedikt Deicke avatar

Watchers

 avatar Spencer Fry avatar James Cloos avatar  avatar

counter's Issues

Use the definition class name as the name of the counter (Counter::Value#name)

Currently the Counter::Value#name column stores a name like "students-broadcast_clicks_counter". The first part (before the underscore) is the parent model; the second part is the counter definition class name.

This seems pretty ugly and the format changes if it's a global counter unrelated to an association. The name also isn't really used anywhere unless you are querying the table directly.

My proposal is that the name stored should be the class name of the counter definition. This has several advantages:

  • When a value is loaded, it becomes trivial to find the definition instance
  • It becomes much clearer when querying the table directly what to search for

Originally I had thought that counters could be reused for different models/associations but that seems unnecessary

Add `join_counter_data` to support filtering on the counter data

with_counter_data uses a select subquery where as this would use join. Perhaps they should be combined?

Site
.joins("INNER JOIN counter_values ON counter_values.parent_id = sites.id AND counter_values.parent_type = 'Site'")
.where(counter_values: {name: "site-confirmed_subscribers", value: 100..})

`keep_count_of`⁉️

I don't much like the method name or signature

keep_count_of students: SiteStudentCounter
keep_count_of orders: { counter: RevenueCounter, column: :price }

Lazy loading in dev breaks things

class User < ApplicationRecord
  counter ProductsCounter
end

class ProductsCounter < Counter::Definition
  count :products
end

I think there's a problem in development if the ProductsCounter class is loaded before the User class since the counter doesn't know it's associated with User -> Products. It's just given the association not the parent model. This can lead to an error or the wrong name being used (I can't remember, one or the other). I've seen this happen in very isolated tests or the Rails console.

Some ideas:

  • Put all counters into their own directory
  • Counters need to specify the parent model and association (unless they are manual counters)
    • if we did that, is it necessary to specify counter ProductsCounter in the User model?
    • If not, is that a weird thing since Rails devs probably expect something to appear there
    • if we want something there, how should we restructure the counter definitions?

Refactor the conditional counters to make it easier to write

This sort of thing is tedious, ugly, and error-prone:

  conditional create: ->(student) { student.subscribed? && student.confirmed? },
    delete: ->(student) { student.subscribed? && student.confirmed? },
    update: ->(student) {
      became_subscribed = student.has_changed? :subscribed_at,
        from: nil,
        to: Counter::Any
      return 1 if became_subscribed

      became_unsubscribed = student.has_changed? :subscribed_at,
        from: Counter::Any,
        to: nil
      return -1 if became_unsubscribed

      became_confirmed = student.has_changed? :confirmed,
        from: false,
        to: true
      return 1 if became_confirmed

      became_unconfirmed = student.has_changed? :confirmed,
        from: true,
        to: false
      return -1 if became_unconfirmed

      return 0
    }

Naming things

Starting with the easy problems in computer science 😉

I'm trying not to worry too much about names at this stage or it would give me an excuse for procrastination. Instead, let's fix it in post! Ideas and discussions welcome/needed on…

  • Counter::Value holds the reconciled value of the counter. I'm not overly happy with this name, especially as it has a value column and Value#value sounds daft
  • Counter::Change holds the changes pending/made to the counter value. A Value has_many :changes but this clashes with the Rails changes method. It's currently Value has_many :updates but it might be nice to resolve this early on
  • Counter::Counters.counters.find_counter 😬 Not sure about the find_counter method on the association but I couldn't think of another name… (but also it's dangerously/hilariously close to Counter::Counters.counters.counter
  • Counter::Countable 🤷‍♂️ probably ok
  • Counter::CounterConfig isn't the right thing but I think this struct should be a first-class object and perhaps mixed-in to Counter::Value?
  • Counting is model doing the counting, a.k.a the parent model, e.g. a store;
  • Countable is the thing which is counted, e.g., an order

Rethink the singleton approach?

I'm not entirely happy with the singleton definition (threadsafe?) or some of the meta-programming in definition. Seems like someone with more experience could refactor this in a "Rails way".

The singleton definitions are useful because we can initialize them once and attach them to any instance of the counter values. This separation of the counter definition and Counter::Value has some advantages, and will particularly be useful when there is a choice of how values are stored (e.g. Counter::Value, Counter::HLL, Counter::Slotted, Counter::…). The definition is just there to store information about the association, models involved, conditions, hooks, etc.

I don't think I see singletons used like this very often so I'm curious what the alternative might be for storing these global, immutable definitions.

How best to support different underlying value storage models?

In the simplest case, counter values are just integers.

  • But then what if you wanted to count really large numbers. BigInt?
  • We needed to count percentages (calculated from other counters) such as spam rate. So Float? Or Double? Or even Decimal?
  • We will need to store counters as HLL values enabling us to approximately count unique values
  • We may want to support slotted counters to allow higher concurrent writes without locking
  • We will need to support counters over time, i.e., number of emails sent per month
  • We might want to support counters/events in external systems like Redis

It's hard to support these different representations in a single database table. We could make Counter::Value an STI table but then we'd be storing Integer, Float, HLL, and maybe JSONB columns for each counter.But maybe that's fine if those columns are nil since they only require ~1bit of storage??

Alternatively, the counter should write to different tables depending on the configuration?

…which gets be reconsidering having separate counter definitions. Perhaps values should just be Rails models? e.g.

# This will be stored on the counter_values table with an type value of "MyCounter" (i.e., using STI)
class MyCounter < Counter::Value
  # Some configuration methods to specific what it is counting, conditionals, hooks etc
  count…
end

# This will be stored in the counter_hll table with a type value of "PageVisitsCounter"
class PageVisitsCounter < Counter::HLL
  # some config…
end

Using different tables might make sorting/filtering a little harder but probably doable.

Recommend Projects

  • React photo React

    A declarative, efficient, and flexible JavaScript library for building user interfaces.

  • Vue.js photo Vue.js

    🖖 Vue.js is a progressive, incrementally-adoptable JavaScript framework for building UI on the web.

  • Typescript photo Typescript

    TypeScript is a superset of JavaScript that compiles to clean JavaScript output.

  • TensorFlow photo TensorFlow

    An Open Source Machine Learning Framework for Everyone

  • Django photo Django

    The Web framework for perfectionists with deadlines.

  • D3 photo D3

    Bring data to life with SVG, Canvas and HTML. 📊📈🎉

Recommend Topics

  • javascript

    JavaScript (JS) is a lightweight interpreted programming language with first-class functions.

  • web

    Some thing interesting about web. New door for the world.

  • server

    A server is a program made to process requests and deliver data to clients.

  • Machine learning

    Machine learning is a way of modeling and interpreting data that allows a piece of software to respond intelligently.

  • Game

    Some thing interesting about game, make everyone happy.

Recommend Org

  • Facebook photo Facebook

    We are working to build community through open source technology. NB: members must have two-factor auth.

  • Microsoft photo Microsoft

    Open source projects and samples from Microsoft.

  • Google photo Google

    Google ❤️ Open Source for everyone.

  • D3 photo D3

    Data-Driven Documents codes.