[IMP] *: trigger-ify merge cron

The merge cron is the one in charge of checking staging state and
either integrating the staging into the reference branch (if
successful) or cancelling the staging (if failed).

The most obvious trigger for the merge cron is a change in staging
state from the states computation (transition from pending to either
success or failure). Explicitly cancelling / failing a staging marks
it as inactive so the merge cron isn't actually needed.

However an other major trigger is *timeout*, which doesn't have a
trivial signal. Instead, it needs to be hooked on the `timeout_limit`,
and has to be re-triggered at every update to the `timeout_limit`,
which in normal operations is mostly from "pending" statuses bumping
the timeout limit. In that case, `_trigger` to the `timeout_limit` as
that's where / when we expect a status change.

Worst case scenario with this is we have parasitic wakeups of this
cron, but having half a dozen wakeups unnecessary wakeups in an hour
is still probably better than having a wakeup every minute.
This commit is contained in:
Xavier Morel 2024-07-31 09:40:53 +02:00
parent 029957dbeb
commit f367a64481
7 changed files with 99 additions and 79 deletions

View File

@ -7,7 +7,6 @@ import requests
@pytest.fixture @pytest.fixture
def default_crons(): def default_crons():
return [ return [
'runbot_merge.merge_cron',
'runbot_merge.staging_cron', 'runbot_merge.staging_cron',
'runbot_merge.check_linked_prs_status', 'runbot_merge.check_linked_prs_status',
] ]

View File

@ -4,8 +4,8 @@
<field name="model_id" ref="model_runbot_merge_project"/> <field name="model_id" ref="model_runbot_merge_project"/>
<field name="state">code</field> <field name="state">code</field>
<field name="code">model._check_stagings(True)</field> <field name="code">model._check_stagings(True)</field>
<field name="interval_number">1</field> <field name="interval_number">6</field>
<field name="interval_type">minutes</field> <field name="interval_type">hours</field>
<field name="numbercall">-1</field> <field name="numbercall">-1</field>
<field name="doall" eval="False"/> <field name="doall" eval="False"/>
<field name="priority">30</field> <field name="priority">30</field>

View File

@ -2062,16 +2062,25 @@ class Stagings(models.Model):
for context, status in statuses.get(commit.sha, {}).items() for context, status in statuses.get(commit.sha, {}).items()
] ]
def write(self, vals):
if timeout := vals.get('timeout_limit'):
self.env.ref("runbot_merge.merge_cron")\
._trigger(fields.Datetime.to_datetime(timeout))
return super().write(vals)
# only depend on staged_at as it should not get modified, but we might # only depend on staged_at as it should not get modified, but we might
# update the CI timeout after the staging have been created and we # update the CI timeout after the staging have been created and we
# *do not* want to update the staging timeouts in that case # *do not* want to update the staging timeouts in that case
@api.depends('staged_at') @api.depends('staged_at')
def _compute_timeout_limit(self): def _compute_timeout_limit(self):
timeouts = set()
for st in self: for st in self:
st.timeout_limit = fields.Datetime.to_string( t = st.timeout_limit = st.staged_at + datetime.timedelta(minutes=st.target.project_id.ci_timeout)
fields.Datetime.from_string(st.staged_at) timeouts.add(t)
+ datetime.timedelta(minutes=st.target.project_id.ci_timeout) if timeouts:
) # we might have very different limits for each staging so need to schedule them all
self.env.ref("runbot_merge.merge_cron")._trigger_list(timeouts)
@api.depends('batch_ids.prs') @api.depends('batch_ids.prs')
def _compute_prs(self): def _compute_prs(self):
@ -2147,9 +2156,11 @@ class Stagings(models.Model):
s.state = st s.state = st
if s.state != 'pending': if s.state != 'pending':
self.env.ref("runbot_merge.merge_cron")._trigger()
s.staging_end = fields.Datetime.now() s.staging_end = fields.Datetime.now()
if update_timeout_limit: if update_timeout_limit:
s.timeout_limit = fields.Datetime.to_string(datetime.datetime.now() + datetime.timedelta(minutes=s.target.project_id.ci_timeout)) s.timeout_limit = datetime.datetime.now() + datetime.timedelta(minutes=s.target.project_id.ci_timeout)
self.env.ref("runbot_merge.merge_cron")._trigger(s.timeout_limit)
_logger.debug("%s got pending status, bumping timeout to %s (%s)", self, s.timeout_limit, cmap) _logger.debug("%s got pending status, bumping timeout to %s (%s)", self, s.timeout_limit, cmap)
def action_cancel(self): def action_cancel(self):

