From f592eabd00d21198dd3eda2de176f47ca8b9889d Mon Sep 17 00:00:00 2001 From: Roger Ng Date: Wed, 10 Jul 2024 16:47:42 +0100 Subject: [PATCH] Add read tile in MySQL storage implementation (#50) * Add read tile in MySQL storage implementation * Fix misspelled `splittedIndexPath` * Mention `parseTileLevelIndex` func doesn't return any partial tile component * Only add the immutable `Cache-Control` header when the number of hash in the returned tile match the requested tile width --- cmd/example-mysql/main.go | 75 +++++++++++++++++++++++++++++++++++++++ storage/mysql/mysql.go | 15 ++++++-- storage/mysql/schema.sql | 11 ++++++ 3 files changed, 99 insertions(+), 2 deletions(-) diff --git a/cmd/example-mysql/main.go b/cmd/example-mysql/main.go index 0acd48cb..51a1578e 100644 --- a/cmd/example-mysql/main.go +++ b/cmd/example-mysql/main.go @@ -20,8 +20,11 @@ import ( "crypto/sha256" "database/sql" "flag" + "fmt" "io" "net/http" + "strconv" + "strings" "time" tessera "github.com/transparency-dev/trillian-tessera" @@ -69,6 +72,40 @@ func main() { } }) + http.HandleFunc("GET /tile/{level}/{index...}", func(w http.ResponseWriter, r *http.Request) { + level, index, width, err := parseTileLevelIndexWidth(r.PathValue("level"), r.PathValue("index")) + if err != nil { + w.WriteHeader(http.StatusBadRequest) + if _, werr := w.Write([]byte(fmt.Sprintf("Malformed URL: %s", err.Error()))); werr != nil { + klog.Errorf("/tile/{level}/{index...}: %v", werr) + } + return + } + + tile, err := storage.ReadTile(r.Context(), level, index) + if err != nil { + if err == sql.ErrNoRows { + w.WriteHeader(http.StatusNotFound) + return + } + + klog.Errorf("/tile/{level}/{index...}: %v", err) + w.WriteHeader(http.StatusInternalServerError) + return + } + + // Only add the immutable Cache-Control header when the number of hash in the returned tile match the requested tile width. + // This ensures the response will not be cached when returning a partial tile on a full tile request. + if len(tile)/32 == int(width) { + w.Header().Set("Cache-Control", "public, max-age=31536000, immutable") + } + + if _, err := w.Write(tile); err != nil { + klog.Errorf("/tile/{level}/{index...}: %v", err) + return + } + }) + http.HandleFunc("POST /add", func(w http.ResponseWriter, r *http.Request) { b, err := io.ReadAll(r.Body) if err != nil { @@ -87,3 +124,41 @@ func main() { klog.Exitf("ListenAndServe: %v", err) } } + +// parseTileLevelWidthIndex takes level and index in string, validates and returns the level, index and width in uint64. +// +// Examples: +// "/tile/0/x001/x234/067" means level 0 and index 1234067 of a full tile. +// "/tile/0/x001/x234/067.p/8" means level 0, index 1234067 and width 8 of a partial tile. +func parseTileLevelIndexWidth(level, index string) (uint64, uint64, uint64, error) { + l, err := strconv.ParseUint(level, 10, 64) + if err != nil { + return 0, 0, 0, fmt.Errorf("failed to parse tile level") + } + + var i, w uint64 + switch indexPaths := strings.Split(index, "/"); len(indexPaths) { + // Full tile = 3 + // Partial tile = 4 + case 3, 4: + indexPath := strings.Join(indexPaths[0:3], "") + indexPath = strings.ReplaceAll(indexPath, "x", "") + indexPath = strings.ReplaceAll(indexPath, ".p", "") + i, err = strconv.ParseUint(indexPath, 10, 64) + if err != nil { + return 0, 0, 0, fmt.Errorf("failed to parse tile index") + } + + w = 256 + if len(indexPaths) == 4 { + w, err = strconv.ParseUint(indexPaths[3], 10, 64) + if err != nil { + return 0, 0, 0, fmt.Errorf("failed to parse tile index") + } + } + default: + return 0, 0, 0, fmt.Errorf("failed to parse tile index") + } + + return l, i, w, nil +} diff --git a/storage/mysql/mysql.go b/storage/mysql/mysql.go index 111b7645..bc5db864 100644 --- a/storage/mysql/mysql.go +++ b/storage/mysql/mysql.go @@ -24,8 +24,9 @@ import ( ) const ( - selectCheckpointByIDSQL = "SELECT `note` FROM `Checkpoint` WHERE `id` = ?" - replaceCheckpointSQL = "REPLACE INTO `Checkpoint` (`id`, `note`) VALUES (?, ?)" + selectCheckpointByIDSQL = "SELECT `note` FROM `Checkpoint` WHERE `id` = ?" + replaceCheckpointSQL = "REPLACE INTO `Checkpoint` (`id`, `note`) VALUES (?, ?)" + selectSubtreeByLevelAndIndexSQL = "SELECT `nodes` FROM `Subtree` WHERE `level` = ? AND `index` = ?" checkpointID = 0 ) @@ -95,3 +96,13 @@ func (s *Storage) writeCheckpoint(ctx context.Context, rawCheckpoint []byte) err // Commit the transaction. return tx.Commit() } + +func (s *Storage) ReadTile(ctx context.Context, level, index uint64) ([]byte, error) { + row := s.db.QueryRowContext(ctx, selectSubtreeByLevelAndIndexSQL, level, index) + if err := row.Err(); err != nil { + return nil, err + } + + var tile []byte + return tile, row.Scan(&tile) +} diff --git a/storage/mysql/schema.sql b/storage/mysql/schema.sql index a7ae85c9..ada29168 100644 --- a/storage/mysql/schema.sql +++ b/storage/mysql/schema.sql @@ -22,3 +22,14 @@ CREATE TABLE IF NOT EXISTS `Checkpoint` ( `note` MEDIUMBLOB NOT NULL, PRIMARY KEY(`id`) ); + +-- "Subtree" table is an internal tile consisting of hashes. There is one row for each internal tile, and this is updated until it is completed, at which point it is immutable. +CREATE TABLE IF NOT EXISTS `Subtree` ( + -- level is the level of the tile. + `level` INT UNSIGNED NOT NULL, + -- index is the index of the tile. + `index` BIGINT UNSIGNED NOT NULL, + -- nodes stores the hashes of the leaves. + `nodes` MEDIUMBLOB NOT NULL, + PRIMARY KEY(`level`, `index`) +);