diff --git a/cmd/serve.go b/cmd/serve.go index 83907b0..75d5de4 100644 --- a/cmd/serve.go +++ b/cmd/serve.go @@ -40,6 +40,7 @@ var flagsServe = append( altsrc.NewStringFlag(&cli.StringFlag{Name: "firebase-key-file", Aliases: []string{"firebase_key_file", "F"}, EnvVars: []string{"NTFY_FIREBASE_KEY_FILE"}, Usage: "Firebase credentials file; if set additionally publish to FCM topic"}), altsrc.NewStringFlag(&cli.StringFlag{Name: "cache-file", Aliases: []string{"cache_file", "C"}, EnvVars: []string{"NTFY_CACHE_FILE"}, Usage: "cache file used for message caching"}), altsrc.NewDurationFlag(&cli.DurationFlag{Name: "cache-duration", Aliases: []string{"cache_duration", "b"}, EnvVars: []string{"NTFY_CACHE_DURATION"}, Value: server.DefaultCacheDuration, Usage: "buffer messages for this time to allow `since` requests"}), + altsrc.NewStringFlag(&cli.StringFlag{Name: "cache-startup-queries", Aliases: []string{"cache_startup_queries"}, EnvVars: []string{"NTFY_CACHE_STARTUP_QUERIES"}, Usage: "queries run when the cache database is initialized"}), altsrc.NewStringFlag(&cli.StringFlag{Name: "auth-file", Aliases: []string{"auth_file", "H"}, EnvVars: []string{"NTFY_AUTH_FILE"}, Usage: "auth database file used for access control"}), altsrc.NewStringFlag(&cli.StringFlag{Name: "auth-default-access", Aliases: []string{"auth_default_access", "p"}, EnvVars: []string{"NTFY_AUTH_DEFAULT_ACCESS"}, Value: "read-write", Usage: "default permissions if no matching entries in the auth database are found"}), altsrc.NewStringFlag(&cli.StringFlag{Name: "attachment-cache-dir", Aliases: []string{"attachment_cache_dir"}, EnvVars: []string{"NTFY_ATTACHMENT_CACHE_DIR"}, Usage: "cache directory for attached files"}), @@ -103,6 +104,7 @@ func execServe(c *cli.Context) error { firebaseKeyFile := c.String("firebase-key-file") cacheFile := c.String("cache-file") cacheDuration := c.Duration("cache-duration") + cacheStartupQueries := c.String("cache-startup-queries") authFile := c.String("auth-file") authDefaultAccess := c.String("auth-default-access") attachmentCacheDir := c.String("attachment-cache-dir") @@ -222,6 +224,7 @@ func execServe(c *cli.Context) error { conf.FirebaseKeyFile = firebaseKeyFile conf.CacheFile = cacheFile conf.CacheDuration = cacheDuration + conf.CacheStartupQueries = cacheStartupQueries conf.AuthFile = authFile conf.AuthDefaultRead = authDefaultRead conf.AuthDefaultWrite = authDefaultWrite diff --git a/docs/config.md b/docs/config.md index db664c7..4ff21dd 100644 --- a/docs/config.md +++ b/docs/config.md @@ -733,6 +733,21 @@ out [this discussion on Reddit](https://www.reddit.com/r/golang/comments/r9u4ee/ Depending on *how you run it*, here are a few limits that are relevant: +### WAL for message cache +By default, the [message cache](#message-cache) (defined by `cache-file`) uses the SQLite default settings, which means it +syncs to disk on every write. For personal servers, this is perfectly adequate. For larger installations, such as ntfy.sh, +the [write-ahead log (WAL)](https://sqlite.org/wal.html) should be enabled, and the sync mode should be adjusted. +See [this article](https://phiresky.github.io/blog/2020/sqlite-performance-tuning/) for details. + +Here's how ntfy.sh has been tuned in the `server.yml` file: + +``` yaml +cache-startup-queries: | + pragma journal_mode = WAL; + pragma synchronous = normal; + pragma temp_store = memory; +``` + ### For systemd services If you're running ntfy in a systemd service (e.g. for .deb/.rpm packages), the main limiting factor is the `LimitNOFILE` setting in the systemd unit. The default open files limit for `ntfy.service` is 10,000. You can override it @@ -865,6 +880,7 @@ variable before running the `ntfy` command (e.g. `export NTFY_LISTEN_HTTP=:80`). | `firebase-key-file` | `NTFY_FIREBASE_KEY_FILE` | *filename* | - | If set, also publish messages to a Firebase Cloud Messaging (FCM) topic for your app. This is optional and only required to save battery when using the Android app. See [Firebase (FCM](#firebase-fcm). | | `cache-file` | `NTFY_CACHE_FILE` | *filename* | - | If set, messages are cached in a local SQLite database instead of only in-memory. This allows for service restarts without losing messages in support of the since= parameter. See [message cache](#message-cache). | | `cache-duration` | `NTFY_CACHE_DURATION` | *duration* | 12h | Duration for which messages will be buffered before they are deleted. This is required to support the `since=...` and `poll=1` parameter. Set this to `0` to disable the cache entirely. | +| `cache-startup-queries` | `NTFY_CACHE_STARTUP_QUERIES` | *string (SQL queries)* | - | SQL queries to run during database startup; this is useful for tuning and [enabling WAL mode](#wal-for-message-cache) | | `auth-file` | `NTFY_AUTH_FILE` | *filename* | - | Auth database file used for access control. If set, enables authentication and access control. See [access control](#access-control). | | `auth-default-access` | `NTFY_AUTH_DEFAULT_ACCESS` | `read-write`, `read-only`, `write-only`, `deny-all` | `read-write` | Default permissions if no matching entries in the auth database are found. Default is `read-write`. | | `behind-proxy` | `NTFY_BEHIND_PROXY` | *bool* | false | If set, the X-Forwarded-For header is used to determine the visitor IP address instead of the remote address of the connection. | @@ -929,6 +945,7 @@ OPTIONS: --behind-proxy, --behind_proxy, -P if set, use X-Forwarded-For header to determine visitor IP address (for rate limiting) (default: false) [$NTFY_BEHIND_PROXY] --cache-duration since, --cache_duration since, -b since buffer messages for this time to allow since requests (default: 12h0m0s) [$NTFY_CACHE_DURATION] --cache-file value, --cache_file value, -C value cache file used for message caching [$NTFY_CACHE_FILE] + --cache-startup-queries value, --cache_startup_queries value queries run when the cache database is initialized [$NTFY_CACHE_STARTUP_QUERIES] --cert-file value, --cert_file value, -E value certificate file, if listen-https is set [$NTFY_CERT_FILE] --config value, -c value config file (default: /etc/ntfy/server.yml) [$NTFY_CONFIG_FILE] --debug, -d enable debug logging (default: false) [$NTFY_DEBUG] diff --git a/docs/releases.md b/docs/releases.md index ebff695..4530349 100644 --- a/docs/releases.md +++ b/docs/releases.md @@ -6,14 +6,9 @@ and the [ntfy Android app](https://github.com/binwiederhier/ntfy-android/release ## ntfy server v1.27.0 (UNRELEASED) -!!! info - The message cache database (typically `cache.db`) now enables the write-ahead log (WAL) in SQLite. - WAL mode will create two additional files (`cache.db-wal` and `cache.db-shm`). This is perfectly normal. - Do not delete or modify these files, as that can lead to database corruption. - **Features:** -* Greatly improve SQLite performance for the message cache by enabling WAL mode (no ticket) +* Add `cache-startup-queries` option to allow custom SQLite performance tuning (no ticket) * ntfy CLI can now [wait for a command or PID](https://ntfy.sh/docs/subscribe/cli/#wait-for-pidcommand) before publishing ([#263](https://github.com/binwiederhier/ntfy/issues/263), thanks to the [original ntfy](https://github.com/dschep/ntfy) for the idea) * Trace: Log entire HTTP request to simplify debugging (no ticket) * Allow setting user password via `NTFY_PASSWORD` env variable ([#327](https://github.com/binwiederhier/ntfy/pull/327), thanks to [@Kenix3](https://github.com/Kenix3)) diff --git a/server/config.go b/server/config.go index 6b4fa85..e34eefb 100644 --- a/server/config.go +++ b/server/config.go @@ -57,6 +57,7 @@ type Config struct { FirebaseKeyFile string CacheFile string CacheDuration time.Duration + CacheStartupQueries string AuthFile string AuthDefaultRead bool AuthDefaultWrite bool diff --git a/server/message_cache.go b/server/message_cache.go index b1d427f..f6fba96 100644 --- a/server/message_cache.go +++ b/server/message_cache.go @@ -88,18 +88,6 @@ const ( selectAttachmentsExpiredQuery = `SELECT mid FROM messages WHERE attachment_expires > 0 AND attachment_expires < ?` ) -// Performance & setup queries (see https://phiresky.github.io/blog/2020/sqlite-performance-tuning/) -// - Write-ahead log (speeds up reads) -// - Only sync on WAL checkpoint -// - Temporary indices in memory -const ( - setupQueries = ` - pragma journal_mode = WAL; - pragma synchronous = normal; - pragma temp_store = memory; - ` -) - // Schema management queries const ( currentSchemaVersion = 7 @@ -198,12 +186,12 @@ type messageCache struct { } // newSqliteCache creates a SQLite file-backed cache -func newSqliteCache(filename string, nop bool) (*messageCache, error) { +func newSqliteCache(filename, startupQueries string, nop bool) (*messageCache, error) { db, err := sql.Open("sqlite3", filename) if err != nil { return nil, err } - if err := setupCacheDB(db); err != nil { + if err := setupCacheDB(db, startupQueries); err != nil { return nil, err } return &messageCache{ @@ -214,13 +202,13 @@ func newSqliteCache(filename string, nop bool) (*messageCache, error) { // newMemCache creates an in-memory cache func newMemCache() (*messageCache, error) { - return newSqliteCache(createMemoryFilename(), false) + return newSqliteCache(createMemoryFilename(), "", false) } // newNopCache creates an in-memory cache that discards all messages; // it is always empty and can be used if caching is entirely disabled func newNopCache() (*messageCache, error) { - return newSqliteCache(createMemoryFilename(), true) + return newSqliteCache(createMemoryFilename(), "", true) } // createMemoryFilename creates a unique memory filename to use for the SQLite backend. @@ -511,10 +499,12 @@ func readMessages(rows *sql.Rows) ([]*message, error) { return messages, nil } -func setupCacheDB(db *sql.DB) error { - // Performance: WAL mode, only sync on WAL checkpoints - if _, err := db.Exec(setupQueries); err != nil { - return err +func setupCacheDB(db *sql.DB, startupQueries string) error { + // Run startup queries + if startupQueries != "" { + if _, err := db.Exec(startupQueries); err != nil { + return err + } } // If 'messages' table does not exist, this must be a new database diff --git a/server/message_cache_test.go b/server/message_cache_test.go index 07929e0..b68fc33 100644 --- a/server/message_cache_test.go +++ b/server/message_cache_test.go @@ -378,7 +378,7 @@ func TestSqliteCache_Migration_From0(t *testing.T) { require.Nil(t, db.Close()) // Create cache to trigger migration - c := newSqliteTestCacheFromFile(t, filename) + c := newSqliteTestCacheFromFile(t, filename, "") checkSchemaVersion(t, c.db) messages, err := c.Messages("mytopic", sinceAllMessages, false) @@ -424,7 +424,7 @@ func TestSqliteCache_Migration_From1(t *testing.T) { require.Nil(t, db.Close()) // Create cache to trigger migration - c := newSqliteTestCacheFromFile(t, filename) + c := newSqliteTestCacheFromFile(t, filename, "") checkSchemaVersion(t, c.db) // Add delayed message @@ -443,6 +443,37 @@ func TestSqliteCache_Migration_From1(t *testing.T) { require.Equal(t, 11, len(messages)) } +func TestSqliteCache_StartupQueries_WAL(t *testing.T) { + filename := newSqliteTestCacheFile(t) + startupQueries := `pragma journal_mode = WAL; +pragma synchronous = normal; +pragma temp_store = memory;` + db, err := newSqliteCache(filename, startupQueries, false) + require.Nil(t, err) + require.Nil(t, db.AddMessage(newDefaultMessage("mytopic", "some message"))) + require.FileExists(t, filename) + require.FileExists(t, filename+"-wal") + require.FileExists(t, filename+"-shm") +} + +func TestSqliteCache_StartupQueries_None(t *testing.T) { + filename := newSqliteTestCacheFile(t) + startupQueries := "" + db, err := newSqliteCache(filename, startupQueries, false) + require.Nil(t, err) + require.Nil(t, db.AddMessage(newDefaultMessage("mytopic", "some message"))) + require.FileExists(t, filename) + require.NoFileExists(t, filename+"-wal") + require.NoFileExists(t, filename+"-shm") +} + +func TestSqliteCache_StartupQueries_Fail(t *testing.T) { + filename := newSqliteTestCacheFile(t) + startupQueries := `xx error` + _, err := newSqliteCache(filename, startupQueries, false) + require.Error(t, err) +} + func checkSchemaVersion(t *testing.T, db *sql.DB) { rows, err := db.Query(`SELECT version FROM schemaVersion`) require.Nil(t, err) @@ -468,7 +499,7 @@ func TestMemCache_NopCache(t *testing.T) { } func newSqliteTestCache(t *testing.T) *messageCache { - c, err := newSqliteCache(newSqliteTestCacheFile(t), false) + c, err := newSqliteCache(newSqliteTestCacheFile(t), "", false) if err != nil { t.Fatal(err) } @@ -479,8 +510,8 @@ func newSqliteTestCacheFile(t *testing.T) string { return filepath.Join(t.TempDir(), "cache.db") } -func newSqliteTestCacheFromFile(t *testing.T, filename string) *messageCache { - c, err := newSqliteCache(filename, false) +func newSqliteTestCacheFromFile(t *testing.T, filename, startupQueries string) *messageCache { + c, err := newSqliteCache(filename, startupQueries, false) if err != nil { t.Fatal(err) } diff --git a/server/server.go b/server/server.go index 579c9f9..7026dfb 100644 --- a/server/server.go +++ b/server/server.go @@ -158,7 +158,7 @@ func createMessageCache(conf *Config) (*messageCache, error) { if conf.CacheDuration == 0 { return newNopCache() } else if conf.CacheFile != "" { - return newSqliteCache(conf.CacheFile, false) + return newSqliteCache(conf.CacheFile, conf.CacheStartupQueries, false) } return newMemCache() } diff --git a/server/server.yml b/server/server.yml index 65f1db3..e6d4c9e 100644 --- a/server/server.yml +++ b/server/server.yml @@ -37,14 +37,22 @@ # # firebase-key-file: -# If set, messages are cached in a local SQLite database instead of only in-memory. This -# allows for service restarts without losing messages in support of the since= parameter. +# If "cache-file" is set, messages are cached in a local SQLite database instead of only in-memory. +# This allows for service restarts without losing messages in support of the since= parameter. # # The "cache-duration" parameter defines the duration for which messages will be buffered # before they are deleted. This is required to support the "since=..." and "poll=1" parameter. # To disable the cache entirely (on-disk/in-memory), set "cache-duration" to 0. # The cache file is created automatically, provided that the correct permissions are set. # +# The "cache-startup-queries" parameter allows you to run commands when the database is initialized, +# e.g. to enable WAL mode (see https://phiresky.github.io/blog/2020/sqlite-performance-tuning/)). +# Example: +# cache-startup-queries: | +# pragma journal_mode = WAL; +# pragma synchronous = normal; +# pragma temp_store = memory; +# # Debian/RPM package users: # Use /var/cache/ntfy/cache.db as cache file to avoid permission issues. The package # creates this folder for you. @@ -55,6 +63,7 @@ # # cache-file: # cache-duration: "12h" +# cache-startup-queries: # If set, access to the ntfy server and API can be controlled on a granular level using # the 'ntfy user' and 'ntfy access' commands. See the --help pages for details, or check the docs. diff --git a/server/server_test.go b/server/server_test.go index d3f25bd..b4099cd 100644 --- a/server/server_test.go +++ b/server/server_test.go @@ -1415,6 +1415,7 @@ func TestServer_PublishWhileUpdatingStatsWithLotsOfMessages(t *testing.T) { count := 50000 c := newTestConfig(t) c.TotalTopicLimit = 50001 + c.CacheStartupQueries = "pragma journal_mode = WAL; pragma synchronous = normal; pragma temp_store = memory;" s := newTestServer(t, c) // Add lots of messages