Skip to content
This repository was archived by the owner on Jun 21, 2023. It is now read-only.

Caching GraphQL queries - #2181

Merged
jcansdale merged 20 commits into
masterfrom
feature/graphql-caching
Apr 4, 2019
Merged

Caching GraphQL queries#2181
jcansdale merged 20 commits into
masterfrom
feature/graphql-caching

Conversation

@grokys

@grokysgrokys commented Jan 16, 2019

Copy link
Copy Markdown
Contributor

This PR adds caching to GraphQL queries. It introduces a new IGraphQLClient which is used instead of (and wraps) Octokit.GraphQL's IConnection.

When a query is submitted, this class hashes the query and uses this as a cache key. Queries are cached in the filesystem using a slightly modified version of https://github.com/acarteas/FileCache.

The main complication here is that if you have say 3 pages worth of the PR list cached then pressing "Refresh" needs to clear all pages of the PR list. We use the region name to identify such queries and when refresh is pressed, call FileCache.ClearRegion. This method isn't present in the mainline FileCache which is why we ship a slightly modified version of it.

Depends on octokit/octokit.graphql.net#186

Also brought `FileCache` into our source instead of importing its package because needed to add the `ClearRegion` method.

@StanleyGoldmanStanleyGoldman 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 don't mean to do this. This is a test.

@StanleyGoldman

Copy link
Copy Markdown
Contributor

As per conversations had on slack graphql caching will be a double edged sword. We will need to review all of our usages of the graphql library to make sure caching will not result in any user experience bugs.

@StanleyGoldman

StanleyGoldman commented Feb 28, 2019

Copy link
Copy Markdown
Contributor

Functionality to get a page of a list of pull requests

  • Handled by: Caching for default 8 hours and clear on refresh
    ICompiledQuery<Page<PullRequestListItemModel>>query;
    if(address.IsGitHubDotCom())
    {
    if(readPullRequests==null)
    {
    readPullRequests=newQuery()
    .Repository(owner:Var(nameof(owner)),name:Var(nameof(name)))
    .PullRequests(
    first:100,
    after:Var(nameof(after)),
    orderBy:newIssueOrder{Direction=OrderDirection.Desc,Field=IssueOrderField.CreatedAt},
    states:Var(nameof(states)))
    .Select(page =>newPage<PullRequestListItemModel>
    {
    EndCursor=page.PageInfo.EndCursor,
    HasNextPage=page.PageInfo.HasNextPage,
    TotalCount=page.TotalCount,
    Items=page.Nodes.Select(pr =>newListItemAdapter
    {
    Id=pr.Id.Value,
    LastCommit=pr.Commits(null,null,1,null).Nodes.Select(commit =>
    newLastCommitSummaryAdapter
    {
    CheckSuites=commit.Commit.CheckSuites(null,null,null,null,null).AllPages(10)
    .Select(suite =>newCheckSuiteSummaryModel
    {
    CheckRuns=suite.CheckRuns(null,null,null,null,null).AllPages(10)
    .Select(run =>newCheckRunSummaryModel
    {
    Conclusion=run.Conclusion.FromGraphQl(),
    Status=run.Status.FromGraphQl()
    }).ToList(),
    }).ToList(),
    Statuses=commit.Commit.Status
    .Select(context =>
    context.Contexts.Select(statusContext =>newStatusSummaryModel
    {
    State=statusContext.State.FromGraphQl(),
    }).ToList()
    ).SingleOrDefault()
    }).ToList().FirstOrDefault(),
    Author=newActorModel
    {
    Login=pr.Author.Login,
    AvatarUrl=pr.Author.AvatarUrl(null),
    },
    CommentCount=pr.Comments(0,null,null,null).TotalCount,
    Number=pr.Number,
    Reviews=pr.Reviews(null,null,null,null,null,null).AllPages().Select(review =>newReviewAdapter
    {
    Body=review.Body,
    CommentCount=review.Comments(null,null,null,null).TotalCount,
    }).ToList(),
    State=pr.State.FromGraphQl(),
    Title=pr.Title,
    UpdatedAt=pr.UpdatedAt,
    }).ToList(),
    }).Compile();
    }
    query=readPullRequests;
    }
    else
    {
    if(readPullRequestsEnterprise==null)
    {
    readPullRequestsEnterprise=newQuery()
    .Repository(owner:Var(nameof(owner)),name:Var(nameof(name)))
    .PullRequests(
    first:100,
    after:Var(nameof(after)),
    orderBy:newIssueOrder{Direction=OrderDirection.Desc,Field=IssueOrderField.CreatedAt},
    states:Var(nameof(states)))
    .Select(page =>newPage<PullRequestListItemModel>
    {
    EndCursor=page.PageInfo.EndCursor,
    HasNextPage=page.PageInfo.HasNextPage,
    TotalCount=page.TotalCount,
    Items=page.Nodes.Select(pr =>newListItemAdapter
    {
    Id=pr.Id.Value,
    LastCommit=pr.Commits(null,null,1,null).Nodes.Select(commit =>
    newLastCommitSummaryAdapter
    {
    Statuses=commit.Commit.Status.Select(context =>
    context==null
    ?null
    :context.Contexts
    .Select(statusContext =>newStatusSummaryModel
    {
    State=statusContext.State.FromGraphQl()
    }).ToList()
    ).SingleOrDefault()
    }).ToList().FirstOrDefault(),
    Author=newActorModel
    {
    Login=pr.Author.Login,
    AvatarUrl=pr.Author.AvatarUrl(null),
    },
    CommentCount=pr.Comments(0,null,null,null).TotalCount,
    Number=pr.Number,
    Reviews=pr.Reviews(null,null,null,null,null,null).AllPages().Select(review =>newReviewAdapter
    {
    Body=review.Body,
    CommentCount=review.Comments(null,null,null,null).TotalCount,
    }).ToList(),
    State=pr.State.FromGraphQl(),
    Title=pr.Title,
    UpdatedAt=pr.UpdatedAt,
    }).ToList(),
    }).Compile();
    }
    query=readPullRequestsEnterprise;
    }
    vargraphql=awaitgraphqlFactory.CreateConnection(address);
    varvars=newDictionary<string,object>
    {
    {nameof(owner),owner},
    {nameof(name),name},
    {nameof(after),after},
    {nameof(states),states.Select(x =>(Octokit.GraphQL.Model.PullRequestState)x).ToList()},
    };
    varregion=owner+'/'+name+"/pr-list";
    varresult=awaitgraphql.Run(query,vars,regionName:region);

