Skip to content

Add support for distributed notes updates - #1473

Merged
dscho merged 11 commits into
gitgitgadget:mainfrom
dscho:transactional-notes-updates
Aug 12, 2025
Merged

Add support for distributed notes updates#1473
dscho merged 11 commits into
gitgitgadget:mainfrom
dscho:transactional-notes-updates

Conversation

@dscho

@dscho dscho commented Nov 8, 2023

Copy link
Copy Markdown
Member

One of the biggest blockers preventing GitGitGadget from moving away from Azure Pipelines toward GitHub workflows is the complication that the refs/notes/gitgitgadget note holds global state and must therefore be updated sequentially.

In the Azure Pipelines approach, we manage that by having an agent pool of one, which naturally can only serve one request at a time. That way, every job can first fetch the GitGitGadget notes ref, do whatever it needs to do, leisurely update the notes ref to reflect the new state and push the ref. Then, the next job can do the same and there is never a situation where a job tries to push the notes ref and fails because another, concurrent job has updated said ref in the meantime.

For various reasons, adding a pool of one to GitHub Actions is impractical. Neither is it desirable.

In this PR, I propose a solution that allows for jobs to run concurrently, fetching the GitGitGadget notes, doing their thing, updating the notes ref along the way, and once it is time to push the notes ref and a situation is detected where it does not fast-forward, combine the changes in a way that is inspired by Conflict-free replicated data types (CRTD), so that it can be pushed without problems after all.

The strategy implemented in this PR is to prefer the local changes in case of a hard conflict.

This means we will still have to be careful to prevent concurrent runs of some GitHub workflows: for example, we really will only ever want at most a single instance of the job to run whose responsibility it is to read new mails from the Git mailing list and mirror the relevant ones into the corresponding PRs. Should two jobs of that workflow run at the same time, they would end up adding PR comments twice, which is undesirable.

This PR already updates the misc-helper commands that are run by GitGitGadget's Azure Pipelines: This is preemptive, so that I can start porting the Pipelines to GitHub workflows and not risk failed notes ref synchronizations.

@dscho dscho left a comment

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Needs some grammar fix and clearer code comment.

Comment thread lib/git-notes.ts Outdated
Comment thread lib/git-notes.ts Outdated
@dscho dscho mentioned this pull request Nov 7, 2024
@dscho
dscho force-pushed the transactional-notes-updates branch from eae63d0 to 97f2c64 Compare December 16, 2024 22:39
@dscho
dscho force-pushed the transactional-notes-updates branch 2 times, most recently from 30999e3 to ba10771 Compare January 5, 2025 11:39
@dscho
dscho force-pushed the transactional-notes-updates branch from ba10771 to 88309db Compare February 8, 2025 14:18
@dscho
dscho force-pushed the transactional-notes-updates branch from 77d6665 to d88727f Compare March 4, 2025 12:09
@dscho

dscho commented Mar 4, 2025

Copy link
Copy Markdown
Member Author

@webstech could you please have a look?

Comment thread tests/git-notes.test.ts Outdated
Comment thread tests/ci-helper.test.ts Outdated
Comment thread lib/git.ts
Comment thread lib/patch-series.ts Outdated
@webstech

webstech commented Mar 7, 2025

Copy link
Copy Markdown
Contributor

Still reviewing (re-reviewing really).

The init-gitgitgadget-options command should initialize a fully initialized options object. This would remove some of the optionsUpdated settings for a new repo. Requiring a repo to be initialized for supporting ggg should be reasonable now. This is unlikely to cause a problem for current use.

The handle-pr command would be cleaner. It is currently missing a push call if optionsUpdated is set.

@dscho

dscho commented Mar 7, 2025

Copy link
Copy Markdown
Member Author

Still reviewing (re-reviewing really).

Thank you!

The init-gitgitgadget-options command should initialize a fully initialized options object.

I would like to have a nicer user experience, though, especially in the (hopefully not so far) future when I can use GitGitGadget also to contribute to Cygwin (i.e. run this Action in a fork of gitgitgadget-workflows with everything configured in a repository variable and some repository secrets after registering a new GitHub App via register-github-app-cli).

