Sync Rails backend with License DB
## Problem to solve Part of the new architecture in https://gitlab.com/groups/gitlab-org/-/epics/8072+ is to synchronize the package metadata external database with a mirror in the monolith. The mirror facilitates quick searches for licenses belonging to components. GitLab instances need to stay in sync with the License Database, which is [exported on a regular basis](https://gitlab.com/gitlab-org/gitlab/-/issues/373030). ## Proposal Check exports of the External License DB periodically. Add new license data into the DB. See https://gitlab.com/gitlab-org/gitlab/-/issues/373030+. See the spike issue for design discussion: https://gitlab.com/gitlab-org/gitlab/-/issues/379137+ Points agreed upon from spike above: * use a GCP bucket to synchronize data for non-offline instances * use `csv` as the format, where each row describes the licenses for a particular package version * to save on amount of data transferred and processed by the instance sync worker, use a unique identifier to denote unique position in sequence - caller can store this position and query only for records newer than the ones denoted by the position 4. for offline mode create a rake task to trigger synchronization against a local file on disk ## Implementation plan The plan has been broken into sub issues by functionality: - [ ] background worker https://gitlab.com/gitlab-org/gitlab/-/issues/383719+ - [ ] sync service https://gitlab.com/gitlab-org/gitlab/-/issues/383722+ - [ ] data import service https://gitlab.com/gitlab-org/gitlab/-/issues/383723+ - [ ] gcp storage connector https://gitlab.com/gitlab-org/gitlab/-/issues/383797+ - [ ] connector for offline instances https://gitlab.com/gitlab-org/gitlab/-/issues/384047+ ### Discussion (old implementation plan) This implementation plan has been superseded by the issues above. It has not been removed as there are discussion threads still linking to it. * [ ] background worker * scheduled background job that triggers the sync * [ ] sync service - retrieve last sync position (see [Store sync position](#store-sync-position) section below) - use connector (see [Connectors](#connectors) section below) and pass `purl-type`, `sequence-id`, `chunk-id` - using stream yielded by connector - iterate over `[package_name, version, spdx_identifiers]` tuples - convert these into batches (e.g. `PackageMetadata::Batch`) which implement [`BulkInsertableTask`](https://gitlab.com/gitlab-org/gitlab/blob/master/ee/lib/gitlab/ingestion/bulk_insertable_task.rb) to update the package metadata models - save sync position * [ ] data import service (bulk insert) * save `{package, version, license}` tuples into the database * use [`BulkInsertableTask`](https://gitlab.com/gitlab-org/gitlab/blob/master/ee/lib/gitlab/ingestion/bulk_insertable_task.rb) * provide caller with callback when save occurred * data connectors * gcp bucket https://gitlab.com/gitlab-org/gitlab/-/issues/383797+ - responsible for establishing a connection to the GCP bucket - can seek to sequence/chunk within bucket - opens a CSV stream and extracts tuples * offline storage (tbd) - responsible for opening compressed package metadata file - can seek to sequence/chunk within directory stored in file - opens a CSV stream and extracts tuples ### Connectors Synchronization will require 2 types of connections: to the GCP bucket and to a "local" file when the instance is offline. Thus the connectors can expose the same API for callers while implementing different connection types. Note: format version 1 will use `csv` as the data format so the connection should be able to parse the CSV rows and provide a stream of well-formed tuples to the caller. Pseudo code to illustrate how caller may use the connectors. ```ruby module PackageMetadata class SyncService def sync(sync_uri) position = SyncPosition.find(sync_uri) connector_for(position.base_uri, position.format_version, position.purl_type) .position_after(position.sequence_id, position.chunk_id) .slice_of(slice_size) do |sequence_id, tuple| if num_tuples_consumed > batch batch.insert_all! batch = PackageMetadata::DataBatch.new(position.purl_type) num_tuples_consumed = 0 end batch.add(sequence_id, tuple.package_name, tuple.package_version, tuple.spdx_identifier) num_tuples_consumed += 1 end end end end ``` ### Store sync position * [ ] introduce `PackageMetadata::SyncPosition` model with a backing table * [ ] store a tuple of `[purl_type, position identifier, timestamp]` * [ ] position identifier will have the structure: `<base_uri>/<format_version>/<purl_type>/<sequence_id>/<chunk_id>` - `base_uri` points to either a `file://` or `https://` #### Sequence ID usage The sequence-id is used as a "cursor" and as a unique identifier. That is, the client must find the sequence-id in the bucket, otherwise it must assume that the sequence-id is zero. Some example use cases follow. Given the following structure: ``` root - 5 - 1668056400 - 1.csv - 2.csv - 3.csv - 1668099600 - 1.csv - 2.csv - 1668488400 - 1.csv ``` #### Use case 1: sequence-id and chunk-id found Client stored: `<bucket>/5/1668099600/1.csv` Client finds both sequence-id (1668099600) and chunk-id (1.csv). Client should start at `1668099600/2.csv`. ### Use case 2: client had last chunk-id of a sequence Client stored: `<bucket>/5/1668099600/2.csv` Client finds both sequence-id (1668099600) and the last chunk-id (2.csv) in sequence. Client should start at `1668488400/1.csv`. ### Use case 3: client does not find sequence-id Client stored: `<bucket>/5/1668099700/1.csv` Client doest not find sequence-id (1668099700). Client rewinds their "cursor" and starts at the first sequence for the `purl_type` in the bucket: `1668056400` and `1.csv`. ### Offline functionality * [ ] create a rake task to package bucket data into a compressed file (using sequence id stored on instance)
epic