View File

@ -7,8 +7,6 @@ def module():
@pytest.fixture @pytest.fixture
def default_crons(): def default_crons():
return [ return [
# env['runbot_merge.project']._check_stagings()
'runbot_merge.merge_cron',
# env['runbot_merge.project']._create_stagings() # env['runbot_merge.project']._create_stagings()
'runbot_merge.staging_cron', 'runbot_merge.staging_cron',
# env['runbot_merge.pull_requests']._check_linked_prs_statuses() # env['runbot_merge.pull_requests']._check_linked_prs_statuses()

View File

@ -3,6 +3,7 @@ import itertools
import json import json
import textwrap import textwrap
import time import time
from typing import Callable
from unittest import mock from unittest import mock
import pytest import pytest
@ -13,8 +14,9 @@ import odoo
from utils import _simple_init, seen, matches, get_partner, Commit, pr_page, to_pr, part_of, ensure_one from utils import _simple_init, seen, matches, get_partner, Commit, pr_page, to_pr, part_of, ensure_one
@pytest.fixture(autouse=True) @pytest.fixture(autouse=True)
def _configure_statuses(project, repo): def _configure_statuses(request, project, repo):
project.repo_ids.required_statuses = 'legal/cla,ci/runbot' if 'defaultstatuses' not in request.keywords:
project.repo_ids.required_statuses = 'legal/cla,ci/runbot'
@pytest.fixture(autouse=True, params=["statuses", "rpc"]) @pytest.fixture(autouse=True, params=["statuses", "rpc"])
def stagings(request, env, project, repo): def stagings(request, env, project, repo):
@ -562,19 +564,32 @@ def test_staging_conflict_second(env, repo, users, config):
assert pr1_id.state == 'error', "now pr1 should be in error" assert pr1_id.state == 'error', "now pr1 should be in error"
def test_staging_ci_timeout(env, repo, config, page): @pytest.mark.defaultstatuses
@pytest.mark.parametrize('update_op', [
pytest.param(
lambda _: {'timeout_limit': datetime.datetime.now().isoformat(" ", "seconds")},
id="set-timeout-limit",
),
pytest.param(
lambda timeout: {'staged_at': (datetime.datetime.now() - datetime.timedelta(minutes=2*timeout)).isoformat(" ", "seconds")},
id="set-staged-at",
),
])
def test_staging_ci_timeout(env, repo, config, page, update_op: Callable[[int], dict]):
"""If a staging timeouts (~ delay since staged greater than """If a staging timeouts (~ delay since staged greater than
configured)... requeue? configured)... requeue?
""" """
with repo: with repo:
m = repo.make_commit(None, 'initial', None, tree={'f': 'm'}) m, _, c2 = repo.make_commits(
None,
Commit('initial', tree={'f': 'm'}),
Commit('first', tree={'f': 'c1'}),
Commit('second', tree={'f': 'c2'}),
)
repo.make_ref('heads/master', m) repo.make_ref('heads/master', m)
c1 = repo.make_commit(m, 'first', None, tree={'f': 'c1'})
c2 = repo.make_commit(c1, 'second', None, tree={'f': 'c2'})
pr = repo.make_pr(title='title', body='body', target='master', head=c2) pr = repo.make_pr(title='title', body='body', target='master', head=c2)
repo.post_status(pr.head, 'success', 'ci/runbot') repo.post_status(pr.head, 'success')
repo.post_status(pr.head, 'success', 'legal/cla')
pr.post_comment('hansen r+ rebase-merge', config['role_reviewer']['token']) pr.post_comment('hansen r+ rebase-merge', config['role_reviewer']['token'])
env.run_crons() env.run_crons()
@ -582,23 +597,26 @@ def test_staging_ci_timeout(env, repo, config, page):
assert pr_id.staging_id assert pr_id.staging_id
timeout = env['runbot_merge.project'].search([]).ci_timeout timeout = env['runbot_merge.project'].search([]).ci_timeout
pr_id.staging_id.staged_at = odoo.fields.Datetime.to_string(datetime.datetime.now() - datetime.timedelta(minutes=2*timeout)) pr_id.staging_id.write(update_op(timeout))
env.run_crons('runbot_merge.merge_cron', 'runbot_merge.staging_cron') env.run_crons('runbot_merge.staging_cron')
assert pr_id.state == 'error', "timeout should fail the PR" assert pr_id.state == 'error', "timeout should fail the PR"
dangerbox = pr_page(page, pr).cssselect('.alert-danger span') dangerbox = pr_page(page, pr).cssselect('.alert-danger span')
assert dangerbox assert dangerbox
assert dangerbox[0].text == 'timed out (>60 minutes)' assert dangerbox[0].text == 'timed out (>60 minutes)'
@pytest.mark.defaultstatuses
def test_timeout_bump_on_pending(env, repo, config): def test_timeout_bump_on_pending(env, repo, config):
with repo: with repo:
m = repo.make_commit(None, 'initial', None, tree={'f': '0'}) [m, c] = repo.make_commits(
None,
Commit('initial', tree={'f': '0'}),
Commit('c', tree={'f': '1'}),
)
repo.make_ref('heads/master', m) repo.make_ref('heads/master', m)
c = repo.make_commit(m, 'c', None, tree={'f': '1'})
prx = repo.make_pr(title='title', body='body', target='master', head=c) prx = repo.make_pr(title='title', body='body', target='master', head=c)
repo.post_status(prx.head, 'success', 'ci/runbot') repo.post_status(prx.head, 'success')
repo.post_status(prx.head, 'success', 'legal/cla')
prx.post_comment('hansen r+', config['role_reviewer']['token']) prx.post_comment('hansen r+', config['role_reviewer']['token'])
env.run_crons() env.run_crons()
@ -606,7 +624,7 @@ def test_timeout_bump_on_pending(env, repo, config):
old_timeout = odoo.fields.Datetime.to_string(datetime.datetime.now() - datetime.timedelta(days=15)) old_timeout = odoo.fields.Datetime.to_string(datetime.datetime.now() - datetime.timedelta(days=15))
st.timeout_limit = old_timeout st.timeout_limit = old_timeout
with repo: with repo:
repo.post_status('staging.master', 'pending', 'ci/runbot') repo.post_status('staging.master', 'pending')
env.run_crons(None) env.run_crons(None)
assert st.timeout_limit > old_timeout assert st.timeout_limit > old_timeout
@ -1092,63 +1110,63 @@ def test_reopen_merged_pr(env, repo, config, users):
] ]
class TestNoRequiredStatus: class TestNoRequiredStatus:
@pytest.mark.defaultstatuses
def test_basic(self, env, repo, config): def test_basic(self, env, repo, config):
""" check that mergebot can work on a repo with no CI at all """ check that mergebot can work on a repo with no CI at all
""" """
env['runbot_merge.repository'].search([('name', '=', repo.name)]).status_ids = False env['runbot_merge.repository'].search([('name', '=', repo.name)]).status_ids = False
with repo: with repo:
m = repo.make_commit(None, 'initial', None, tree={'0': '0'}) [m, c] = repo.make_commits(
None,
Commit('initial', tree={'0': '0'}),
Commit('first', tree={'0': '1'}),
)
repo.make_ref('heads/master', m) repo.make_ref('heads/master', m)
c = repo.make_commit(m, 'first', None, tree={'0': '1'}) pr = repo.make_pr(title='title', body='body', target='master', head=c)
prx = repo.make_pr(title='title', body='body', target='master', head=c) pr.post_comment('hansen r+', config['role_reviewer']['token'])
prx.post_comment('hansen r+', config['role_reviewer']['token'])
env.run_crons() env.run_crons()
pr = env['runbot_merge.pull_requests'].search([ pr_id = to_pr(env, pr)
('repository.name', '=', repo.name),
('number', '=', prx.number) st = env['runbot_merge.stagings'].search([], context={'active_test': False})
])
assert pr.state == 'ready'
st = pr.staging_id
assert st
env.run_crons()
assert st.state == 'success' assert st.state == 'success'
assert pr.state == 'merged' assert pr_id.state == 'merged'
@pytest.mark.defaultstatuses
def test_updated(self, env, repo, config): def test_updated(self, env, repo, config):
env['runbot_merge.repository'].search([('name', '=', repo.name)]).status_ids = False env['runbot_merge.repository'].search([('name', '=', repo.name)]).status_ids = False
with repo: with repo:
m = repo.make_commit(None, 'initial', None, tree={'0': '0'}) [m, c] = repo.make_commits(
None,
Commit('initial', tree={'0': '0'}),
Commit('first', tree={'0': '1'}),
)
repo.make_ref('heads/master', m) repo.make_ref('heads/master', m)
c = repo.make_commit(m, 'first', None, tree={'0': '1'}) pr = repo.make_pr(title='title', body='body', target='master', head=c)
prx = repo.make_pr(title='title', body='body', target='master', head=c)
env.run_crons() env.run_crons()
pr = env['runbot_merge.pull_requests'].search([ pr_id = to_pr(env, pr)
('repository.name', '=', repo.name), assert pr_id.state == 'validated'
('number', '=', prx.number)
])
assert pr.state == 'validated'
# normal push # normal push
with repo: with repo:
repo.make_commits(c, repo.Commit('second', tree={'0': '2'}), ref=prx.ref) repo.make_commits(c, repo.Commit('second', tree={'0': '2'}), ref=pr.ref)
env.run_crons() env.run_crons()
assert pr.state == 'validated' assert pr_id.state == 'validated'
with repo: with repo:
prx.post_comment('hansen r+', config['role_reviewer']['token']) pr.post_comment('hansen r+', config['role_reviewer']['token'])
assert pr.state == 'ready' assert pr_id.state == 'ready'
# force push # force push
with repo: with repo:
repo.make_commits(m, repo.Commit('xxx', tree={'0': 'm'}), ref=prx.ref) repo.make_commits(m, repo.Commit('xxx', tree={'0': 'm'}), ref=pr.ref)
env.run_crons() env.run_crons()
assert pr.state == 'validated' assert pr_id.state == 'validated'
with repo: with repo:
prx.post_comment('hansen r+', config['role_reviewer']['token']) pr.post_comment('hansen r+', config['role_reviewer']['token'])
assert pr.state == 'ready' assert pr_id.state == 'ready'
class TestRetry: class TestRetry:
@pytest.mark.xfail(reason="This may not be a good idea as it could lead to tons of rebuild spam") @pytest.mark.xfail(reason="This may not be a good idea as it could lead to tons of rebuild spam")
@ -1230,7 +1248,7 @@ class TestRetry:
with repo: with repo:
pr.post_comment('hansen retry', config['role_' + retrier]['token']) pr.post_comment('hansen retry', config['role_' + retrier]['token'])
assert pr_id.state == 'ready' assert pr_id.state == 'ready'
env.run_crons('runbot_merge.merge_cron', 'runbot_merge.staging_cron') env.run_crons('runbot_merge.staging_cron')
staging_head2 = repo.commit('heads/staging.master') staging_head2 = repo.commit('heads/staging.master')
assert staging_head2 != staging_head assert staging_head2 != staging_head
@ -1263,7 +1281,7 @@ class TestRetry:
with repo: with repo:
pr.post_comment('hansen retry', config['role_reviewer']['token']) pr.post_comment('hansen retry', config['role_reviewer']['token'])
env.run_crons('runbot_merge.merge_cron', 'runbot_merge.staging_cron') env.run_crons('runbot_merge.staging_cron')
with repo: with repo:
repo.post_status('staging.master', 'success', 'legal/cla') repo.post_status('staging.master', 'success', 'legal/cla')
@ -1293,42 +1311,36 @@ class TestRetry:
(users['user'], "@{reviewer} retry makes no sense when the PR is not in error.".format_map(users)), (users['user'], "@{reviewer} retry makes no sense when the PR is not in error.".format_map(users)),
] ]
@pytest.mark.defaultstatuses
@pytest.mark.parametrize('disabler', ['user', 'other', 'reviewer']) @pytest.mark.parametrize('disabler', ['user', 'other', 'reviewer'])
def test_retry_disable(self, env, repo, disabler, users, config): def test_retry_disable(self, env, repo, disabler, users, config):
with repo: with repo:
prx = _simple_init(repo) prx = _simple_init(repo)
repo.post_status(prx.head, 'success', 'ci/runbot') repo.post_status(prx.head, 'success')
repo.post_status(prx.head, 'success', 'legal/cla')
prx.post_comment('hansen r+ delegate=%s rebase-merge' % users['other'], prx.post_comment('hansen r+ delegate=%s rebase-merge' % users['other'],
config["role_reviewer"]['token']) config["role_reviewer"]['token'])
env.run_crons() env.run_crons()
assert env['runbot_merge.pull_requests'].search([ pr_id = to_pr(env, prx)
('repository.name', '=', repo.name), staging_id = pr_id.staging_id
('number', '=', prx.number) assert staging_id
]).staging_id
staging_head = repo.commit('heads/staging.master')
with repo: with repo:
repo.post_status('staging.master', 'success', 'legal/cla') repo.post_status('staging.master', 'failure')
repo.post_status('staging.master', 'failure', 'ci/runbot')
env.run_crons() env.run_crons()
pr = env['runbot_merge.pull_requests'].search([ assert staging_id.state == 'failure'
('repository.name', '=', repo.name), assert not staging_id.active
('number', '=', prx.number) assert pr_id.state == 'error'
])
assert pr.state == 'error'
with repo: with repo:
prx.post_comment('hansen r-', config['role_' + disabler]['token']) prx.post_comment('hansen r-', config['role_' + disabler]['token'])
assert pr.state == 'validated' assert pr_id.state == 'validated'
with repo: with repo:
repo.make_commit(prx.ref, 'third', None, tree={'m': 'c3'}) repo.make_commit(prx.ref, 'third', None, tree={'m': 'c3'})
# just in case, apparently in some case the first post_status uses the old head... # just in case, apparently in some case the first post_status uses the old head...
with repo: with repo:
repo.post_status(prx.head, 'success', 'ci/runbot') repo.post_status(prx.head, 'success')
repo.post_status(prx.head, 'success', 'legal/cla')
env.run_crons() env.run_crons()
assert pr.state == 'validated' assert pr_id.state == 'validated'
class TestMergeMethod: class TestMergeMethod:
""" """
@ -1759,7 +1771,7 @@ commits, I need to know how to merge it:
c0 = repo.make_commit(m, 'C0', None, tree={'a': 'b'}) c0 = repo.make_commit(m, 'C0', None, tree={'a': 'b'})
prx = repo.make_pr(title="gibberish", body="blahblah", target='master', head=c0) prx = repo.make_pr(title="gibberish", body="blahblah", target='master', head=c0)
env.run_crons('runbot_merge.merge_cron', 'runbot_merge.staging_cron') env.run_crons('runbot_merge.staging_cron')
with repo: with repo:
repo.post_status(prx.head, 'success', 'legal/cla') repo.post_status(prx.head, 'success', 'legal/cla')
@ -1801,7 +1813,7 @@ commits, I need to know how to merge it:
c0 = repo.make_commit(m, 'C0', None, tree={'a': 'b'}) c0 = repo.make_commit(m, 'C0', None, tree={'a': 'b'})
prx = repo.make_pr(title="gibberish", body=None, target='master', head=c0) prx = repo.make_pr(title="gibberish", body=None, target='master', head=c0)
env.run_crons('runbot_merge.merge_cron', 'runbot_merge.staging_cron') env.run_crons('runbot_merge.staging_cron')
with repo: with repo:
repo.post_status(prx.head, 'success', 'legal/cla') repo.post_status(prx.head, 'success', 'legal/cla')
@ -3077,7 +3089,7 @@ class TestBatching(object):
with repo: with repo:
repo.post_status('staging.master', 'success', 'ci/runbot') repo.post_status('staging.master', 'success', 'ci/runbot')
repo.post_status('staging.master', 'success', 'legal/cla') repo.post_status('staging.master', 'success', 'legal/cla')
env.run_crons('runbot_merge.merge_cron', 'runbot_merge.staging_cron') env.run_crons('runbot_merge.staging_cron')
assert pr2.state == 'merged' assert pr2.state == 'merged'
class TestReviewing: class TestReviewing:

