1 package com.acumenvelocity.ath.service;
2
3 import java.io.IOException;
4 import java.net.URI;
5 import java.util.ArrayList;
6 import java.util.Date;
7 import java.util.List;
8 import java.util.Map;
9 import java.util.UUID;
10 import java.util.concurrent.ExecutorService;
11 import java.util.concurrent.Executors;
12 import java.util.stream.Collectors;
13
14 import org.apache.solr.client.solrj.SolrClient;
15 import org.apache.solr.client.solrj.SolrQuery;
16 import org.apache.solr.client.solrj.SolrServerException;
17 import org.apache.solr.client.solrj.response.QueryResponse;
18 import org.apache.solr.common.SolrDocument;
19 import org.apache.solr.common.SolrDocumentList;
20 import org.apache.solr.common.SolrInputDocument;
21
22 import com.acumenvelocity.ath.common.Const;
23 import com.acumenvelocity.ath.gcs.AthStorage;
24 import com.acumenvelocity.ath.model.DatasetStats;
25 import com.acumenvelocity.ath.model.ModelInfo;
26 import com.acumenvelocity.ath.model.TrainingFailedResponse.ErrorTypeEnum;
27 import com.acumenvelocity.ath.model.TrainingJobStatus;
28 import com.acumenvelocity.ath.solr.AthIndex;
29 import com.google.api.gax.longrunning.OperationFuture;
30 import com.google.cloud.storage.Blob;
31 import com.google.cloud.storage.Storage;
32 import com.google.cloud.translate.v3.CreateDatasetRequest;
33 import com.google.cloud.translate.v3.CreateModelRequest;
34 import com.google.cloud.translate.v3.Dataset;
35 import com.google.cloud.translate.v3.DatasetInputConfig;
36 import com.google.cloud.translate.v3.GcsInputSource;
37 import com.google.cloud.translate.v3.ImportDataRequest;
38 import com.google.cloud.translate.v3.LocationName;
39 import com.google.cloud.translate.v3.Model;
40 import com.google.cloud.translate.v3.TranslationServiceClient;
41 import com.google.protobuf.Empty;
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86 public class AutoMlTrainingService {
87
88 private static final int MIN_SEGMENTS = 1_000;
89 private static final int MAX_SEGMENTS = 15_000_000;
90
91 private static final List<String> SUPPORTED_EXTENSIONS = List.of(
92 ".tmx", ".tsv", ".csv", ".txt");
93
94 private final ExecutorService executorService;
95 private final Storage storageClient;
96 private final SolrClient solrClient;
97
98
99
100
101
102
103
104 private ModelInfo documentToModelInfo(SolrDocument doc) {
105 ModelInfo info = new ModelInfo();
106
107 String trainingJobId = (String) doc.getFieldValue("trainingJobId");
108 info.trainingJobId(trainingJobId != null ? UUID.fromString(trainingJobId) : null);
109
110 info.modelId((String) doc.getFieldValue("modelId"));
111 info.modelName((String) doc.getFieldValue("modelName"));
112 info.datasetId((String) doc.getFieldValue("datasetId"));
113 info.srcLang((String) doc.getFieldValue("srcLang"));
114 info.trgLang((String) doc.getFieldValue("trgLang"));
115 info.createdAt((Date) doc.getFieldValue("completedAt"));
116
117
118 Integer filesProcessed = (Integer) doc.getFieldValue("filesProcessed");
119 Long totalSegments = (Long) doc.getFieldValue("totalSegments");
120
121 if (filesProcessed != null && totalSegments != null) {
122 DatasetStats stats = new DatasetStats()
123 .filesProcessed(filesProcessed)
124 .totalSegments(totalSegments)
125 .trainSegments((Long) doc.getFieldValue("trainSegments"))
126 .validationSegments((Long) doc.getFieldValue("validationSegments"))
127 .testSegments((Long) doc.getFieldValue("testSegments"));
128
129 info.datasetStats(stats);
130 }
131
132 return info;
133 }
134
135
136
137
138 private static class TrainingJobContext {
139 final UUID trainingJobId;
140 final String modelName;
141 final String srcLang;
142 final String trgLang;
143 final List<URI> gcsBuckets;
144 final String projectId;
145 final String location;
146
147
148 TrainingJobContext(UUID trainingJobId, String modelName, String srcLang, String trgLang,
149 List<URI> gcsBuckets, String projectId, String location, UUID userId) {
150 this.trainingJobId = trainingJobId;
151 this.modelName = modelName;
152 this.srcLang = srcLang;
153 this.trgLang = trgLang;
154 this.gcsBuckets = gcsBuckets;
155 this.projectId = projectId;
156 this.location = location;
157
158 }
159 }
160
161
162
163
164
165 public AutoMlTrainingService() {
166 this.executorService = Executors.newFixedThreadPool(5);
167 this.storageClient = AthStorage.getGcs();
168 this.solrClient = AthIndex.getSolr().getClient();
169 }
170
171
172
173
174
175
176
177 public AutoMlTrainingService(SolrClient solrClient, Storage storageClient) {
178 this.executorService = Executors.newFixedThreadPool(5);
179 this.storageClient = storageClient;
180 this.solrClient = solrClient;
181 }
182
183
184
185
186
187
188
189 public boolean trainingJobExists(UUID trainingJobId) {
190 try {
191 SolrQuery query = new SolrQuery("trainingJobId:" + trainingJobId.toString());
192 QueryResponse response = solrClient.query(Const.SOLR_CORE_ATH_AUTOML_JOBS, query);
193 return response.getResults().getNumFound() > 0;
194
195 } catch (Exception e) {
196 throw new RuntimeException("Error checking if training job exists", e);
197 }
198 }
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226 public void initiateTraining(
227 UUID trainingJobId,
228 String modelName,
229 String srcLang,
230 String trgLang,
231 List<URI> gcsBuckets,
232 String projectId,
233 String location,
234 UUID userId) {
235
236 try {
237
238 SolrInputDocument doc = new SolrInputDocument();
239 doc.addField("trainingJobId", trainingJobId.toString());
240 doc.addField("modelName", modelName);
241 doc.addField("srcLang", srcLang);
242 doc.addField("trgLang", trgLang);
243 doc.addField("projectId", projectId);
244 doc.addField("location", location);
245 doc.addField("createdBy", userId.toString());
246 doc.addField("createdAt", new Date());
247 doc.addField("startedAt", new Date());
248 doc.addField("status", TrainingJobStatus.SCANNING_BUCKETS.name());
249 doc.addField("progress", 0);
250 doc.addField("currentPhase", "Scanning GCS buckets for training files");
251
252
253 for (URI bucket : gcsBuckets) {
254 doc.addField("gcsBuckets", bucket.toString());
255 }
256
257 solrClient.add(Const.SOLR_CORE_ATH_AUTOML_JOBS, doc);
258 solrClient.commit(Const.SOLR_CORE_ATH_AUTOML_JOBS);
259
260
261 TrainingJobContext context = new TrainingJobContext(
262 trainingJobId, modelName, srcLang, trgLang, gcsBuckets, projectId, location, userId);
263
264 executorService.submit(() -> executeTraining(context));
265
266 } catch (Exception e) {
267 throw new RuntimeException("Error initiating training job", e);
268 }
269 }
270
271
272
273
274
275
276
277 public TrainingJobStatus getTrainingStatus(UUID trainingJobId) {
278 try {
279 SolrDocument doc = getJobDocument(trainingJobId);
280 if (doc == null) {
281 return null;
282 }
283
284 String statusStr = (String) doc.getFieldValue("status");
285 return statusStr != null ? TrainingJobStatus.valueOf(statusStr) : null;
286
287 } catch (Exception e) {
288 throw new RuntimeException("Error getting training status", e);
289 }
290 }
291
292
293
294
295
296
297
298 public int getProgress(UUID trainingJobId) {
299 try {
300 SolrDocument doc = getJobDocument(trainingJobId);
301 Integer progress = (Integer) doc.getFieldValue("progress");
302 return progress != null ? progress : 0;
303
304 } catch (Exception e) {
305 return 0;
306 }
307 }
308
309
310
311
312
313
314
315 public Date getStartedAt(UUID trainingJobId) {
316 try {
317 SolrDocument doc = getJobDocument(trainingJobId);
318 return (Date) doc.getFieldValue("startedAt");
319
320 } catch (Exception e) {
321 return null;
322 }
323 }
324
325
326
327
328
329
330
331 public String getCurrentPhase(UUID trainingJobId) {
332 try {
333 SolrDocument doc = getJobDocument(trainingJobId);
334 return (String) doc.getFieldValue("currentPhase");
335
336 } catch (Exception e) {
337 return null;
338 }
339 }
340
341
342
343
344
345
346
347 public String getModelId(UUID trainingJobId) {
348 try {
349 SolrDocument doc = getJobDocument(trainingJobId);
350 return (String) doc.getFieldValue("modelId");
351
352 } catch (Exception e) {
353 return null;
354 }
355 }
356
357
358
359
360
361
362
363 public String getModelName(UUID trainingJobId) {
364 try {
365 SolrDocument doc = getJobDocument(trainingJobId);
366 return (String) doc.getFieldValue("modelName");
367
368 } catch (Exception e) {
369 return null;
370 }
371 }
372
373
374
375
376
377
378
379 public String getDatasetId(UUID trainingJobId) {
380 try {
381 SolrDocument doc = getJobDocument(trainingJobId);
382 return (String) doc.getFieldValue("datasetId");
383
384 } catch (Exception e) {
385 return null;
386 }
387 }
388
389
390
391
392
393
394
395 public Date getCompletedAt(UUID trainingJobId) {
396 try {
397 SolrDocument doc = getJobDocument(trainingJobId);
398 return (Date) doc.getFieldValue("completedAt");
399
400 } catch (Exception e) {
401 return null;
402 }
403 }
404
405
406
407
408
409
410
411 public DatasetStats getDatasetStats(UUID trainingJobId) {
412 try {
413 SolrDocument doc = getJobDocument(trainingJobId);
414 if (doc == null) {
415 return null;
416 }
417
418 Integer filesProcessed = (Integer) doc.getFieldValue("filesProcessed");
419 Long totalSegments = (Long) doc.getFieldValue("totalSegments");
420 Long trainSegments = (Long) doc.getFieldValue("trainSegments");
421 Long validationSegments = (Long) doc.getFieldValue("validationSegments");
422 Long testSegments = (Long) doc.getFieldValue("testSegments");
423
424 if (filesProcessed == null || totalSegments == null) {
425 return null;
426 }
427
428 return new DatasetStats()
429 .filesProcessed(filesProcessed)
430 .totalSegments(totalSegments)
431 .trainSegments(trainSegments)
432 .validationSegments(validationSegments)
433 .testSegments(testSegments);
434
435 } catch (Exception e) {
436 return null;
437 }
438 }
439
440
441
442
443
444
445
446 public ErrorTypeEnum getErrorType(UUID trainingJobId) {
447 try {
448 SolrDocument doc = getJobDocument(trainingJobId);
449 String errorType = (String) doc.getFieldValue("errorType");
450 return errorType != null ? ErrorTypeEnum.valueOf(errorType) : null;
451
452 } catch (Exception e) {
453 return null;
454 }
455 }
456
457
458
459
460
461
462
463 public String getErrorMessage(UUID trainingJobId) {
464 try {
465 SolrDocument doc = getJobDocument(trainingJobId);
466 return (String) doc.getFieldValue("errorMessage");
467
468 } catch (Exception e) {
469 return null;
470 }
471 }
472
473
474
475
476
477
478
479 public Date getFailedAt(UUID trainingJobId) {
480 try {
481 SolrDocument doc = getJobDocument(trainingJobId);
482 return (Date) doc.getFieldValue("failedAt");
483
484 } catch (Exception e) {
485 return null;
486 }
487 }
488
489
490
491
492
493
494
495 public UUID getJobOwnerId(UUID trainingJobId) {
496 try {
497 SolrDocument doc = getJobDocument(trainingJobId);
498 String userId = (String) doc.getFieldValue("createdBy");
499 return userId != null ? UUID.fromString(userId) : null;
500
501 } catch (Exception e) {
502 return null;
503 }
504 }
505
506
507
508
509
510
511 public void cancelTraining(UUID trainingJobId) {
512 try {
513 SolrInputDocument doc = new SolrInputDocument();
514 doc.addField("trainingJobId", trainingJobId.toString());
515
516
517 doc.addField("status", Map.of("set", TrainingJobStatus.CANCELLED.name()));
518
519 solrClient.add(Const.SOLR_CORE_ATH_AUTOML_JOBS, doc);
520 solrClient.commit(Const.SOLR_CORE_ATH_AUTOML_JOBS);
521
522
523
524
525 } catch (Exception e) {
526 throw new RuntimeException("Error cancelling training job", e);
527 }
528 }
529
530
531
532
533
534
535 public List<ModelInfo> getAllModels() {
536 try {
537 SolrQuery query = new SolrQuery("status:" + TrainingJobStatus.COMPLETED.name());
538 query.setSort("completedAt", SolrQuery.ORDER.desc);
539 query.setRows(1000);
540
541 QueryResponse response = solrClient.query(Const.SOLR_CORE_ATH_AUTOML_JOBS, query);
542 SolrDocumentList docs = response.getResults();
543
544 return docs.stream()
545 .map(this::documentToModelInfo)
546 .collect(Collectors.toList());
547
548 } catch (Exception e) {
549 throw new RuntimeException("Error fetching models", e);
550 }
551 }
552
553
554
555
556
557
558
559 private void executeTraining(TrainingJobContext context) {
560 try {
561
562 updateJobStatus(context.trainingJobId, TrainingJobStatus.SCANNING_BUCKETS, 10,
563 "Scanning GCS buckets for training files");
564
565 List<String> trainingFiles = scanGcsBuckets(context.gcsBuckets);
566
567 if (trainingFiles.isEmpty()) {
568 failJob(context.trainingJobId, ErrorTypeEnum.INVALID_FILES,
569 "No valid training files found in specified buckets");
570 return;
571 }
572
573
574 int estimatedSegments = trainingFiles.size() * 1000;
575
576 if (estimatedSegments < MIN_SEGMENTS) {
577 failJob(context.trainingJobId, ErrorTypeEnum.INSUFFICIENT_DATA,
578 "Dataset contains approximately " + estimatedSegments +
579 " segments. Minimum required: " + MIN_SEGMENTS);
580 return;
581 }
582
583 if (estimatedSegments > MAX_SEGMENTS) {
584 failJob(context.trainingJobId, ErrorTypeEnum.EXCESSIVE_DATA,
585 "Dataset contains approximately " + estimatedSegments +
586 " segments. Maximum allowed: " + MAX_SEGMENTS);
587 return;
588 }
589
590
591 if (isCancelled(context.trainingJobId)) {
592 return;
593 }
594
595
596 updateJobStatus(context.trainingJobId, TrainingJobStatus.PREPARING_DATASET, 25,
597 "Creating AutoML dataset");
598
599 String datasetId = createAutoMLDataset(context);
600 updateDatasetId(context.trainingJobId, datasetId);
601
602 if (isCancelled(context.trainingJobId)) {
603 return;
604 }
605
606
607 updateJobStatus(context.trainingJobId, TrainingJobStatus.IMPORTING_DATA, 40,
608 "Importing " + trainingFiles.size() + " files to dataset");
609
610 importDataToDataset(context, datasetId, trainingFiles);
611
612 if (isCancelled(context.trainingJobId)) {
613 return;
614 }
615
616
617 updateJobStatus(context.trainingJobId, TrainingJobStatus.TRAINING, 60,
618 "Training AutoML model");
619
620 String modelId = trainAutoMLModel(context, datasetId);
621
622
623 completeJob(context.trainingJobId, modelId, trainingFiles.size(), estimatedSegments);
624
625 } catch (InterruptedException e) {
626
627 Thread.currentThread().interrupt();
628
629 } catch (Exception e) {
630 failJob(context.trainingJobId, ErrorTypeEnum.TRAINING_ERROR,
631 "AutoML training failed: " + e.getMessage());
632 }
633 }
634
635
636
637
638
639
640
641 private List<String> scanGcsBuckets(List<URI> gcsBuckets) {
642 List<String> trainingFiles = new ArrayList<>();
643
644 for (URI bucketUri : gcsBuckets) {
645 String[] parts = bucketUri.toString().replace("gs://", "").split("/", 2);
646 String bucketName = parts[0];
647 String prefix = parts.length > 1 ? parts[1] : "";
648
649 for (Blob blob : storageClient.list(bucketName,
650 Storage.BlobListOption.prefix(prefix)).iterateAll()) {
651
652 String fileName = blob.getName().toLowerCase();
653
654 if (SUPPORTED_EXTENSIONS.stream().anyMatch(fileName::endsWith)) {
655 trainingFiles.add("gs://" + bucketName + "/" + blob.getName());
656 }
657 }
658 }
659
660 return trainingFiles;
661 }
662
663
664
665
666
667
668
669
670 private String createAutoMLDataset(TrainingJobContext context) throws Exception {
671 try (TranslationServiceClient client = TranslationServiceClient.create()) {
672 LocationName parent = LocationName.of(context.projectId, context.location);
673
674 Dataset dataset = Dataset.newBuilder()
675 .setDisplayName(context.modelName + "-dataset")
676 .setSourceLanguageCode(context.srcLang)
677 .setTargetLanguageCode(context.trgLang)
678 .build();
679
680 CreateDatasetRequest request = CreateDatasetRequest.newBuilder()
681 .setParent(parent.toString())
682 .setDataset(dataset)
683 .build();
684
685 OperationFuture<Dataset, ?> operationFuture = client.createDatasetAsync(request);
686 Dataset createdDataset = operationFuture.get();
687
688 return createdDataset.getName();
689 }
690 }
691
692
693
694
695
696
697
698
699
700 private void importDataToDataset(TrainingJobContext context, String datasetId,
701 List<String> trainingFiles) throws Exception {
702
703 try (TranslationServiceClient client = TranslationServiceClient.create()) {
704 DatasetInputConfig.Builder inputConfigBuilder = DatasetInputConfig.newBuilder();
705
706 for (String fileUri : trainingFiles) {
707 GcsInputSource gcsSource = GcsInputSource.newBuilder()
708 .setInputUri(fileUri)
709 .build();
710
711 inputConfigBuilder.addInputFiles(
712 DatasetInputConfig.InputFile.newBuilder()
713 .setGcsSource(gcsSource)
714 .build());
715 }
716
717 ImportDataRequest request = ImportDataRequest.newBuilder()
718 .setDataset(datasetId)
719 .setInputConfig(inputConfigBuilder.build())
720 .build();
721
722 OperationFuture<Empty, ?> operationFuture = client.importDataAsync(request);
723 operationFuture.get();
724 }
725 }
726
727
728
729
730
731
732
733
734
735 private String trainAutoMLModel(TrainingJobContext context, String datasetId) throws Exception {
736 try (TranslationServiceClient client = TranslationServiceClient.create()) {
737 LocationName parent = LocationName.of(context.projectId, context.location);
738
739 Model model = Model.newBuilder()
740 .setDisplayName(context.modelName)
741 .setDataset(datasetId)
742 .build();
743
744 CreateModelRequest request = CreateModelRequest.newBuilder()
745 .setParent(parent.toString())
746 .setModel(model)
747 .build();
748
749 OperationFuture<Model, ?> operationFuture = client.createModelAsync(request);
750
751
752 while (!operationFuture.isDone()) {
753 Thread.sleep(60_000);
754
755 if (isCancelled(context.trainingJobId)) {
756 operationFuture.cancel(true);
757 throw new InterruptedException("Training cancelled by user");
758 }
759
760
761 int currentProgress = getProgress(context.trainingJobId);
762 updateProgress(context.trainingJobId, Math.min(95, currentProgress + 2));
763 }
764
765 Model createdModel = operationFuture.get();
766 return createdModel.getName();
767 }
768 }
769
770
771
772
773
774
775
776 private boolean isCancelled(UUID trainingJobId) {
777 TrainingJobStatus status = getTrainingStatus(trainingJobId);
778 return TrainingJobStatus.CANCELLED.equals(status);
779 }
780
781
782
783
784
785
786
787
788
789 private void updateJobStatus(UUID trainingJobId, TrainingJobStatus status, int progress,
790 String phase) {
791 try {
792 SolrInputDocument doc = new SolrInputDocument();
793 doc.addField("trainingJobId", trainingJobId.toString());
794 doc.addField("status", Map.of("set", status.name()));
795 doc.addField("progress", Map.of("set", progress));
796 doc.addField("currentPhase", Map.of("set", phase));
797
798 solrClient.add(Const.SOLR_CORE_ATH_AUTOML_JOBS, doc);
799 solrClient.commit(Const.SOLR_CORE_ATH_AUTOML_JOBS);
800
801 } catch (Exception e) {
802 throw new RuntimeException("Error updating job status", e);
803 }
804 }
805
806
807
808
809
810
811
812 private void updateProgress(UUID trainingJobId, int progress) {
813 try {
814 SolrInputDocument doc = new SolrInputDocument();
815 doc.addField("trainingJobId", trainingJobId.toString());
816 doc.addField("progress", Map.of("set", progress));
817
818 solrClient.add(Const.SOLR_CORE_ATH_AUTOML_JOBS, doc);
819 solrClient.commit(Const.SOLR_CORE_ATH_AUTOML_JOBS);
820
821 } catch (Exception e) {
822
823 System.err.println("Error updating progress: " + e.getMessage());
824 }
825 }
826
827
828
829
830
831
832
833 private void updateDatasetId(UUID trainingJobId, String datasetId) {
834 try {
835 SolrInputDocument doc = new SolrInputDocument();
836 doc.addField("trainingJobId", trainingJobId.toString());
837 doc.addField("datasetId", Map.of("set", datasetId));
838
839 solrClient.add(Const.SOLR_CORE_ATH_AUTOML_JOBS, doc);
840 solrClient.commit(Const.SOLR_CORE_ATH_AUTOML_JOBS);
841
842 } catch (Exception e) {
843 throw new RuntimeException("Error updating dataset ID", e);
844 }
845 }
846
847
848
849
850
851
852
853
854
855 private void completeJob(UUID trainingJobId, String modelId, int filesProcessed,
856 int totalSegments) {
857 try {
858 SolrInputDocument doc = new SolrInputDocument();
859 doc.addField("trainingJobId", trainingJobId.toString());
860 doc.addField("status", Map.of("set", TrainingJobStatus.COMPLETED.name()));
861 doc.addField("progress", Map.of("set", 100));
862 doc.addField("modelId", Map.of("set", modelId));
863 doc.addField("completedAt", Map.of("set", new Date()));
864 doc.addField("filesProcessed", Map.of("set", filesProcessed));
865 doc.addField("totalSegments", Map.of("set", (long) totalSegments));
866 doc.addField("trainSegments", Map.of("set", (long) (totalSegments * 0.8)));
867 doc.addField("validationSegments", Map.of("set", (long) (totalSegments * 0.1)));
868 doc.addField("testSegments", Map.of("set", (long) (totalSegments * 0.1)));
869
870 solrClient.add(Const.SOLR_CORE_ATH_AUTOML_JOBS, doc);
871 solrClient.commit(Const.SOLR_CORE_ATH_AUTOML_JOBS);
872
873 } catch (Exception e) {
874 throw new RuntimeException("Error completing job", e);
875 }
876 }
877
878
879
880
881
882
883
884
885 private void failJob(UUID trainingJobId, ErrorTypeEnum errorType, String errorMessage) {
886 try {
887 SolrInputDocument doc = new SolrInputDocument();
888 doc.addField("trainingJobId", trainingJobId.toString());
889 doc.addField("status", Map.of("set", TrainingJobStatus.FAILED.name()));
890 doc.addField("errorType", Map.of("set", errorType.name()));
891 doc.addField("errorMessage", Map.of("set", errorMessage));
892 doc.addField("failedAt", Map.of("set", new Date()));
893
894 solrClient.add(Const.SOLR_CORE_ATH_AUTOML_JOBS, doc);
895 solrClient.commit(Const.SOLR_CORE_ATH_AUTOML_JOBS);
896
897 } catch (Exception e) {
898 throw new RuntimeException("Error failing job", e);
899 }
900 }
901
902
903
904
905
906
907
908 private SolrDocument getJobDocument(UUID trainingJobId) throws SolrServerException, IOException {
909 SolrQuery query = new SolrQuery("trainingJobId:" + trainingJobId.toString());
910 QueryResponse response = solrClient.query(Const.SOLR_CORE_ATH_AUTOML_JOBS, query);
911 SolrDocumentList docs = response.getResults();
912
913 return docs.isEmpty() ? null : docs.get(0);
914 }
915
916 }