drive 0.2.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +20 -0
- data/README.md +15 -26
- data/app/controllers/concerns/rate_limited.rb +4 -3
- data/app/models/concerns/counted.rb +17 -0
- data/app/models/concerns/mirrored.rb +8 -17
- data/app/models/crawler.rb +33 -0
- data/app/models/run.rb +1 -4
- data/db/migrate/20261006100000_drop_changes.rb +16 -0
- data/lib/drive/engine.rb +2 -2
- data/lib/drive/version.rb +1 -1
- data/lib/drive.rb +1 -1
- metadata +7 -7
- data/app/controllers/feeds_controller.rb +0 -25
- data/app/models/change.rb +0 -19
- data/app/models/concerns/logged.rb +0 -26
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: cf5ec0cbf839ae0e64fabcecc4f66f40a1e4654252053c57837c8895b290366f
|
|
4
|
+
data.tar.gz: e5819d0ad8774657ad71eb4695971399ac22215fd7c37d96cf7cc8db3c64f543
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: c2589ac5dc34686550476cf266d06ef8424c0e3d8307761cb4861d15bc3cd7b00884407ac273132f4efa70b4ec1cc671cec67b0bbec70e426aa53d65dbd3bfd3
|
|
7
|
+
data.tar.gz: e4610a657c780d8458a236bc7512b8dc984e75337e4a17624cff92cc53458799375761027fa5361432c924f0fdfaa088ac5dcde2eddf012039d746ba7e4945f9
|
data/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,26 @@ For more information about changelogs, check [Keep a Changelog](http://keepachan
|
|
|
6
6
|
|
|
7
7
|
## [Unreleased]
|
|
8
8
|
|
|
9
|
+
## 0.4.0 - 2026-10-06
|
|
10
|
+
|
|
11
|
+
* [BREAKING] No more log of changes, and no feed
|
|
12
|
+
|
|
13
|
+
Nothing followed the feed: the apps that read a mirror read its tables directly, from the same
|
|
14
|
+
database. `Change`, `Change.prune`, `FeedsController` and the `changes` table go;
|
|
15
|
+
`bin/rails drive:install:migrations db:migrate` copies and runs the migration that drops them.
|
|
16
|
+
`Mirrored` counts the rows a refresh inserts, updates and deletes instead of logging them, and
|
|
17
|
+
`Logged` is now `Counted`, which counts each save and delete in the run at hand; a table
|
|
18
|
+
including it no longer needs `KEY` or `SOURCE`.
|
|
19
|
+
|
|
20
|
+
## 0.3.0 - 2026-10-06
|
|
21
|
+
|
|
22
|
+
* [FEATURE] `RateLimited` no longer limits a search engine's own crawler
|
|
23
|
+
|
|
24
|
+
`Crawler.verified?(request)` believes a user agent naming Google's, Bing's or Apple's crawler
|
|
25
|
+
only where its address resolves to their domain and that host back to the address, and
|
|
26
|
+
remembers the answer for a day. Behind a proxy, `request.remote_ip` must be the visitor's:
|
|
27
|
+
list the proxy's ranges in `config.action_dispatch.trusted_proxies`.
|
|
28
|
+
|
|
9
29
|
## 0.2.0 - 2026-10-05
|
|
10
30
|
|
|
11
31
|
* [BREAKING] `drive` is a new gem: a Rails engine for apps that mirror what a source publishes
|
data/README.md
CHANGED
|
@@ -1,16 +1,18 @@
|
|
|
1
1
|
# Drive
|
|
2
2
|
|
|
3
3
|
A Rails engine for an app that keeps a copy of what somebody else publishes — a government's
|
|
4
|
-
files, a monthly database —
|
|
4
|
+
files, a monthly database — changing only the rows the source did, and counting them.
|
|
5
5
|
|
|
6
6
|
- **`Mirrored`**: a table copying one of the source's tables field for field, as text. A refresh
|
|
7
7
|
stages the source's table, then inserts, updates and deletes only the rows the source did.
|
|
8
|
-
- **`
|
|
9
|
-
- **`Run`**: one refresh, in one transaction, the only one running
|
|
10
|
-
|
|
11
|
-
- **`FeedsController`**: `GET /feed?after=ID&kind=TABLE`, the next 5,000 changes, as JSON.
|
|
8
|
+
- **`Counted`**: a table written a record at a time, such as a document fetched by URL.
|
|
9
|
+
- **`Run`**: one refresh, in one transaction, the only one running, with how many rows it
|
|
10
|
+
inserted, updated and deleted.
|
|
12
11
|
- **`AccessFile`**: stages a table of a Microsoft Access file, read with `mdb-export`.
|
|
13
|
-
- **`RateLimited`**: 20 requests a minute and 200 a day per address, for public pages.
|
|
12
|
+
- **`RateLimited`**: 20 requests a minute and 200 a day per address, for public pages. A search
|
|
13
|
+
engine's own crawler has no limit.
|
|
14
|
+
- **`Crawler`**: whether a request comes from Google's, Bing's or Apple's crawler: its address
|
|
15
|
+
resolves to their domain and back, checked once a day per address.
|
|
14
16
|
|
|
15
17
|
It needs PostgreSQL.
|
|
16
18
|
|
|
@@ -22,12 +24,12 @@ gem install drive
|
|
|
22
24
|
|
|
23
25
|
```ruby
|
|
24
26
|
# Gemfile
|
|
25
|
-
gem 'drive', '~> 0.
|
|
27
|
+
gem 'drive', '~> 0.4.0'
|
|
26
28
|
```
|
|
27
29
|
|
|
28
|
-
`~> 0.
|
|
30
|
+
`~> 0.4.0` takes every 0.4.x and stops short of 0.5, which may break what 0.4 does.
|
|
29
31
|
|
|
30
|
-
Then copy its
|
|
32
|
+
Then copy its migrations, of runs, and run them:
|
|
31
33
|
|
|
32
34
|
```bash
|
|
33
35
|
bin/rails drive:install:migrations db:migrate
|
|
@@ -55,29 +57,16 @@ Run.record 'engines' do
|
|
|
55
57
|
end
|
|
56
58
|
```
|
|
57
59
|
|
|
58
|
-
A table written a record at a time
|
|
60
|
+
A table written a record at a time counts each save and delete in the run at hand:
|
|
59
61
|
|
|
60
62
|
```ruby
|
|
61
63
|
class Publication < ApplicationRecord
|
|
62
|
-
include
|
|
63
|
-
|
|
64
|
-
KEY = 'document_number'.freeze
|
|
65
|
-
SOURCE = 'federal_register'.freeze
|
|
64
|
+
include Counted
|
|
66
65
|
end
|
|
67
66
|
```
|
|
68
67
|
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
```ruby
|
|
72
|
-
resource :feed, only: :show
|
|
73
|
-
```
|
|
74
|
-
|
|
75
|
-
Where `FEED_TOKEN` is set, it asks for that token (`Authorization: Bearer …`). Runs commit one
|
|
76
|
-
at a time, so a reader that remembers the last id it applied never misses a change.
|
|
77
|
-
|
|
78
|
-
The log is meant to hold about a month: call `Change.prune` after each night's refresh, and it
|
|
79
|
-
deletes the changes of runs that started over 30 days ago. A reader that falls further behind
|
|
80
|
-
copies the tables afresh.
|
|
68
|
+
Runs commit one at a time, so another app reading the tables directly sees either the last
|
|
69
|
+
run's rows or the one before's, never a mix.
|
|
81
70
|
|
|
82
71
|
In tests, `Drive::TestHelper#stage` stages rows as a source's file would:
|
|
83
72
|
|
|
@@ -1,9 +1,10 @@
|
|
|
1
|
-
# The limits of a page anybody may ask for, by the address asking: 20 a minute, 200 a day.
|
|
1
|
+
# The limits of a page anybody may ask for, by the address asking: 20 a minute, 200 a day. A search
|
|
2
|
+
# engine's own crawler has none, so it can list every page.
|
|
2
3
|
module RateLimited
|
|
3
4
|
extend ActiveSupport::Concern
|
|
4
5
|
|
|
5
6
|
included do
|
|
6
|
-
rate_limit to: 20, within: 1.minute, name: 'minute'
|
|
7
|
-
rate_limit to: 200, within: 1.day, name: 'day'
|
|
7
|
+
rate_limit to: 20, within: 1.minute, name: 'minute', unless: -> { Crawler.verified? request }
|
|
8
|
+
rate_limit to: 200, within: 1.day, name: 'day', unless: -> { Crawler.verified? request }
|
|
8
9
|
end
|
|
9
10
|
end
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# A table written a record at a time, each save and delete counted in the current run.
|
|
2
|
+
module Counted
|
|
3
|
+
extend ActiveSupport::Concern
|
|
4
|
+
|
|
5
|
+
included do
|
|
6
|
+
after_save :count_upsert, if: :saved_changes?
|
|
7
|
+
after_destroy :count_delete
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
private
|
|
11
|
+
|
|
12
|
+
def count_upsert
|
|
13
|
+
Run.current.tally(**(previously_new_record? ? { inserted: 1 } : { updated: 1 }))
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def count_delete = Run.current.tally(deleted: 1)
|
|
17
|
+
end
|
|
@@ -17,9 +17,7 @@ module Mirrored
|
|
|
17
17
|
# @return [Hash{Symbol => Integer}] how many rows were inserted, updated and deleted.
|
|
18
18
|
def mirror(staged, source:)
|
|
19
19
|
stage_incoming staged
|
|
20
|
-
counts = {
|
|
21
|
-
inserted: log_inserted(source), updated: log_updated(source), deleted: log_deleted(source),
|
|
22
|
-
}
|
|
20
|
+
counts = { inserted: inserted(source), updated: updated(source), deleted: deleted(source) }
|
|
23
21
|
apply source
|
|
24
22
|
counts.tap { Run.current.tally(**it) }
|
|
25
23
|
end
|
|
@@ -47,37 +45,30 @@ module Mirrored
|
|
|
47
45
|
connection.execute 'ANALYZE mirror_incoming'
|
|
48
46
|
end
|
|
49
47
|
|
|
50
|
-
def
|
|
51
|
-
|
|
48
|
+
def inserted(source)
|
|
49
|
+
rows <<~SQL.squish
|
|
52
50
|
FROM mirror_incoming i
|
|
53
51
|
WHERE NOT EXISTS (SELECT 1 FROM #{quoted_table_name} t
|
|
54
52
|
WHERE t.source = #{connection.quote source} AND t.row_key = i.row_key)
|
|
55
53
|
SQL
|
|
56
54
|
end
|
|
57
55
|
|
|
58
|
-
def
|
|
59
|
-
|
|
56
|
+
def updated(source)
|
|
57
|
+
rows <<~SQL.squish
|
|
60
58
|
FROM mirror_incoming i JOIN #{quoted_table_name} t USING (row_key)
|
|
61
59
|
WHERE t.source = #{connection.quote source} AND t.row_hash <> i.row_hash
|
|
62
60
|
SQL
|
|
63
61
|
end
|
|
64
62
|
|
|
65
|
-
def
|
|
66
|
-
|
|
63
|
+
def deleted(source)
|
|
64
|
+
rows <<~SQL.squish
|
|
67
65
|
FROM #{quoted_table_name} i
|
|
68
66
|
WHERE i.source = #{connection.quote source}
|
|
69
67
|
AND NOT EXISTS (SELECT 1 FROM mirror_incoming n WHERE n.row_key = i.row_key)
|
|
70
68
|
SQL
|
|
71
69
|
end
|
|
72
70
|
|
|
73
|
-
def
|
|
74
|
-
connection.update <<~SQL.squish
|
|
75
|
-
INSERT INTO changes (operation, kind, source, row_key, run_id, record, created_at)
|
|
76
|
-
SELECT #{operation}, #{connection.quote table_name}, #{connection.quote source}, i.row_key,
|
|
77
|
-
#{Run.current.id}, #{record}, now()
|
|
78
|
-
#{from}
|
|
79
|
-
SQL
|
|
80
|
-
end
|
|
71
|
+
def rows(from) = connection.select_value("SELECT count(*) #{from}").to_i
|
|
81
72
|
|
|
82
73
|
def apply(source)
|
|
83
74
|
quoted = fields.map { connection.quote_column_name it }
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
require 'resolv'
|
|
2
|
+
|
|
3
|
+
# A search engine's own crawler, told apart from a client that only names one in its user agent:
|
|
4
|
+
# its address resolves to the search engine's domain, and that host back to the address, which is
|
|
5
|
+
# how Google and Bing say to check theirs.
|
|
6
|
+
module Crawler
|
|
7
|
+
# The user agents of the crawlers checked.
|
|
8
|
+
AGENTS = /Googlebot|Google-InspectionTool|Storebot-Google|GoogleOther|bingbot|Applebot/i
|
|
9
|
+
|
|
10
|
+
# The domains their addresses resolve to.
|
|
11
|
+
DOMAINS = %w[.googlebot.com .google.com .search.msn.com .applebot.apple.com].freeze
|
|
12
|
+
|
|
13
|
+
# @param request [ActionDispatch::Request] request a page was asked with.
|
|
14
|
+
# @return [Boolean] whether it came from a crawler, checked once a day for each address.
|
|
15
|
+
def self.verified?(request)
|
|
16
|
+
ip = request.remote_ip
|
|
17
|
+
request.user_agent.to_s.match?(AGENTS) &&
|
|
18
|
+
Rails.cache.fetch(['crawler', ip], expires_in: 1.day) { resolves? ip }
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# @param ip [String] address a request came from.
|
|
22
|
+
# @return [Boolean] whether it names a crawler's host, and that host names it back.
|
|
23
|
+
def self.resolves?(ip)
|
|
24
|
+
dns = Resolv::DNS.new.tap { it.timeouts = 2 }
|
|
25
|
+
host = dns.getname(ip).to_s
|
|
26
|
+
DOMAINS.any? { host.end_with? it } && dns.getaddresses(host).map(&:to_s).include?(ip)
|
|
27
|
+
rescue Resolv::ResolvError
|
|
28
|
+
false
|
|
29
|
+
ensure
|
|
30
|
+
dns&.close
|
|
31
|
+
end
|
|
32
|
+
private_class_method :resolves?
|
|
33
|
+
end
|
data/app/models/run.rb
CHANGED
|
@@ -1,11 +1,8 @@
|
|
|
1
1
|
# One refresh of a source: when it started and finished, and how many rows it changed.
|
|
2
2
|
class Run < ApplicationRecord
|
|
3
|
-
# The lock every refresh takes, so runs commit one at a time
|
|
3
|
+
# The lock every refresh takes, so runs commit one at a time.
|
|
4
4
|
LOCK = 1_729_001
|
|
5
5
|
|
|
6
|
-
# Named apart from Active Record’s own `changes`, which says what an unsaved record changed.
|
|
7
|
-
has_many :row_changes, class_name: 'Change', dependent: :delete_all
|
|
8
|
-
|
|
9
6
|
# Refreshes a source inside one transaction, the only one running, recording what it changed.
|
|
10
7
|
# @param source [String] what is refreshed, such as "registry".
|
|
11
8
|
# @return [Run] finished run.
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
class DropChanges < ActiveRecord::Migration[8.1]
|
|
2
|
+
def change
|
|
3
|
+
drop_table :changes do |t|
|
|
4
|
+
t.enum :operation, enum_type: :operation, null: false
|
|
5
|
+
t.text :kind, null: false
|
|
6
|
+
t.text :source, null: false
|
|
7
|
+
t.text :row_key, null: false
|
|
8
|
+
t.references :run, null: false, foreign_key: true
|
|
9
|
+
t.jsonb :record
|
|
10
|
+
t.datetime :created_at, null: false
|
|
11
|
+
t.index %i[kind row_key]
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
drop_enum :operation, %w[upsert delete]
|
|
15
|
+
end
|
|
16
|
+
end
|
data/lib/drive/engine.rb
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
module Drive
|
|
2
|
-
# Lends the host its models (Run,
|
|
3
|
-
#
|
|
2
|
+
# Lends the host its models (Run, Mirrored, Counted, AccessFile, Crawler), RateLimited and the
|
|
3
|
+
# migrations of runs (`bin/rails drive:install:migrations`).
|
|
4
4
|
class Engine < ::Rails::Engine
|
|
5
5
|
engine_name 'drive'
|
|
6
6
|
end
|
data/lib/drive/version.rb
CHANGED
data/lib/drive.rb
CHANGED
|
@@ -2,6 +2,6 @@ require 'drive/version'
|
|
|
2
2
|
require 'drive/engine'
|
|
3
3
|
|
|
4
4
|
# Keeps a copy of the tables another source publishes, changing only the rows the source did,
|
|
5
|
-
# and
|
|
5
|
+
# and counts what each refresh changed (Mirrored, Counted, Run).
|
|
6
6
|
module Drive
|
|
7
7
|
end
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: drive
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.4.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- claudiob
|
|
@@ -52,8 +52,8 @@ dependencies:
|
|
|
52
52
|
- !ruby/object:Gem::Version
|
|
53
53
|
version: '8.1'
|
|
54
54
|
description: 'A Rails engine for apps that mirror a public source table for table:
|
|
55
|
-
each refresh changes only the rows the source did, in one run, and
|
|
56
|
-
|
|
55
|
+
each refresh changes only the rows the source did, in one run, and counts how many
|
|
56
|
+
it inserted, updated and deleted.'
|
|
57
57
|
email:
|
|
58
58
|
- claudiob@users.noreply.github.com
|
|
59
59
|
executables: []
|
|
@@ -64,14 +64,14 @@ files:
|
|
|
64
64
|
- MIT-LICENSE
|
|
65
65
|
- README.md
|
|
66
66
|
- app/controllers/concerns/rate_limited.rb
|
|
67
|
-
- app/controllers/feeds_controller.rb
|
|
68
67
|
- app/models/access_file.rb
|
|
69
|
-
- app/models/
|
|
70
|
-
- app/models/concerns/logged.rb
|
|
68
|
+
- app/models/concerns/counted.rb
|
|
71
69
|
- app/models/concerns/mirrored.rb
|
|
70
|
+
- app/models/crawler.rb
|
|
72
71
|
- app/models/drive/current.rb
|
|
73
72
|
- app/models/run.rb
|
|
74
73
|
- db/migrate/20261005100000_create_runs_and_changes.rb
|
|
74
|
+
- db/migrate/20261006100000_drop_changes.rb
|
|
75
75
|
- lib/drive.rb
|
|
76
76
|
- lib/drive/engine.rb
|
|
77
77
|
- lib/drive/test_helper.rb
|
|
@@ -99,5 +99,5 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
|
99
99
|
requirements: []
|
|
100
100
|
rubygems_version: 4.0.3
|
|
101
101
|
specification_version: 4
|
|
102
|
-
summary: Keep a copy of what a source publishes,
|
|
102
|
+
summary: Keep a copy of what a source publishes, changing only the rows it changed.
|
|
103
103
|
test_files: []
|
|
@@ -1,25 +0,0 @@
|
|
|
1
|
-
# The changes after one another app has read, a page at a time, for it to keep its copy current.
|
|
2
|
-
class FeedsController < ApplicationController
|
|
3
|
-
# How many changes a page holds.
|
|
4
|
-
PAGE = 5_000
|
|
5
|
-
|
|
6
|
-
# The fields of a change the feed answers.
|
|
7
|
-
FIELDS = %w[id operation kind source row_key record created_at].freeze
|
|
8
|
-
|
|
9
|
-
before_action :authenticate, if: -> { ENV['FEED_TOKEN'].present? }
|
|
10
|
-
|
|
11
|
-
# Answers the changes after `after`, of one `kind` (table) or all, as JSON.
|
|
12
|
-
def show
|
|
13
|
-
changes = Change.after(params[:after]).limit PAGE
|
|
14
|
-
changes = changes.where(kind: params[:kind]) if params[:kind]
|
|
15
|
-
render json: changes.map { it.attributes.slice(*FIELDS) }
|
|
16
|
-
end
|
|
17
|
-
|
|
18
|
-
private
|
|
19
|
-
|
|
20
|
-
def authenticate
|
|
21
|
-
authenticate_or_request_with_http_token do |token|
|
|
22
|
-
ActiveSupport::SecurityUtils.secure_compare token, ENV.fetch('FEED_TOKEN')
|
|
23
|
-
end
|
|
24
|
-
end
|
|
25
|
-
end
|
data/app/models/change.rb
DELETED
|
@@ -1,19 +0,0 @@
|
|
|
1
|
-
# A row a run inserted, changed or deleted, which another app follows by id to keep its copy.
|
|
2
|
-
class Change < ApplicationRecord
|
|
3
|
-
enum :operation, %w[upsert delete].index_by(&:itself), prefix: true
|
|
4
|
-
|
|
5
|
-
belongs_to :run
|
|
6
|
-
|
|
7
|
-
# The changes after one another app has already read, in the order they were made.
|
|
8
|
-
scope :after, ->(id) { where(id: (id.to_i + 1)..).order(:id) }
|
|
9
|
-
|
|
10
|
-
# Deletes the changes of runs that started before a time, so the log holds about a month.
|
|
11
|
-
# @param before [Time] start of the oldest run whose changes are kept.
|
|
12
|
-
# @return [Integer] changes deleted.
|
|
13
|
-
def self.prune(before: 30.days.ago)
|
|
14
|
-
where(run_id: Run.where(started_at: ...before).select(:id)).delete_all
|
|
15
|
-
end
|
|
16
|
-
|
|
17
|
-
# @return [String] operation, table and row key, as the changes' pages name a change.
|
|
18
|
-
def to_s = "#{operation} #{kind} #{row_key}"
|
|
19
|
-
end
|
|
@@ -1,26 +0,0 @@
|
|
|
1
|
-
# A table written a record at a time, each save and delete logged as a change of the current run.
|
|
2
|
-
module Logged
|
|
3
|
-
extend ActiveSupport::Concern
|
|
4
|
-
|
|
5
|
-
included do
|
|
6
|
-
after_save :log_upsert, if: :saved_changes?
|
|
7
|
-
after_destroy :log_delete
|
|
8
|
-
end
|
|
9
|
-
|
|
10
|
-
private
|
|
11
|
-
|
|
12
|
-
def log_upsert
|
|
13
|
-
log :upsert, attributes.except('id', 'created_at', 'updated_at')
|
|
14
|
-
Run.current.tally(**(previously_new_record? ? { inserted: 1 } : { updated: 1 }))
|
|
15
|
-
end
|
|
16
|
-
|
|
17
|
-
def log_delete
|
|
18
|
-
log :delete, nil
|
|
19
|
-
Run.current.tally deleted: 1
|
|
20
|
-
end
|
|
21
|
-
|
|
22
|
-
def log(operation, record)
|
|
23
|
-
Change.create! operation:, kind: self.class.table_name, source: self.class::SOURCE,
|
|
24
|
-
row_key: self[self.class::KEY], run: Run.current, record:
|
|
25
|
-
end
|
|
26
|
-
end
|