Skip to content

Commit 14b9fe4

Browse files
committed
WIP on caggs DSL
1 parent fd83fd6 commit 14b9fe4

File tree

5 files changed

+268
-3
lines changed

5 files changed

+268
-3
lines changed

lib/timescaledb.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
require_relative 'timescaledb/application_record'
44
require_relative 'timescaledb/acts_as_hypertable'
55
require_relative 'timescaledb/acts_as_hypertable/core'
6+
require_relative 'timescaledb/continuous_aggregates_helper'
67
require_relative 'timescaledb/connection'
78
require_relative 'timescaledb/toolkit'
89
require_relative 'timescaledb/chunk'

lib/timescaledb/acts_as_hypertable/core.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ def define_association_scopes
3535
CompressionSettings.where(hypertable_name: table_name)
3636
end
3737

38-
scope :continuous_aggregates, -> do
38+
scope :caggs, -> do
3939
ContinuousAggregates.where(hypertable_name: table_name)
4040
end
4141
end

lib/timescaledb/continuous_aggregates.rb

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
module Timescaledb
2-
class ContinuousAggregate < ::Timescaledb::ApplicationRecord
2+
class ContinuousAggregates < ::Timescaledb::ApplicationRecord
33
self.table_name = "timescaledb_information.continuous_aggregates"
44
self.primary_key = 'materialization_hypertable_name'
55

