mirror of
https://github.com/penpot/penpot.git
synced 2026-11-03 08:30:40 +00:00
`file_change` is the biggest relation of the database: 683 GB in production, of which almost all the TOAST is dead space (the legacy data is already migrated to `file_data`; what is left is the xlog of the revisions, 4.4 kB on average). It grows with every save of a file and every version, and its rows are spread over the whole table, so every query that filters by a file has to walk all of it. The table is now partitioned by `HASH (file_id)` with 16 partitions, the same bounds as `file_data`, so the queries that filter by a file can prune to one partition and the join between the two tables is resolved partition by partition. The primary key has to include the partition key, so it becomes `(file_id, id)`, and every access path of the table filters by `file_id` accordingly: the snapshot commands require it (2.19), the frontend sends it, and the garbage collector locks the pending rows by their `(file_id, id)` pair. The migration creates the partitioned table with the final schema and copies the rows, keeping the old one aside as `file_change_legacy` for the rollback. The copy happens inside the transaction of the migration, so it does not fit a big instance: there the migration is marked as applied and the procedure of `operations/` does it by hand, in phases, driven by the `updated_at` watermark. Along the way: - every mutation of the table moves `updated_at` to the moment of the mutation, so the phased copy does not lose the rows changed while it runs. It is never set to the `deleted_at` of the row, which for the system snapshots is a date in the future. - a new `kind` column tells the snapshots (1) and the revisions (2) of the table apart, and the two paths that insert in it fill it. It has no default on purpose, so an insert that forgets it fails instead of storing the wrong kind silently. It is a reserve: the queries still tell a snapshot from a revision by its label, and no constraint ties the two together. - the revision does not repeat the features of the file anymore: they were 348 bytes written on every save and nobody read them from there. - the legacy file data backend is gone, and with it the `data`, `data_ref_id` and `data_backend` columns, which nobody wrote anymore. - the migration copies the legacy blobs of the snapshots to `file_data` before the column goes away, and stops when a snapshot with legacy data is left without its blob: the backend is better down than losing content. - the foreign key of the legacy table on `file_id` goes away with the copy: it is `NO ACTION`, so while the legacy table has rows, the objects GC cannot delete the files they point to. - the quota of the snapshots of a team resolves the files of the team first and counts their versions with `= ANY`, instead of joining the team with every version of the table. - the pool sets `enable_partitionwise_join` on every connection, so the join between the two partitioned tables is resolved partition by partition. - the snapshot lookup reports an unknown snapshot with its own error instead of the generic "object not found" of the database helper, and the decode script fails instead of printing an empty JSON. - the procedure of `operations/` stops the backend and the workers during the cutover: the pool keeps server-side prepared statements, and a prepared statement holds the OID of the table it was prepared with, so a connection would keep writing to the legacy table after the rename. - the procedure creates the partitions with their final name, so the result is the schema a new installation gets, and its verification checks the names and not only how many there are. - the index of the system snapshots is gone: it filtered them by `deleted_at IS NULL`, and they always carry a deletion date in the future, so it never matched a row. - the configuration guide, the memories and the docstring of the decode script say that the `legacy-db` backend is gone, instead of offering a value that takes the service down. - a test fixes the schema the migration leaves: the 16 partitions with their name, the primary key, the foreign keys, the check of the kind, the six indexes, the absence of the legacy columns, and that an insert without a kind or with an unknown one fails. Closes #12416 AI-assisted-by: glm-5.3-flash AI-assisted-by: minimax-m3.1-flash
309 lines
10 KiB
Clojure
309 lines
10 KiB
Clojure
;; This Source Code Form is subject to the terms of the Mozilla Public
|
|
;; License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
|
;;
|
|
;; Copyright (c) KALEIDOS INC
|
|
|
|
(ns app.scripts.decode-file-data
|
|
"Decode a file's `file_data` binary blob to `jq`-readable JSON.
|
|
|
|
The content of `file_data.data` (or the `file-data` object on storage) is
|
|
not text: it is a blob
|
|
encoded with `app.util.blob` (versions 1, 3, 4 and 5; transit+json or
|
|
fressian plus lz4/zstd). That is why it cannot be inspected with SQL
|
|
directly.
|
|
|
|
Two uses:
|
|
|
|
1. Offline (no DB, just the blob): dump the `bytea` to a file with
|
|
`psql` (`\\copy ...` or `encode(data, 'base64')`) and pass it with `--input`.
|
|
Needs no system and no `file_data` read permissions.
|
|
|
|
backend/scripts/decode-file-data --input /tmp/blob.bin --format summary | jq .
|
|
backend/scripts/decode-file-data --input /tmp/blob.b64 --from-base64 --format pages | jq .
|
|
|
|
2. Inside the penpot context (nREPL with the backend running, where
|
|
`sys/system` already exists): call `get-file-json` or `get-snapshot-json`.
|
|
They resolve the backend (`db`, `storage`), realize the
|
|
pointer-maps and objects-maps and return JSON.
|
|
|
|
(require '[app.scripts.decode-file-data :as dfd] :reload)
|
|
(spit \"/tmp/maquette.json\"
|
|
(dfd/get-file-json sys/system #uuid \"9e056cfe-f83f-81f8-8008-cc885732f00d\"
|
|
:format :full))
|
|
(spit \"/tmp/snap-518.json\"
|
|
(dfd/get-snapshot-json sys/system #uuid \"9e056cfe-f83f-81f8-8008-cc885732f00d\"
|
|
#uuid \"<snapshot-id>\" :format :summary))
|
|
|
|
Then filter with `jq`: `.pages`, `.data[\"pages-index\"] | keys`,
|
|
`[.data[\"pages-index\"][] | {id, name}]`, etc."
|
|
(:require
|
|
[app.binfile.common :as bfc]
|
|
[app.common.exceptions :as ex]
|
|
[app.common.json :as json]
|
|
[app.db :as db]
|
|
[app.features.file-snapshots :as fsnap]
|
|
[app.util.blob :as blob]
|
|
[app.util.objects-map :as omap-legacy]
|
|
[app.util.pointer-map :as pmap]
|
|
[clojure.java.io :as io]
|
|
[clojure.string :as str])
|
|
(:import
|
|
java.util.Base64))
|
|
|
|
(set! *warn-on-reflection* true)
|
|
|
|
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
|
;; JSON sanitization
|
|
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
|
|
|
(defn- sanitize-key
|
|
[k]
|
|
(cond
|
|
(keyword? k) (subs (str k) 1)
|
|
(symbol? k) (str k)
|
|
(uuid? k) (str k)
|
|
:else (str k)))
|
|
|
|
(declare sanitize)
|
|
|
|
(defn- sanitize-map
|
|
[m]
|
|
(persistent!
|
|
(reduce-kv (fn [acc k v]
|
|
(assoc! acc (sanitize-key k) (sanitize v)))
|
|
(transient {})
|
|
m)))
|
|
|
|
(defn sanitize
|
|
"Convert a blob-decoded value to plain JSON-ready data.
|
|
|
|
- Unrealized pointers become `{\"$pointer\": \"<uuid>\"}`.
|
|
- `objects-map` instances are materialized to a plain map.
|
|
- Bytes become `{\"$bytes\": \"<base64>\"}`.
|
|
- UUIDs, instants, keywords and symbols become strings."
|
|
[x]
|
|
(cond
|
|
(nil? x) nil
|
|
(string? x) x
|
|
(boolean? x) x
|
|
|
|
(number? x)
|
|
(if (ratio? x) (double x) x)
|
|
|
|
(keyword? x) (subs (str x) 1)
|
|
(symbol? x) (str x)
|
|
(uuid? x) (str x)
|
|
(inst? x) (str x)
|
|
|
|
(bytes? x)
|
|
{"$bytes" (.encodeToString (Base64/getEncoder) ^bytes x)}
|
|
|
|
(pmap/pointer-map? x)
|
|
{"$pointer" (str (pmap/get-id x))}
|
|
|
|
(omap-legacy/objects-map? x)
|
|
(sanitize (into {} x))
|
|
|
|
(map? x)
|
|
(sanitize-map x)
|
|
|
|
(set? x)
|
|
(mapv sanitize x)
|
|
|
|
(coll? x)
|
|
(mapv sanitize x)
|
|
|
|
:else
|
|
(str x)))
|
|
|
|
(defn data->json
|
|
"Encode sanitizable data to a JSON string."
|
|
[data]
|
|
(json/encode (sanitize data)))
|
|
|
|
(defn blob->json
|
|
"Decode a `file_data` blob (`app.util.blob`) and return it as JSON."
|
|
[^bytes blob-bytes]
|
|
(data->json (blob/decode blob-bytes)))
|
|
|
|
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
|
;; jq-oriented investigation summary
|
|
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
|
|
|
(defn summarize-data
|
|
"Small, stable summary for `jq`: pages (in `:pages` order) with object
|
|
counts, plus component, color and typography counters. Takes the already
|
|
decoded `:data` (not the JSON)."
|
|
[data]
|
|
(let [pages (:pages data)
|
|
pages-index (:pages-index data)
|
|
page-entry (fn [pid]
|
|
{:id (str pid)
|
|
:name (get-in pages-index [pid :name])
|
|
:objects (count (get-in pages-index [pid :objects]))})]
|
|
{:pages (mapv page-entry (or pages []))
|
|
:stats {:pages (count (or pages []))
|
|
:components (count (:components data))
|
|
:colors (count (:colors data))
|
|
:typographies (count (:typographies data))}}))
|
|
|
|
(defn file-data-summary-json
|
|
[data]
|
|
(data->json (summarize-data data)))
|
|
|
|
(defn file-data-pages-json
|
|
[data]
|
|
(data->json (:pages (summarize-data data))))
|
|
|
|
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
|
;; DB access (for use from nREPL with the backend running)
|
|
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
|
|
|
(defn- write-output
|
|
[^String payload output]
|
|
(if output
|
|
(do (spit output payload) output)
|
|
(print payload))
|
|
payload)
|
|
|
|
(defn get-file-json
|
|
"Return a file as JSON (realized). For nREPL.
|
|
|
|
`format` can be `:full` (whole file), `:data` (`:data` only),
|
|
`:summary` or `:pages`. With `:output` it also writes it to a file."
|
|
[system file-id & {:keys [format output] :or {format :full}}]
|
|
(let [file (db/run! system bfc/get-file file-id :realize? true)
|
|
data (case format
|
|
:full file
|
|
:data (:data file)
|
|
:summary (summarize-data (:data file))
|
|
:pages (:pages (summarize-data (:data file))))]
|
|
(write-output (data->json data) output)))
|
|
|
|
(defn get-snapshot-json
|
|
"Return a snapshot as JSON (realized). For nREPL.
|
|
|
|
Takes the same `format` and `output` as `get-file-json`. It raises when the
|
|
file has no snapshot with that id."
|
|
[system file-id snapshot-id & {:keys [format output] :or {format :full}}]
|
|
(let [snapshot (db/run! system
|
|
(fn [cfg]
|
|
(fsnap/get-snapshot cfg file-id snapshot-id)))
|
|
data (if snapshot
|
|
(case format
|
|
:full snapshot
|
|
:data (:data snapshot)
|
|
:summary (summarize-data (:data snapshot))
|
|
:pages (:pages (summarize-data (:data snapshot))))
|
|
(ex/raise :type :not-found
|
|
:code :snapshot-not-found
|
|
:hint "unable to find snapshot with the provided id"
|
|
:snapshot-id snapshot-id
|
|
:file-id file-id))]
|
|
(write-output (data->json data) output)))
|
|
|
|
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
|
;; Offline CLI: decode a file holding the blob
|
|
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
|
|
|
(def ^:private cli-usage
|
|
(str "Usage: decode-file-data --input PATH [--output PATH] [--format full|data|summary|pages]\n"
|
|
" [--from-base64|--from-hex]\n"
|
|
"\n"
|
|
"Decode a file_data blob (app.util.blob) to JSON for inspection with jq.\n"
|
|
"\n"
|
|
"Examples:\n"
|
|
" psql -c \"\\\\copy (select data from file_data where file_id='...' and id='...') to '/tmp/blob.bin' with binary\"\n"
|
|
" decode-file-data --input /tmp/blob.bin --format summary | jq .\n"
|
|
" decode-file-data --input /tmp/blob.b64 --from-base64 --format pages | jq .\n"))
|
|
|
|
(defn- parse-args
|
|
[args]
|
|
(loop [args args
|
|
opts {:format "full"}]
|
|
(if (empty? args)
|
|
opts
|
|
(let [[a b & rest-args] args]
|
|
(cond
|
|
(contains? #{"-h" "--help"} a)
|
|
(assoc opts :help true)
|
|
|
|
(= a "--input")
|
|
(recur rest-args (assoc opts :input b))
|
|
|
|
(= a "--output")
|
|
(recur rest-args (assoc opts :output b))
|
|
|
|
(= a "--format")
|
|
(recur rest-args (assoc opts :format b))
|
|
|
|
(= a "--from-base64")
|
|
(recur (cons b rest-args) (assoc opts :from :base64))
|
|
|
|
(= a "--from-hex")
|
|
(recur (cons b rest-args) (assoc opts :from :hex))
|
|
|
|
:else
|
|
(assoc opts :error (str "unknown argument: " a)))))))
|
|
|
|
(defn- hex->bytes
|
|
[^String s]
|
|
(let [s (str/trim s)
|
|
s (cond
|
|
(str/starts-with? s "\\x") (subs s 2)
|
|
(str/starts-with? s "0x") (subs s 2)
|
|
:else s)
|
|
s (str/replace s #"\s+" "")
|
|
n (/ (count s) 2)
|
|
out (byte-array n)]
|
|
(dotimes [i n]
|
|
(aset-byte out i (unchecked-byte (Integer/parseInt (subs s (* i 2) (+ (* i 2) 2)) 16))))
|
|
out))
|
|
|
|
(defn- read-input-bytes
|
|
[{:keys [input from]}]
|
|
(cond
|
|
(nil? input)
|
|
(throw (ex-info "missing --input" {}))
|
|
|
|
(= from :base64)
|
|
(.decode (Base64/getDecoder) ^String (str/trim (slurp (io/file input))))
|
|
|
|
(= from :hex)
|
|
(hex->bytes (slurp (io/file input)))
|
|
|
|
:else
|
|
(java.nio.file.Files/readAllBytes (.toPath (io/file input)))))
|
|
|
|
(defn -main
|
|
[& args]
|
|
(let [{:keys [help error input format output] :as opts} (parse-args (vec args))]
|
|
(cond
|
|
help
|
|
(do (print cli-usage) nil)
|
|
|
|
error
|
|
(do (binding [*out* *err*] (println error) (println cli-usage)) (System/exit 2))
|
|
|
|
(nil? input)
|
|
(do (binding [*out* *err*] (println "missing --input") (println cli-usage)) (System/exit 2))
|
|
|
|
(not (contains? #{"full" "data" "summary" "pages"} (or format "full")))
|
|
(do (binding [*out* *err*] (println (str "unknown format: " format))) (System/exit 2))
|
|
|
|
:else
|
|
(let [blob-bytes (read-input-bytes opts)
|
|
decoded (blob/decode blob-bytes)
|
|
payload (case format
|
|
"full" decoded
|
|
"data" (:data decoded (:data decoded decoded))
|
|
"summary" (summarize-data (:data decoded decoded))
|
|
"pages" (:pages (summarize-data (:data decoded decoded))))
|
|
;; NOTE: the file_data blob already IS the file's :data; but if
|
|
;; someone dumps the whole serialized row, tolerate both.
|
|
out (data->json payload)]
|
|
(if output
|
|
(spit output out)
|
|
(println out))))))
|