Skip to content

Commit b691ada

Browse files
authored
SF-1992 Fail sync if push failed (#3778)
- When pushing a bundle to the PT SendReceive server, we can incorrectly think we gave commits to the server and that they were retained. This leads to additional problems later when pushing again, because the server doesn't have a commit that we are expecting it to have. - This patch checks if the PT SendReceive server has the commit we intended to push, and fails the sync if not. - The outgoing bundle size is also logged for help in investigating problems. - This patch does not fix the situation where pushing a commit to the SR server doesn't work. But it should prevent SF from (1) incorrectly communicating to the user that the sync succeeded, and (2) getting into a state where the SF project is stuck and can not sync without manual intervention. The SF project is left in a state where the sync can at least be re-tried.
1 parent a1eaeca commit b691ada

6 files changed

Lines changed: 222 additions & 22 deletions

File tree

AGENTS.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,7 @@ This repository contains three interconnected applications:
8282
- Do put comments into the code if the intent is not clear from the code.
8383
- All classes and interfaces should have a comment to briefly explain why it is there and what its purpose is in the overall system, even if it seems obvious.
8484
- Please do not fail to add a comment to any classes or interfaces that are created. All classes and interfaces should have a comment.
85+
- Use good argument and variable names that explain themselves without needing a comment. Well named arguments or variables are better than unclearly named arguments or variables with a comment.
8586

8687
# TypeScript language
8788

src/SIL.XForge.Scripture/Services/HgWrapper.cs

Lines changed: 13 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66

77
namespace SIL.XForge.Scripture.Services;
88

9-
/// <summary> A wrapper for the <see cref="Hg" /> class for calling Mercurial. </summary>
9+
/// <summary>A wrapper for the <see cref="Hg" /> class for calling Mercurial.</summary>
1010
public class HgWrapper : IHgWrapper
1111
{
1212
public static string RunCommand(string repository, string cmd)
@@ -16,14 +16,17 @@ public static string RunCommand(string repository, string cmd)
1616
return Hg.Default.RunCommand(repository, cmd).StdOut;
1717
}
1818

19-
public static byte[] Bundle(string repository, params string[] heads)
19+
/// <summary>
20+
/// Returns a Mercurial bundle containing changesets above the given base revisions.
21+
/// </summary>
22+
public byte[] Bundle(string repository, params string[] baseRevisions)
2023
{
2124
if (Hg.Default == null)
2225
throw new InvalidOperationException("Hg default has not been set.");
23-
return Hg.Default.Bundle(repository, heads);
26+
return Hg.Default.Bundle(repository, baseRevisions);
2427
}
2528

26-
public static string[] Pull(string repository, byte[] bundle)
29+
public string[] Pull(string repository, byte[] bundle)
2730
{
2831
if (Hg.Default == null)
2932
throw new InvalidOperationException("Hg default has not been set.");
@@ -72,12 +75,12 @@ public string GetLastPublicRevision(string repository)
7275
/// <summary>
7376
/// Returns the currently checked out revision of an hg repository.
7477
/// </summary>
75-
public string GetRepoRevision(string repositoryPath)
78+
public string GetRepoRevision(string repository)
7679
{
77-
string rev = RunCommand(repositoryPath, "log --limit 1 --rev . --template {node}");
80+
string rev = RunCommand(repository, "log --limit 1 --rev . --template {node}");
7881
if (string.IsNullOrWhiteSpace(rev))
7982
{
80-
throw new InvalidDataException($"Unable to determine repo revision for hg repo at {repositoryPath}");
83+
throw new InvalidDataException($"Unable to determine repo revision for hg repo at {repository}");
8184
}
8285
return rev;
8386
}
@@ -98,9 +101,9 @@ public void RestoreRepository(string destination, string backupFile)
98101
}
99102

100103
/// <summary>
101-
/// Mark all changesets available on the PT server public.
104+
/// Mark all changesets as public.
102105
/// </summary>
103-
/// <param name="repository">The repository.</param>
106+
/// <param name="repository">The repository path.</param>
104107
public void MarkSharedChangeSetsPublic(string repository) => RunCommand(repository, "phase -p -r 'tip'");
105108

106109
/// <summary>
@@ -136,5 +139,5 @@ public void SetDefault(Hg hgDefault)
136139
/// `git checkout --force --detach COMMITTISH`
137140
/// Changes to tracked files will be discarded. Untracked files are left in place without being cleaned up.
138141
/// </summary>
139-
public void Update(string repositoryPath, string rev) => Hg.Default.Update(repositoryPath, rev);
142+
public void Update(string repository, string rev) => Hg.Default.Update(repository, rev);
140143
}

src/SIL.XForge.Scripture/Services/IHgWrapper.cs

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
namespace SIL.XForge.Scripture.Services;
44

5+
/// <summary>Mercurial operations</summary>
56
public interface IHgWrapper
67
{
78
void SetDefault(Hg hgDefault);
@@ -11,8 +12,10 @@ public interface IHgWrapper
1112
void BackupRepository(string repository, string backupFile);
1213
void RestoreRepository(string destination, string backupFile);
1314
string GetLastPublicRevision(string repository);
14-
string GetRepoRevision(string repositoryPath);
15+
string GetRepoRevision(string repository);
1516
void MarkSharedChangeSetsPublic(string repository);
17+
string[] Pull(string repository, byte[] bundle);
18+
byte[] Bundle(string repository, params string[] baseRevisions);
1619
string RecentLogGraph(string repositoryPath);
1720
string[] GetDraftRevisions(string repositoryPath);
1821
}

src/SIL.XForge.Scripture/Services/JwtInternetSharedRepositorySource.cs

Lines changed: 92 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -13,12 +13,15 @@
1313

1414
namespace SIL.XForge.Scripture.Services;
1515

16-
/// <summary> An internet shared repository source that networks using JWT authenticated REST clients. </summary>
16+
/// <summary>
17+
/// An internet shared repository source that networks using JWT authenticated REST clients.
18+
/// </summary>
1719
public class JwtInternetSharedRepositorySource : InternetSharedRepositorySource, IInternetSharedRepositorySource
1820
{
1921
private readonly JwtRestClient _registryClient;
2022
private readonly IHgWrapper _hgWrapper;
2123
private readonly ILogger _logger;
24+
private readonly int _maxJsonLogChars = 200;
2225

2326
public JwtInternetSharedRepositorySource(
2427
string accessToken,
@@ -62,9 +65,9 @@ public bool CanUserAuthenticateToPTArchives()
6265
/// Uses the a REST client to pull from the Paratext send/receive server. This overrides the base implementation
6366
/// to avoid needing the current user's Paratext registration code to get the base revision.
6467
/// </summary>
65-
public override string[] Pull(string repository, SharedRepository pullRepo)
68+
public override string[] Pull(string repositoryPath, SharedRepository pullRepo)
6669
{
67-
string baseRev = _hgWrapper.GetLastPublicRevision(repository);
70+
string baseRev = _hgWrapper.GetLastPublicRevision(repositoryPath);
6871

6972
// Get bundle
7073
string guid = Guid.NewGuid().ToString();
@@ -92,24 +95,33 @@ public override string[] Pull(string repository, SharedRepository pullRepo)
9295
return [];
9396

9497
// Use bundle
95-
string[] changeSets = HgWrapper.Pull(repository, bundle);
98+
string[] changeSets = _hgWrapper.Pull(repositoryPath, bundle);
9699

97-
_hgWrapper.MarkSharedChangeSetsPublic(repository);
100+
_hgWrapper.MarkSharedChangeSetsPublic(repositoryPath);
98101
return changeSets;
99102
}
100103

101104
/// <summary>
102105
/// Uses the a REST client to push to the Paratext send/receive server. This overrides the base implementation
103106
/// to avoid needing the current user's Paratext registration code to get the base revision.
104107
/// </summary>
105-
public override void Push(string repository, SharedRepository pushRepo)
108+
public override void Push(string repositoryPath, SharedRepository pushRepo)
106109
{
107-
string baseRev = _hgWrapper.GetLastPublicRevision(repository);
110+
string baseRev = _hgWrapper.GetLastPublicRevision(repositoryPath);
108111

109112
// Create bundle
110-
byte[] bundle = HgWrapper.Bundle(repository, baseRev);
113+
byte[] bundle = _hgWrapper.Bundle(repositoryPath, baseRev);
111114
if (bundle.Length == 0)
115+
{
116+
_logger.LogInformation($"Not pushing a 0 Byte bundle for project PT ID {pushRepo.SendReceiveId.Id}.");
112117
return;
118+
}
119+
120+
string localTip = _hgWrapper.GetRepoRevision(repositoryPath);
121+
_logger.LogInformation(
122+
$"Pushing bundle of {bundle.Length} Bytes to S/R server for project PT ID "
123+
+ $"{pushRepo.SendReceiveId.Id}. Base revision {baseRev ?? "(null)"}. Local tip {localTip}."
124+
);
113125

114126
// Send bundle
115127
string guid = Guid.NewGuid().ToString();
@@ -128,7 +140,78 @@ public override void Push(string repository, SharedRepository pushRepo)
128140
"no"
129141
);
130142

131-
_hgWrapper.MarkSharedChangeSetsPublic(repository);
143+
(bool isRevOnServer, int serverRevCount, string? serverLastRev) = CheckIfRevisionIsOnServer(pushRepo, localTip);
144+
if (!isRevOnServer)
145+
{
146+
throw new InvalidOperationException(
147+
$"Push verification failed for project PT ID {pushRepo.SendReceiveId.Id}. "
148+
+ $"Expected revision {localTip} was not found in the server's revision history. "
149+
+ $"Server has {serverRevCount} revisions. Last server revision: {serverLastRev ?? "(null)"}."
150+
);
151+
}
152+
153+
_hgWrapper.MarkSharedChangeSetsPublic(repositoryPath);
154+
}
155+
156+
/// <summary>
157+
/// Returns whether the expected revision is present on the Paratext Send/Receive server.
158+
/// </summary>
159+
internal (bool isRevOnServer, int serverRevCount, string? serverLastRev) CheckIfRevisionIsOnServer(
160+
SharedRepository serverRepo,
161+
string expectedRevision
162+
)
163+
{
164+
string projRevHistResponse = GetClient()
165+
.Get("projrevhist", "proj", serverRepo.ScrTextName, "projid", serverRepo.SendReceiveId.Id, "all", "1");
166+
167+
JObject jsonResult = JObject.Parse(projRevHistResponse);
168+
if (jsonResult["project"]?["revision_history"]?["revisions"] is not JArray revisions)
169+
{
170+
string truncatedResult = FormatAndTruncate(jsonResult, _maxJsonLogChars);
171+
_logger.LogWarning(
172+
$"Getting projrevhist unexpectedly received null revisions for PT project ID {serverRepo.SendReceiveId.Id}. The JSON result is: {truncatedResult}"
173+
);
174+
return (false, 0, null);
175+
}
176+
177+
bool isRevOnServer = revisions.Any(r =>
178+
string.Equals(r["id"]?.ToString(), expectedRevision, StringComparison.Ordinal)
179+
);
180+
int serverRevCount = revisions.Count;
181+
string? serverLastRev = GetFirstElementId(revisions);
182+
183+
return (isRevOnServer, serverRevCount, serverLastRev);
184+
}
185+
186+
/// <summary>
187+
/// Returns the first element's "id" value, if possible.
188+
/// </summary>
189+
private static string? GetFirstElementId(JArray revisions)
190+
{
191+
if (revisions.Count > 0)
192+
return revisions[0]["id"]?.ToString();
193+
return null;
194+
}
195+
196+
/// <summary>
197+
/// Formats JSON and truncates if too long.
198+
/// </summary>
199+
private static string FormatAndTruncate(JObject jsonResult, int maxChars)
200+
{
201+
string prettyJson = jsonResult.ToString(Newtonsoft.Json.Formatting.Indented);
202+
return TruncateLogString(prettyJson, maxChars);
203+
}
204+
205+
/// <summary>
206+
/// Truncates a string to the configured character count if needed and appends truncation details.
207+
/// </summary>
208+
private static string TruncateLogString(string value, int maxChars)
209+
{
210+
if (value.Length <= maxChars)
211+
return value;
212+
213+
int truncatedChars = value.Length - maxChars;
214+
return value[..maxChars] + Environment.NewLine + $"... (truncated {truncatedChars} more characters)";
132215
}
133216

134217
/// <summary>

src/SIL.XForge.Scripture/Services/LazyScrTextCollection.cs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,8 @@ public void Initialize(string projectsPath)
3232
}
3333

3434
/// <summary>
35-
/// Get a ScrText for a given user from the data for a paratext project with the target project ID and type.
35+
/// Get a ScrText for a given user from the data for a paratext project with the target project ID and type. Not to
36+
/// be confused with ParatextData ScrTextCollection.FindById, which this is not an override of.
3637
/// </summary>
3738
/// <param name="ptUsername"> The username of the user retrieving the ScrText. </param>
3839
/// <param name="projectId"> The ID of the target project. </param>

0 commit comments

Comments
 (0)