@@ -39,5 +39,4 @@ class ContinuousAggregate < ::Timescaledb::ApplicationRecord
3939
end
4040
end
4141
end
42-
ContinuousAggregates = ContinuousAggregate
4342
end
Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
module Timescaledb
2+
module ContinuousAggregatesHelper
3+
extend ActiveSupport::Concern
4+
5+
class_methods do
6+
def continuous_aggregates(options = {})
7+
@time_column = options[:time_column] || 'ts'
8+
@timeframes = options[:timeframes] || [:minute, :hour, :day, :week, :month, :year]
9+
10+
scopes = options[:scopes] || []
11+
@aggregates = {}
12+
13+
scopes.each do |scope_name|
14+
@aggregates[scope_name] = {
15+
scope_name: scope_name,
16+
select: nil,
17+
group_by: nil,
18+
refresh_policy: options[:refresh_policy] || {}
19+
}
20+
end
21+
22+
# Allow for custom aggregate definitions to override or add to scope-based ones
23+
@aggregates.merge!(options[:aggregates] || {})
24+
25+
define_continuous_aggregate_classes
26+
end
27+
28+
def refresh_aggregates(timeframes = nil)
29+
timeframes ||= @timeframes
30+
@aggregates.each do |aggregate_name, _|
31+
timeframes.each do |timeframe|
32+
klass = const_get("#{aggregate_name}_per_#{timeframe}".classify)
33+
klass.refresh!
34+
end
35+
end
36+
end
37+
38+
def create_continuous_aggregates(with_data: false)
39+
@aggregates.each do |aggregate_name, config|
40+
previous_timeframe = nil
41+
@timeframes.each do |timeframe|
42+
klass = const_get("#{aggregate_name}_per_#{timeframe}".classify)
43+
interval = "'1 #{timeframe.to_s}'"
44+
base_query =
45+
if previous_timeframe
46+
prev_klass = const_get("#{aggregate_name}_per_#{previous_timeframe}".classify)
47+
prev_klass
48+
.select("time_bucket(#{interval}, #{@time_column}) as #{@time_column}, #{config[:select]}")
49+
.group(1, *config[:group_by])
50+
else
51+
scope = public_send(config[:scope_name])
52+
select_values = scope.select_values.join(', ')
53+
group_values = scope.group_values
54+
55+
config[:select] = select_values.gsub('count(*) as total', 'sum(total) as total')
56+
config[:group_by] = (2...(2 + group_values.size)).map(&:to_s).join(', ')
57+
58+
self.select("time_bucket(#{interval}, #{@time_column}) as #{@time_column}, #{select_values}")
59+
.group(1, *group_values)
60+
end
61+
62+
connection.execute <<~SQL
63+
CREATE MATERIALIZED VIEW IF NOT EXISTS #{klass.table_name}
64+
WITH (timescaledb.continuous) AS
65+
#{base_query.to_sql}
66+
#{with_data ? 'WITH DATA' : 'WITH NO DATA'};
67+
SQL
68+
69+
if (policy = klass.refresh_policy)
70+
connection.execute <<~SQL
71+
SELECT add_continuous_aggregate_policy('#{klass.table_name}',
72+
start_offset => INTERVAL '#{policy[:start_offset]}',
73+
end_offset => INTERVAL '#{policy[:end_offset]}',
74+
schedule_interval => INTERVAL '#{policy[:schedule_interval]}');
75+
SQL
76+
end
77+
78+
previous_timeframe = timeframe
79+
end
80+
end
81+
end
82+
83+
private
84+
85+
def define_continuous_aggregate_classes
86+
@aggregates.each do |aggregate_name, config|
87+
@timeframes.each do |timeframe|
88+
_table_name = "#{aggregate_name}_per_#{timeframe}"
89+
class_name = "#{aggregate_name}_per_#{timeframe}".classify
90+
const_set(class_name, Class.new(ActiveRecord::Base) do
91+
extend ActiveModel::Naming
92+
93+
class << self
94+
attr_accessor :config, :timeframe
95+
end
96+
97+
self.table_name = _table_name
98+
self.config = config
99+
self.timeframe = timeframe
100+
101+
102+
def self.refresh!
103+
connection.execute("CALL refresh_continuous_aggregate('#{table_name}', null, null);")
104+
end
105+
106+
def readonly?
107+
true
108+
end
109+
110+
def self.refresh_policy
111+
config[:refresh_policy]&.dig(timeframe)
112+
end
113+
end)
114+
end
115+
end
116+
end
117+
end
118+
end
119+
end
Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,146 @@
1+
require 'spec_helper'
2+
3+
class Download < ActiveRecord::Base
4+
include Timescaledb::ContinuousAggregatesHelper
5+
6+
acts_as_hypertable time_column: 'ts'
7+
8+
scope :total_downloads, -> { select("count(*) as total") }
9+
scope :downloads_by_gem, -> { select("gem_name, count(*) as total").group(:gem_name) }
10+
scope :downloads_by_version, -> { select("gem_name, gem_version, count(*) as total").group(:gem_name, :gem_version) }
11+
12+
continuous_aggregates(
13+
time_column: 'ts',
14+
timeframes: [:minute, :hour, :day, :month],
15+
scopes: [:total_downloads, :downloads_by_gem, :downloads_by_version],
16+
refresh_policy: {
17+
minute: { start_offset: "10 minutes", end_offset: "1 minute", schedule_interval: "1 minute" },
18+
hour: { start_offset: "4 hour", end_offset: "1 hour", schedule_interval: "1 hour" },
19+
day: { start_offset: "3 day", end_offset: "1 day", schedule_interval: "1 hour" },
20+
month: { start_offset: "3 month", end_offset: "1 hour", schedule_interval: "1 hour" }
21+
}
22+
)
23+
end
24+
25+
RSpec.describe Timescaledb::ContinuousAggregatesHelper do
26+
let(:test_class) do
27+
Download
28+
end
29+
30+
before(:all) do
31+
ActiveRecord::Base.connection.instance_exec do
32+
hypertable_options = {
33+
time_column: 'ts',
34+
chunk_time_interval: '1 day',
35+
compress_segmentby: 'gem_name, gem_version',
36+
compress_orderby: 'ts DESC',
37+
}
38+
create_table(:downloads, id: false, hypertable: hypertable_options) do |t|
39+
t.timestamptz :ts, null: false
40+
t.text :gem_name, :gem_version, null: false
41+
t.jsonb :payload
42+
end
43+
end
44+
end
45+
46+
after(:all) do
47+
ActiveRecord::Base.connection.drop_table :downloads, if_exists: true
48+
end
49+
50+
describe '.continuous_aggregates' do
51+
it 'defines aggregate classes' do
52+
expect(test_class.const_defined?(:TotalDownloadsPerMinute)).to be true
53+
expect(test_class.const_defined?(:TotalDownloadsPerHour)).to be true
54+
expect(test_class.const_defined?(:TotalDownloadsPerDay)).to be true
55+
expect(test_class.const_defined?(:TotalDownloadsPerMonth)).to be true
56+
57+
expect(test_class.const_defined?(:DownloadsByVersionPerMinute)).to be true
58+
expect(test_class.const_defined?(:DownloadsByVersionPerHour)).to be true
59+
expect(test_class.const_defined?(:DownloadsByVersionPerDay)).to be true
60+
expect(test_class.const_defined?(:DownloadsByVersionPerMonth)).to be true
61+
62+
expect(test_class.const_defined?(:DownloadsByGemPerMinute)).to be true
63+
expect(test_class.const_defined?(:DownloadsByGemPerHour)).to be true
64+
expect(test_class.const_defined?(:DownloadsByGemPerDay)).to be true
65+
expect(test_class.const_defined?(:DownloadsByGemPerMonth)).to be true
66+
end
67+
68+
it 'sets up correct table names for aggregates' do
69+
expect(test_class::TotalDownloadsPerMinute.table_name).to eq('total_downloads_per_minute')
70+
expect(test_class::TotalDownloadsPerHour.table_name).to eq('total_downloads_per_hour')
71+
expect(test_class::TotalDownloadsPerDay.table_name).to eq('total_downloads_per_day')
72+
expect(test_class::TotalDownloadsPerMonth.table_name).to eq('total_downloads_per_month')
73+
74+
expect(test_class::DownloadsByVersionPerMinute.table_name).to eq('downloads_by_version_per_minute')
75+
expect(test_class::DownloadsByVersionPerHour.table_name).to eq('downloads_by_version_per_hour')
76+
expect(test_class::DownloadsByVersionPerDay.table_name).to eq('downloads_by_version_per_day')
77+
expect(test_class::DownloadsByVersionPerMonth.table_name).to eq('downloads_by_version_per_month')
78+
79+
expect(test_class::DownloadsByGemPerMinute.table_name).to eq('downloads_by_gem_per_minute')
80+
expect(test_class::DownloadsByGemPerHour.table_name).to eq('downloads_by_gem_per_hour')
81+
expect(test_class::DownloadsByGemPerDay.table_name).to eq('downloads_by_gem_per_day')
82+
expect(test_class::DownloadsByGemPerMonth.table_name).to eq('downloads_by_gem_per_month')
83+
end
84+
85+
it 'defines rollup scope for aggregates' do
86+
test_class.create_continuous_aggregates
87+
aggregate_classes = [test_class::TotalDownloadsPerMinute, test_class::TotalDownloadsPerHour, test_class::TotalDownloadsPerDay, test_class::TotalDownloadsPerMonth]
88+
aggregate_classes.each do |agg_class|
89+
expect(agg_class).to respond_to(:rollup)
90+
expect(agg_class.rollup.to_sql).to include('time_bucket')
91+
expect(agg_class.rollup.to_sql).to include('count(*) as total')
92+
end
93+
end
94+
95+
it 'defines time-based scopes for aggregates' do
96+
aggregate_classes = [test_class::TotalDownloadsPerMinute, test_class::TotalDownloadsPerHour, test_class::TotalDownloadsPerDay, test_class::TotalDownloadsPerMonth]
97+
aggregate_scopes = [:total_downloads, :downloads_by_gem, :downloads_by_version]
98+
99+
aggregate_scopes.each do |scope|
100+
aggregate_classes.each do |agg_class|
101+
expect(agg_class).to respond_to(scope)
102+
end
103+
end
104+
end
105+
end
106+
107+
describe '.create_continuous_aggregates' do
108+
before do
109+
allow(ActiveRecord::Base.connection).to receive(:execute).and_call_original
110+
end
111+
112+
it 'creates materialized views for each aggregate' do
113+
test_class.create_continuous_aggregates
114+
115+
expect(ActiveRecord::Base.connection).to have_received(:execute).with(/CREATE MATERIALIZED VIEW.*downloads_total_downloads_per_minute/i)
116+
expect(ActiveRecord::Base.connection).to have_received(:execute).with(/CREATE MATERIALIZED VIEW.*downloads_total_downloads_per_hour/i)
117+
expect(ActiveRecord::Base.connection).to have_received(:execute).with(/CREATE MATERIALIZED VIEW.*downloads_total_downloads_per_day/i)
118+
expect(ActiveRecord::Base.connection).to have_received(:execute).with(/CREATE MATERIALIZED VIEW.*downloads_total_downloads_per_month/i)
119+
end
120+
121+
it 'sets up refresh policies for each aggregate' do
122+
test_class.create_continuous_aggregates
123+
124+
expect(ActiveRecord::Base.connection).to have_received(:execute).with(/add_continuous_aggregate_policy.*downloads_minutely/i)
125+
expect(ActiveRecord::Base.connection).to have_received(:execute).with(/add_continuous_aggregate_policy.*downloads_total_downloads_per_hour/i)
126+
expect(ActiveRecord::Base.connection).to have_received(:execute).with(/add_continuous_aggregate_policy.*downloads_total_downloads_per_day/i)
127+
expect(ActiveRecord::Base.connection).to have_received(:execute).with(/add_continuous_aggregate_policy.*downloads_total_downloads_per_month/i)
128+
end
129+
end
130+
131+
describe 'refresh policies' do
132+
it 'defines appropriate refresh policies for each timeframe' do
133+
policies = {
134+
minute: { start_offset: "INTERVAL '10 minutes'", end_offset: "INTERVAL '1 minute'", schedule_interval: "INTERVAL '1 minute'" },
135+
hour: { start_offset: "INTERVAL '4 hour'", end_offset: "INTERVAL '1 hour'", schedule_interval: "INTERVAL '1 hour'" },
136+
day: { start_offset: "INTERVAL '3 day'", end_offset: "INTERVAL '1 day'", schedule_interval: "INTERVAL '1 day'" },
137+
month: { start_offset: "INTERVAL '3 month'", end_offset: "INTERVAL '1 day'", schedule_interval: "INTERVAL '1 day'" }
138+
}
139+
140+
policies.each do |timeframe, expected_policy|
141+
actual_policy = test_class.const_get(timeframe).refresh_policy
142+
expect(actual_policy).to eq(expected_policy)
143+
end
144+
end
145+
end
146+
end

0 commit comments

Comments
 (0)