source: iknow-api/Services/Implementations/EnrollmentService.cs

Last change on this file was 002cf5f, checked in by Boris Gjorgjievski <boris@…>, 7 days ago

Phase 8: connection pooling and concurrent-enrolment handling

Two enrolments for the same semester sent at once both pass the
"already enrolled" check, because each transaction reads the state from
before the other one wrote. UNIQUE (user_id, semester_id) is what
settles it, so EnrollAsync now catches the 23505 and returns the same
sentence instead of a 500.

Sizes the connection pool in the connection string (max 10, min 1)
rather than taking Npgsql's default of 100 - every physical connection
is one more channel through the SSH tunnel - and switches to
AddDbContextPool, which AppDbContext qualifies for since its only
constructor takes DbContextOptions.

docs/Ph8.md is the wiki page: the three transactional scenarios, the
isolation level and what it does not cover, and the pool settings with
the measured connection counts.

  • Property mode set to 100644
File size: 10.0 KB
Line 
1using iknow_api.Data;
2using iknow_api.DTOs;
3using iknow_api.Models;
4using Microsoft.EntityFrameworkCore;
5using Npgsql;
6
7namespace iknow_api.Services
8{
9 public class EnrollmentService : IEnrollmentService
10 {
11 /// <summary>An enrolment is exactly this many subjects.</summary>
12 public const int RequiredSubjects = 5;
13
14 private readonly AppDbContext _context;
15
16 public EnrollmentService(AppDbContext context)
17 {
18 _context = context;
19 }
20
21 public async Task<EnrollmentOptionsDto> GetOptionsAsync(int studentId)
22 {
23 var enrolments = await _context.EnrolledSemesters
24 .AsNoTracking()
25 .Include(es => es.Semester)
26 .Where(es => es.UserId == studentId)
27 .ToListAsync();
28
29 var takenSemesterIds = enrolments.Select(es => es.SemesterId).ToHashSet();
30
31 var available = await _context.ActiveSemesters
32 .AsNoTracking()
33 .Where(a => !takenSemesterIds.Contains(a.Id))
34 .ToListAsync();
35
36 // Semesters are labelled Year/Year+1, so within a year winter comes first.
37 var semesters = available
38 .OrderBy(a => a.Year)
39 .ThenBy(a => a.Type == sType.summer)
40 .Select(a => new SemesterOptionDto
41 {
42 Id = a.Id,
43 Year = a.Year,
44 Type = a.Type.ToString(),
45 Name = $"{(a.Type == sType.winter ? "Зимски" : "Летен")}({a.Year}/{a.Year + 1})"
46 })
47 .ToList();
48
49 var passedSubjectIds = await _context.PassedSubjects
50 .AsNoTracking()
51 .Where(ps => ps.SemesterSubject!.EnrolledSemester!.UserId == studentId)
52 .Select(ps => ps.SemesterSubject!.SubjectId)
53 .ToListAsync();
54 var passed = passedSubjectIds.ToHashSet();
55
56 var majorRows = await _context.MajorSubjects
57 .AsNoTracking()
58 .Include(ms => ms.Majors)
59 .Include(ms => ms.Subjects)
60 .ToListAsync();
61
62 var majors = majorRows
63 .Where(ms => ms.Majors != null && ms.Subjects != null)
64 .GroupBy(ms => ms.Majors!.Id)
65 .Select(g => new MajorOptionDto
66 {
67 Id = g.Key,
68 Name = g.First().Majors!.Name,
69 Subjects = g
70 .Select(ms => new SubjectOptionDto
71 {
72 Id = ms.Subjects!.Id,
73 Name = ms.Subjects.Name,
74 Code = ms.Subjects.Code,
75 Credits = ms.Subjects.AwardedCredits ?? 0,
76 MandatorySemester = ms.MandatorySemester,
77 AlreadyPassed = passed.Contains(ms.Subjects.Id)
78 })
79 .OrderBy(x => x.MandatorySemester)
80 .ThenBy(x => x.Name)
81 .ToList()
82 })
83 .OrderBy(m => m.Name)
84 .ToList();
85
86 var latest = enrolments
87 .Where(es => es.Semester != null)
88 .OrderByDescending(es => es.Semester!.Year)
89 .ThenByDescending(es => es.Semester!.Type == sType.summer)
90 .FirstOrDefault();
91
92 return new EnrollmentOptionsDto
93 {
94 RequiredSubjects = RequiredSubjects,
95 Semesters = semesters,
96 Majors = majors,
97 DefaultMajorId = latest?.MajorId
98 };
99 }
100
101 public async Task<EnrollSemesterResultDto> EnrollAsync(
102 int studentId, EnrollSemesterRequestDto request)
103 {
104 var subjectIds = request.SubjectIds?.Distinct().ToList() ?? new List<int>();
105
106 if (subjectIds.Count != RequiredSubjects)
107 {
108 return Fail($"Pick exactly {RequiredSubjects} different subjects; got {subjectIds.Count}.");
109 }
110
111 if (!await _context.ActiveSemesters.AnyAsync(a => a.Id == request.SemesterId))
112 {
113 return Fail("That semester does not exist.");
114 }
115
116 // enrolled_semesters has UNIQUE (user_id, semester_id); check first so
117 // the student gets a sentence rather than a constraint violation.
118 if (await _context.EnrolledSemesters.AnyAsync(
119 es => es.UserId == studentId && es.SemesterId == request.SemesterId))
120 {
121 return Fail("You are already enrolled in that semester.");
122 }
123
124 if (!await _context.Majors.AnyAsync(m => m.Id == request.MajorId))
125 {
126 return Fail("That study programme does not exist.");
127 }
128
129 var offered = await _context.MajorSubjects
130 .Where(ms => ms.MajorId == request.MajorId)
131 .Select(ms => ms.SubjectId)
132 .ToListAsync();
133
134 var notOffered = subjectIds.Except(offered).ToList();
135 if (notOffered.Count > 0)
136 {
137 return Fail($"Subject(s) {string.Join(", ", notOffered)} are not offered by that study programme.");
138 }
139
140 // semesters_subjects.professor_id is NOT NULL, so every chosen subject
141 // needs somebody teaching it that semester.
142 var teachers = await _context.ProfessorSubjects
143 .Where(ps => ps.SemesterId == request.SemesterId && subjectIds.Contains(ps.SubjectId))
144 .ToListAsync();
145
146 // A subject can be taught by more than one professor in the same
147 // semester. Spread students over them instead of always taking the
148 // lowest id, which would leave the other professor with nobody.
149 var currentLoad = await _context.SemesterSubjects
150 .Where(ss => subjectIds.Contains(ss.SubjectId)
151 && ss.EnrolledSemester!.SemesterId == request.SemesterId)
152 .GroupBy(ss => new { ss.SubjectId, ss.ProfessorId })
153 .Select(g => new { g.Key.SubjectId, g.Key.ProfessorId, Count = g.Count() })
154 .ToListAsync();
155
156 var loadLookup = currentLoad.ToDictionary(
157 x => (x.SubjectId, x.ProfessorId), x => x.Count);
158
159 var professorBySubject = teachers
160 .GroupBy(ps => ps.SubjectId)
161 .ToDictionary(
162 g => g.Key,
163 // Fewest students first; professor id only breaks ties, so
164 // the choice stays deterministic and testable.
165 g => g.OrderBy(ps => loadLookup.TryGetValue((ps.SubjectId, ps.ProfessorId), out var n) ? n : 0)
166 .ThenBy(ps => ps.ProfessorId)
167 .First().ProfessorId);
168
169 var untaught = subjectIds.Where(id => !professorBySubject.ContainsKey(id)).ToList();
170 if (untaught.Count > 0)
171 {
172 return Fail($"Subject(s) {string.Join(", ", untaught)} are not taught in that semester.");
173 }
174
175 var student = await _context.User.FirstOrDefaultAsync(u => u.Id == studentId);
176 if (student is null)
177 {
178 return Fail("Student not found.");
179 }
180
181 var now = DateTime.SpecifyKind(DateTime.UtcNow, DateTimeKind.Unspecified);
182
183 await using var transaction = await _context.Database.BeginTransactionAsync();
184 try
185 {
186 var enrolment = new EnrolledSemesters
187 {
188 UserId = studentId,
189 SemesterId = request.SemesterId,
190 MajorId = request.MajorId,
191 // enrolled_semesters.quota is NOT NULL; fall back to the
192 // state quota when the student record has none.
193 QuotaType = student.Quota ?? Models.Quota.drzavna,
194 CratedAt = now,
195 LastChange = now,
196 Verified = null
197 };
198
199 _context.EnrolledSemesters.Add(enrolment);
200 await _context.SaveChangesAsync();
201
202 foreach (var subjectId in subjectIds)
203 {
204 _context.SemesterSubjects.Add(new SemesterSubject
205 {
206 EnrolledSemesterId = enrolment.Id,
207 SubjectId = subjectId,
208 ProfessorId = professorBySubject[subjectId],
209 Signature = false
210 });
211 }
212
213 await _context.SaveChangesAsync();
214 await transaction.CommitAsync();
215
216 return new EnrollSemesterResultDto
217 {
218 Ok = true,
219 Message = $"Enrolled with {subjectIds.Count} subjects.",
220 EnrolledSemesterId = enrolment.Id
221 };
222 }
223 catch (DbUpdateException ex) when (IsUniqueViolation(ex))
224 {
225 // Two enrolments for the same semester sent at the same time both
226 // pass the check above, because each transaction reads the state
227 // from before the other one wrote. UNIQUE (user_id, semester_id)
228 // is what actually settles it, so the loser gets the same
229 // sentence as if the check had caught it.
230 await transaction.RollbackAsync();
231 return Fail("You are already enrolled in that semester.");
232 }
233 catch
234 {
235 await transaction.RollbackAsync();
236 throw;
237 }
238 }
239
240 /// <summary>23505 is unique_violation.</summary>
241 private static bool IsUniqueViolation(DbUpdateException ex) =>
242 ex.InnerException is PostgresException { SqlState: "23505" };
243
244 private static EnrollSemesterResultDto Fail(string message) =>
245 new() { Ok = false, Message = message };
246 }
247}
Note: See TracBrowser for help on using the repository browser.