-
Notifications
You must be signed in to change notification settings - Fork 6
Feat/beacon manifest #581
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Feat/beacon manifest #581
Changes from all commits
be9624b
0dea3e0
d88a5be
f677129
da357cc
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,52 @@ | ||
| module Api | ||
| module V1 | ||
| module Beacons | ||
| class ManifestsController < Beacons::BaseController | ||
| def show | ||
| stored_etag = "v#{Current.beacon.manifest_version}" | ||
|
|
||
| # During sync, beacon sends If-Match with its current version. | ||
| # If the manifest changed since sync started, return 412 so beacon aborts and restarts. | ||
| if sync_version_stale?(stored_etag) | ||
| response.headers["ETag"] = stored_etag | ||
| head :precondition_failed | ||
| return | ||
| end | ||
|
|
||
| # Beacon sends If-None-Match with its cached version to check for updates. | ||
| # If versions match, return 304 — no sync needed. | ||
| if cached_version_fresh?(stored_etag) | ||
| response.headers["ETag"] = stored_etag | ||
| head :not_modified | ||
| return | ||
| end | ||
|
|
||
| # Build full manifest only when we need to return the body | ||
| manifest = manifest_builder.call | ||
| response.headers["ETag"] = manifest[:manifest_version] | ||
| render json: manifest | ||
| end | ||
|
|
||
| private | ||
|
|
||
| def manifest_builder | ||
| ::Beacons::ManifestBuilder.new(Current.beacon) | ||
| end | ||
|
|
||
| def sync_version_stale?(etag) | ||
| if_match = request.headers["If-Match"] | ||
| return false if if_match.blank? | ||
|
|
||
| if_match != etag | ||
| end | ||
|
|
||
| def cached_version_fresh?(etag) | ||
| if_none_match = request.headers["If-None-Match"] | ||
| return false if if_none_match.blank? | ||
|
|
||
| if_none_match == etag | ||
| end | ||
| end | ||
| end | ||
| end | ||
| end |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,12 @@ | ||
| module Beacons | ||
| class RebuildManifestJob < ApplicationJob | ||
| queue_as :default | ||
|
|
||
| def perform(beacon_id) | ||
| beacon = Beacon.find_by(id: beacon_id) | ||
| return unless beacon | ||
|
|
||
| ManifestBuilder.new(beacon).call | ||
| end | ||
| end | ||
| end |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,9 +1,24 @@ | ||
| class SynchronizeCognatesOnTopicsJob < ApplicationJob | ||
| def perform(tag) | ||
| modified_topic_ids = [] | ||
|
|
||
| Topic.where(id: tag.taggings.select(:taggable_id)).each do |topic| | ||
| tags = topic.tag_list << tag.cognates_tags.uniq.pluck(:name) | ||
| topic.tag_list.add(tags) | ||
| topic.save | ||
| modified_topic_ids << topic.id if topic.save | ||
| end | ||
|
|
||
| rebuild_beacon_manifests(modified_topic_ids) | ||
| end | ||
|
|
||
| private | ||
|
|
||
| def rebuild_beacon_manifests(topic_ids) | ||
| return if topic_ids.empty? | ||
|
|
||
| beacon_ids = BeaconTopic.where(topic_id: topic_ids).distinct.pluck(:beacon_id) | ||
| beacon_ids.each do |beacon_id| | ||
| Beacons::RebuildManifestJob.perform_later(beacon_id) | ||
| end | ||
| end | ||
| end |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -23,4 +23,12 @@ | |
| class BeaconTopic < ApplicationRecord | ||
| belongs_to :beacon | ||
| belongs_to :topic | ||
|
|
||
| after_commit :rebuild_beacon_manifest, on: [ :create, :destroy ] | ||
|
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. When we add controller/mutator for managing association, move this logic there
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Could this logic go in a concern shared by the two models?
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. It will not stay in models. It will go to controller/mutator when we add managing associations |
||
|
|
||
| private | ||
|
|
||
| def rebuild_beacon_manifest | ||
| Beacons::RebuildManifestJob.perform_later(beacon_id) | ||
| end | ||
| end | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,136 @@ | ||
| module Beacons | ||
| class ManifestBuilder | ||
| def initialize(beacon) | ||
| @beacon = beacon | ||
| end | ||
|
|
||
| def call | ||
| content = build_content | ||
| checksum = "sha256:#{compute_checksum(content)}" | ||
| version = resolve_version(checksum, content) | ||
|
|
||
| content.merge( | ||
| manifest_version: "v#{version}", | ||
| manifest_checksum: checksum, | ||
| generated_at: Time.current.iso8601, | ||
| ) | ||
| end | ||
|
|
||
| private | ||
|
|
||
| attr_reader :beacon | ||
|
|
||
| def resolve_version(checksum, content) | ||
| return beacon.manifest_version if beacon.manifest_checksum == checksum | ||
|
|
||
| beacon.update!( | ||
| manifest_version: beacon.manifest_version + 1, | ||
| manifest_checksum: checksum, | ||
| previous_manifest_data: beacon.manifest_data, | ||
| manifest_data: content, | ||
| ) | ||
|
|
||
| beacon.manifest_version | ||
| end | ||
|
|
||
| def build_content | ||
| providers_data = build_providers | ||
|
|
||
| { | ||
| language: build_language, | ||
| region: build_region, | ||
| tags: build_tags, | ||
| providers: providers_data, | ||
| total_size_bytes: compute_total_size(providers_data), | ||
| total_files: compute_total_files(providers_data), | ||
| } | ||
| end | ||
|
|
||
| def build_language | ||
| language = beacon.language | ||
|
|
||
| { | ||
| id: language.id, | ||
| code: language.code, | ||
| name: language.name, | ||
| } | ||
| end | ||
|
|
||
| def build_region | ||
| region = beacon.region | ||
|
|
||
| { | ||
| id: region.id, | ||
| name: region.name, | ||
| } | ||
| end | ||
|
|
||
| def build_tags | ||
| topics | ||
| .flat_map(&:tags) | ||
| .uniq(&:id) | ||
| .sort_by(&:id) | ||
| .map { |tag| { id: tag.id, name: tag.name } } | ||
| end | ||
|
|
||
| def build_providers | ||
| topics_by_provider = topics.group_by(&:provider_id) | ||
|
|
||
| beacon.providers.sort_by(&:id).map do |provider| | ||
| provider_topics = (topics_by_provider[provider.id] || []).sort_by(&:id) | ||
|
|
||
| { | ||
| id: provider.id, | ||
| name: provider.name, | ||
| topics: provider_topics.map { |topic| build_topic(topic) }, | ||
| } | ||
| end | ||
| end | ||
|
|
||
| def build_topic(topic) | ||
| { | ||
| id: topic.id, | ||
| name: topic.title, | ||
| tag_ids: topic.tags.map(&:id).sort, | ||
| files: topic.documents.sort_by { |d| d.blob.id }.map { |doc| build_file(topic, doc) }, | ||
| } | ||
| end | ||
|
|
||
| def build_file(topic, document) | ||
| blob = document.blob | ||
| filename = topic.custom_file_name(document) | ||
|
|
||
| { | ||
| id: blob.id, | ||
| filename: filename, | ||
| path: "providers/#{topic.provider_id}/topics/#{topic.id}/#{filename}", | ||
| checksum: blob.checksum, | ||
| size_bytes: blob.byte_size, | ||
| content_type: blob.content_type, | ||
| updated_at: blob.created_at.iso8601, | ||
| } | ||
| end | ||
|
|
||
| def topics | ||
| @topics ||= beacon.topics.active.includes(:tags, :provider, documents_attachments: :blob) | ||
| end | ||
|
|
||
| def compute_total_size(providers_data) | ||
| providers_data.sum do |provider| | ||
| provider[:topics].sum do |topic| | ||
| topic[:files].sum { |file| file[:size_bytes] } | ||
| end | ||
| end | ||
| end | ||
|
|
||
| def compute_total_files(providers_data) | ||
| providers_data.sum do |provider| | ||
| provider[:topics].sum { |topic| topic[:files].size } | ||
| end | ||
| end | ||
|
|
||
| def compute_checksum(content) | ||
| OpenSSL::Digest::SHA256.hexdigest(content.to_json) | ||
| end | ||
| end | ||
| end |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -42,6 +42,7 @@ | |
|
|
||
| namespace :beacons do | ||
| resource :status, only: :show | ||
| resource :manifest, only: :show | ||
| end | ||
| end | ||
| end | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,6 @@ | ||
| class AddManifestVersioningToBeacons < ActiveRecord::Migration[8.0] | ||
| def change | ||
| add_column :beacons, :manifest_version, :integer, default: 0, null: false | ||
| add_column :beacons, :manifest_checksum, :string | ||
| end | ||
| end |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,5 @@ | ||
| class AddManifestDataToBeacons < ActiveRecord::Migration[8.0] | ||
| def change | ||
| add_column :beacons, :manifest_data, :jsonb | ||
| end | ||
| end |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,5 @@ | ||
| class AddPreviousManifestDataToBeacons < ActiveRecord::Migration[8.0] | ||
| def change | ||
| add_column :beacons, :previous_manifest_data, :jsonb | ||
| end | ||
| end |
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
When we add controller/mutator for managing association, move this logic there