That means that I'd like to reduce the number of steps anyone has to go through to set up their own "GitGitGadget instance", which means: no more init-gitgitgadget-options command.

Comment thread lib/ci-helper.ts Outdated

if (optionsUpdated) {
await this.notes.set("", options, true);
await this.notes.push(this.urlRepo);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

CIHelper::handlePR is doing a push. Is any of this necessary now?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Good point! I'll drop this call.

Comment thread lib/ci-helper.ts

if (optionsUpdated && updateOptionsInRef) {
await this.notes.set("", options, true);
await this.notes.push(this.urlRepo);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Is updateOptionsInRef still useful? It seems outdated and would allow the update if needed which would simplify upstream callers. optionsUpdated does not need to return true if the notes are actually updated but that only matters if callers use it.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

It is totally outdated, thank you for pointing that out! I will drop it in a new commit.

@webstech

webstech commented Mar 7, 2025

Copy link
Copy Markdown
Contributor

That means that I'd like to reduce the number of steps anyone has to go through to set up their own "GitGitGadget instance", which means: no more init-gitgitgadget-options command.

Maybe the options get needs to be able to push updates if it does not have a full object and callers can assume they have a complete object. Having checks for missing fields is noise and complicates code. Some code is deciding if the options is updated and then calling code that also has to decide if the options is updated. Now that code (ie handlePR) is pushing the update but the caller is also thinking it may need to do an update.

Edit:
The primary owner would need to be in the config to allow the init command to be removed. Then a full options can be created as needed (and pushed immediately to simplify things). The handle-pr command would only need to update if optionsUpdated && !optionsUpdated2.

@dscho

dscho commented Mar 23, 2025

Copy link
Copy Markdown
Member Author

The primary owner would need to be in the config to allow the init command to be removed.

That's probably the cleanest solution.

A more convenient solution would be to obtain the "collaborator permission" (similar to what Git for Windows' GitHub App does), so that automatically all accounts with write permission would be added.

Instead of resorting to GraphQL, this could be done via the collaborators endpoint. Using GitHub CLI, it would look something like

$ gh api repos/gitgitgadget/git/collaborators --jq \
  '.[] | select(.permissions.admin or .permissions.maintain or .permissions.push) | .login'

@dscho

dscho commented Mar 23, 2025

Copy link
Copy Markdown
Member Author
$ gh api repos/gitgitgadget/git/collaborators --jq \
  '.[] | select(.permissions.admin or .permissions.maintain or .permissions.push) | .login'

I guess the following would be enough:

$ gh api repos/gitgitgadget/git/collaborators --jq  '.[] | select(.permissions.push) | .login'

@webstech webstech left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I have finished reviewing the other commits with no additional comments. Thanks for the education.

I will re-review when your changes arrive.

@dscho
dscho force-pushed the transactional-notes-updates branch from d88727f to 10df503 Compare March 24, 2025 13:33
@dscho

dscho commented Mar 24, 2025

Copy link
Copy Markdown
Member Author

I have finished reviewing the other commits with no additional comments. Thanks for the education.

I will re-review when your changes arrive.

Thank you! And my apologies, I had forgotten to push my updates... 🤦

Comment thread lib/patch-series.ts
}

logger.log("Publishing branch and tag");
await this.publishBranch(tagName);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I did not look at what cleanup could occur but there are quite a few branches and tags on the git repo. Are there more than is currently needed?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

The code under discussion is a left-over from the CLI times, when the branch had not necessarily been published yet. The log message still talks about that branch.

However, what does need to still be pushed, even in these times, is the tagged version that reflects the iteration that has been sent to the mailing list.

That function is already provided by the conditional block if (publishTagsAndNotesToRemote) { ... } below, though, and therefore the publishBranch() logic is no longer needed.

The branches that are in gitgitgadget/git are actually synchronized by the sync-* workflows from some upstream sources, and serve only as potential PR target branches.

The vast majority of the tags in gitgitgadget/git are the ones reflecting submitted patch series iterations, the remainder have been mirrored by the sync-* workflows together with the branches.

Comment thread lib/ci-helper.ts
Comment thread tests/ci-helper.test.ts Outdated

await expect(ci.handleComment("gitgitgadget", 433865360)).rejects.toThrow(/is not a valid GitHub username/);
expect(ci.addPRCommentCalls[0][1]).toMatch(/is not a valid GitHub username/);
await expect(ci.handleComment("gitgitgadget", 433865360)).rejects.toThrow(/SOME ERROR/);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The purpose of the test is to verify the bad user is reported by CIHelper. Verifying the network access was bypassed seems like a different test. In the CI test, github throws a bad request type error that CIHelper converts to the bad user with the GitHubGlue error message as part of the error. Running locally, the test actually gets a not authenticated error thrown by GitHubGlue (so not quite the test I thought it was). Setting
getConfig().repo.owner = "gooduser"; fixes the auth error if the config is properly set up. I understand what the update is proofing but don't think it is part of the test - the checks do not need to be changed.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Wait, you mean to say that the patch avoiding network access to GitHub REST should be dropped?

@webstech webstech Mar 29, 2025

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Running locally, there is no network access due to no authentication (required to ask about users). Running on workflows or pipelines, there is network access but is that a problem? I'll let you decide on that.

I think, either way, the tests should still be checking for is not a valid GitHub username and not SOME ERROR since that is the purpose of the test. CIHelper is setting the not valid message. Sorry I was not clear.

@dscho Did you see this?

@webstech

Copy link
Copy Markdown
Contributor

@dscho Thanks for the updates. Sorry but a couple of minor things. fyi, due to the change overlap, I reviewed the complete change instead of individual commits - too much to track when comparing with local source. I also assumed large commits I had not commented on were unchanged.

@dscho
dscho force-pushed the transactional-notes-updates branch from 10df503 to 836f3db Compare March 29, 2025 07:00
@dscho

dscho commented Mar 29, 2025

Copy link
Copy Markdown
Member Author

@webstech thank you for the review!

I do not quite understand what you'd like me to do with c61a853; I'd like to get this over the finish line so that I can start playing with #1392 on top of this ;-)

dscho added 11 commits August 12, 2025 10:58
In aefc723 (correspond draft PR and 'RFC PATCH', 2025-01-16) we
introduced code that my local linter claims is an unsafe assignment.
Let's work around that.

Signed-off-by: Johannes Schindelin <[email protected]>
To handle the situation when concurrent GitGitGadget operations want to
modify the state that is persisted in Git notes (such as
`refs/notes/gitgitgadget`), we need a way to reconcile diverging Git
notes.

Happily, the way GitGitGadget works makes this relatively
straight-forward, as concurrent operations usually do not conflict and
merely need to add/modify/remove independent attributes and/or values.
This allows us to treat these Git notes as kind of a Conflict-free
Replicated Data Type (https://en.wikipedia.org/wiki/CRDT).

The strategy implemented in this patch is relatively simple, and follows
the "local-first" idea (where state is applied locally first and only
then persisted remotely): The newly added method determines what changes
were made locally, and then reapplies them on top of the upstream note
commit. The local operations need to fall into one of the following
categories:

- a note has been appended to

- a single line of text (which may, or may not, be a stringified JSON
  object) is added as a note on an object that had not had a note yet

- a note containing a single line of text has been replaced with a new,
  single line of text

- a note that contained a JSON object (stringified as a single line of
  text) has been modified. Valid modifications include:

  - an attribute that was added

  - an attribute that has been removed

  - an attribute containing a primitive type (string, number or boolean)
    has been modified (if local and upstream diverged, local wins)

  - items from an attribute containing an array have been removed and/or
    added

  - an attribute containing a Plain Old Javascript Object has seen any
    of these modifications

Other modifications are as yet unhandled.

For historical reasons (*cough* Git commands implemented in Unix shell
scripts *cough*) it is impossible to add a Git note that does not end in
a newline character. Let's ensure that we don't do that, either.

To avoid tampering with a Git index, let's use a temporary index. This
requires the environment variable `GIT_INDEX_FILE` to be passed to the
Git processes, via the `env` attribute of the `IGitOptions` (which only
need to be declared and no other changes are needed because the
`options` are then used as dugite's `IGitExecutionOptions`, which
already has the `env` attribute).

Signed-off-by: Johannes Schindelin <[email protected]>
We actually do want to work exclusively on bare repositories. A recent
Git version requires either the `safe.bareRepository` config setting to
indicate that it is safe, or the git dir to be specified explicitly via
`--git-dir`. Let's do the latter.

This will become very important when we switch to using GitHub Actions
with ephemeral (and partial) clones of `gitgitgadget/git`.

Signed-off-by: Johannes Schindelin <[email protected]>
In the olden days, when GitGitGadget was still `mail-patch-series.sh`,
the source of truth was the local repository where it was run, and as
such, the branch needed to be pushed to the remote to make it, well,
public.

However, in the current setup, the source of truth is the
gitgitgadget/git (or git/git) repository's Pull Request, i.e. the branch
is already published.

Therefore, this branch publishing logic should have been removed in
6d397b0 (Remove support for the CLI mode, 2023-12-31)... better late
than never...

Signed-off-by: Johannes Schindelin <[email protected]>
Let's only ever push `refs/notes/gitgitgadget` from
`GitGitGadget.pushNotesRef()`. This will make the next change easier,
where we make notes pushing more resilient against concurrency by
incorporating the shiny new `GitNotes.notesSync()` call.

Signed-off-by: Johannes Schindelin <[email protected]>
GitGitGadget's state lives in a JSON object at the tip of the
`refs/notes/gitgitgadget` ref at https://github.com/gitgitgadget/git.

When GitGitGadget's automation runs, it typically modifies that, and
then pushes the new state.

In case of concurrent runs, this would cause a problem (which is why we
currently prevent that). But not for long! This here commit uses the
`notesSync()` function that was introduced _just_ for the purpose of
allowing concurrent runs, by merging whatever changed upstream and then
pushing again.

To allow for multiple concurrent contenders, let's try that a couple of
times before giving up, waiting for an increasingly long time.

This functionality is now made available via the shiny new
`GitNotes.push(url)` method.

This method will henceforth be used by the `GitGitGadget.pushNotesRef()`
method to guarantee robustness for concurrent GitGitGadget workflows.

Signed-off-by: Johannes Schindelin <[email protected]>
I'd like the following `misc-helper` commands to call the new
`GitNotes.push()` method as needed, to benefit from the just-introduced
support for concurrent GitGitGadget workflow runs:

- update-open-prs

- update-commit-mappings

- handle-open-prs

- handle-new-mails

Currently, these are handled in the "Update GitGitGadget's PRs" Pipeline at
https://dev.azure.com/gitgitgadget/git/_build/index?definitionId=2&_a=completed
and in the "Mirror Git List to GitGitGadget's PRs" Azure Pipeline at
https://dev.azure.com/gitgitgadget/git/_build/index?definitionId=5&_a=completed

The Pipeline definitions perform those pushes manually, and changing
CIHelper to do the push preemptively should allow for those definitions
to remain unchanged until the point in time when they are replaced by
workflows in https://github.com/gitgitgadget/gitgitgadget-workflows.

Technically, this commit does not even touch `misc-helper.ts`; The
reason is that the logic is actually implemented in the `CIHelper`
class, which is a good thing, because that class will become the
principal entry-point of above-mentioned GitHub workflows.

Also note that there is a slight change of (`push`) behavior in
`handle-open-prs`: This command calls `CIHelper.handleOpenPRs()`, which
in turn calls `CIHelper.handlePR()` in a loop. Now, the notes ref will
be pushed every time the latter updates something, where before, the
notes ref was only pushed once, at the very end. This is arguably safer
because PRs that have already been handled in a Pipeline run that fails
somewhere in the middle won't be handled again upon the re-run.

Eventually I'd like the same graceful notes ref pushing even for
script/update-mail-to-commit-notes.sh (which updates not the
`gitgitgadget`, but the `mail-to-commit` and `commit-to-mail` notes
refs), so that the "Update GitGitGadget's commit to mail notes" Pipeline at
https://dev.azure.com/gitgitgadget/git/_build/index?definitionId=9&_a=completed
can also be migrated to a GitHub workflow.

Signed-off-by: Johannes Schindelin <[email protected]>
While we may want to consider taking out the dry-run mode (as it is not
surfaced in GitGitGadget ever since 6d397b0 (Remove support for the CLI
mode, 2023-12-31) removed the ability to run it in command-line mode),
the mode still exists and pushing a tag (and now also notes ref) should
definitely not be done in dry-run mode.

Pointed-out-by: Chris. Webster <[email protected]>
Signed-off-by: Johannes Schindelin <[email protected]>
All current callers pass in a valid `options` parameter, therefore there
is no need any longer for the original logic to potentially initialize
those options.

Signed-off-by: Johannes Schindelin <[email protected]>
The `CIHelper.handlePR()` method already took care of updating the
notes, no need to do it again.

Signed-off-by: Johannes Schindelin <[email protected]>
… `handlePR()`

We already push the notes ref when updating the PR metadata (i.e. the
object attached to the blob whose contents are the PR URL), therefore it
makes only sense to combine that with any update to the
GitGitGadgetOptions (i.e. the object attached to the empty object).

This simplifies the logic (and the separation of concerns) quite a bit.

Suggested-by: Chris. Webster <[email protected]>
Signed-off-by: Johannes Schindelin <[email protected]>
@dscho
dscho force-pushed the transactional-notes-updates branch from 836f3db to d011ae5 Compare August 12, 2025 08:59
@dscho

dscho commented Aug 12, 2025

Copy link
Copy Markdown
Member Author

I do not quite understand what you'd like me to do with c61a853; I'd like to get this over the finish line so that I can start playing with #1392 on top of this ;-)

Okay, I still don't know, but I really want to merge this PR, so I simply dropped that patch from the PR and will merge as soon as CI finishes successfully.

@dscho
dscho merged commit 7423b6d into gitgitgadget:main Aug 12, 2025
4 checks passed
@dscho
dscho deleted the transactional-notes-updates branch August 12, 2025 09:29
@dscho dscho mentioned this pull request Aug 15, 2025
dscho added a commit that referenced this pull request Aug 15, 2025
When merging #1473 (Add
support for distributed notes updates), I did not anticipate that the
current Azure Pipelines were not prepared for that: Contrary to what I
had remembered, they did _not_ simply configure `http.extraHeader` to be
able to push (which is somewhat surprising because I authored them, and
I typically did that a lot in the olden days).

Happily, I _just_ introduced support for configuring a token in
`CIHelper` that is specifically used for accessing `gitgitgadget/git`,
including the Git notes pushes.

Let's teach `misc-helper` about this new trick, and pick up the token
that was configured via `set-app-token` and tell `CIHelper` to use it.

Signed-off-by: Johannes Schindelin <[email protected]>
dscho added a commit that referenced this pull request Aug 15, 2025
When merging #1473 (Add
support for distributed notes updates), I did not anticipate that the
current Azure Pipelines were not prepared for that: Contrary to what I
had remembered, they did _not_ simply configure `http.extraHeader` to be
able to push (which is somewhat surprising because I authored them, and
I typically did that a lot in the olden days).

Happily, I _just_ introduced support for configuring a token in
`CIHelper` that is specifically used for accessing `gitgitgadget/git`,
including the Git notes pushes.

Let's teach `misc-helper` about this new trick, and pick up the token
that was configured via `set-app-token` and tell `CIHelper` to use it.

Signed-off-by: Johannes Schindelin <[email protected]>
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.

2 participants