Functionality to get the pull request detail

  • Handled by: Caching for default 8 hours and clear on item refresh
    if(readPullRequest==null)
    {
    readPullRequest=newQuery()
    .Repository(owner:Var(nameof(owner)),name:Var(nameof(name)))
    .PullRequest(number:Var(nameof(number)))
    .Select(pr =>newPullRequestDetailModel
    {
    Id=pr.Id.Value,
    Number=pr.Number,
    Author=newActorModel
    {
    Login=pr.Author.Login,
    AvatarUrl=pr.Author.AvatarUrl(null),
    },
    Title=pr.Title,
    Body=pr.Body,
    BaseRefSha=pr.BaseRefOid,
    BaseRefName=pr.BaseRefName,
    BaseRepositoryOwner=pr.Repository.Owner.Login,
    HeadRefName=pr.HeadRefName,
    HeadRefSha=pr.HeadRefOid,
    HeadRepositoryOwner=pr.HeadRepositoryOwner!=null?pr.HeadRepositoryOwner.Login:null,
    State=pr.State.FromGraphQl(),
    UpdatedAt=pr.UpdatedAt,
    CommentCount=pr.Comments(0,null,null,null).TotalCount,
    Comments=pr.Comments(null,null,null,null).AllPages().Select(comment =>newCommentModel
    {
    Id=comment.Id.Value,
    Author=newActorModel
    {
    Login=comment.Author.Login,
    AvatarUrl=comment.Author.AvatarUrl(null),
    },
    Body=comment.Body,
    CreatedAt=comment.CreatedAt,
    DatabaseId=comment.DatabaseId.Value,
    Url=comment.Url,
    }).ToList(),
    Reviews=pr.Reviews(null,null,null,null,null,null).AllPages().Select(review =>newPullRequestReviewModel
    {
    Id=review.Id.Value,
    Body=review.Body,
    CommitId=review.Commit.Oid,
    State=review.State.FromGraphQl(),
    SubmittedAt=review.SubmittedAt,
    Author=newActorModel
    {
    Login=review.Author.Login,
    AvatarUrl=review.Author.AvatarUrl(null),
    },
    Comments=review.Comments(null,null,null,null).AllPages().Select(comment =>newCommentAdapter
    {
    Id=comment.Id.Value,
    PullRequestId=comment.PullRequest.Number,
    DatabaseId=comment.DatabaseId.Value,
    Author=newActorModel
    {
    Login=comment.Author.Login,
    AvatarUrl=comment.Author.AvatarUrl(null),
    },
    Body=comment.Body,
    Path=comment.Path,
    CommitSha=comment.Commit.Oid,
    DiffHunk=comment.DiffHunk,
    Position=comment.Position,
    OriginalPosition=comment.OriginalPosition,
    OriginalCommitId=comment.OriginalCommit.Oid,
    ReplyTo=comment.ReplyTo!=null?comment.ReplyTo.Id.Value:null,
    CreatedAt=comment.CreatedAt,
    Url=comment.Url,
    }).ToList(),
    }).ToList(),
    Timeline=pr.Timeline(null,null,null,null,null).AllPages().Select(item =>item.Switch<object>(when=>
    when.Commit(commit =>newCommitModel
    {
    AbbreviatedOid=commit.AbbreviatedOid,
    // TODO: commit.Author.User can be null
    Author=newActorModel
    {
    Login=commit.Author.User.Login,
    AvatarUrl=commit.Author.User.AvatarUrl(null),
    },
    MessageHeadline=commit.MessageHeadline,
    Oid=commit.Oid,
    }).IssueComment(comment =>newCommentModel
    {
    Author=newActorModel
    {
    Login=comment.Author.Login,
    AvatarUrl=comment.Author.AvatarUrl(null),
    },
    Body=comment.Body,
    CreatedAt=comment.CreatedAt,
    DatabaseId=comment.DatabaseId.Value,
    Id=comment.Id.Value,
    Url=comment.Url,
    }))).ToList()
    }).Compile();
    }
    varvars=newDictionary<string,object>
    {
    {nameof(owner),owner},
    {nameof(name),name},
    {nameof(number),number},
    };
    varconnection=awaitgraphqlFactory.CreateConnection(address);
    varresult=awaitconnection.Run(readPullRequest,vars,refresh:refresh);

Functionality to get the last commit of a pull request for statuses

  • Handled by: Caching for default 8 hours and clear on item refresh
    asyncTask<LastCommitAdapter>GetPullRequestLastCommitAdapter(HostAddressaddress,stringowner,stringname,intnumber,boolrefresh)
    {
    ICompiledQuery<IEnumerable<LastCommitAdapter>>query;
    if(address.IsGitHubDotCom())
    {
    if(readCommitStatuses==null)
    {
    readCommitStatuses=newQuery()
    .Repository(owner:Var(nameof(owner)),name:Var(nameof(name)))
    .PullRequest(number:Var(nameof(number))).Commits(last:1).Nodes.Select(
    commit =>newLastCommitAdapter
    {
    HeadSha=commit.Commit.Oid,
    CheckSuites=commit.Commit.CheckSuites(null,null,null,null,null).AllPages(10)
    .Select(suite =>newCheckSuiteModel
    {
    CheckRuns=suite.CheckRuns(null,null,null,null,null).AllPages(10)
    .Select(run =>newCheckRunModel
    {
    Id=run.Id.Value,
    Conclusion=run.Conclusion.FromGraphQl(),
    Status=run.Status.FromGraphQl(),
    Name=run.Name,
    DetailsUrl=run.Permalink,
    Summary=run.Summary,
    Text=run.Text,
    Annotations=run.Annotations(null,null,null,null).AllPages()
    .Select(annotation =>newCheckRunAnnotationModel
    {
    Title=annotation.Title,
    Message=annotation.Message,
    Path=annotation.Path,
    AnnotationLevel=annotation.AnnotationLevel.Value.FromGraphQl(),
    StartLine=annotation.Location.Start.Line,
    EndLine=annotation.Location.End.Line,
    }).ToList()
    }).ToList(),
    ApplicationName=suite.App!=null?suite.App.Name:"Private App"
    }).ToList(),
    Statuses=commit.Commit.Status
    .Select(context =>
    context.Contexts.Select(statusContext =>newStatusModel
    {
    State=statusContext.State.FromGraphQl(),
    Context=statusContext.Context,
    TargetUrl=statusContext.TargetUrl,
    Description=statusContext.Description
    }).ToList()
    ).SingleOrDefault()
    }
    ).Compile();
    }
    query=readCommitStatuses;
    }
    else
    {
    if(readCommitStatusesEnterprise==null)
    {
    readCommitStatusesEnterprise=newQuery()
    .Repository(owner:Var(nameof(owner)),name:Var(nameof(name)))
    .PullRequest(number:Var(nameof(number))).Commits(last:1).Nodes.Select(
    commit =>newLastCommitAdapter
    {
    Statuses=commit.Commit.Status==null?null:commit.Commit.Status
    .Select(context =>context==null
    ?null
    :context.Contexts
    .Select(statusContext =>newStatusModel
    {
    State=statusContext.State.FromGraphQl(),
    Context=statusContext.Context,
    TargetUrl=statusContext.TargetUrl,
    Description=statusContext.Description,
    }).ToList()
    ).SingleOrDefault()
    }
    ).Compile();
    }
    query=readCommitStatusesEnterprise;
    }
    varvars=newDictionary<string,object>
    {
    {nameof(owner),owner},
    {nameof(name),name},
    {nameof(number),number},
    };
    varconnection=awaitgraphqlFactory.CreateConnection(address);
    varresult=awaitconnection.Run(query,vars,refresh);
    returnresult.First();
    }

Functionality to get a list of viewer repositories for cloning

  • Handled by: Caching for default 8 hours and clear on refresh
    publicasyncTask<ViewerRepositoriesModel>ReadViewerRepositories(HostAddressaddress)
    {
    if(readViewerRepositories==null)
    {
    varorder=newRepositoryOrder
    {
    Field=RepositoryOrderField.PushedAt,
    Direction=OrderDirection.Desc
    };
    varrepositorySelection=newFragment<Repository,RepositoryListItemModel>(
    "repository",
    repo =>newRepositoryListItemModel
    {
    IsFork=repo.IsFork,
    IsPrivate=repo.IsPrivate,
    Name=repo.Name,
    Owner=repo.Owner.Login,
    Url=newUri(repo.Url),
    });
    readViewerRepositories=newQuery()
    .Viewer
    .Select(viewer =>newViewerRepositoriesModel
    {
    Owner=viewer.Login,
    Repositories=viewer.Repositories(null,null,null,null,null,null,null,order,null,null)
    .AllPages()
    .Select(repositorySelection).ToList(),
    ContributedToRepositories=viewer.RepositoriesContributedTo(100,null,null,null,null,null,null,order,null)
    .Nodes
    .Select(repositorySelection).ToList(),
    Organizations=viewer.Organizations(null,null,null,null).AllPages().Select(org =>new
    {
    org.Login,
    Repositories=org.Repositories(100,null,null,null,null,null,null,order,null,null)
    .Nodes
    .Select(repositorySelection).ToList()
    }).ToDictionary(x =>x.Login, x =>(IReadOnlyList<RepositoryListItemModel>)x.Repositories),
    }).Compile();
    }
    vargraphql=awaitgraphqlFactory.CreateConnection(address).ConfigureAwait(false);
    varresult=awaitgraphql.Run(readViewerRepositories).ConfigureAwait(false);
    returnresult;
    }

Functionality to get a pull request node id

  • Handled by: Caching for default 8 hours
    publicasyncTask<string>GetGraphQLPullRequestId(
    LocalRepositoryModellocalRepository,
    stringrepositoryOwner,
    intnumber)
    {
    varaddress=HostAddress.Create(localRepository.CloneUrl.Host);
    vargraphql=awaitgraphqlFactory.CreateConnection(address);
    varquery=newQuery()
    .Repository(owner:repositoryOwner,name:localRepository.Name)
    .PullRequest(number)
    .Select(x =>x.Id);
    return(awaitgraphql.Run(query)).Value;
    }

Functionality to get assignable users

  • Handled by: Caching for 1 hour
    publicasyncTask<Page<ActorModel>>ReadAssignableUsers(
    HostAddressaddress,
    stringowner,
    stringname,
    stringafter)
    {
    if(readAssignableUsers==null)
    {
    readAssignableUsers=newQuery()
    .Repository(owner:Var(nameof(owner)),name:Var(nameof(name)))
    .AssignableUsers(first:100,after:Var(nameof(after)))
    .Select(connection =>newPage<ActorModel>
    {
    EndCursor=connection.PageInfo.EndCursor,
    HasNextPage=connection.PageInfo.HasNextPage,
    TotalCount=connection.TotalCount,
    Items=connection.Nodes.Select(user =>newActorModel
    {
    AvatarUrl=user.AvatarUrl(30),
    Login=user.Login,
    }).ToList(),
    }).Compile();
    }
    vargraphql=awaitgraphqlFactory.CreateConnection(address);
    varvars=newDictionary<string,object>
    {
    {nameof(owner),owner},
    {nameof(name),name},
    {nameof(after),after},
    };
    returnawaitgraphql.Run(readAssignableUsers,vars);
    }

Functionality to get the viewer details

  • Handled by: Caching for 10 minutes
    publicvirtualasyncTask<ActorModel>ReadViewer(HostAddressaddress)
    {
    if(readViewer==null)
    {
    readViewer=newQuery()
    .Viewer
    .Select(x =>newActorModel
    {
    Login=x.Login,
    AvatarUrl=x.AvatarUrl(null),
    }).Compile();
    }
    varconnection=awaitgraphqlFactory.CreateConnection(address);
    returnawaitconnection.Run(readViewer);
    }

Functionality to get a parent repo if one exists

  • Handled by: Caching for default 8 hours
    publicasyncTask<(stringowner,stringname)?>FindParent(HostAddressaddress,stringowner,stringname)
    {
    Guard.ArgumentNotNull(address,nameof(address));
    Guard.ArgumentNotEmptyString(owner,nameof(owner));
    Guard.ArgumentNotEmptyString(name,nameof(name));
    if(readParentOwnerLogin==null)
    {
    readParentOwnerLogin=newQuery()
    .Repository(owner:Var(nameof(owner)),name:Var(nameof(name)))
    .Select(r =>r.Parent!=null?Tuple.Create(r.Parent.Owner.Login,r.Parent.Name):null)
    .Compile();
    }
    varvars=newDictionary<string,object>
    {
    {nameof(owner),owner},
    {nameof(name),name},
    };
    vargraphql=awaitgraphqlFactory.CreateConnection(address);
    varresult=awaitgraphql.Run(readParentOwnerLogin,vars);
    returnresult!=null?(result.Item1,result.Item2):((string,string)?)null;
    }

@StanleyGoldmanStanleyGoldman 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.

The refresh flag should be chained down to this call

varlastCommitModel=awaitlog.TimeAsync(nameof(GetPullRequestLastCommitAdapter),
()=>GetPullRequestLastCommitAdapter(address,owner,name,number));

@StanleyGoldmanStanleyGoldman 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.

Minor nitpick

Comment threadsrc/GitHub.App/Services/PullRequestService.cs

public async Task ClearPullRequestsCache(HostAddress address, string owner, string name)
{
var region = owner + '/' + name + "/pr-list";

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.

With that.

@StanleyGoldmanStanleyGoldman changed the title WIP: GraphQL caching.GraphQL caching.Mar 27, 2019
…hing
# Conflicts:
#	src/GitHub.InlineReviews/Services/PullRequestSessionService.cs
@StanleyGoldman
StanleyGoldman dismissed their stale reviewMarch 27, 2019 11:07

I think it's good, but i'm probably too close now to evaluate

@jcansdalejcansdale left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Looks good to me. Just a question about removing a Visual Studio threading dependency.

Comment threadsrc/GitHub.Api/GraphQLClient.cs Outdated
@StanleyGoldman

Copy link
Copy Markdown
Contributor

While I was prepping to present at the Visual Studio 2019 launch, my head had a thought about how the caching would affect the fork function and what routine is that using...

@StanleyGoldman

Copy link
Copy Markdown
Contributor

GraphQL is not used to fork repos

@jcansdalejcansdale left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Let's do this!

@jcansdale
jcansdale merged commit 794ae47 into masterApr 4, 2019
@jcansdale
jcansdale deleted the feature/graphql-caching branch April 4, 2019 19:09
@StanleyGoldmanStanleyGoldman changed the title GraphQL caching.Caching GraphQL queriesApr 11, 2019
Sign up for freeto subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants

@grokys@StanleyGoldman@jcansdale