diff --git a/surfsense_backend/alembic/versions/144_add_automation_tables.py b/surfsense_backend/alembic/versions/144_add_automation_tables.py index 8b59ee969..6daf4075f 100644 --- a/surfsense_backend/alembic/versions/144_add_automation_tables.py +++ b/surfsense_backend/alembic/versions/144_add_automation_tables.py @@ -89,6 +89,7 @@ def upgrade() -> None: params JSONB NOT NULL, enabled BOOLEAN NOT NULL DEFAULT true, last_fired_at TIMESTAMP WITH TIME ZONE, + next_fire_at TIMESTAMP WITH TIME ZONE, created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW() ); """ @@ -105,6 +106,17 @@ def upgrade() -> None: op.execute( "CREATE INDEX ix_automation_triggers_created_at ON automation_triggers(created_at);" ) + # Partial index for the schedule tick: only enabled schedule triggers + # with a scheduled next fire are ever scanned for due rows. + op.execute( + """ + CREATE INDEX ix_automation_triggers_due + ON automation_triggers (next_fire_at) + WHERE enabled = true + AND type = 'schedule' + AND next_fire_at IS NOT NULL; + """ + ) # automation_runs — the immutable per-fire execution record op.execute( @@ -148,6 +160,7 @@ def downgrade() -> None: op.execute("DROP INDEX IF EXISTS ix_automation_runs_automation_id;") op.execute("DROP TABLE IF EXISTS automation_runs;") + op.execute("DROP INDEX IF EXISTS ix_automation_triggers_due;") op.execute("DROP INDEX IF EXISTS ix_automation_triggers_created_at;") op.execute("DROP INDEX IF EXISTS ix_automation_triggers_enabled;") op.execute("DROP INDEX IF EXISTS ix_automation_triggers_type;") diff --git a/surfsense_backend/app/automations/persistence/models/trigger.py b/surfsense_backend/app/automations/persistence/models/trigger.py index 7582234d4..b09bc3419 100644 --- a/surfsense_backend/app/automations/persistence/models/trigger.py +++ b/surfsense_backend/app/automations/persistence/models/trigger.py @@ -46,6 +46,11 @@ class AutomationTrigger(BaseModel, TimestampMixin): last_fired_at = Column(TIMESTAMP(timezone=True), nullable=True) + # Precomputed next fire moment in UTC; advanced after each fire by the + # schedule tick. NULL means the trigger has never been scheduled (the + # tick self-heals on first sight). Manual triggers leave this NULL. + next_fire_at = Column(TIMESTAMP(timezone=True), nullable=True) + automation = relationship("Automation", back_populates="triggers") runs = relationship( "AutomationRun", diff --git a/surfsense_backend/pyproject.toml b/surfsense_backend/pyproject.toml index 71c53caae..2ed0acca4 100644 --- a/surfsense_backend/pyproject.toml +++ b/surfsense_backend/pyproject.toml @@ -87,6 +87,7 @@ dependencies = [ "opentelemetry-instrumentation-httpx>=0.61b0", "opentelemetry-instrumentation-celery>=0.61b0", "opentelemetry-instrumentation-logging>=0.61b0", + "croniter>=2.0.0", ] [dependency-groups] diff --git a/surfsense_backend/uv.lock b/surfsense_backend/uv.lock index b902363dc..ba88153c5 100644 --- a/surfsense_backend/uv.lock +++ b/surfsense_backend/uv.lock @@ -1265,6 +1265,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/8e/ca/6a667ccbe649856dcd3458bab80b016681b274399d6211187c6ab969fc50/courlan-1.3.2-py3-none-any.whl", hash = "sha256:d0dab52cf5b5b1000ee2839fbc2837e93b2514d3cb5bb61ae158a55b7a04c6be", size = 33848, upload-time = "2024-10-29T16:40:18.325Z" }, ] +[[package]] +name = "croniter" +version = "6.2.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "python-dateutil" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/df/de/5832661ed55107b8a09af3f0a2e71e0957226a59eb1dcf0a445cce6daf20/croniter-6.2.2.tar.gz", hash = "sha256:ba60832a5ec8e12e51b8691c3309a113d1cf6526bdf1a48150ce8ec7a532d0ab", size = 113762, upload-time = "2026-03-15T08:43:48.112Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d0/39/783980e78cb92c2d7bdb1fc7dbc86e94ccc6d58224d76a7f1f51b6c51e30/croniter-6.2.2-py3-none-any.whl", hash = "sha256:a5d17b1060974d36251ea4faf388233eca8acf0d09cbd92d35f4c4ac8f279960", size = 45422, upload-time = "2026-03-15T08:43:46.626Z" }, +] + [[package]] name = "cryptography" version = "46.0.6" @@ -8132,6 +8144,7 @@ dependencies = [ { name = "celery", extra = ["redis"] }, { name = "chonkie", extra = ["all"] }, { name = "composio" }, + { name = "croniter" }, { name = "datasets" }, { name = "daytona" }, { name = "deepagents" }, @@ -8228,6 +8241,7 @@ requires-dist = [ { name = "celery", extras = ["redis"], specifier = ">=5.5.3" }, { name = "chonkie", extras = ["all"], specifier = ">=1.5.0" }, { name = "composio", specifier = ">=0.10.9" }, + { name = "croniter", specifier = ">=2.0.0" }, { name = "datasets", specifier = ">=2.21.0" }, { name = "daytona", specifier = ">=0.146.0" }, { name = "deepagents", specifier = ">=0.4.12,<0.5" },