Skip to content

Copy-DbaAgentJob: compare job definitions instead of date_modified for -UseLastModified - #10767

Merged
potatoqualitee merged 9 commits into
dataplat:developmentfrom
mrahman-DBA:Copy-DbaAgentJob_UseLastModified_Streamline
Oct 1, 2026
Merged

potatoqualitee merged 9 commits into
dataplat:developmentfrom
mrahman-DBA:Copy-DbaAgentJob_UseLastModified_Streamline

Conversation

@mrahman-DBA

@mrahman-DBA mrahman-DBA commented Sep 28, 2026 •

Copy link
Copy Markdown
Contributor

Type of Change

  • Bug fix (non-breaking change, fixes # )
  • New feature (non-breaking change, adds functionality, fixes # )
  • Breaking change (affects multiple commands or functionality, fixes # )
  • Ran manual Pester test and has passed (Invoke-ManualPester -Path <command> -ScriptAnalyzer -Compliance)
  • Adding code coverage to existing functionality
  • Pester test is included
  • If new file reference added for test, has is been added to github.com/dataplat/appveyor-lab ?
  • Unit test is included
  • Documentation
  • Build system

Purpose

Copy-DbaAgentJob -UseLastModified decides whether to copy a job by comparing msdb.dbo.sysjobs.date_modified on source and destination. That column is a poor proxy for "has this job changed", for two reasons:

  1. Identical jobs are recreated needlessly. date_modified is stamped with GETDATE() by sp_add_job/sp_update_job and cannot be set through the API, so two jobs with identical definitions that were created independently — the normal state for Availability Group replicas — always carry different timestamps. Upon AG failover, a sync run drops and recreates them, and this will continue to happen after every failover because the target will always have more recent date_modified
  2. Schedule-only changes never propagate. Editing an existing schedule (time, frequency, enabled flag) goes through sp_update_schedule, which stamps sysschedules.date_modified but never touches the job row. After the first sync the destination's date_modified is always later than the source's, so the change is reported as "newer on destination" and skipped forever, unless something else happens to touch the job row.

A secondary issue: the comparison used server-local datetime values, so instances in different time zones compared wall-clock times rather than the same instant.

Approach

-UseLastModified now compares the job definition first and only uses timestamps to decide direction when the definitions actually differ.

  • New private function Get-AgentJobFingerprint (private/functions/) normalises a job's properties, steps and schedules into a deterministic string and returns a SHA256 hash plus the per-section text. It deliberately excludes everything that legitimately differs between independently created copies: job_id, date_created, date_modified, version_number, schedule_id/schedule_uid, originating server, run history, and the job name (already matched by the caller; may differ under -NewName). Job-level IsEnabled is included but kept in its own section.
  • Direction is decided by the job's effective last-modified time: the later of sysjobs.date_modified and the date_modified of every attached schedule, computed server-side and normalised to UTC with DATEPART(TZOFFSET, SYSDATETIMEOFFSET()) so cross-time-zone instances compare correctly.
  • Enabled-only differences are fixed in place with Alter() instead of drop-and-recreate, preserving job_id, history and alert links.
  • -DisableOnDestination is folded into the comparison, so a job deliberately kept disabled on the destination is not reported as drift on every run.
  • -Force with a missing owner is folded in the same way: the source fingerprint is computed with the sa owner the copy will actually produce.
  • SMO objects are refreshed before comparison (job, steps, schedules on both sides, plus the destination job collection once per destination) because dbatools reuses server objects within a session and cached collections would otherwise be compared — and scripted.
  • The output Notes column now states which section differed (job properties, enabled state, steps, schedules), making sync output reviewable at scale.

-UseLastModified behaviour

Situation Before After
Job missing on destination create create (unchanged)
Definitions identical, timestamps differ drop & recreate skip
Only enabled state differs, source not older drop & recreate update flag in place
Definition differs, source newer drop & recreate drop & recreate (unchanged)
Definition differs, timestamps equal skip drop & recreate (source wins)
Definition differs, destination newer skip with warning skip with warning (unchanged)
Schedule-only edit on source skipped as "newer on destination" drop & recreate

The equal-timestamp case changed because timestamps only tie in practice when the job was copied, and the definitions are now known to differ.

Commands to test

  • Copy-DbaAgentJob with -UseLastModified
  • Copy-DbaAgentJob without -UseLastModified (regression: behaviour should be identical to current)

Tests

tests/Copy-DbaAgentJob.Tests.ps1, context "UseLastModified parameter", needs updating to accommodate the changed -UseLastModified behaviour:

  • It "skips job when dates are equal" asserts Notes -BeLike "*same modification date*"; the new skip reason is "Job definition is identical on source and destination". The context's BeforeAll also updates msdb.dbo.sysjobs.date_modified directly on the destination to fake equal timestamps, which is no longer necessary — the job is skipped because the definitions match, regardless of timestamps.
  • It "updates job when source is newer" passes as-is.
  • New behaviours not yet covered: identical definitions with differing timestamps → Skipped; enabled-only change → Successful with unchanged job_id; schedule-only change via sp_update_schedule on the source → Successful and schedule updated on destination; definition change on the destination only → Skipped with the "newer on destination" warning; -DisableOnDestination with -UseLastModified → Skipped/identical on the second run.

Internal function. Builds a content fingerprint of a SQL Agent job so two jobs can be compared by definition rather than by date_modified.

Normalises the job properties, steps and schedules into a deterministic string and returns a SHA256 hash of it, plus the per-section text so callers can report which part differs.

Deliberately excluded because they differ between instances even when the definition is identical: job_id, date_created, date_modified, version_number, schedule_id, schedule_uid, originating server, run history/status, and the job name (the caller already matched by name and may be using -NewName).

Job-level IsEnabled is part of the fingerprint but kept in its own section so the caller can align it in place rather than recreating the job when nothing else differs.
Compares the job definition on source and destination - job properties, enabled state, steps and schedules - and only copies when they actually differ.

When the definitions differ, the direction is decided by each job's effective last-modified time: the later of msdb.dbo.sysjobs.date_modified and the date_modified of every schedule attached to the job (sp_update_schedule only touches sysschedules, not the job row). Both values are normalized to UTC using each server's own time zone offset so instances in different time zones compare correctly:
- Job doesn't exist on destination: creates it
- Definitions identical: skips, regardless of timestamps
- Only the enabled state differs and source is not older: updates the flag in place without recreating the job
- Definitions differ and source is newer (or equal): drops and recreates the job
- Definitions differ and destination is newer: skips with a warning

Job IDs, timestamps, version numbers, schedule IDs/UIDs and run history are excluded from the comparison, so jobs that are identical but were created independently (for example on AG replicas) are not needlessly recreated.

Use this for incremental synchronization scenarios where you want to keep jobs up-to-date without unconditionally overwriting them.
$refreshedDestinations = @{} added at the end of begin {}, and the unconditional $destServer.JobServer.Jobs.Refresh() at the top of the destination loop is now wrapped in the if ($UseLastModified -and -not $refreshedDestinations.ContainsKey(...)) guard
@andreasjordan

Copy link
Copy Markdown
Collaborator

Thanks for this - comparing the job definition instead of date_modified is the right direction, and the schedule-only case (sp_update_schedule never touching sysjobs) is a good catch. The $MaintenancePlanNam typo fix is welcome too.

A few points before this can go in:

Must fix

  1. Tests. tests/Copy-DbaAgentJob.Tests.ps1 still expects *same modification date* in the "skips job when dates are equal" test, so it will fail - please update it in this PR rather than leaving it for later. The new behaviours also need coverage, at least:
    • identical definitions with different timestamps -> Skipped
    • enabled-state-only difference -> Successful, job_id unchanged
    • schedule-only change via sp_update_schedule on the source -> job copied
  2. SQL Server 2000/2005 regression. SYSDATETIMEOFFSET() and DATEPART(TZOFFSET, ...) need SQL Server 2008+. On 2005 the query throws, the catch block runs, and every existing job is skipped with a warning; 2000 additionally has no msdb.dbo.sysschedules. The old code worked there. DATEDIFF(MINUTE, GETUTCDATE(), GETDATE()) gives the offset on 2000+, and on 2000 the query could fall back to sysjobs.date_modified only.
  3. -DisableOnSource is skipped on some paths. The "identical" and "newer on destination" paths continue before the $DisableOnSource block, while the enabled-only path handles it inline. The old "dates equal" path had the same gap, but the behaviour is now inconsistent between paths.

Smaller points

  1. Schedules are sorted by Name only. A job can have two schedules with the same name, and Sort-Object in Windows PowerShell 5.1 is not guaranteed to be stable, so the same job can produce different fingerprints and be recreated needlessly. Sorting by the full schedule text avoids that.
  2. The UTC conversion uses the current offset, not the one at modification time, so across a DST switch the value is off by an hour. This only matters across time zones - a note in the help is enough.
  3. The source job is refreshed only inside the comparison branch; the earlier database/owner/proxy/operator checks still run on the cached object.

Style (see CLAUDE.md)

  • The two Invoke-DbaQuery calls for $sqlLastModified pass four parameters inline - please use a splat (the old code did).
  • Double quotes for strings: 'IsEnabled', 'x2', '', 'yyyyMMdd', ['OwnerLoginName'], ['IsEnabled'].
  • Get-AgentJobFingerprint.ps1 has no trailing newline.

This text was created by Claude and reviewed by Andreas Jordan.

Schedule names are not unique per job and Sort-Object is not
guaranteed to be stable in Windows PowerShell, so two schedules with the same name could produce different fingerprints for the same job and trigger a needless recreate. Sorting the normalised schedule text makes the fingerprint deterministic.
- Timestamp query now uses DATEDIFF against GETUTCDATE and falls back to sysjobs.date_modified on SQL Server 2000, restoring 2000/2005 support lost to SYSDATETIMEOFFSET
- Identical and enabled-only paths fall through to the common tail so -DisableOnSource applies consistently; documented in help
- Source job refreshed before dependency checks, not only at comparison
- DST limitation of the UTC offset noted in help
- Splatted the timestamp queries; double quotes for string literals
…ison

The "skips job when dates are equal" test asserted the old
"same modification date" note and faked equal timestamps with a direct UPDATE of sysjobs.date_modified. Jobs are now skipped because their definitions match, regardless of timestamps, so the test asserts that instead and the UPDATE is removed.

Adds coverage for the new behaviours: identical definitions with
differing timestamps, enabled-only change aligned in place (job_id unchanged), schedule-only change via sp_update_schedule, no churn with -DisableOnDestination, -DisableOnSource on an identical job, and skip with warning when the destination is newer.
@mrahman-DBA

Copy link
Copy Markdown
Contributor Author

The requested changes have been pushed. Please review now

@potatoqualitee potatoqualitee left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Three blocking defects at this exact head:

  1. public/Copy-DbaAgentJob.ps1:438-446,582-586: an enabled-state update failure can still disable the source. With -UseLastModified -DisableOnSource, an enabled source and otherwise identical disabled destination take the in-place Alter() path. If that Alter() throws, the catch emits a Failed result but sets $skipCreate and falls through to the unconditional source-disable block. The operation can leave both jobs disabled despite reporting migration failure, stopping the workload. Continue to the next job after the failed destination update, or gate source disabling on verified successful synchronization.

  2. ests/Copy-DbaAgentJob.Tests.ps1:130-138: the new schedule fixture supplies neither StartDate nor Force. New-DbaAgentSchedule therefore stops with “Please enter a start date or use -Force to use defaults.” The real COPY CI lane fails all seven new UseLastModified cases in setup. Add Force = True or all required schedule fields, then rerun the SQL-backed lane.

  3. ests/Copy-DbaAgentJob.Tests.ps1:318: the expected Notes glob is impossible for the implementation text. The test expects newer on destination, while the command emits Definition differs (...) but destination job is newer than source (...); the literal wildcard comparison is false. Align the assertion or the emitted Notes. Once the fixture is repaired, this otherwise-unreached assertion will still fail.

- Schedule fixture passes StartDate and -Force to New-DbaAgentSchedule
- Destination-newer test asserts the Notes text the command emits
- Cleanup pipes Get-DbaAgentJob into Remove-DbaAgentJob so a failed setup doesn't also fail AfterAll
- Schedule fixture passes StartDate and -Force to New-DbaAgentSchedule
- Destination-newer test asserts the Notes text the command emits
- Cleanup pipes Get-DbaAgentJob into Remove-DbaAgentJob so a failed setup doesn't also fail AfterAll
- Enabled-only path skips to the next job when the in-place Alter() fails, so -DisableOnSource can no longer leave the job disabled on both instances after a reported failure
- Schedule fixture passes StartDate and -Force to New-DbaAgentSchedule
- Destination-newer test asserts the Notes text the command emits
- Cleanup pipes Get-DbaAgentJob into Remove-DbaAgentJob so a failed setup doesn't also fail AfterAll
@mrahman-DBA

Copy link
Copy Markdown
Contributor Author

I have implemented the requested changes and tested them the best I can within my home environment. If any additional issues arise, I will need your assistance to help resolve them

@potatoqualitee potatoqualitee left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed the exact current head. The prior blockers are addressed: an in-place enabled-state failure now exits before the source-disable tail, the SQL-backed schedule fixture supplies valid start/default inputs, and the newer-destination assertion now matches the emitted result. I also checked the definition fingerprint, refresh paths, effective schedule/job timestamps, multi-destination flow, and cleanup/error branches and found no remaining material defect. The SQL-backed ci-azure run is currently queued rather than failed; the completed validation and repository checks are green.

@potatoqualitee

Copy link
Copy Markdown
Member

Thank you 💯

@potatoqualitee
potatoqualitee merged commit 4391a34 into dataplat:development Oct 1, 2026
15 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants