• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

taosdata / TDengine / #4488

12 Jul 2025 07:47AM UTC coverage: 62.207% (-0.7%) from 62.948%
#4488

push

travis-ci

web-flow
docs: update stream docs (#31822)

157961 of 324087 branches covered (48.74%)

Branch coverage included in aggregate %.

244465 of 322830 relevant lines covered (75.73%)

6561668.76 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

53.7
/source/dnode/vnode/src/tsdb/tsdbFile2.c
1
/*
2
 * Copyright (c) 2019 TAOS Data, Inc. <jhtao@taosdata.com>
3
 *
4
 * This program is free software: you can use, redistribute, and/or modify
5
 * it under the terms of the GNU Affero General Public License, version 3
6
 * or later ("AGPL"), as published by the Free Software Foundation.
7
 *
8
 * This program is distributed in the hope that it will be useful, but WITHOUT
9
 * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
10
 * FITNESS FOR A PARTICULAR PURPOSE.
11
 *
12
 * You should have received a copy of the GNU Affero General Public License
13
 * along with this program. If not, see <http://www.gnu.org/licenses/>.
14
 */
15

16
#include "tsdbFile2.h"
17
#include "tcs.h"
18
#include "vnd.h"
19

20
// to_json
21
static int32_t head_to_json(const STFile *file, cJSON *json);
22
static int32_t data_to_json(const STFile *file, cJSON *json);
23
static int32_t sma_to_json(const STFile *file, cJSON *json);
24
static int32_t tomb_to_json(const STFile *file, cJSON *json);
25
static int32_t stt_to_json(const STFile *file, cJSON *json);
26

27
// from_json
28
static int32_t head_from_json(const cJSON *json, STFile *file);
29
static int32_t data_from_json(const cJSON *json, STFile *file);
30
static int32_t sma_from_json(const cJSON *json, STFile *file);
31
static int32_t tomb_from_json(const cJSON *json, STFile *file);
32
static int32_t stt_from_json(const cJSON *json, STFile *file);
33

34
static const struct {
35
  const char *suffix;
36
  int32_t (*to_json)(const STFile *file, cJSON *json);
37
  int32_t (*from_json)(const cJSON *json, STFile *file);
38
} g_tfile_info[] = {
39
    [TSDB_FTYPE_HEAD] = {"head", head_to_json, head_from_json},
40
    [TSDB_FTYPE_DATA] = {"data", data_to_json, data_from_json},
41
    [TSDB_FTYPE_SMA] = {"sma", sma_to_json, sma_from_json},
42
    [TSDB_FTYPE_TOMB] = {"tomb", tomb_to_json, tomb_from_json},
43
    [TSDB_FTYPE_STT] = {"stt", stt_to_json, stt_from_json},
44
};
45

46
void tsdbRemoveFile(const char *fname) {
8,164✔
47
  int32_t code = taosRemoveFile(fname);
8,164✔
48
  if (code) {
8,164!
49
    tsdbError("failed to remove file:%s, code:%d, error:%s", fname, code, tstrerror(code));
×
50
  } else {
51
    tsdbInfo("file:%s is removed", fname);
8,164!
52
  }
53
}
8,164✔
54

55
static int32_t tfile_to_json(const STFile *file, cJSON *json) {
368,288✔
56
  /* did.level */
57
  if (cJSON_AddNumberToObject(json, "did.level", file->did.level) == NULL) {
368,288!
58
    return TSDB_CODE_OUT_OF_MEMORY;
×
59
  }
60

61
  /* did.id */
62
  if (cJSON_AddNumberToObject(json, "did.id", file->did.id) == NULL) {
368,364!
63
    return TSDB_CODE_OUT_OF_MEMORY;
×
64
  }
65

66
  /* lcn - last chunk number */
67
  if (cJSON_AddNumberToObject(json, "lcn", file->lcn) == NULL) {
368,382!
68
    return TSDB_CODE_OUT_OF_MEMORY;
×
69
  }
70

71
  /* fid */
72
  if (cJSON_AddNumberToObject(json, "fid", file->fid) == NULL) {
368,372!
73
    return TSDB_CODE_OUT_OF_MEMORY;
×
74
  }
75

76
  /* cid */
77
  if (cJSON_AddNumberToObject(json, "cid", file->cid) == NULL) {
368,375!
78
    return TSDB_CODE_OUT_OF_MEMORY;
×
79
  }
80

81
  /* size */
82
  if (cJSON_AddNumberToObject(json, "size", file->size) == NULL) {
368,337!
83
    return TSDB_CODE_OUT_OF_MEMORY;
×
84
  }
85

86
  if (file->minVer <= file->maxVer) {
368,336!
87
    /* minVer */
88
    if (cJSON_AddNumberToObject(json, "minVer", file->minVer) == NULL) {
368,338!
89
      return TSDB_CODE_OUT_OF_MEMORY;
×
90
    }
91

92
    /* maxVer */
93
    if (cJSON_AddNumberToObject(json, "maxVer", file->maxVer) == NULL) {
368,387!
94
      return TSDB_CODE_OUT_OF_MEMORY;
×
95
    }
96
  }
97
  return 0;
368,319✔
98
}
99

100
static int32_t tfile_from_json(const cJSON *json, STFile *file) {
2,554✔
101
  const cJSON *item;
102

103
  /* did.level */
104
  item = cJSON_GetObjectItem(json, "did.level");
2,554✔
105
  if (cJSON_IsNumber(item)) {
2,555!
106
    file->did.level = item->valuedouble;
2,556✔
107
  } else {
108
    return TSDB_CODE_FILE_CORRUPTED;
×
109
  }
110

111
  /* did.id */
112
  item = cJSON_GetObjectItem(json, "did.id");
2,556✔
113
  if (cJSON_IsNumber(item)) {
2,556!
114
    file->did.id = item->valuedouble;
2,557✔
115
  } else {
116
    return TSDB_CODE_FILE_CORRUPTED;
×
117
  }
118

119
  /* lcn */
120
  item = cJSON_GetObjectItem(json, "lcn");
2,557✔
121
  if (cJSON_IsNumber(item)) {
2,557!
122
    file->lcn = item->valuedouble;
2,559✔
123
  } else {
124
    // return TSDB_CODE_FILE_CORRUPTED;
125
  }
126

127
  /* fid */
128
  item = cJSON_GetObjectItem(json, "fid");
2,559✔
129
  if (cJSON_IsNumber(item)) {
2,556!
130
    file->fid = item->valuedouble;
2,557✔
131
  } else {
132
    return TSDB_CODE_FILE_CORRUPTED;
×
133
  }
134

135
  /* cid */
136
  item = cJSON_GetObjectItem(json, "cid");
2,557✔
137
  if (cJSON_IsNumber(item)) {
2,558!
138
    file->cid = item->valuedouble;
2,558✔
139
  } else {
140
    return TSDB_CODE_FILE_CORRUPTED;
×
141
  }
142

143
  /* size */
144
  item = cJSON_GetObjectItem(json, "size");
2,558✔
145
  if (cJSON_IsNumber(item)) {
2,555!
146
    file->size = item->valuedouble;
2,556✔
147
  } else {
148
    return TSDB_CODE_FILE_CORRUPTED;
×
149
  }
150

151
  /* minVer */
152
  file->minVer = VERSION_MAX;
2,556✔
153
  item = cJSON_GetObjectItem(json, "minVer");
2,556✔
154
  if (cJSON_IsNumber(item)) {
2,558!
155
    file->minVer = item->valuedouble;
2,559✔
156
  }
157

158
  /* maxVer */
159
  file->maxVer = VERSION_MIN;
2,559✔
160
  item = cJSON_GetObjectItem(json, "maxVer");
2,559✔
161
  if (cJSON_IsNumber(item)) {
2,556!
162
    file->maxVer = item->valuedouble;
2,558✔
163
  }
164
  return 0;
2,558✔
165
}
166

167
static int32_t head_to_json(const STFile *file, cJSON *json) { return tfile_to_json(file, json); }
2,910✔
168
static int32_t data_to_json(const STFile *file, cJSON *json) { return tfile_to_json(file, json); }
2,910✔
169
static int32_t sma_to_json(const STFile *file, cJSON *json) { return tfile_to_json(file, json); }
2,910✔
170
static int32_t tomb_to_json(const STFile *file, cJSON *json) { return tfile_to_json(file, json); }
5,104✔
171
static int32_t stt_to_json(const STFile *file, cJSON *json) {
354,457✔
172
  TAOS_CHECK_RETURN(tfile_to_json(file, json));
354,457!
173

174
  /* lvl */
175
  if (cJSON_AddNumberToObject(json, "level", file->stt->level) == NULL) {
354,487!
176
    return TSDB_CODE_OUT_OF_MEMORY;
×
177
  }
178

179
  return 0;
354,530✔
180
}
181

182
static int32_t head_from_json(const cJSON *json, STFile *file) { return tfile_from_json(json, file); }
161✔
183
static int32_t data_from_json(const cJSON *json, STFile *file) { return tfile_from_json(json, file); }
162✔
184
static int32_t sma_from_json(const cJSON *json, STFile *file) { return tfile_from_json(json, file); }
162✔
185
static int32_t tomb_from_json(const cJSON *json, STFile *file) { return tfile_from_json(json, file); }
11✔
186
static int32_t stt_from_json(const cJSON *json, STFile *file) {
2,057✔
187
  TAOS_CHECK_RETURN(tfile_from_json(json, file));
2,057!
188

189
  const cJSON *item;
190

191
  /* lvl */
192
  item = cJSON_GetObjectItem(json, "level");
2,062✔
193
  if (cJSON_IsNumber(item)) {
2,061!
194
    file->stt->level = item->valuedouble;
2,061✔
195
  } else {
196
    return TSDB_CODE_FILE_CORRUPTED;
×
197
  }
198

199
  return 0;
2,061✔
200
}
201

202
int32_t tsdbTFileToJson(const STFile *file, cJSON *json) {
368,292✔
203
  if (file->type == TSDB_FTYPE_STT) {
368,292✔
204
    return g_tfile_info[file->type].to_json(file, json);
354,458✔
205
  } else {
206
    cJSON *item = cJSON_AddObjectToObject(json, g_tfile_info[file->type].suffix);
13,834✔
207
    if (item == NULL) {
13,834!
208
      return TSDB_CODE_OUT_OF_MEMORY;
×
209
    }
210
    return g_tfile_info[file->type].to_json(file, item);
13,834✔
211
  }
212
}
213

214
int32_t tsdbJsonToTFile(const cJSON *json, tsdb_ftype_t ftype, STFile *f) {
10,015✔
215
  f[0] = (STFile){.type = ftype};
10,015✔
216

217
  if (ftype == TSDB_FTYPE_STT) {
10,015✔
218
    TAOS_CHECK_RETURN(g_tfile_info[ftype].from_json(json, f));
2,057!
219
  } else {
220
    const cJSON *item = cJSON_GetObjectItem(json, g_tfile_info[ftype].suffix);
7,958✔
221
    if (cJSON_IsObject(item)) {
7,989✔
222
      TAOS_CHECK_RETURN(g_tfile_info[ftype].from_json(item, f));
497!
223
    } else {
224
      return TSDB_CODE_NOT_FOUND;
7,493✔
225
    }
226
  }
227

228
  return 0;
2,556✔
229
}
230

231
int32_t tsdbTFileObjInit(STsdb *pTsdb, const STFile *f, STFileObj **fobj) {
751,895✔
232
  fobj[0] = taosMemoryMalloc(sizeof(*fobj[0]));
751,895!
233
  if (!fobj[0]) {
752,131!
234
    return terrno;
×
235
  }
236

237
  (void)taosThreadMutexInit(&fobj[0]->mutex, NULL);
752,131✔
238
  fobj[0]->f[0] = f[0];
752,095✔
239
  fobj[0]->state = TSDB_FSTATE_LIVE;
752,095✔
240
  fobj[0]->ref = 1;
752,095✔
241
  tsdbTFileName(pTsdb, f, fobj[0]->fname);
752,095✔
242
  // fobj[0]->nlevel = tfsGetLevel(pTsdb->pVnode->pTfs);
243
  fobj[0]->nlevel = vnodeNodeId(pTsdb->pVnode);
752,167✔
244
  return 0;
752,147✔
245
}
246

247
int32_t tsdbTFileObjRef(STFileObj *fobj) {
3,353,343✔
248
  int32_t nRef;
249
  (void)taosThreadMutexLock(&fobj->mutex);
3,353,343✔
250

251
  if (fobj->ref <= 0 || fobj->state != TSDB_FSTATE_LIVE) {
3,358,977!
252
    tsdbError("file %s, fobj:%p ref:%d", fobj->fname, fobj, fobj->ref);
×
253
    (void)taosThreadMutexUnlock(&fobj->mutex);
×
254
    return TSDB_CODE_FAILED;
×
255
  }
256

257
  nRef = ++fobj->ref;
3,359,127✔
258
  (void)taosThreadMutexUnlock(&fobj->mutex);
3,359,127✔
259
  tsdbTrace("ref file %s, fobj:%p ref:%d", fobj->fname, fobj, nRef);
3,358,898✔
260
  return 0;
3,359,110✔
261
}
262

263
int32_t tsdbTFileObjUnref(STFileObj *fobj) {
4,103,221✔
264
  (void)taosThreadMutexLock(&fobj->mutex);
4,103,221✔
265
  int32_t nRef = --fobj->ref;
4,106,008✔
266
  (void)taosThreadMutexUnlock(&fobj->mutex);
4,106,008✔
267

268
  if (nRef < 0) {
4,106,092!
269
    tsdbError("file %s, fobj:%p ref:%d", fobj->fname, fobj, nRef);
×
270
    return TSDB_CODE_FAILED;
×
271
  }
272

273
  tsdbTrace("unref file %s, fobj:%p ref:%d", fobj->fname, fobj, nRef);
4,106,092✔
274
  if (nRef == 0) {
4,106,092✔
275
    if (fobj->state == TSDB_FSTATE_DEAD) {
744,065✔
276
      tsdbRemoveFile(fobj->fname);
190✔
277
    }
278
    taosMemoryFree(fobj);
744,065!
279
  }
280

281
  return 0;
4,105,883✔
282
}
283

284
static void tsdbTFileObjRemoveLC(STFileObj *fobj, bool remove_all) {
7,974✔
285
  if (fobj->f->type != TSDB_FTYPE_DATA || fobj->f->lcn < 1) {
7,974!
286
    tsdbRemoveFile(fobj->fname);
7,974✔
287
    return;
7,974✔
288
  }
289
#ifdef USE_S3
290
  if (!remove_all) {
×
291
    // remove local last chunk file
292
    char lc_path[TSDB_FILENAME_LEN];
293
    tstrncpy(lc_path, fobj->fname, TSDB_FQDN_LEN);
×
294

295
    char *dot = strrchr(lc_path, '.');
×
296
    if (!dot) {
×
297
      tsdbError("unexpected path: %s", lc_path);
×
298
      return;
×
299
    }
300
    snprintf(dot + 1, TSDB_FQDN_LEN - (dot + 1 - lc_path), "%d.data", fobj->f->lcn);
×
301

302
    tsdbRemoveFile(lc_path);
×
303

304
  } else {
305
    // delete by data file prefix
306
    char lc_path[TSDB_FILENAME_LEN];
307
    tstrncpy(lc_path, fobj->fname, TSDB_FQDN_LEN);
×
308

309
    char   *object_name = taosDirEntryBaseName(lc_path);
×
310
    int32_t node_id = fobj->nlevel;
×
311
    char    object_name_prefix[TSDB_FILENAME_LEN];
312
    snprintf(object_name_prefix, TSDB_FQDN_LEN, "%d/%s", node_id, object_name);
×
313

314
    char *dot = strrchr(object_name_prefix, '.');
×
315
    if (!dot) {
×
316
      tsdbError("unexpected path: %s", object_name_prefix);
×
317
      return;
×
318
    }
319
    *(dot + 1) = 0;
×
320

321
    tcsDeleteObjectsByPrefix(object_name_prefix);
×
322

323
    // remove local last chunk file
324
    dot = strrchr(lc_path, '.');
×
325
    if (!dot) {
×
326
      tsdbError("unexpected path: %s", lc_path);
×
327
      return;
×
328
    }
329
    snprintf(dot + 1, TSDB_FQDN_LEN - (dot + 1 - lc_path), "%d.data", fobj->f->lcn);
×
330

331
    tsdbRemoveFile(lc_path);
×
332
  }
333
#endif
334
}
335

336
int32_t tsdbTFileObjRemove(STFileObj *fobj) {
8,164✔
337
  (void)taosThreadMutexLock(&fobj->mutex);
8,164✔
338
  if (fobj->state != TSDB_FSTATE_LIVE || fobj->ref <= 0) {
8,164!
339
    tsdbError("file %s, fobj:%p ref:%d", fobj->fname, fobj, fobj->ref);
×
340
    (void)taosThreadMutexUnlock(&fobj->mutex);
×
341
    return TSDB_CODE_FAILED;
×
342
  }
343
  fobj->state = TSDB_FSTATE_DEAD;
8,164✔
344
  int32_t nRef = --fobj->ref;
8,164✔
345
  (void)taosThreadMutexUnlock(&fobj->mutex);
8,164✔
346
  tsdbTrace("remove unref file %s, fobj:%p ref:%d", fobj->fname, fobj, nRef);
8,164✔
347
  if (nRef == 0) {
8,164✔
348
    tsdbTFileObjRemoveLC(fobj, true);
7,974✔
349
    taosMemoryFree(fobj);
7,974!
350
  }
351
  return 0;
8,164✔
352
}
353

354
int32_t tsdbTFileObjRemoveUpdateLC(STFileObj *fobj) {
×
355
  (void)taosThreadMutexLock(&fobj->mutex);
×
356

357
  if (fobj->state != TSDB_FSTATE_LIVE || fobj->ref <= 0) {
×
358
    (void)taosThreadMutexUnlock(&fobj->mutex);
×
359
    tsdbError("file %s, fobj:%p ref:%d", fobj->fname, fobj, fobj->ref);
×
360
    return TSDB_CODE_FAILED;
×
361
  }
362

363
  fobj->state = TSDB_FSTATE_DEAD;
×
364
  int32_t nRef = --fobj->ref;
×
365
  (void)taosThreadMutexUnlock(&fobj->mutex);
×
366
  tsdbTrace("remove unref file %s, fobj:%p ref:%d", fobj->fname, fobj, nRef);
×
367
  if (nRef == 0) {
×
368
    tsdbTFileObjRemoveLC(fobj, false);
×
369
    taosMemoryFree(fobj);
×
370
  }
371
  return 0;
×
372
}
373

374
void tsdbTFileName(STsdb *pTsdb, const STFile *f, char fname[]) {
1,100,461✔
375
  SVnode *pVnode = pTsdb->pVnode;
1,100,461✔
376
  STfs   *pTfs = TSDB_TFS(pTsdb->pVnode);
1,100,461!
377

378
  if (pTfs) {
1,100,461!
379
    if (!pVnode->mounted) {
1,100,527!
380
      snprintf(fname,                              //
1,100,497✔
381
               TSDB_FILENAME_LEN,                  //
382
               "%s%s%s%sv%df%dver%" PRId64 ".%s",  //
383
               tfsGetDiskPath(pTfs, f->did),       //
384
               TD_DIRSEP,                          //
385
               pTsdb->path,                        //
386
               TD_DIRSEP,                          //
387
               TD_VID(pVnode),                     //
388
               f->fid,                             //
1,100,528✔
389
               f->cid,                             //
1,100,528✔
390
               g_tfile_info[f->type].suffix);
1,100,528✔
391
    } else {
392
      snprintf(fname,                                              //
×
393
               TSDB_FILENAME_LEN,                                  //
394
               "%s%svnode%svnode%d%s%s%sv%df%dver%" PRId64 ".%s",  //
395
               tfsGetDiskPath(pTfs, f->did),                       //
396
               TD_DIRSEP,                                          //
397
               TD_DIRSEP,                                          //
398
               TSDB_VID(pVnode),                                   //
×
399
               TD_DIRSEP,                                          //
400
               pTsdb->name,                                        //
×
401
               TD_DIRSEP,                                          //
402
               TSDB_VID(pVnode),                                   //
×
403
               f->fid,                                             //
×
404
               f->cid,                                             //
×
405
               g_tfile_info[f->type].suffix);
×
406
    }
407
  } else {
408
    snprintf(fname,                          //
×
409
             TSDB_FILENAME_LEN,              //
410
             "%s%sv%df%dver%" PRId64 ".%s",  //
411
             pTsdb->path,                    //
412
             TD_DIRSEP,                      //
413
             TD_VID(pVnode),                 //
414
             f->fid,                         //
×
415
             f->cid,                         //
×
416
             g_tfile_info[f->type].suffix);
×
417
  }
418
}
1,100,431✔
419

420
void tsdbTFileLastChunkName(STsdb *pTsdb, const STFile *f, char fname[]) {
×
421
  SVnode *pVnode = pTsdb->pVnode;
×
422
  STfs   *pTfs = TSDB_TFS(pTsdb->pVnode);
×
423

424
  if (pTfs) {
×
425
    if (!pVnode->mounted) {
×
426
      snprintf(fname,                                 //
×
427
               TSDB_FILENAME_LEN,                     //
428
               "%s%s%s%sv%df%dver%" PRId64 ".%d.%s",  //
429
               tfsGetDiskPath(pTfs, f->did),          //
430
               TD_DIRSEP,                             //
431
               pTsdb->path,                           //
432
               TD_DIRSEP,                             //
433
               TD_VID(pVnode),                        //
434
               f->fid,                                //
×
435
               f->cid,                                //
×
436
               f->lcn,                                //
×
437
               g_tfile_info[f->type].suffix);
×
438
    } else {
439
      snprintf(fname,                                                 //
×
440
               TSDB_FILENAME_LEN,                                     //
441
               "%s%svnode%svnode%d%s%s%sv%df%dver%" PRId64 ".%d.%s",  //
442
               tfsGetDiskPath(pTfs, f->did),                          //
443
               TD_DIRSEP,                                             //
444
               TD_DIRSEP,                                             //
445
               TSDB_VID(pVnode),                                      //
×
446
               TD_DIRSEP,                                             //
447
               pTsdb->name,                                           //
×
448
               TD_DIRSEP,                                             //
449
               TSDB_VID(pVnode),                                      //
×
450
               f->fid,                                                //
×
451
               f->cid,                                                //
×
452
               f->lcn,                                                //
×
453
               g_tfile_info[f->type].suffix);
×
454
    }
455
  } else {
456
    snprintf(fname,                             //
×
457
             TSDB_FILENAME_LEN,                 //
458
             "%s%sv%df%dver%" PRId64 ".%d.%s",  //
459
             pTsdb->path,                       //
460
             TD_DIRSEP,                         //
461
             TD_VID(pVnode),                    //
462
             f->fid,                            //
×
463
             f->cid,                            //
×
464
             f->lcn,                            //
×
465
             g_tfile_info[f->type].suffix);
×
466
  }
467
}
×
468

469
bool tsdbIsSameTFile(const STFile *f1, const STFile *f2) {
22,460✔
470
  if (f1->type != f2->type) return false;
22,460!
471
  if (f1->did.level != f2->did.level) return false;
22,460!
472
  if (f1->did.id != f2->did.id) return false;
22,460✔
473
  if (f1->fid != f2->fid) return false;
22,427!
474
  if (f1->cid != f2->cid) return false;
22,427✔
475
  if (f1->lcn != f2->lcn) return false;
21,822!
476
  return true;
21,822✔
477
}
478

479
bool tsdbIsTFileChanged(const STFile *f1, const STFile *f2) {
21,822✔
480
  if (f1->size != f2->size) return true;
21,822✔
481
  // if (f1->type == TSDB_FTYPE_STT && f1->stt->nseg != f2->stt->nseg) return true;
482
  return false;
21,676✔
483
}
484

485
int32_t tsdbTFileObjCmpr(const STFileObj **fobj1, const STFileObj **fobj2) {
10,258✔
486
  if (fobj1[0]->f->cid < fobj2[0]->f->cid) {
10,258✔
487
    return -1;
31✔
488
  } else if (fobj1[0]->f->cid > fobj2[0]->f->cid) {
10,227✔
489
    return 1;
2,701✔
490
  } else {
491
    return 0;
7,526✔
492
  }
493
}
494

495
const char *tsdbFTypeLabel(tsdb_ftype_t ftype) { return g_tfile_info[ftype].suffix; }
349,888✔
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc