From 476688c84d699936d23a06bd02204d5b7559f360 Mon Sep 17 00:00:00 2001 From: WithoutPants <53250216+WithoutPants@users.noreply.github.com> Date: Fri, 20 Sep 2024 12:56:26 +1000 Subject: [PATCH] Database connection pool refactor (#5274) * Move optimise out of RunAllMigrations * Separate read and write database connections * Enforce readonly connection constraint * Fix migrations not using tx * #5155 - allow setting cache size from environment * Document new environment variable --- pkg/sqlite/anonymise.go | 6 +- pkg/sqlite/database.go | 124 +++++++++++++------ pkg/sqlite/migrate.go | 56 +++------ pkg/sqlite/migrations/45_postmigrate.go | 2 +- pkg/sqlite/migrations/48_premigrate.go | 8 +- pkg/sqlite/migrations/60_postmigrate.go | 4 +- pkg/sqlite/transaction.go | 24 ++-- pkg/sqlite/transaction_test.go | 129 ++++++++++---------- pkg/txn/transaction.go | 26 ++-- ui/v2.5/src/docs/en/Manual/Configuration.md | 6 + 10 files changed, 207 insertions(+), 178 deletions(-) diff --git a/pkg/sqlite/anonymise.go b/pkg/sqlite/anonymise.go index f9396034c..519489abf 100644 --- a/pkg/sqlite/anonymise.go +++ b/pkg/sqlite/anonymise.go @@ -28,7 +28,7 @@ type Anonymiser struct { } func NewAnonymiser(db *Database, outPath string) (*Anonymiser, error) { - if _, err := db.db.Exec(fmt.Sprintf(`VACUUM INTO "%s"`, outPath)); err != nil { + if _, err := db.writeDB.Exec(fmt.Sprintf(`VACUUM INTO "%s"`, outPath)); err != nil { return nil, fmt.Errorf("vacuuming into %s: %w", outPath, err) } @@ -75,12 +75,12 @@ func (db *Anonymiser) Anonymise(ctx context.Context) error { } func (db *Anonymiser) truncateColumn(tableName string, column string) error { - _, err := db.db.Exec("UPDATE " + tableName + " SET " + column + " = NULL") + _, err := db.writeDB.Exec("UPDATE " + tableName + " SET " + column + " = NULL") return err } func (db *Anonymiser) truncateTable(tableName string) error { - _, err := db.db.Exec("DELETE FROM " + tableName) + _, err := db.writeDB.Exec("DELETE FROM " + tableName) return err } diff --git a/pkg/sqlite/database.go b/pkg/sqlite/database.go index 7dd4771d3..eed335f09 100644 --- a/pkg/sqlite/database.go +++ b/pkg/sqlite/database.go @@ -17,17 +17,21 @@ import ( ) const ( - // Number of database connections to use + maxWriteConnections = 1 + // Number of database read connections to use // The same value is used for both the maximum and idle limit, // to prevent opening connections on the fly which has a notieable performance penalty. // Fewer connections use less memory, more connections increase performance, // but have diminishing returns. // 10 was found to be a good tradeoff. - dbConns = 10 + maxReadConnections = 10 // Idle connection timeout, in seconds // Closes a connection after a period of inactivity, which saves on memory and // causes the sqlite -wal and -shm files to be automatically deleted. - dbConnTimeout = 30 + dbConnTimeout = 30 * time.Second + + // environment variable to set the cache size + cacheSizeEnv = "STASH_SQLITE_CACHE_SIZE" ) var appSchemaVersion uint = 67 @@ -80,8 +84,9 @@ type storeRepository struct { type Database struct { *storeRepository - db *sqlx.DB - dbPath string + readDB *sqlx.DB + writeDB *sqlx.DB + dbPath string schemaVersion uint @@ -128,7 +133,7 @@ func (db *Database) SetBlobStoreOptions(options BlobStoreOptions) { // Ready returns an error if the database is not ready to begin transactions. func (db *Database) Ready() error { - if db.db == nil { + if db.readDB == nil || db.writeDB == nil { return ErrDatabaseNotInitialized } @@ -140,7 +145,7 @@ func (db *Database) Ready() error { // necessary migrations must be run separately using RunMigrations. // Returns true if the database is new. func (db *Database) Open(dbPath string) error { - db.lockNoCtx() + db.lock() defer db.unlock() db.dbPath = dbPath @@ -152,7 +157,9 @@ func (db *Database) Open(dbPath string) error { db.schemaVersion = databaseSchemaVersion - if databaseSchemaVersion == 0 { + isNew := databaseSchemaVersion == 0 + + if isNew { // new database, just run the migrations if err := db.RunAllMigrations(); err != nil { return fmt.Errorf("error running initial schema migrations: %w", err) @@ -174,31 +181,23 @@ func (db *Database) Open(dbPath string) error { } } - // RunMigrations may have opened a connection already - if db.db == nil { - const disableForeignKeys = false - db.db, err = db.open(disableForeignKeys) + if err := db.initialise(); err != nil { + return err + } + + if isNew { + // optimize database after migration + err = db.Optimise(context.Background()) if err != nil { - return err + logger.Warnf("error while performing post-migration optimisation: %v", err) } } return nil } -// lock locks the database for writing. -// This method will block until the lock is acquired of the context is cancelled. -func (db *Database) lock(ctx context.Context) error { - select { - case <-ctx.Done(): - return ctx.Err() - case db.lockChan <- struct{}{}: - return nil - } -} - // lock locks the database for writing. This method will block until the lock is acquired. -func (db *Database) lockNoCtx() { +func (db *Database) lock() { db.lockChan <- struct{}{} } @@ -214,31 +213,47 @@ func (db *Database) unlock() { } func (db *Database) Close() error { - db.lockNoCtx() + db.lock() defer db.unlock() - if db.db != nil { - if err := db.db.Close(); err != nil { + if db.readDB != nil { + if err := db.readDB.Close(); err != nil { return err } - db.db = nil + db.readDB = nil + } + if db.writeDB != nil { + if err := db.writeDB.Close(); err != nil { + return err + } + + db.writeDB = nil } return nil } -func (db *Database) open(disableForeignKeys bool) (*sqlx.DB, error) { +func (db *Database) open(disableForeignKeys bool, writable bool) (*sqlx.DB, error) { // https://github.com/mattn/go-sqlite3 url := "file:" + db.dbPath + "?_journal=WAL&_sync=NORMAL&_busy_timeout=50" if !disableForeignKeys { url += "&_fk=true" } + if writable { + url += "&_txlock=immediate" + } else { + url += "&mode=ro" + } + + // #5155 - set the cache size if the environment variable is set + // default is -2000 which is 2MB + if cacheSize := os.Getenv(cacheSizeEnv); cacheSize != "" { + url += "&_cache_size=" + cacheSize + } + conn, err := sqlx.Open(sqlite3Driver, url) - conn.SetMaxOpenConns(dbConns) - conn.SetMaxIdleConns(dbConns) - conn.SetConnMaxIdleTime(dbConnTimeout * time.Second) if err != nil { return nil, fmt.Errorf("db.Open(): %w", err) } @@ -246,6 +261,43 @@ func (db *Database) open(disableForeignKeys bool) (*sqlx.DB, error) { return conn, nil } +func (db *Database) initialise() error { + if err := db.openReadDB(); err != nil { + return fmt.Errorf("opening read database: %w", err) + } + if err := db.openWriteDB(); err != nil { + return fmt.Errorf("opening write database: %w", err) + } + + return nil +} + +func (db *Database) openReadDB() error { + const ( + disableForeignKeys = false + writable = false + ) + var err error + db.readDB, err = db.open(disableForeignKeys, writable) + db.readDB.SetMaxOpenConns(maxReadConnections) + db.readDB.SetMaxIdleConns(maxReadConnections) + db.readDB.SetConnMaxIdleTime(dbConnTimeout) + return err +} + +func (db *Database) openWriteDB() error { + const ( + disableForeignKeys = false + writable = true + ) + var err error + db.writeDB, err = db.open(disableForeignKeys, writable) + db.writeDB.SetMaxOpenConns(maxWriteConnections) + db.writeDB.SetMaxIdleConns(maxWriteConnections) + db.writeDB.SetConnMaxIdleTime(dbConnTimeout) + return err +} + func (db *Database) Remove() error { databasePath := db.dbPath err := db.Close() @@ -289,7 +341,7 @@ func (db *Database) Reset() error { // Backup the database. If db is nil, then uses the existing database // connection. func (db *Database) Backup(backupPath string) (err error) { - thisDB := db.db + thisDB := db.writeDB if thisDB == nil { thisDB, err = sqlx.Connect(sqlite3Driver, "file:"+db.dbPath+"?_fk=true") if err != nil { @@ -372,13 +424,13 @@ func (db *Database) Optimise(ctx context.Context) error { // Vacuum runs a VACUUM on the database, rebuilding the database file into a minimal amount of disk space. func (db *Database) Vacuum(ctx context.Context) error { - _, err := db.db.ExecContext(ctx, "VACUUM") + _, err := db.writeDB.ExecContext(ctx, "VACUUM") return err } // Analyze runs an ANALYZE on the database to improve query performance. func (db *Database) Analyze(ctx context.Context) error { - _, err := db.db.ExecContext(ctx, "ANALYZE") + _, err := db.writeDB.ExecContext(ctx, "ANALYZE") return err } diff --git a/pkg/sqlite/migrate.go b/pkg/sqlite/migrate.go index 9fb36dba1..ba4754458 100644 --- a/pkg/sqlite/migrate.go +++ b/pkg/sqlite/migrate.go @@ -7,6 +7,7 @@ import ( "github.com/golang-migrate/migrate/v4" sqlite3mig "github.com/golang-migrate/migrate/v4/database/sqlite3" "github.com/golang-migrate/migrate/v4/source/iofs" + "github.com/jmoiron/sqlx" "github.com/stashapp/stash/pkg/logger" ) @@ -15,8 +16,9 @@ func (db *Database) needsMigration() bool { } type Migrator struct { - db *Database - m *migrate.Migrate + db *Database + conn *sqlx.DB + m *migrate.Migrate } func NewMigrator(db *Database) (*Migrator, error) { @@ -24,7 +26,18 @@ func NewMigrator(db *Database) (*Migrator, error) { db: db, } + const disableForeignKeys = true + const writable = true var err error + m.conn, err = m.db.open(disableForeignKeys, writable) + if err != nil { + return nil, err + } + + m.conn.SetMaxOpenConns(maxReadConnections) + m.conn.SetMaxIdleConns(maxReadConnections) + m.conn.SetConnMaxIdleTime(dbConnTimeout) + m.m, err = m.getMigrate() return m, err } @@ -51,13 +64,7 @@ func (m *Migrator) getMigrate() (*migrate.Migrate, error) { return nil, err } - const disableForeignKeys = true - conn, err := m.db.open(disableForeignKeys) - if err != nil { - return nil, err - } - - driver, err := sqlite3mig.WithInstance(conn.DB, &sqlite3mig.Config{}) + driver, err := sqlite3mig.WithInstance(m.conn.DB, &sqlite3mig.Config{}) if err != nil { return nil, err } @@ -110,14 +117,7 @@ func (m *Migrator) runCustomMigrations(ctx context.Context, fns []customMigratio } func (m *Migrator) runCustomMigration(ctx context.Context, fn customMigrationFunc) error { - const disableForeignKeys = false - d, err := m.db.open(disableForeignKeys) - if err != nil { - return err - } - - defer d.Close() - if err := fn(ctx, d); err != nil { + if err := fn(ctx, m.conn); err != nil { return err } @@ -136,14 +136,7 @@ func (db *Database) getDatabaseSchemaVersion() (uint, error) { } func (db *Database) ReInitialise() error { - const disableForeignKeys = false - var err error - db.db, err = db.open(disableForeignKeys) - if err != nil { - return fmt.Errorf("re-initializing the database: %w", err) - } - - return nil + return db.initialise() } // RunAllMigrations runs all migrations to bring the database up to the current schema version @@ -171,18 +164,5 @@ func (db *Database) RunAllMigrations() error { } } - // re-initialise the database - const disableForeignKeys = false - db.db, err = db.open(disableForeignKeys) - if err != nil { - return fmt.Errorf("re-initializing the database: %w", err) - } - - // optimize database after migration - err = db.Optimise(ctx) - if err != nil { - logger.Warnf("error while performing post-migration optimisation: %v", err) - } - return nil } diff --git a/pkg/sqlite/migrations/45_postmigrate.go b/pkg/sqlite/migrations/45_postmigrate.go index 6cafc5e7f..3a2ee6702 100644 --- a/pkg/sqlite/migrations/45_postmigrate.go +++ b/pkg/sqlite/migrations/45_postmigrate.go @@ -247,7 +247,7 @@ func (m *schema45Migrator) insertImage(data []byte, id int, destTable string, de func (m *schema45Migrator) dropTable(ctx context.Context, table string) error { if err := m.withTxn(ctx, func(tx *sqlx.Tx) error { logger.Debugf("Dropping %s", table) - _, err := m.db.Exec(fmt.Sprintf("DROP TABLE `%s`", table)) + _, err := tx.Exec(fmt.Sprintf("DROP TABLE `%s`", table)) return err }); err != nil { return err diff --git a/pkg/sqlite/migrations/48_premigrate.go b/pkg/sqlite/migrations/48_premigrate.go index f0e59620e..05c2c3523 100644 --- a/pkg/sqlite/migrations/48_premigrate.go +++ b/pkg/sqlite/migrations/48_premigrate.go @@ -52,7 +52,7 @@ func (m *schema48PreMigrator) validateScrapedItems(ctx context.Context) error { func (m *schema48PreMigrator) fixStudioNames(ctx context.Context) error { // First remove NULL names if err := m.withTxn(ctx, func(tx *sqlx.Tx) error { - _, err := m.db.Exec("UPDATE studios SET name = 'NULL' WHERE name IS NULL") + _, err := tx.Exec("UPDATE studios SET name = 'NULL' WHERE name IS NULL") return err }); err != nil { return err @@ -64,7 +64,7 @@ func (m *schema48PreMigrator) fixStudioNames(ctx context.Context) error { // collect names if err := m.withTxn(ctx, func(tx *sqlx.Tx) error { - rows, err := m.db.Query("SELECT id, name FROM studios ORDER BY name, id") + rows, err := tx.Query("SELECT id, name FROM studios ORDER BY name, id") if err != nil { return err } @@ -114,7 +114,7 @@ func (m *schema48PreMigrator) fixStudioNames(ctx context.Context) error { var count int - row := m.db.QueryRowx("SELECT COUNT(*) FROM studios WHERE name = ?", newName) + row := tx.QueryRowx("SELECT COUNT(*) FROM studios WHERE name = ?", newName) err := row.Scan(&count) if err != nil { return err @@ -131,7 +131,7 @@ func (m *schema48PreMigrator) fixStudioNames(ctx context.Context) error { } logger.Infof("Renaming duplicate studio id %d to %s", id, newName) - _, err := m.db.Exec("UPDATE studios SET name = ? WHERE id = ?", newName, id) + _, err := tx.Exec("UPDATE studios SET name = ? WHERE id = ?", newName, id) if err != nil { return err } diff --git a/pkg/sqlite/migrations/60_postmigrate.go b/pkg/sqlite/migrations/60_postmigrate.go index dfed33f18..53d4da5c9 100644 --- a/pkg/sqlite/migrations/60_postmigrate.go +++ b/pkg/sqlite/migrations/60_postmigrate.go @@ -48,7 +48,7 @@ func (m *schema60Migrator) migrate(ctx context.Context) error { if err := m.withTxn(ctx, func(tx *sqlx.Tx) error { query := "SELECT id, mode, find_filter, object_filter, ui_options FROM `saved_filters` WHERE `name` = ''" - rows, err := m.db.Query(query) + rows, err := tx.Query(query) if err != nil { return err } @@ -98,7 +98,7 @@ func (m *schema60Migrator) migrate(ctx context.Context) error { // remove the default filters from the database query = "DELETE FROM `saved_filters` WHERE `name` = ''" - if _, err := m.db.Exec(query); err != nil { + if _, err := tx.Exec(query); err != nil { return fmt.Errorf("deleting default filters: %w", err) } diff --git a/pkg/sqlite/transaction.go b/pkg/sqlite/transaction.go index 705c61e07..fb86723bd 100644 --- a/pkg/sqlite/transaction.go +++ b/pkg/sqlite/transaction.go @@ -17,7 +17,7 @@ type key int const ( txnKey key = iota + 1 dbKey - exclusiveKey + writableKey ) func (db *Database) WithDatabase(ctx context.Context) (context.Context, error) { @@ -26,10 +26,10 @@ func (db *Database) WithDatabase(ctx context.Context) (context.Context, error) { return ctx, nil } - return context.WithValue(ctx, dbKey, db.db), nil + return context.WithValue(ctx, dbKey, db.readDB), nil } -func (db *Database) Begin(ctx context.Context, exclusive bool) (context.Context, error) { +func (db *Database) Begin(ctx context.Context, writable bool) (context.Context, error) { if tx, _ := getTx(ctx); tx != nil { // log the stack trace so we can see logger.Error(string(debug.Stack())) @@ -37,22 +37,17 @@ func (db *Database) Begin(ctx context.Context, exclusive bool) (context.Context, return nil, fmt.Errorf("already in transaction") } - if exclusive { - if err := db.lock(ctx); err != nil { - return nil, err - } + dbtx := db.readDB + if writable { + dbtx = db.writeDB } - tx, err := db.db.BeginTxx(ctx, nil) + tx, err := dbtx.BeginTxx(ctx, nil) if err != nil { - // begin failed, unlock - if exclusive { - db.unlock() - } return nil, fmt.Errorf("beginning transaction: %w", err) } - ctx = context.WithValue(ctx, exclusiveKey, exclusive) + ctx = context.WithValue(ctx, writableKey, writable) return context.WithValue(ctx, txnKey, tx), nil } @@ -88,9 +83,6 @@ func (db *Database) Rollback(ctx context.Context) error { } func (db *Database) txnComplete(ctx context.Context) { - if exclusive := ctx.Value(exclusiveKey).(bool); exclusive { - db.unlock() - } } func getTx(ctx context.Context) (*sqlx.Tx, error) { diff --git a/pkg/sqlite/transaction_test.go b/pkg/sqlite/transaction_test.go index 00aa9c2de..513a60a20 100644 --- a/pkg/sqlite/transaction_test.go +++ b/pkg/sqlite/transaction_test.go @@ -77,80 +77,83 @@ func waitForOtherThread(c chan struct{}) error { } } -func TestConcurrentReadTxn(t *testing.T) { - var wg sync.WaitGroup - ctx := context.Background() - c := make(chan struct{}) +// this test is left commented as it's no longer possible to write to the database +// with a read-only transaction. - // first thread - wg.Add(2) - go func() { - defer wg.Done() - if err := txn.WithReadTxn(ctx, db, func(ctx context.Context) error { - scene := &models.Scene{ - Title: "test", - } +// func TestConcurrentReadTxn(t *testing.T) { +// var wg sync.WaitGroup +// ctx := context.Background() +// c := make(chan struct{}) - if err := db.Scene.Create(ctx, scene, nil); err != nil { - return err - } +// // first thread +// wg.Add(2) +// go func() { +// defer wg.Done() +// if err := txn.WithReadTxn(ctx, db, func(ctx context.Context) error { +// scene := &models.Scene{ +// Title: "test", +// } - // wait for other thread to start - if err := signalOtherThread(c); err != nil { - return err - } - if err := waitForOtherThread(c); err != nil { - return err - } +// if err := db.Scene.Create(ctx, scene, nil); err != nil { +// return err +// } - if err := db.Scene.Destroy(ctx, scene.ID); err != nil { - return err - } +// // wait for other thread to start +// if err := signalOtherThread(c); err != nil { +// return err +// } +// if err := waitForOtherThread(c); err != nil { +// return err +// } - return nil - }); err != nil { - t.Errorf("unexpected error in first thread: %v", err) - } - }() +// if err := db.Scene.Destroy(ctx, scene.ID); err != nil { +// return err +// } - // second thread - go func() { - defer wg.Done() - _ = txn.WithReadTxn(ctx, db, func(ctx context.Context) error { - // wait for first thread - if err := waitForOtherThread(c); err != nil { - t.Errorf(err.Error()) - return err - } +// return nil +// }); err != nil { +// t.Errorf("unexpected error in first thread: %v", err) +// } +// }() - defer func() { - if err := signalOtherThread(c); err != nil { - t.Errorf(err.Error()) - } - }() +// // second thread +// go func() { +// defer wg.Done() +// _ = txn.WithReadTxn(ctx, db, func(ctx context.Context) error { +// // wait for first thread +// if err := waitForOtherThread(c); err != nil { +// t.Errorf(err.Error()) +// return err +// } - scene := &models.Scene{ - Title: "test", - } +// defer func() { +// if err := signalOtherThread(c); err != nil { +// t.Errorf(err.Error()) +// } +// }() - // expect error when we try to do this, as the other thread has already - // modified this table - // this takes time to fail, so we need to wait for it - if err := db.Scene.Create(ctx, scene, nil); err != nil { - if !db.IsLocked(err) { - t.Errorf("unexpected error: %v", err) - } - return err - } else { - t.Errorf("expected locked error in second thread") - } +// scene := &models.Scene{ +// Title: "test", +// } - return nil - }) - }() +// // expect error when we try to do this, as the other thread has already +// // modified this table +// // this takes time to fail, so we need to wait for it +// if err := db.Scene.Create(ctx, scene, nil); err != nil { +// if !db.IsLocked(err) { +// t.Errorf("unexpected error: %v", err) +// } +// return err +// } else { +// t.Errorf("expected locked error in second thread") +// } - wg.Wait() -} +// return nil +// }) +// }() + +// wg.Wait() +// } func TestConcurrentExclusiveAndReadTxn(t *testing.T) { var wg sync.WaitGroup diff --git a/pkg/txn/transaction.go b/pkg/txn/transaction.go index b8d0aa830..219482fbb 100644 --- a/pkg/txn/transaction.go +++ b/pkg/txn/transaction.go @@ -7,7 +7,7 @@ import ( ) type Manager interface { - Begin(ctx context.Context, exclusive bool) (context.Context, error) + Begin(ctx context.Context, writable bool) (context.Context, error) Commit(ctx context.Context) error Rollback(ctx context.Context) error @@ -28,34 +28,30 @@ type MustFunc func(ctx context.Context) // WithTxn executes fn in a transaction. If fn returns an error then // the transaction is rolled back. Otherwise it is committed. -// Transaction is exclusive. Only one thread may run a transaction -// using this function at a time. This function will wait until the -// lock is available before executing. +// This function will call m.Begin with writable = true. // This function should be used for making changes to the database. func WithTxn(ctx context.Context, m Manager, fn TxnFunc) error { const ( execComplete = true - exclusive = true + writable = true ) - return withTxn(ctx, m, fn, exclusive, execComplete) + return withTxn(ctx, m, fn, writable, execComplete) } // WithReadTxn executes fn in a transaction. If fn returns an error then // the transaction is rolled back. Otherwise it is committed. -// Transaction is not exclusive and does not enforce read-only restrictions. -// Multiple threads can run transactions using this function concurrently, -// but concurrent writes may result in locked database error. +// This function will call m.Begin with writable = false. func WithReadTxn(ctx context.Context, m Manager, fn TxnFunc) error { const ( execComplete = true - exclusive = false + writable = false ) - return withTxn(ctx, m, fn, exclusive, execComplete) + return withTxn(ctx, m, fn, writable, execComplete) } -func withTxn(ctx context.Context, m Manager, fn TxnFunc, exclusive bool, execCompleteOnLocked bool) error { +func withTxn(ctx context.Context, m Manager, fn TxnFunc, writable bool, execCompleteOnLocked bool) error { // post-hooks should be executed with the outside context - txnCtx, err := begin(ctx, m, exclusive) + txnCtx, err := begin(ctx, m, writable) if err != nil { return err } @@ -94,9 +90,9 @@ func withTxn(ctx context.Context, m Manager, fn TxnFunc, exclusive bool, execCom return err } -func begin(ctx context.Context, m Manager, exclusive bool) (context.Context, error) { +func begin(ctx context.Context, m Manager, writable bool) (context.Context, error) { var err error - ctx, err = m.Begin(ctx, exclusive) + ctx, err = m.Begin(ctx, writable) if err != nil { return nil, err } diff --git a/ui/v2.5/src/docs/en/Manual/Configuration.md b/ui/v2.5/src/docs/en/Manual/Configuration.md index 197ae5410..e3824f064 100644 --- a/ui/v2.5/src/docs/en/Manual/Configuration.md +++ b/ui/v2.5/src/docs/en/Manual/Configuration.md @@ -149,6 +149,12 @@ These options are typically not exposed in the UI and must be changed manually i | `no_proxy` | A list of domains for which the proxy must not be used. Default is all local LAN: localhost,127.0.0.1,192.168.0.0/16,10.0.0.0/8,172.16.0.0/12 | | `sequential_scanning` | Modifies behaviour of the scanning functionality to generate support files (previews/sprites/phash) at the same time as fingerprinting/screenshotting. Useful when scanning cached remote files. | +The following environment variables are also supported: + +| Environment variable | Remarks | +|----------------------|---------| +| `STASH_SQLITE_CACHE_SIZE` | Sets the SQLite cache size. See https://www.sqlite.org/pragma.html#pragma_cache_size. Default is `-2000` which is 2MB. | + ### Custom served folders Custom served folders are served when the server handles a request with the `/custom` URL prefix. The following is an example configuration: