diff --git a/enterprise/backend/test/metabase_enterprise/advanced_permissions/api/setting_test.clj b/enterprise/backend/test/metabase_enterprise/advanced_permissions/api/setting_test.clj index 69eb5807bf0b3278e33e52f8c51f554fdff6a46a..05e413888fff3cb03cd7d67f9a9320d8c7a3d789 100644 --- a/enterprise/backend/test/metabase_enterprise/advanced_permissions/api/setting_test.clj +++ b/enterprise/backend/test/metabase_enterprise/advanced_permissions/api/setting_test.clj @@ -334,8 +334,11 @@ (testing (format "persist/disable with %s user" (mt/user-descriptor user)) (mt/user-http-request user :post status "persist/disable"))) (set-interval [user status] - (testing (format "persist/set-interval with %s user" (mt/user-descriptor user)) - (mt/user-http-request user :post status "persist/set-interval" {"hours" 1})))] + (testing (format "persist/set-refresh-schedule with %s user" + (mt/user-descriptor user)) + (mt/user-http-request user :post status + "persist/set-refresh-schedule" + {"cron" "0 0 0/1 * * ? *"})))] (testing "if `advanced-permissions` is disabled, require admins," (enable-persist :crowberto 204) diff --git a/src/metabase/api/database.clj b/src/metabase/api/database.clj index 5c3558c510cbf8eac8f10b93fcffc55ba36ba4d9..77b3edaca8f0b1a64e88b3c9cbdc9355820483e6 100644 --- a/src/metabase/api/database.clj +++ b/src/metabase/api/database.clj @@ -672,8 +672,7 @@ (assoc (:options database) :persist-models-enabled true)) (task.persist-refresh/schedule-persistence-for-database! database - (public-settings/persisted-model-refresh-interval-hours) - (public-settings/persisted-model-refresh-anchor-time)) + (public-settings/persisted-model-refresh-cron-schedule)) api/generic-204-no-content) (throw (ex-info (ddl.i/error->message error schema) {:error error diff --git a/src/metabase/api/persist.clj b/src/metabase/api/persist.clj index 87bb927ee37b639af8375c7754ceadae37d5af30..4cfdf749925abd889c5c5983f710c6291813af87 100644 --- a/src/metabase/api/persist.clj +++ b/src/metabase/api/persist.clj @@ -86,36 +86,28 @@ (api/write-check (Database (:database_id persisted-info))) persisted-info)) -(def ^:private HoursInterval - "Schema representing valid interval hours for refreshing persisted models." - (su/with-api-error-message - (s/constrained s/Int #(<= 1 % 24) - (deferred-tru "Integer greater than or equal to one and less than or equal to twenty-four")) - (deferred-tru "Value must be an integer representing hours greater than or equal to one and less than or equal to twenty-four"))) - -(def ^:private AnchorTime - "Schema representing valid anchor time for refreshing persisted models." +(def ^:private CronSchedule + "Schema representing valid cron schedule for refreshing persisted models." (su/with-api-error-message (s/constrained s/Str (fn [t] - (let [[hours minutes] (map parse-long (str/split t #":"))] - (and (<= 0 hours 23) (<= 0 minutes 59)))) - (deferred-tru "String representing a time in format HH:mm")) - (deferred-tru "Value must be a string representing a time in format HH:mm"))) + (let [parts (str/split t #" ")] + (= 7 (count parts)))) + (deferred-tru "String representing a cron schedule")) + (deferred-tru "Value must be a string representing a cron schedule of format <seconds> <minutes> <hours> <day of month> <month> <day of week> <year>"))) -(api/defendpoint POST "/set-interval" - "Set the interval (in hours) to refresh persisted models. - Anchor can be provided to set the time to begin the interval (local to reporting-timezone or system). - Shape should be JSON like {hours: 4, anchor: 16:45}." - [:as {{:keys [hours anchor], :as _body} :body}] - {hours (s/maybe HoursInterval) - anchor (s/maybe AnchorTime)} +(api/defendpoint POST "/set-refresh-schedule" + "Set the cron schedule to refresh persisted models. + Shape should be JSON like {cron: \"0 30 1/8 * * ? *\"}." + [:as {{:keys [cron], :as _body} :body}] + {cron CronSchedule} (validation/check-has-application-permission :setting) - (when hours - (public-settings/persisted-model-refresh-interval-hours! hours)) - (if (and hours (< hours 6)) - (public-settings/persisted-model-refresh-anchor-time! "00:00") - (when anchor - (public-settings/persisted-model-refresh-anchor-time! anchor))) + (when cron + (when-not (and (string? cron) + (org.quartz.CronExpression/isValidExpression cron) + (str/ends-with? cron "*")) + (throw (ex-info (tru "Must be a valid cron string not specifying a year") + {:status-code 400}))) + (public-settings/persisted-model-refresh-cron-schedule! cron)) (task.persist-refresh/reschedule-refresh!) api/generic-204-no-content) diff --git a/src/metabase/public_settings.clj b/src/metabase/public_settings.clj index 07060ca0edaf97f40fb001920a3feecba7f58606..aaf432a40670351c5a4927390895e83bba10144f 100644 --- a/src/metabase/public_settings.clj +++ b/src/metabase/public_settings.clj @@ -213,16 +213,10 @@ :default false :visibility :authenticated) -(defsetting persisted-model-refresh-interval-hours - (deferred-tru "Hour interval to refresh persisted models.") - :type :integer - :default 6 - :visibility :admin) - -(defsetting persisted-model-refresh-anchor-time - (deferred-tru "Anchor time to begin refreshing persisted models.") +(defsetting persisted-model-refresh-cron-schedule + (deferred-tru "cron syntax string to schedule refreshing persisted models.") :type :string - :default "00:00" + :default "0 0 0/6 * * ? *" :visibility :admin) (def ^:private ^:const global-max-caching-kb diff --git a/src/metabase/task/persist_refresh.clj b/src/metabase/task/persist_refresh.clj index 6ea669e7fe832b4281fba2d0f7275c53f3ee1420..987f2826858fb4324e44d7e501ea033d9005530b 100644 --- a/src/metabase/task/persist_refresh.clj +++ b/src/metabase/task/persist_refresh.clj @@ -253,16 +253,23 @@ (defn- cron-schedule "Return a cron schedule that fires every `hours` hours." - [hours anchor-time] - (let [[start-hour start-minute] (map parse-long (str/split anchor-time #":"))] - (cron/schedule - (cron/cron-schedule (if (= 24 hours) + [cron-spec] + (cron/schedule + (cron/cron-schedule cron-spec) + (cron/in-time-zone (TimeZone/getTimeZone (or (driver/report-timezone) + (qp.timezone/system-timezone-id) + "UTC"))) + (cron/with-misfire-handling-instruction-do-nothing))) + +(comment + (let [[start-hour start-minute] (map parse-long (str/split "00:00" #":")) + hours 1] + + (if (= 24 hours) (format "0 %d %d * * ? *" start-minute start-hour) (format "0 %d %d/%d * * ? *" start-minute start-hour hours))) - (cron/in-time-zone (TimeZone/getTimeZone (or (driver/report-timezone) - (qp.timezone/system-timezone-id) - "UTC"))) - (cron/with-misfire-handling-instruction-do-nothing)))) + + ) (def ^:private prune-scheduled-trigger (triggers/build @@ -271,7 +278,7 @@ (triggers/for-job (jobs/key prune-job-key)) (triggers/start-now) (triggers/with-schedule - (cron-schedule 1 "00:00")))) + (cron-schedule "0 0 0/1 * * ? *")))) (def ^:private prune-once-trigger (triggers/build @@ -280,7 +287,7 @@ (triggers/for-job (jobs/key prune-job-key)) (triggers/start-now))) -(defn- database-trigger [database interval-hours anchor-time] +(defn- database-trigger [database cron-spec] (triggers/build (triggers/with-description (format "Refresh models for database %d" (u/the-id database))) (triggers/with-identity (database-trigger-key database)) @@ -289,7 +296,7 @@ (triggers/for-job (jobs/key refresh-job-key)) (triggers/start-now) (triggers/with-schedule - (cron-schedule interval-hours anchor-time)))) + (cron-schedule cron-spec)))) (defn- individual-trigger [persisted-info] (triggers/build @@ -304,8 +311,8 @@ (defn schedule-persistence-for-database! "Schedule a database for persistence refreshing." - [database interval-hours anchor-time] - (let [tggr (database-trigger database interval-hours anchor-time)] + [database cron-spec] + (let [tggr (database-trigger database cron-spec)] (log/info (u/format-color 'green "Scheduling persistence refreshes for database %d: trigger: %s" @@ -360,14 +367,13 @@ (defn reschedule-refresh! "Reschedule refresh for all enabled databases. Removes all existing triggers, and schedules refresh for databases with - `:persist-models-enabled` in the options at interval [[public-settings/persisted-model-refresh-interval-hours]]." + `:persist-models-enabled` in the options at interval [[public-settings/persisted-model-refresh-cron-schedule]]." [] (let [dbs-with-persistence (filter (comp :persist-models-enabled :options) (Database)) - interval-hours (public-settings/persisted-model-refresh-interval-hours) - anchor-time (public-settings/persisted-model-refresh-anchor-time)] + cron-schedule (public-settings/persisted-model-refresh-cron-schedule)] (unschedule-all-refresh-triggers! refresh-job-key) (doseq [db dbs-with-persistence] - (schedule-persistence-for-database! db interval-hours anchor-time)))) + (schedule-persistence-for-database! db cron-schedule)))) (defn enable-persisting! "Enable persisting diff --git a/test/metabase/api/persist_test.clj b/test/metabase/api/persist_test.clj new file mode 100644 index 0000000000000000000000000000000000000000..469fff8d64778a4a3dddd197077c6e48f0179589 --- /dev/null +++ b/test/metabase/api/persist_test.clj @@ -0,0 +1,44 @@ +(ns metabase.api.persist-test + (:require [clojure.test :refer :all] + [metabase.models.database :refer [Database]] + [metabase.task.persist-refresh :as task.persist-refresh] + [metabase.test :as mt] + [metabase.test.fixtures :as fixtures])) + +(use-fixtures :once (fixtures/initialize :db :test-users)) + +(def ^:private default-cron "0 0 0/12 * * ? *") + +(defn- do-with-setup [f] + (mt/with-temp-scheduler + (#'task.persist-refresh/job-init!) + (mt/with-temporary-setting-values [:persisted-models-enabled true] + (mt/with-temp* [Database [db {:options {:persist-models-enabled true}}]] + (task.persist-refresh/schedule-persistence-for-database! db default-cron) + (f db))))) + +(defmacro ^:private with-setup + "Sets up a temp scheduler, a temp database and enabled persistence" + [db-binding & body] + `(do-with-setup (fn [~db-binding] ~@body))) + +(deftest set-refresh-schedule-test + (testing "Setting new cron schedule reschedules refresh tasks" + (with-setup db + (is (= default-cron (get-in (task.persist-refresh/job-info-by-db-id) + [(:id db) :schedule]))) + (let [new-schedule "0 0 0/12 * * ? *"] + (mt/user-http-request :crowberto :post 204 "persist/set-refresh-schedule" + {:cron new-schedule}) + (is (= new-schedule + (get-in (task.persist-refresh/job-info-by-db-id) + [(:id db) :schedule])))))) + (testing "Prevents setting a year value" + (with-setup db + (let [bad-schedule "0 0 0/12 * * ? 1995"] + (is (= "Must be a valid cron string not specifying a year" + (mt/user-http-request :crowberto :post 400 "persist/set-refresh-schedule" + {:cron bad-schedule}))) + (is (= default-cron + (get-in (task.persist-refresh/job-info-by-db-id) + [(:id db) :schedule]))))))) diff --git a/test/metabase/task/persist_refresh_test.clj b/test/metabase/task/persist_refresh_test.clj index 26b8005a3f033dfba5633b6ab67fb9f52f5c3b2b..6170a82723ac22e46e0ab40ff17ec71313589930 100644 --- a/test/metabase/task/persist_refresh_test.clj +++ b/test/metabase/task/persist_refresh_test.clj @@ -23,44 +23,44 @@ (deftest cron-schedule-test (testing "creates schedule per hour when less than 24 hours" (is (= "0 0 0/8 * * ? *" - (schedule-string (#'pr/cron-schedule 8 "00:00")))) + (schedule-string (#'pr/cron-schedule "0 0 0/8 * * ? *")))) (testing "when anchored" (is (= "0 30 1/8 * * ? *" - (schedule-string (#'pr/cron-schedule 8 "01:30")))))) + (schedule-string (#'pr/cron-schedule "0 30 1/8 * * ? *")))))) (testing "creates schedule string per day when 24 hours" (is (= "0 0 0 * * ? *" - (schedule-string (#'pr/cron-schedule 24 "00:00")))) + (schedule-string (#'pr/cron-schedule "0 0 0 * * ? *")))) (testing "when anchored" (is (= "0 30 1 * * ? *" - (schedule-string (#'pr/cron-schedule 24 "01:30"))))))) + (schedule-string (#'pr/cron-schedule "0 30 1 * * ? *"))))))) (deftest trigger-job-info-test (testing "Database refresh trigger" - (let [tggr (#'pr/database-trigger {:id 1} 5 "00:00")] + (let [tggr (#'pr/database-trigger {:id 1} "0 0 0/5 * * ? *")] (is (= {"db-id" 1 "type" "database"} (qc/from-job-data (.getJobDataMap tggr)))) (is (= "0 0 0/5 * * ? *" (schedule-string tggr))) (is (= "metabase.task.PersistenceRefresh.database.trigger.1" (.. tggr getKey getName)))) - (let [tggr (#'pr/database-trigger {:id 1} 24 "00:00")] + (let [tggr (#'pr/database-trigger {:id 1} "0 0 0 * * ? *")] (is (= {"db-id" 1 "type" "database"} (qc/from-job-data (.getJobDataMap tggr)))) (is (= "0 0 0 * * ? *" (schedule-string tggr)))) (testing "in report timezone UTC" (mt/with-temporary-setting-values [report-timezone "UTC"] - (let [tggr (#'pr/database-trigger {:id 1} 5 "00:00")] + (let [tggr (#'pr/database-trigger {:id 1} "0 0 0/5 * * ? *")] (is (= "UTC" (.. tggr getTimeZone getID)))))) (testing "in report timezone LA" (mt/with-temporary-setting-values [report-timezone "America/Los_Angeles"] - (let [tggr (#'pr/database-trigger {:id 1} 5 "00:00")] + (let [tggr (#'pr/database-trigger {:id 1} "0 0 0/5 * * ? *")] (is (= "America/Los_Angeles" (.. tggr getTimeZone getID)))))) (testing "in system timezone" (mt/with-temporary-setting-values [report-timezone nil] - (let [tggr (#'pr/database-trigger {:id 1} 5 "00:00")] + (let [tggr (#'pr/database-trigger {:id 1} "0 0 0/5 * * ? *")] (is (= (qp.timezone/system-timezone-id) (.. tggr getTimeZone getID))))))) (testing "Individual refresh trigger" @@ -82,8 +82,7 @@ (mt/with-temp* [Database [db-1 {:options {:persist-models-enabled true}}] Database [db-2 {:options {:persist-models-enabled true}}]] (#'pr/job-init!) - (mt/with-temporary-setting-values [persisted-model-refresh-interval-hours 4 - persisted-model-refresh-anchor-time "00:00"] + (mt/with-temporary-setting-values [persisted-model-refresh-cron-schedule "0 0 0/4 * * ? *"] (pr/reschedule-refresh!) (is (= {(u/the-id db-1) {:data {"db-id" (u/the-id db-1) "type" "database"} :schedule "0 0 0/4 * * ? *" @@ -92,8 +91,7 @@ :schedule "0 0 0/4 * * ? *" :key (format "metabase.task.PersistenceRefresh.database.trigger.%d" (u/the-id db-2))}} (job-info db-1 db-2)))) - (mt/with-temporary-setting-values [persisted-model-refresh-interval-hours 8 - persisted-model-refresh-anchor-time "00:00"] + (mt/with-temporary-setting-values [persisted-model-refresh-cron-schedule "0 0 0/8 * * ? *"] (pr/reschedule-refresh!) (is (= {(u/the-id db-1) {:data {"db-id" (u/the-id db-1) "type" "database"} :schedule "0 0 0/8 * * ? *" @@ -102,8 +100,7 @@ :schedule "0 0 0/8 * * ? *" :key (format "metabase.task.PersistenceRefresh.database.trigger.%d" (u/the-id db-2))}} (job-info db-1 db-2)))) - (mt/with-temporary-setting-values [persisted-model-refresh-interval-hours 8 - persisted-model-refresh-anchor-time "01:30"] + (mt/with-temporary-setting-values [persisted-model-refresh-cron-schedule "0 30 1/8 * * ? *"] (pr/reschedule-refresh!) (is (= {(u/the-id db-1) {:data {"db-id" (u/the-id db-1) "type" "database"} :schedule "0 30 1/8 * * ? *"