View File

@ -493,7 +493,7 @@ def test_ff_fail(env, project, repo_a, repo_b, config):
with repo_a, repo_b: with repo_a, repo_b:
repo_a.post_status('heads/staging.master', 'success') repo_a.post_status('heads/staging.master', 'success')
repo_b.post_status('heads/staging.master', 'success') repo_b.post_status('heads/staging.master', 'success')
env.run_crons('runbot_merge.merge_cron', 'runbot_merge.staging_cron') env.run_crons('runbot_merge.staging_cron')
assert repo_b.commit('heads/master').id == cn,\ assert repo_b.commit('heads/master').id == cn,\
"B should still be at the conflicting commit" "B should still be at the conflicting commit"
assert repo_a.commit('heads/master').id == root_a,\ assert repo_a.commit('heads/master').id == root_a,\

View File

@ -114,7 +114,7 @@ def test_staging_priority(env, project, repo, config, mode, cutoff, second):
env[model].browse([cron_id]).write({ env[model].browse([cron_id]).write({
'nextcall': (datetime.datetime.utcnow() + datetime.timedelta(minutes=10)).isoformat(" ", "seconds") 'nextcall': (datetime.datetime.utcnow() + datetime.timedelta(minutes=10)).isoformat(" ", "seconds")
}) })
env.run_crons('runbot_merge.merge_cron') env.run_crons(None)
assert not staging.active assert not staging.active
assert not env['runbot_merge.stagings'].search([]).active assert not env['runbot_merge.stagings'].search([]).active
assert env['runbot_merge.split'].search_count([]) == 2 assert env['runbot_merge.split'].search_count([]) == 2